Tails a UCI engine log and broadcasts live chess games over UDP in TLCS protocol — a minimal TLCS server for node-tlcv and desktop TLCV.
  • TypeScript 100%
Find a file
Jay Honnold 1057bd71b4
Support mid-game client join: fix LOGON snapshot ordering (#6)
* fix: order mid-game LOGON snapshot so position precedes clocks and PV

The LOGON snapshot (site/players/FEN/FMR/clocks/last-PV) already let a client
join mid-game, but clocks and PV were sent fire-and-forget, arriving before
the stop-and-wait FEN. Route them through the reliable queue after FMR so a
late joiner's position always precedes its clocks and PV.

- test: mid-game LOGON replays site, players, latest position, clocks and PV
  in order before the next move
- docs: record the mid-game join contract (tlcs-wire.md), drop the resolved
  snapshot-ordering gap (gaps.md)
- verified end-to-end with the mock client joining a live fastchess feed
  mid-game: snapshot FEN matched the last broadcast position, live moves
  continued, all snapshot ids ACKed

* test: wait for full LOGON snapshot before order asserts

PR review: the order assertions could read FMR/clocks/PV indices before those
stop-and-wait messages landed (idx -1 on a slow ACK round-trip). Wait for the
snapshot's last message first. Also note in tlcs-wire.md that the snapshot's
clocks/PV on the reliable channel is the one exception to the unwrapped rule,
and that a slow-ACKing joiner can briefly stall the global queue.

* fix(tlcs): register joiner only after its snapshot is delivered

New joiners stay out of the broadcast registry until the snapshot's last
reliable message is ACKed (or gives up), so live unwrapped clock/PV sends
and moves queued before the LOGON can't overtake the snapshot. Empty
snapshots register immediately.

* Revert "fix(tlcs): register joiner only after its snapshot is delivered"

This reverts commit 82cc39e1c3.
2026-08-20 18:09:44 -07:00
.opencode/skills/fastchess docs: add known-gaps analysis, e2e runbook, and fastchess skill (#2) 2026-08-18 05:38:58 -07:00
docs Support mid-game client join: fix LOGON snapshot ordering (#6) 2026-08-20 18:09:44 -07:00
fixtures fix: parse multi-token fastchess engine names (#3) 2026-08-18 13:46:47 -07:00
scripts feat: reply to client source port (ephemeral-port support + idle reaper) 2026-05-25 12:57:51 -07:00
src Support mid-game client join: fix LOGON snapshot ordering (#6) 2026-08-20 18:09:44 -07:00
test Support mid-game client join: fix LOGON snapshot ordering (#6) 2026-08-20 18:09:44 -07:00
.gitignore feat: multi-game / multi-matchup support via a LogSource adapter (#1) 2026-05-25 17:19:14 -07:00
AGENTS.md docs: add known-gaps analysis, e2e runbook, and fastchess skill (#2) 2026-08-18 05:38:58 -07:00
package-lock.json feat: TLCS-compatible UDP broadcast server for raw UCI transcripts 2026-05-25 10:42:15 -07:00
package.json feat: TLCS-compatible UDP broadcast server for raw UCI transcripts 2026-05-25 10:42:15 -07:00
README.md fix: broadcast eval in side-to-move POV to match TLCS (#2) 2026-05-31 15:22:45 -07:00
tsconfig.json feat: TLCS-compatible UDP broadcast server for raw UCI transcripts 2026-05-25 10:42:15 -07:00

uci-to-tlcs

Broadcast chess games reconstructed from a UCI engine transcript over UDP in a TLCS-compatible protocol, so node-tlcv (and the desktop TLCV) can watch them live. A single transcript may contain many games and many matchups (e.g. a whole fastchess run); they are segmented and shown in sequence on one board.

It's the inverse of node-tlcv: node-tlcv is a client of Tom's Live Chess Server; this is a minimal server that speaks enough of the same wire protocol to drive it. The protocol contract is defined empirically by what node-tlcv accepts — not by an official spec — so this aligns with node-tlcv rather than being a 1:1 TLCS clone.

How it works

log ──tail──▶ LogSource adapter ──▶ parser ──▶ Pipeline / GameState (chess.js) ──▶ TLCS UDP server ──▶ node-tlcv

A LogSource adapter normalizes one producer line into { uci, engineId?, direction? }, so different log producers can be supported without touching the chess logic. Three adapters ship today (--format):

  • fastchess — reads fastchess -log file=… engine=true output directly (no external stripping). The engine tags let it bind each game's player names to the right colour and detect game/matchup boundaries.
  • myracle — reads myracle's tournament .debug log directly. Lines look like 863187 >first : uci (ms timestamp, >/< direction, first/second engine tag). The tag is remapped to the real display name (learned from the Starting engine N banner and the engine's id name), so names bind to the right colour like fastchess.
  • raw — a bare, already-stripped UCI transcript. Games are still segmented (board resets between them), but without engine tags it can't bind names per game, so it falls back to CLI --white/--black (with id name filling the defaults).
  • auto (default) sniffs the first line and picks one.

It live-tails the log and, for each event, emits the matching TLCS message(s):

UCI TLCS out Notes
position … FEN (+FMR) once at start truncated FEN (board stm castling); board is authoritative
go wtime … btime … WTIME/BTIME ms → centiseconds (÷10)
info … score … pv … WPV/BPV score in side-to-move (engine) POV, matching TLCS; time ms→cs; PV coords→SAN; only multipv 1
bestmove <coord> FEN, WMOVE/BMOVE, FMR move number + SAN
game over (board) result: mate/stalemate/draw
new game (boundary) result: (prev, if none) → WPLAYER/BPLAYER → startpos FEN resets node-tlcv's board for the next game

Reliability matches node-tlcv's transport: state-critical messages are ID-wrapped (<N>MSG) and resent until ACK: N, in strict order; WPV/BPV/WTIME/BTIME are sent unwrapped. Late joiners get a unicast snapshot of current state.

Each client is tracked by its source ip:port, and the server replies to that source port — not the fixed broadcast port that strict TLCS assumes. Compliant clients (node-tlcv, desktop TLCV) send from the broadcast port, so this is identical for them; it additionally lets clients on ephemeral ports receive the broadcast and lets several clients share one host. A client that goes silent (no PING/ACK for ~30s) is reaped.

Usage

npm install
npm run build           # or run straight from source with tsx:
npm start -- --log path/to/game.uci --port 16066 --white "Engine A" --black "Engine B" --site "My Match"

Options: --log <path> (required), --format auto|raw|fastchess|myracle (auto), --port (16066), --bind (0.0.0.0), --white/--black/--site, --from-end (skip existing content). LOG_LEVEL=debug logs every UDP message.

With --format fastchess (or myracle) you point --log at the engine/tournament log itself; with --format raw you point it at a pre-stripped transcript. --white/--black are only used as the fallback names for the raw/untagged path. To attach to a log that is already running, keep the default (read from start) so the top-of-file engine names are learned — --from-end would skip them and the myracle path would fall back to first/second.

Point node-tlcv at it via config/config.json: { "connections": ["<host>:16066"] }.

Testing

npm test                # unit tests (parser, encoder, pipeline) via node:test

Local loop with the mock client — emulates node-tlcv. Since the server replies to the client's source port, the client can bind an ephemeral port on the same host (no loopback alias needed):

npm start -- --log fixtures/sample-game.uci --port 16066 --bind 127.0.0.1 &
npm run mock-client -- --server 127.0.0.1 --port 16066 --ephemeral
# append lines to the log and watch them decode live

(To emulate a strict TLCS client that binds the broadcast port instead, drop --ephemeral and run the client on a loopback alias, e.g. --bind 127.0.0.2.)

Faithful end-to-end with real node-tlcv (single host, no Docker) — node-tlcv now supports an ephemeral connection mode, so it runs alongside the bridge on one host (no container or loopback alias). Point its config/config.json at the bridge:

{ "connections": [ { "connection": "127.0.0.1:16066", "ephemeral": true } ] }

Start the bridge first (it must be listening before node-tlcv boots — node-tlcv LOGONs once and never retries), start node-tlcv, then feed it a real game from fastchess:

# 1) bridge — tails the fastchess log directly (no sed), broadcasts on 16066
npm start -- --log /tmp/fc.log --format fastchess --port 16066 --bind 127.0.0.1 &
# 2) node-tlcv (in ../node-tlcv): npm run dev-server   → http://127.0.0.1:8080/16066
# 3) real games — multiple games / matchups in one log are fine
fastchess -engine cmd=<engineA> -engine cmd=<engineB> -each tc=10+0.1 \
  -rounds 4 -games 2 -repeat -concurrency 1 \
  -log file=/tmp/fc.log engine=true realtime=true

With --format fastchess the bridge consumes the engine-tagged log directly, so the player names come from the run and swap correctly each game. (The old --format raw path with tail -F … | sed -u -E 's/.*(<--- |---> )//' >> /tmp/live.uci still works for pre-stripped transcripts.) Use a real tc= (not st=/movetime) so the clocks tick, -concurrency 1 so games don't interleave in one log, and no fastchess adjudication so games end on the board (a non-board end emits result: *). Needs node-tlcv checked out at ../node-tlcv.

Scope

Sequential multi-game / multi-matchup on one port (each game replaces the previous on the board; assumes -concurrency 1 so the log isn't interleaved). Player-name binding needs a tagged producer (--format fastchess); the raw path keeps CLI names. Results are board-derived (mate/stalemate/draw); an adjudicated/unknown end emits result: *. A new game that starts from a non-startpos position won't visually reset node-tlcv's board. Mid-game joiners get the current position (not full move history); minimal RESULTTABLE. See the comments in src/ and the plan for the rationale behind each.