The stamping scheme was wrong in a way worth recording. tools/stamp.js rewrote
src/build-info.js before every serve and build, so after each commit the
committed stamp named the *previous* commit — as it does right now, reading
76072ed+ while HEAD is 5c1784d — and the next serve rewrote it and dirtied the
tree again. A generated value does not belong in a tracked file if anything
routinely regenerates it.
src/build-info.js is now permanent and reads `commit: 'dev'`. tools/build.js
substitutes the real commit into the bundled output only, and fails loudly if
the substitution finds nothing to replace. So dist/theladder.html names the
commit that produced it, running from source honestly reads "0.3.0 · dev", and
the working tree never churns. tools/stamp.js is gone and `npm run serve` is a
plain static server again.
Also here, for picking this up later: a "Where things stand" section in the
README with the five open questions in the order they are likely to matter —
whether money stops mattering late, the negotiation gate that four early options
sit behind, the feedback widget nobody uses, how thin Dispatch is next to the
mailroom, and third-tier versus second-pack.
121 tests. docs/DECISIONS.md §25 corrected to describe what the code now does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
222 lines
8.7 KiB
Markdown
222 lines
8.7 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.
|
|
`corporateladder` is the default pack; a Wild West or lemonade-stand pack would
|
|
be new data and no new code.
|
|
|
|
## Status
|
|
|
|
Engine and the first content pack are built and tested. No UI yet — the game is
|
|
currently playable only from a test harness.
|
|
|
|
| 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 |
|
|
| Played in a real browser | done — first playtest 2026-09-09 |
|
|
|
|
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 # 121 tests: engine, content, UI wiring, and the build
|
|
```
|
|
|
|
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. Two tiers, 31 events, a
|
|
retrospective, saves, and a feedback log that has already found one real bug.
|
|
|
|
Open questions, in the order they are likely to matter:
|
|
|
|
- **Does money stop mattering late?** A careful player ends around $8,300 over
|
|
200 turns. Tuning has been corrected twice from playtest logs and should be
|
|
judged from a session, not from simulated play — that is how the last trap got
|
|
in.
|
|
- **`skills.negotiation >= 15` gates four different early options.** The logs
|
|
show players bouncing off it repeatedly in the first thirty turns. It is
|
|
probably set too high for how slowly the skill grows.
|
|
- **Nobody uses the feedback widget.** Zero ratings across three sessions. The
|
|
locked-gate data in the log has been more useful than anything typed; the
|
|
widget may need to be more present, or may not need to exist.
|
|
- **Dispatch is eleven events against the mailroom's nineteen.** It passes the
|
|
variety guard, but it has had far less play than the mailroom.
|
|
- **The third tier, or the second content pack.** Promotion working entirely as
|
|
data is decent evidence the engine/content split holds; a second *setting*
|
|
would be the proof.
|
|
|
|
## 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 names a content pack
|
|
screens/ the turn screen and the retrospective
|
|
format.js state paths and conditions rendered as English
|
|
src/io/ local-storage save, file export/import, the feedback log
|
|
content/ content packs (settings)
|
|
tools/build.js flattens everything into one self-contained HTML file
|
|
test/ engine tests against a fixture pack; content and UI tests
|
|
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.
|