202 lines
9.5 KiB
Markdown
202 lines
9.5 KiB
Markdown
# 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.
|