An AlphaZero-style engine for the board game Quoridor, trained from zero knowledge — no opening book, no human games, no hand-written evaluation. Just the rules, self-play, and a neural network that learns from its own games.
No install, nothing to download — the network runs client-side in your browser, using your CPU via ONNX. Don't know the game? RULES.md walks through it in diagrams — including every jump and wall case.
The reference opponent throughout this project was gorisanson's quoridor-ai, which also uses MCTS, but guided by hand-written heuristics rather than a learned network. It's a reasonable benchmark rather than a brick wall — it plays sensibly and a decent human can beat it — but as of 25.07.2026 it was the strongest publicly available Quoridor AI I could find, which makes it the bar worth clearing.
My 9×9 network beats it as both first and second player using no search at all — a single forward pass, playing the arg-max of the policy head, zero MCTS simulations.
And that is its floor, not its ceiling. Hand it the search it was trained with and it pulls away from itself: playing at 100 simulations per move it beats the same weights running search-free in 84% of games — and the gains keep coming with depth: at 400 simulations it beats its own 200-simulation self in 61% (200-game matches; a searching side samples its visit counts at temperature 0.5 so the games differ, while the searchless side always plays its policy arg-max).
Past that point, measuring progress gets awkward — neither I nor the reference bot can beat it
any more, so there's no external yardstick left. Improvement is tracked instead by round-robin
Elo tournaments between checkpoints (runs/tournaments/).
Those numbers above come from one such tournament, run on the search budget itself: one set of weights entered six times, at six simulation counts, everyone playing everyone, 200 games a pair.
| simulations / move | Elo | score vs the field |
|---|---|---|
| 400 | 1776 | 83.1% |
| 200 | 1700 | 74.9% |
| 100 | 1539 | 55.0% |
| 50 | 1483 | 47.6% |
| 10 | 1277 | 22.5% |
| 1 — the raw network, no search | 1225 | 17.1% |
If you already know AlphaZero, skip to Speed.
Classical engines (Stockfish-style) use a neural network too these days — but only as an evaluator: a small, extremely fast net that scores a leaf position, called millions of times per second inside an alpha-beta search that grinds through tens of millions of nodes and prunes branches by rules the network has no say in.
AlphaZero shifts the weight the other way. The network is far larger and slower, so the search can only afford a few hundred evaluations per move — and it earns that by doing a second job: as well as scoring a position, it proposes which moves are worth looking at. That output steers the search itself. Monte-Carlo Tree Search grows the tree lopsidedly, pouring its budget into the lines the network already finds promising and barely glancing at the rest, rather than sweeping a tapering-but-broad frontier. Less brute force, more guided attention. And the network learns all of this only from games the engine plays against itself — no human games, no hand-written evaluation to start from.
The network. One network, two outputs ("heads"), fed a stack of planes describing the board position:
- a policy head — a probability over every legal move: "which moves look worth considering?"
- a value head — a single number in [-1, +1]: "who is winning, and by how much confidence?"
The search. The network alone is a decent intuition but a poor calculator, so it is wrapped in Monte-Carlo Tree Search (MCTS). Each "simulation" walks down the game tree, picking moves that balance the network's prior against how well that move has scored so far and how rarely it has been tried (the PUCT rule). At the leaf, instead of playing the game out randomly, it just asks the network "who's winning here?" and propagates that answer back up. Run a few hundred simulations and the visit counts across the root's children are a better move distribution than the raw policy — search sharpens intuition.
The loop. That improvement is the entire training signal:
┌─────────────────────────────────────────────┐
│ │
▼ │
┌───────────┐ games ┌───────────┐ better │
│ SELF-PLAY │ ───────────► │ TRAIN │ ─ weights ─┘
│ MCTS + NN │ │ policy → │
└───────────┘ │ MCTS's │
store for each move: │ visits │
• position │ value → │
• MCTS visit counts │ who won │
• who eventually won └───────────┘
- Self-play — the current network plays thousands of games against itself. For every position, record the MCTS visit distribution and, at the end, who won.
- Train — nudge the policy head toward the visit counts (which were better than its own guess) and the value head toward the actual result.
- Repeat — the improved network makes better self-play games, which make a better training target, and so on.
Nothing in that loop knows anything about Quoridor beyond the legal-move generator and who won. That is why the approach ports to other games — see Adapting this to another game.
Where this implementation differs from some other approaches:
- Discrete cycles, not a continuous stream. AlphaZero runs self-play and training simultaneously and asynchronously — workers generate games nonstop against whatever weights are current, while a trainer updates those weights in parallel. Here the two strictly alternate: freeze the network, play 2048 games with it, stop, train on the replay buffer, repeat. It's simpler to reason about and to restart (the two halves are even separate processes, so a crash costs at most one cycle), and it makes "cycle N" a meaningful, comparable checkpoint. The cost is that the GPU switches roles instead of doing both at once, and games late in a cycle are played by a network that is already slightly out of date.
- No promotion gate. Every cycle's weights are kept; there's no "only accept the new network if it beats the old one by 55%" step. Progress can be verified after the fact with tournaments.
- (Optional) exact endgame solver. Once few enough walls remain, positions are solved exactly by alpha-beta instead of guessed by the network, giving perfect training labels for endgames.
- A shared transposition table caches network evaluations across the thousands of games running concurrently, which is a large part of the throughput.
The current network is the product of a single from-scratch run on my personal desktop — not a cluster, not a rented A100.
| Machine | Ryzen 7 7800X3D (8 cores) · RTX 5070 Ti 16 GB · 32 GB RAM · Windows 11 |
| Board | 9×9, 10 walls per player |
| Training | 321 cycles · 2048 self-play games each · 800 MCTS sims/move |
| Compute | 35.7 h total — 27.2 h self-play + 8.4 h network training |
| Gradient steps | 319,000 |
| Network | 128 filters × 10 residual blocks, KataGo-style global pooling every 3rd block |
Speed on consumer hardware is a design goal here, not an afterthought — and it's the main thing this repo does differently from most AlphaZero reimplementations, which tend to be written for clarity or for clusters and are content to generate a few hundred games an hour on one GPU.
Measured on the machine above, at full 9×9 / 800-simulations-per-move settings:
| Self-play | ~20,000–30,000 games/hour (2048 games in ~4–6 min) |
| Training | ~105 s per cycle (1000 gradient steps at batch 1024) |
| Full cycle | ~6–7 minutes, self-play and training |
Cycle times vary by a fair margin — with how long the games happen to run, and with how much of the search the transposition table manages to absorb. The 321 recorded cycles average 6.7 minutes end to end. That is what makes such a run finish over a weekend instead of a month. Where the speed comes from:
- The whole search is in C++ (
cpp/) — a second, from-scratch rules engine with bitboards and Zobrist hashing, running leaf-parallel MCTS on worker threads that hold no GIL. - Thousands of games run concurrently so inference runs on batches of hundreds of positions instead of one position at a time — the single biggest factor.
- A transposition table shared across all those games, so the openings every game passes through are evaluated once, not thousands of times.
- Exact alpha-beta endgame solving, bf16 inference, and a pre-allocated flat replay buffer.
Worth knowing if you're picking hardware: the CPU is the tighter constraint on this machine — though the GPU is under-fed rather than idle. Tree search, move generation, the transposition table and the endgame solver all run on the CPU. Sampled every two seconds across a full 9×9 / 800-simulation cycle (ignoring the straggler tail at the end):
| median | peak | |
|---|---|---|
| System CPU | 92% | 100% |
| GPU utilisation | 42% | 85% |
| GPU board power | 98 W | 178 W |
Self-play asks the card for ~25,000 network evaluations per second. Fed synthetic full batches, the same network on the same card retires ~90,000 per second, so roughly 3× of GPU headroom is going unused. That is by design rather than by accident: around 90% of the ~90 million simulations in a cycle never reach the network at all — the shared transposition table, in-tree exact solving and terminal-node detection answer them on the CPU instead. Buying CPU work to avoid GPU work is a large net win, but it is also what makes self-play CPU-heavy.
If you did want more self-play throughput here, cores are the lever rather than the GPU — the
search, the table and the solver are all CPU work, and they are what leaves the card mostly idle.
The payoff is bounded, though: the GPU saturates at ~90,000 evaluations/s however you feed it, so
there is roughly 3× of room and then a wall. Raising --max-batch above ~1024 buys nothing on
this card either: throughput plateaus from batch ~768 upward (768: 94k evals/s, 1024: 93k,
2048: 83k, 8192: 87k — the variation up there is within run-to-run noise) while per-batch latency
grows from 11 ms to 94 ms, which only makes every in-flight simulation's statistics staler. Where
that knee sits is specific to this card and this network size; the shape is not — past the point
where batches saturate the GPU, a bigger cap adds round-trip latency without adding throughput. The constraint also
moves around within a single cycle: the opening burst, where thousands of games all want their
first positions evaluated at once, is genuinely GPU-bound (85% utilisation, 178 W), and the CPU
only becomes the limit once the transposition table starts absorbing those shared openings.
Worth saying plainly, though: more throughput is not what this needs. Self-play games overlap heavily — 2048 games a cycle yield only about 59,000 distinct positions, 42% of the rows written, and that share shrinks as the policy sharpens and sampling becomes more deterministic. Faster hardware of either kind would mostly buy more duplicate positions. Widening the range of openings the games actually explore would buy more information than either.
bartolomeo3000.github.io/SigmaQuoridor — everything runs in your browser (ONNX Runtime Web); no data leaves your machine.
Making moves. Both move types are a plain left-click on the board:
- Move your pawn — legal destination squares are highlighted; click one. Jumps over the opponent and diagonal go-arounds are already resolved into destinations, so you just click where you want to end up.
- Place a wall — hover the groove between cells. A preview appears: orange = legal, red = illegal (overlaps, or would completely seal off a player). Orientation is inferred from which gap you're hovering, so there's no mode switch or right-click.
Controls worth knowing:
| Control | What it does |
|---|---|
| Board | 7×7 (5 walls) or 9×9 (10 walls) — 9×9 is the default and the stronger net |
| Mode | H vs AI (default), AI vs H (AI moves first), H vs H, AI vs AI |
| Agent | Per side: SigmaQuoridor (net + MCTS), MCTS (pure random rollouts), Minimax (depth 2–8) |
| Checkpoint | Per side — which exported net that side plays. In AI vs AI the two sides can pick different ones, so one checkpoint can play another |
| Simulations | Per side, 1 → 5000, default 100. This is the AI's thinking budget: 1 = raw network intuition with no search, 5000 = slow and very strong |
| Temperature | Per side, default argmax (0) — always play the most-visited move. Above 0 the side samples from its visit counts instead (visits^(1/T), same convention as mcts.py and tournament_cpp.py --temp), so repeated games diverge |
| Playback delay | Minimum time a move stays on screen while a game plays out on its own — AI vs AI, and replaying the timeline. 0 → 5 s, default 0.75 s; a search that already took longer isn't held back further. It never applies to the AI's reply to your move, which always appears as soon as the search finishes |
| ◀ ▶ / scrubber | Step one ply back or forward, or drag to any position in the game. The ← / → keys do the same |
▶ Play / ⏸ Pause |
Start or stop auto-advance (Space). Paused at the live position, ▶ plays exactly one move; parked back in the game, ▶ replays it forward from there |
⇅ Flip Board |
Flip orientation |
Moving from a rewound position branches the game — everything after it is discarded — so the timeline doubles as undo and redo.
- 🤖 AI vs AI — give each side its own agent, budget and checkpoint, then watch. Point the two sides at different checkpoints and you get the same head-to-head the Elo tournament runs, one game at a time at whatever speed you set. Scrub back through any game afterwards, ply by ply.
- 📊 Analysis — win-probability bars (the network's value head, plus the MCTS root value once a search finishes) and a ranked move list showing the network's prior (green) against MCTS's visit counts (red) side by side. That contrast is precisely the "search improves on intuition" step described above, made visible. You can hover a row to highlight the move, click to play it, and analyze with a different checkpoint than you're playing against.
- 🧠 NN Channels — renders all 8 input planes the network actually sees (pawns, walls, walls-in-hand, BFS distance-to-goal maps), from the side-to-move's perspective.
Try setting Simulations to 1 — that's the pure policy head, no search at all, and it still plays a respectable game.
In the shot above the network can barely separate its top three — H(6,5) 17.8%, ←(5,3)
16.6%, ↑(6,4) 16.0% — and search splits them into 41.0%, 25.0% and 16.0%. It
also pulls H(6,4) up from 2.5% to 13.0%. That gap between the prior and the visit counts
is what the policy head is trained toward.
Only needed if you want to train or modify the engine — to just play, use the link above.
Prerequisites
- Python 3.13
- A C++17 compiler — MSVC Build Tools on Windows, gcc/clang elsewhere. The
quoridor_cppextension is not distributed as a binary; you build it locally. - An NVIDIA GPU for realistic training speed (CPU works but is impractically slow for self-play).
git clone https://github.com/bartolomeo3000/SigmaQuoridor.git
cd SigmaQuoridor
python -m venv .venv
# Windows: .venv/Scripts/python · Linux/macOS: .venv/bin/python
.venv/Scripts/python -m pip install -U pip setuptools
# Install torch FIRST, from the index matching your CUDA version.
# A plain `pip install torch` may give you a CPU-only wheel that runs but crawls.
.venv/Scripts/pip install torch --index-url https://download.pytorch.org/whl/cu128
.venv/Scripts/pip install -r requirements.txt
# Build the C++ engine (produces quoridor_cpp*.pyd / .so in the repo root)
.venv/Scripts/python setup_cpp.py build_ext --inplaceVerify the build — this is the test that matters, because it proves the C++ engine agrees with the Python reference engine move-for-move:
.venv/Scripts/python tests/test_cpp_parity.py --games 50
# -> all parity checks passed (N moves compared)Then confirm the GPU is actually being used:
.venv/Scripts/python -c "import torch; print(torch.cuda.is_available(), torch.version.cuda)"Two things that will bite you otherwise:
- Run every command from the repo root. All artifact paths are relative to the working directory.
- If you add a new
cpp/*.hpp, list it insetup_cpp.py'sdepends. That list is howbuild_ext --inplacenotices header-only changes; a header missing from it won't trigger a rebuild and you'll silently keep testing the old engine. To force a clean build:rm -rf build && rm -f quoridor_cpp*.pyd quoridor_cpp*.so .venv/Scripts/python setup_cpp.py build_ext --inplace
Training artifacts live in lineages: a matched pair of directories
runs/models_<name>/ (weights) and runs/data_<name>/ (self-play data). They are paired by
naming convention — the models_/data_ prefix swap is how the scripts find one from the other.
python train.py --model-dir runs/models_myrun --cycles 0--cycles 0 means "initialise and exit": it writes random-init weights to
runs/models_myrun/best.pt and creates checkpoints/ and runs/data_myrun/, without running
any (slow, pure-Python) self-play.
Architecture is fixed at creation time via --filters / --res / --gpool-every /
--value-head / --pawn-head (defaults: 128 filters, 10 blocks, global pooling every 3rd block,
pooled value head, local pawn head). Board size comes from BOARDSIZE in train.py, not a flag.
Checkpoints are self-describing — load_model() re-derives all of it from the weights, so you
never have to remember what you trained.
python cpp_train_loop.py --model-dir runs/models_myrun --cycles 50 --games 2048 --sims 800 --bf16Each cycle: generate --games self-play games with the current net (C++, many games in parallel)
→ append them as runs/data_myrun/cycle_NNNN.npz → train on a replay buffer of the last
--buffer-cycles (default 30) cycles → overwrite best.pt, save a checkpoint, append a row to
training_stats.csv.
The two halves run as separate subprocesses, so the loop survives a crash in either and you can stop it between cycles with no loss.
Knobs that matter most:
| Flag | Default | Effect |
|---|---|---|
--games / --sims |
2048 / 800 | The main cost/quality trade: how much data per cycle, and how good the MCTS targets are |
--parallel / --max-batch |
2048 / 1024 | Throughput. More concurrent games = bigger GPU batches |
--threads |
7 | C++ search threads; roughly your physical core count |
--bf16 |
off | Near-free speedup on modern NVIDIA GPUs — turn it on |
--lr |
3e-4 at start, decay on later cycles | Learning rate |
--buffer-cycles |
30 | How much history to train on; higher = more stable, more stale |
--solver-max-total-walls |
1 | Exact endgame solving once few walls remain (see gotchas) |
runs/models_<name>/training_stats.csv gets one row per cycle. The columns actually worth
watching:
value_accuracy— how often the value head's sign matches the game result. The single most interpretable learning signal.loss_policy/loss_value— but note these are in-sample on a moving buffer, so they measure fit to current self-play, not strength. Falling loss is not proof of improvement.selfplay_time_s/train_time_s/cumulative_time_s— throughput and total compute.
Console output is teed to runs/logs/. Per-game statistics (game length, walls placed, win
balance) only appear in runs/logs/selfplay_*.log, not the CSV, because self-play happens in
a subprocess.
Loss curves do not tell you if the engine got stronger — and there's no promotion gate here,
so best.pt just means "latest". The real measure is checkpoints playing each other:
# One-off round robin over a lineage's checkpoints
python tournament_cpp.py --dir runs/models_myrun/checkpoints --games 100 --sims 800 --temp 0.3For tracking progress over a long run, use a series — each version reuses the previous version's games, so adding a checkpoint only plays the genuinely new pairings:
python tournament_cpp.py --series myrun --add runs/models_myrun/checkpoints/cycle_0100.ptIt infers everything else (roster, games/pair, rules config, output path, which games to reuse)
from the previous version in runs/tournaments/myrun/. Elo is a Bradley-Terry fit over all
results.
Sanity-check search value too — the same weights at different budgets should show a clear ladder:
python tournament_cpp.py --model runs/models_myrun/best.pt --sim 100 --sim 800 --sim 2000 --games 100 --temp 0The training loop knows nothing about Quoridor. What's game-specific is the rules engine, the action encoding, and the input planes.
Rules exist twice: game.py and cpp/engine.hpp — a from-scratch C++ reimplementation with
bitsets and Zobrist hashing, not a translation.
Training and tournaments run entirely on the C++ side — nothing in the production loop touches
the Python engine. It still backs app.py, the eval scripts, and the parity test, but it is not
what generates training data.
That makes it the one to read first if you're trying to understand the rules or port them. Not
because it's smaller — game.py is twice the length of cpp/engine.hpp — but because it says
what it means: plain data structures and readable path-finding, where the C++ is bitsets,
incremental Zobrist hashing and cached path edges written for speed.
tests/test_cpp_parity.py is the contract between them. It plays identical random games through
both, and every ply asserts they agree on: the legal action set, all 8 NN input planes,
terminal status, and the winner — across 7×7, 9×9 and 5×5. Change a rule in one engine
and forget the other, and this fails on the first divergent ply.
Run it after every rules change. A silent mismatch means self-play generates data under different rules than your reference — the kind of bug that costs a training run.
Every move is an integer index, and both engines must agree on the mapping:
action_space_size(N) = 8 + 2 * (N - 1)**2 # game.py:986action_space(N) = 8 + 2 * (N - 1) * (N - 1); // cpp/engine.hpp:458 = the pawn directions (4 orthogonal + 4 diagonal go-arounds; straight jumps reuse the
orthogonal index). 2 * (N-1)² = wall placements: an (N-1)×(N-1) anchor grid, × 2 orientations,
horizontal block first, row-major. 9×9 → 136 actions.
Python uses action_to_index / index_to_action (game.py); the C++ side inlines the same
arithmetic in gen_legal / apply.
Input is (B, 8, N, N): my pawn, opponent pawn, horizontal walls, vertical walls, my walls left,
opponent walls left, BFS distance-to-my-goal, BFS distance-to-opponent-goal. Built by
State.to_nn_input() (game.py) and mirrored exactly by GameState::nn_input()
(cpp/engine.hpp).
Output is (policy_logits, value) — raw logits over the full action space (softmax happens later,
over legal moves only) and a tanh value from the side-to-move's perspective.
Same game, different board size — most of the stack is already N-parametric:
MAXNincpp/engine.hpp(currently 9) — fixed-size arrays are sized from it.- The
boardsize must be odd and <= 9guard (in all three engines). - Defaults in
game.py,dual_network.py,cpp/bindings.cpp,docs/game.js. - The hardcoded 200-ply draw limit — must change in both engines together or parity breaks.
- Add the size to the parity test's list and re-run it.
- Retrain — checkpoints are board-size-specific.
Odd sizes only, currently: starting columns are N/2.
A genuinely different game — work in dependency order: game.py → cpp/engine.hpp →
cpp/bindings.cpp (the literal 8 appears in several array shapes) → dual_network.py
(IN_CHANNELS, and the policy head's pawn/wall split is Quoridor-shaped) → mcts.py, train.py,
cpp/selfplay.hpp → export_onnx.py → the JS engine in docs/. Rewrite the parity test's
expectations alongside.
One thing to get right from the start: the board is always shown to the network from the
side-to-move's perspective, so for player 2 it is flipped. The policy has to be flipped the same
way, everywhere — training targets, serving, self-play, and the JS frontend. If those disagree
the model just plays way worse without anything erroring.
tests/test_canon_consistency.py checks it.
SigmaQuoridor/
│
├─ Core engine ─────────────────────────────────────────────────────────────
│ game.py Readable Python rules: State, legal moves, BFS, action encoding.
│ The reference the C++ is checked against — start here.
│ mcts.py PUCT MCTS against a pluggable Evaluator (network or random rollout).
│ Python-only; the training loop uses the C++ search instead.
│ dual_network.py Policy+value ResNet, NNEvaluator, self-describing save/load
│ cpp/ ★ What actually runs during training — a second, from-scratch engine
│ (pybind11), kept in parity with game.py
│ ├─ engine.hpp rules, BFS, Zobrist hashing, action encoding
│ ├─ selfplay.hpp SelfPlayManager: leaf-parallel MCTS, GIL-free worker threads
│ ├─ tournament.hpp TournamentManager: cross-play games
│ ├─ alphabeta.hpp exact endgame solver
│ └─ bindings.cpp pybind11 module definition
│ setup_cpp.py Builds the above into quoridor_cpp*.pyd / .so
│
├─ Training ────────────────────────────────────────────────────────────────
│ cpp_train_loop.py ★ Production entry point: alternates self-play and training
│ selfplay_cpp.py Self-play data generation only -> cycle_NNNN.npz
│ train.py Training half (--train-only), lineage bootstrap, stats CSV
│ supervised_train.py Supervised training directly on recorded self-play data
│
├─ Evaluation ──────────────────────────────────────────────────────────────
│ tournament_cpp.py ★ C++ round-robin Elo; --series for incremental tracking
│ tournament.py Pure-Python equivalent; supplies the Bradley-Terry Elo solver
│ benchmark_agents.py Baseline opponents: Random, GreedyDistance, Minimax, RawPolicy
│ eval_*.py Ad-hoc evaluation scripts
│
├─ Serving ─────────────────────────────────────────────────────────────────
│ app.py Flask server + JSON API for local play
│ export_onnx.py Exports .pt checkpoints to ONNX for the web frontend
│ static/ Frontend served by app.py
│ docs/ GitHub Pages site — a third engine implementation, in JavaScript
│ ├─ index.html UI, board renderer, per-side agent pickers, timeline, analysis panel
│ ├─ game.js JS port of game.py
│ └─ mcts_worker.js Web Worker: MCTS + onnxruntime-web inference (one per checkpoint)
│
├─ Tests & tooling ─────────────────────────────────────────────────────────
│ tests/ Verification CLIs, run directly (no pytest)
│ ├─ test_cpp_parity.py ★ C++ engine vs Python reference
│ ├─ test_canon_consistency.py player-2 policy flip consistency
│ └─ test_head_redesign.py network head variants
│ tools/ One-off analysis/debug/setup scripts (not maintained pipeline)
│
└─ runs/ ALL generated artifacts (gitignored except results)
├─ models_<name>/ best.pt, checkpoints/cycle_NNNN.pt, training_stats.csv
├─ data_<name>/ self-play cycle_NNNN.npz (gitignored — regenerable, GBs)
├─ tournaments/ Elo results per series
└─ logs/ console transcripts (gitignored)
Each of tests/ and tools/ has a _bootstrap.py that puts the repo root on sys.path; a new
script there needs import _bootstrap before importing project modules.
- Run everything from the repo root. Paths are relative to the working directory.
best.ptmeans "latest", not "best". There is no promotion gate; every cycle overwrites it. Confirm with a tournament before trusting or deploying a checkpoint.- A new
cpp/*.hpphas to go intosetup_cpp.py'sdepends— otherwise editing it won't rebuild the extension and you'll test stale code (see Setup). - Model and data directories are paired. Point a script at a mismatched pair and it trains on the wrong buffer instead of erroring.
- Falling loss ≠ a stronger engine. Losses are in-sample on a moving buffer. Only head-to-head results are evidence.
- Self-play degenerating to a thin set of unique variants. You don't want your self-play to duplicate the same game trajectories within one cycle too excessively, as that would lead to the net overfitting and overall playing strength regression. Monitor that from time to time, and if needed, loosen the temperature schedule.
- The endgame solver can stall self-play.
--solver-max-total-walls 2occasionally hit 4-second timeouts that starved the search threads;1solves ~30k endgames per cycle with zero timeouts. Watch thesolver: N calls, M timeoutsline — ifMclimbs, lower the cap or the time limit. - Don't delete checkpoints that appear in a tournament series roster. Game reuse matches on model path; if the file is gone, those pairs can neither be reused nor replayed.
- Benchmark numbers are hardware-specific. Re-sweep batch/parallel/t-table/solver settings on your own machine.
CLAUDE.md— architecture notes and conventions (written for AI assistants, but it's the densest description of how the pieces fit)docs/cpp_selfplay_notes.md— benchmark history and design rationale for the C++ self-play path; read before changing itBREAKTHROUGH.md— running log of milestone results
See LICENSE.

