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
857 lines
46 KiB
Markdown
857 lines
46 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 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.
|