Files
TheLadder/docs/DECISIONS.md
T
JesseMarkowitzandClaude Opus 5 cc59b81155 Add the engine core: path-addressed state, data-driven turns
The planning docs commit to one thing above all: adding content must never
require touching the engine. This lays the foundation that makes that true.

All game state is addressable by dotted path (money, skills.negotiation,
relationships.boss.likes_you, flags.took_loan), and conditions and effects are
generic operations over those paths. The schema in the planning docs had
`requires: { skill, min }` and `effects: { money, skills, relationships }`,
both of which hardcode the state shape into the engine; the first gate wanting
a relationship threshold or a flag would have meant an engine change.

There is deliberately no "random event" category. Every turn filters the whole
event pool by stage and conditions and draws by weight, so a routine turn, a
rare interruption and the hard-times branch differ only in their data.

Also here, neither in the planning docs but both cheap and load-bearing:

- A seeded, serializable RNG. Runs replay exactly from seed plus choices, which
  makes tests deterministic and playtest reports reproducible. The generator's
  position is one uint32 living in the save.
- A load-time content validator. Undeclared paths, unknown character ids, ids
  that cannot be path segments, events that can never fire and stages with no
  fallback event are all caught on load instead of forty turns into a game.

The turn loop is a pure function, so a saved game resumes into exactly the run
it left — covered by a test that plays thirty turns, plays the same thirty with
a JSON round trip in the middle, and deep-equals the two.

72 tests, no dependencies. Reasoning recorded in docs/DECISIONS.md.

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

5.1 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.