Initial commit
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
# 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`](../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:
|
||||
|
||||
1. **Events must render standalone.** A `TrainMoved` should 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.
|
||||
2. **Finished games keep their log, seed and config.** See the retention note in
|
||||
[`lobby-and-sessions.md`](lobby-and-sessions.md).
|
||||
3. **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`](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`](game-state.md) belongs to the rules engine. Everything in
|
||||
[`lobby-and-sessions.md`](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`](components.md).
|
||||
Reference in New Issue
Block a user