Each pack declares a lifestyle tier, bought on the second rung and charged for every cycle after: Corporate Ladder's car and address, the Frontier's bay and the room over the Ellis place. Falling behind what the job expects costs standing, in steps that steepen as standing rises. A Dispatch coordinator keeping up fully nets -21 a week and earns the difference out of the days. No engine change; the only interface change is a tier display format. Sized by measurement, not feel (DECISIONS #36): a -2/week penalty is invisible against Dispatch's standing gains, and a player who chases standing every turn beats any flat penalty — 99 standing while buying nothing, and $2,245 richer than one who kept up. Hence the top step. Two defects fixed on the way. The conformance suite excluded debt from the economic baseline by matching /debt|owed|loan/i on path names, which would have counted every lifestyle tier into it; packs now declare tuning.baselineExcludes. And 'the promotion leaves a player better off' pooled end balances across archetypes that do not spend alike, so it measured the mix — it is now derived from the upkeep arithmetic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RxdJFbLq1rBKsUeticGV1g
245 lines
10 KiB
Markdown
245 lines
10 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.
|
|
|
|
- **Standing now costs money to keep, and nobody has played it.** Each setting
|
|
has a `lifestyle` tier bought on the second rung and charged for every cycle
|
|
after — and a standing penalty, steepening as standing rises, for lagging
|
|
behind what the job expects. A Dispatch coordinator keeping up fully runs at
|
|
-$21 a week and has to earn the difference. It is measured (DECISIONS #36)
|
|
and, like everything else here, unplayed.
|
|
- **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). Money mattering late is
|
|
built rather than open (DECISIONS #36).
|
|
|
|
## 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.
|