Broadcast Mirroring

Point the board at a live tournament round — a World Championship game, a Titled Tuesday, an Olympiad board — and it plays the moves out in the living room as they happen, with nobody touching it. It needs no account, and it is the best unattended demonstration the board has.

Three assumptions that did not survive

The feature was scoped as “review’s replay path pointed at a live feed”, sharing the auth, the HTTP client and the FEN$\rightarrow$identity work. Only the last of those held.

D-82 — Broadcast needs no auth at all, and cannot share the Lichess HTTP client

Decided 2026-07-19 · Origin: docs/broadcast-docs/PLAN.md §1 · Status: in force

Context Every other Lichess feature on the board is account-scoped and goes through LichessClient.

Decision The broadcast endpoints are public — no OAuth token, no scope, and mirroring works on a board that has never been linked. BroadcastClient (kirin-gui/src/broadcast_client.h) is its own object with its own network manager, alongside the other clients rather than inside any of them, and its tests assert the absence of an authorization header rather than trusting it.

Rejected Reusing LichessClient. It is a strictly serialized queue with exactly one request in flight. A broadcast stream is a long-lived connection that never completes, so it would occupy that slot forever and starve every puzzle fetch and rating post behind it.

Consequences This is the first Lichess feature with no account story at all, so it ships without touching token handling. The cost is a third copy of a client shape the repository already had twice.

D-80 — Keep clocks=true; the tokenizer extracts [%clk] rather than skipping it

Decided 2026-07-19 · Origin: docs/broadcast-docs/PLAN.md §2 · Status: in force

Context Real broadcast movetext is full of decorations: 25. Nf3 { [%eval -0.82] [%clk 0:04:37] } 25... Ngxe3?!. The only PGN reader the project had tokenized on whitespace and aborted on the first brace.

Decision Build a real PGN reader (kirin/src/app/pgn.h): split a multi-game text into games, parse the tag block, and tokenize movetext while discarding nestable comments, recursive variations, NAGs, move numbers and the result — but extracting the clock comment.

Rejected Requesting comments=false and skipping clocks with everything else. A live clock beside each player is most of what makes a broadcast feel live. The tokenizer must tolerate annotations anyway, because the committed archive files carry them and they are the test corpus.

Consequences The reader replaced the puzzle path’s ad-hoc loop too, so there is one tokenizer with two entry points (a whole game, and a bare movetext fragment as the puzzle API sends). Running it over nine months of real archive — 27,662 games — taught two things no amount of reasoning had: 2,189 games have no moves at all (a round is published before a move is played, and that is the state a user picks a board in), and 34 have no player tags at all (operator placeholder chapters). Neither may be treated as an error.

The stream is whole games, and that is the good news

As soon as a move happens, Lichess sends the entire PGN of that game again. The feed is therefore a sequence of full snapshots, repeated.

That sounds wasteful and is in fact the design’s foundation. Broadcast relays are operated by humans typing moves at a venue, and they retroactively correct mistakes: a game that read 24. Nf3 a minute ago can read 24. Nd2 now. A delta protocol would need a retraction message. A whole-game feed just sends the corrected game, and the consumer diffs.

D-81 — Broadcast mirroring composes ReviewSession for its bidirectional cursor

Decided 2026-07-19 · Origin: docs/broadcast-docs/PLAN.md §3 · Status: in force

Context What a mirror needs from review is not forward replay — it is the ability to go backwards through a capture, correctly, on a physical board.

Decision BroadcastSession (kirin/src/app/broadcast_session.h) owns a ReviewSession and is nothing but a diff policy. On each update it finds $k$, the length of the common prefix with what is loaded, and then either appends the tail (the normal case) or, when $k$ is shorter than what is loaded, rewinds to ply $k$, truncates, and appends the corrected tail.

Rejected A fresh forward-only follower. Rewinding a physical board through a capture — routing the captured piece back out of storage and onto its square — is the hard, subtle, already-tested part. A new replayer would have had to reimplement it.

Consequences Two properties come free. Catch-up: when moves land faster than the gantry executes them, the mirror re-targets the cursor at the tip and lets jumpToPly decide, so past the step threshold it plans one direct board setup instead of an unbounded backlog of individual moves. Deep corrections are cheap for the same reason. The extraction that enabled this — appendMoves and truncateTo out of the existing open loop — kept the review tests green throughout, which is what makes it believable.

Note

truncateTo refuses when the cursor is past its target: a caller must rewind the physical board before dropping the plies it is standing in. That ordering is an invariant, not a convention.

Mirroring requires a standard start, for the reason every physical-identity feature does: a Chess960 or odds broadcast has unknown piece identities and therefore unroutable captures. A non-standard game falls back to on-screen mirroring.

Choosing a board

A round is not a list you fetch once — the stream re-sends whole games and adds boards mid-round — so boards are upserted by identity (the game URL, falling back to the player pair), never by index. A board is offerable when it is paired and standard; a game with zero moves is deliberately offerable, since following from move one is the demo, while a finished game is listed but not selectable.

The picker defaults to classical only, because a rapid game can produce moves faster than the gantry can play them. Two honest caveats, both deliberate:

  • The filter reads a human-typed free-text field. Real production values include "90 min + 30 sec", "90min/40moves+30min" and "(100min + 30sec/move)" — that last one broke the first implementation, which read the leading number and got zero from the parenthesis, so a genuinely classical event read as fast. It can only ever bias ordering; it must never gate anything.

  • It is a default, not a refusal. Nothing stops a user deliberately mirroring a blitz board; the mirror keeps up or falls behind gracefully, and finding out is their call.

Three screen rules are worth keeping: the mirroring board is not interactive (a broadcast is watched, and a board that accepts taps and silently drops them is worse than one that plainly does not); mirroring is mutually exclusive with a live game and says so up front, rather than letting the gantry start rearranging a game in progress; and clocks tick rather than sitting frozen at their last stamp, counting down for the side to move only.

Warning

The ticking clock is an approximation and is documented as one: it counts from when the move reached the board, not from when it was played at the venue, so it reads slightly high by the relay’s lag. It is bounded, it self-corrects on every move, and it is the same approximation Lichess’s own broadcast viewer makes.

Two findings from live use, both fixed and both pinned by tests. A stream can cut mid-token, and a half-received game must be withheld rather than parsed as a short game — that would read as a correction and rewind the physical board. And the “active” round list includes rounds that have not started yet (a round is published as soon as pairings exist), which return headers and then zero body bytes for hours; the client now treats response headers as the honest “connected” signal, and an upcoming round is labelled as such rather than sitting on “Connecting…” indefinitely.