8.5 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.
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.