¹ Line counts sum every tracked .ts and .tsx file at the published commit, split into test and app lines.
One part of the entire system is not deterministic. Everything else is arithmetic over stored facts.
chess.js decides legality.
Stockfish decides truth: how much better a position is, whether a forced mate exists, and the best line.
Maia decides what a human of a given rating would actually play.
The annotator decides what mattered: verdict tiers, turning points, conversion failures, game phase.
Claude only puts it into words.
From the spec: code computes every chess fact, award and schedule; the model only phrases them.
Four texts reach me. Code writes two of them from templates filled with stored facts: the hint ladder under the board and the debrief after the game. The coach writes the other two: its chat replies and the one-line note under the hint ladder. Those are the only sentences a model composes.
Writes its chat replies and the note under the hint ladder, from facts it is handed.
The only part that is not deterministic. Recommended setting: Claude, through the Anthropic Agent SDK.
Its words are checked before you see them; which checks run depends on where it speaks (see §08).
A failed reply is retried once, then replaced by a canned reply.
If the debrief says something false, the bug is in my code, not in a model: the annotator never calls one, so it cannot make up a fact. Where the coach writes, its words are checked before I see them (§08).
Stockfish scores positions. Maia plays mallow the way a person at my chosen rating would. They run side by side, neither reads the other, and each sits behind its own interface, so either can be swapped.
From the spec: opponents that play human-like at every level up to Maia's 1900 cap.
Stockfish searches ahead through the tree of possible moves and scores each position with a small neural network trained on millions of positions. It plays the strongest chess it can find and has no notion of what a person would do. Here it is the source of truth for the judge, the hints and the debrief.
lc0 plays the opponent using Maia weights, a trained weights file rather than a separate engine, across nine bands from 1100 to 1900. lc0 learned chess by playing itself. Maia uses the same kind of network, trained on human games from Lichess instead, one model per rating band, so it predicts the move a player at that rating would make, mistakes included. It runs with search off, one node per move.
At lc0's default temperature of 0, Maia always plays its single most likely move, which plays stronger than its band; at 1.0 it samples moves the way players at that rating do. And every query starts with a new-game reset, because a warmed engine could otherwise return the same move call after call.
How long each search runs (milliseconds of engine time)
| Search | Time | Why |
|---|---|---|
| the judge, on every move I consider | 350 ms, run twice: before and after the move | holds the verdict under two seconds |
| a hint, when I ask for one | 1,500 ms search, then a 500 ms check that the move holds up; 3,000 ms retry if the check fails | a hint must never be a bad move; hints from the judge's quick score nearly lost me a playtest game |
This is everything the coach model gets: a typed fact list, not a game history. Two assembly paths build it, and both skip the live evaluator, so the coach never waits in the judge's Stockfish queue.
currentFen the live boardtoMove whose turn it isoccupancy every piece, every squarelegalSans every legal movecontested attackers and defendersgameSans every move playedallowedSans what may be namedside whose move it was, written on the row when the move is madeperPlyAnalysis eval, best, line, phasehintFindings what the hint search already found and verified for this positionread the eval, as wordsgap played versus best, in wordscontestedAfterBest which squares are attacked and defended once the best move is playedcandidateLine a move I name in chat but have not played gets its own checked engine line, instead of a guesspendingMove the piece I picked uphintFocus the open hint, verbatimturningPointFocus the card I clickedhighlightedPlies moves I flaggedrecentHints the deep hints I was shown this game, oldest first, at most eightturningPoints ply, move, labelfocusPosition the board as it was thenstatus live or finishedoutcome result, winner, howstandingNotes the newest 10 notes I asked the coach to recordFields added after launch, by the day each reached the code:
hintFindings Aug 2, 2026recentHints Aug 29, 2026side Sep 1, 2026candidateLine Sep 8, 2026contestedAfterBest Sep 21, 2026Aug 2, 2026: gap words. One phrase, “a bit better”, covered every position edge from 60 to 299 centipawns, so the coach said a better move existed when the two were virtually the same.
Chat replies and the note under the hint ladder.
A fact, or anything back into the store.
Engine scores reach the model as feedback words, measured against the best move, never as numbers, because a coach that says "plus 144" to a beginner is useless.
The coach talks to one interface, and four backends sit behind it: Claude through the Anthropic Agent SDK by default, the Claude command-line tool, a local model through Ollama, and canned replies.
From the spec: the game never depends on a subscription to run; a local model sits behind the same interface.
I built the interface before there was a speed problem. By Jul 21, 2026, 13 of 14 live nudges had missed their 15-second budget on the Claude command-line tool, because starting a process ate most of each call. The fix, the same day, was another backend behind the same interface, Claude through the Anthropic Agent SDK, which keeps one process warm; nothing downstream changed. Every addition since has been an optional parameter, so the other backends needed no edits. The Agent SDK backend is the only one I have optimized: streaming, prompt caching and the thinking setting exist only there.
Reply time before and after the Agent SDK backend (seconds, one reply)
| Reply | Before | After |
|---|---|---|
| the note under the hint ladder | 9 s | 3.6 s |
| a chat reply | about 9 s | about 7 s |
The worst bug here was plumbing, not the model. Two surfaces search the same position: the judge, on every move I consider, and the hint, only when I ask (their search times are in §02).
From the spec: a chat answer is grounded in engine facts, and thumbs-down captures why it missed.
| Time | Game 167, move 19, 2026-08-02 -- every row from the database |
|---|---|
| 11:18 | The hint engine found a forced mate, verified it, and showed it to me on screen. |
| 11:21 | I asked the coach about it. It timed out and served a canned template. |
| 11:22 | I asked again. It replied: "no mate line for this position in the facts i've got." |
"The hint said that there's a forced mate that's available if I move my knight to G5. When I tried to ask the coach about it, the coach couldn't figure it out and gave a timed-out answer. Now, even after feeding it to the coach and telling it to check the chess facts from stockfish, the coach is claiming that the chess brain has not found a forced mate, but I know that it has."
The cause? The hint computed the mate, used the score to pick which piece to point at, then threw it away at the return. The mate distance was never carried. The coach's facts had ten categories and zero mentions of mate. The mate checker's set of true mate distances was empty, so nothing even blocked a mate claim. The model correctly refused to invent one.
The coach and the hint answer from one search. The hint's verified findings are kept per position and reach the coach only on an exact position match. For a position the hint has not searched, or a move I name in chat but have not played, the coach runs that same verified search first.
More detail on how the hint ladder and the coach work together: The hint ladder and the coach.
Before any model call, code decides: this position, or chess in general?
From the spec: when I do not understand why a move was wrong, I ask in plain English and get an answer at my level.
It began as a thumbs-down: "will only answer about moves i already did, not general chess questions for strategy next game." The refusal came from a line in the prompt, not a check, so the decision moved into code, before the call. Letting the model route itself was the failure I had already hit.
Board is the default because 38 of the 65 board questions I asked carry no signal a router can detect: "is this ok", "should i take it", a bare "yes". Sent to general, each would be answered without the position.
When a hint is showing, an "ask about this" button opens the chat pinned to that hint. That pin is what the first question in the flowchart checks, and a yes goes straight to board.
Both routes think at the same low setting; they differ in what the coach is handed and what it may draw on.
board: every fact about this position; the answer stays inside those facts
“Why is King to H1 a good idea?” (Aug 28)
general: four facts and no position; the answer comes from chess knowledge, tied to this game only when the connection is true
“What are good opening strategies?” (Sep 4)
The four tiers are read from the router’s code.
On Aug 3, 2026, before merging, a read-only tool replayed the old and new routing over 102 of my real messages. Zero board questions flipped to general.
An iterative process. Each time the coach or the game review got something wrong, I handed the coach a fact the code already held, or added a check on what it said. The timeline starts in July, about when I launched the game.
| Date | What it said | Missing fact or check |
|---|---|---|
| Jul 21 | Described my own pending move as mallow's, and told me I'd win my own queen for free. | Whose turn it is |
| Jul 22 | Said my pawn on e4 did not guard f5. It does. That exact trade happened a few moves later. | The attacked-and-defended map |
| Jul 22 | 7.5% of its statements about where pieces sat were false. Nothing checked them. | Occupancy, and a checker |
| Sep 21 | Threw out 14 true answers in one game, because the check compared where a piece would stand after the move to the board from before it. | The check reads every board the coach was shown, not only the current one |
| Sep 21 | Described a rook attacking a square it had no line to. Nothing checked attacks or captures. | A check on what can capture or attack what |
None of the July additions needed a bigger model. They needed facts the code already had. With them, placement errors fell from 7.5% to zero and explanation latency from 13-15 seconds to about 4, on the small model and the large one alike. In September the gap was in the checks: one compared answers to the wrong board, and one kind of claim had no check. Both are fixed: after a move, true answers wrongly rejected went from 65 of 144 to 0.
Nine checks, and which run depends on where the coach writes: board chat, general strategy chat, or the note under the hint ladder.
From the spec's ship gates: zero invented moves; a failed fact-check pauses the coach, never the game.
A failed check gets one corrective retry. A second failure serves a canned reply, labelled with the reason, that asks me to try again or put it another way.
The note under the hint ladder is about one thing: the move I have picked up and am deciding whether to confirm. So its square list is strict: every square it names must come from its facts, the threat and the recommended move, and it cannot drift to pieces outside that move. Chat is meant to range wider, into what happens next and what else to consider, so it runs the placement check instead of a closed list.
i couldn't get that one clean. ask me again and i'll come at it from a different angle.
garbledthat one took me longer than i had. ask me again and i'll get you an answer.
slowi can't reach my thinking right now. try me again in a moment.
offlinecookie needs you signed in to Claude Code. you can still play, get hints, and read every debrief. run claude in Terminal, sign in, then restart with npm run dev.
not set upkeep it on the board. ask me about a move from this game and i'll break it down.
Check times from a July benchmark. Retry share from the 290 model-written replies stored from Jul 18 to Sep 22.
Limit: the check times were measured once, in July.
So adding a check is free. The cost is the retry, which is why the correction for mallow's move quality fixes the wording in place instead of asking for one. A check that rejects true answers costs the same way: in one September game, 14 of the placement check's 16 rejections threw out true answers (§09).
I do not wait for new games to find out whether a fix worked. A fix runs against every position already stored, before and after the change, and then I break it on purpose to prove the harness can tell the difference.
From the spec: a red flag is something to fix, not a feature to cut, and diagnosis starts from my thumbs-down notes and the traces.
In game 198 the check on where pieces stand rejected 16 coach answers. 14 were true statements about the position after the move being explained; the check had compared them to the board before it. The easy fix was to delete the check. I kept it and taught it which boards the coach was talking about, and it still catches the 2 answers that were wrong.
A fix is checked on saved games in four steps before I play again, and the last step puts the bug back on purpose to prove the harness catches it.
The 201 coach conversations and 34 equally fast mates come from my games; the 4,772 true and 2,243 false statements about them are generated.
Limit: the labels follow the check’s own rules, so a pass shows the check does what it was built to do, not that those rules are complete.
After the fix, the check on where pieces stand wrongly rejected 0 of 144 true statements about the board after a move, down from 65, and still caught all 2,243 false ones.
Positions come from 201 of my conversations with the coach; the statements about them are generated and labelled true or false by reading each board. The last row is the 16 answers the check rejected in game 198.
Limit: the labels follow the check’s own rules, so this shows the check does what it was built to do, not that those rules are complete.
| measure | what it counts | measured on | before | after |
|---|---|---|---|---|
| false positive rate | true statements wrongly rejected | 144 generated statements about the board after a move | 45% (65 of 144) | 0% |
| false positive rate | true statements wrongly rejected | 4,772 generated true statements | 1.4% (65 of 4,772) | 0% |
| recall | false statements caught | 2,243 generated false statements | 100% | 100% |
| precision | rejections that were right | one September game’s rejections | 2 of 16 | 2 of 2 |
A check only ever seen passing proves nothing. So I broke the fix on purpose: pulling the move lines back out brought the after-move rejections straight back, and restoring them cleared them again. Four such breaks ran, each built to move one named cell of the results and nothing else.
The same method proved a second fix. In game 198 my move mated as fast as the engine's stored best move, and the review still marked it as a miss, because it compared the two by name rather than by how fast they mate. The fix treats an equally fast mate as the best move everywhere the review judges a move.
When my move mates as fast as the engine’s best, every place that judges a move counts it as the best move; the four review places were checked on all 34 such moves in my games:
The placement check accepts a claim that is true on any board the coach was shown, up to four moves down a line. A claim about the current board that only becomes true a few moves later would pass, and the "false statements caught" figures above are measured against that rule. Nothing yet measures how often the coach writes one in play.
Two independent controls, not one parameter. One turns adaptive thinking off. The other caps it at low and leaves it on.
Capping the coach at low thinking cut its median reply from 14.5 to 6.2 seconds and its p90 from 47.4 to 11.8.
From the Aug 3 eval: 119 questions asked 3 times each, 357 replies per setting; p90 is the time nine in ten replies beat.
Limit: disabled was faster still, 5.6 seconds at the median, but fell back to a canned reply most often on the hardest questions, 22.7% of the time.
On the hardest questions, low fell back to a canned reply least: 13.3%, against 18.7% for default and 22.7% for disabled.
From the same Aug 3 eval: the 357 replies per setting, sorted into three buckets by how hard the question was.
Limit: on direct fact questions low fell back 2.4% of the time, where default never did.
So why low? Difficulty, not the average. On the hardest bucket it beats both. It beats disabled in every bucket at a speed cost inside the noise, and beats default on tactical questions while running 2.3x faster on a fifth of the tokens. A blinded human read of 26 questions, columns randomized and the key held separately, gave the bounded arms 18 of 25 non-tie picks.
The first three passes each found a cause, shipped a fix, watched the median improve, and filed the rest under model variance. The fourth pass zoomed out and ran a root cause analysis, a method from total quality management: it accounted for every second of one slow call, which surfaced what the first three passes were not measuring. The same investigation used a fishbone diagram to sort a failure with several causes, where a forced mate the hint knew about never reached the chat.
| Pass | Diagnosis | Right? |
|---|---|---|
| Jul 21 | Process startup, not thinking time | Yes, and so was the fix |
| Jul 27 | Budgets are too tight | A mitigation, not a diagnosis |
| Aug 1 | Stale served code, starved retry cap, prompt size | Two right. The third was a confound |
| Aug 2 | Unbounded adaptive thinking, 1,500-4,500 invisible tokens | Named, reproduced, toggled |
One instrumented call from the Aug 2 investigation, at the low end of the slow range; first-token and wait times from the same investigation.
It hid because thinking streams with empty text by default: no log, trace or stream ever showed a thinking token. Both tells were arithmetic and sat in every call: the gap between the first token and the whole wait, and billed output tokens far larger than the visible answer.
A fix that measurably helps is the most dangerous stopping point. It validates the pass without validating the diagnosis. Zoom out and name the confounders. Build an eval for what was not being measured, so the cause that matters shows up. The obvious fix, prompt size, was already measured and would only have been a band-aid.
199 games, 3,287 moves, integrity verified by counting rows.
From the spec's guardrails: trace completeness 100%.
| Table | Rows | What it records |
|---|---|---|
sessions | 1,438 | one per sitting |
games | 199 | opponent, result, which colour I played (player_color) |
moves | 3,287 | both sides, with the engine's eval attached and whose move it was (side) |
verdicts | 4,078 | every judged move, including retracted ones |
advice_traces | 377 | every coach call: prompt, output, the thinking setting it ran under, and which of its sentences a check looked at |
turning_points | 165 | what actually swung each game |
chat_messages | 377 | my questions, and only replies that passed their checks |
game_events | 1,606 | hints and overrides |
mode_timers | 426 | screen time |
coach_notes | 6 | notes I ask the coach to record; the newest 10 go into every chat prompt |
Verdicts outnumber moves: every move I considered and retracted was judged and recorded too. Of the chat messages, 201 are mine and 176 are the coach's. There is no migration folder and no version number: the code declares every column each table should have, diffs that against the live database at startup, and adds what is missing.
An agent debugging the coach reads the game's state as data, from three endpoints, four command-line tools and the page itself, without a screenshot.
From the spec: a red flag is diagnosed from the traces, not cut.
| Endpoint | What it lets you see |
|---|---|
/api/health | whether the server is up, and when the process started, the one reliable sign of which code it is running |
/api/game/:id/state | the live board, the move count, whose turn it is, the result, the last verdict, and whether the coach has been switched off for that game |
/api/agent/manifest | this table, as JSON, so a tool reads the vocabulary rather than this page |
| Command | What it lets you see |
|---|---|
npm run dossier | everything the coach saw and said for one reply, on one screen; with a date, how many sentences a check looked at across every reply since then |
npm run tail | a read-only live feed of moves and coach calls as I play |
npm run scoreboard | for one game: how many coach replies were model-written or canned, how many needed a retry, my thumbs, the thinking setting, and how many sentences no check looked at |
tools/replay-trace.ts | one stored coach reply run again through today's checks, to see whether it would still be rejected |
| On the page itself | What it lets you see |
|---|---|
data-gc-fen | the board as it stands |
data-gc-side | whose turn it is |
data-gc-pending | whether a move is picked up, waiting |
data-gc-arrows | which arrows are drawn |
data-gc-hint-level | how far up the hint ladder I am |
data-gc-hint-visible | whether the hint is showing |
data-gc-trace-id | which coach reply is in view |
data-gc-postgame | whether the game is over |
data-gc-turning-count | how many turning-point cards there are |
Every coach call also records its thinking setting and which sentences a check looked at. Older rows have no thinking setting, so their retries can't be compared on it. A chat reply's count covers every check it ran; a note's covers only the defence check, so compare counts within one place, never across.
The surface grew one debugging session at a time. An agent needs what I see, what the coach was handed, and what each check decided, as data.
Girl Chess. Built 0-to-1 by directing Claude Code. Counts verified read-only against the live database, 2026-09-22. They are a snapshot: I still play into this database, so the live figures move with every game.