The demo below runs MCTS on a tiny game: two players alternately pick numbers from 1 to 5 (without repeats) and the first to hold three numbers that sum to 15 wins (this is equivalent to tic-tac-toe on a magic square). You play as Player 1 (blue); MCTS plays as Player 2 (red).
<div id="app">
<div id="info-bar">
<span id="turn-label">Your turn (Player 1 โ blue)</span>
</div>
<div id="number-row">
<button class="num-btn" data-n="1">1</button>
<button class="num-btn" data-n="2">2</button>
<button class="num-btn" data-n="3">3</button>
<button class="num-btn" data-n="4">4</button>
<button class="num-btn" data-n="5">5</button>
<button class="num-btn" data-n="6">6</button>
<button class="num-btn" data-n="7">7</button>
<button class="num-btn" data-n="8">8</button>
<button class="num-btn" data-n="9">9</button>
</div>
<div id="chosen">
<div id="p1-chosen"><strong>You:</strong> <span id="p1-nums">โ</span></div>
<div id="p2-chosen"><strong>MCTS:</strong> <span id="p2-nums">โ</span></div>
</div>
<div id="tree-section">
<div id="tree-title">MCTS simulation budget (300 rollouts per move)</div>
<div id="tree-bars"></div>
</div>
<div id="result-bar"></div>
<button id="reset-btn" type="button">New game</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
#app { padding: .6rem; max-width: 500px; margin: 0 auto; }
#info-bar { font-weight: 600; font-size: .95rem; margin-bottom: .5rem; }
#number-row { display: flex; gap: .3rem; margin-bottom: .6rem; flex-wrap: wrap; }
.num-btn { flex: 1; min-width: 42px; height: 44px; font-size: 1.15rem; font-weight: 700;
border-radius: 9px; border: 2px solid #1d3557; background: #fff;
color: #1d3557; cursor: pointer; transition: background .12s, color .12s; }
.num-btn:hover:not(:disabled) { background: #e8eef3; }
.num-btn:disabled { opacity: .38; cursor: default; }
.num-btn.taken-p1 { background: #3b82f6; border-color: #2563eb; color: #fff; }
.num-btn.taken-p2 { background: #ef4444; border-color: #dc2626; color: #fff; }
#chosen { display: flex; gap: 1.5rem; font-size: .9rem; margin-bottom: .6rem; }
#p1-chosen { color: #2563eb; } #p2-chosen { color: #dc2626; }
#tree-section { background: #f4f7fa; border-radius: 10px; padding: .5rem .7rem;
margin-bottom: .5rem; min-height: 90px; }
#tree-title { font-size: .78rem; color: #666; margin-bottom: .3rem; }
.tree-row { display: flex; align-items: center; gap: .4rem; margin-bottom: .22rem; font-size: .82rem; }
.tree-label { width: 22px; text-align: center; font-weight: 700; color: #444; }
.tree-bar-wrap { flex: 1; background: #dde4ec; border-radius: 4px; height: 13px; overflow: hidden; }
.tree-bar { height: 100%; background: #ef4444; border-radius: 4px; transition: width .2s; }
.tree-stat { width: 74px; text-align: right; color: #555; font-size: .78rem; }
#result-bar { font-size: 1rem; font-weight: 700; min-height: 1.3em; margin-bottom: .4rem;
text-align: center; }
.win { color: #15803d; } .lose { color: #dc2626; } .draw { color: #78350f; }
#reset-btn { display: block; margin: 0 auto; padding: .4rem 1.2rem; border-radius: 8px;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
font: 600 .9rem system-ui; cursor: pointer; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Number-picking game equivalent to tic-tac-toe via a 3ร3 magic square.
// Players alternate picking numbers 1-9 (no repeats).
// First player with THREE numbers summing to 15 wins.
//
// Magic square (every row/col/diag sums to 15):
// 2 | 7 | 6
// 9 | 5 | 1
// 4 | 3 | 8
//
// Win triples (all 8 lines of the magic square):
// [2,7,6],[9,5,1],[4,3,8],[2,9,4],[7,5,3],[6,1,8],[2,5,8],[6,5,4]
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const WIN_TRIPLES = [
[2,7,6],[9,5,1],[4,3,8],
[2,9,4],[7,5,3],[6,1,8],
[2,5,8],[6,5,4]
];
function checkWin(nums) {
for (const [a,b,c] of WIN_TRIPLES)
if (nums.includes(a) && nums.includes(b) && nums.includes(c)) return true;
return false;
}
function available(p1, p2) {
const taken = new Set([...p1, ...p2]);
return [1,2,3,4,5,6,7,8,9].filter(n => !taken.has(n));
}
// โโ MCTS โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const C_EXPLORE = Math.SQRT2;
function randomPlayout(p1, p2, turnIsP2) {
p1 = [...p1]; p2 = [...p2];
let avail = available(p1, p2);
let t = turnIsP2; // true = MCTS turn (p2), false = human turn (p1)
while (avail.length > 0) {
const pick = avail[Math.floor(Math.random() * avail.length)];
if (t) p2.push(pick); else p1.push(pick);
if (checkWin(t ? p2 : p1)) return t ? 1 : 0; // 1=p2 wins, 0=p1 wins
avail = available(p1, p2);
t = !t;
}
return -1; // draw
}
// Returns { move, stats[] } where stats = array of { move, visits, wins }
function mcts(p1, p2, simulations) {
const avail = available(p1, p2);
if (avail.length === 0) return null;
const nodes = avail.map(m => ({ move: m, visits: 0, wins: 0 }));
for (let i = 0; i < simulations; i++) {
const total = nodes.reduce((s, n) => s + n.visits, 0);
let chosen;
if (total < nodes.length) {
// Initialise all nodes once before UCB
chosen = nodes[i < nodes.length ? i : Math.floor(Math.random() * nodes.length)];
} else {
let best = -Infinity;
for (const nd of nodes) {
const ucb = nd.visits === 0
? Infinity
: nd.wins / nd.visits + C_EXPLORE * Math.sqrt(Math.log(total) / nd.visits);
if (ucb > best) { best = ucb; chosen = nd; }
}
}
// Expand: p2 picks chosen.move
const newP2 = [...p2, chosen.move];
let result;
if (checkWin(newP2)) {
result = 1; // p2 wins immediately
} else if (available(p1, newP2).length === 0) {
result = -1; // draw
} else {
result = randomPlayout(p1, newP2, false); // next turn = p1
}
chosen.visits++;
if (result === 1) chosen.wins++;
}
// Robust child: most visited
const best = nodes.reduce((a, b) => b.visits > a.visits ? b : a);
return { move: best.move, stats: nodes };
}
// โโ Game state โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let p1 = [], p2 = [], gameOver = false;
const SIMS = 300;
function renderBars(stats) {
const barsEl = document.getElementById('tree-bars');
barsEl.innerHTML = '';
if (!stats) { barsEl.textContent = 'Pick a number to see MCTS think.'; return; }
const maxV = Math.max(...stats.map(s => s.visits), 1);
for (const nd of [...stats].sort((a,b) => b.visits - a.visits)) {
const pct = (nd.visits / maxV * 100).toFixed(1);
const wr = nd.visits > 0 ? (nd.wins / nd.visits * 100).toFixed(0) + '%' : 'โ';
const row = document.createElement('div');
row.className = 'tree-row';
row.innerHTML =
fmt('<span class="tree-label">%move%</span><span class="tree-bar-wrap"><span class="tree-bar" style="width:%pct%%"></span></span><span class="tree-stat">%visits%v / %wr%</span>', { move: nd.move, pct: pct, visits: nd.visits, wr: wr });
barsEl.appendChild(row);
}
}
function updateButtons() {
document.querySelectorAll('.num-btn').forEach(btn => {
const n = +btn.dataset.n;
btn.disabled = gameOver || p1.includes(n) || p2.includes(n);
btn.className = 'num-btn' +
(p1.includes(n) ? ' taken-p1' : p2.includes(n) ? ' taken-p2' : '');
});
document.getElementById('p1-nums').textContent = p1.length ? p1.sort((a,b)=>a-b).join(', ') : 'โ';
document.getElementById('p2-nums').textContent = p2.length ? p2.sort((a,b)=>a-b).join(', ') : 'โ';
}
function endCheck() {
const res = document.getElementById('result-bar');
const tl = document.getElementById('turn-label');
if (checkWin(p1)) {
res.textContent = 'You win! You found a winning sequence.'; res.className = 'win';
gameOver = true; tl.textContent = 'Game over';
return true;
}
if (checkWin(p2)) {
res.textContent = 'MCTS wins! It navigated to a forced sequence.'; res.className = 'lose';
gameOver = true; tl.textContent = 'Game over';
return true;
}
if (available(p1, p2).length === 0) {
res.textContent = 'Draw โ no player formed a sum-to-15 triple.'; res.className = 'draw';
gameOver = true; tl.textContent = 'Game over';
return true;
}
return false;
}
document.querySelectorAll('.num-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (gameOver) return;
const n = +btn.dataset.n;
if (p1.includes(n) || p2.includes(n)) return;
p1.push(n);
updateButtons();
if (endCheck()) { renderBars(null); return; }
document.getElementById('turn-label').textContent = 'MCTS is thinkingโฆ';
setTimeout(() => {
const res = mcts(p1, p2, SIMS);
if (res) {
p2.push(res.move);
document.getElementById('turn-label').textContent = 'Your turn (Player 1 โ blue)';
updateButtons();
endCheck();
renderBars(res.stats);
}
}, 20);
});
});
document.getElementById('reset-btn').addEventListener('click', () => {
p1 = []; p2 = []; gameOver = false;
document.getElementById('result-bar').textContent = '';
document.getElementById('result-bar').className = '';
document.getElementById('turn-label').textContent = 'Your turn (Player 1 โ blue)';
updateButtons();
renderBars(null);
});
// โโ Self-test (runs silently in the console) โโโโโโโโโโโโโโโโโโโโโโ
(function selfTest() {
// checkWin basics
console.assert(checkWin([2,7,6]), 'row win OK');
console.assert(checkWin([2,5,8]), 'diag win OK');
console.assert(!checkWin([1,2,3]), 'no win OK');
// available
console.assert(available([1,2,3],[4,5,6]).join(',') === '7,8,9', 'available OK');
// mcts from empty โ 5 stats with visits
const r0 = mcts([], [], 100);
console.assert(r0 && r0.stats.length === 9, fmt('stats length 9: got %v%', { v: r0 && r0.stats.length }));
// forced win: p2 has [2,7], avail includes 6; picking 6 wins (2+7+6=15)
const fw = mcts([1,3], [2,7], 500);
console.assert(fw && fw.move === 6, fmt('forced win move=6: got %v%', { v: fw && fw.move }));
// forced block: p1 has [2,7], p2 must pick 6 to block
const fb = mcts([2,7], [1,3], 500);
console.assert(fb && fb.move === 6, fmt('forced block move=6: got %v%', { v: fb && fb.move }));
console.log('MCTS self-test passed. Forced win:', fw.move, 'Forced block:', fb.move);
})();
updateButtons();
renderBars(null);
<div id="app">
<div id="info-bar">
<span id="turn-label">Your turn (Player 1 โ blue)</span>
</div>
<div id="number-row">
<button class="num-btn" data-n="1">1</button>
<button class="num-btn" data-n="2">2</button>
<button class="num-btn" data-n="3">3</button>
<button class="num-btn" data-n="4">4</button>
<button class="num-btn" data-n="5">5</button>
<button class="num-btn" data-n="6">6</button>
<button class="num-btn" data-n="7">7</button>
<button class="num-btn" data-n="8">8</button>
<button class="num-btn" data-n="9">9</button>
</div>
<div id="chosen">
<div id="p1-chosen"><strong>You:</strong> <span id="p1-nums">โ</span></div>
<div id="p2-chosen"><strong>MCTS:</strong> <span id="p2-nums">โ</span></div>
</div>
<div id="tree-section">
<div id="tree-title">MCTS simulation budget (300 rollouts per move)</div>
<div id="tree-bars"></div>
</div>
<div id="result-bar"></div>
<button id="reset-btn" type="button">New game</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
#app { padding: .6rem; max-width: 500px; margin: 0 auto; }
#info-bar { font-weight: 600; font-size: .95rem; margin-bottom: .5rem; }
#number-row { display: flex; gap: .3rem; margin-bottom: .6rem; flex-wrap: wrap; }
.num-btn { flex: 1; min-width: 42px; height: 44px; font-size: 1.15rem; font-weight: 700;
border-radius: 9px; border: 2px solid #1d3557; background: #fff;
color: #1d3557; cursor: pointer; transition: background .12s, color .12s; }
.num-btn:hover:not(:disabled) { background: #e8eef3; }
.num-btn:disabled { opacity: .38; cursor: default; }
.num-btn.taken-p1 { background: #3b82f6; border-color: #2563eb; color: #fff; }
.num-btn.taken-p2 { background: #ef4444; border-color: #dc2626; color: #fff; }
#chosen { display: flex; gap: 1.5rem; font-size: .9rem; margin-bottom: .6rem; }
#p1-chosen { color: #2563eb; } #p2-chosen { color: #dc2626; }
#tree-section { background: #f4f7fa; border-radius: 10px; padding: .5rem .7rem;
margin-bottom: .5rem; min-height: 90px; }
#tree-title { font-size: .78rem; color: #666; margin-bottom: .3rem; }
.tree-row { display: flex; align-items: center; gap: .4rem; margin-bottom: .22rem; font-size: .82rem; }
.tree-label { width: 22px; text-align: center; font-weight: 700; color: #444; }
.tree-bar-wrap { flex: 1; background: #dde4ec; border-radius: 4px; height: 13px; overflow: hidden; }
.tree-bar { height: 100%; background: #ef4444; border-radius: 4px; transition: width .2s; }
.tree-stat { width: 74px; text-align: right; color: #555; font-size: .78rem; }
#result-bar { font-size: 1rem; font-weight: 700; min-height: 1.3em; margin-bottom: .4rem;
text-align: center; }
.win { color: #15803d; } .lose { color: #dc2626; } .draw { color: #78350f; }
#reset-btn { display: block; margin: 0 auto; padding: .4rem 1.2rem; border-radius: 8px;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
font: 600 .9rem system-ui; cursor: pointer; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Number-picking game equivalent to tic-tac-toe via a 3ร3 magic square.
// Players alternate picking numbers 1-9 (no repeats).
// First player with THREE numbers summing to 15 wins.
//
// Magic square (every row/col/diag sums to 15):
// 2 | 7 | 6
// 9 | 5 | 1
// 4 | 3 | 8
//
// Win triples (all 8 lines of the magic square):
// [2,7,6],[9,5,1],[4,3,8],[2,9,4],[7,5,3],[6,1,8],[2,5,8],[6,5,4]
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const WIN_TRIPLES = [
[2,7,6],[9,5,1],[4,3,8],
[2,9,4],[7,5,3],[6,1,8],
[2,5,8],[6,5,4]
];
function checkWin(nums) {
for (const [a,b,c] of WIN_TRIPLES)
if (nums.includes(a) && nums.includes(b) && nums.includes(c)) return true;
return false;
}
function available(p1, p2) {
const taken = new Set([...p1, ...p2]);
return [1,2,3,4,5,6,7,8,9].filter(n => !taken.has(n));
}
// โโ MCTS โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const C_EXPLORE = Math.SQRT2;
function randomPlayout(p1, p2, turnIsP2) {
p1 = [...p1]; p2 = [...p2];
let avail = available(p1, p2);
let t = turnIsP2; // true = MCTS turn (p2), false = human turn (p1)
while (avail.length > 0) {
const pick = avail[Math.floor(Math.random() * avail.length)];
if (t) p2.push(pick); else p1.push(pick);
if (checkWin(t ? p2 : p1)) return t ? 1 : 0; // 1=p2 wins, 0=p1 wins
avail = available(p1, p2);
t = !t;
}
return -1; // draw
}
// Returns { move, stats[] } where stats = array of { move, visits, wins }
function mcts(p1, p2, simulations) {
const avail = available(p1, p2);
if (avail.length === 0) return null;
const nodes = avail.map(m => ({ move: m, visits: 0, wins: 0 }));
for (let i = 0; i < simulations; i++) {
const total = nodes.reduce((s, n) => s + n.visits, 0);
let chosen;
if (total < nodes.length) {
// Initialise all nodes once before UCB
chosen = nodes[i < nodes.length ? i : Math.floor(Math.random() * nodes.length)];
} else {
let best = -Infinity;
for (const nd of nodes) {
const ucb = nd.visits === 0
? Infinity
: nd.wins / nd.visits + C_EXPLORE * Math.sqrt(Math.log(total) / nd.visits);
if (ucb > best) { best = ucb; chosen = nd; }
}
}
// Expand: p2 picks chosen.move
const newP2 = [...p2, chosen.move];
let result;
if (checkWin(newP2)) {
result = 1; // p2 wins immediately
} else if (available(p1, newP2).length === 0) {
result = -1; // draw
} else {
result = randomPlayout(p1, newP2, false); // next turn = p1
}
chosen.visits++;
if (result === 1) chosen.wins++;
}
// Robust child: most visited
const best = nodes.reduce((a, b) => b.visits > a.visits ? b : a);
return { move: best.move, stats: nodes };
}
// โโ Game state โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let p1 = [], p2 = [], gameOver = false;
const SIMS = 300;
function renderBars(stats) {
const barsEl = document.getElementById('tree-bars');
barsEl.innerHTML = '';
if (!stats) { barsEl.textContent = 'Pick a number to see MCTS think.'; return; }
const maxV = Math.max(...stats.map(s => s.visits), 1);
for (const nd of [...stats].sort((a,b) => b.visits - a.visits)) {
const pct = (nd.visits / maxV * 100).toFixed(1);
const wr = nd.visits > 0 ? (nd.wins / nd.visits * 100).toFixed(0) + '%' : 'โ';
const row = document.createElement('div');
row.className = 'tree-row';
row.innerHTML =
fmt('<span class="tree-label">%move%</span><span class="tree-bar-wrap"><span class="tree-bar" style="width:%pct%%"></span></span><span class="tree-stat">%visits%v / %wr%</span>', { move: nd.move, pct: pct, visits: nd.visits, wr: wr });
barsEl.appendChild(row);
}
}
function updateButtons() {
document.querySelectorAll('.num-btn').forEach(btn => {
const n = +btn.dataset.n;
btn.disabled = gameOver || p1.includes(n) || p2.includes(n);
btn.className = 'num-btn' +
(p1.includes(n) ? ' taken-p1' : p2.includes(n) ? ' taken-p2' : '');
});
document.getElementById('p1-nums').textContent = p1.length ? p1.sort((a,b)=>a-b).join(', ') : 'โ';
document.getElementById('p2-nums').textContent = p2.length ? p2.sort((a,b)=>a-b).join(', ') : 'โ';
}
function endCheck() {
const res = document.getElementById('result-bar');
const tl = document.getElementById('turn-label');
if (checkWin(p1)) {
res.textContent = 'You win! You found a winning sequence.'; res.className = 'win';
gameOver = true; tl.textContent = 'Game over';
return true;
}
if (checkWin(p2)) {
res.textContent = 'MCTS wins! It navigated to a forced sequence.'; res.className = 'lose';
gameOver = true; tl.textContent = 'Game over';
return true;
}
if (available(p1, p2).length === 0) {
res.textContent = 'Draw โ no player formed a sum-to-15 triple.'; res.className = 'draw';
gameOver = true; tl.textContent = 'Game over';
return true;
}
return false;
}
document.querySelectorAll('.num-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (gameOver) return;
const n = +btn.dataset.n;
if (p1.includes(n) || p2.includes(n)) return;
p1.push(n);
updateButtons();
if (endCheck()) { renderBars(null); return; }
document.getElementById('turn-label').textContent = 'MCTS is thinkingโฆ';
setTimeout(() => {
const res = mcts(p1, p2, SIMS);
if (res) {
p2.push(res.move);
document.getElementById('turn-label').textContent = 'Your turn (Player 1 โ blue)';
updateButtons();
endCheck();
renderBars(res.stats);
}
}, 20);
});
});
document.getElementById('reset-btn').addEventListener('click', () => {
p1 = []; p2 = []; gameOver = false;
document.getElementById('result-bar').textContent = '';
document.getElementById('result-bar').className = '';
document.getElementById('turn-label').textContent = 'Your turn (Player 1 โ blue)';
updateButtons();
renderBars(null);
});
// โโ Self-test (runs silently in the console) โโโโโโโโโโโโโโโโโโโโโโ
(function selfTest() {
// checkWin basics
console.assert(checkWin([2,7,6]), 'row win OK');
console.assert(checkWin([2,5,8]), 'diag win OK');
console.assert(!checkWin([1,2,3]), 'no win OK');
// available
console.assert(available([1,2,3],[4,5,6]).join(',') === '7,8,9', 'available OK');
// mcts from empty โ 5 stats with visits
const r0 = mcts([], [], 100);
console.assert(r0 && r0.stats.length === 9, fmt('stats length 9: got %v%', { v: r0 && r0.stats.length }));
// forced win: p2 has [2,7], avail includes 6; picking 6 wins (2+7+6=15)
const fw = mcts([1,3], [2,7], 500);
console.assert(fw && fw.move === 6, fmt('forced win move=6: got %v%', { v: fw && fw.move }));
// forced block: p1 has [2,7], p2 must pick 6 to block
const fb = mcts([2,7], [1,3], 500);
console.assert(fb && fb.move === 6, fmt('forced block move=6: got %v%', { v: fb && fb.move }));
console.log('MCTS self-test passed. Forced win:', fw.move, 'Forced block:', fb.move);
})();
updateButtons();
renderBars(null);
After each of your moves, the tree panel shows MCTS spending its simulation budget. Wider bars mean more visits; the score shows the win-rate estimate that UCB1 uses to direct future simulations. Notice how early in the game the tree explores broadly, then narrows onto promising continuations as evidence accumulates.
Comments
Loading comments...