# 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, the game's history | 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 are the authoritative history: `state = fold(events)`, which buys reconnection, persistence and post-game replay for one design decision. **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: - **`collisionOccurred` carries `faultPlayer` explicitly** 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 folds them and pushes the resulting `Frame` (`multiplayer.md` D2/D3). They are the store and the replay format, not the protocol. --- ## 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 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`) means several players may hold an open turn at once, and their intents still land one at a time.