How nn-chess thinks: PUCT-style MCTS, sims/move, and cPuct

← back to play

Every time the nn-chess opponent picks a move, it runs a small Monte Carlo Tree Search (MCTS) rooted at the current position. The specific variant is called PUCT — "Predictor + UCT," a bandit-style action-selection rule we'll define carefully in §2. The two knobs the main page exposes, sims/move and cPuct, are both parameters of that rule. This page is a math-first walk-through of what the search does, why it does it that way, and what those two knobs actually control; the end of the page is an interactive mate-in-2 that uses your own trained networks to drive each MCTS call.

1. The shape of the search

Classical full-width minimax explores every legal move to a fixed depth and is hopeless for chess: with a branching factor around 35 and a value network costing real wall-clock time per evaluation, even depth-6 minimax dwarfs the per-move budget we have in the browser. MCTS gives up exhaustive coverage in exchange for a budget we can dial: it spends $n$ simulations exploring the tree, and the tree it builds is highly asymmetric — deep along promising lines, shallow elsewhere.

Concretely, MCTS treats the game as a tree. A node is a board position. An edge is a legal move leading from one node to another; we'll usually write an edge as a pair $(s, a)$ — "the action $a$ available at state $s$." The root is the position we're choosing a move for. A leaf is a node we haven't yet expanded — either because it's terminal (game over) or because the search hasn't spent a simulation on it yet. One "simulation" or "playout" is a single walk from root to leaf plus an evaluation and a bookkeeping pass back up the path.

The flavour of MCTS we use is the PUCT variant from AlphaGo Zero[1] and AlphaZero[2]. A single neural network ("the net" — one set of weights, two output heads) supplies:

The only terminal-reward signal the search ever sees is fixed: $+1$ if the side that just moved delivered checkmate, $-1$ if it got checkmated, $0$ for any draw. That is the only chess heuristic baked in. Everything else — piece values, tactics, positional understanding — has to come out of the network from games.

One simulation has four phases, run repeatedly for the search budget:

  1. Selection. Starting at the root, descend through the tree by repeatedly picking the edge with the highest PUCT score (defined in §2) until you reach a node that's either unexpanded (we've never asked the net about it) or terminal.
  2. Expansion. If the leaf is non-terminal, ask the net for $P(\cdot \mid s)$ and $V(s)$. Mask $P$ to the legal moves at $s$ and renormalise. Create one child edge per legal move and store its prior.
  3. Evaluation. Use $V(s)$ as the value to back up. If the leaf is terminal, use the terminal reward instead. Classical MCTS does an actual rollout[3] here — it plays a random or weakly-policy-driven game from the leaf to the end, and backs up the win/loss. AlphaZero showed that once you have a competent value head, the value head replaces what the rollout used to estimate; no random play is needed at all.
  4. Backup. Walk back up the path you descended, incrementing visit counts and accumulating value along each edge. Because chess is zero-sum and turn-alternating, the value flips sign at every step — a position worth $+0.4$ to me is worth $-0.4$ to my opponent, who is to move one ply earlier. §3 makes this precise.

After all simulations finish, the move chosen at the root is the one with the most visits. The visit-count distribution also doubles as a policy target when the net is being trained: during AlphaZero-style self-play the policy head is regressed toward $\pi(a) \propto N(s, a)$, on the theory that the search produces an improvement over the raw prior. We're not training on this page — everything below is inference — but the same machinery feeds the training step on the main page.

2. The PUCT formula in detail

Each edge $(s, a)$ in the search tree carries four numbers we update as the search runs:

Selection at $s$ picks the legal action $a$ that maximises:

$$ \mathrm{PUCT}(s, a) \;=\; \underbrace{Q(s, a)}_{\text{exploit}} \;+\; \underbrace{c_{\text{puct}} \cdot P(s, a) \cdot \dfrac{\sqrt{N(s)}}{1 + N(s, a)}}_{\text{explore } U(s,a)} $$

where $N(s) = \sum_{a'} N(s, a')$ is the total visit count at the parent node $s$ (the sum of $N$ over all the edges leaving it). Both terms in the PUCT sum are interpretable:

Why $\sqrt{N(s)}$ in the explore bonus? It's a heuristic choice from the AlphaGo Zero paper, loosely inspired by UCB1[4] — the canonical exploration bonus for stochastic multi-armed bandit problems. UCB1's bonus is $\sqrt{\log N(s) / N(s, a)}$, derived from a Chernoff-style confidence bound on the empirical mean of an arm; PUCT swaps the $\log$ for a plain $\sqrt{N(s)}$ and multiplies the whole thing by the policy prior $P(s, a)$ so high-prior moves get the bulk of early exploration. The net effect: visits distribute roughly in proportion to $\sqrt{N(s)} \cdot P(s, a)$ early on (when $N(s, a)$ is small and $Q$ is noisy), then concentrate on whichever edges develop a high $Q$.

All ties on $Q + U$ are broken deterministically (high prior first, then lexicographic UCI). Search is otherwise purely deterministic once the network weights and rng seed are fixed — see the determinism test in core/mcts/mcts.test.ts.

3. The backup, with sign-flipping

During backup, the value $v$ produced at the leaf travels back up the descent path. Because chess is zero-sum and turn-alternating, adjacent plies belong to opposite players: if the leaf is worth $+0.7$ to the side to move there, the same outcome is worth $-0.7$ to its parent's mover, $+0.7$ to the grandparent's mover, and so on. So at each step up the path we negate $v$ before crediting it to the edge:

$$ v \;\leftarrow\; -v, \quad N(s, a) \mathrel{+}= 1, \quad W(s, a) \mathrel{+}= v $$

This convention keeps every $Q(s, a)$ in the tree measured from the side-to-move's perspective at $s$, which is exactly what selection needs to compare candidate moves consistently.

Terminal leaves get the same flip in disguise. If the leaf is a checkmate, the side that just moved won — so $+1$ for them, $-1$ for the side to move at the leaf (the side that's mated). Our backup convention measures everything from the side-to-move's perspective, so the value we want to push up is the second of those two — $-1$ at the leaf. The helper terminalValueForMover in core/mcts/index.ts answers from the just-moved side's perspective (because that's the convenient one for the rules module), so the search code negates it once before sending it up the path.

4. What sims/move actually controls

The sims/move slider on the main page caps the number of PUCT simulations the search runs before picking a move. A few quantitative things follow from increasing it:

5. What cPuct actually controls

$c_{\text{puct}}$ is a single scalar that determines how much weight selection puts on $U$ relative to $Q$. The two boundary cases are instructive:

AlphaZero used $c_{\text{puct}} = 1.5$ for chess; that's the nn-chess default. The right value depends on (a) the value-head scale (ours is $\tanh$, so $Q \in [-1, 1]$) and (b) how peaked the prior is. A near-uniform prior — what you get from a cold-start net — calls for a smaller $c_{\text{puct}}$, because $U$ is then much flatter and adding more of it just spreads visits across edges $Q$ has already ranked. A very peaked prior calls for a larger $c_{\text{puct}}$ to overcome the policy's confidence when it's wrong.

Concretely on the demo below: with the cold-start nets nn-chess ships with, a value of $c_{\text{puct}}$ around $1$–$1.5$ is enough that the terminal signal from Qd7+ Kf8 Qf7# can propagate back through MCTS. Push it up to $4$ and you'll see visits scatter across many root moves and the mate becomes harder to land at low sim counts. Push it down to $0.25$ and you'll see the search lock onto whichever queen move happened to get the first above-zero $Q$.

6. Dirichlet noise (briefly)

The MCTS module supports root-only Dirichlet noise: $\tilde P(s_0, a) = (1 - \varepsilon) P(s_0, a) + \varepsilon \eta_a$ where $\eta \sim \mathrm{Dir}(\alpha)$. This is how AlphaZero forces a bit of exploration during self-play (it keeps the data generator from collapsing onto a single line). We use it in self-play games against the imitator with the standard chess values $\alpha = 0.3$, $\varepsilon = 0.25$[7]; we do not use it during play against the user — at inference time we want the best move, not an exploration-noised one. See SELFPLAY_SEARCH in nn-chess/ui/opponent-config.ts for where this is wired in. The demo below also leaves Dirichlet noise off so the visit counts you see are pure PUCT.

7. Interactive demo: a mate-in-2 against your own nets

The walkthrough below loads your stored opponent and imitator networks from this browser's IndexedDB (the same ones the main page plays against and trains). At each position it runs MCTS with the sims-per-move and cPuct you set, and lists the top candidate moves with their $N$, $Q$, $P$, and the $U = c_{\text{puct}} \cdot P \cdot \sqrt{N(s)} / (1 + N(s, a))$ that the next simulation would add. Click any candidate row to play that move and see MCTS think about the resulting position; the demo goes up to four half-moves deep so you can walk the full forced sequence and one extra ply of slack for offline exploration.

White (the winning side) uses the opponent network; black (the defender) uses the imitator. Both have to have been initialised — if you've never visited the main page, click over there once first so the random-init weights get persisted.

A few things worth poking at:

8. Where this lives in the code

Footnotes & further reading

  1. Silver et al., Mastering the game of Go without human knowledge, Nature 550, 354–359 (2017). Nature; the paper that introduced AlphaGo Zero and the PUCT formula we use here. ↩
  2. Silver et al., A general reinforcement learning algorithm that masters chess, shogi, and Go through self-play, Science 362, 1140–1144 (2018); arXiv:1712.01815. The AlphaZero paper, which carries the same PUCT machinery over to chess (and is where the $c_{\text{puct}} = 1.5$ default comes from). ↩
  3. The original MCTS line of work — Coulom (2006), Kocsis & Szepesvári (2006), and the broader UCT family — relies on these random rollouts and works without a learned value head at all. See Browne et al., A Survey of Monte Carlo Tree Search Methods, IEEE Transactions on Computational Intelligence and AI in Games (2012), DOI: 10.1109/TCIAIG.2012.2186810, for the pre-AlphaGo state of the art. The Wikipedia page on MCTS walks through the rollout-based version step by step. ↩
  4. Auer, Cesa-Bianchi & Fischer, Finite-time Analysis of the Multiarmed Bandit Problem, Machine Learning 47, 235–256 (2002), Springer. The paper that derives UCB1. For PUCT specifically, the relevant precursor is Rosin's Multi-armed bandits with episode context (2011), Springer, which introduces the prior-weighted variant the AlphaGo Zero paper builds on. ↩
  5. AlphaZero used $T = 1$ for the first $30$ plies of each self-play game (so the data generator could see varied openings) and then $T \to 0$ for the rest of the game. The SELFPLAY_TEMPERATURE_PLIES constant in nn-chess/ui/opponent-config.ts follows the same convention. nn-chess's self-play uses $T = 1$ during that opening window and greedy after; the human-play opponent is always greedy. ↩
  6. This is a very loose worst-case bound. With a uniform prior and no value-head signal, a fresh search has nothing to push visits one way or the other, so all $b^k$ leaves $k$ plies down are roughly equally likely to be reached first. As soon as a few terminal leaves are reached, the resulting $+1$/$-1$ values flow up through $Q$ and bias selection sharply, which is why the empirical sims-to-mate is much smaller than $b^k$ in practice. ↩
  7. Dirichlet noise is applied only at the root and never inside the tree. Adding it deeper would corrupt the recursive structure of $Q$ — child evaluations would no longer be reusable for selection at the same state in a future simulation, because the priors that determined their visit counts would have been different. Restricting noise to the root keeps the per-state tree consistent while still letting the data generator wander across distinct opening choices. ↩