Match Review¶
Match review is three capabilities stacked: browse your past games, analyze them with the engine, and review them on the physical board — scrub to any position in a past game, have the machine arrange the pieces there, and play on from it.
Two findings bounded the entire design, and everything below is downstream of them. The first is that solo games — the primary product mode — were persisted nowhere: only networked games were archived, by the server, and the live board feed is an in-memory mirror that keeps nothing. The second is that a FEN is not a valid target for a physical setup, which is Chapter Piece Identity and Physical Setup’s subject and the reason PieceTracker identity threads through everything here.
What is recorded¶
D-58 — Store the minimum — startFen plus the UCI move list — and derive everything else
Context A reviewer wants SAN, PGN, per-ply FENs, per-ply piece identity and an eval graph.
Decision GameRecord (kirin/src/app/game_record.h) stores the start position and the moves, plus provenance: id, source (solo/network), the two seats, result, reason, timestamps, device id. Everything else is recomputed by replaying.
Rejected Storing the derived forms. Derivation is deterministic, so a stored copy is the same fact recorded in several places that can disagree — and the derived forms are exactly the ones that change when the code that produces them improves.
Consequences This is what makes physical review cost nothing: the per-ply PieceTracker snapshot is derivable too, so a record written before the setup planner existed still supports being physically replayed. Records land at 1–5 KB.
GameRecorder (kirin/src/app/game_recorder.h) is a GameSessionListener that appends on onMovePlayed and finalizes on onGameOver. Attach one and every finished game is archived; attach none and nothing is. Only finished games are recorded — a game abandoned mid-play is dropped, because a review list of half-games nobody chose to finish is noise, and an incomplete record would still have to claim a result.
D-57 — The board-local store is one JSON file per game, and it is the source of truth for solo games
Context A game played with no network, no account and no linked board must still be fully recorded and reviewable.
Decision GameStore (kirin/src/app/game_store.h) writes one JSON file per game under $XDG_DATA_HOME/kirin/games. The directory listing is the index; there is no index file. Unreadable or malformed files are skipped rather than failing the listing.
Rejected SQLite on the Pi — more moving parts, a schema and a migration story, for no gain at 1–5 KB per record. Archiving from BoardFeed, which was tempting because it already sees every move of a solo game, but it is in-memory, explicitly non-authoritative, only exists while the board holds a writer socket, and never sees a fully offline game at all.
Consequences Uploading is a later, optional step (a pendingUpload flag on the record), never a precondition. One corrupt file costs you one game rather than the archive.
Analysis¶
D-59 — The board analyzes its own games; the cloud renders a precomputed artifact
Context Review wants an eval graph and per-move annotations, which means running the engine over every position of a finished game.
Decision The board does it, post-game, and uploads the finished analysis alongside the game. The web app renders the artifact and evaluates nothing.
Rejected An engine in a Cloudflare Worker. The engine is a C++ binary; a WASM port is a large lift for no benefit. The Pi, meanwhile, is idle the instant a game ends and already has the engine in-process.
Consequences No analysis queue, no serverless CPU budget, and analysis works with no account at all — an unshared solo game is still analyzed and still reviewable on the board. See D-15 for where on the board it runs, which the plan initially got wrong.
GameAnalyzer (kirin/src/app/game_analyzer.h) walks the record, evaluating each position at a fixed node budget and forced full strength regardless of the personality the game was played at — a game played on Novice still earns a full-strength review, which is the point. Nodes rather than time keeps the artifact reproducible across machines, so two boards analyzing one networked game produce identical output and an idempotent upload is a no-op rather than a race.
Per ply it emits white-relative centipawns, a mate distance, the best move, the PV and a classification (blunder / mistake / inaccuracy / best, computed from the mover’s point of view); per side, an average centipawn loss.
Note
Scores are white-relative in the artifact. The search reports side-to-move relative scores, and LinkedEngine::analyze normalizes that away exactly once. An eval graph plotted from side-to-move scores is a sawtooth of lies.
D-60 — Analysis lives in a separate table, not a column on games
Context The cloud copy needs somewhere to put a per-ply artifact that is much larger than the game row.
Decision A game_analysis table keyed by game id, carrying engine version, nodes per move, per-side ACPL, the per-ply JSON and an analysis version.
Rejected A column on games, which would fatten every list query with a payload no list ever shows.
Consequences A game can be re-analyzed — a better net, a bigger node budget — without rewriting its game row, and “not analyzed yet” is a natural, representable state rather than an empty string.
Physical review¶
ReviewSession (kirin/src/app/review_session.h) is a sibling of GameSession driving the same IBoardBackend. Opening a record replays it twice: through the engine, to encode each move and cache the per-position FENs, and through a PieceTracker, to know the exact identity — and therefore each captured piece’s storage slot — at every ply.
That second replay is what makes backward stepping one gantry operation rather than a replay. IBoardBackend::undoMove(engineMove, capturedSlot) mirrors the four execute paths in GameController:
normal — carry the piece
to$\rightarrow$from;capture — carry the piece back, then retrieve the captured piece from its storage slot onto the capture square;
castling — undo king and rook;
en passant — pawn back, and restore the captured pawn to its own square, not the target square;
promotion — the pawn never physically changed, so undo is just moving it back.
The captured slot has to be supplied by the caller: after the move, the captured piece is on no square, so the post-move tracker cannot recover which slot it went to. The review session holds the replayed identity and passes it in; the controller reverses its own tracker with the same slot, so board and tracker stay in lockstep as the reviewer scrubs.
Jumps¶
jumpToPly(n) steps incrementally when the target is within kJumpStepThreshold (3) plies, and otherwise plans a direct setup through planSetup (Chapter Piece Identity and Physical Setup). With the planner, even ply 40 $\rightarrow$ ply 2 is a handful of piece moves, so reset-and-replay-from-the-start is not a normal path at all — it survives only as a last-resort resync when identity is unknown and the plan fails.
Physical follow requires known identity, which in practice means a standard start. A record with a non-standard start FEN still reviews perfectly well on screen, with physicalReady() false.
Warning
Setting the board up from a raw arbitrary FEN — one not drawn from a recorded game — is deliberately out of scope. It is not merely harder; it is sometimes impossible, since a FEN wanting two white queens has no physical realization. If it is ever built, the planner must fail loudly or substitute a stand-in and say so. It must never silently produce a board that lies about itself.
Play from here¶
Selecting a position and starting a live game from it goes through NewGameConfig::startIdentity — the reviewed position’s PieceTracker snapshot — and IBoardBackend::prepareNewGameFromSnapshot.
The mechanism people expect is ResetMode::Hand (“the pieces are already where they belong”), and assuming it would have been a bug. Follow-on-board is off by default: a review must never move a piece unasked. Scrub to ply 20 on screen with follow off and the pieces are still standing wherever the last game left them, so Hand would have started a game against a board that is not in the position the player asked for. The backend therefore plans the board to the snapshot from wherever the pieces actually stand. An already-correct board plans to an empty plan and executes as a no-op, so the followed and unfollowed cases are one code path and resetMode is simply not consulted.
The dividend is takeback. GameSession refuses takeback from a non-standard start, because an arbitrary FEN is a board of anonymous pieces whose captures cannot be routed. A reviewed position is the one arbitrary position whose pieces can be named — so supplying the snapshot keeps takeback alive, and the takeback code needed no change at all.
The review screen¶
ReviewBridge (kirin-gui/src/review_bridge.h) lists the archive, opens a game, derives its SAN and analysis, and drives the ReviewSession. It exposes the games list, an annotated move model (SAN, quality badge, per-ply eval), the eval graph, transport controls, and the followOnBoard toggle. ChessBoard.qml is reused non-interactively — it already renders any FEN and highlights the last move.
The engine, backend and archive are the session’s, handed over by main.cpp: live play and review are mutually exclusive modes over one engine and one board (D-14).
The Puzzles tab¶
D-63 — Completed puzzles are filed in a dedicated PuzzleHistoryStore, not in GameStore
Context Finished puzzle attempts — solved and failed — are worth reviewing, and the review screen already has a viewer.
Decision A separate board-local store (kirin/src/app/puzzle_history.h). The review screen gains a Puzzles tab, and opening one synthesizes a transient GameRecord (start FEN = the solving position, moves = the solution line) to drive the same ReviewSession, so board, move list and scrub controls are reused wholesale.
Rejected Reusing GameStore. A puzzle is not a game: it starts from an arbitrary mid-game position with anonymous pieces, carries a rating and themes a game does not, and is scored solved/failed rather than won/lost/drawn. Above all it must never enter the server upload queue that GameStore feeds.
Consequences The arbitrary start propagates: physicalReady is false, so puzzle review is on-screen only — no follow-on-board and no play-from-here — and the move list is a single column numbered from the puzzle’s own ply rather than the viewer’s paired White/Black rows, which assume a standard, white-first opening. Puzzles carry no analysis, so there is no eval graph.