The Live Board Feed and Telemetry

One primitive — a read-only live stream of what a physical board is doing — serves three purposes at once: spectating (watch a board’s game in a browser), debugging (watch a bench board from a laptop during bring-up), and presence (the socket being open answers “is this board online?”).

Like networked play, this is not a new game loop. It is one more observer on the existing one.

Per device, not per game

D-75 — The live feed is a BoardFeed Durable Object keyed per device, and its socket state is presence

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

Context The relay’s GameRoom is keyed per game, which is right for a match with a shared code.

Decision Spectating a physical appliance is different: you watch “board X” across its whole lifetime — mid-solo-game, idle between games, or in a networked match. So one BoardFeed DO per device id holds that board’s current state and fans out to read-only subscribers.

Rejected Per-game keying. A board is a stable, long-lived thing; a game is transient. Per-game keying would also have missed the mode that matters most.

Consequences This is the only server object that ever sees solo games — the primary product mode, which otherwise never touches a server at all. And presence closes an open question in two other plans with no separate heartbeat and no last_seen polling: the same object that carries the feed answers whether the board is online.

The DO is explicitly a mirror, not an authority. It validates no moves: the board is the source of truth for its own solo game, and GameRoom already is for networked ones. A compromised board can therefore only misreport its own feed; no other board or game is affected.

Routes and gating

Route

Who

Notes

/board/:id/feed

the board

Writer. Device-token authenticated (D-74). Sole writer.

/board/:id/watch

viewers

Read-only. Inbound frames are ignored — a viewer can never move a board. Rate-limited so board ids cannot be swept for presence.

/board/:id/presence

internal

Cheap online read for the accounts Worker.

Visibility is enforced in the Worker before the upgrade, never by the client. Today the watch gate requires a short-lived HMAC admin watch-token bound to the device id — the one watcher identity the relay can currently verify. The owner-watch path is deferred: the public site carries no player session, and the corporate admin session lives in a database the relay cannot read.

On the board: FeedPublisher

FeedPublisher (kirin-gui/src/feed_publisher.h) is a GameSessionListener that serializes events onto the wire protocol. onStateChanged $\rightarrow$ status, onMovePlayed $\rightarrow$ move, onGameOver $\rightarrow$ gameover. It attaches through addListener with zero game-loop change, and works identically whether the opponent is the local engine or a remote board.

Per-game context the listener stream does not carry — which kind of opponent, which personality, the start position — is supplied by the app layer at the top of each game, keeping the frontend-agnostic core untouched.

The feed is feature-flagged off unless the board is provisioned (a persisted device id and token). A dropped feed never affects play: it is a pure observer, reconnecting best-effort and re-sending a snapshot.

Warning

An unprovisioned bench board looks exactly like a broken capture hook — the socket never opens, so the feed, telemetry and everything else are simply absent with no error. This is the third of the three innocent causes of a blank debug page, after “the board is running old code” and “the relay is not deployed”.

Telemetry

The feed protocol reserved a debug message type from the start. Telemetry fills it: the bring-up console that shows what the subsystems are doing, not merely which move was played.

Stream

State

Source seam

eval

live on the bench

The engine’s own search, through GameSessionListener::onThinking.

path

live on the bench

GameController’s PlanObserver — the real A^*^ route and blocker parking.

gcode

built, tested

GrblController::sendCommand, the one choke point every command passes through.

sensors

needs hardware

The 96-channel scan; simulation has nothing to report.

gantry

needs hardware

The controller’s replies; a dry-run controller has nothing to reply.

Three of these were buildable and verifiable with no hardware for one reason: SimBackend runs a real GameController in dry-run, so path planning and G-code generation genuinely execute off-hardware — only the serial write is skipped. Note that gcode and gantry are inverses: outbound commands are sim-observable, inbound replies are not.

Where the streams do not go

The path and gcode observers deliberately do not route through GameSession. Path planning is a hardware concern that the frontend-agnostic play loop knows nothing about, so they run GameController $\rightarrow$ IBoardBackend $\rightarrow$ the app layer $\rightarrow$ FeedPublisher, following the precedent the LED scanner observer set. Both backends forward them, which is what makes them debuggable at a desk.

D-84 — Telemetry is live-only, and is sent now or dropped — never queued

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

Context Telemetry frames fire many times per move. The feed transport has a bounded offline queue for frames that matter — the current status and the game’s moves.

Decision Telemetry uses a separate sendLiveFrame() path that sends if the socket is up and drops otherwise, and the relay does not add it to the snapshot state: a late watcher sees no backfilled telemetry, only what happens from now on.

Rejected Queueing telemetry while the socket is down. It would evict exactly the frames a reconnecting watcher needs, in order to deliver stale readings nobody wants.

Consequences Retention is render-what-is-on-the-wire, store nothing. The relay also passes the stream name through unvalidated, which is what let a whole new stream ship later without touching the relay at all; unknown streams are counted and surfaced in the UI rather than dropped, so the next capture hook is visible the day it lands instead of looking broken.

Three deploy targets

Nothing about telemetry is visible until all three layers ship, each with its own release path: the relay, the admin UI, and the board. Deploy the relay first — a new board against an old relay is silent (the old handler drops every debug frame), while an old board against a new relay is merely quiet. Get the order wrong and you cannot tell which layer is at fault.

Note

The board is a deploy target, not a checkout: the tree on the Pi has no .git and nothing propagates to it automatically. It is replaced wholesale by the deploy script, which is the only supported path.