Files
TheLadder/docs/DECISIONS.md
T
JesseMarkowitzandClaude Opus 5 76072edf4e Show the build, play from the keyboard, and clean up the ledger
Five things from the second playtest.

A status line at the top of every screen: the game, the pack, your current
title, and the version plus the commit it came from. `tools/stamp.js` writes the
commit into src/build-info.js before serving and before building, so a
distributed dist/theladder.html states exactly which commit produced it. An
uncommitted working tree gets a `+` suffix — "4cab9fc+" is honest in a way a
bare SHA would not be.

The whole game is now playable on Enter. Every screen marks one control and the
app focuses it after each render: the first *available* option while choosing,
the next-turn button once the result is in, "keep going" on the retrospective.
Tab and Shift+Tab reach the other options. Locked options needed no special
handling — the browser's tab order already skips disabled controls, so they stay
visible without being in the way.

"What is notable? under What changed?" — two fixes. `notable` is the
retrospective's own list of moments; a display rule can now say
`inLedger: false` and it is left out, because "Notable — changed" tells nobody
anything. Flags are the opposite and stay, but as sentences the pack supplies
rather than as booleans: "The envelope is in your locker." instead of "the
envelope: now true".

Exported logs live in logs/, gitignored, and `npm run analyze` reads the newest
one there when given no argument. A log records what a real person did turn by
turn, including whatever they typed into the feedback box — committing one once
would put it in the history for good.

Version set to 0.3.0 on the reasoning that the engine was 0.1 and the interface
0.2; say if you want a different scheme and I will renumber.

119 tests. Reasoning in docs/DECISIONS.md §25-28.

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

368 lines
18 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.
# 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.
## 25. The screen says which build it is
Playtest: *"need entry on the main screen showing what version we're running…
so it's easy to tell when new stuff is."*
A status line at the top of every screen names the game, the pack, the player's
current title, and the version plus the commit it was built from. `tools/stamp.js`
writes the commit into `src/build-info.js` before serving and before building,
so a distributed `dist/theladder.html` states exactly which commit produced it,
and a dev session says so too. A working tree with uncommitted changes gets a
`+` suffix — "4cab9fc+" is honest in a way that a bare SHA would not be.
The file is tracked rather than generated-and-ignored so a fresh clone runs
without a build step first. It changes at most once per commit, and the stamper
ignores a same-commit restamp so a running server does not churn the tree.
## 26. The whole game is playable from the keyboard
Playtest: *"when I get the results, the focus should automatically be on the
next day, so I can just hit Enter."*
Every screen marks one control with `dataset.autofocus` and the app focuses it
after each render: the first *available* option while choosing, the next-turn
button once the result is in, "keep going" on the retrospective. A run is
therefore playable on Enter alone, with Tab and Shift+Tab to reach a different
option.
Locked options need no special handling — the browser's own tab order skips
disabled controls, so they stay visible without being in the way, which is
exactly the behaviour wanted.
## 27. The ledger is written for a player, not for the engine
Two fixes from the same playtest note, *"what is notable? under what changed?"*.
`notable` is the retrospective's own list of moments. Reading "Notable —
changed" in the turn ledger tells nobody anything, so a display rule may now
say `inLedger: false` and it is left out.
Flags are the opposite: a flag flipping is a fact about the world and worth
seeing. But "the envelope: now true" is engine talk. A display rule can supply
`whenTrue` and `whenFalse` sentences — "The envelope is in your locker." — and
the ledger prints those instead of a delta.
## 28. Exported logs live in logs/, and are not committed
`logs/` holds exported feedback logs and `npm run analyze` reads the newest one
there when given no argument. The logs are gitignored: they record what a real
person did turn by turn, including anything they typed into the feedback box.
That is not repository content, and making it so once would put it in the
history for good.