Files

202 lines
9.5 KiB
Markdown
Raw Permalink Normal View History

2026-10-18 00:29:36 +00:00
# Moonlit Engine — design spec
A SCUMM-style, web-based adventure game **engine**. This repo is the engine
only: game ideas, art and music generation come later. The engine is
content-agnostic and data-driven — a game is a JSON spec, not code.
Status: v0.1 — core complete and tested (68 tests), web harness working,
asset *integration* in place, asset *generation* explicitly out of scope.
---
## 1. Design principles
1. **Headless core.** `src/core/` has zero DOM, zero timers, zero network.
It takes an injected clock and emits events. Everything runs under
`node --test`. The browser layer (`web/`) is ~150 lines of glue.
2. **Declarative everything.** Conditions, effects, dialogue, scenes and
interactions are plain JSON. No `eval`, ever. Specs and saves stay
inspectable, validatable and diffable.
3. **Report every problem at once.** The loader validates the whole spec
in one pass and lists *all* errors (agents iterate on full error lists,
not one crash at a time). Same house rule as the midi-playlist repo.
4. **Never declare success without a passing run.** `./tools/run_all.sh`
(validate + tests) must be green before any claim of "works".
5. **Degrade gracefully while art doesn't exist yet.** Missing asset files
draw labelled placeholder boxes and warn once per id. A game with zero
art files is fully playable for design purposes.
## 2. Architecture
```
game.json ──► loader.js (validate + compile) ┌─► canvas.js (draws)
│ ├─► hud.js (verb bar / text box / squares)
▼ ├─► audio.js (music w/ loop points, sfx)
engine.js ◄── tick(dt) from rAF or fake clock └─► boot.js (DOM glue, save slots)
│
├── world.js mutable state: room, inventory, flags, vars, characters
├── verbs.js verb-coin interaction resolution
├── dialogue.js branching conversation trees
├── walkable.js polygons, clamping, Dijkstra paths
├── conditions.js JSON condition evaluator (no eval)
├── effects.js JSON world mutations (no eval)
├── save.js versioned JSON snapshots
└── events.js pub/sub; renderers/audio subscribe, core never calls them
```
The core emits events (`roomEnter`, `say`, `dialogueLine`, `playMusic`,
`walkStart`, …). Renderers subscribe. The core never knows a browser exists.
## 3. Lifecycle
Phases: `boot → title → menu → playing → ended`.
- **Title screen** — web layer's business (a DOM overlay in the demo).
The engine exposes `newGame()`, `load()`, `startScene()`.
- **Intro** — just a *scene* (`scenes.intro` in the spec): timed slides,
text, music, then `goto` the first room. Skippable (`skipScene()`).
- **Menus** (save/load/options/volume) — web layer; the engine exposes
`hud.canSave()` and save/load APIs. Keeping menus out of the core means
a future game can dress them however it likes.
- **Playing** — the verb/click/dialogue loop, with scene and dialogue
overlays.
- **Ended** — an `endGame` effect sets `phase = "ended"`; clicks stop;
the web layer shows the ending screen.
## 4. The game spec (game.json)
Top-level keys: `meta`, `startRoom`, `startInventory`, `walkSpeed`,
`defaultVerb`, `moveVerb`, `verbs[]`, `rooms[]`, `objects[]`,
`characters[]`, `interactions[]`, `dialogues{}`, `scenes{}`, `music[]`,
`sfx[]`, `assets[]`.
### Rooms & walkable areas
A room is a background asset id, one or more **walkable polygons**
(`[[x,y],...]` in a 640×480 coordinate space), an `entry` point, `exits`
(`{to, at:[x,y], label}`) and optional `onEnter` effects. The loader warns
(never errors) when an exit has no return exit — adventure convention, not
a hard rule.
### Objects
`{id, room, name, hotspot:{x,y,w,h}, takes?, tags?, sprite?}`. `takes:
true` objects leave the room once in inventory. `room: "*"` = everywhere.
`tags` feed tag-based interactions.
### Characters
Must include one with `id: "player"` (the walker). Others have a room,
position, sprite and tags. Characters render back-to-front by y.
### Verbs & interactions (the verb coin)
The verb table is free-form: classic nine-coin, modern two-verb, whatever.
Each verb has a label and a **default response** — the engine always
answers.
Interactions are a flat list:
`{verb, noun | tag, with?, conditions?, effects?, response?, priority?}`.
Resolution order: **exact noun id > tag > `*`**, then higher `priority`,
then spec order. Conditions gate each candidate; first full match wins.
`with` marks item-with interactions (`give mug to barkeep`).
### Conditions (JSON, no eval)
`{flag}`, `{not}`, `{all:[...]}`, `{any:[...]}`,
`{var, op: eq|ne|lt|lte|gt|gte, value}`, `{has: item}`, `{at: room}`,
`{here: character}`, `{dialogue, node}`, `{seen: scene}`.
### Effects (JSON, no eval)
`setFlag`, `setVar`, `addVar`, `give`, `remove`, `goTo`, `moveTo`,
`walkTo`, `say`, `playMusic`, `stopMusic`, `playSfx`, `startDialogue`,
`startScene`, `animate`, `endGame`.
### Dialogue
Named dialogues with a `start` node and a node map. Nodes: `speaker`,
`text`, then either `choices[]` (waits for the player; each choice has
`to`/`end`, optional `conditions`/`effects`) or `next` (auto-advances on
a timed hold) or neither (ends). Nodes can fire `onEnter` effects.
Mid-conversation position lives in world state, so saves resume
conversations. Finished dialogues are marked done and never auto-resume.
### Scenes (intro / cutscenes)
Ordered steps: `music`, `stopMusic`, `sfx`, `slide {asset, ms}`,
`text {text, ms}`, `wait {ms}`, `setFlag`, `startDialogue`, `goto {room}`.
Instant steps fall through; timed ones wait on the clock. Skipping jumps
to the final `goto`.
## 5. Game mechanics
- **Movement**: click a walkable point → pathfind (Dijkstra over boundary
waypoints between polygons, endpoints line-of-sight joined) → the engine
moves the player at `walkSpeed` px/s on `tick(dt)`. Clicks outside the
walkable area clamp to the nearest edge. Doors are exits: walk verb +
exit click changes rooms and fires `onEnter`.
- **Inventory**: `give`/`remove` effects; `has` conditions; taken objects
disappear from rooms; the HUD lists names.
- **World state**: flags (booleans) and vars (numbers) — the only way
stories remember things. Unknown flags read false / vars read 0, so
specs may gate on flags created later.
- **Hit-testing priority**: characters → objects → exits → walkable floor.
## 6. User interaction
- **Verb then target** (SCUMM): select verb, click hotspot. No verb
selected: click walks (or fires `defaultVerb` if the spec sets one).
- **Keyboard**: number keys 1–9 pick verbs, Esc clears the verb, Space
skips scenes.
- **Dialogue squares**: choices render as buttons; hidden by conditions.
- **Touch**: single taps = clicks; works as-is via canvas pointer events.
- **The engine always answers**: unmatched interactions fall back to the
verb's default response. Dead air is a spec bug, not an engine bug.
## 7. Save / load
Saves are plain JSON strings (`SAVE_VERSION = 1`, game-name tagged,
timestamped) — the web layer parks them in `localStorage`; the core never
touches storage. Loading rejects: bad JSON, missing/future version, wrong
game. Migration hooks exist for format changes. Mid-dialogue saves resume
at the saved node. Saving is gated off mid-dialogue/mid-scene (`canSave`).
## 8. Assets & audio — integration only
- **Asset manifest** in the spec: `{id, file, frames?, framesPerRow?,
framesRows?}`. The engine references ids; the web layer loads files.
Missing files → labelled placeholder + one warning. How files get made
is out of scope (that's the ComfyUI / midi-playlist side, later).
- **Music carries loop points** — `loopStartMs` / `loopEndMs` per track,
mirroring the MIDI repo's loop-marker convention. The web audio layer
plays the intro head once, then loops the body forever. The
*Moonlit Mangrove Calypso* track (bars 4–36 loop) is the reference
example: `example/game.json` ships it with loop points set.
- **Formats**: images png/jpg/webp; audio ogg/mp3 (anything WebAudio
decodes). Sprites are horizontal/row grids; frame timing per anim.
## 9. Testing policy
`node --test tests/*.test.js` — 68 tests, one file per module:
conditions, effects, walkable (incl. doorway traversal + unreachable
areas), loader (example validates clean; every error class reported in
one pass; dangling refs; symmetry warnings), verbs (specificity,
priority, condition fallthrough, with-items), dialogue (branching, timed
auto-advance, hidden choices, mid-dialogue snapshots), save (round-trip,
version/game rejection, resume), engine end-to-end (intro scene on the
clock, walking, clamping, take/give chains, dialogue gating, endGame),
render (manifest degradation, frame cycling, fake-canvas draw ops, HUD
model).
`./tools/run_all.sh` = validate example + full suite. Green or it didn't happen.
## 10. Conventions
- Coordinates: 640×480 room space, y-down, sprites anchored bottom-centre.
- Interaction precedence is spec order at equal specificity — put the
conditional variant **before** the unconditional one.
- Dialogue nodes are named strings, not indices.
- Save format changes require bumping `SAVE_VERSION` + a migration.
## 11. Roadmap (deliberately not in v0.1)
1. Game idea + writing (the fun part, next session).
2. Asset generation pipeline: ComfyUI for backgrounds/sprites,
midi-playlist → ogg for music, wired into the manifest.
3. Sprite animation polish (facing, walk cycles), character `animate`.
4. Multiple save slots, options menu, gamepad.
5. Room transitions with fades; parallax backgrounds.