Files
moonlit-engine/docs/SPEC.md
T
Kevin Hermes a4a2348a54 SCUMM-style web adventure engine v0.1
Headless, data-driven core (no DOM, no eval): loader with one-pass
validation, verb-coin interaction resolution, polygon walkables with
Dijkstra pathfinding, branching dialogue with mid-conversation saves,
declarative JSON conditions/effects, versioned JSON saves.

Thin integration layer: asset manifest with graceful placeholders,
WebAudio music loop points (midi-playlist convention), canvas renderer
(hotspot boxes drawn where hitTest clicks), HUD model.

Web harness (web/) + skeleton example game exercising every feature.
69 tests (node --test), tools/run_all.sh gate, docs/SPEC.md design spec.
Verified end-to-end in a real browser: intro scene, walking, look/take,
room exits, dialogue choices.
2026-10-18 00:29:41 +00:00

203 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.