# 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`](../../src/engine/intents.ts) — `Intent`, 28 variants | | **Event** — a fact, ordered; narration, not the record (§3) | server → clients | [`src/engine/events.ts`](../../src/engine/events.ts) — `GameEvent`, 46 variants | | **View** — a redacted projection | server → one client | [`src/sim/view.ts`](../../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`](multiplayer.md) (D2/D3, D5). Written against [`../rules/rules-v0.2.md`](../rules/rules-v0.2.md) and [`game-state.md`](game-state.md); the flows are in [`overview.md`](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.dropCars` takes 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.** `trainScheduled` comes 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.passCar` is 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: - **`trainsDestroyed` carries the player at fault (`player`) explicitly** rather than leaving clients to derive it (the event was called `collisionOccurred` in this design; the engine emits `trainsDestroyed`). 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.md` D2/D3). Events are narration and cues, not the protocol — and a reconnect gets a fresh `Frame` rather 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 `Frame` rather 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.justDrawn` is 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](multiplayer.md). 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 count is the server's, for the life of the game**: the connect push carries the seat's last accepted `seq` (`lastSeq`) and the client continues from it, never from 1 — a page that restarted its own count after a reload re-sent a number the server had already applied, and the move was silently swallowed as a resend (v0.8.4). - **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. It is a real queue (`http.ts`'s `inTurn`), not a reliance on the single thread: the handler awaits the disk write between applying and answering, and two moves arriving together used to interleave across that await (v0.8.4). Note this is a serialisation rule, not a one-actor-at-a-time rule: per-player turn state (`turns: Map`) means several players may hold an open turn at once, and their intents still land one at a time.