Cold Fork is a freight yard: 29 events across two stages, a four-day cycle, and its own relationship and skill vocabulary. src/engine/ was not touched. Adding it needed three changes outside the engine: one save slot per pack, app.js holding the current pack, and turn.js no longer naming the mailroom (DECISIONS #34). build.js now handles imports wrapped across lines. A conformance suite runs over every registered pack. DECISIONS #35 records three calls on open questions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RxdJFbLq1rBKsUeticGV1g
242 lines
9.9 KiB
Markdown
242 lines
9.9 KiB
Markdown
# The Ladder
|
|
|
|
A turn-based career simulation game that runs entirely in the browser. You
|
|
start at the bottom of an organisation and play turns — a day at a time —
|
|
making choices that move your money, your skills and your standing with the
|
|
people around you. There is no win state and no game over: a bad run means
|
|
worse options, not an ending, and you can stop whenever you like and read the
|
|
retrospective.
|
|
|
|
The engine knows nothing about offices, mailrooms or bosses. A **setting is a
|
|
content pack** — characters, events, choices, stat names and tuning, all data.
|
|
Two ship: **Corporate Ladder**, a modern office and a mailroom, and **The
|
|
Frontier**, a freight yard at the end of a stage line. They share every line of
|
|
engine code and no content at all.
|
|
|
|
## Status
|
|
|
|
Engine, interface and two content packs, all built and tested, and played in a
|
|
real browser.
|
|
|
|
| Piece | State |
|
|
| --- | --- |
|
|
| Path-addressed state, conditions, effects | done |
|
|
| Seeded RNG, save/resume | done |
|
|
| Event selection, option gating | done |
|
|
| Turn loop, per-turn upkeep | done |
|
|
| Content validator + reachability tests | done |
|
|
| Corporate Ladder pack — 31 events, two tiers | done |
|
|
| Turn screen, retrospective, feedback widget | done |
|
|
| Local-storage save, file export/import | done |
|
|
| Single-file build | done |
|
|
| Calendar, scheduled wages and rent | done |
|
|
| Promotion from the mailroom to Dispatch | done |
|
|
| Repetition analytics (`npm run analyze`) | done |
|
|
| Version and build shown on screen | done |
|
|
| Keyboard play (Enter / Tab) | done |
|
|
| Second content pack — The Frontier, 29 events, two tiers | done |
|
|
| Setting picker, one save slot per pack | done |
|
|
| Pack conformance suite (every pack, every check) | done |
|
|
| Played in a real browser | Corporate Ladder yes, first playtest 2026-09-09; **The Frontier not yet** |
|
|
|
|
The first playtest confirmed the interface, saves and the exported log all work
|
|
in a real browser. Its findings — an unexplained daily drain, no sense of the
|
|
week, and an economy nobody could get ahead in — are what the calendar and the
|
|
retuned economy are for.
|
|
|
|
Note that the development machine has no browser that can render a page, so the
|
|
UI tests drive the real modules against a minimal fake DOM. That covers wiring,
|
|
not layout: **styling and dark mode are only ever verified by a human opening
|
|
it.**
|
|
|
|
## Playing it
|
|
|
|
```sh
|
|
npm run serve # then open http://localhost:8080
|
|
```
|
|
|
|
The game plays from the keyboard: Enter takes the focused option, Enter again
|
|
moves to the next day, and Tab / Shift+Tab pick a different option. The top of
|
|
every screen names the game, the pack, your current title, and the build —
|
|
`0.3.0 · dev` when running from source, and the commit it came from when built.
|
|
|
|
A server is needed during development because browsers refuse ES module imports
|
|
over `file://`. If port 8080 is taken, run `python3 -m http.server <port>`
|
|
instead.
|
|
|
|
To hand the game to someone else:
|
|
|
|
```sh
|
|
npm run build # writes dist/theladder.html
|
|
```
|
|
|
|
That is one self-contained file with the stylesheet and every module inlined.
|
|
It plays by double-clicking it — no server, no network, nothing installed.
|
|
|
|
```sh
|
|
npm test # 149 tests: engine, both packs, UI wiring, and the build
|
|
```
|
|
|
|
Tests are in three layers. `test/conformance.test.js` runs over **every pack in
|
|
the registry** and asserts only what is true of a pack because it is a pack —
|
|
validity, no dead content, the declared economic baseline, variety, replay from
|
|
seed, a way out of every event. `test/content.test.js` and
|
|
`test/frontier.test.js` hold each pack to its own tuning. The engine suite runs
|
|
against a fixture pack, so tuning a setting can never break it.
|
|
|
|
To see what a playtest actually did — turns against distinct situations, how
|
|
often each recurred, which locked gates players kept meeting, and anything they
|
|
typed into the feedback box:
|
|
|
|
```sh
|
|
npm run analyze # the newest export in logs/
|
|
npm run analyze -- <file> # a specific one
|
|
```
|
|
|
|
Drop exports into `logs/`. They are gitignored — a log is a record of what a
|
|
real person did, including anything they typed into the feedback box.
|
|
|
|
There are no runtime dependencies and no build step for development. Node is
|
|
used only to run the tests, the single-file build, and the log analyser.
|
|
|
|
## Where things stand
|
|
|
|
The MVP is complete and has survived three playtests, and a second setting now
|
|
exists to prove the engine/content split holds. Adding it required **no engine
|
|
change** — and three interface changes, two of which were latent defects a
|
|
second pack made visible (a single global save slot, and the one place the UI
|
|
still named a setting). See DECISIONS.md #34.
|
|
|
|
Open questions, in the order they are likely to matter:
|
|
|
|
- **The Frontier has never been played by a human.** Its economy is asserted by
|
|
the conformance suite and exercised by six archetypes, and that is exactly the
|
|
evidence that was not enough the last two times. It needs a session before its
|
|
numbers are believed.
|
|
|
|
- **Money stops mattering late, and it should not.** A careful player ends
|
|
around $8,300 over 200 turns. The direction is set: standing should cost
|
|
money to keep — a better car, a better place to live, better clothes — so
|
|
expenses climb as the player rises. Not built yet.
|
|
- **Dispatch is eleven events against the mailroom's nineteen.** It passes the
|
|
variety guard, but it has had far less play than the mailroom. Waits on more
|
|
playtesting.
|
|
- **A third tier in either pack.** Two rungs is what both settings have. The
|
|
split is now proven across settings; it is not proven across a long ladder.
|
|
Waits until the Frontier has been played.
|
|
|
|
Settled, and not open: Corporate Ladder's negotiation gate stays as it is, and
|
|
the feedback widget stays as it is (DECISIONS #35).
|
|
|
|
## Layout
|
|
|
|
```
|
|
src/engine/ the game engine — imports nothing from content/, or from src/ui/
|
|
paths.js dotted-path get/set over state
|
|
conditions.js generic predicates: { path, op, value }, all/any/not
|
|
effects.js generic mutations: add/set/push/remove, with clamping
|
|
state.js pack -> schema and initial state
|
|
select.js which event fires this turn, which options are available
|
|
game.js the turn loop
|
|
rng.js seeded, serializable RNG
|
|
validate.js load-time content checking
|
|
src/ui/ screens, formatting and the app controller
|
|
app.js the only module that knows there is more than one pack
|
|
screens/ the turn screen, the retrospective, the setting picker
|
|
format.js state paths and conditions rendered as English
|
|
src/io/ local-storage save (one slot per pack), export/import, feedback log
|
|
content/
|
|
index.js the pack registry — the only list of settings
|
|
corporateladder/ a modern office and a mailroom
|
|
frontier/ a freight yard at the end of a stage line
|
|
tools/build.js flattens everything into one self-contained HTML file
|
|
test/ engine tests against a fixture pack; conformance, per-pack, UI
|
|
docs/ DECISIONS.md — why things are the way they are
|
|
Planning/ the original design documents
|
|
```
|
|
|
|
**The rule that keeps this honest: nothing in `src/engine/` may import from
|
|
`content/`.** The engine names no stat, no character and no setting of its own.
|
|
|
|
## How content works
|
|
|
|
A pack declares what state exists and what it starts at, the relationship stats
|
|
every character carries, the characters, and the events. Everything else falls
|
|
out of that.
|
|
|
|
```js
|
|
export default {
|
|
id: 'corporateladder',
|
|
startingStage: 'mailroom',
|
|
|
|
state: {
|
|
'money': { initial: 1200, min: -5000, max: 1000000 },
|
|
'skills.negotiation': { initial: 1, min: 0, max: 100 },
|
|
'flags.took_loan': { initial: false },
|
|
},
|
|
|
|
// Expanded per character into relationships.<id>.<stat>, so adding a
|
|
// character adds its relationship state automatically.
|
|
relationshipStats: {
|
|
likes_you: { initial: 50, min: 0, max: 100 },
|
|
fears_you: { initial: 50, min: 0, max: 100 },
|
|
wants_to_help_you: { initial: 50, min: 0, max: 100 },
|
|
},
|
|
|
|
characters: [
|
|
{ id: 'boss', name: '...', role_type: 'boss', description: '...' },
|
|
],
|
|
|
|
events: [
|
|
{
|
|
id: 'mail_run',
|
|
stage: 'mailroom',
|
|
weight: 100, // relative likelihood among eligible events
|
|
description: 'A cart of mail and two hours to move it.',
|
|
options: [
|
|
{
|
|
id: 'hustle',
|
|
label: 'Get it done fast.',
|
|
requires: [{ path: 'skills.negotiation', op: '>=', value: 3 }],
|
|
effects: [
|
|
{ path: 'money', op: 'add', value: 40 },
|
|
{ path: 'relationships.boss.likes_you', op: 'add', value: 3 },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
```
|
|
|
|
An event may also carry `once: true`, a `cooldown` in turns, and `requires`
|
|
conditions of its own. There is no separate notion of a random event: every
|
|
turn draws from the whole eligible pool by weight.
|
|
|
|
Conditions may read engine bookkeeping (`turn`, `stage`, `seen.<eventId>`,
|
|
`lastSeen.<eventId>`) as well as pack state. Effects may not write it.
|
|
|
|
A pack also declares `upkeep` — a list of `{ label, requires?, effects }`
|
|
applied at the end of a turn, after the choice. Adding `every` and `offset`
|
|
makes an entry periodic rather than daily: Corporate Ladder pays wages on
|
|
alternate Fridays (`{ every: 14, offset: 4 }`), charges rent every Monday, and
|
|
adds a loan service charge that steps up as the debt grows.
|
|
|
|
A pack may also declare a `calendar`:
|
|
|
|
```js
|
|
calendar: {
|
|
cycleLength: 7,
|
|
cycleName: 'Week',
|
|
unitNames: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],
|
|
},
|
|
```
|
|
|
|
The engine derives `calendar.index`, `calendar.cycle` and `calendar.name` from
|
|
the turn number before each event is drawn, so content can require a Friday and
|
|
the interface can title the turn. Declare no calendar and there is no week.
|
|
|
|
Run `validatePack(pack)` when loading in development — it catches undeclared
|
|
paths, unknown characters, unusable ids, unreachable events and stages with no
|
|
fallback event, all of which otherwise fail silently mid-playthrough.
|