22 KiB
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, protocol.md,
lobby-and-sessions.md and 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 }
LocalSessionwraps today'sGame: applies through the engine, pumpsadvance, keepshistory, supports undo andlocalStorage. This is solitaire, unchanged.RemoteSessionholds no authoritative state. Posts intents, receivesFrameandMenu, re-renders on push. Undo and local save are absent fromcapabilities, 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:
localStorageis 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.
- Separate seat from identity (D9).
Playercarries id, name and revenue; the seat carries the Office. Touches setup,areaOf, scoring, seating, the Division build and most tests. - 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. - Add
option,status,outcome,players[],handCounttoFrame. - Remove all 11
game.statereads frommain.ts; add a test that it never regains one. - Rewrite
protocol.mdto referenceintents.tsandevents.tsrather 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
- Define
Session; implementLocalSessionaround today'sGame. - Move
main.tsonto it, withcapabilitiesgating 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
- Process bootstrap: env-configured bind address, port, data directory, join secret; static serving.
- Game session host — the loop in §8.
- Per-seat
Frame+Menu, with the board delta reusingreplay.ts's packing. - The redaction test (§7). Fail loudly.
- SSE stream and
POST /api/intent, withseqandLast-Event-ID. RemoteSessionin 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
- Append-only
{engineVersion, seed, config, history}per game; index. - 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
- Join secret; per-game session token; display name.
- Create/join by game code; bot seats; seating UI showing the west-to-east chain; config locked at start.
- Disconnect keeps the seat and announces it; reconnect resumes from
Last-Event-ID. - 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
- The 10 Action and 12 Space-use cards; flip
opponentCardsInDeckinsetup.ts. - 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
.s9pkper 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.