Networked Play

Two physical Kirin boards, connected over the internet: a move made on one board is physically played by the other board’s gantry. This part covers how that works, and it is a much smaller feature than it sounds — because the loop that plays it already existed.

The seam: an opponent is an interface

D-67 — A networked game is the existing loop with the opponent swapped for a socket

Decided 2026-07-03 · Origin: docs/networked-play-docs/PLAN.md §2 · Status: in force

Context GameSession already separates where the opponent’s move comes from (IEngine) from the physical board (IBoardBackend).

Decision NetworkOpponent (kirin/src/app/network_opponent.h) implements IEngine: its go() blocks on the transport and returns the peer’s move instead of searching. It is also a GameSessionListener, so onMovePlayed relays each locally made move back out. GameSession needed no change at all — it already treats “the opponent produced a move, play it on the board” as one path.

Rejected A second game loop for networked play. Everything hard about the physical side — captures to storage, castling, en passant, promotion, PieceTracker identity, board verification — is local to each board and already worked; a parallel loop would have had to reimplement all of it.

Consequences Every other IEngine method delegates to a wrapped inner engine, so the authoritative chess state stays consistent with the backend that shares it. Networked mode is a runtime toggle (setNetworked): turned off, go() runs a real local search and no moves are relayed. One opponent instance therefore backs both a friend game and a local AI game, which is what lets the Qt bridge flip a single game loop between the two modes rather than building a second one.

D-68 — The wire carries only UCI move strings and a few control messages

Decided 2026-07-03 · Origin: docs/networked-play-docs/PLAN.md §3 · Status: in force

Context Two boards have to agree about a game while each drives its own gantry.

Decision Moves cross as absolute UCI squares (e2e4, e7e8q), plus match-start, colour assignment, resign/draw, game-over and resync. Each board runs its own GameController and PieceTracker.

Rejected Sending board coordinates, piece identity, or capture routing. All of that is local to a board, already works, and would couple two machines whose calibration and storage state have nothing to do with each other.

Consequences Colour is symmetric and needs no coordinate transform: if I am White, my board sets enginePlaysWhite = false (the “engine” is my Black opponent) and theirs sets it true. Both drive the same absolute squares. The server assigns colour at match time.

The concrete transport is WebSocketTransport (kirin-gui/src/websocket_transport.h), which lives in the Qt layer and is injected, so no Qt reaches kirin_app. It bridges the worker thread’s blocking awaitRemoteMove() to the GUI thread’s QWebSocket through a mutex and condition variable, with a sticky end flag so a gameover or a disconnect releases a parked wait — without it, a resign leaves the worker blocked forever.

The server

D-66 — Cloudflare is the backend: Workers, one Durable Object per game, D1 and KV

Decided 2026-07-03 · Origin: docs/networked-play-docs/PLAN.md §4 · Status: in force

Context Live games need a rendezvous point that both boards can reach, plus somewhere to keep users, devices and results.

Decision Workers for the HTTP API and WebSocket routing; a GameRoom Durable Object per live game holding both sockets and the authoritative move list; D1 for relational data; KV for the ephemeral 6-digit code mapping. The company already runs Workers and owns the domain there.

Rejected The Hetzner lichess-bot VPS. It has a recurring multi-day crash — do not host live games on it. P2P with STUN/TURN: symmetric home NATs would force a relay anyway, so we would be running a relay either way, and the relay we run can also be the referee.

Consequences NAT is a non-issue: both boards open outbound WSS connections, so there is no port forwarding and no inbound surface on the appliance at all. WebSocket hibernation makes idle games cost approximately nothing, which matters at pre-revenue volume.

D-69 — Authoritative game state lives on the server; the Durable Object re-validates every move

Decided 2026-07-03 · Origin: docs/networked-play-docs/PLAN.md §5 · Status: in force

Context Each board already validates its own human move against the engine’s legal-move list, so a board cannot emit an illegal move by accident.

Decision The GameRoom DO holds the authoritative move list and re-validates each move against the authoritative position (a chess.js backstop), deciding seating, turn order and terminal states itself.

Rejected Trusting board-reported moves. Re-validation is defence in depth against a desynced or malicious client, and — more usefully in practice — it is the anchor a board resyncs to when its physical board diverges.

Consequences The DO is also where results are computed, which is what makes rating them possible. Contrast the live board feed (Chapter The Live Board Feed and Telemetry), which is explicitly a mirror and validates nothing.

D-70 — The 6-digit join code is kept permanently as the zero-friction path

Decided 2026-07-03 · Origin: docs/networked-play-docs/PLAN.md §4 · Status: in force

Context Codes were the bootstrap mechanism that let the hardware loop be built before accounts existed.

Decision Keep them forever. Host shows a code, guest types it, game starts — no account, no network identity, nothing to sign up for.

Rejected Retiring codes once accounts exist. The simplest path to a game is the one people actually use in a living room, and it is the demo.

Consequences Codes are single-use with a short TTL and rate-limited on join: a million-value space is only safe with those guards. The lifecycle (open $\rightarrow$ active $\rightarrow$ deleted) is enforced in the Worker, and a per-IP RateLimiter DO defends it.

D-71 — Track H (the hardware loop) and Track A (accounts) run in parallel, with H first

Decided 2026-07-03 · Origin: docs/networked-play-docs/PLAN.md §4 · Status: in force

Context “Accounts ship in v1” (D-65) could easily have been read as “accounts before hardware”.

Decision Build the relay and the two-board mirroring loop against a device-token plus code path first, so hardware is not gated on signup UX.

Rejected Accounts before hardware. The novel, risky, demo-defining work is the mirroring loop; account systems are well-trodden.

Consequences The two tracks meet: a game can start from a raw code (H) or from inviting a linked friend (A $\rightarrow$ H). The code path never became vestigial — see D-70.

Seating, and what a rated game means

Who is sitting at a board is decided by the Worker, never by the board. authGameSeat resolves a seat identity and the Worker forwards that to the DO in place of whatever the client claimed — and the DO is not publicly reachable, so it only ever acts on a decided identity.

Presented

Seated as

Consequence

Matching device token

dev:<deviceId>

Attributed to the device, resolves to a linked user, can be rated.

No token

anon:<opaque>

Plays normally; the game records no device and rated=0.

Wrong token

401

Never a silent downgrade.

D-73 — An unprovisioned board is seated anonymous, not rejected — but a wrong token is a 401

Decided 2026-07-16 · Origin: docs/networked-play-docs/PLAN.md §9 · Status: in force

Context Before this, the game socket took a claimed player id on trust and resolved it straight to a linked user and a rating update: knowing a board’s device id was enough to play rated games as its owner.

Decision Verify the device token against the stored hash. An unprovisioned board is seated anonymously and plays exactly as before, recording no device and rated=0. A board presenting a wrong token gets a 401 rather than a quiet downgrade — a board that believes it is authenticated must be told when it is not.

Rejected Rejecting unprovisioned boards. That would break the product’s simplest path to a game in order to protect a rating those boards could never have earned.

Consequences The dev:/anon: prefix is the load-bearing part, because it reaches both places identity matters: the relay’s seat map (so an anonymous socket cannot reclaim a verified board’s seat by naming its device id) and device attribution in the DO. This is what makes rated=1 trustworthy — it now requires two seats that each proved a device linked to two distinct accounts. An impostor naming a linked victim’s device id still plays, but the game records a null user, rated=0, and the victim’s rating is untouched.

D-74 — The device token rides the upgrade as an X-Device-Token header, not a query parameter

Decided 2026-07-16 · Origin: docs/networked-play-docs/PLAN.md §9 · Status: in force

Context The board has to present its token when opening the game (and feed) socket.

Decision A header on the upgrade request. ?token= is still accepted for the tooling that needs it.

Rejected Query-string tokens as the norm — URLs end up in logs, proxies and browser history in a way headers do not.

Consequences The feed socket does the same thing, so there is one rule for both.

Physical desync and recovery

The networking is the easy half. The risk is physical divergence: a gantry skips steps, a piece is dropped, a human bumps the board — and the two physical boards no longer agree.

The decision logic is a pure function, planRecovery (Chapter Desync Detection and Recovery, D-35). Given the engine-expected versus scanned occupancy, the just-reproduced move, a retry count, and the authoritative versus local ply counts, it returns one of four actions:

Action

When

Proceed

The scan matches, or occupancy is unavailable.

RetryDrive

Divergence is confined to {from, to} and retries remain — the piece did not land.

PromptOperator

A re-drive cannot fix it: an off-move bump, a stray extra piece, or the retry budget spent. Carries the missing/extra squares and a ready-to-show message.

Resync

This board is behind the authoritative move list — the protocol gap dominates the physical diff.

Three of the four are wired into the live loop: GameSession::reconcileBoard re-runs the drive on RetryDrive and halts into SessionState::BoardMismatch for PromptOperator, which the GUI renders as a scrim naming the squares to fix.

Warning

The Resync branch currently halts as an operator prompt. The re-derive-from-authoritative-ply round trip over the transport and the DO is the remaining networked integration, and physical acceptance of retry and resync is hardware-gated.