v0.4.0 — multiplayer Phases 0 and 1: seat and player split apart, turn state per player, the page behind a Session, and eight seat/player mix-ups fixed with tests that fail without them

This commit is contained in:
Jesse
2026-08-13 14:06:02 -04:00
parent 216006b091
commit 49f8504b05
34 changed files with 1743 additions and 526 deletions
+2 -2
View File
@@ -161,8 +161,8 @@ is what makes the whole system testable without a server.
**11. Transport**
- **Where:** Server · **When:** per connection; pushes on every event
- **Does:** HTTP for lobby operations, WebSocket for the ordered event stream, static asset serving.
Bidirectional — most traffic is server → client.
- **Does:** HTTP POST for lobby operations and intents, SSE for the ordered push stream, static asset
serving ([`multiplayer.md`](multiplayer.md) D5). Most traffic is server → client.
- **MVP: S** · **Final: M** · ~150 → ~400 LOC
- **Size driver:** small at MVP because one client needs no fan-out. Grows with broadcast, backpressure
and reconnect-with-replay.
+2 -2
View File
@@ -15,7 +15,7 @@ The requirements are modest, which is what makes both paths viable:
| Need | Why |
| --- | --- |
| One long-running process | Active games are held in memory ([`overview.md`](overview.md)) |
| HTTP + WebSocket on one port | Lobby over HTTP, game stream over WebSocket ([`protocol.md`](protocol.md)) |
| Plain HTTP on one port | Lobby and intents over POST, game stream over SSE — no WebSocket upgrade, which is what keeps this a *plain* HTTP need ([`multiplayer.md`](multiplayer.md) D5) |
| A writable data directory | The append-only event log ([`lobby-and-sessions.md`](lobby-and-sessions.md)) |
| Static asset serving | The browser client |
| No outbound network access | The game talks to nobody |
@@ -59,7 +59,7 @@ network access to that StartOS box. How they get it is the user's configuration
something the application chooses or should claim.
**One thing the architecture must respect.** A packaged service should not assume it is reachable at
a fixed, publicly-routable URL. Anything that bakes an origin into the client — absolute WebSocket
a fixed, publicly-routable URL. Anything that bakes an origin into the client — absolute stream
URLs, hard-coded hostnames in links, CORS allow-lists pinned to one domain — will break. Serve the
client from the same origin as the API and use relative URLs throughout. This costs nothing on a
plain web host and is required on StartOS, so it should simply be the rule.
+432
View File
@@ -0,0 +1,432 @@
# Multiplayer — design and build plan
How Station Master becomes a multiplayer game with an authoritative server, **as a layer added on top
of what exists**. Solitaire keeps working exactly as it does today: opened from a static host, played
entirely in the browser, no server involved at any point.
This is a plan, not an implementation. Written against the engine as built (v0.3.1). It reconciles
[`overview.md`](overview.md), [`protocol.md`](protocol.md),
[`lobby-and-sessions.md`](lobby-and-sessions.md) and [`deployment.md`](deployment.md) — all written
*before* the engine existed — with what was actually built, and with the decisions reviewed and
settled in §11.
---
## 1. The constraint that shapes everything
> Solitaire still plays the same. No server needed.
That decides the architecture:
- The engine stays **pure and browser-runnable**. No server-only dependency enters `src/engine/`.
- The client runs in **two modes** without forking: authoritative-local (solitaire, today's
behaviour) and view-only-remote (multiplayer).
- The static deploy keeps working. A server is an *additional* way to run the game.
The existing architecture anticipated nearly all of this and the engine was built to it. Most of what
follows is assembly.
---
## 2. What holds, and what moved
`overview.md`'s core claims survived contact with the implementation: server authority, a pure
deterministic engine, two entry points (`applyIntent` / `pump`), `state = fold(events)`, events that
render standalone, per-player redacted views.
Three things moved:
**The save format is already the wire format.** A game is `{ seed, history: Intent[] }` and `fromSave`
reconstructs it exactly. That is what makes persistence nearly free (§7).
**`protocol.md`'s message vocabulary was out of date; fixed in Phase 0.** It had been written from
the rules before the engine existed, and the real `Intent` union diverged — different names
(`switch.move`, not `Switch.Move`), different shapes (`dropCars` grew `fromNose`; `card.play` grew
`variant` and `node`), and intents that did not exist then (`switch.sortConsist`,
`newTrain.secondSection`, `maneuver.*`, `redFlag.*`), plus 40-odd rejection codes against a listed
20. **The real types are the protocol**, so `protocol.md` now points at `intents.ts` and `events.ts`
and keeps only what the types cannot say.
**The client already renders from a projection.** `snapshot()` produces a `Frame`, `actionMenu()` a
`Menu`, and the renderers consume only those. This is why the multiplayer client is cheap.
---
## 3. Measurements this plan rests on
Taken from 8 four-player bot games and 12 solitaire games, so the sizing is not guesswork.
| | value |
| --- | --- |
| Intents per game (4 players) | ~355, over ~16 Stages |
| Intents per player per game | ~89 |
| Intents per Stage | 22 — about 5.5 yours, 16.7 watched |
| Phase split | Local Ops 78%, Load/Unload 19%, New Train 3% |
| A turn: switch / draw / freight agent | 5.8 / 4.3 / 2.0 intents |
| Frame | 9.3 KB mean; **2.9 KB** with the board delta'd |
| Menu | 2.2 KB mean, 9.8 KB max |
| Events per intent | 3.3, ~160 B |
| Push rate | ~0.4 per second across a whole game |
Two consequences worth stating plainly:
**Latency is not the problem.** Watching does not block — pushes arrive asynchronously. The only
latency a player feels is on their own ~89 actions: **1.7 seconds across an entire game** on a LAN.
Nothing here is a performance decision.
**Idle time is the problem.** With four players you watch ~17 of 22 intents per Stage. That is a
turn-order property of the rules, not of the implementation — see §11 D19 for what is being kept
open about it.
> **Open, and not a plan decision:** a 4-player competitive game ends after **~16 Stages of a
> possible 60**, well before the 5-day limit. Most likely the collision or revenue floor (§3.4)
> firing early. Worth understanding before building a lobby around game length — it may be a balance
> bug rather than intent.
---
## 4. The structural idea: a Session boundary
Today `main.ts` calls `submit(game, intent)`, which applies in-process and re-renders. Introduce one
interface between the UI and the game:
```
Session
view() -> Frame what to draw
menu() -> Menu what may be done
submit(intent) -> Promise<ok> propose an action
subscribe(cb) -> unsubscribe "something changed, re-render"
capabilities -> { undo, saveLocal, newGame }
```
- **`LocalSession`** wraps today's `Game`: applies through the engine, pumps `advance`, keeps
`history`, supports undo and `localStorage`. **This is solitaire, unchanged.**
- **`RemoteSession`** holds no authoritative state. Posts intents, receives `Frame` and `Menu`,
re-renders on push. Undo and local save are absent from `capabilities`, so the UI hides those
controls rather than failing when pressed.
The client cannot keep authoritative state in remote mode even if it wanted to: it has neither the
deck order nor the other players' hands.
---
## 5. What the client must stop doing
`main.ts` reaches into `game.state` in **11 places**. Five new `Frame` fields remove all of them:
| Reads today | Fix |
| --- | --- |
| `clock.day`, `clock.stage`, `clock.phase` | already on `Frame` |
| `turn.option` | **add** — and under §6 it becomes *your* turn, not *the* turn |
| `status`, `outcome` | **add** |
| `players` (names and revenues of every seat) | **add** — the scoreboard is public |
| `decks.hands.get(actor).length` | **add** as `handCount`, plus per-seat counts |
Everything else it draws already comes from `Frame`/`Menu`.
**Solitaire-only, gated by `capabilities`:** undo (other players have seen the result), `localStorage`
save/restore, new-game-by-seed-in-URL.
---
## 6. Per-player turn state
`s.turn` is a **single** `TurnState` — one `option`, one `movesRemaining`, one `freightWorked` — read
in 50 places, with a phase driver that walks players one at a time calling `freshTurn()` as it goes.
**Phase 0 makes it per-player: `turns: Map<PlayerIndex, TurnState>`, with a turn created for every
player at phase entry.** This is deliberately **behaviour-neutral** — the cursor still advances one
player at a time, so play is identical and every existing test still describes the same game.
It is done now because the engine is least encumbered now, and because the alternative later means
the same work *plus* reworking a client built around "wait your turn". There is no data migration
either way: we persist intents, not state.
**What it buys later.** There is exactly one `NOT_YOUR_TURN` gate in the engine (`apply.ts:463`,
delegating to `isActor`). Once turn state is per-player, allowing genuinely local work to happen
off-cursor is a change to that one function:
```
isActor(s, player, intent)
local intent -> that player's own turn isn't done
shared intent -> player is at the shared cursor
```
An intent is **local** iff it touches only the acting player's Office Area **and** does not advance
the shared RNG (three sites: the reshuffle, the D12 that schedules a train, and setup).
| Local | Shared |
| --- | --- |
| `switch.move`, `dropCars`, `sortConsist` | `draw.fromHomeOffice`, `draw.fromDepartment` |
| `card.play` — track, facility, office, modifier, enhancement | `card.discard` (buries a shared pile) |
| `localOps.choose`, `switch.end`, `draw.end` | `card.play` — **train cards**, which roll the D12 |
| | `freightAgent.*`, and the yards generally |
That flip is **not** part of this plan. See D19.
---
## 7. Redaction — the trust boundary
`protocol.md` §4's table governs, and `Frame` was designed for it: `Frame.hand` is the *viewer's own*,
`Frame.deck` is a count, and the seed is not on it.
**The redaction surface is four fields, not sixty event types.** An earlier draft of this document
claimed the latter and it was wrong — it biased the design. The genuinely secret things are:
| Secret | Why |
| --- | --- |
| `seed`, `rngState` | leak every future shuffle and roll |
| `decks.homeOffice` — contents *and* order | §12.1; the count is public, the pile's height is visible |
| `decks.hands` — other players' | owner only; counts public |
| `cards` (the id→kind map) | the dictionary that turns any leaked id into a known card |
Everything else is public by the rules: the board, the Division, trays and consists, the timetable,
the yards, the salvage pile, the face-up Departments, revenues, held Red Flags, the clock and whose
turn it is. `trainScheduled` is **public** — `trainNumber`, `roll` and `slot` all belong on screen;
only its `rngState` field must be stripped.
So redaction reduces to: **call `snapshot(s, seat)` and never send `GameState`.** Because `Frame`
resolves card names and descriptions server-side, the client never needs the `cards` dictionary at
all.
**This must be enforced, not assumed.** Phase 2's redaction test is the single most important test in
the plan: serialize a seat's payload and assert it contains no other seat's card ids, no deck array,
and no rng state.
---
## 8. Server shape
One process, one port, same origin. Three layers:
```
transport HTTP: lobby, intents, static assets. SSE: the per-seat stream.
|
game session one per active game: owns state, serializes intents, pumps advance,
| computes per-seat Frame+Menu, appends to the log
|
rules engine unchanged, imported as-is from src/engine/
```
The session host is thin, because the engine does the hard part:
```
on intent(seat, intent, seq):
if seq already applied: ignore // idempotent resend
result = applyIntent(state, seat, intent)
if not result.ok: reply Rejected(result.code)
append intent to the log
pump(state) // drain the automatic phases
for each connected seat:
push { frameΔ: delta(snapshot(state, seat)), menu: seat may act ? menuFor(seat) : null, lines }
```
`pump` after every intent is what today's `drain()` does, and it is why the Mainline Phase needs no
special handling: `pump` stops, and the next push simply carries a `Menu` containing
`mainline.clearance` for whoever must rule.
**Bots run server-side** using the existing `developerBot`, for seats chosen at lobby time only.
---
## 9. Transport, and multiple addresses
**SSE for push, HTTP POST for intents.**
```
POST /api/lobby/create, /api/lobby/join lobby
POST /api/intent { gameId, seq, intent }
GET /api/stream EventSource — per-seat frames, with Last-Event-ID resume
GET / the client
```
Chosen over WebSocket because this game is **idle most of the time** — turn-based with human
think-time means a connection sits silent for minutes, exactly when proxies reap sockets. SSE's
reconnection and `Last-Event-ID` resume are handled by the browser, and it needs no `Upgrade` support
from anything in the path. WebSocket is supported on StartOS (`recipe-multi-interface.md`;
`cln-startos` ships one) and remains a contained swap behind the `Session` interface if
bidirectionality is ever wanted. Two channels means intents carry a `seq`, which the design already
required for idempotent resend.
**Players will reach one server at different addresses** — a LAN `IP:port` and a clearnet subdomain,
in the same game. That works because the server serves the client from the same origin, so each
browser is same-origin with itself and **no CORS is involved at any point**. One rule makes it hold:
> The client derives every endpoint from `location`. Never from configuration, never baked in.
Two consequences:
- **`localStorage` is per-origin**, so a session token exists only at the address it was created at.
**A player rejoins at the address they joined from.** This is a stated constraint, not a bug to fix.
- The two paths have different reliability characteristics — only the clearnet player traverses TLS
termination and ingress. SSE's automatic recovery is what makes that difference not matter.
---
## 10. Identity, access and persistence
**Access: a server-wide join secret**, set by environment variable and passed out of band by whoever
runs the server. Anyone holding it may create a game, and may join any created game that has not
started. No accounts, no user database. *(Revisit — see `TODO.md`.)*
**Identity: a session token scoped to one game**, exactly as `lobby-and-sessions.md` already
describes. Joining issues it; presenting it *is* the rejoin, because it already names the game and
the seat:
```
Session token, gameId, seat, displayName
```
The join secret is separate and server-wide — typed once and kept per-origin for convenience. It
gates entry; the token identifies a seat.
**One game at a time per person is the expected usage, and is deliberately not enforced.** Enforcing
it would mean a server-scoped token carrying an `activeGame`, and then answering what clears it — a
finished game, a player who leaves, a game that never ends — which is real state to get wrong for no
benefit. Nothing in the design needs a person to be in only one game, so nothing checks. A browser
that ends up holding two tokens simply has two games.
**Persistence: `{ engineVersion, seed, config, history: Intent[] }`**, append-only per game, plus a
small index. Rebuilding is `fromSave`, already implemented and exercised by every published replay.
No state snapshot — see D6.
**Games do not survive a rules change, by design.** A saved intent legal under old rules is rejected
under new ones; that happened four times in one release. So every game is stamped with its engine
version, and on load a mismatch is **refused with an explicit message** rather than silently
truncated. The upgrade path is: stop new games on the old version, let running ones drain.
**Retention: finished games keep everything**, because replay reveals everything (D20) and the log is
all it needs.
---
## 11. Decisions — reviewed and settled
| # | Decision | Rationale |
| --- | --- | --- |
| **D1** | **SSE + HTTP POST** | The game is idle for minutes at a time and players arrive by different paths; browser-native reconnect and resume, no `Upgrade` dependency. Swap is contained behind `Session`. |
| **D2/D3** | **Push `Frame` + `Menu`, delta'd. No raw event stream.** | Latency is not the deciding factor (1.7 s per game on a LAN), so decide on correctness: one reducer, one redaction chokepoint, no card dictionary on the client. 2.9 KB per push with the board omitted when unchanged — reusing `replay.ts`'s packing, already tested for losslessness. |
| **D4** | **One client bundle**, mode switch | The engine ships either way; in remote mode it is simply not used for authority. |
| **D5** | **Persist intents**, not events | Smaller, already the save format, already proven by `fromSave`. |
| **D6** | **No state snapshot** | A second format to keep correct, and D7 removes the need. |
| **D7** | **Stamp the engine version; refuse to resume a mismatch** | Rules changes invalidate stored intents. Drain before upgrading. |
| **D8** | **Bots fill empty seats at lobby time only** | Makes short-handed games and one-person testing possible. Never automatic on disconnect — see the two `TODO.md` items. |
| **D9** | **Separate seat from identity, now** | Employee Rotation needs a player's score to follow them while their seat changes. The single hardest thing here to retrofit, and the codebase is smallest today. |
| **D10** | **No hotseat** in v1 | Nearly free once seats exist, but a different UX. Additive later. |
| **D11** | **Server accepts 1-seat games**, but solitaire stays the static build | Falls out of seats generalising, and is genuinely useful for testing and validation. Not a featured path — playing alone gains nothing from a round trip. |
| **D12** | **No spectators** in v1 | A view with no private section and no intent rights. Additive later. |
| **D13** | **No accounts** | Display name plus a per-game session token, as `lobby-and-sessions.md` describes. One game at a time per person is the expected usage but is **not enforced** — doing so would add cross-game state to get wrong for no benefit. |
| **D14** | **Server-wide join secret**, passed out of band | The server may be clearnet-reachable. Cheapest thing that stops it being someone else's game server. Revisit. |
| **D15** | **Build to `deployment.md`'s five portability rules; package as an `.s9pk`** | This is a StartOS packaging workspace and the toolchain is on disk. |
| **D16** | **The server serves the client** | Required for same-origin, which is what makes multiple access addresses work without CORS. |
| **D17** | **Undo is solitaire-only** | Other players have seen the result. |
| **D18** | **Player cap 6** | Per `lobby-and-sessions.md` §2. |
| **D19** | **Per-player turn state now; parallel turns deferred** | The model change is behaviour-neutral and cheap today. Whether local work should run off-cursor depends on how often humans choose to switch — 13% for the bot, and the benefit ranges from ~8 minutes to ~27 off an hour-long game between 13% and 50%. Measure with real players, then flip one function. |
| **D20** | **Replay reveals everything once the game ends** | Most useful for learning and for arguing about it afterwards; costs nothing extra to retain. |
**Still open, and deliberately so:** whether local turns should run in parallel (D19, needs human
switching data); whether the join secret is enough (D14); and the ~16-Stage game length in §3, which
is a balance question rather than an architecture one.
---
## 12. Build plan
Sizes use `components.md`'s scale. Each phase ends somewhere demonstrable.
### Phase 0 — Engine and client preparation · M — **done, v0.4.0**
Safe to do now, and all of it improves the code whether or not multiplayer ships. Larger than a
typical "phase 0" because two structural changes are cheapest here.
1. **Separate seat from identity** (D9). `Player` carries id, name and revenue; the seat carries the
Office. Touches setup, `areaOf`, scoring, seating, the Division build and most tests.
2. **Per-player turn state** (D19). `turns: Map<PlayerIndex, TurnState>`, one per player at phase
entry, `turnOf(s, player)` at 50 sites. **Behaviour-neutral** — the cursor still walks one player
at a time, and every existing test must still pass unchanged.
3. Add `option`, `status`, `outcome`, `players[]`, `handCount` to `Frame`.
4. Remove all 11 `game.state` reads from `main.ts`; add a test that it never regains one.
5. Rewrite `protocol.md` to reference `intents.ts` and `events.ts` rather than restate them.
**Done when:** solitaire plays identically, the client renders from `Frame` + `Menu` alone, and the
engine has per-player turn state that nothing yet exploits. *All five shipped in v0.4.0. Item 1 found
a real bug on the way — `awardDeparture` was indexing `s.players` with a seat.*
### Phase 1 — The Session boundary · S — **done, v0.4.0**
6. Define `Session`; implement `LocalSession` around today's `Game`.
7. Move `main.ts` onto it, with `capabilities` gating undo, save and new-game.
**Done when:** solitaire plays identically through the new interface. Maximum "nothing appears to have
happened"; the regression suite is the proof. *Shipped in v0.4.0. The proof is `test/session.test.ts`,
which plays seed 77 to a finish through both routes and asserts the boards and histories match, plus a
source check that `main.ts` never regains a `GameState` read or a value import from `game.ts`.*
### Phase 2 — Server core, one game, no lobby · M
8. Process bootstrap: env-configured bind address, port, data directory, join secret; static serving.
9. Game session host — the loop in §8.
10. Per-seat `Frame` + `Menu`, with the board delta reusing `replay.ts`'s packing.
11. **The redaction test** (§7). Fail loudly.
12. SSE stream and `POST /api/intent`, with `seq` and `Last-Event-ID`.
13. `RemoteSession` in the client.
**Done when:** two browsers — one on a LAN IP, one on a hostname — play a 2-player game to a finish.
### Phase 3 — Persistence and resumption · S
14. Append-only `{engineVersion, seed, config, history}` per game; index.
15. Load on start; rebuild via `fromSave`; refuse a version mismatch explicitly.
**Done when:** the server restarts mid-game and both clients carry on.
### Phase 4 — Lobby, sessions, reconnection · M
16. Join secret; per-game session token; display name.
17. Create/join by game code; bot seats; seating UI showing the west-to-east chain; config locked at
start.
18. Disconnect keeps the seat and announces it; reconnect resumes from `Last-Event-ID`.
19. Optional turn timer, off by default, **denying** clearance on expiry.
**Done when:** four people join from four browsers at two different addresses, one closes the tab and
rejoins where they left off.
### Phase 5 — Multiplayer content · M
20. The 10 Action and 12 Space-use cards; flip `opponentCardsInDeck` in `setup.ts`.
21. Facing Point Locks, Water Column and Overpass stop being dormant — already wired.
**Done when:** an opponent-directed card resolves against another player and the Enhancement that
answers it fires.
### Phase 6 — Package for StartOS · S
22. `.s9pk` per the workspace guide: interface, health check, backup of the data directory.
**Done when:** it installs on a StartOS box and players on two different addresses play a game.
**Shape of it:** Phases 0–1 are low-risk refactoring that stands on its own merits. Phases 2–4 are the
real system. Phase 5 is content and independent of the rest. Phase 6 is packaging.
---
## 13. Risks
**R1 — The switching UI is still the sleeper.** `components.md` called it that and it remains the
hardest interface in the game. Multiplayer does not make it harder, but four people now watch one
person use it.
**R2 — Redaction must be exactly right.** Everything else degrades gracefully; a redaction bug hands
someone else's hand to a player and cannot be walked back. Hence the explicit test, not a review.
**R3 — `Menu` peaks at 9.8 KB** against a 2.2 KB mean. If that bites, send placements only for a
selected card. Measure before optimising.
**R4 — Idle time, not latency, is what makes 4-player games drag.** You watch ~17 of 22 intents per
Stage. D19 keeps the door open; the instrumentation to decide it is a few lines and belongs in the
first real multiplayer games.
**R5 — The two provisional rules are still unplaytested.** The opening deal and departure Revenue are
both flagged for review in `TODO.md`. Phases 0–1 are safe regardless; **Phase 2 onward should wait
until those settle**, or the server gets built against rules that are still moving.
+10 -7
View File
@@ -115,13 +115,16 @@ trains move and wrecks happen while the Superintendent rules on clearances.
That means the server must push. A request/response API alone would leave four players polling to
watch a fifth switch cars.
**WebSocket, with the HTTP endpoints alongside it** for lobby operations (list games, create, join)
where a request/response shape is the natural fit. Server-Sent Events would also serve, since the
push is nearly one-directional and intents are rare enough to send over HTTP — worth keeping in mind
if the eventual stack makes SSE materially simpler.
**Server-Sent Events, with HTTP POST alongside** for lobby operations and for intents, where a
request/response shape is the natural fit. This paragraph originally leaned the other way, toward
WebSocket with SSE as the fallback; it was settled the other way in
[`multiplayer.md`](multiplayer.md) D5, because the push is nearly one-directional, intents are rare,
and SSE reconnects itself through anything in the path without a protocol upgrade to negotiate.
What matters more than the choice: **every state change reaches clients as an event on one ordered
stream**. Clients apply events in order and never mutate state locally in a way that could drift.
What matters more than the choice: **every state change reaches clients on one ordered stream**, and
clients never mutate state locally in a way that could drift. What travels on that stream is a
redacted `Frame`, not the raw events — see `multiplayer.md` D2/D3 for why one reducer and one
redaction chokepoint beat shipping the history to every client.
---
@@ -159,7 +162,7 @@ Keep the **rules engine** free of any knowledge of networking, storage, or playe
│
game session owns one game's state, applies intents, emits events
▲
lobby / transport connections, reconnection, persistence, HTTP + WebSocket
lobby / transport connections, reconnection, persistence, HTTP POST + SSE
```
The rules engine being pure and deterministic is what makes the whole thing testable — you can drive
+83 -214
View File
@@ -1,269 +1,138 @@
# Protocol
The client↔server message vocabulary, and how per-player views are redacted. Written against
[`../rules/rules-v0.2.md`](../rules/rules-v0.2.md) and [`game-state.md`](game-state.md).
The client↔server message vocabulary, and how per-player views are redacted.
Three message families, matching the flows in [`overview.md`](overview.md):
**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.
- **Intent** — client → server. A proposal. May be rejected.
- **Event** — server → clients. A fact. Ordered, and the game's history.
- **View** — server → one client. A redacted state snapshot.
| 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` |
Message names below are illustrative. What matters is the *set* of decisions a player can make, which
is fixed by the rules.
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
Every intent carries `gameId`, `playerId`, and a `seq` the server uses to reject duplicates from a
reconnecting client.
**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.
### 1.1 Local Operations Phase
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.
The phase opens with a three-way exclusive choice (§6). Choosing one forecloses the others for that
Stage.
### What is deliberately NOT an intent
```
LocalOps.Choose { option: switch | draw | freightAgent }
```
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.
**If `switch`** — six Moves, divisible across Crew Trays (§6.1). Five Moves under Reduced Visibility
during night Stages (Appendix B).
- **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.
```
Switch.Move { trayId, to: GridCoord }
Switch.DropCars { trayId, count } -- from the tray end; order is fixed (§A.3)
Switch.SetDirection{ trayId, direction } -- costs a Move; a Move never reverses (§2.4)
Switch.End { } -- forfeit remaining Moves
```
### Two loops the client must not flatten
Pick-up is **not** an intent. Moving into standing cars couples them automatically and mandatorily
(§A.4) — the server does it as part of resolving `Switch.Move`. Offering a choice would be a rules
violation.
`Switch.DropCars` takes a count, not a set of car ids: cars come off in seated order (§A.3), so the
only free choice is how many.
**If `draw`** (§6.2):
```
Draw.FromHomeOffice { }
Draw.FromDepartment { slot: 0|1|2 }
Card.Play { cardId, placement? } -- placement required for track/facility/office cards
Card.Discard { cardId, toSlot: 0|1|2 }
Draw.End { } -- server rejects while hand > 3
```
`placement` is a `GridCoord` for track, Facility and Office cards (§11.2); absent for train cards,
which go to the timetable or to an Extra's staging.
**If `freightAgent`** — exactly one of three operations (§6.3):
```
FreightAgent.StockToOutbound { facilityId, stockType, loaded }
FreightAgent.InboundToClass { facilityId, stockIndex }
FreightAgent.UnjamToClass { facilityId, from: outbound|inbound|menAtWork, index }
```
### 1.2 New Train Phase
Per train being made up, each player adds one car in Superintendent-then-left order (§7):
```
NewTrain.PlaceCar { trainId, stockType, loaded }
NewTrain.PassCar { trainId } -- only legal when no suitable car exists in the Division Yard
```
**This cycles** (§7, Gap 9). The server keeps going round the table — one car per player per pass —
until the consist is full or no suitable car remains in the Division Yard. It is not a single pass, so
the client must not assume one prompt per player per train. At five or more players the round ends
mid-pass when the consist fills; players not yet reached simply are not prompted.
`PassCar` must be validated, not trusted: §7 requires the player to "make every effort to find a
suitable car." The server checks the Division Yard against the train's `consistSpec` and rejects a
pass when a legal car is available. A pass by every player in a full pass is the loop's other
termination condition.
For a played Extra (§7, Gap 4c) the owning player chooses its whole consist:
```
Extra.Launch { trainCardId, at: westDP | eastDP, direction }
Extra.LoadConsist { trainCardId, stock: [{stockType, loaded}] } -- ≤3 cars + caboose
```
### 1.3 Mainline Phase
The phase is automatic **except** for the Superintendent's clearance decision (§8.1, fourth
condition). The server pauses movement, sets `clock.pendingDecision`, and waits:
```
Mainline.Clearance { trainId, allow: boolean }
```
Only the current Superintendent may send this, and only for the train named in the pending decision.
This intent arrives out of the normal turn order and may concern another player's train — the one
place in the game where a player acts during someone else's traffic.
The Red Flag card (Emergency Toolbox, Appendix B) also interrupts here:
```
RedFlag.Play { } -- averts an imminent collision; card is removed from the game
```
### 1.4 Load/Unload Phase
Each Laborer and Porter is usable once per Stage (§9.1). Resolve one at a time.
```
Porter.Board { facilityId } -- §9.2, +1 Revenue
Porter.Detrain { facilityId } -- §9.2, +1 Revenue
Laborer.AdvanceLoad { facilityId, loadRef } -- Green → MEN → AT → WORK → car (§9.3)
Laborer.BeginUnload { facilityId, carIndex } -- first step of unloading
LoadUnload.End { }
```
A freight load needs four `Laborer.AdvanceLoad` intents to score one point; a Porter scores in one
(§9.3). The client should show this clearly — it is the single most surprising thing about the game's
economy for a new player.
### 1.5 Lobby
Covered in [`lobby-and-sessions.md`](lobby-and-sessions.md); listed here for completeness.
```
Lobby.Create { config }
Lobby.Join { gameCode, displayName }
Lobby.Leave { }
Lobby.SetConfig { config } -- host only, before start
Lobby.Start { } -- host only
```
- **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
An intent is rejected with a reason a client can render, not a stack trace:
`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.
```
Rejected { seq, code, message, ... }
codes: NOT_YOUR_TURN | WRONG_PHASE | OPTION_ALREADY_CHOSEN | NO_MOVES_REMAINING
ILLEGAL_MOVE | TRACK_OCCUPIED | NOT_OPERATIONAL_RAIL | WOULD_REVERSE
CONSIST_FULL | CONSIST_ORDER | HAND_LIMIT | CARD_NOT_IN_HAND
NO_PLACEMENT | NOT_CONNECTED | RESOURCE_SPENT | SUITABLE_CAR_EXISTS
NOT_SUPERINTENDENT | NO_PENDING_DECISION
```
The client may pre-validate to grey out illegal moves — good UX — but the server's answer is the only
one that counts (`overview.md`). Rejections should be rare in a well-built client and are therefore
worth logging server-side: a spike usually means client and server rules have drifted.
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 is `fold(events)`, which is what gives reconnection,
persistence and replay for one design decision.
Events are the authoritative history: `state = fold(events)`, which buys reconnection, persistence and
post-game replay for one design decision.
**Phase and clock**
**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.
```
StageBegan { day, stage }
PhaseBegan { phase }
ActorChanged { playerIndex | null }
SuperintendentChanged { playerIndex }
DayEnded { day, revenues }
```
Two consequences worth knowing before adding an event:
**Local operations**
```
LocalOpsOptionChosen { playerIndex, option }
TrayMoved { trayId, from, to, movesRemaining }
CarsCoupled { trayId, stock[] } -- automatic, from a Move
CarsDropped { trayId, at, stock[] }
CardDrawn { playerIndex, source, cardId? } -- cardId omitted for other players
CardPlayed { playerIndex, cardId, placement? }
OfficeUpgraded { playerIndex, from, to } -- property change; connections unaffected (§11.3)
CardDiscarded { playerIndex, cardId, toSlot }
DeckReshuffled { }
```
**Trains**
```
TrainScheduled { trainCardId, timetableSlot } -- from the 1D12 roll (§7)
TrainMadeUp { trainCardId, trayId, at }
CarPlacedOnTrain { playerIndex, trayId, stock }
ClearanceRequested { trainId, followingInto, occupiedBy }
ClearanceGiven { trainId, allow }
TrainHighballed { trayId, from, to }
TrainMoved { trayId, from, to }
TrainCompleted { trayId, atDivisionPoint }
```
**Consequences**
```
CollisionOccurred { at, trains[], struckCars[], faultPlayer, penalty: 5 }
RedFlagPlayed { playerIndex, avertedAt }
RevenueChanged { playerIndex, delta, total, reason }
CollisionCountChanged { collisionsToday }
GameEnded { result, winner?, reason }
```
`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
his Limits (§10) — and getting that wrong misattributes a −5 and, in Competitive, contributes to a
floor that ends the game.
- **`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 a projection with other players' private state removed.
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, explicitly so players can audit discards (§2.6) |
| 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 — it is a known starting card (Appendix B) |
| Red Flag held | Public — a known starting card (Appendix B) |
| Revenue totals | **Public** — the race is the game |
| RNG seed | **Secret** — sending it leaks all future shuffles |
| RNG seed and state | **Secret** — either leaks every future shuffle and roll |
```
View {
public : PublicState -- identical for every client
private : { hand: CardId[], redFlag: boolean }
you : PlayerIndex
canAct : boolean
legalIntents? : IntentSummary[] -- optional server-computed affordances
}
```
**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`.**
**Two redaction traps.** First, `CardDrawn` from the Home Office must omit `cardId` for every client
except the drawer — the obvious version of this event leaks the draw to the table. Second, the deck
*count* is public but the *order* is secret; a naive implementation that ships the deck array and
tells the client not to look is not redaction.
`legalIntents` is optional but worth it: the server already computes legality to validate, so
returning the affordance set costs little and removes any need for the client to reimplement rules
like turnout directionality or the four-slot consist limit.
**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 `eventSeq` per game. Clients apply strictly in order and request a replay
on a gap rather than guessing.
- 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. With one actor at a time this is
free — a per-game queue is sufficient and there is no need for anything cleverer at this scale.
- **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.