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
121 lines
4.2 KiB
Markdown
121 lines
4.2 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 core is built and tested. No UI yet.
|
|
|
|
| Piece | State |
|
|
| --- | --- |
|
|
| Path-addressed state, conditions, effects | done |
|
|
| Seeded RNG, save/resume | done |
|
|
| Event selection, option gating | done |
|
|
| Turn loop | done |
|
|
| Content validator | done |
|
|
| Corporate Ladder content pack | not started |
|
|
| UI, save/export, feedback log | not started |
|
|
|
|
## Running
|
|
|
|
```sh
|
|
npm test # engine test suite (node's built-in runner, no dependencies)
|
|
npm run serve # static server on :8080 for development
|
|
```
|
|
|
|
There are no runtime dependencies and no build step for development. Node is
|
|
used only to run tests and, later, the single-file build.
|
|
|
|
## Layout
|
|
|
|
```
|
|
src/engine/ the game engine — imports nothing from content/
|
|
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
|
|
content/ content packs (settings)
|
|
test/ engine tests, run against a fixture pack, never real content
|
|
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.
|
|
|
|
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.
|