Chauncey WangChess ends by rule: checkmate is checkmate, the referee is built into the move generator. Go doesn't...
Chess ends by rule: checkmate is checkmate, the referee is built into the move generator. Go doesn't work like that. A game of Go ends by agreement — both players pass, then they discuss which stones are dead, take them off the board, and count. The rules don't decide when the game is over; the players do.
Look at the two circled stones on the left board. They still have a liberty. No rule has captured them. They are also, to any human player, obviously dead — they can never make two eyes — which is why both players pass, lift them off, and count the points under them for Black. Their death is a fact about futures that were never played.
Which is a problem if your players are programs, because agreement is the one thing programs can't do — and that fact about unplayed futures is the fact every engine in this post has to manufacture for itself.
In 2012 I wrote FoolGo, a Monte-Carlo Go engine that plays about 40,000 random games per second on a 9×9 board. In my last post I compared its data structures with six other engines and found they had all converged on the same answer. This time I went looking at a question I never consciously answered when I wrote the thing: how does each engine know a game is over? A Monte-Carlo engine finishes tens of thousands of games per second, so whatever your answer is, it runs constantly, and it had better be cheap.
I had Claude Code read the sources of the same seven engines — FoolGo, GNU Go 3.8, Pachi, libego, Fuego 1.1, Leela Zero, and KataGo — and verified the load-bearing claims against the code myself. Last time everyone had converged. This time they didn't. Five different referees came back:
Start with the confession. FoolGo cannot choose to pass — a pass happens only when a player has no legal move at all, a mechanical necessity so that one side running out of moves first doesn't deadlock the loop. It also has no move limit. Strictly speaking, it doesn't have a concept of the game ending at all — only a board with no moves left on it:
// src/board/full_board.h
bool FullBoard<BOARD_LEN>::IsEnd() const {
return is_end_ || (PlayableIndexBitSet(Force::BLACK_FORCE).none()
&& PlayableIndexBitSet(Force::WHITE_FORCE).none());
}
A playout ends when neither color has a single playable point left. Each side keeps a bitset of playable points; stones fill the board until both bitsets are empty. That's the whole referee.
It's the same game as the opening figure — but where the humans stopped and agreed, FoolGo kept going. The invasion they lifted off the board by agreement, it captured and refilled en route. Same verdict on the dead stones, computed the slow way.
Why does that terminate? Because of the one thing a random player refuses to do: fill its own eyes. FoolGo marks a point as a real eye when all four orthogonal neighbors are friendly and enough diagonals are controlled — the threshold is a three-entry lookup table:
// src/board/full_board.h — diagonals required: center 3, edge 2, corner 1
static const PositionIndex TABLE[3] = { 3, 2, 1 };
auto state = calculator.CentralOrEdgeOrCorner(position);
if (TABLE[state] <= piece_or_eye_count) {
SetRealEyeAsTrue(ForceAndPositionIndex(force, indx));
}
A real eye is removed from both colors' playable bitsets: the owner won't fill it, the opponent can't legally sit in it. Every other point eventually gets a stone. Groups with two real eyes survive; everything else gets captured and refilled; the board monotonically runs out of legal moves, and IsEnd() fires. Termination isn't a rule in FoolGo — it's a theorem, and the eye test is the proof.
The theorem has a crack in it, though, and the crack has a name: seki (双活, "mutual life"). Two groups can stand in a truce where neither has two eyes, but whoever fills a shared liberty first puts their own group in atari and dies. Correct play is to leave those points alone forever — which is to say, correct play is to pass.
FoolGo can't. Its bitsets exclude only real eyes, and a seki's shared liberties look like ordinary playable points. So once the rest of the board fills up, the engine — forbidden from passing while any playable point remains — is eventually forced to fill one, put its own group in atari, and hand the opponent a capture that correct play would never allow. The exhaustion referee doesn't merely fail to recognize mutual life; it is structurally incapable of letting a truce stand. Every seki in a FoolGo playout collapses, and which side it collapses against is decided by who runs out of safe moves first — so a position whose true value is "standoff" gets evaluated, over thousands of playouts, as a weighted coin flip.
To be honest about it: this was a choice, not an oversight. Seki is rare — the large majority of games never produce one — and ignoring it bought me a referee with no pass logic, no self-atari exceptions, no special cases at all. Accept a coin flip on a rare position, and the whole design stays one IsEnd() check. 2012 me judged that a good trade, and for a 9×9 hobby engine it probably was.
The grown-up engines all knew about this trap — and, tellingly, not one of them recognizes seki by looking for it. Each solves it in the style of its own referee, a preview of everything below.
Fuego solves it with an escape hatch. Pass is a legal move at every node of its search tree, and the values do the rest: filling a seki liberty leads to positions where your group is gone, passing leads to positions where the truce holds, and the search notices which door not to open. No detector — just an exit, and a search smart enough to take it. The documentation names the trap in so many words: "in the in-tree-phase of the game, pass moves are always allowed to avoid zugzwang situations, if there is a seki on the board." And note the "if": it's motivation, not a condition. Nothing in Fuego checks whether a seki exists — the pass move is appended to every node's move list unconditionally, one line, no questions asked. The tree never knows there's a truce; it discovers, a few thousand simulations later, that every move except pass loses a group. In other words: there is no seki algorithm anywhere in Fuego — MCTS itself discovers that pass is the only move that doesn't lose.
Pachi solves it with statistics. Its playout policy refuses bad self-atari (is_bad_selfatari) — putting your own group at one liberty for no gain; the check is careful enough to still permit sacrificial self-ataris like throw-ins and nakade (deliberate sacrifices that ruin the opponent's shape or eyespace) — so the truce survives most of its random futures — and then its ownership map notices what survived: a point that stays empty in 80% of playouts earns a named verdict, PJ_SEKI. That's how a truce looks when you count futures.
And GNU Go solves it by elimination, the prover's way. Its aftermath module tries to make every friendly group invincible and remove every enemy stone; whatever resists both — stones that "cannot be removed, nor turned invincible" — is declared alive in seki. Run that on our corner: every attempt to capture the white group means filling d1 and dying, so removal fails; every attempt to give it a second eye fails too, because there's no room for one. The stones survive both procedures while belonging to neither side's territory — and that leftover gets the label GNU Go reserved for it: ALIVE_IN_SEKI. Seki isn't detected; it's what's left when killing and securing have both been tried and both failed.
Three referees, three ways of letting a truce be a truce. Mine made everyone fight to the death.
The scoring, in turn, is exactly as simple as the no-pass design allows it to be. FoolGo counts black — and only black:
// src/board/full_board.h
PositionIndex BlackRegion() const {
return black_pieces_count_ + eye_states_array_[BLACK_FORCE].RealCount();
}
Black's score is its stones plus its real eyes, as a fraction of the board; white's is defined as the complement, 1 − black_ratio. That sounds reckless until you remember what exhaustion guarantees: when IsEnd() fires, every point on the board is a stone or a real eye — nothing neutral, nothing contested. On such a board the complement of black is white, so one counter suffices. The scheme is internally consistent with the referee that feeds it — assuming, as ever, no seki.
Here's the thing though: strip away the naivety, and FoolGo's termination argument is everyone's termination argument. Every playout engine rests on the same lemma — a random player that never fills its own true eyes runs out of moves — and every one of them needs the same sub-routine: a cheap test for "is this point a real eye, or a false one?"
A false eye looks like an eye (four friendly orthogonal neighbors) but the diagonals betray it: with enough enemy stones on the diagonals, the connecting stones can be captured and the "eye" collapses. The classic heuristic is: in the center, an eye survives at most one bad diagonal; on the edge or corner, none. I found three independent implementations of that sentence — and a fourth engine that has one, but deliberately keeps it out of its playouts.
None of the four spends more than a few lines on it — and they split into exactly two schools:
{3, 2, 1} table.Three codebases converge on the same diagonal count — the same forced-answer convergence I found in the chain data structures last time. Fuego's playout is the exception that sharpens the rule: the diagonal test was never the point. The point is a cheap answer to one question — will this eye still exist after the next move? — and you can buy it statically, by counting diagonals, or dynamically, by watching for atari. Either way, the answer is the termination proof of the whole Monte-Carlo method, implemented as a neighbor count.
Pachi's version carries my favorite comment in this entire investigation, admitting the heuristic's known defeat:
/* XXX: We attempt false eye detection but we will yield false
* positives in case of http://senseis.xmp.net/?TwoHeadedDragon :-( */
FoolGo lets every playout run to structural exhaustion, uncapped — the only engine of the seven that does. (It once had a cap; a rewrite dropped it, nobody ever noticed, and the orphaned constant still sits in a header.) Everyone else wears a belt with their suspenders:
3 × board_area moves — and here's the detail I love: a playout that hits the cap is thrown away, not scored. DoOnePlayout just returns without updating the tree. An over-long game is treated as a measurement failure, not a data point.MAX_GAMELEN — 600 moves.3 * size * size with a comment explaining exactly why the cap must exist: for speed, playouts only check simple ko. A superko cycle — a position repeating after four or six moves — would loop forever, and nobody's going to spend hash lookups on full cycle detection at playout speed. The cap is the cheap insurance against the rules corner they deliberately chose not to implement.Here's what that fear looks like on a board — the classic triple ko: two eyeless groups locked together, three kos between them, both lives hanging on the fight:
How scared should anyone actually be? Not very. Of the 168,813 games played by Nihon Kiin professionals between 1924 and 2007, exactly 19 ended "no result" — the scoreboard's name for an unbreakable cycle. About one game in nine thousand. (The most famous one is also why triple ko is considered bad luck: in 1582 it suspended a game played in Oda Nobunaga's presence in Kyoto, and the next day Nobunaga was betrayed and killed.) Which explains FoolGo one more time: no cap, no superko check, nothing that could ever break this cycle — the same wager as the seki one. Accept a catastrophic failure mode on a one-in-nine-thousand position, and the referee stays one line long.
Fuego adds one more mechanism, my favorite name in the whole codebase — the mercy rule:
// gouct/GoUctGlobalSearch.h
m_mercyRuleThreshold = static_cast<int>(0.3 * size * size);
It keeps a running stone-difference counter, updated by captures after every playout move. The moment one side is ahead by 30% of the board — 102 stones on 19×19 — the playout stops and is scored as a certain win. No need to play the last 200 moves of a massacre. The game is over long before it's over, and Fuego is the only engine honest enough to write that down as a rule.
Once a playout ends, someone has to count. The engines' scorers are a study in how much correctness you can trade for speed when you're only feeding a win-rate estimator.
libego's playout scorer doesn't flood-fill territory at all. It counts stones, then makes one assumption: any empty point left on a finished board must be an eye of whoever surrounds it — because playouts fill everything else:
// board.cpp — PlayoutScore(): stones + one pass over the empties
int RawBoard::EyeScore (Vertex v) const {
return
nbr_cnt[v].player_cnt_is_max (Player::Black ()) -
nbr_cnt[v].player_cnt_is_max (Player::White ());
}
No dame arbitration, no seki handling, O(area), branch-light. The exact scorer exists in the codebase too: Tromp-Taylor, the computer-friendly statement of Go's rules — every stone counts as it stands, nothing is ever judged dead, and each empty region is flood-filled to see whose stones it touches: one color's only, it's their territory; both colors', it's nobody's. That scorer is reserved for the search tree, where positions end in passes rather than exhaustion and empty regions can still be large.
Here's the whole rule on one board:
Fuego makes that same split explicit with two scorers and a selector: if the two passes happened inside the playout (board full, position clean), use the fast ScoreSimpleEndPosition, whose per-point helper literally asserts there are no empty neighbors left to worry about. If the passes happened in the search tree (real-game-shaped position, open regions), use the flood-filling TrompTaylorScore. Where the passes happened tells you which scorer you're allowed to afford.
And FoolGo? Black's stones plus black's eyes, divided by 81, white gets the complement — which, on an exhaustion-finished board, is exact area counting.
Pachi's answer to the dead-stone problem is the most Monte-Carlo idea in this whole story: don't judge — count.
Every finished playout deposits its per-point winner into an ownermap — a per-intersection tally of who owned that point when the game ended. Play a few thousand random futures, and life and death becomes a frequency:
/* ownermap.c — a point's status, judged by its futures */
if (n >= total * thres) return PJ_SEKI; /* stays empty 80% of the time */
else if (n + b >= total * thres) return PJ_BLACK;
else if (n + w >= total * thres) return PJ_WHITE;
else return PJ_UNKNOWN;
A group is declared dead when the enemy ends up owning its points in at least 67% of playouts. Seki — the mutual-life stalemate that breaks naive scorers — falls out for free: it's a point that stays empty in 80% of futures, because neither side can afford to fill it — exactly what the frequency bars showed at d1, back in the seki section. Nobody wrote a seki detector. The statistics are the seki detector.
And here is the same tally meeting a harder version of the question this post opened with — the corner invasion unanswered, reinforced by a fresh white stone at h7, with black pressing from below at h5:
One measured footnote on that 73%: remove the black stone at h5 from that board and the tally slides to 65% — under the bar — and the verdict flips from dead to withheld. Pachi has furniture for exactly that case: ownermap_dead_groups files every group into one of two queues — dead or unclear — and board_position_final refuses to call the position finished while anything sits in the unclear queue: the game simply continues until the futures agree. The verdict isn't a boolean; it's a threshold crossing, and the referee knows which side it's standing on. (Both numbers come from my own uniform-random referee; Pachi's production playouts add patterns and capture heuristics — sharper futures, sharper verdicts.)
What sold me is how far Pachi trusts this. The GTP command final_status_list dead — the protocol question "which stones do we remove before counting?" — is answered by seeding the ownermap with at least 500 fresh playouts and reading death off the frequencies. Even Pachi's decision to pass in a real game is gated on the ownermap: it passes only when the opponent has passed, the ownership-based score estimate says it's winning, and the map says the position is final. The referee, the scorer, and the "is it safe to stop?" instinct are all the same object: a histogram.
GNU Go is the classical engine in the group — pre-Monte-Carlo, pure knowledge — and its referee is philosophically opposite. It doesn't even have a pass rule. It has an accountant:
/* engine/genmove.c */
move = PASS_MOVE;
*value = 0.0;
Every candidate move gets a value in estimated points. If the best value on the whole board is not strictly positive — after escalating through endgame patterns, re-examined capturing races, and finally dame worth a single point — the move stays PASS_MOVE. GNU Go passes when the ledger is empty. Passing isn't a decision; it's what's left when arithmetic finds nothing worth one point.
I watched it happen. GNU Go 3.8 still compiles, and fed the opening position over GTP, it prices the board out loud:
Deeper judgments get the full symbolic treatment — a dedicated life-and-death search (the owl code) stamps each group DEAD, CRITICAL, or ALIVE — but here is the confession: ask GNU Go to actually score a finished game, and it sets every symbolic verdict aside, plays the position out with one careful, deterministic playout (threats priced at zero), and reads life and death off the finished board. The reasoning engine's most trusted referee is a Monte-Carlo sample of size one — and the module that does it is named aftermath, which exists, per its own header comment, "to robustly determine life and death status": the proofs weren't robust enough to bet the score on. Run live on the opening position, the whole apparatus names exactly two dead stones — h9 and i9 — and returns B+12.0: the humans' verdict, reached by one careful playout instead of a handshake. (One point shy of the area count from the fiat figure only because GNU Go defaults to territory scoring, Japanese-style, while Tromp-Taylor counts area, Chinese-style — a parity quirk between the two systems, not a disagreement about life.)
Leela Zero — the AlphaZero-style engine — solved the dead-stone problem by deleting it:
// FastBoard.cpp — Needed for scoring passed out games not in MC playouts
float FastBoard::area_score(const float komi) const {
auto white = calc_reach_color(WHITE);
auto black = calc_reach_color(BLACK);
return black - white - komi;
}
That's Tromp-Taylor area scoring where the flood-fill is seeded from every stone on the board, unconditionally. There is no dead-stone adjudication anywhere in the engine. If a stone is on the board when the game ends, it is alive, and it counts — even if it's a hopeless prisoner sitting in your territory.
Here's what that fiat does to the position this post opened with — the one where the humans agreed the invasion was dead:
The two "dead" stones aren't just alive — they poison everything that can reach them. Twenty-nine points of Black's territory become no-man's-land, and a game Black won by 13 becomes a game White wins by 20. Mercy, under this referee, costs more than the mercy was worth.
This isn't an oversight; it's the training contract. The scorer's rule is pushed onto the player: if you want your opponent's dead stones to not count, capture them before you pass. The network learns to physically clean the board because the referee will not do it any favors. The judgment that GNU Go performs with a specialized search module, and Pachi with a histogram, Leela Zero absorbs into the policy network's weights.
KataGo looked at all of the above and did the most engineer thing possible: it treated "when is the game over, and what does it score" as a first-class rules-engineering problem. Its Rules object has knobs the other six engines don't even have vocabulary for: area vs territory scoring, a group tax (every living group forfeits its two eye points — the ancient Chinese 还棋头, surviving as an enum value), a button (half-point to whoever passes first, buying area rules the fine endgame incentives of territory rules), and spight-style single-pass endings under certain ko rules. And one more, wonderfully: friendlyPassOk. Under area scoring an uncaptured dead stone counts against you — the fiat figure showed the bill — so a strict engine must capture everything before daring to pass. This flag tells KataGo whether the opponent can be trusted to agree about dead stones after the passes; that is, whether the human ending is available. The problem this post opened with, shipped as a boolean.
But the crown jewel is the referee that can't be wrong. KataGo implements Benson's algorithm — the 1976 result that identifies groups that are unconditionally alive: alive even if their owner passes forever. (Stronger than what club players usually mean by the phrase — alive even if the opponent moves first — Benson's sense allows the owner no answering moves at all.) Pass-alive is not a heuristic, a frequency, or a search verdict; it's a fixpoint computation with a proof:
// game/board.cpp — https://senseis.xmp.net/?BensonsAlgorithm
// Walk all player heads and kill them if they haven't
// accumulated at least 2 vital liberties
while(true) {
...
if(vitalCountByPlaHead[plaHead] < 2) {
plaHasBeenKilled[i] = true;
...
}
Here's the distinction the theorem draws, on a board — two black groups any club player would call alive:
The bottom-left group's two eyes are each entirely walled by the chain — both vital, in Benson's vocabulary — and the fixpoint keeps it: no sequence of White moves, of any length, can ever capture it. The top-right group has more eyespace and less life: the corner point of its big eye touches only the eyespace, never the chain, and that single non-liberty is both why the region isn't vital and how the kill works if Black never responds: White fills the three real liberties while breathing on exactly that point, then takes the last eye as a capture. In a real game Black defends trivially, which is the whole distinction: one group's life is a theorem, the other's is a promise to keep answering. Benson's algorithm certifies only theorems.
And it's used exactly the way a theorem should be. During self-play, after every single move, KataGo checks: does every point on the board now resolve to a pass-alive owner? If yes, the game ends immediately — no passes needed, the outcome is provably settled. If even one point is unproven, the check bails and play continues:
// game/boardhistory.cpp — endGameIfAllPassAlive
if(area[loc] == C_WHITE) boardScore += 1;
else if(area[loc] == C_BLACK) boardScore -= 1;
else return; // one unproven point → keep playing
For everything Benson can't prove — the ordinary dead stones of an ordinary human game — KataGo falls back to a learned judge: the search's visit-averaged ownership map, thresholded (a chain is alive only if its average ownership is safely its own color, and no point of it is badly contested). Which is to say: KataGo's referee is a theorem where a theorem is possible, and Pachi's histogram — upgraded from playout frequencies to neural predictions — everywhere else.
So: the same question, seven codebases, five answers. (Five philosophies, not five disjoint mechanisms — KataGo layers three of them, and GNU Go's confession shows reasoning leaning on exhaustion when the score is on the line.)
| Referee | Engine(s) | "The game is over when..." | Dead stones are... |
|---|---|---|---|
| Exhaustion | FoolGo, libego, Fuego | the board runs out of legal non-eye moves | whatever got captured along the way |
| Statistics | Pachi | the ownership histogram says the position is final | stones the enemy owns in ≥67% of futures |
| Reasoning | GNU Go | no move on the board is worth a single point | what the owl search proves is dead |
| Fiat | Leela Zero | two passes — and the scorer believes the board as-is | nothing; kill them yourself before passing |
| Mathematics | KataGo | every point is provably pass-alive (or the rules say so) | Benson-unprovable chains the ownership net condemns |
Last time, seven engines converged on one data structure, and the lesson was that the problem forces the answer. This time the same seven engines diverged into five referees, and I think the lesson is the mirror image: the board doesn't force an answer, because the board genuinely doesn't contain one. "Is this group dead?" is a question about futures that haven't been played, and each era answered it with whatever epistemology it had — exhaustive filling in 2012, statistics in the playout era, symbolic proof in the classical era, learned judgment after AlphaZero, and, in KataGo, an honest layering of all of them with a theorem at the bottom.
Grouped one level up, though, the five referees are really two. Pachi, GNU Go, and KataGo adjudicate death — by histogram, by owl proof, by theorem plus a trained judge. The exhaustion engines and Leela Zero refuse to adjudicate at all: they insist death be made physical — the corpse gets captured, by the playout or by you, or it isn't a corpse. Every referee makes death factual before it counts. The one thing no engine does is the human thing from the first figure: leave the dead stones on the board and agree. Agreement was never on the menu — so half of them replaced it with violence, and the other half with a courtroom.
FoolGo's referee asked no questions at all. It just waited for the board to run out of answers — no voluntary pass, no move cap, and a scoring function that counted one side and inferred the other. I built the degenerate case of the idea everyone else spent a decade refining. But it terminated, forty thousand times a second, for the same underlying reason every playout in this story terminates: a random player who refuses to fill his own eyes eventually has nothing left to say. Even the fool stops playing when the board is full.
Every code excerpt above is from the engines' actual sources: FoolGo (github), GNU Go 3.8, Pachi, libego, Fuego 1.1, Leela Zero, KataGo — read with Claude Code, with the quotable claims re-checked by hand. If you find a misreading, tell me — the previous post survived three rounds of adversarial review and I'd like this one to earn the same.