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

9.5 KiB
Raw Blame History

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.