Files
TheLadder/docs/DECISIONS.md
T
JesseMarkowitzandClaude Opus 5 4cab9fc41e Add promotion to Dispatch, and measure how repetitive a run is
The mailroom is no longer the whole game. Around turn 50, a player who has
built standing and either Marlene's goodwill or the nerve to ask gets offered
the Dispatch post — and can take it, take it and put Trevor up for the mailroom,
or turn it down, which is a real strategy rather than a mistake and comes back
after a cooldown.

Dispatch is eleven events of its own. The money problem is largely solved up
there and replaced by other people's days depending on yours, including the
mailroom's — which you used to be. Trevor branches on whether you recommended
him: he either runs the mailroom and has opinions about how it used to be run,
or he stays put and starts addressing you by your full title, kindly, in front
of people.

None of this is a promotion mechanic in the engine. It is
`{ path: 'stage', op: 'set', value: 'dispatch' }` on an option, since `stage`
was already how events are filtered. The reserved-write rule now allows it
narrowly — `set` only, and only to a stage that has events, both checked by the
validator, so a typo cannot strand the player in an empty pool. Wages differ by
rank through two upkeep entries gated on `stage`. The hard-times events dropped
their stage entirely: rent is due wherever you work.

The second half of this is the thing the last playtest asked for. A 183-turn
session, and no way to see how much of it was new ground.

`npm run analyze <exported-log.json>` now reports turns against distinct
situations, how often each recurred, which options were most used, which locked
gates players kept meeting, and anything they typed. The retrospective shows the
player-facing version.

It found a regression in this very change on its first run. Dispatch started
with eight events against the mailroom's nineteen, so its two floor events were
46% of every promoted run — one every three turns. Promotion was moving the
player into *thinner* content. Three more events and a weight rebalance took the
top two to 24.5%, and the heaviest recurrence from every ~3 turns to every ~5.
A test now holds that line.

Also corrected: the coverage test was asking whether an archetype ever *took* an
option, which is a property of the bot, not the pack. It had flagged
`rent_bounced.ask_trevor` as dead content when it had been offered, unlocked,
419 times across a sweep and simply never chosen. Options are now checked on
availability; events are still checked on firing.

114 tests. Reasoning in docs/DECISIONS.md §22-24.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
2026-09-10 05:05:01 -04:00

16 KiB
Raw Blame History

Architecture decisions

Why the code is shaped the way it is. Append to this file as decisions are made; the reasoning is worth more later than the conclusion.

1. All game state is addressable by path

Every value in a run — money, skills.negotiation, relationships.boss.likes_you, flags.took_loan — is reachable by a dotted path string, and conditions and effects are expressed against those paths.

The planning docs' schema had requires: { skill, min } and effects: { money, skills, relationships }. Both hardcode the state shape into the engine: the first gate that wants a relationship threshold, a money floor or a flag, and the first effect that touches a fourth kind of state, means editing engine code. Path addressing costs one getPath/setPath pair and removes the whole class of change.

Everything else in the engine is built on this primitive: option gates, event triggers, the soft-failure branch, and — later — promotion tiers.

2. Conditions and effects are data, evaluated generically

A condition is { path, op, value } or a combinator (all / any / not). An effect is { path, op, value } with add / set / push / remove. The engine knows the operators; it never knows what a path means.

Bounds live in the pack's state declaration (min / max) and are applied by the effect layer, so clamping is content's decision, not the engine's.

3. There is no such thing as a "random event"

Every turn, the engine filters the entire event pool by stage and conditions, then draws by weight. A routine turn is a high-weight event with loose conditions; a rare interruption is a low-weight one; the hard-times branch is an event whose conditions include a money threshold. The engine branches on none of it, which is what the planning docs asked for in 03-claude-code-build-prompt.md §3 — but achieved by deleting the category rather than by carefully not special-casing it.

Consequence: every stage needs at least one event that no exclusion rule can remove (no requires, no once, no cooldown), or the pool can run dry mid-run. The validator enforces this.

4. The engine owns some state; content may read it but not write it

turn, stage, seen, lastSeen, history and meta are engine bookkeeping. Content can read them in conditions — "only after day 10", "only if this event has never fired" — which is useful and safe. Writing them is a validation error, so data cannot corrupt the loop.

seen.<eventId> is a count and lastSeen.<eventId> a turn number, which is why event ids must be valid path segments.

5. Seeded, serializable RNG

Not in the planning docs; added because it is nearly free and pays three ways: tests are deterministic, playtest reports are reproducible from seed plus choice sequence, and the feedback log becomes replayable rather than merely descriptive. mulberry32 was chosen because its entire state is one uint32, so the generator's position round-trips through JSON with no special handling.

The seed and position live in meta inside the save file. Nothing about a run exists outside its state object.

6. The turn loop is a pure function

takeTurn(pack, state, optionId) -> { state, record }. No mutable game object, no hidden state. A saved game resumes into exactly the run it left — there is a test that plays 30 turns straight, plays the same 30 turns with a JSON round trip in the middle, and deep-equals the results.

7. Content is validated at load, not discovered at play

validatePack checks that every path referenced by a condition or effect is declared, every character id resolves, ids are usable as path segments, no event is unreachable, and every stage has a fallback. Without it, a typo in content fails silently forty turns into a playthrough — the exact failure mode that makes "just add data" unsafe for a non-programmer.

8. Distribution: ES modules in dev, one inlined file to ship

Chrome and Firefox block ES module imports and fetch over file://, so "open the HTML file" and "modular JS with JSON content" are in direct conflict. Resolved by serving during development (npm run serve) and inlining everything into a single self-contained HTML file for distribution (npm run build).

Related: content is authored as .js modules exporting plain objects rather than .json. Same data, but comments and multiline strings are available — which matters a great deal for narrative text — and it sidesteps fetch entirely.

9. Tuning values live in the content pack

Relationship stats run 0–100 starting at 50, clamped; typical choice effects are a few points, so one choice matters but several turns of consistent behaviour are needed to move someone. Money is dollars. Turn is a day.

None of these are engine constants. They are declared in the pack manifest, so retuning the game — or a pack that runs on weeks instead of days — touches no code.

10. Naming

The product is The Ladder; the default content pack is Corporate Ladder. The planning docs use "Corporate Ladder" as the working title for both, which would have baked one setting into the product name, against the explicit goal that corporate is one pack among many.

11. Per-turn upkeep is declared by the pack

The MVP spec wants "basic income and basic recurring costs", but effects only run when a player chooses something. Baking wages and rent into every option would be tedious and easy to get wrong.

pack.upkeep is a list of { label, requires?, effects } applied at the end of each turn, after the choice lands. It is still data — the engine reads it and knows nothing about wages — and the conditions make it expressive enough to be useful: Corporate Ladder's loan service charge is three conditional entries forming a step function that escalates with the size of the debt, which needed no multiply operation and no engine change.

Upkeep changes are recorded separately from choice changes in the turn record, so the UI can show "you chose this" and "and then the month happened" as different things.

12. Characters may start somewhere other than the pack default

relationshipStats sets the defaults; a character's startingRelationship overrides individual stats. Trevor starts below the default on likes_you because he arrived two weeks before you and has priced that in — which is characterisation expressed as data rather than as an opening cutscene.

Nobody starts feared: fears_you defaults to 10 rather than 50, because a midpoint default would mean every new hire arrives already intimidating.

13. Content is tested for reachability, not just validity

validatePack proves a pack can run. It does not prove any of it is ever seen. The content suite plays 60 seeded runs and asserts that every event fires and every option is taken at least once — dead content is otherwise invisible, and two whole events plus a hard-times escape route were in fact unreachable on the first pass because Trevor's gate sat above anything a player could plausibly reach.

A trap that cost a cycle: the chooser's RNG must be seeded differently from the game's. Both draw exactly one number per turn from the same generator, so a shared seed correlates the choice index with the event draw and makes whole option combinations structurally unreachable. It presents as a content bug and is not one. Any headless playtest harness written later has to know this.

14. The interface is named by the pack, not by the UI

pack.display maps a state path to { label, format, order, hidden, hideWhenZero }. The header reads it to decide what to show, in what order, and whether a value is money or a bar. Anything the pack does not describe falls back to a name derived from the path, so content is never required to supply a label just to be playable.

This is why the header says "Standing" for a path called reputation, and why debt disappears while it is zero — both are content's decisions. A Wild West pack renames the whole interface without touching a UI file.

15. A turn has two phases

Choosing, then outcome. The outcome phase shows what the choice did, as a list of deltas, and separately what the day cost regardless — wages, rent, the loan. Collapsing these into one screen would have made the numbers move without the player seeing why, which is most of what makes them mean anything.

Locked options are rendered greyed with the requirement spelled out ("Needs Negotiation 15+") rather than hidden. A player should be able to see the door they cannot open yet; content can still opt into hiding one with whenLocked: 'hide'.

The feedback widget lives in the outcome phase, because that is the moment a player has an opinion — they have now seen both the options and the result.

16. Full re-render, with one deliberate exception

The root element is rebuilt from state on every change. At this size it is instant and it removes a whole class of stale-view bug.

The exception is the feedback text fields, which update the model without re-rendering: rebuilding the DOM under a cursor throws the player out of the box they are typing in. Their contents are flushed to the log on a timer, when the turn advances, and on beforeunload.

A related bug worth remembering: the flush originally skipped writing when the feedback was empty, which meant clearing a rating did not clear it from the log. Withdrawing feedback is itself feedback. The guard is now "has the player touched this", not "is there content".

17. The single-file build is verified by running it

tools/build.js inlines the stylesheet and flattens the module graph into one script. It is a deliberately small bundler that understands only the module syntax this project uses and throws on anything it does not recognise — a silent mis-bundle is far worse than a failed build.

The build is covered by a test that executes the bundled script against the fake DOM and plays a turn through it. A build that merely produces a file proves nothing; a broken bundle is otherwise discovered by whoever was handed the file.

18. What the tests do not cover

There is no browser on this machine that can render the page — headless Firefox cannot get a framebuffer, and no Chromium is installed. The UI tests drive the real app modules against a minimal fake DOM (test/fixtures/fake-dom.js), which covers wiring: imports resolve, handlers fire, screens render the right text, choices reach the engine, saves and the log are written.

It does not cover layout, CSS, the dark-mode palette, file downloads or file imports. Those need a human with a browser, and should be treated as unverified until someone has clicked them.

19. The calendar is the pack's, derived by the engine

First playtest: "wasn't clear what daily drain was for… should probably pay rent weekly / get paid every other week… would be good to have day of week shown and week # instead of just day #".

A pack may declare calendar: { cycleLength, cycleName, unitNames }. The engine derives calendar.index, calendar.cycle and calendar.name from the turn number and writes them into engine-owned state before the turn's event is drawn — so content can require a Friday, and upkeep can charge rent on a Monday. A pack that declares no calendar has none; the engine imposes no week of its own, and turnUnit remains whatever the pack says.

20. Upkeep runs on a schedule

every and offset on an upkeep entry make it periodic: wages are { every: 14, offset: 4 } — alternate Fridays, given that turn 1 is a Monday — and rent is { every: 7, offset: 7 }. What was a flat $25/day of unexplained drain is now $18 of small change, a fortnightly paycheque and a weekly rent cheque, which is both easier to plan around and easier to feel.

Two display consequences, both of which were the real cause of the "unclear drain" complaint. The ledger now labels a change with the upkeep entry that caused it ("Rent −$455") rather than the path it moved ("Money −$455"). And the turn screen carries a diary line — "Wages tomorrow · Rent in 4 days" — computed generically from any pack's scheduled entries, so the player is told what is coming before it happens rather than after.

21. Content coverage is measured across player archetypes

Playtest: "always in debt — could never get ahead". The economy was retuned so that a careful player climbs, a careless one sinks, and an unplanned one treads water, with a spare_shift event that surfaces because you are broke — the way out of a hole should be visible from inside it.

That retune broke the old coverage test, and usefully. Random play stopped reaching hard times at all, which made the whole debt branch look dead. It was not dead; random play is simply not a plausible player.

Coverage is now the union across five archetypes (test/fixtures/strategies.js) — random, thrifty, spendthrift, sociable, and "desperate", which is broke and well-liked. That last one exists because the "ask a friend for money" options sit in a state no single-axis strategy ever reaches. The economy's shape is asserted as an ordering between archetypes rather than as absolute figures, so tuning does not churn the tests.

22. Stage is the one piece of engine state content may write

A promotion is { path: 'stage', op: 'set', value: 'dispatch' } on an option's effects. Nothing else about it exists in the engine — stage was already how events are filtered, and moving the player between stages is a content decision, so the reserved-write rule now allows it.

It allows it narrowly. Only set, and only to a stage that actually has events; both are checked by the validator. A typo cannot strand the player somewhere with an empty event pool.

Wages differ by rank through two upkeep entries gated on stage, not through any notion of rank in the engine. The hard-times events dropped their stage entirely and so apply everywhere — rent is due wherever you work, and that branch has to follow the player up rather than vanishing on promotion.

23. What "reachable" means for an option

The coverage test used to ask whether an archetype ever took an option. That is the wrong question: whether a bot picks something is an artefact of how the bot scores, not a property of the pack. rent_bounced.ask_trevor was offered and unlocked 419 times across a sweep and chosen once, and the test called it dead content.

Events are still checked on firing, since the engine draws those. Options are checked on availability: every option must, at some point, be offered to a player unlocked. What they then do with it is theirs.

24. Variety is a measured property of the pack

Playtest: a 183-turn session, "mailroom stayed mostly interesting despite the number of repeats… what might be of interest is seeing how many turns are played versus how many unique situations."

So it is now measured, in three places.

tools/analyze-log.js reads an exported feedback log and reports turns against distinct situations, how often each recurred, which options were most used, which locked gates players kept meeting, and anything they typed. The retrospective shows the player-facing version of the same thing.

And a test asserts no single event exceeds 25% of a 200-turn run, and that a run sees more than 60% of what was reachable in the stages it visited — measured against reachable content, since a run that never leaves the mailroom cannot see Dispatch and counting that as thin would be measuring career progress instead.

That guardrail exists because the first version of Dispatch failed it badly: eight events against the mailroom's nineteen meant its two floor events were 46% of every promoted run, one every three turns. Promotion was moving the player into thinner content. Three more events and a weight rebalance took the top two to 24.5%, and the heaviest recurrence from every ~3 turns to every ~5.