9.7 KiB
Architecture Overview
Station Master as a multiplayer digital game: players connect from a web browser, an authoritative server manages lobbies and game state. Target scale is small self-hosted — a handful of concurrent games, friends-and-family — hosted on a website or packaged as a StartOS service.
No stack is chosen. These documents describe what any implementation must do. Where a concrete shape helps, it is written as pseudo-structure, not as a language.
Written against ../rules/rules-v0.2.md.
The server is authoritative
The server owns all game state and is the sole party that may change it. Clients render state and submit intents; the server validates, applies, and broadcasts.
This is not a default choice — the rules force it:
The game has hidden information. Each player holds up to three cards (four with the Emergency Toolbox) that no other player may see. The Home Office deck is face-down and its order must not be knowable. A client holding full state could read both.
The game has randomness that decides outcomes. D12 rolls set the Eastern Division Point, the opening Superintendent, and — critically — the timetable slot a newly played Timetabled Train lands in (§7). Deck shuffles decide everything else. A client-side RNG is a client-side cheat.
Turn legality is global, not local. After Gap 1, phases resolve phase-major with one player acting at a time. Whether you may act depends on where every other player is. Only a global observer can answer that. The Mainline Phase adds a wrinkle: it is automatic except when §8.1's fourth condition prompts the Superintendent to rule on a following-train clearance — an input that arrives mid-phase, out of the normal turn order, and can be triggered by another player's train.
Collision evaluation must be identical for everyone. After Gap 2, collisions are automatic — a pure function of board state at each movement step. Two clients disagreeing about a wreck is a divergent game, and in Competitive mode a wreck can end the game for everyone (§3.4).
What that means in practice
| Concern | Where it lives |
|---|---|
| Deck order, shuffles, all RNG | Server only. Seeded, so a game is replayable for debugging. |
| Player hands | Server. Sent only to their owner. |
| Board state (track, pieces, facilities, yards) | Server. Public — broadcast to everyone. |
| Whose turn it is | Server. Derived, never client-asserted. |
| Rules validation | Server. The client may pre-validate for responsiveness, but the server's answer is the only one that counts. |
| Rendering, animation, local UI state | Client. |
The client is allowed to know the rules and grey out illegal moves — that is good UX. It is never allowed to decide one.
State, intents, events
Three flows, and keeping them distinct is what keeps the implementation tractable:
client ──── intent ────► server "I want to move Crew Tray 3 to card B4"
│
├─ validate against rules + current phase + current actor
├─ apply to authoritative state
│
client ◄──── event ─────────┘ "Crew Tray 3 moved to B4; 4 Moves remain"
client ◄──── view ─────────┘ per-player redacted snapshot
- Intents are what a player wants to do. They are proposals; they can be rejected.
- Events are what happened. They are facts, ordered, and form the game's history.
- Views are per-player projections of state, with other players' hands redacted.
An event log that fully determines state is worth building even at this scale. It gives reconnection (replay to catch up), persistence (store the log, not a snapshot), and debugging (replay a reported bug exactly) for one design decision.
Post-game replay — a desired future capability
Not to be implemented yet. Recorded here so it is not accidentally designed out, because the architecture above already produces almost everything it needs.
The goal: when a game finishes, players can watch it back at speed — the Division filling with traffic, the wrecks, who was Superintendent when. For a game whose drama is largely invisible while you are playing your own Office, this is worth more than it would be in most games. You spend the game watching your own station; the replay is where you find out what the railroad was doing.
State is already fold(events) over an ordered, append-only log, and all randomness derives from a
stored seed. Replay is therefore a read-only projection over the existing log — no new rules-engine
surface, no second code path, nothing the server has to do differently during play.
Three constraints keep it cheap, and all three are free if honoured from the start:
- Events must render standalone. A
TrainMovedshould carry its from/to, not just an id the renderer has to resolve against live state. An event stream that only makes sense when applied in order to a mutable world is replayable but not displayable — the renderer would need to reconstruct the whole board to draw one frame. - Finished games keep their log, seed and config. See the retention note in
lobby-and-sessions.md. - Timing is a presentation concern. The log has no wall-clock pacing and does not need any — the replay UI decides how fast to advance. Do not record timestamps for the benefit of replay; Stage and phase boundaries are the natural beats.
The one genuine open question is whose view a replay shows. Hands were private during play, and revealing them afterwards is a design choice rather than a technical one — it changes what players learn about each other between games. Worth deciding before building, not during.
Transport
The game is turn-based with exactly one actor at a time, so the traffic pattern is bursty and small — but every player needs to watch the acting player, and above all to watch the Mainline Phase, where 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.
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.
Timing and the absence of clocks
Nothing in the rules is real-time. A Stage advances when the phase sequence completes, not when a timer fires. There are no reflexes to test and no reason to rush a player.
The one place real time may need to intrude is a player who walks away mid-game. That is a session
concern, not a rules concern — see lobby-and-sessions.md. Any turn timer
is a lobby setting layered on top of the rules, never part of them.
What the server does not need
Worth stating, because small self-hosted scale makes several standard concerns disappear:
- No horizontal scaling. A handful of concurrent games fits in one process. Game state lives in memory; the event log is persisted for durability, not for coordination.
- No matchmaking service. Players share a game code (see
lobby-and-sessions.md). - No accounts system, initially. A display name plus a session token is enough to join and reconnect. Real accounts can be layered on later without touching the game engine.
- No anti-cheat beyond authority. Server authority plus redacted views is the whole model.
The one boundary that matters
Keep the rules engine free of any knowledge of networking, storage, or players-as-connections:
rules engine pure. state + intent → events, or a rejection.
▲ no I/O, no clock, no sockets. deterministic given a seed.
│
game session owns one game's state, applies intents, emits events
▲
lobby / transport connections, reconnection, persistence, HTTP + WebSocket
The rules engine being pure and deterministic is what makes the whole thing testable — you can drive a full game through it in a unit test with no server at all, which matters a great deal for a game with this much rules surface. It is also what would make a future Solitaire offline mode or an AI opponent cheap rather than a rewrite.
Everything in game-state.md belongs to the rules engine. Everything in
lobby-and-sessions.md belongs outside it.
The engine is reactive and active. It exposes two entry points, not one:
apply(state, intent) → events | rejection a player did something
advance(state) → events | needsInput the game moves on its own
The second matters more than it looks. Mainline movement, collision resolution, Stage and Day advance, Fedora rotation and train make-up all happen with no player acting — at a table the players do this collectively, and in software something must. Keeping that phase driver inside the engine rather than in the session layer means all rules knowledge stays in one pure deterministic place, and a balance simulation becomes a loop over these two functions with no server involved.
For the full list of buildable pieces — where each runs, when, and how big — see
components.md.