# Component Inventory & Project Sizing The buildable pieces: what code must be written, where each runs, when each runs, and how big each is. The five sibling documents describe the system's *concerns*; this one describes its *parts*. Sizes are **indicative, not estimates of effort**. T-shirt size for MVP and for finished, a one-line driver of that size, and a LOC band for comparison between components. --- ## 1. What the MVP is **Solitaire. One Office. A fixed number of Days. A server talking to a single browser. The display functional, not pretty.** ``` DP(W) [ML] [ Office ] [ML] DP(E) ``` Two Mainline cards, one Office Area, and the player is permanently the Superintendent. Trains enter at a Division Point, cross a Mainline card, reach the Office, get switched and worked, highball, and exit at the far end. ### What solitaire simplifies — and what it does not **It removes players, not rules.** Every rule still runs with one player: the six-Move switching game, mandatory coupling, consist ordering, the four-Laborer freight pipeline, highball conditions, automatic collisions, the twelve-Stage clock, and the Superintendent's following-train clearance decision (you are the Superintendent, ruling on your own trains). | Removed at MVP | Still required at MVP | | --- | --- | | Lobby, matchmaking, game codes | Full rules engine | | Hiding hands from opponents | Phase driver | | Turn arbitration between players | Card catalogue | | Presence, reconnection of others | Track graph and Move legality | | Fedora rotation between players | Board and action UI | | Competitive/Co-op victory modes | Event log and persistence | | Collision floor (Competitive only) | Server and protocol | **Consequence: the engine is the dominant cost and it is front-loaded.** Components 4–6 barely differ between MVP and finished. Most of the MVP-to-finished delta lives in the server and client layers, not in the rules. --- ## 2. Component inventory Execution locations: **Engine** (pure, runs wherever it is hosted — server-side at MVP), **Server**, **Browser**, **Dev** (developer machine or CI; ships nowhere). ### A. Engine core Pure, no I/O, no clock, deterministic given a seed. See [`overview.md`](overview.md) — this boundary is what makes the whole system testable without a server. --- **1. Card catalogue / static content** - **Where:** Engine · **When:** loaded once at startup, immutable thereafter - **Does:** the 52-card deck composition, freight and passenger facility profiles, Modifier effects, train consist specs, track geometries. Source: [`../rules/card-reference.md`](../rules/card-reference.md) - **MVP: S** · **Final: S** · ~200 → ~350 LOC - **Size driver:** it is data, not logic. Grows only if card variants are added. - **Note:** every number here is provisional and will be retuned repeatedly. Keep it as data files, not code, so tuning never requires touching the engine. **2. State model and types** - **Where:** Engine · **When:** always resident - **Does:** the entity model from [`game-state.md`](game-state.md) — Division, Office Areas, Crew Trays, Facilities, decks, yards, clock, Revenue. - **MVP: M** · **Final: M** · ~400 → ~600 LOC - **Size driver:** breadth of the model. Nearly all of it is needed even for one Office; the delta is multi-player and multi-Office bookkeeping. **3. Track graph and movement** - **Where:** Engine · **When:** per Move, and per train step in the Mainline Phase - **Does:** the Office grid as a port graph — turnouts whose two legs are not joined to each other (§A.1), Operational Rail, the no-reversal rule within a Move, mandatory coupling, the Office card's plain junction stubs, and dynamic loss of Operational Rail while `MEN | AT | WORK` is occupied. - **MVP: L** · **Final: L** · ~500 → ~700 LOC · *built: 285 LOC* - **Size driver:** which port pairs each card joins, and the rule that a traversal never leaves by the port it entered. The constraints in `game-state.md` §3 all land here. Barely changes from MVP to finished. **4. Intent validation and application** - **Where:** Engine · **When:** per submitted player intent - **Does:** `apply(state, intent) → events | rejection`. Every legal player action from [`protocol.md`](protocol.md) §1 — the three-way Local Ops choice, Moves, drops, draws, card plays, Freight Agent operations, car placement, Porter and Laborer actions, clearance rulings. - **MVP: L** · **Final: L** · ~800 → ~1000 LOC - **Size driver:** the number of distinct legal actions, which solitaire does not reduce. **5. Phase driver** - **Where:** Engine · **When:** continuously between player inputs - **Does:** `advance(state) → events | needsInput`. Runs the whole automatic side: Mainline movement in `(number, isExtra)` order, highball evaluation, collision resolution, train make-up, Stage and Day advance, Fedora rotation, per-Stage resource reset, victory checks. Pauses for the Superintendent's clearance decision. - **MVP: M** · **Final: L** · ~400 → ~800 LOC · *built: 430 LOC* - **Size driver:** the Mainline Phase. Ordering, highball conditions and collision triggers are all here. Grows with multiplayer turn arbitration and the Competitive collision floor. - **Note:** carries a deliberate safety net — if an actor somehow has no legal action, the turn ends rather than the game hanging. It should never fire, but a hung game is far harder to diagnose than a forfeited turn. - **Note:** this is the component most likely to be missed entirely when planning, because it is not a feature anyone asks for — it is simply how the game moves. **6. Legal-action enumeration** - **Where:** Engine · **When:** whenever the client needs affordances — every state change - **Does:** `legalActions(state, actor) → Intent[]`. Which grid squares a Crew Tray can reach, which cars may be dropped, whether a Laborer has work available, which cards are playable where. - **MVP: M** · **Final: M** · ~300 → ~450 LOC - **Size driver:** it must mirror component 4's rules exactly. The temptation is to reimplement them; the discipline is to share predicates with 4 so the two can never disagree. **7. Seeded RNG** - **Where:** Engine · **When:** deck shuffles, the 1D12 timetable roll, setup rolls - **Does:** deterministic pseudo-random from one stored seed, so any game is exactly replayable. - **MVP: S** · **Final: S** · ~50 → ~80 LOC - **Size driver:** trivial, but must be threaded through the engine rather than called ambiently — a single stray `Math.random()` destroys replay. ### B. Server --- **8. Game session host** - **Where:** Server · **When:** per active game, for the game's lifetime - **Does:** owns one game's state, pumps `advance` until it needs input, routes intents to `apply`, emits events, serializes access so two intents never interleave. - **MVP: S** · **Final: M** · ~150 → ~400 LOC - **Size driver:** at MVP one game and one player, so the pump loop is nearly all of it. Grows with concurrent games and per-player routing. **9. Event log and persistence** - **Where:** Server · **When:** append per event; full read on startup - **Does:** append-only log to disk; state is `fold(events)`. Powers restart recovery at MVP, and later reconnection and the replay viewer. - **MVP: S** · **Final: M** · ~100 → ~350 LOC - **Size driver:** an append and a read-back. Grows with snapshotting, retention and indexing. **10. View projection / redaction** - **Where:** Server · **When:** per state change, per connected client - **Does:** builds each player's permitted view. Public board, private hand, deck **count** visible but deck **order** never sent. - **MVP: S** · **Final: M** · ~80 → ~300 LOC - **Size driver:** nearly free at MVP — one player, so the only secret is deck order. Grows sharply once opponents' hands must be hidden, and it is the trust boundary, so it must be exactly right. **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. - **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. **12. Lobby and session lifecycle** - **Where:** Server · **When:** before a game starts; on reconnect - **Does:** create/join by game code, seating order (which sets adjacency *and* Superintendent rotation), config lock at start, session tokens, reconnection. - **MVP: XS** · **Final: M** · ~50 → ~450 LOC - **Size driver:** essentially absent at MVP — one player, one game, start immediately. This is the single largest MVP-to-finished delta in the system. **20. Process bootstrap and configuration** - **Where:** Server · **When:** once, at process start - **Does:** reads environment configuration (bind address, port, data directory), opens or creates the event log, constructs the session host and transport, wires them together, handles shutdown. - **MVP: XS** · **Final: S** · ~80 → ~200 LOC - **Size driver:** trivial in itself, but it is where [`deployment.md`](deployment.md)'s five portability rules are actually enforced — single process and single port, relative URLs only, all state under one configurable directory, configurable bind address, and no assumption of public reachability. Those rules cost nothing here and are what keep both the plain-web-host and StartOS-service paths open. ### C. Browser --- **13. Client state store** - **Where:** Browser · **When:** on every received event - **Does:** applies events in order, detects gaps and requests replay, holds the current view and affordance set. Never mutates state locally in a way that could drift. - **MVP: S** · **Final: M** · ~150 → ~350 LOC - **Size driver:** ordered application is simple; gap recovery and optimistic prediction are what grow. **14. Board renderer** - **Where:** Browser · **When:** on every view change - **Does:** draws the Division, the Office grid, track geometry, trains and consists, facilities with their boxes and `MEN | AT | WORK` track, the turn chart and timetable. - **MVP: M** · **Final: L** · ~500 → ~1500 LOC - **Size driver:** at MVP, one Office and "functional not pretty". Grows with several Offices, the full Division chain, animation, and responsive layout. **15. Action and affordance UI** - **Where:** Browser · **When:** whenever the player may act - **Does:** presents the three-way Local Ops choice; the switching interface — six Moves, reachable squares, mandatory coupling, drop-in-seated-order, the four-slot limit; card play and placement; Laborer and Porter assignment; the clearance prompt. - **MVP: L** · **Final: L** · ~600 → ~1200 LOC - **Size driver:** **the sleeper of the project.** The switching interface is the hardest UI in the game and it is needed in full at MVP — a player cannot do anything useful without it. It barely simplifies for solitaire. **16. Replay viewer** - **Where:** Browser · **When:** after a game ends — **post-MVP** - **Does:** read-only projection over the stored event log, with playback controls. - **MVP: —** · **Final: S** · 0 → ~250 LOC - **Size driver:** small *because* it reuses component 14. Cheap only if events render standalone — see [`overview.md`](overview.md#post-game-replay--a-desired-future-capability). ### D. Dev-side Real code that must be written and maintained, but ships nowhere. This is the answer to "what might need to run somewhere else." --- **17. Heuristic bot players** - **Where:** Dev · **When:** simulation runs and tests - **Does:** plays legal games without a human — picks a Local Ops option, spots cars, works freight, rules on clearances. - **MVP: S** · **Final: M** · ~200 → ~500 LOC - **Size driver:** a bot that plays *legally* is small; one that plays *well* enough to validate balance numbers is much larger. Only the former is needed first. **18. Balance simulation harness** - **Where:** Dev · **When:** on demand, in batches - **Does:** runs many seeded games, reports Revenue per Day, game length, collision frequency, Crew Tray contention — to retune the provisional numbers. - **MVP: S** · **Final: M** · ~150 → ~400 LOC - **Size driver:** trivial to run games; the work is in reporting and in deciding what to measure. - **Note:** this exists *only* because the phase driver lives inside the engine. It is a loop over `advance` and `apply` with no server involved. **19. Test fixtures and golden replays** - **Where:** Dev · **When:** CI, every commit - **Does:** hand-built states for tricky rules (facing-point switching, collision triggers, consist ordering) plus recorded full games replayed to detect regressions. - **MVP: M** · **Final: L** · ~400 → ~1000 LOC - **Size driver:** proportional to rules surface, which is large. Under-investing here is the most expensive mistake available, because rules bugs are silent. --- ### Estimate accuracy so far Components 1–4, 6 and 7 are built. Actual against the MVP estimate: | Component | Estimated | Actual | | | --- | ---: | ---: | --- | | 1 · card catalogue | ~200 | 465 | data-with-types is more verbose than allowed for | | 2 · state model | ~400 | 349 | close | | 3 · track graph | ~500 | 286 | the port-pair model turned out compact | | 4 · apply | ~800 | 830 | close | | 6 · legalActions | ~300 | 187 | small *because* it delegates every rule to `check` | | 7 · seeded RNG | ~50 | 75 | close | | **Engine subtotal** | **~2,250** | **2,192** | **within 3%** | The individual figures move around by up to 2× in both directions, but the subtotal is close enough that the group-level numbers below are worth keeping. The one systematic lesson: **static data costs more than logic** — component 1 was the worst miss, and components 2 and 3 (types and graph) came in under. ## 3. Dependency graph ``` [1] card catalogue │ ▼ [2] state model ◄──── [7] seeded RNG │ ▼ [3] track graph │ ┌───────┼───────┐ ▼ ▼ ▼ [4]apply [5]advance [6]legalActions └───────┼───────┘ ▼ [8] session host ──► [9] event log │ ▼ [10] view projection │ ▼ [11] transport ◄──── [12] lobby ▲ │ wired together at startup by [20] bootstrap / config ──► [9] │ ▼ [13] client store │ ┌───────┴───────┐ ▼ ▼ [14] renderer [15] action UI │ ▼ [16] replay viewer dev-side: [17] bots ──► [18] harness both depend on [4][5][6] [19] fixtures depends on [4][5][6] ``` Acyclic. Everything flows from the engine outward. --- ## 4. Build order Each step ends at something demonstrable. | Step | Build | Testable when done | | --- | --- | --- | | 1 | 1, 2, 7 | Card data loads; a game state can be constructed and seeded | | 2 | 3 | Move legality provable against Appendix A's worked switching examples | | 3 | 4, 6 | Individual actions apply correctly; illegal ones rejected | | 4 | 5 | **A full solitaire game runs to completion, headless** | | 5 | 19, 17 | Rules regressions caught automatically; bots play legal games | | 6 | 18 | **Provisional numbers validated or retuned against real data** | | 7 | 8, 9, 20 | A game runs in a server process and survives restart | | 8 | 10, 11 | A browser can connect and receive an ordered event stream | | 9 | 13, 14 | The board renders and updates live | | 10 | 15 | **MVP complete — a human plays a solitaire game end to end** | | 11 | 12 | Multiple games, multiple players, lobby and reconnection | | 12 | 16 | Post-game replay | **Step 4 is the important milestone.** A full game running headless proves the rules before a single pixel is drawn. **Step 6** answers whether the numbers are right — while changing them is still free. --- ## 5. Overall sizing | Group | Components | MVP | Finished | | --- | --- | ---: | ---: | | A. Engine core | 1–7 | ~2,650 | ~3,980 | | B. Server | 8–12, 20 | ~610 | ~2,100 | | C. Browser | 13–16 | ~1,250 | ~3,300 | | D. Dev-side | 17–19 | ~750 | ~1,900 | | **Total** | **20** | **~5,260** | **~11,280** | Read these as ratios, not promises. What they say: - **The engine is half the MVP and it comes first.** No way around it — solitaire removes players, not rules. - **The MVP is roughly half the finished system.** Unusually high, because the rules do not shrink. - **The server is the cheapest group at MVP and grows the most proportionally** (~3.5×), almost entirely in lobby, redaction and fan-out — all multiplayer concerns. - **Dev-side is ~15% of the work.** That is proportionate for a game whose rules bugs are silent. --- ## 6. Worked trace — one Move, end to end Following a single action through every component, to show where the seams are. The player moves a Crew Tray onto a card holding two standing cars, which couples them automatically (§A.4). | # | Component | What happens | | --- | --- | --- | | 6 | Legal-action enumeration | Computes reachable Operational Rail squares from the tray's position, respecting turnout direction and the no-reversal rule. The occupied card is among them. | | 15 | Action UI | Highlights those squares. Player clicks the occupied one. | | 15 | Action UI | Emits `Switch.Move { trayId, to }`. Note it does **not** emit a couple intent — coupling is mandatory, not a choice. | | 11 | Transport | Carries the intent to the server. | | 8 | Session host | Checks it is this player's turn and the right phase; hands it to the engine. | | 4 | Apply | Validates the Move via component 3; rejects with `WOULD_REVERSE` / `NOT_OPERATIONAL_RAIL` / `TRACK_OCCUPIED` if illegal. | | 3 | Track graph | Confirms the path exists without a direction change. | | 4 | Apply | Moves the tray, couples the standing cars **in track order**, checks the four-slot limit, decrements Moves. Emits `TrayMoved` and `CarsCoupled`. | | 9 | Event log | Appends both events to disk. | | 8 | Session host | Calls `advance` — no automatic work is due mid-Local-Ops, so it returns `needsInput`. | | 10 | View projection | Rebuilds the view. Nothing is redacted here; the board is public. | | 6 | Legal-action enumeration | Recomputes affordances from the new state — fewer Moves remain, and a fuller consist may now be at its four-slot limit. | | 11 | Transport | Pushes events and the new view. | | 13 | Client store | Applies both events in order. | | 14 | Board renderer | Redraws the tray at its new position with the coupled cars in the consist. | | 15 | Action UI | Updates the Moves counter and the highlighted squares. | Two things this makes concrete. **Coupling never appears as an intent** — it is a consequence the engine produces, so a client that offered it as a choice would be wrong. And **component 6 runs twice**, before and after: affordances are a projection of state, not a response to an action. ## 7. Design decisions this surfaces The point of doing this before coding — questions now answerable, or now visibly needing an answer. **Settled by the shape above:** - **The phase driver belongs in the engine.** Without it the balance harness (18) would have to reimplement the phase loop, and the two would drift. - **`legalActions` must share predicates with `apply`**, not reimplement them. Two independent copies of turnout directionality will disagree eventually, and the failure is a legal move the UI refuses or an illegal one it offers. - **Persist events, not snapshots.** Components 9, 13 and 16 all assume it, and retrofitting it later means re-instrumenting everything. - **The RNG must be threaded, not ambient.** One stray global random call and replay silently breaks. **Settled since:** - **Stack: TypeScript**, chosen precisely so the engine can run in both places. Node 22 executes TypeScript natively by type stripping, so the engine, server and tests need no build step during development; `tsc --noEmit` provides typechecking separately. Note the constraint this implies: **erasable syntax only** — no `enum`, no parameter properties, no namespaces. - **The engine ships to the browser.** It is pure and has no I/O, so component 6 runs client-side for instant affordances without a round-trip, and there is exactly one implementation of turnout directionality rather than two that can disagree. - **The client computes affordances locally**, following from the above. Component 10 sends state, not affordance lists. **Now visible, still open:** - **How is the Office grid rendered — DOM, SVG or canvas?** Component 14's finished size varies by roughly 2× across those choices, and component 15 depends on the hit-testing model the choice implies. - **What does the replay viewer show?** Hands were private during play. Revealing them afterwards is a design question, not a technical one — noted in `overview.md` and still unanswered.