Files
JesseMarkowitzandClaude Opus 5.5 3108319a2b Add chance and a cast that changes between runs, and a pack to try them
An option used to do exactly what it said. Now it may carry a check: one of
four attributes (Nerve, Wits, Charm, Resolve) against a set difficulty or
against a named person's own attribute, leaning on how they regard you, with a
success and a failure. The player sees only the odds in words ("a long shot",
"likely"), never a number. The roll is one draw from the run's generator, so a
run still replays exactly from its seed and choices.

A character may be a role with several authored candidates, one drawn per run,
or a group of two to four drawn into numbered slots. Everyone carries a name, a
description and the same four attributes as the player, so a replayed pack
meets a different office. Text names people as {partner} or {clerk}, and an
event about "a clerk" binds one present clerk for the turn. The draw and the
binding are saved with the run, and a save from before a pack drew a cast gets
the same one every time it is loaded.

All of it is generic and optional: Corporate Ladder and the Frontier use none
of it and play as before. The Counting House, a one-stage office about 1910
built from the story-to-pack catalogue, exercises it, and was tuned against the
conformance suite by measurement. DECISIONS #41. Version 0.4.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MhABSrqkwTdcybXmheDPr
2026-09-23 22:12:26 -04:00

857 lines
46 KiB
Markdown
Raw Permalink 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 build.
`src/build-info.js` is tracked and permanently reads `commit: 'dev'`; it is
never rewritten. `tools/build.js` substitutes the real commit into the *bundled
output* only, so `dist/theladder.html` states exactly which commit produced it
while the working tree never churns. A build from a dirty tree gets a `+`
suffix — "5c1784d+" is honest in a way a bare SHA would not be, and the build
fails loudly if the substitution finds nothing to replace.
Running from source therefore reads "0.3.0 · dev", which is what it is.
The first attempt stamped the tracked file in place before every serve and
build. It was wrong in a way worth recording: after each commit the committed
stamp named the *previous* commit, and the next serve rewrote it and dirtied the
tree. A generated value does not belong in a tracked file if anything routinely
regenerates it.
## 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.
## 29. The economy needs a test on its arithmetic, not just on its outcomes
A real 182-turn session spent 30% of its turns below zero, went negative on turn
18 and never recovered, and chose the same hard-times option — "take every shift
going" — thirty-seven times.
The cause was a tuning change made without re-deriving what it meant. Rent went
from 385 to 455 while chasing a different problem, which took the mailroom
baseline from the designed −$3/day to −$13/day. Worse, at the debt that run
accumulated, the weekly service charge came to $180 against an escape option
worth $120: the hole was inescapable *by arithmetic*, whatever the player did.
Nothing caught it. Every archetype either optimised money or was promoted out of
the problem before it bit, and the 25%-dominance test passed because
`rent_bounced` was only 17% of turns.
Three things came out of it.
The baseline is now asserted directly — a test sums the pack's own upkeep over
twenty fortnights and requires the mailroom to net between −90 and +10, and
Dispatch to be better but under +250. That test would have failed the moment
rent changed. Outcome tests over simulated play are worth having, but they are a
slow and noisy way to detect a number that is simply wrong.
A `lifer` archetype was added: it refuses any option that would change stage —
generically, by looking for an effect on `stage` — and otherwise plays for
people. It reproduces the session that found this, and no other archetype can.
And a test now holds that no archetype spends more than a quarter of a run below
zero. Hard times is a state a player passes through. Living in it is the
failure mode.
Fixes: mailroom wages 980 → 1120, the escape option 120 → 220 so it is worth
more than a week's rent, `rent_bounced` from weight 5000 with no cooldown to 900
on a cooldown of 3 — being broke should colour a run, not replace it — and
`debt_collector` from weight 25 to 45, since at 25 it appeared six times in 262
turns and debt became a one-way ratchet.
## 30. Gaps are measured within a run, never across two
`tools/analyze-log.js` reported "every ~-5 turns". One export can hold several
playthroughs and turn numbers restart with each, so a first-seen in run two
compared against a last-seen in run one produces nonsense.
Spans and pair-counts are now accumulated per run and pooled, so the figure is a
proper weighted average of within-run gaps. It is worth stating the general
form: any statistic over a log has to respect the run boundary, because a log is
not one sequence.
## 31. A pack declares the numbers it must hit
Decision #29 added a test that asserts Corporate Ladder's economic baseline
directly, because a tuning change made without re-deriving what it meant had
trapped a real playtester. That test named the pack, named its stages and
hard-coded its ranges, which meant a second pack got none of it — the guard
that exists precisely because a number went unchecked would itself have gone
uncopied.
So the numbers moved into the pack, as `tuning.baselinePerCycle`:
```js
tuning: {
baselinePerCycle: { hand: [0, 12], deputy: [10, 30] },
},
```
The conformance suite reads that and holds each pack to its own declared range.
A new setting is checked the moment it is in the registry, and what it is
checked against is its designer's stated intent rather than another setting's
numbers. The pack is required to declare it: a missing `tuning` block fails,
because the silent failure mode here is a pack that is never economically
tested at all.
The figure is **derived, not simulated**. An upkeep entry worth `v` every `n`
turns is worth `v * cycleLength / n` per cycle, exactly. Simulating it over a
fixed window reads differently depending on where the last rent lands: Corporate
Ladder measures −19 a fortnight over 280 turns and −42 over 560, and only the
second is the steady state. The original test used the 280-turn window and was
reading a number 23 dollars off the truth — inside its own tolerance, so it
never said so.
## 32. A skill gate is never gated behind itself
Corporate Ladder gates four options on `skills.negotiation >= 15`, and several
of its largest negotiation gains sit behind that same gate — `+4` on
`marlene_confides.ask_how_to_leave`, `+3` on `trevor_favor.trade`. Its logs show
players meeting the wall repeatedly through the first thirty turns.
Measured, the gate is not unreachable: a player steering directly for the skill
is through it by turn 11. What the logs actually show is ordinary players not
prioritising it and having no incidental route, which is a pacing failure rather
than a reachability one — and the reachability test could never have caught it,
because reachability is exactly what holds.
The Frontier is built to a rule instead, asserted in `test/frontier.test.js`:
for every skill threshold in the pack, the growth available *without* passing
that threshold must be able to reach it, from repeatable events — a single
one-off award is not a route. A second test measures it, requiring a player who
steers for a skill to be through its lowest gate inside twenty turns.
The rule is deliberately not in the conformance suite, because Corporate Ladder
does not satisfy it and retuning that pack is a separate decision from adding
this one. It is written down here so the choice is visible rather than implied.
## 33. One save slot per pack, and the picker is the front door
`theladder.save.v1` was a single global slot. The app guarded it by comparing
the packId inside the save against the pack it was about to run, and started a
new game when they differed — so with two settings, the first switch would have
silently destroyed the other career.
Slots are now `theladder.save.v1.<packId>`, one per setting, and switching is
non-destructive in both directions: each run is written to its own slot every
turn, and coming back resumes rather than restarts. `theladder.pack.v1` holds
the last setting played, so a returning player lands back where they were.
The pre-packs slot is still read once, for Corporate Ladder only, and only when
it really holds a Corporate Ladder save. It is never written. Someone mid-career
when this shipped finds their career.
A first-time player — nothing saved, nothing remembered — gets the setting
picker rather than being dropped into whichever pack is first in the registry.
A build shipping one pack skips it entirely.
## 34. What a second setting proved, and what it cost
The engine/content split was a claim until there were two settings. The
Frontier is 29 events across two stages, a four-day cycle instead of a
seven-day one, a different relationship vocabulary (`trusts_you`, `owes_you`,
`wary_of_you`), a different skill pair, and money about fifteen times smaller
that arrives from work rather than from a wage. `src/engine/` was not touched.
Three things outside the engine did have to change, and none of them was about
the setting:
- **`src/io/save.js`** — the single save slot, above.
- **`src/ui/app.js`** — it named one pack; it now holds the current one. The
screens, the engine and the rest of the io layer still take the pack as an
argument and are no less pack-agnostic than before.
- **`src/ui/screens/turn.js:40`** — hardcoded `'The mailroom'` as the heading
over the people panel. That was the interface naming a setting, against
decision #14, and it had gone unnoticed while only one setting existed. It is
now `pack.peopleHeading`.
`tools/build.js` needed one fix, unrelated to packs but exposed by them: its
module scan used `.*?` where `transform` used a pattern that spans lines, so a
destructured import wrapped across several lines was rewritten into a
`__require` for a module the scan had never collected. The bundle built clean
and failed only when run. Both now use `[\s\S]*?`.
The honest summary is that adding a setting required no engine change and three
interface changes, two of which were latent defects that a second pack merely
made visible.
## 35. Three open questions, closed by the designer
Recorded 2026-09-11, alongside 0.3.1.
**Corporate Ladder's negotiation gate stays.** A gate that takes deliberate work
to get past is acceptable, and so is an option that needs groundwork first. #32
remains the rule the Frontier is built to and tested against; it is a choice a
pack may make, not a standard every pack must meet, and Corporate Ladder is not
being retuned toward it.
**The feedback widget stays exactly as it is.** Zero ratings across three
sessions says nothing about the widget: those playtests were rapid, and feedback
came back directly rather than through it. It is not evidence either way until a
playtest runs without that direct channel.
**Money has to matter late, through the cost of standing.** A careful player
ends around $8,300 over 200 turns, and money stops being a constraint. The
direction is that rising standing brings rising expenses — the car, the place to
live, the clothes a position expects. Upkeep entries are already gated
conditions over state, so this is content work, not an engine change.
## 37. A playtest is only as good as its instruments
The second session — 181 turns of the Frontier, the first time that pack has
been played by a human at all — ran against a **stale bundle**. The page had
been loaded before the last rebuild, and switching settings inside an open tab
does not re-fetch the script, so the run exercised everything except the change
it was meant to test. The charge that never stops fired zero times in ninety
turns where its conditions held.
Working that out took file timestamps and a simulation. It should have taken one
line of the log, so three things changed:
- **Every logged turn now records `app_version` and `build`.** The log says
which bundle produced it, and a stale session is visible immediately rather
than looking exactly like a feature that does not work.
- **`src/version.js` was lying.** It hardcoded `0.3.0` through the whole of the
0.3.1 and 0.3.2 work, while playtesters were told to check the status bar to
know what they were on. A version string that lies is worse than none, because
it is trusted. A test now holds it to `package.json`.
- **A locked door is only worth showing when it is a door.** `pay_down` — "put
money against the book" — was the most-locked option in the session, shown six
times to a player who owed nothing. Options gated on a state the player is not
in and cannot choose to enter now carry `whenLocked: 'hide'`. Gates worth
bouncing off, like a skill threshold, stay visible: that is the signal the log
exists to collect.
## 36. Standing costs money to keep
Built in both packs, with no engine change, exactly as #35 predicted.
Each pack declares a `lifestyle` tier — Corporate Ladder's car and address, the
Frontier's own horse and the room over the Ellis place — bought at a second-rung
event and charged for every cycle thereafter. Dispatch nets +49 a week; with the
car +19; with both, **-21**, so a coordinator keeping up has to earn the
difference out of the days themselves. The Frontier runs the same shape at its
own scale: +18, +11, +4 a run.
**The charge that makes it a choice is levied in standing, not money.** A player
whose lifestyle lags behind the job loses standing every cycle, in steps that
steepen as standing rises. Both are gated on the second stage: the first rung
pays too little for any of this, and charging a mailroom associate for a car
would be a trap rather than a decision.
Three things were measured rather than guessed, and all three changed the design:
- **At -2 a week the charge was invisible.** Dispatch hands out standing several
points at a time, so a coordinator who bought nothing still finished pinned at
100. The step had to reach -4/-6 before ordinary play settled in the seventies.
- **A player who chased standing every turn beat it anyway** — 99 standing while
buying nothing, and $2,245 richer than one who kept up. The lifestyle was a
pure loss to anybody paying attention, which is the opposite of the intent.
The top step (-14 above 80 standing) puts that player's ceiling at 82, so the
last fifteen points are bought rather than worked for.
- **The Frontier's first numbers left a keeper on $8 after 200 turns.** That is
not pressure, it is the debt trap this pack was tuned twice to avoid; the room
went from 9 a run to 7.
**Which upkeep counts as the baseline is now the pack's to declare.** The
conformance suite used to exclude debt-gated entries by matching
`/debt|owed|loan/i` against path names — the generic suite guessing at content's
vocabulary, which #31 exists to prevent, and which would have silently counted
every lifestyle tier at once into a baseline that is supposed to mean "the floor
a player faces having opted into nothing." Packs now name those paths in
`tuning.baselineExcludes`, and the suite fails if one is not declared state.
**The top of the ladder was a plateau, so one charge never stops.** The first
human play of the lifestyle system — 96 turns, Corporate Ladder — found both
purchases (the car at turn 52, the address at 75) and showed the standing
pressure working exactly as intended: "Looking the part" took 56 reputation, and
standing sagged into the low seventies on the car alone before recovering to 100
once the address was bought. But from turn 84 the run had nothing left to spend
on: every charge switches off at the top tier, and the balance simply climbed.
Money stopped mattering again, one rung higher than before.
Both packs now carry a charge levied by standing itself, which no purchase ends.
They are shaped differently, and the difference is measured rather than stylistic:
- **Corporate Ladder steps on money.** A flat charge cannot flatten a slope —
late income comes from choices and scales with how well the player plays, so
the flat charge needed to stop an optimiser accumulating (-360 a week) put
ordinary archetypes below zero for 44% of a run and ended them $1,385 in debt.
Stepping on money ($1,200/-60, $2,400/-120, $3,600/-240) lands it on whoever
has it: the balance settles around $4,250 and stops climbing (-3 a week), and
ordinary play comes out *better* than before the charge existed — 13% of a run
below zero against 17%, a tier-2 run ending +$175 rather than -$25.
- **The Frontier is flat, at -4 a run.** Its late game never accumulated in the
first place — a deputy who owns everything drifts 0 a run at every charge
strength tested — so there is no slope to flatten. The charge is there to keep
money live, and break-even at the top is the intent.
**A save written before a stat existed silently loses the content gated on it.**
Found while working out how to playtest this: `loadLocal` returns the saved
state verbatim, and `conditions.js` compares numbers only — so for a career
saved before `lifestyle` existed, both `lifestyle < 2` and `lifestyle >= 1` are
false. The car lot never fires, the purchase never unlocks, the charge never
lands, and nothing says so. Resuming a save would have shown a playtester none
of this feature while reporting nothing wrong. Loading now fills in any path the
pack has gained since (`hydrate`, in `state.js`), writes the repair back, and
does the same for an imported file. This is the price of "adding content is just
adding data", and it applies to every stat any pack adds from here on.
**And a test that had passed for two releases was measuring the wrong thing.**
"The promotion leaves a player better off" compared the end balances of promoted
and unpromoted players pooled across archetypes — but which archetypes get
promoted is not independent of how they spend, so the comparison mostly measured
the mix. It inverted the moment there was something to spend money on. It is now
derived from upkeep arithmetic, and the lifestyle's effect is tested with two
players who differ in exactly one habit.
## 38. A repayment pays what is owed, never more
Recorded 2026-09-15, for 0.3.4. Designer's call: a small engine addition rather
than a content workaround.
The first human session of the Frontier on a current build — 112 turns — owed the
store four dollars on turn 27 and chose "Put money against the book". It took
twenty dollars and cleared four: the option was a flat `money -20, owed -20`, and
`owed` clamped at zero while `money` did not. The ledger showed "−$4 at its
limit", which the player read, reasonably, as a debt still standing.
The same shape was in five options across both packs — the Frontier's store and
both of its hard-times repayments, and both of Corporate Ladder's loan
repayments — and none had been caught, because every archetype that pays debt
pays large debts.
**Content could not fix it.** Effects add or set a number the pack wrote, and a
balance built from +14, +32, +4, −45 and −10 can be anything, so no fixed payment
can equal it. The alternatives were to gate each instalment on the balance
covering it (leaving small balances to cost more than they are) or to retune
every credit amount to a common step (changing tuned numbers to suit a missing
primitive).
**So an `add` effect's value may now be read from state:**
`{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 20 } }` is
"take away whatever is owed, but no more than 20". The amount is resolved at the
moment the effect applies, so a repayment lists the money effect before the
balance effect — both read the balance before it moves. The change record keeps
the number actually applied, with the reference in `source`.
This is not the engine learning about debt. It reads a number from a path without
knowing what the path means, exactly as a condition does, and #1 and #2 still
hold: it is data, validated at load. The validator requires the referenced path to
be declared and `times` and `cap` to be numbers. The test archetypes score such an
effect at its cap, the most a player reading the label could expect.
A conformance test now holds every pack to it: an option that spends money and
reduces a balance — any money-formatted stat other than money — must read the
amount from the balance, or be gated on the balance covering the whole instalment.
## 39. A hidden option is not a locked door in the log either
`whenLocked: 'hide'` (#37) kept "Put money against the book" off the screen of a
player who owed nothing, and it worked. But the turn record listed offered options
without saying which were hidden, so `npm run analyze` reported the option as a
locked door shown three times to a player who never saw it. The instrument was
counting the very thing the fix removed.
The turn record now carries `hidden` for every offered option, and the analyser
skips hidden options when counting locked doors. Logs exported before 0.3.4 do not
have the field and still over-count.
The same session's exports also put a save file next to the feedback log in
`logs/`. The analyser read "the newest `.json`", so whichever of the two was
exported last would have been read as the log. It now reads only
`theladder-feedback-*.json` when no file is named.
## 40. What the first Frontier playtest changed
The session (112 turns, 0.3.3, promoted on turn 44) confirmed the systems #36 and
#37 were built for: both lifestyle purchases were found and taken, the standing
charge fired as designed, money never went below zero, and the run ended flat —
the break-even the Frontier intends at the top. Everything it found wrong was
legibility, plus the defect in #38. All eight of the player's comments were
questions about what a number meant, and both thumbs-down were on events that
confused them.
- **Wages are called wages.** "Settlement" and "Deputy pay" became "Wages from
Vane" and "Wages from the county". The player could not connect "Settlement"
to the twenty-four a run the first morning quotes, and read a run's own +$9 as
the pay going missing (#27: the ledger is written for a player).
- **A clamped change says where it stopped** — "stopped at $0", "stopped at 100" —
instead of "at its limit", which was read as a balance still outstanding.
- **Two option labels were rewritten** that the player could not parse: "Have no
view on the axle" and "take hold of the nearest one".
- **The horse and the room are separate events.** One event offered both, with a
description that was always about the horse, so the room was offered under
horse text and "Buy the bay" was shown locked to a player who had bought it — a
door that could never open. Each is now gated on the tier it sells, and a test
holds that neither is offered once bought.
- **The promotion gets a warning.** "How do I find out how close I am to
promotion?" had no answer anywhere. `ward_takes_notice` comes once, to a hand
past turn 24 with standing 55, and says in the town's terms what the offer waits
for — a name the street says for the right reasons, and a man Ward takes at his
word or one with the Ridge road behind him — without printing a threshold. Across
72 archetype runs it fired in 67, at a median of turn 29, always before the offer.
A numeric progress display was not built: it would need the interface to know
what a promotion is, which #22 keeps in content.
- **The office got five more days.** 69 deputy turns saw 13 distinct situations,
and `office_day` and `the_cells` were 17% of the stage each — the same thinness
#24 caught in Dispatch. Five events were added (the stage in short, the county's
fees, a bounty hunter, a fire at the store, Dutch wanting the Ridge road) and the
horse/room split adds a sixth. Measured across 72 archetype runs of 250 turns,
the three floor events fell from about 17.5% of deputy turns each to about
13.5%. Every new event has an ungated way through, and its skill gates (nerve 30
and 34, trade 28 and 30) sit under growth available without them (#32).
**Measured and not yet understood.** In the same simulation, promoted runs now end
with a median of $164 rather than $88, and a median standing of 89 rather than 98.
Two changes plausibly account for it — repayments no longer overcharge, and lower
standing triggers the $4 standing charge less often — but it has not been
isolated. The declared baselines and every tuning test still pass. The Frontier is
meant to be roughly level at the top, so the next Frontier session should watch
whether money stops mattering again, as it did in Corporate Ladder before #36.
"Owed at the store" was asked for as "a major field at the top". It already is,
second after Money whenever anything is owed. Whether it is prominent enough can
only be judged in a browser, which this machine cannot render (#18).
## 41. Chance, and a cast that changes between runs
Recorded 2026-09-23. Designer's call: a change to the base engine, generic, which
every pack may use and none has to. Corporate Ladder and the Frontier use none of
it and replay exactly as before.
Until now an option did exactly what it said. Skills only decided whether an
option was on the menu (#15, #32). The story-to-pack catalogue of recurring
situations (asking for a rise, the door to the powerful, a colleague's needling)
is made of moments whose outcome depends on who you are and who you are dealing
with, so three things were added. `content/countinghouse` is the pack that exercises them.
**Attributes.** A pack names them in `pack.attributes`. The player's live in
ordinary declared state (`attributes.<id>`), so they grow by effects like anything
else. The prototype's four, agreed with the designer, are Nerve, Wits, Charm and
Resolve. Another pack may rename them, as #14 lets it rename the interface.
**Checks.** An option may carry `check: { attribute, against?, opposedBy?,
relationship?, difficulty?, modifier? }` and then must carry `success` and
`failure`, each with its own `result` and `effects`. Its plain `effects` apply
either way. The chance is
base + perPoint × (yours − theirs) + relationshipPerPoint × (their regard − centre) + modifier,
clamped to [floor, ceiling]
where "theirs" is the opposing character's attribute, or the check's `difficulty`,
or the pack's `unopposed`. Every number is the pack's, in `pack.checks` (#9).
**The player never sees a number.** Options show the odds in words ("a long shot",
"likely"), from bands the pack may rename, and the outcome says whether it came
off. The roll is one draw from the run's own generator, so a run with checks still
replays exactly from its seed and choices (#5, #6). A pack that never checks draws
nothing extra.
**A cast drawn per run.** A character may be a *role* with `candidates`, several
authored people who could fill it, and one is drawn when a run starts. A role may
be a *group* with `count: [min, max]` (two to four clerks), which draws that many
different people into numbered slots (`clerk_1` .. `clerk_4`). Every candidate
carries a name, a description and the same attributes the player has, so a
replayed pack meets a different office with different strengths. Candidates are
authored rather than generated from random parts: a character written as sharp
has the wits to see through a bluff, which a random attribute roll pasted onto a
random description would not guarantee.
- **The draw is saved in engine-owned state** (`cast`, now a reserved root, #4)
and made on its own stream derived from the seed, so it neither moves the event
generator nor depends on it. `hydrate` (#36) draws one for a save that predates
it, and draws the same one every time.
- **Every slot up to a group's maximum is declared in the schema from the start.**
Relationship state for `clerk_3` exists whether or not anyone was drawn into it,
so it validates and saves like any other path. An empty slot is recorded as
`null` and is simply absent from the run.
- **Text names people by role.** `{partner}`, `{chief.first}` and `{clerk.title}`
are filled in from whoever was drawn. An event about "a clerk" says
`involves: ['clerk']`. When it is drawn, one present clerk is bound to it for
the turn, and `{clerk}` in its text, `relationships.{clerk}.trust` in its effects
and `against: '{clerk}'` in a check all mean that person. The binding is saved
with the run. An event cannot fire in a run with nobody to bind. Text kept in
state, such as a notable moment, has its placeholders pinned to the slot
(`{clerk_2}`), so the retrospective names the right person later.
- **The validator holds all of it:**
- attributes declared, and every drawn person carrying all of them;
- enough candidates for a group's maximum, and unique names;
- checks naming real attributes and people, with both branches;
- `{placeholders}` that name real characters, where a group is only named inside an event that binds it;
- no placeholder in an event's own `requires`, which is evaluated before anyone is bound.
**What the test harness had to learn.** The archetypes score an option by its
effects (#21). A checked option is now scored at its expected value: plain effects
in full, and each branch weighted by its chance, the way a player reading the odds
would. The conformance check that no repayment takes more than is owed (#38) now
reads both branches, since a check can pay or spend on either.
**The prototype pack was tuned by measurement, not by eye.** Its first draft
failed three conformance tests:
- the routine ledgers were a third of every player's turns;
- a careless player was broke 40% of a run;
- it had no balance a borrower could owe.
Two more catalogue situations (an errand for the partner's family, and a desk you
and a clerk both want), a lighter floor event, a loan that is refused but still
helps a little, and an "owed to the floor" balance repaid on Saturdays brought
those to 22% at worst, 12% at worst, and a debt that is repaid by reading the
balance (#38).
**Not yet known.** Whether chance makes the same situations worth replaying, and
whether the odds in words are legible, can only be judged by someone playing it
(#18). The pack has one stage on purpose, so that question can be answered before
a career is built on it.