Puzzles¶
Tactics puzzles are the appliance’s second mode: the board sets a position up, lights the move that created the tactic, waits for the solver, judges each try against the solution, and plays the refutation back.
PuzzleSession (kirin/src/app/puzzle_session.h) is a sibling of GameSession — the same worker-thread and listener shape, driving the same IBoardBackend — but the opponent is not an engine. It is the puzzle’s scripted solution.
Two sources, and the optional one is Lichess¶
The board ships with its own puzzles: a subset of the CC0 Lichess puzzle database, built by kirin/tools/make_puzzle_book.py and installed as a data file. The Lichess account link is a sync upgrade, never where the puzzles come from — Lichess can revoke API access at their discretion, and a living room can lose its Wi-Fi at any time, and neither may leave the owner of a chess board unable to solve a chess puzzle (see D-76).
When the board is linked, a solve from the shipped book is still reported to Lichess: the puzzle ids are Lichess’s own and the solve was unaided, so it is rating the player earned. The client persists it and flushes when the network returns.
The one-ply trap¶
Puzzles reach the board by two roads that disagree by exactly one ply. Getting this wrong makes every puzzle silently off by a move, and it is the single sharpest edge in the feature.
Source |
What it gives |
|---|---|
API ( |
No FEN at all. The game’s PGN in SAN plus |
CC0 database CSV |
A |
Both normalize to one convention (kirin/src/app/puzzle.h): fen is the position the solver faces, and solution begins with the solver’s move and alternates. Reaching it requires applying chess moves in both cases, which means an engine — and the engine’s state is global and belongs to the session’s worker thread.
That constraint shapes both formats. The shipped book is pre-reconciled at build time, so serving a puzzle on the UI thread is a pure string parse that touches no engine. The API’s puzzles arrive on a network callback on the UI thread, so parseApiBatch hands back unresolved puzzles and resolveFromPgn() finishes the job later, on the thread that owns the engine. The two file formats are deliberately different so neither can be mistaken for the other.
Aiming at the player¶
Linked, Lichess picks the difficulty (its difficulty parameter is relative to the player’s own puzzle rating). Unlinked, nothing does — so the book serves from a band around the Kirin puzzle rating (Chapter Playing a Game), which exists for exactly this.
Setting the position up¶
A puzzle position is roughly 20–25 pick-and-places from an arbitrary board. That number is what makes the next decision the difference between a feature and an unusable one.
D-28 — Never tear the board down between puzzles; plan each setup as a delta, and order a batch by setup distance
Context Each puzzle needs an arbitrary mid-game position built physically, and puzzles are solved in runs, not one at a time.
Decision PuzzleSession owns the board’s identity — it keeps a PieceTracker across puzzles, so the next setup is planned as a delta from where the pieces physically are. Within a fetched batch, puzzles are ordered by setup distance, so consecutive positions are cheap to reach.
Rejected Reset-then-build for each puzzle — 30-odd operations to tear down plus 20-odd to build, minutes per puzzle, which rules out solving a run of them entirely.
Consequences solve() deliberately does not reset the board, and any caller that moves the pieces behind the session’s back must say so via prepare() or setBoardIdentity() — an unannounced change makes every subsequent plan wrong. Batch ordering measured 241 $\rightarrow$ 78 operations on a fetched batch, a 68% saving. What one pick-and-place costs in wall-clock is a bench measurement, and is the feature’s main remaining product risk.
Resolving a puzzle’s FEN into physical pieces is the identity-matching problem of Chapter Piece Identity and Physical Setup: D-27’s min-cost assignment, with promoted pieces represented by stand-ins — defaulted off for puzzles, because a stand-in is honest in a game the player promoted in and baffling in a puzzle handed to them cold.
Fair play is structural¶
Solving a Lichess puzzle on the board moves the player’s real Lichess puzzle rating, and Lichess forbids engine assistance. The board’s answer is not a setting.
PuzzleSession takes no IHintSource — not one that is switched off, one that does not exist. PuzzleLedAssist holds none either. Both are pinned by static_assert in their tests, so no such constructor can be added without the test failing to compile. PuzzleBridge consequently has nothing to offer QML: no recommendedMove property, no hint() invokable. The full reasoning is D-77.
The LED layer during a puzzle is therefore a reduced one: it shows the position, the move that created the tactic, and success/failure feedback, but never a legal-move cloud that would narrow the search for the solver.
After the attempt¶
Every finished attempt — solved or failed — is filed to PuzzleHistoryStore and appears in the review screen’s Puzzles tab (D-63). The Kirin puzzle rating updates from the result, and, when the board is linked, the solve is reported to Lichess.