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.
9.5 KiB
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
- Headless core.
src/core/has zero DOM, zero timers, zero network. It takes an injected clock and emits events. Everything runs undernode --test. The browser layer (web/) is ~150 lines of glue. - Declarative everything. Conditions, effects, dialogue, scenes and
interactions are plain JSON. No
eval, ever. Specs and saves stay inspectable, validatable and diffable. - 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.
- Never declare success without a passing run.
./tools/run_all.sh(validate + tests) must be green before any claim of "works". - 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.introin the spec): timed slides, text, music, thengotothe 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
endGameeffect setsphase = "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
walkSpeedpx/s ontick(dt). Clicks outside the walkable area clamp to the nearest edge. Doors are exits: walk verb + exit click changes rooms and firesonEnter. - Inventory:
give/removeeffects;hasconditions; 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
defaultVerbif 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/loopEndMsper 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.jsonships 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)
- Game idea + writing (the fun part, next session).
- Asset generation pipeline: ComfyUI for backgrounds/sprites, midi-playlist → ogg for music, wired into the manifest.
- Sprite animation polish (facing, walk cycles), character
animate. - Multiple save slots, options menu, gamepad.
- Room transitions with fades; parallax backgrounds.