Playing a Game¶
A game on the board is: pick an opponent, pick a colour and a time control, let the machine set the pieces up, and play. This chapter covers what each of those means in the code — the opponent ladder, odds games, the clock, takeback, and how a game is restarted or reset.
Personalities: the opponent ladder¶
D-51 — A personality is data, not code: a named preset of existing UCI options
Context The board needs a range of opponents, from someone learning the moves to full strength, presented as named characters rather than a numeric slider.
Decision A personality is a JSON document: display metadata (name, blurb, avatar, approximate Elo) plus an EngineOptions preset and a per-move SearchLimits budget. PersonalityRegistry (kirin/src/app/personality.h) loads the builtins, then any *.json in the personalities directory, where a file whose id matches a builtin replaces it. Selecting one calls IEngine::applyOptions before the game starts.
Rejected N pinned engine binaries, one per difficulty. Every behavioural knob the engine has is already a UCI option, so presets give the full range for free, and shipping several binaries multiplies the build and the update payload. The pinned-binary path survives as an option in the schema — the engine field — for the one case presets cannot cover: a genuinely different historical build as an opponent.
Consequences Users can add opponents without recompiling, and the ladder is tunable by editing a file. It also means a personality can never do anything the engine cannot already be told to do over UCI — which is a constraint on what “personality” can mean here, and an honest one.
The seven rungs¶
The shipped ladder is seven personalities, in ascending strength. They exist twice: as loadBuiltins() in kirin/src/app/personality.cpp, so the app has content even with no files on disk, and as mirrored JSON under kirin/personalities/. The builtins load first and the directory overrides by id, so the on-screen card order is fixed in code.
Id |
Name |
Target Elo |
Nodes/move |
Skill Level |
|---|---|---|---|---|
|
Novice |
500 |
5,000 |
0 |
|
Pupil |
800 |
20,000 |
0 |
|
Scholar |
1150 |
40,000 |
1 |
|
Challenger |
1450 |
90,000 |
1 |
|
Expert |
1650 |
60,000 |
2 |
|
Master |
1900 |
150,000 |
2 |
|
Kirin |
2100 |
400,000 |
2 |
Two levers, both already in the engine. Skill Level (Chapter UCI Protocol) caps search depth at 3 / 5 / uncapped and injects $\pm$<!-- -->{=html}150 / $\pm$<!-- -->{=html}50 / 0 centipawns of noise into root move selection. A fixed node budget (go nodes) then separates the rungs within a tier, and is used rather than a time budget for the reason the SPRT harness uses it: it is reproducible and hardware-independent, so a rung plays the same on a warm Pi as on a cold one.
Warning
The listed Elos are targets, not measurements. Per-rung calibration is SPRT work (Chapter Measuring Strength) and has not been done; the numbers on the cards are design intent.
D-10 — The difficulty ladder is one shared NNUE plus a parameterized weakening layer, not one net per rung
Context Seven playable strength bands, from roughly 500 to full strength, on one board.
Decision Ship one evaluation net and derive every rung by weakening the search around it.
Rejected Per-band nets, trained to be weak. Search dominates strength: a deliberately weak net still plays strong tactical chess when given depth, so a weakening layer would be needed anyway — and then there are seven nets to retrain every time the engine improves. Maia-style human-like policy nets were also rejected, but on goals rather than cost: the target is a calibrated, believable opponent in the Chess.com-bot mould, not an imitation of how a particular rating band actually moves.
Consequences One net to train, one net to ship, one net in the OTA bundle. The weakening layer becomes the thing that has to be good, and it is where the remaining calibration work sits.
D-11 — The node budget is the primary difficulty dial; deliberate imperfect move selection supplies the low end
Context Node-limiting alone bottoms out: below roughly 1000–1200 Elo, an engine that always plays the best move it found is still too strong, because at that level opponents blunder and it does not.
Decision Nodes per move set the rung; below the floor, the engine must sometimes choose a move it knows is not best.
Rejected Node-limiting alone — it cannot reach the 400–700 band at all.
Consequences The as-built low end uses the engine’s existing coarse mechanism: uniform $\pm$<!-- -->{=html}150 cp (Skill 0) or $\pm$<!-- -->{=html}50 cp (Skill 1) noise over re-scored root moves. That mechanism is explicitly not the end state — a per-rung calibrated blunder model, which chooses believable mistakes rather than uniformly random ones, is the outstanding work behind this decision.
D-12 — The ceiling and the ladder are independent tracks — the top rung is full strength and rises with it
Context If the ladder were built from fixed artifacts, every engine improvement would invalidate it.
Decision The top rung (kirin) is defined as “the engine, unweakened, with a generous node budget”. Making the engine stronger therefore makes the top rung stronger, with no ladder work at all.
Rejected Retraining seven nets on every engine improvement, which would make strength work expensive enough to discourage it.
Consequences Engine strength work and difficulty-ladder work never block each other. The intermediate rungs’ targets stay fixed while the ceiling moves, so their node budgets are expected to drift down over time — which is a recalibration, not a redesign.
Odds games¶
Piece odds are the honest way to make a strong engine beatable: a weakened engine that hangs a rook feels broken, while queen odds feels like a real game. The engine spots the human a piece by simply not having it from move one.
This was nearly free, because the board already knew how to start a game from a known non-standard identity snapshot — exactly what review’s “play from here” needs (Chapter Match Review). buildHandicapStart (kirin/src/app/handicap.h) emits two things:
a start FEN: the standard position with one of the engine’s pieces removed and castling rights corrected — removing the a-file rook strips that side’s queenside castling;
a matching
PieceTrackersnapshot: the standard identity with that one piece already in its own storage slot.
The snapshot is the part that matters. A FEN alone would give up identity, and with it capture routing and takeback (D-25). Because the snapshot is supplied, an odds game behaves exactly like an ordinary one on the physical board. planSetup reaches a queen-odds start from the standard board in a single store operation.
Offered as Queen, Rook (a-file) and Knight (b-file), always taken from the engine’s side — the point is to weaken the machine, not the player. Available from the CLI as --odds queen|rook|knight and from the opponent screen’s Odds row.
The clock¶
GameClock (kirin/src/app/game_clock.h) is an ordinary two-sided chess clock with Fischer increment — except for one rule that only a physical board needs.
Phase |
Whose clock runs |
|---|---|
Human deciding and playing a move |
the human’s |
Engine searching |
the engine’s |
Gantry carrying the piece |
nobody’s |
Human’s turn begins |
the human’s |
A turn is charged from when a side is put on move to when its move is decided — not to when the machine has finished acting on it. completeTurn() is therefore called the instant the move is known (the human’s move detected, or the engine’s search returned), never after the gantry has reproduced it. Wall-clock time for a game consequently exceeds the time drawn from the two clocks, by design.
The alternative was charging the engine for its own gantry travel. That would make the engine’s apparent strength a function of how well the gantry is tuned — a coupling worth refusing, since it would mean a mechanical regression showed up as a chess regression.
Time controls are the familiar Lichess/Chess.com buckets, classified the same way Lichess does it (estimate a game as base $+$ 40$\times$increment seconds and bin the estimate): Bullet, Blitz, Rapid, Classical. Untimed is the default everywhere — a board in someone’s living room has no business flagging them.
The clock is pure and takes an injectable time source, so tests drive it with a fake now and never sleep. ClockInfo is emitted at turn boundaries only, never on a timer: a UI that wants a ticking display interpolates locally from runningSide, because pushing 5 Hz updates across the worker/UI thread boundary would buy nothing and break the “listeners fire on the worker” contract.
Takeback¶
GameSession::takeback() takes back two plies — the opponent’s reply and your own move — so it is your turn again in the position you had before you moved. One ply would simply hand the move back to the engine, which would play again; on a physical board, “take that back” means “let me try that again”.
It is honoured only:
on the human’s turn. A request made while the engine is thinking is applied as soon as it is your move again.
when piece identity is known. A game started from an arbitrary FEN has pieces whose identity nobody knows, so a captured piece cannot be retrieved from the right storage slot. In practice this means a standard start or a supplied identity snapshot (odds, “play from here”) —
takebackable_inkirin/src/app/game_session.h.
Each played ply is recorded with the storage slot it captured into, read from the identity tracker before the move was applied. That timing is not incidental: once the piece is off the board, its identity is unrecoverable, and without the slot a takeback cannot know which piece to bring back out of storage.
Both clocks are also snapshotted per ply, and a takeback restores them. The players get those seconds back, because on a physical board a takeback is a shared social act rather than a penalty.
The session emits TakebackInfo{plyCount, fen}, and everything accumulating moves — the move list, the recorder, the feed — truncates to that count. The undone moves did not happen.
Restart, and who sets the board up¶
D-55 — Restart prompts Auto vs Hand board reset on every use
Context Restarting mid-game means the pieces are scattered. Somebody has to put them back.
Decision ResetMode flows from the UI down: SessionBridge::restartGame(byHand) $\rightarrow$ GameSession::restart(mode) $\rightarrow$ NewGameConfig::resetMode $\rightarrow$ IBoardBackend::prepareNewGame(engineWhite, mode). Auto homes the gantry and runs the machine’s own setup routine; Hand homes only and waits for the operator, then verifies. The GUI asks which, every time.
Rejected Always auto-homing and setting up. The operator may already have reset the board by hand while the machine sat idle — in which case an auto setup is a slow, pointless routine that has to pick up 32 pieces to put them where they already are.
Consequences SimBackend ignores the mode entirely (there is nothing to set up), so the seam costs nothing off-hardware. A hand-set board is verified before play starts, which is what makes trusting the operator safe.
Two neighbouring operations are deliberately not restarts. resetBoard() physically restores the starting position without starting a game — the “reset the board” action offered once a game is over. It runs on the worker so it cannot overlap a game, and it plans from tracked identity rather than assuming every piece is parked in storage. rescan() re-reads the sensors and re-verifies the board against engine state without touching the game.
The Kirin rating¶
The board keeps a private, on-device estimate of the player’s strength (kirin/src/app/player_rating.h), exposed to QML as kirinRating. Two separate numbers, because a good tactician is not automatically a good player: play (against the engine) and puzzles.
It is not the account rating, and the two must never be conflated in the UI. The server refuses to rate a solo game and is right to — a ladder you can climb by beating a machine you configured yourself is not a ladder (Chapter Accounts, Devices and Ratings). What this number is for is narrower and more useful: giving someone who plays alone, offline, with no account, a number that moves; and aiming the opponent, which is the second job it was built for.
Glicko-2, storing the same (rating, rd, vol) triple the server’s ratings table does. The choice is about how boards are actually used: played in bursts, then left alone for three weeks. Plain Elo would treat a rusty player’s first game back as gospel; the rating deviation says “I am no longer sure”, moves fast while uncertain, and settles once it is confident.