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
368 lines
18 KiB
Markdown
368 lines
18 KiB
Markdown
# 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.
|