Files
TheLadder/docs/DECISIONS.md
T
JesseMarkowitzandClaude Opus 5 1d4ac0fdbd Add the Corporate Ladder pack, and two engine features it needed
Seventeen events across the mailroom: ordinary days, Marlene Hobb and Trevor
Pike, a misrouted envelope that can be delivered or kept, and a hard-times
branch that fires on negative money and always leaves an ungated way out.

Writing real content turned up two things the engine could not express.

Per-turn upkeep (`pack.upkeep`): the MVP spec wants recurring income and costs,
but effects only run when a player chooses something, and baking wages into
every option would be tedious and easy to get wrong. Upkeep entries carry their
own conditions, which turned out to be enough for the loan service charge to be
a three-step function that escalates with the size of the debt — no multiply
operation, no engine change. Upkeep changes are recorded separately from choice
changes so the UI can distinguish them.

Per-character starting relationships: Trevor starts below the pack default on
`likes_you` because he arrived two weeks earlier and has priced that in.
Characterisation as data rather than as an opening cutscene. Nobody starts
feared, either — `fears_you` defaults to 10, since a midpoint default would mean
every new hire arrives already intimidating.

The content suite plays 60 seeded runs and asserts every event fires and every
option is taken at least once. That caught real dead content: Trevor's favour
chain sat behind a gate no plausible player could reach, taking two events and a
hard-times escape route with it, and debt could pass 5,500 while the service
charge stayed flat, so the failure spiral flattened out instead of tightening.
Both fixed in the data — the gate softened and given another route up, the loan
capped and its charge tiered.

The validator also gained a real fix: it counted an event with a cooldown as a
stage's fallback, but a cooldown can exclude it, so a stage could still run dry.

86 tests. Reasoning in docs/DECISIONS.md §11-13, including the seeding trap that
makes a correlated chooser look exactly like a content bug.

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

7.3 KiB
Raw Blame History

Architecture decisions

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

1. All game state is addressable by path

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

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

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

2. Conditions and effects are data, evaluated generically

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

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

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

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

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

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

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

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

5. Seeded, serializable RNG

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

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

6. The turn loop is a pure function

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

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

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

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

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

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

9. Tuning values live in the content pack

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

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

10. Naming

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

11. Per-turn upkeep is declared by the pack

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

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

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

12. Characters may start somewhere other than the pack default

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

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

13. Content is tested for reachability, not just validity

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

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