Files
station-master/TODO.md
T
Jesse e76bd77099 v0.5.1 — multiplayer Phase 4: lobby, sessions, reconnection
A real server existed since v0.5.0 but nobody could reach it without a hand-built ?seat=&secret=
URL. This is what makes it a game you can actually create or join.

The server now hosts more than one game: src/server/lobby.ts (new) is pure logic — creating,
joining, bot seats, host transfer, starting — same split session.ts already draws for a running
game. persistence.ts gained one directory per gameId plus a top-level index so index.ts resumes
every saved game on boot. /api/stream and /api/intent now authenticate by session token instead of
?seat=&secret= — the token alone proves identity (lobby-and-sessions.md §1), so the join secret's
job ends at the lobby door.

Bots fill empty seats at Lobby.Start only, never take over a disconnected human (D8): session.ts
gained driveBots(), playing developerBot forward through consecutive bot seats after every accepted
intent. Disconnect keeps the seat and says so — Push gained an optional presence field, built
entirely by http.ts and never routed through the engine, since a disconnect is transport news, not
a GameEvent. Host rights pass to the earliest-joined remaining player if the host drops before
start.

Client: src/web/lobby.ts adds create/join forms and a live seating screen; localStorage replaces
?seat= for reconnecting straight back into a game already joined. A Multiplayer button sits beside
New game; the New Game dialog itself is untouched.

Found only by the live smoke test, not by typechecking: /api/intent read its token from the JSON
body while the client sends it in the query string (matching /api/stream) — every intent failed
"no such game" until caught by curl-level verification.

Doc fix: multiplayer.md's D18 said the player cap was 6; lobby-and-sessions.md §2 says 2-4 with the
reasoning and the test coverage to back it. The two had drifted apart. D18 now reads 2-4.

Not verified: an actual browser walking through the lobby screens — none available in this
environment, same limitation Phase 2's RemoteSession shipped under. 656 tests, 0 failures.

tools/jitsi-harness/ deliberately left untracked — unrelated side-project work, not part of this
release.
2026-08-21 05:25:47 -04:00

1012 lines
84 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# To do
Things worth coming back to. Anything noted here should either get done or get an explicit decision
not to — the point is that nothing quietly evaporates.
Grouped by what kind of work it is — Next, Replay/Save Games, Bot Performance, Play Balance,
Multiplayer, Rules Questions, Other — and ordered within each by how much it is currently costing us.
Reorganized 2026-08-20 from a flat list; nothing below changed, only where it lives. Two duplicate
entries (Heavy Grade orientation, the Local's coach) were merged into one each, and the industry-table
item that had been sitting in a "these are all done" section without actually being done was moved out
to Rules Questions.
---
## Next
Queued from the 2026-08-20 multiplayer planning session (reasoning in Multiplayer below), in order:
1. ~~**Fix the New Train phase car-placement round**~~ — done, see Multiplayer below.
2. ~~**Unify victory conditions across solitaire, competitive and coop**~~ — done, see Multiplayer
below.
3. ~~**Phase 2 of `docs/architecture/multiplayer.md` — server core**~~ — done, see Multiplayer below.
Nothing else queued at the moment.
---
## Replay / Save Games
The replay viewer, the save format, and how a game gets shared.
- [ ] **INVESTIGATE: how would a player publish a replay so other people can watch it?** Today
"Save replay" downloads a JSON file to the player's own machine, and the only way it reaches
the site is by sending it to Jesse to drop into `public/replays/` and redeploy. The question is
what a self-service version would look like.
**The constraint.** The site is fully static — `dist/` is uploaded to File Browser and Start9
Pages serves the folder — and the replay list is a build-time `manifest.json` because static
hosting cannot list a directory. So publishing needs something that accepts a write.
**The one measurement that matters:** a full 5-Day game is **451–1017 bytes** compressed
(brotli), about **600–1150 characters** base64. A save is the seed plus the intents and the
engine recomputes the board, so a whole game fits in a URL.
Four shapes, roughly costed:
1. **Share by link, no server (~1–2 hours).** Put the compressed save in the URL fragment
(`replays.html#s=…`); "Share replay" copies a link and anyone opening it watches the game.
The viewer already parses saves and already has a file-open path, so this is compression, a
hash reader and a copy button. The fragment never reaches the host. It is a link rather than
a gallery: nobody discovers a game they were not sent.
2. **A write endpoint (a day or two, and it is a service).** Accepts a POST, validates the save
by replaying it through the engine — `save-replay.ts` already does exactly that check —
writes the file and regenerates the manifest. The work is the surround: auth or rate
limiting, abuse handling for a public write, CORS, and a deploy story. It also ends "static
hosting is all this needs", which has been load-bearing.
3. **Browser writes to File Browser directly — rejected.** It needs FB credentials in a static
page, so anyone viewing source gets write access to the whole File Browser, and the manifest
would need a read-modify-write from the browser that loses a save when two people publish at
once.
4. **Curated, manual (zero code).** What happens today, and it composes with (1): players send
links, Jesse publishes the good ones.
**The question behind the question is whether a gallery of strangers' games is wanted on a
personal StartOS box at all.** If it is, (1) is the piece (2) would need anyway, so it is the
right thing to build first either way.
- [ ] **`fromSave`'s replayed narration loses "Player X" attribution — found 2026-08-20 building
multiplayer Phase 3, not fixed there.** `fromSave`'s loop (`game.ts`) calls `record(game,
result.events)` without the `actor` argument `submit()` always passes it (`game.ts`'s own
`record(game, events, actor)` — `actor` is what turns "Chose to draw a card" into "Player X
chose to draw a card"). So a restored save, an undone game (`undo` rebuilds via `fromSave`
internally), or a replayed one all lose attribution on every line — invisible in solitaire
because nothing ever compares a `fromSave`-built log against a live-played one (the one test
that compares logs, `test/web.test.ts`'s "leaves nothing in the log describing a move that was
taken back", compares `undo`'s `fromSave`-built log against ANOTHER `fromSave`-built log, so
the missing attribution cancels out both sides), but it would read as broken the moment more
than one seat's history is on screen at once — exactly what the replay viewer and any
multiplayer post-game replay (D20) need to get right. Fixed in `fromMultiplayerSave`
(multiplayer's version of this function, added for Phase 3) by passing `actor` through; not
touched in `fromSave` itself since it's used far more widely (undo, save/restore, the replay
viewer) and deserves its own careful look rather than a fix bundled into an unrelated change.
- [ ] **Review the standalone replay against the site's replay viewer.** `node src/sim/replay.ts
--seed 1234 --out replay.html` writes a self-contained HTML file; the site instead reads JSON
saves from `public/replays/`. Nothing links to the standalone one and its output is gitignored,
so it is a developer tool that happens to look like a product feature. It carries two panels
the site viewer does not — the bot's decision trace ("what it chose, why, and what it passed
over") and the timetable — which is debugging material rather than something a player wants.
Decide: fold the decision trace into the JSON viewer and delete the standalone, or keep it and
accept that it is a tool. No action for now.
- [ ] **The yards are shown on the play page but not in either replay viewer.** The Frame carries
them, so it is a rendering job, not a modelling one.
- [ ] **Undo is unlimited step-back, and that is a decision to revisit.** The save is the seed plus
the intents, so `undo()` replays without the last one and can walk all the way to the deal. The
RNG advances with the replay, so the same play re-rolls the same 1D12 — you cannot undo your
way to a better die. But you CAN see a train's departure Stage and then spend the turn
differently, which is an ordinary solitaire take-back and also a real information leak. Options
if it starts to feel like cheating: make the Stage boundary a commit point, or cap the depth at
the current Stage. Deliberately left open until it has been played with. Multiplayer gets
nothing until there is a proposal/agreement flow — undo there is a table decision, not a
button.
- [ ] **The 5 MB replay size limit is arbitrary.** Invented, not a browser constraint. It has earned
its place — it caught a 5.2 MB payload that turned out to be the whole grid re-serialised every
frame — but the number itself deserves a reason.
- [ ] **Save/restore is not version-aware.** A save from an older ruleset stops replaying rather than
failing loudly, which is the safe direction but says little about what changed. **This has now
bitten once**: both published replays were dead — one got 42 intents into 360, the other 4 of
338 — and nothing said so; they simply ended early and looked like short games. A save should
carry a ruleset stamp and the page should say "this replay was recorded under an older
ruleset and stops at Stage N" rather than presenting a truncated game as a whole one.
---
## Bot Performance
What the developer bot can and cannot yet do, measured. Every revenue figure below measured before
v0.4.7 is low by roughly half a point — see the stub-industry entry — and the rebalance pass should
not read that drop as a deck problem.
- [ ] **THE BOT DOES NOT KNOW TO BRING AN EXPEDITED TRAIN BACK TO THE STATION — new in v0.4.9.**
The `expediteFault` mechanic (§7, Q3) charges 1 Revenue every Mainline Phase an expedited train
is left off the Office square, and the bot has no heuristic that accounts for it: measured over
30 fresh games, one left Train 4 (3/4 Express) parked on Secondary Track from Day 3 Stage 10 to
the end of the game, drawing the fault **26 times**. Not an engine bug — the mechanism fires
exactly as designed — but a clear next bot heuristic: prefer ending a switching turn with any
expedited crew back on the Office square, at least once it has finished the work it went out for.
- [ ] **THE BOT CANNOT SPOT A CAR AT A STUB INDUSTRY, and the cut-ordering rules made that visible.**
Coupling is mandatory on your own square now (v0.4.7), so a crew that sets a car out *between
itself and the only way out* picks it straight back up. At a stub industry that is every
set-out the bot makes: its trains run engine-first with all four cars behind, so the tail cut
always lands on the exit side. The correct play is §A.5's **facing point** move — couple the car
onto the nose, shove it into the stub, set out off the nose, back away — which is the same
cross-turn planning already recorded as out of reach of any bot in "THE RUN-AROUND IS OUT OF
REACH OF ANY BOT" below.
Measured over 200 paired seeds: **-0.55 revenue** (t = -3.63) and freight revenue 1.11 → 0.56.
Filtering self-recoupling moves out of the bot's options took recoupling from **625 of 1,029
set-outs in 60 games to 101 of 677**, and all 101 that remain are this case. Nothing is broken —
the game models the difficulty correctly and the bot cannot yet play it — but **every revenue
figure in this file measured before v0.4.7 is now low by roughly half a point** and the rebalance
pass should not read the drop as a deck problem.
- [ ] **BOT DRIFT ACROSS THIS RELEASE — four measurements, all for the rebalance pass.** Recorded
together so the pattern is visible rather than four relaxed thresholds nobody adds up:
- **Switching work down ~16%, 1.76 → 1.48 productive acts a game** (400 games), because an
expedited train now stands at the Office for a Stage instead of passing straight through, and
a train on the A/D track and the Office square is in the crew's way. That is the change doing
its job rather than a fault — but it is drift. Collisions also went 0.05 → 0.06 and the worst
game went −3 → −9, same cause: the Office fills up. **Separately, the `work > 2` floor that
caught this had never actually been met** — it read 2.16 at 150 games and 1.76 at 400, so it
was passing on which seeds the sample happened to include. Now 400 games and a floor of 1.2.
- **Track laid badly, 7% → 15%** of pieces butting a card that cannot accept them. Forced to
shed on turn one, the bot would rather lay a piece than discard it; a player would discard
the ones with nowhere good to go. It also means the district-size gain from the new deal is
partly padding rather than useful railroad.
- **Interlocking placed, 15/60 → 7/60 games.** Departure Revenue pulls the bot toward other
work and it spends its opening on the track it was dealt.
- **Aimless shuttling in 3 games of 16** — thirteen are clean, so this is a minority behaviour
rather than the every-game waste the detector was written for.
Each floor was moved to match what is measured, with the reasoning written into the test. None
is a crisis on its own; together they say the bot spends its openings worse than it did.
- [ ] **The bot was partly living off an illegal placement.** Barring curves from the Running Track
(they have no east-west road and dead-end the main) cost it districts 28.0 → 19.7 cards and
revenue ~2.0 → 0.8. It has no plan for where a curve should go once the easy square is gone.
Same root cause as "the bot cannot get a crew next to an industry" and "THE RUN-AROUND IS OUT
OF REACH OF ANY BOT" below; fix them together, after the rebalance.
- [ ] **THE BOT'S PRIORITIES ARE NOT THE PROBLEM — measured.** Ten heuristic variations, each paired
over 400+ seeds. Every reordering of what the bot prefers came out inside the noise; the only
thing that moved revenue was refusing to schedule a train the Office cannot hold
(**+1.09 ± 0.16, t = 6.79** at 1600 seeds, revenue 1.19 → 2.28). Notable failures, all
instructive:
- **Refusing to bury the engine costs more than it saves** (−0.35, t = −2.98). It works —
burial falls from 8.4 decisions a game to 0.03 — and freight halves with it, because
coupling is mandatory (§A.4): the moves that bury the engine ARE the moves that pick cars
up. Burial is the price of collecting, not a mistake.
- **Reserving Moves to get home costs 0.55** (t = −2.32), though 62 of 120 trains left on the
board at game end were stranded in the district. The switching work is worth more than the
departures.
- **Granting clearance when the train ahead has one Stage left is −0.98** (t = −5.24). Trains
move in numeric order, so a follower can enter the region the leader still occupies before
the leader moves. "About to leave" is not "gone".
- Preferring coaches at make-up, stocking the platform first, playing Interlocking earlier,
hunting the Depot in the Departments: all within noise, and three of them were exact
no-ops — Interlocking sits in hand alongside a train card **0.04 decisions a game**.
The funnel says why: only **8% of Cargo phases** have a stocked green box, and the bot already
takes 42% of the turns where stocking is productive. The opportunities are not there to be
prioritised better. What is left is the economy itself, which is a deck question.
- [ ] **The bot cannot get a crew next to an industry, so Flying Switch never fires.** Industries are
now stub-only and the bot places 2.23 a game (was 3.84), in districts averaging under two rows
deep. `flyingSwitch` is exempted by name in the reachability sweep in `sim.test.ts`; deleting
that line is the test that this is fixed. Same root cause as the item below.
- [ ] **THE RUN-AROUND IS OUT OF REACH OF ANY BOT, AND THE DECK IS WHY — measured, five ways.**
"Teach the bot to plan across turns" was tried properly and does not work. Every attempt is
neutral or negative, and they fail for one reason that the numbers make plain.
| attempt | result |
|---|---|
| hold ALL track for the siding | **−0.70** (t = −3.27) |
| hold only CURVES, the closing piece | **−0.26** (t = −3.22), district 17.9 → 16.7 cards |
| finish a run before cutting another way down | 0.00 — 398/400 games identical |
| treat a second turnout as the closing piece | 0.00 — **400/400 identical** |
| spend a curve only on a square that CLOSES | −0.11, and only 15 games in 400 differ at all |
**The pieces never meet.** Over 12,000 Local Operations turns: a turnout and a curve are in
hand together on **0.3%** of them, and a turnout with a MATCHING-hand curve on **0.2%** — about
once every eight games. A run-around needs five specific pieces of the right hands in a usable
order; the bot does not get to the two-piece prerequisite.
And it is not hand pressure. The hand is FULL — mean 2.66 cards, at the three-card limit on
78% of turns. The bot plays 11.4 track cards a game and discards 1.5, so it spends the pieces
as they arrive because a piece that builds anything outscores holding one that might build
more later. Holding is the only counter, and holding measures worse every way it is tried.
This is a consequence of moving track into the deck, not a bot weakness: 91 run-arounds per 100
games when track was a private 26-piece supply the player chose from, 29/100 once it was drawn,
4/60 now. **If the run-around is meant to be the central switching puzzle — and the rules
present it that way — the supply has to change, not the player.** Options: give track its own
hand or yard the way the prototype did, raise the hand limit for track specifically, or print a
siding as a single card. Nothing else reaches it.
- [ ] **Re-run the three "worth ~0" action-mix experiments against the new floor.** Capping the draw,
pairing the two halves of a load, and restricting Enhancements were each measured "within noise
of zero" over 400 games — but at 400 games the standard error is ±0.33, so a real +0.5 would
have looked like nothing. They are nearly free to re-run now and at least one may have been
discarded wrongly.
---
## Play Balance
Numbers chosen to fix a measured problem rather than taken from the design. Revisit once the victory
target is settled and freight carries its intended share; read no balance conclusion from a revenue
number until the rules stop moving.
- [ ] **A DISTRICT CAN NOW ONLY WIDEN AS FAR AS ITS MAIN REACHES (v0.4.8) — worth watching in the
rebalance rather than acting on now.** Track stays inside the Limits at every row, so extending
the Running Track is the only way to buy room for sidings, and a straight laid on the sign is
worth more than it was. The bot barely notices — it built outside its own Limits 5 times in 100
games — but the bot also builds close to its Office; a human building deliberately hits this on
the first wide district, which is how it was reported. If territory turns out to be the real
constraint on freight, this is one of the two places to look (the other is the track supply,
in Bot Performance).
- [ ] **REBALANCE, once the rules are right — deliberately deferred.** Card counts, industry counts
and the track mix all need a pass together, and none of them should move until the rules stop
moving. Standing distortions to account for when it happens: offices are doubled (Q12) and
industries tripled (Gap 12), both tuned when the deck held 139 cards and **no track**; it now
holds 235 of which 96 are track, so every draw is diluted by 41% — precisely the pressure
those multipliers exist to relieve. The 8 sharp curves have already been taken out on that
argument; offices and industries are the two left. Until then, read no balance conclusion from the revenue
numbers; they are a functionality signal only.
- [ ] **Superseded 2026-08-20 by the victory-condition redesign (Multiplayer) — kept for the
measurements.** `minCombinedRevenue` replaces the fixed target these numbers were read
against; re-measure once that lands rather than off this. **RE-MEASURE THE BOT AT THE NEW
DEFAULTS.** Both provisional rules below are now **settings on
the New Game dialog** rather than fixed choices, and the defaults are not what the numbers in
this file were measured under: the opening hand defaults to **three random cards** (the
prototype rule) rather than 3+3, and **train revenue per transit defaults to 0** rather than 1.
That second one is the big move — it was worth ~5.4 of a 7.0 mean, so the bot's revenue should
fall to roughly the working freight-and-passenger economy alone, which is the number this game
has actually been trying to read all along. Every mean, floor and threshold quoted below and in
the tests predates it. The three revenue rates run 0–5, so the useful next step is a sweep
rather than a single re-run.
- [ ] **Not superseded by the 2026-08-20 victory-condition redesign (Multiplayer) — these two stay
`houseRules` dials, separate from the new `GameConfig` victory dials.** Noted only so the two
redesigns aren't conflated. **REVIEW THE TWO NEW RULES ONCE THEY HAVE BEEN
PLAYED — both went in provisional, and both are
now selectable rather than fixed.** Jesse's call, both implemented and measured, both flagged
in `rules-v0.2.md`. What follows is what was measured when each was the only option.
**The opening deal (3 track + 3 other, from two separately shuffled piles).** It did what it
was aimed at, modestly: run-arounds **4/60 → 7/60** and districts **17.9 → 20.3 cards**, with
revenue unmoved on its own (−0.1, inside noise). Still nowhere near the 91/100 of the
private-supply era, so the supply question is softened rather than answered. Two things to
watch at the table: whether opening with six against a limit of three is a real decision or
just bookkeeping, and whether three is the right number of each.
**~~One Revenue for every train that clears your section.~~ Now: one Revenue to EVERY player
when a train completes its run.** Jesse's revision in v0.4.2. The first version paid the Office
a train departed, which on a five-Office railroad paid five separate times for one train and
paid most to whoever it passed first. It pays once now, when the train runs off the end of the
Division, and it pays the whole table — getting a train the length of the railroad is the
shared achievement, and every Office it crossed had to clear it.
Solitaire is nearly unmoved (7.0 → 7.3 mean over 200 games) because one player's departures and
completions run at almost the same rate; **in a multi-player game the shape is completely
different** and needs measuring once multiplayer exists — N players × 1 per completed run
against the old N payments per train. **The victory-target question stays live**: 20 over 5 Days
is still reachable largely on traffic, which is either the intent or an argument for raising it
— and at the new default of 0 per transit it is not reachable on traffic at all, which is the
first thing a playtest should check.
- [ ] **The marginal Local Operations action is worth ~0, and that is the real ceiling.** Three
separate attempts to spend the 60 actions better — capping the draw, pairing the two halves of
a load, restricting Enhancements — each measured within noise of zero over 400 paired seeds.
76% of the time an outbound industry has neither a stocked green box nor a spotted car, and
only 5% of Stages have a single workable facility anywhere, yet redirecting actions at that
does nothing. Something upstream limits how much work exists to do at all; find out what
before spending more effort on the option mix.
- [ ] **Freight was stuck at ~2.7 loads a game and three fixes have not moved it.** Sidings,
facility placement, car selection and the discarded-load leak all raised revenue (3.2 → 6.5)
without raising `loadStarted` past 2.7. The chain is not leaking and the cars are arriving
correctly (57% of drops land on a facility that wants them, 0% on one that does not). The
binding constraint is now upstream of routing: 60 Local Operations actions a game, and a load
needs a stocked green box AND a spotted car AND a free Laborer to line up in the same Stage.
Measure how many Stages have all three before changing any heuristic — the answer may be that
the economy, not the bot, is what caps freight.
- [ ] **The rolling stock supply is a guess.** `ROLLING_STOCK_SUPPLY` (coach 8+8, boxcar 10+10,
hopper 8+8, reefer 5+5, tank 6+6, caboose 6) is marked provisional in `content.ts` and was
scaled alongside the Gap 12 industry increase. Now that the Classification Yard returns stock
only when the Division Yard empties, these numbers set the real supply pressure. Adjust from
playtesting rather than theory, and watch whether industry density feels light or heavy at the
same time.
- [ ] **Office card density** (Depot 4→8, Station 2→4, Terminal 1→2). Chosen to remove a 25% chance
of an unwinnable opening deal. Blunt: it lifts the whole ladder and dilutes every other
category. The better answer may be fewer Terminals, a cheaper first upgrade, or more A/D
capacity at the Whistle Post itself.
- [ ] **Industry density** (9 → 27, Gap 12). Restored roughly the prototype ratio. The "freight is
only 13–18% of gross" figure that motivated this was partly a measurement bug (see the
`stats.ts` item in Done) and partly the car-selection bug; freight now runs at 37%. Worth
re-deciding whether 27 is still the right number now that the industries are actually served.
- [ ] **Train density.** Left alone by decision, but noted: 22 train cards in 140 are drawn less often
than 22 in 115 were, and trains scheduled fell 2.9 → 2.1 as a side effect of the other density
changes.
- [ ] **Superseded 2026-08-20 by the victory-condition redesign (Multiplayer) — kept for the
measurements and the reasoning.** `LENGTH_PROFILES.target` (20 over 5 Days, `standard`) is
retiring in favour of `minCombinedRevenue`, defaulting to `3 × players × days` (15 for
1-player/5-day, not 20) — a different number, deliberately not tuned to match this table.
Whether the Office-ladder bottleneck below still applies at the new default is worth
re-measuring once the redesign lands, but the fixed "20" this data argues against no longer
exists as a target. **The victory target (20 over 5 Days) is out of reach by a factor of
about four, and the Office ladder is why.** Measured over 800 games with the tuned bot, which
no longer throws
revenue away on collisions (0.0 a game, down from 0.4):
| trains scheduled | games | revenue | | Office reached | games | trains | revenue |
|---|---|---|---|---|---|---|---|
| 0 | 110 | 0.67 | | Whistle Post | 297 | 0.81 | 0.62 |
| 1 | 379 | 1.69 | | Depot | 272 | 1.49 | 2.92 |
| 2 | 234 | 3.72 | | Station | 176 | 1.89 | 4.36 |
| 3 | 68 | 5.68 | | Terminal | 55 | 1.93 | 5.04 |
| 4 | 9 | 5.78 | | | | | |
Revenue is almost exactly linear in trains scheduled — about **1.9 a train** — and trains are
capped by A/D capacity, which is the Office tier, which is a card you have to draw. So the
whole economy hangs off one valve: **37% of games never leave the Whistle Post and earn 0.62;
53% of all games earn nothing at all.**
Extrapolating the line, 20 Revenue needs roughly **11 trains and therefore 11 A/D tracks**. A
Terminal has four. The target is not merely missed, it is structurally unreachable under this
deck at this Office ladder — no amount of bot skill closes it, and the best game seen in 800
was 26 against a median of 0.
The three ways out are all yours to choose between, and they are different games:
1. **Lower the target** to what a 5-Day game can produce (6–8 looks like the honest number).
2. **Open the valve** — more Office cards, or a cheaper first upgrade, or more A/D capacity at
the Whistle Post, so the ladder is climbed rather than drawn.
3. **Raise revenue per arrival.** It is 0.46 today; each arrival can in principle pay 2 for
passengers alone. That is the freight/passenger conversion problem, not the traffic problem.
Nothing here is a bot weakness any more, which is what this measurement was waiting on.
---
## Multiplayer
Deferred while planning the server; decisions and reasoning are in `docs/architecture/multiplayer.md`.
- [ ] **Let the game join a call and talk to the table.** Long-term. If the game could join a Zoom,
Teams or Jitsi call and post into its chat, it could carry the whole table's shared state
without anyone alt-tabbing: the history of actions as they happen, and a prompt when someone
is holding the game up — "Now waiting on player Alice to complete the Cargo phase."
- Further out, audio into the same call: a crash when a collision happens, a bell as the Stage
clock turns over.
- Further out still, a nudge on a timer — if a player has not moved within some interval, the
game says so, by beep or by spoken line: "Still waiting on Alice to complete the Cargo
phase." That turns the turn chart's "waiting on" chip into something a distracted table
actually notices.
- [ ] **Multiplayer train make-up is a round, not one player's job.** When a new train is built,
players take turns adding cars to the consist; in solitaire one player does all of it. The
engine currently has no per-player turn within the New Train phase, so this is unbuilt rather
than wrong.
- [ ] **MULTIPLAYER — three things deliberately deferred while planning the server.** Decisions and
reasoning are in `docs/architecture/multiplayer.md` §11; these are the ones left open.
- **Bots should take minimally damaging, defensive actions when a player steps away**, so a
game is not permanently halted. Deliberately NOT automatic today: a turn timer forfeiting is
different from a bot competing, and the clearance ruling is the one decision that changes
another player's score. Bots fill empty seats at lobby time only (D8).
- **Let a player resign and hand their railroad to a bot** to finish. Same care needed as
above, but it is consented rather than imposed.
- **A forcing turn timer — explicitly NOT in the design.** `lobby-and-sessions.md` §5 used to
specify one: on expiry the server took "the safest legal action", including denying a
clearance. Cut in the review, because it is the same objection as a bot playing for an absent
player — the clearance decision changes somebody else's score, so anything that answers it
automatically changes the game. Explore later if halted games turn out to be a real problem
at a real table; the reasoning worth keeping is that **deny** is the safe default, since a
held train costs a Stage and a wrecked one costs 5 Revenue and feeds the collision floor.
- **~~The opening D12 for the Eastern Division Point (§4.4) decides nothing.~~ Done in
v0.4.1** — it orders the whole chain now, west to east by ascending roll. The lobby still owes
it a display: `state.openingRolls` is kept so clients can show the rolls forming the chain
rather than only the result (`lobby-and-sessions.md` §4).
- **Revisit the join secret** (D14). One server-wide secret, passed out of band, gates create
and join. Enough for a private box, probably not enough if `stationmaster.<domain>` is
pointed at the open internet for long. Note that one-game-at-a-time per person is expected
usage and deliberately NOT enforced — enforcing it needs cross-game state whose only job is
deciding when to release someone, and getting that wrong locks a player out.
- [x] **~~WHY DOES A 4-PLAYER COMPETITIVE GAME END AFTER ~16 STAGES OF A POSSIBLE 60?~~ Answered
2026-08-20: the collision floor, not the revenue floor.** Traced `checkVictory`
(`advance.ts:1086-1133`): in competitive mode the revenue floor can only fire at the exact
Day-5 boundary (Stage 60), so it structurally cannot explain a 16-Stage ending. Only the
collision floor can (`advance.ts:1076-1080`, 3 collisions in one Day, checked at every Stage
boundary). `collisionsToday` is one counter every seat feeds, so a 4-player table burns a
fixed shared budget roughly 4x faster than one player would. Also: `multiplayer.md` §3's
8-game sample predates `DEFAULT_HOUSE_RULES` (v0.4.2) and most likely ran under what is now
`LEGACY_HOUSE_RULES` — that sizing data is stale on top of the collision-floor explanation.
Jesse's call, 2026-08-20: keep the collision caps flat rather than player-scaled (below), so
16-Stage games under default settings are an accepted, deliberate outcome, not something to
re-tune away — re-measure `multiplayer.md` §3's sizing table once the redesign lands, but
expect similar early endings by design.
- [x] **~~Victory conditions unified across solitaire, competitive and coop~~ — designed and
implemented 2026-08-20.** One shared, fully-configurable set of `GameConfig` dials replaces
`LENGTH_PROFILES.target`, `VictoryCondition: 'firstToTarget'` (confirmed dead — grepped, never
selected anywhere in the codebase today) and the flat `COLLISION_FLOOR_PER_DAY` constant:
| dial | meaning | default |
| --- | --- | --- |
| `days` | how many Days the game runs | 5, all modes |
| `minCombinedRevenue` | everyone loses if the table's total Revenue is below this when Days run out | `3 × players × days` — reuses `collectiveRevenueFloor()` (`content.ts:1020`), now also applied to solitaire (1 player) rather than competitive-only |
| `maxCollisionsPerDay` | everyone loses immediately, mid-game, once collisions in one Day reach this | 3, **flat — not scaled by players.** Jesse's call: more players means more independent chances to collide, not a bigger shared budget, so multiplayer is deliberately riskier than solitaire at the same default |
| `maxCollisionsTotal` | same, summed across the whole game | 5, flat, same reasoning |
| `pvpCardsAllowed` | whether the 22 opponent-directed cards (still unbuilt, see below) are in the deck | forced off in solitaire and coop — no valid target for them in either — on by default in competitive |
`0` means "off" for every dial. Win/lose shape is otherwise unchanged from what solitaire
already does: most Revenue when Days run out wins, unless `minCombinedRevenue` was missed, in
which case everyone loses — just made configurable per game instead of a fixed `length`
lookup. Coop keeps its existing "score is the table's total" model, now against a
configurable floor instead of `profile.target * players.length`.
**New Game dialog:** one shared dialog for all three modes, per Jesse — a mode radio button
at the top, the same field set underneath for all three, greyed out wherever a mode forces a
value (the PvP checkbox in solitaire/coop). Solitaire gains the four new dials alongside the
starting-hand and revenue-rate fields it already has; picking a mode only changes the
defaults, never the field set. **Deal stays disabled for Competitive/Co-op** with a "needs a
server" note, since Phase 2 didn't yet expose a way to actually start one from the browser
(see below) — only Solitaire's Deal path is wired to a real game today.
- [x] **~~New Train phase car-placement is one player's job even in competitive mode~~ — fixed
2026-08-20.** §7 (`rules-v0.2.md:346-363`) is explicit: "starting with the Superintendent and
working left, each player may place ONE car... the round repeats... until the consist is
full," with a worked 2-player example. `newTrainPhase` (`advance.ts:187-282`) never
implemented the round: `enterPhase` resets `actorOffset = 0` on entering the phase
(`advance.ts:172`) and `newTrainPhase` never incremented it the way `playerPhase` does for
Local Ops (`advance.ts:140`), so the actor was always the Superintendent alone, for every car
of every train made up that Stage. Fixed by reading the round position off
`tray.consist.length` instead — it already counts placements toward that tray and resets per
train with no new state needed. Test in `multiplayer.test.ts`, "the New Train phase
car-placement round rotates."
**Found in the process, not fixed, logged separately:** `newTrain.passCar`'s `check()`
(`apply.ts:959-967`) tests whether the *entire* Division Yard is empty rather than whether a
car suitable for *this* tray exists, and `reduce()` has no case for `carPassed` at all
(`apply.ts:2246-2247`, falls to `default: break` — applying a pass currently mutates nothing).
Unreachable in practice today: `trainNeedingCars` only ever flags a tray that already has a
suitable car waiting, so a legal `passCar` for the flagged tray can't occur. Only matters if a
future change lets the New Train phase address more than one tray at a time. Not fixed here —
nothing to verify against an intent that can't legally fire.
- [ ] **The redaction test (multiplayer.md §7) is more done than the plan suggests, but the
exhaustive check is still missing.** `test/multiplayer.test.ts`'s "the view shows one seat at
a time" section (added earlier) already proves `snapshot(s, ..., viewer)` gives each seat its
own hand, board, Revenue and impediments — traced `snapshot()` itself
(`src/sim/view.ts:1180-1219`): `hand` reads only `s.decks.hands.get(viewer)`, `deck` is a
count, other seats' hands appear only as `.length`, and `Frame`'s type has no `seed`,
`rngState` or card-id-dictionary field for anything to leak through by accident. What exists
is all spot-checks, though — "this seat's Frame has the right hand length." What's still
missing is the exhaustive one §7 actually calls for: serialize a seat's `Frame` and assert it
contains none of another seat's actual card ids and no deck order, so a future careless edit
is caught rather than assumed safe. Doesn't need a server — buildable now against `snapshot()`
and the existing `game()`/`playGame` harness already in `multiplayer.test.ts`. Held for now,
2026-08-20.
- [ ] **D19's switching-instrumentation still needs writing, once real people are playing.** "13%
for the bot" (`multiplayer.md` D19) was a one-off measurement, not code — nothing in `bot.ts`
or the sim tools logs it today. It needs live human wait-state data, so it can't usefully land
before Phase 2 and realistically not before Phase 4 (real people at a lobby, not bots). A few
lines when the time comes: log whether a legal local-only action existed for a waiting player,
and whether they took it the moment their turn arrived.
- [ ] **THE 22 OPPONENT-DIRECTED CARDS — 10 Action, 12 Space-use — ARE OUT OF EVERY DECK UNTIL THEY
ARE BUILT.** Jesse's call. They were already cut from solitaire (Q6, no legal target with one
player); they are now cut from the competitive deck too, because `checkPlay` answers both
categories `NOT_IMPLEMENTED` and dealing them would make ~9% of draws reject outright. Flip
`opponentCardsInDeck` in `setup.ts` when they land. They are played AT another player —
Watertower, Derail, Railroad Crossing and so on — so they are genuinely multiplayer work, and
**three Enhancements are waiting on them**: Facing Point Locks, Water Column and Overpass are
wired and read, and fire only against these cards. Until then those three are dormant by
design rather than broken.
- [x] **Multiplayer proper — Phases 0-4 done (v0.4.0 through v0.5.1), Phases 5-6 to go.** The
full plan is `docs/architecture/multiplayer.md` §12. Phase 2 (server core) landed in one pass:
- `src/sim/frame-delta.ts` — the live per-seat board delta (`deltaFrame`/`applyDelta`), a
smaller, purpose-written replacement for reusing `replay.ts`'s `compress()` — that function
interns strings across a whole recorded array, which a live single-frame push has nothing to
intern against; only its one-step-back "null if unchanged" idea carried over.
- **Found and fixed a real bug tracing this**: `actionMenu(game, seat)` only used `seat` for the
`hand` field — everything else came from `currentActor(game)` regardless of who asked, so a
server computing every connected seat's Menu would have handed the acting player's legal
moves to a waiting seat, paired with the wrong seat's cards. Fixed in `game.ts` with a guard;
tested in `multiplayer.test.ts`.
- The redaction test (§7) is built — `test/redaction.test.ts` — and passed on the first run
against the existing `snapshot()`, confirming it was already correct, not just apparently so.
- `src/server/session.ts` — the game session host (pure logic, no sockets, reuses `game.ts`'s
`Game`/`submit`/`currentActor`/`actionMenu` wholesale rather than re-deriving intent
application/narration). **Found while building it**: `submit()` derives the acting player from
`currentActor(game)` itself and does not check who is calling it — safe for `LocalSession`
(one possible caller) but not for a server, so the session host verifies `seat ===
currentActor(game)` itself before ever calling `submit`, rejecting with `NOT_YOUR_TURN`
otherwise. Idempotent resend (a repeat `seq`) and the illegal-intent path (checked via
`check()` directly, so a rejection never pollutes the shared narration log with "not allowed"
text meant only for the submitter) are both handled here too.
- `src/server/http.ts` / `src/server/index.ts` — plain `node:http`, no framework (confirmed
nothing to reuse and nothing else warranted — zero runtime dependencies anywhere else in the
project). `POST /api/game`, `GET /api/stream` (SSE, per-seat, with a 20s heartbeat and an
`id:` line per push), `POST /api/intent`, and static serving of `dist/` so the server can be
same-origin with itself (D16). No `gameId`/multi-game concept yet — one game per process,
matching "Phase 2 has no lobby."
- `src/web/session.ts` gained `createRemoteSession`; `main.ts`'s `start()` switches on `?seat=`
presence (D4 — one bundle, unchanged). Every `LocalSession`-only call site in `main.ts`
(`seed()`, `save()`, `undo()`, the New Game dialog) now goes through an `isLocal()` type guard
rather than assuming, since `session` can now be either.
- **Found and fixed a real infrastructure bug**: adding `test/server/` broke `npm test`'s glob.
`"test": "node --test test/**/*.test.ts"` relied on bash's non-globstar behaviour of passing
the *literal, unexpanded* pattern through to Node (which then globs it correctly itself) —
that only happens when the pattern matches *no* files at the shell level. The moment a
subdirectory existed, bash expanded it to just that one file, and `npm test` silently ran only
the new suite. Fixed by listing both depths explicitly:
`"test": "node --test test/*.test.ts test/**/*.test.ts"`.
- Verified two ways: `test/server/session.test.ts` exercises the session host directly (no
sockets); a live end-to-end curl smoke test (server started, a 2-player game created, two SSE
streams opened, an intent rejected from the non-acting seat, accepted from the acting seat and
broadcast to both, a resent `seq` producing no second push, and the board correctly nulled on
the second push) — see the session transcript. **Not verified**: an actual browser — no
browser binary exists in this environment, so `RemoteSession`'s DOM-facing code
(`EventSource`/`fetch` wiring) compiled and typechecks but was not clicked through visually.
**Phase 3 (persistence/resumption) done, same session, 2026-08-21.** Per §12 steps 14-16 and
`lobby-and-sessions.md` §5-6 (unusually concrete — the exact storage shape was specified, not
designed here):
- `src/server/persistence.ts` — `game.json` (`{engineVersion, seed, config, playerNames,
history, status, createdAt}`) and `turn-timings.json`, both atomic-rewrite-then-rename, no
`gameId`/index yet (one game per process, same deferral as Phase 2's `gameId`).
- `game.ts` gained `fromMultiplayerSave` — `fromSave`'s multi-player sibling, built on
`newMultiplayerGame`. **Found while testing it**: `fromSave`'s replay loop calls
`record(game, result.events)` without the `actor` argument `submit()` itself always passes,
so every replayed line loses its "Player X" attribution — invisible for solitaire (nothing
ever compares a `fromSave` replay against a live-played log; `undo`'s rebuilt game is itself
`fromSave`-built, so the one test that compares logs only ever compares two unattributed
replays against each other) but immediately visible for multiplayer, where anonymous "Chose
to..." lines are unreadable the moment there is more than one seat. Fixed in the new function;
**`fromSave` itself still has the gap** — not touched here, since it is used far more widely
(undo, save/restore, the replay viewer) and deserves its own careful pass rather than a
touch-in-passing. Worth its own TODO item if picked up.
- `session.ts` gained `exportSave()`, `resumeSession()`, and turn-timing tracking — a `TurnTiming`
span (player, phase, day, stage, start/end wall-clock) closes and reopens whenever the acting
player, phase, Day or Stage changes; recorded entirely in the session host, never touching the
engine (which must stay clock-free and deterministic) and never stored inside `history` (a
replay must reproduce a game from decisions alone). No reporting/aggregation/UI on this data
yet — §5 calls that "optional... if unobtrusive," and the Phase 3 deliverable is the data
being recorded, not a view of it.
- `index.ts` loads `game.json` on boot before starting the HTTP listener: version match →
`resumeSession`, replayed straight through; mismatch → refused explicitly and loudly (the
file is left untouched, so rolling the running version back recovers it), server starts with
no active game rather than replaying under the wrong rules.
- Verified live, matching this phase's own "done when": server started against a fresh data
directory, a 2-player game created, intents submitted from both seats, **the server process
killed and restarted**, both `?seat=` streams reconnected and picked up exactly where they
left off — same Day/Stage/phase, correct whose-turn-it-is, correct narration attribution.
Separately confirmed the version-mismatch path: hand-edited `engineVersion` to a bogus value,
restarted, server logged the refusal and started with no active game (confirmed via `POST
/api/game` succeeding rather than 409ing).
**Phase 4 (lobby, sessions, reconnection) done, 2026-08-21 — v0.5.1.** Per §12 steps 17-20 and
`lobby-and-sessions.md` in full:
- `src/server/lobby.ts` — pure logic, no sockets, no filesystem, same split `session.ts`
already draws. `createLobby`/`joinLobby`/`setBotSeat`/`reassignHost`/`startLobby`, a
speakable game code (`RAIL-4471` style, from a small railroad-word list rather than a
dictionary — read aloud across a table, not typed from memory), and the 2-4 player cap
(`playerCountAllowed`) — see the doc-fix note below.
- **The server now holds more than one game.** `persistence.ts` gained one directory per
`gameId` (`games/<gameId>/`) plus a top-level `index.json` naming every game, so `index.ts`
can resume all of them on boot rather than the one `game.json` Phase 3 assumed.
`writeGame`/`loadGame`/`appendTiming` needed no signature change — they already took a
directory directly.
- **Session tokens replace `?seat=&secret=` on the running-game routes.**
`lobby-and-sessions.md` §1: the token alone proves identity, so `/api/stream` and
`/api/intent` now read `?token=` and the join secret's job ends at the lobby door
(`/api/lobby/create`/`/api/lobby/join`). `web/session.ts`'s `createRemoteSession` takes
`(token, seat)` — `seat` still passed in rather than learned from a push, since it has to
answer before any push necessarily arrives, and the caller already has it from the
join/create/start response.
- **Bots fill empty seats at `Lobby.Start` only (D8)**, never mid-game. `session.ts` gained
`driveBots()`: after any accepted intent (and once at construction, for a resume that lands
exactly on a bot's turn), it plays `developerBot` forward through every consecutive bot seat
before the push goes out — reuses `legalActions`/`developerBot` wholesale, no new bot logic.
`SavedGame` gained `botSeats: PlayerIndex[]` so a bot seat survives a restart.
- **Host rights pass to the earliest-joined remaining player** if the host's LOBBY connection
closes before start (`lobby-and-sessions.md` §2) — tracked via `Lobby.joinOrder`, a token
list rather than seat order, since a bot-filled seat has no join time of its own.
- **Disconnect/reconnect** (§5): `Push` gained an optional `presence` field — connection news
about ANOTHER seat, built entirely by `http.ts` (which owns the connection table) and never
routed through `session.ts` or the engine, since a disconnect is transport news about a
connection, not a `GameEvent`. The page shows a banner naming who has dropped
(`renderPresence`, `main.ts`) and clears it the moment they reconnect. Reconnect itself needed
no new engine-side work: `session.connect(seat)` already sent a full un-delta'd `Frame`.
- **The client lobby** (`src/web/lobby.ts`, wired from `main.ts`'s `start()`): create-or-join
forms, a live seating screen (host-only bot toggles and Start button, updated over a new
`/api/lobby/stream` SSE), and `localStorage` in place of `?seat=` for "was I already in a
game" — found on load, reconnects straight to `createRemoteSession` and skips the lobby
entirely. A `Multiplayer` button beside `New game` is the entry point; the New Game dialog
itself is untouched, still solitaire-only, its old "needs a server" note repointed at the
new button.
- **Found and fixed while running the live smoke test, not by typechecking:** `/api/intent`
read its token from the JSON body, but `web/session.ts`'s `submit()` — unchanged from Phase
2 — sends it in the query string, same as `/api/stream`. Every request failed `no such
game`. Both sides independently typecheck fine (an HTTP body is `unknown` on the wire), which
is exactly why the curl-level smoke test exists rather than stopping at `tsc --noEmit`.
- **Doc fix:** `multiplayer.md`'s D18 said "player cap 6", citing `lobby-and-sessions.md` §2 —
which actually specifies 2-4 and gives the reasoning (what `test/multiplayer.test.ts` exercises).
The two had drifted apart; "6" was never implemented or tested anywhere. D18 now says 2-4.
- Verified: `test/server/lobby.test.ts` (pure logic — creating, joining, capacity, bot seats,
host transfer, starting) plus new coverage in `session.test.ts` (bot-driving, including two
bots in one game) and `web.test.ts`. A live smoke test through `curl`: create a lobby, join a
second player, start, submit intents from both (including the wrong-actor rejection and an
idempotent resend), reconnect after a real server kill-and-restart, a bot-filled coop lobby
starting and never stalling on the bot's seat, and a disconnect/reconnect presence notice
observed on an open stream. **Not verified: an actual browser** walking through the lobby
screens — none is available in this environment, the same limitation Phase 2's `RemoteSession`
shipped under.
---
## Other
Doesn't fit the above.
- [ ] **Real audio, as committed assets.** Everything the game plays is synthesised from oscillators
(`src/web/sound.ts`), which was the honest choice for a site that fetches nothing — but it is a
placeholder, not the finished sound. Sound therefore defaults to OFF.
- **"All aboard" most of all.** It currently goes through the browser's `speechSynthesis`, so
it is whatever system voice the player happens to have — a robot, not a conductor. A real
clip is the single biggest improvement available here.
- `arrive` (a train pulling into an Office), `depart` (a train highballing out of one) and
`crash` (§10 — a collision) are now synthesised too, v0.4.9 — three chuffing/screeching cues
built from the same oscillator-and-filtered-noise toolkit as `stage`, wired to `trainArrived`,
`trainHighballed` (Office departures only), and `trainsDestroyed`. Good enough to keep as the
real thing rather than a placeholder — no WAV clips needed for these three.
- **Find and add the rest as assets**: steam whistle, grade-crossing bell, couplers clashing.
**Every file added needs three things recorded alongside it: the sound file itself, its
source (where it was obtained from), and its license.** The preferred license is **CC0
("Creative Commons Zero")** — a public-domain dedication: the creator waives all copyright
and related rights, so the file may be used, modified, and redistributed for any purpose,
including commercial, with **no attribution required and no restriction**. That is the
cleanest fit for a file committed straight into the repo, since it needs no attribution to
track going forward. Only fall back to an equally-permissive alternative (e.g. a license that
explicitly permits redistribution with no ongoing obligation) if CC0 isn't available for a
given sound, and record that license's actual terms plainly rather than assuming they match
CC0. Files also need to be small enough to commit, and a check that the "fetches nothing
external" test still passes — assets must be served from the site's own folder, never
hot-linked.
- Keep the synthesised versions as the fallback for anything not sourced, so a missing file is
a quieter game rather than a broken one.
- [ ] **Regions as the primary model (the other half of §8.2).** The Division map now DRAWS regions,
deriving position from what the crossing already cost. The engine still models a crossing as a
countdown of Stages, so two things printed on the cards remain unimplemented:
- `entryPoints` is declared on every Mainline profile and read nowhere. The Heavy Grade card
has five named Start positions, and playing Brakeman is supposed to move your entry point
along the card. The engine gets the same ANSWER by taking a Stage off the crossing, which is
why the derived drawing looks right — but the mechanism is not the printed one, so a card
whose starts do not correspond to its speed would be drawn wrong.
- `implications.md` §6 calls this "the single largest mechanical gap" and asks for typed cards
with speeds and named entries, with crossing time DERIVED from the region walk.
Doing it properly changes movement, so it invalidates every balance figure — revenue 8.7, the
freight numbers, all of it — and needs a full paired re-measure over 400 seeds. Needs the
source Start-position art for the ten card types before it can begin.
- [ ] **`card-reference.md`'s industry table may still be stale beyond Grocer's Warehouse, the Oil
Refinery and Freight House (corrected v0.5.0) — Mine Tipple, Produce Shed and Power Plant were
NOT re-verified.** The v0.5.0 pass corrected three rows (and the "Freight House is not a card"
claim across `card-reference.md`, `glossary.md`, `rules-v0.2.md` and `open-questions.md`) on
Jesse's explicit call. Checking `content.ts` while making that change turned up that
`mineTipple` and `powerPlant` are ALSO base 1 out/in + 1 Laborer in the engine — the same
uniform model as the three that were corrected — while `card-reference.md` still prints Mine
Tipple 3/3/4 and Power Plant 3/3/4, and the "Throughput — why these Laborer counts" section
right below the table is built entirely on those higher numbers. Flagged inline in
`card-reference.md` rather than silently rewritten — this needs the same kind of decision Jesse
made for the other three, not an assumption that the same correction applies, since raising or
lowering Laborer counts is also a balance question, not only a docs one.
---
## Done, kept for the reasoning
- [x] **Put rolling stock back into circulation.** The Classification Yard was write-only — seven
writers, no readers — so 37% of all rolling stock left the game by Day 5. Returning it at the
Day boundary is **+2.32 ± 0.52 (t = 8.79)**, the largest single change measured on this bot,
and it was ranked THIRD and predicted not to matter because the Division Yard never runs dry.
The aggregate was the wrong measure; having the right commodity at the right moment is what
counts.
- [x] **Make Enhancements reachable at all.** The bot never laid a straight on the Running Track
(0.00 in 100 games) because two-arc run-arounds do not need one — so 13 of the 18 Enhancement
cards had nowhere to go, including Interlocking, the only cure for the only penalty in the
game (`no free A/D track`, 27% of gross). One scored straight fixed it: enhancements placed
0.64 → 3.01, collision cost 2.70 → 1.91, worst game −47 → −24. Revenue +0.70 ± 0.74 paired
over 400 seeds — real but not significant alone; the variance reduction is the clearer win.
- [x] **Stop the bot discarding its own freight.** `canStockProductively` did not check the Division
Yard while the engine's `stockOutbound` does, so Freight Agent was chosen when nothing could be
stocked and the follow-through fell through to an unjam that threw a waiting load out of the
green box — 3.10 a game against 2.71 started. Now 0.00. Revenue 6.0 → 6.5, wins 5 → 8 in 100.
Also confirmed **routing was never the problem**: 0% of drops land on a facility that does not
want the car.
- [x] **Why switching work did not become Revenue.** Answered: it was the freight the crew shuffled,
not the shuffling. The chain never leaked — 95% of started loads finished — it was barely
entered, because a load needs a matching empty car spotted and half the industries never asked
for one. Three fixes later (sidings, facility placement, car selection) revenue is 3.2 → 6.0
and freight 26% → 37% of gross.
- [x] **Fix car selection.** Three of six industries were invisible to `wantedCars` — a hand-written
industry→car map naming two industries that do not exist and omitting three that do — so tank
cars were dropped **0 times in 100 games**. Derived from `INDUSTRY_PROFILES` now, and the
second commodity of the two-commodity industries is reachable. Revenue 5.0 → 6.0, freight
share 25% → 37%, wins 1 → 5 in 100.
- [x] **Put the industries on the run-around.** Facility placement was unscored — the first legal
square — so 0.00 facilities a game sat on a loop; now 1.08. The instructive part was the
second bug: scoring facilities onto the siding row dropped run-arounds 91→36, because the
anchor test asked a card's KIND rather than its PORTS and an industry in the line read as a
dead end. Revenue 4.1 → 5.0. Freight did **not** follow, which is the item above.
- [x] **Make the bot build sidings that are sidings.** 0 run-arounds in 100 games → 91. Three bugs,
all scoring on local shape without checking it reached anything; the decisive one was that
`bestTrackLay` never declined a piece, so it spent the track supply on whatever was legal.
- [x] **Teach the bot what a siding is for.** Nose coupling (§A.3) implemented, so approach direction
decides which car is droppable; the bot runs around rather than setting out, when the drop can
follow. Switching activity transformed, revenue unchanged.
- [x] **Curve geometry.** Curves were topologically identical duplicates of turnouts, and nothing
reached north, so a district could only be a vertical column. Now two-port rotatable arcs.
- [x] **Q10 — when track may be laid.** During the "draw a card" option, one piece a turn. Track was a
card when §6.2 was written; a 26-piece supply has no hand to bound it.
- [x] **Q11 — which way a Heavy Grade climbs.** Answered from the card: it prints "(Up)" and "Player
sets orientation", so it is a property of the placed card, not a compass constant.
- [x] **§6.2's reshuffle.** Implemented, and the `deckReshuffled` event it had already declared and
narrated — but never emitted or reduced — is now real. Not yet reached in play: solitaire
Campaign ends with 168.8 of 243 in the deck and four-player Campaign with 86.6, and no run of
any length has emptied it. It is a safety net rather than a live mechanic today, which is worth
knowing before tuning draw rates.
- [x] **Q12 — Whistle Post lock-in.** Players always start at a Whistle Post; office density doubled
instead.
- [x] **~~CLEARING AN INBOUND BOX MINTS A CAR — measured at 1.29 a game against a supply of 80.~~
Re-audited in v0.4.3: rolling stock is EXACTLY CONSERVED, 100 games out of 100, range 0..0.**
The old audit's premise was right — the two directions were not symmetrical — but the asymmetry
has since been closed from the other end. `unloadBegan` and `passengersDetrained` now take their
replacement empty OUT of the Division Yard rather than conjuring it, so a load is a car that
moved rather than a car that appeared: one leaves the yard, one arrives in Classification.
Jesse's description of the tabletop procedure confirms this is the intended model — the token
you push along the MEN|AT|WORK sign IS the car, fetched from the yard by the Freight Agent and
swapped onto the industry track at the end.
Both conjuring fallbacks now **throw** rather than minting, so the leak cannot silently return;
neither fired across the suite or 100 audited games.
**`ROLLING_STOCK_SUPPLY` is therefore unblocked** — it was waiting on this and can now be tuned
in the rebalance pass. (The first audit's own arithmetic was off in the same way mine was on the
first attempt: cars set out on a card, in `card.standing`, are easy to leave out of the count and
make a conserved game look like a leaking one.)
- [x] **`state = fold(events)` was not true, and the docs said it was. Settled: the INTENTS are
canonical.** Measured before deciding — `advance.ts` never calls `reduce`, so **14 of the 46
event types are never reduced**: the clock, and the entire Mainline phase, which is every train
movement in the game. Folding the log rebuilds a district and not a railroad. Jesse's call, and
the cheap one: the plan never needed fold — persistence is `{ engineVersion, seed, config,
history }` (`multiplayer.md` §10) and the wire carries `Frame`s, not events (D2/D3), so
reconnection is a fresh Frame rather than an event tail. Making the phase driver reduce would
have been a rewrite of the most rule-dense code in the project to buy something nothing uses.
Corrected in the README, four architecture documents and six source comments; `test/events.test.ts`
pins the unreduced set so that closing the gap later is a deliberate act, and asserts the
property that does hold. **If you ever do make the phase driver reduce, that test fails and
tells you which docs now understate the engine.**
- [x] **Measure with error bars from now on.** Built: `node src/sim/compare.ts 1600 <tweak>=<n>`
runs the current bot and one variant over the same deals and reports the paired difference.
Pairing drops σ from ~9 on the level to **5.3 on the difference**, so 1600 seeds gives ±0.13 in
about 1m45s — the noise floor is now ~±0.15 rather than ±1.0. Threshold to keep a heuristic is
**t ≥ 3**, and the report prints the better/worse/identical split beside the mean, because a
mean carried by a skewed tail is a different claim from broad improvement.
- [x] **Confirm the Classification Yard rule against the source.** Confirmed, and the guess was
wrong. The rule is: used Rolling Stock to the Classification Yard, used engines and cabooses
straight back to the Division Yard, and the Classification Yard empties ONLY when the Division
Yard is bare — then all at once. The Day-boundary version I had invented was far more generous
and worth **+2.42 revenue a game the game does not actually grant**. Corrected; revenue 9.67
→ 7.25.
- [x] **Enhancements are placed but mostly do nothing.** Measured: forbidding every Enhancement
except Interlocking is worth **-0.01 ± 0.41 (t = -0.04)** over 400 paired seeds. They neither
pay nor cost. Left alone. Unlocking the Running Track straight put
nine kinds on the board (telegraph 0.73, waterColumn 0.57 …), but only Interlocking has a
measured effect. The Telegraph/Telephone/Radio chain adds to the other train's number when
dispatching facing trains, which may be worth nothing in solitaire; Water Column removes a
Watertower; Facing Point Locks prevents Derail, which is multiplayer-only. Worth measuring
what each is actually worth before the bot spends actions on them.
- [x] **`stats.ts` undercounts freight.** Fixed: both halves counted, freight share 39% → 49%.
Worth revisiting the **industry density** decision in Play Balance, which was taken on the old
number.
- [x] **LEFT AND RIGHT ARE ON THE WRONG DIAGONAL — for turnouts and for curves, the same way.** The
engine's `left` turnout is `{stem:'w', through:'e', diverge:'s'}`: a train entering at the
points from the west heads east and the diverging route leaves to its **right**. The engine's
`left` curve is arc `sw`, which turns an eastbound train **right** as well. One consistent sign
error in the hand↔diagonal mapping, and it mislabels every track card a player ever holds.
**The artwork is right and does not change** — `board-svg.ts:462` draws rails from
`connectionsFor()` geometry alone, so only words are wrong. **Decision: flip the `hand` value on
the `TRACK_CARDS` rows AND the two variant tables in the same commit**, so the code keeps
speaking left/right like the physical supply and now means it. Keep the **row order** in
`TRACK_CARDS` untouched: `setup.ts:95` builds the deck by iterating that array, so flipping only
the labels leaves pre-shuffle slot 32 holding a `ne_sw` curve before and after, and
`variantsFor(…)[0]` still `'sw'` — same seed, same board, and every published replay still
plays. Also: `track.ts:126` arc fallback, `track.ts:191-212` doc block, `view.ts:1041` and
`view.ts:1208` diagonal phrases, `bot.ts:837-852` `arcInHand`, seven test files, and the prose
plus ~14 `data-tip="Turnout · left"` strings in `docs/design/track-geometry.html` — which has no
generator and must be hand-edited. Verify by fingerprinting a fixed seed's board before and
after: identical geometry, different words.
- [x] **A turnout should be playable as an UPGRADE, on top of a card already down.** On a straight,
or on a curve whose arc matches the turnout's diverging leg. Nothing like this exists — the only
"upgrade" in the game is the Office tier change, which is explicitly not a card swap
(`apply.ts:1290`), and `canPlaceAt` hard-stops at `if (existing && !isMovableSign) return false`
(`track.ts:518`). **Decision: cars AND enhancements both block it** — `standing.length > 0` →
`UPGRADE_OCCUPIED`, `enhancements.length > 0` → `UPGRADE_ENHANCED`, so an Interlocked straight
stays a straight. Express the curve rule on **arcs, not hands**, so it survives the flip above.
No extra connection requirement: all four turnout orientations are port supersets of a straight
and of any same-arc curve, so an upgrade can never sever an existing join, and the new leg is
allowed to dangle — that is what it is for. The lifted card leaves play, which is already how
board cards behave (`apply.ts:1249` salvages only cards that were *not* placed). Reuse
`card.play` with a placement on an occupied square; `legal.ts:252` must offer those squares for
turnouts, and the `attachments` set at `legal.ts:137` is already exactly that list.
**The other half of this report needs no work:** a turnout carries the through route
(`carriesThroughTrack`), so it is already legal at a Limit, along the Running Track and on
Secondary Track. Confirmed, not re-investigated.
- [x] **A Depot shows three MEN AT WORK boxes it can never work.** Offices are Passenger Facilities —
no freight — but `setup.ts:176` gives every one a three-slot `menAtWork` array, and the three
renderers loop it with no guard while the green and red rows beside them *are* guarded
(`board-svg.ts:548`, `panels.ts:218`, `replay.ts:522`). **Decision: all three tiers** — Depot,
Station and Terminal are all Passenger Facilities and all get `laborers: 0`, so the boxes are
inert on every one. **Fix it in the model, not the renderers**, so it cannot reappear in a
fourth place: make `menAtWork` nullable and null for passenger facilities, which is what the
"Freight only" comment at `state.ts:153` has claimed all along. Freight handling must be neither
allowed nor displayed there. Two more artifacts of the same "an Office is a Facility" modelling
go with it: `board-svg.ts:562` calls a Depot **"SHIPS + RECEIVES"**, an industry's flow word,
and `board-svg.ts:599`'s `Math.max(1, trackCap)` draws it a siding slot although its industry
track has length 0. `FacilityView` needs a `kind` field; it has no freight/passenger flag today.
- [x] **Two Ice Houses can be built in one district.** Industries already ban duplicates per Office
Area — `isLockedOut` (`apply.ts:1774`) covers the Mine Tipple half of the report — but **Ice
House is a Modifier, not an industry**, and `checkPlay`'s modifier branch (`apply.ts:662`) has
no duplicate check at all. **Decision: extend the ban to modifier kinds, leave enhancements
alone.** An Interlocking is a plant at one junction, so a second on another straight is a
different installation, and the Telegraph → Telephone → Radio chain is already gated per card.
Expect modifiers with `copies > 1` to go partly dead in solitaire, where there is one Office
Area — correct, since the spare copies exist for other players' districts.
- [x] **A drawn card lands at the far end of the hand with nothing to mark it.** `apply.ts:1219`
pushes, the hand renders in state order (`game.ts:355`), and with `flex-wrap` the newest card is
exactly where the eye is least likely to be. **Decision: reverse in the display layer, not the
engine** — `view.ts:898-899` (reversing `hand` and `handWhat` identically, or they desync)
covers the replay viewer and the standalone replay together, and `game.ts:355` covers the play
page. Keeping `hand.push` means the bot's option-iteration order does not move, so every revenue
figure in this file stays comparable; an engine `unshift` would invalidate the lot. The marker
is play-page only — a `Frame` carries card names, not ids, so a replay cannot say which card
arrived that step. Follow the `game.scheduled` precedent for a `justDrawn` field, but make the
badge **persist** rather than flash: it says *which card is new*, not *something just happened*.
A static `::before` badge as `.handcard.target` does it (`panels.ts:262`), no keyframe — the
innerHTML rebuild would restart an animation on every render. Clear it in `renderUndo()` and
after `fromSave()`, or a fresh page load badges last session's draw.
- [x] **The inbound boxes render green in the side panel.** Green is outbound and red is inbound
everywhere the colour carries direction — `board-svg.ts:528-545`, `replay.ts:452`, rules §9.1 —
except `panels.ts`, whose shared `boxes()` helper (`panels.ts:162`) emits class `f` for every
filled box regardless of direction, so the red row at `panels.ts:222` comes out green. The board
SVG on the same page draws it correctly, which makes the panel actively contradict the board.
Give `boxes()` its class from the caller as `replay.ts` already does, add `.box.r` in
`board-svg.ts:820`'s palette, and take the siding off green in both panels and `replay.ts:525`.
- [x] **An Interlocking on the board is a bare label, and it does nothing.** The enhancement text is
drawn with no tooltip (`board-svg.ts:651`) while the copy already exists as data in
`ENHANCEMENT_CARDS` (`content.ts:589`). Done: every card prints its effect, and the one nothing
reads says so. **CORRECTED — the first pass had five of the ten statuses wrong.** It claimed
only Telegraph/Telephone/Radio were live, because the survey grepped for four helper function
names and read "no match" as "no implementation". In fact **seven are live**: those three plus
Interlocking (`advance.ts:770`), Yard Office (`advance.ts:750`), Small Yard (`apply.ts:405`) and
ABS Signals (`advance.ts:599`, stored on the Mainline node). Facing Point Locks and Water Column
are wired but dormant in solitaire; **Overpass alone has no code path at all**. The shipped
tooltip briefly told players four working cards did nothing, which is worse than the bare label
it replaced — `enhancements.test.ts` had passing tests for all four the whole time.
- [x] **A modifier's grant can land on a direction its host cannot use, and nothing says so —
corrected in v0.4.7.** The earlier "no bug" verdict below was wrong. The reasoning had been that
a Grocer's Warehouse is inbound-only. It is not — the card reference says "Both" — so the grant
was being dropped on a direction the facility should have had. Reported again in play as
"grocer's warehouse didn't get extra outbound slot for truck dock". The suppression machinery
itself was right and is kept: it still fires for a passenger Modifier beside a Whistle Post,
which is not a Passenger Facility — and v0.4.7 gives that grant BACK when the Office is
upgraded, which it never used to. Original note follows.
Reported as "Ice House added the laborer but not the outbound slot" — **checked, and there is no
bug**: Ice House prints +1 *outbound*, a Grocer's Warehouse is `flow: 'inbound'` so
`allows.outbound` is false, and the capacity was raised on a direction that can never render or
be stocked. The laborer arrived because laborers have no direction gate. **Decision: an
industry's printed flow is absolute** — drop the grant on hosts that cannot use it rather than
opening the direction up. So `applyModifier` (`apply.ts:1788`) gates each capacity grant on
`allows` and grows `industryTrack.length` only by what was actually applied. **Audit all 17
profiles for the same trap:** `iceHouse`, `truckDock` and `forklifts` all print `addOut: 1` and
list `grocersWarehouse`; `waitingArea`, `restaurant` and `hotel` print `addOut: 1` for
`hosts: ['office']`, which includes a Whistle Post. Then say it in both directions — the hand
tooltip naming which printed hosts cannot use which half (computable from the profiles, no host
on the board needed), and the panel's "prints N, Modifiers add M" line showing a suppressed
grant as suppressed instead of quietly omitting it. That delta display already cites the Ice
House as the bug that motivated it.
- [x] **Q13 — rear-end collisions on a Mainline card.** Answered: collide on catching up.
Implemented, and not on cards that print "trains may pass". Invisible to a bot that always
denies clearance; a bot that always allows drops from 7.34 revenue to **-5.13**.
- [x] **Nine of the twelve special-train rules are declared and read by nothing.** Done — all
nine enforced, and one of them deleted instead. `copiesNextScheduled` was never carried by any
train card: a Second Section is a Maneuver with its own working intent, so the flag was an
unreachable second description of an existing mechanic. Cost 0.8 revenue and half the wins
(8.0 → 7.2, 14/200 → 6/200), which is what enforcing restrictions does.
- [x] **Carry `links` forward in replay frames.** Done, and the premise was wrong in an instructive
way: measured, `links` was 5% of the `cells` payload. What actually cost was the `what` prose
(32%), the facility object stored a second time inside its own cell (24%) and the rest of the
static identity (26%). All three are interned now — 3415 KB → 1877 KB, and a round-trip test
runs the page's own unpacking function.
- [x] **Every published replay was dead.** All three replayed **2 intents of roughly 400** and
presented as short games, exactly as the item below it (now in Replay / Save Games) predicted.
Re-recorded from bot games with `node src/sim/save-replay.ts`, which verifies each save
round-trips before writing it, and `harness.test.ts` now fails if a published replay stops
short. The version-stamp item in Replay / Save Games is still worth doing — this catches the
breakage, it does not explain it to a player.
- [x] **Coordinate labels read Y,X on the board — v0.4.9.** Now X,Y everywhere a coordinate is shown
to a player: the on-card label (`board-svg.ts`), the switching-crew tooltip and button
(`main.ts`), the rejected-option and switching-group text (`game.ts`, `view.ts`), and the
blocked-move text (`narrate.ts`). Display-only — `GridCoord{row,col}` already had the right
geometry (row increases north, col increases east), and internal `Map` keys are untouched.
- [x] **"No switching" blocked moving a train clear of the mainline — v0.4.9.** The six no-switching
cards (both expresses, Light Engine, Campaign, Circus, Military) mean may not add or drop cars,
not may never be touched. `switch.move` now refuses only a move that would couple a fresh car —
the same way `dropOnly` was already handled — so these trains can still be shunted onto
Secondary Track to clear the mainline. `switch.dropCars` and `switch.sortConsist` stay blocked.
- [x] **Q3 corrected: Expedite governs WHERE a train may stand, not WHEN it leaves — v0.4.9.** The
forced same-Stage departure (`departsThisStage`, the `shiftChange` expedite pass) is gone; an
expedited train now arrives and is released like any other train, switchable in between. New
fault instead: left off the Office square when a Mainline Phase begins, it costs 1 Revenue
(`expediteFault`, `EXPEDITE_FAULT_PENALTY`), every Phase it is still caught there. Resolved
"3/4 EXPRESS PRINTS A RULE IT CAN NEVER USE" as a side effect — it can now reach the Local
Operations turn its printed freight rule needs. Revealed a bot gap instead: logged above under
Bot Performance.
- [x] **`evaluateClearance` checked only the first occupant it found — v0.4.9.** Found while
explaining a playtest report: the Superintendent was asked to rule on a same-direction train
instead of being held against an opposite-direction one also on the card, because the loop
returned on whichever occupant it examined first rather than checking all of them — invisible
until the Telegraph/Telephone/Radio exception made it possible for a card to hold two trains at
once. Now checks every occupant for an absolute bar before offering any judgment call. Pinned
with a test that fails against the old single-pass code.
- [x] **The splash page now shows the box art — v0.4.9.** `docs/StationMasterSplashScreen.png`
(3.0 MB) resized to a 145 KB JPEG (`public/images/`, copied into the build by `build-web.ts`)
and placed beside the title, tagline, blurb and both buttons in a side-by-side hero, stacking
to image-above-text on mobile.
- [x] **A coach may now be set out at the Office — v0.5.0, Jesse's call.** §A.4's blanket "no Rolling
Stock may be left at the Office" now carries one exception: any train (not just 7/8) may drop
one or more coaches there; freight and cabooses stay banned. Unlocks the `ENGINE boxcar coach`
arrangement that used to lock completely — measured at 1,181 refused set-outs over 60 games,
every one of them this case. `canDropCarsAt` (`track.ts`) takes a `coachesOnly` flag instead of
refusing the Office outright; trains 7/8's `coachStaysOnStationTrack` rule now forbids the coach
everywhere EXCEPT the Office, rather than everywhere. The Office's "cars fouling the Running
Track" collision (`advance.ts`) is exempted for coach-only standing cars, so a legally parked
coach is not a hazard to the next arrival.
- [x] **3/4 EXPRESS PRINTS A RULE IT CAN NEVER USE — closed, v0.5.0.** Confirmed already resolved as
a side effect of the v0.4.9 Expedite fix (see that entry above); removed from Rules Questions.
- [x] **THE INDUSTRY TABLE VS. THE CARD REFERENCE — v0.5.0, Jesse's call: the engine was right, the
docs were stale.** `card-reference.md` printed Grocer's Warehouse and Oil Refinery at 2/2 with
2–3 Laborers, and claimed "'Freight House' is not a card"; the engine already had both
industries at 1 out/1 in/1 Laborer and already dealt Freight House as a real sixth industry, 6
copies. Corrected the docs (`card-reference.md`, `glossary.md`, `rules-v0.2.md`,
`open-questions.md`) to match the engine; no engine change. Turned up that Mine Tipple, Produce
Shed and Power Plant may be similarly stale — flagged as a new item above rather than assumed.
- [x] **Poling — closed, v0.5.0.** Confirmed already at 0 copies, the same treatment as Sharp Curves,
pinned by `mainline-cards.test.ts`. No code change; removed from Rules Questions.
- [x] **Heavy Grade orientation stays rolled, permanently — v0.5.0, Jesse's call.** The card prints
"Player sets orientation", but a Heavy Grade sits on the shared Division chain between two
players (or beyond an end Division Point, next to one) — never inside one player's own district
— so there is no single player with a fair claim to the choice. Settled as random from the
seed, identically for solitaire and multiplayer, overriding the card's print. No code change
(the roll in `setup.ts` was already doing this); the comments and `implications.md` §10 Q11
previously framed it as a placeholder awaiting an interactive setup phase — corrected.
- [x] **Engines are not a separate supply from Crew Trays — confirmed, v0.5.0.** `rules-v0.2.md`:339
(Gap 4b) ties Crew Trays and engine pieces together as one combined resource, `player count +
3` — not two independently-tracked supplies. The engine already enforces exactly that via
`crewTrayCount`/`freeTrays`; `NO_FREE_TRAY` already fires exactly when engine supply would run
out too. No code change; corrected the comments that called this provisional or unsourced.
- [x] **Player settings, saved — v0.5.0.** A `localStorage` settings object (`SETTINGS_KEY`, separate
from the game save) now persists district auto-focus mode, sound on/off, and board zoom level
across reloads — all three previously reset every time. Falls back to today's defaults on a
missing, corrupt, or disabled `localStorage`, the same guard the save already had.
- [x] **The test suite's flakiness under `npm test` — fixed, v0.5.0.** Two changes: (1) a `pretest`
npm script now builds the shared `dist/` once, before `node --test` runs, so every test reading
`dist/` no longer depends on another test in the file having built it first; (2) the one test
that actually exercises the build COMMAND now builds into its own `dist-test/` directory
(`BUILD_DIST_DIR` env var, `scripts/build-web.ts`) instead of rebuilding the shared `dist/` out
from under the tests reading it. `dist/` is now single-writer.
- [x] **Curves and turnout diverging legs now draw as smooth curves, not two straight segments
meeting at a corner — v0.5.0.** `curvedRail` in `board-svg.ts` replaces the old hard-cornered
"run to the frog, then a straight 45° leg" with a sampled cubic-Bezier easement: tangent to
horizontal at the east/west edge (so an abutting straight card's rail still reads as one
unbroken line) and tangent to exactly 45° at the north/south edge (so two stacked curves still
read as one continuous diagonal). Both plain curve cards and a turnout's diverging leg go
through this same code path, so both are fixed by the one change.
- [x] **Wide boards can now be zoomed — v0.5.0.** Discrete zoom presets (75/100/125/150%) for both
the district grid and the Division map, applied by resizing the rendered SVG's own pixel
dimensions (not a CSS `transform`), so the existing `overflow-x:auto` scrollbars keep doing the
panning with no new gesture code. Persisted in the new settings object above.