The multiplayer set-up, the lobby, the start of a game, and four signals a remote client had never been sent. Reasoning, the preset table and what was verified how: CHANGELOG.md. - Co-op, Competitive, Cutthroat, Solitaire and Custom, on both screens, from one shared block — they had drifted, and each was missing a question the other asked. - A player reads the whole rule set before taking a seat, may leave a lobby or a running game, and keeps a seat across a reload. The host may clear a chair. The browser remembers every game it is in, not just the last one. - The start of a game is drawn: a handoff beat, an announcement, the code and type in the header. - Sound, the timetable flash, announcements and the just-drawn badge now reach a remote client; justDrawn goes to the seat that drew it and nobody else. - Played on StartOS, which found the rest: an Extra belongs to the player who played it, the board never named the Superintendent, bot seats were reported as absent players, and rule section numbers are out of every string a player reads. Also carries the previous session's Heavy Grade documentation work — asked again, answer unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016JczK5i33ZNSf2PtzZqdhS
9.4 KiB
Protocol
The client↔server message vocabulary, and how per-player views are redacted.
The vocabulary is code, not prose. This document used to spell out every message with an illustrative name and an illustrative payload. It was written before the engine existed, and the engine then went and defined all three families for real — so the document became a second, drifting, subtly-wrong copy of three TypeScript files. It is now a map of those files plus the rules that live nowhere else.
| Family | Direction | Authority |
|---|---|---|
| Intent — a proposal, may be rejected | client → server | src/engine/intents.ts — Intent, 28 variants |
| Event — a fact, ordered; narration, not the record (§3) | server → clients | src/engine/events.ts — GameEvent, 46 variants |
| View — a redacted projection | server → one client | src/sim/view.ts — Frame and snapshot(state, …, seat) |
| Rejection — why an intent was refused | server → one client | RejectionCode in intents.ts |
Read those files for the shapes. Each variant carries its own doc comment explaining the rule behind it and, where the field arrangement is load-bearing, why it is arranged that way. What follows is what the types cannot say.
The transport that carries these — SSE down, HTTP POST up — and the decision to push Frame + Menu
rather than a raw event stream, are in multiplayer.md (D2/D3, D5). Written against
../rules/rules-v0.2.md and game-state.md; the flows are
in overview.md.
1. Intents
Intents are already the wire format. A saved game is { seed, history: Intent[] } and fromSave
replays it, which means the client→server message type was fixed the day save/restore worked. There
is no separate protocol layer to design and nothing to keep in sync.
Every intent is validated server-side even when the client only ever offers legal ones — legalActions
and check are the same code, so a client-side menu is a convenience, never a guarantee.
The framing an intent needs on the wire (gameId, who sent it, a seq for de-duplicating a
reconnecting client's resend) is added by the transport and is not part of Intent itself: the engine
is given the actor by its caller.
What is deliberately NOT an intent
These are the places where offering a choice would be a rules violation, and they are worth stating here because "the message is missing" looks like an oversight in a way that "the message exists" never does.
- Picking up cars. Moving into standing cars couples them automatically and mandatorily (§A.4).
The engine does it while resolving
switch.move. - Which cars come off.
switch.dropCarstakes a count: cars come off in seated order (§A.3), so the only free choice is how many, and from which end (fromNose). - Mainline movement. The whole phase is automatic except the Superintendent's clearance decision
(
mainline.clearance, §8.1) — which arrives out of turn order and may concern another player's train. It is the one place a player acts during someone else's traffic. - Scheduling.
trainScheduledcomes from the 1D12 (§7); nobody chooses it.
Two loops the client must not flatten
- New Train cycles. §7 goes round the table one car per player per pass until the consist is full or no suitable car remains. It is not one prompt per player per train. At five or more players a round can end mid-pass with players never prompted.
newTrain.passCaris validated, not trusted. §7 requires "every effort to find a suitable car", so the engine checks the Division Yard against the consist spec and refuses a pass when a legal car is there.
2. Rejections
RejectionCode in intents.ts is the closed set. applyIntent returns
{ ok: false, code, message } — a reason a client can show, never a stack trace. The message today
is the code restated (apply.ts); a client that wants prose should key off code, which is stable,
rather than parsing it.
A client may pre-validate to grey out illegal moves — that is what legalActions is for, and it is
good UX — but the server's answer is the only one that counts. Rejections should therefore be rare,
which makes them worth logging server-side: a spike means client and server rules have drifted.
3. Events
Events narrate; they do not reconstruct. This section claimed state = fold(events) until
v0.4.0, and it was never true: applyIntent goes through reduce, but the phase driver mutates state
and then emits a descriptive event, so fourteen of the forty-six event types are never reduced — the
clock, and the whole Mainline phase, which is to say every train movement in the game.
The canonical record is { seed, history: Intent[] }, replayed by fromSave. That is what save,
share, undo, restart recovery and post-game replay all run on, and it is what persistence stores
(multiplayer.md §10). Events drive the display and the notifications. Do not build anything on
folding them without first making the phase driver reduce, which is a rewrite of the most rule-dense
code in the project and buys nothing the current plan uses.
Events must render standalone — the rule is stated at the top of events.ts and it is the one that
gets broken by accident. Carry the from/to, not an id the renderer resolves against live state,
or a replay viewer has to reconstruct the whole board to draw one frame.
Two consequences worth knowing before adding an event:
collisionOccurredcarriesfaultPlayerexplicitly rather than leaving clients to derive it. Fault depends on where the wreck happened — Superintendent for a Mainline card, the local player between their Limits (§10) — and getting it wrong misattributes a −5 and, in Competitive, feeds a floor that ends the game.- Events are not what goes over the wire. The server applies the intent and pushes the resulting
Frame(multiplayer.mdD2/D3). Events are narration and cues, not the protocol — and a reconnect gets a freshFramerather than the tail it missed. - What events EARN does go over the wire, as four transient signals (2026-08-23): the sound cues,
the timetable slot a D12 just filled, a one-line announcement, and the id of the card that just came
into this seat's hand. They ride beside the
Framerather than on it because they mark a moment and are consumed — putting them on the Frame would re-fire them on every redraw. Before this a remote client got none of them, so multiplayer had no sound at all, no flash and no announcements while solitaire had all three. The first three are shared and identical in every seat's push; the fourth is not —game.justDrawnis one field for the whole game and does not say whose card it is, so the server remembers who drew and sends it to that seat alone (test/server/session.test.ts, "the four transient signals"). A reconnect gets none of the shared three: a fresh connection is drawing a state, and replaying the sounds of everything it missed is a burst of noise about the past.
4. Views and redaction
Each client receives snapshot(state, …, seat) — a Frame, which is already a projection built for
rendering and already takes the viewing seat. Nothing else is sent.
| State | Visibility |
|---|---|
| Board: track grids, Offices, Limits, trains, standing cars | Public |
Facilities: boxes, MEN AT WORK, Laborer/Porter usage |
Public |
| Division Yard, Classification Yard contents | Public — physical piles on the table |
| Salvage Yard | Public — face up, so players can audit discards (§2.6) |
| Department slots (the three face-up cards) | Public |
| Home Office deck contents and order | Secret — never sent, to anyone |
| Home Office deck count | Public — players can see the pile's height |
| A player's hand | Owner only; others see the count |
| Red Flag held | Public — a known starting card (Appendix B) |
| Revenue totals | Public — the race is the game |
| RNG seed and state | Secret — either leaks every future shuffle and roll |
The redaction surface is four fields, not sixty event types — seed, rngState,
deckReshuffled.order, and cardDrawn.cardId when the draw was from the Home Office. The full
argument, including why trainScheduled is public despite being a die roll, is in
multiplayer.md §7. It reduces to: call snapshot and never send GameState.
This is enforced, not assumed. The redaction test is the single most important test in the multiplayer work: everything else degrades gracefully, a redaction bug hands a player the deck.
5. Ordering and idempotency
- Events carry a monotonic sequence per game. Clients apply strictly in order and request a replay on a gap rather than guessing.
- Intents carry a client
seq. The server ignores a repeat of one it has already applied, so a reconnecting client can safely resend anything it is unsure about. - The server never applies two intents concurrently within a game. A per-game queue is sufficient
and there is nothing cleverer to do at this scale. Note this is a serialisation rule, not a
one-actor-at-a-time rule: per-player turn state (
turns: Map<PlayerIndex, TurnState>) means several players may hold an open turn at once, and their intents still land one at a time.