THE MRPGI BOOK

Monster Robot Party Game Interpreter — the complete user & developer manual.
Play it · paint it · write worlds as JSON · drive it from terminals, Python and AIs · read the engine's soul.

play now: monsterrobot.games/engine engine version 0.1 this page is long on purpose

1. What MRPGI is

In the 1980s Sierra built AGI, the engine behind King's Quest, Space Quest and Police Quest. Its genius was the dual-buffer room: every screen is drawn twice — once as the picture you see, once as an invisible map of meaning (what's walkable, what's a wall, what's near the camera). One trick gives you walk-behind depth, collision, and doors, with zero per-object bookkeeping.

MRPGI keeps that soul and rebuilds everything around it, new-school:

The engine is Rust. The window is macroquad. The web version you're probably reading this next to is the whole engine — editor included — compiled to a ~1 MB WASM file.

2. Three quick starts

2a. Play in the browser (zero install)

Open monsterrobot.games/engine. You're in The DJ's Lost Fuse. Walk with arrows or click. Type look, take fuse, talk scrap-bot. Press Tab to open the paint tool. Hit SIGN IN (top right) to make an account so Save world keeps your edits between visits.

2b. Run it native

# requires Rust — https://rustup.rs
git clone <the repo> && cd MRPGI
cargo run -p mrpgi -- --game games/lost-fuse     # the GUI
cargo run -p mrpgi -- --sample                   # write + open the 3-room robot demo
cargo run -p mrpgi -- --kit                      # the 4-room fantasy starter world

2c. Make your first game in ten minutes

  1. mkdir -p games/mygame && cargo run -p mrpgi -- --sample --game games/mygame — a working skeleton.
  2. Open it: cargo run -p mrpgi -- --game games/mygame. You boot into the editor.
  3. Paint over room 0. Pick a colour, pick a meaning (floor = walkable, wall = blocked), draw. Tab to walk it. Tab back. K saves.
  4. Press 0 (object tool), click somewhere, and fill in the inspector fields: name, look text, use text. Toggle takeable or wins.
  5. Set an exit on the top strip (E: 1), press >, paint room 1.
  6. Edit games/mygame/game.json — name your game, write the intro line.

That's a game. Everything past this point is depth.

3. Playing

Moving

Arrow keys walk in eight directions; click walks to the point (greedy steering — it stops at walls rather than pathfinding around them, like the originals). Walk off a screen edge that has an exit and you enter the next room from the opposite side.

The parser

Type a sentence, press Enter. Filler words (the, a, at, please…) are dropped, the first word maps to a canonical verb, the rest becomes the noun and is matched loosely against object names and their synonyms.

VerbAlso answers toDoes
lookl, examine, x, inspect, check, read, view, see, studydescribe the room, or one thing
takeget, grab, pick, snatch, steal, pocket, collect, nabput a takeable object in your inventory
useopen, unlock, activate, push, pull, turn, operatethe puzzle verb — locks, levers, wins
talkspeak, ask, chat, greet, saystart a conversation
dropdiscard, leave, putremove from inventory
inventoryinv, i, items, bagwhat you're carrying
help?, commands, verbslist the verbs

Games can add their own synonyms (see the manifest) — The DJ's Lost Fuse accepts fix amp because its manifest maps fix to use.

The menu bar

Along the top, like the classics: Game (New Game, Edit Mode, CRT, Sound, Manual, About) and Action (Look Around, Inventory, Help). Slide along the bar with a menu open to switch menus. Every item just injects the same command a typed sentence would — menus, keyboards, terminals and AIs all speak one language to the engine.

Dialogue

talk <name> opens a conversation. Press 16 to pick a reply, Esc to leave. The world pauses while you talk. Some replies only appear once you've done something — if an NPC seems cagey, come back after you've changed the world.

The AI lane (native, optional)

When the parser doesn't understand a sentence and a local LLM is configured, the sentence is translated into one allowed command and re-run through the real parser. You'll see the translation as (≈ open crate). It's the difference between "I don't understand 'pry the lid off'" and the game just working — while your puzzle logic stays fully deterministic. Setup in the appendix.

4. The editor, deeply

Press Tab anywhere. The left panel holds tools; the top strip holds the world (room number, exits, music); the canvas is the room.

The one idea that matters: LOOK and MEANING

Every stroke paints up to two layers at once:

Colour never implies meaning. A brown floor and a brown wall are identical to the eye and opposites to the engine. Press L to cycle the view: look (the picture), both (walls tinted red, water blue, triggers green), ctrl (the raw meaning map). If you're confused about why the robot won't walk somewhere, look at both.

A fresh room is entirely walkable. You paint walls where you don't want walking — the opposite of most tile editors.

Tools

KeyToolNotes
1brushfreehand; size with , .
2lineclick points; right-click or Enter commits the polyline
3rectdrag a filled rectangle — your workhorse for rooms
4fillflood-fill a sealed region; a huge-spill warning means your outline leaks
5pickeyedropper
6eraseback to blank + walkable
7imagestamp a sprite from the game's sprites/; [ ] choose, R reload — stamped pixels bake the current meaning underneath (a tree stamped with wall is solid)
8ovalfilled ellipse
9spawnwhere the robot appears (cyan circle)
0objectlive objects — the game-logic tool (below)

Z undoes (just Z — no Ctrl). C clears the room. K saves, O reloads from disk. Rooms are stroke lists, so undo works by replaying every stroke but the last — nothing is ever destructively merged.

The object tool & the kit browser

With 0 active, [ ] flips through 60+ ready-made archetypes (fantasy, sci-fi, monsters, nightlife, horror — all in kit/). Click empty canvas to stamp one, fully written. Click an existing object to select it and edit in the inspector: name, look text, use text, needs-item, synonyms, takeable, wins, delete. Enter hops to the next field, Esc stops typing.

Rooms and exits

The strip's N E S W buttons cycle each edge's destination room. - = (or < >) move between rooms — moving saves the room you're leaving. Going past your highest room number creates a fresh blank one. In play, arriving at a walled edge falls back to the room's spawn point, so you can't get bricked in.

5. The authoring cookbook

Everything the editor does is readable, diffable JSON. This section is the full data reference — learn it and you can write entire games in a text editor, or get an AI to write them for you (§12).

A game is a folder

games/mygame/
  game.json          # the manifest — optional, every field has a default
  rooms/room0.json   # one RoomDoc per room
  sprites/*.png      # auto-quantized to EGA; filename = sprite name
  sfx/  music/       # optional audio overrides (§9)

game.json — the manifest

{
  "name": "The DJ's Lost Fuse",
  "start_room": 0,
  "intro_text": "Night. A scrapyard. Somewhere above, a party has gone horribly silent. Type 'help'.",
  "verbs": [ { "name": "use", "words": ["plug", "fix", "repair"] } ],
  "flags": {}
}

verbs merges extra synonyms into the built-in table by canonical name. The AI lane's whitelist is generated from the same table, so the parser and the AI can never drift apart. flags presets flag values for each new game.

RoomDoc — one room

{
  "strokes": [ ...paint operations, replayed in order on load... ],
  "spawn": [30, 140],
  "exits": [null, 1, null, null],   // N, E, S, W → room number or null
  "objects": [ ...ObjDefs... ],
  "music": 2                        // 0 off · 1 calm · 2 eerie · 3 tense · 4 jolly · 5 spooky
}

Strokes — the paint ops

Externally tagged: each stroke is an object with exactly one key. meaning is "None" | "Floor" | "Wall" | "Water" | "Trigger"; color is an EGA index 0–15 (palette table). The room grid is 160 × 168; y 0–15 is traditionally the "sky/wall" band and the playable floor usually starts around y 16.

{ "Rect":    { "x":0, "y":0, "w":160, "h":16, "color":0, "meaning":"Wall" } }
{ "Ellipse": { "x":22, "y":104, "w":34, "h":18, "color":6, "meaning":"None" } }
{ "Fill":    { "x":80, "y":100, "color":7, "meaning":"Floor" } }
{ "Line":    { "pts":[[0,80],[159,80]], "color":15 } }
{ "Brush":   { "pts":[[50,60],[52,61],[54,62]], "color":4, "meaning":"None", "size":2 } }
{ "Image":   { "name":"tree", "x":60, "y":80, "meaning":"Wall" } }

The standard walled room (copy this)

[
  { "Rect": { "x":0,   "y":0,   "w":160, "h":168, "color":1, "meaning":"None"  } },  // backdrop
  { "Rect": { "x":6,   "y":16,  "w":148, "h":146, "color":8, "meaning":"Floor" } },  // floor
  { "Rect": { "x":0,   "y":0,   "w":160, "h":16,  "color":0, "meaning":"Wall"  } },  // N wall
  { "Rect": { "x":0,   "y":160, "w":160, "h":8,   "color":0, "meaning":"Wall"  } },  // S wall
  { "Rect": { "x":0,   "y":0,   "w":6,   "h":168, "color":0, "meaning":"Wall"  } },  // W wall
  { "Rect": { "x":154, "y":0,   "w":6,   "h":70,  "color":0, "meaning":"Wall"  } },  // E wall, top half…
  { "Rect": { "x":154, "y":100, "w":6,   "h":68,  "color":0, "meaning":"Wall"  } }   // …leaving a door gap y 70–100
]

For a door on the N/S edge, split those walls at x 65–95 instead. The exit trigger zone is the outer ~9 pixels of each edge, so gaps must reach the border.

ObjDef — every field

{
  "name": "amp",               // what the parser calls it (required)
  "sprite": "chest",           // a PNG in sprites/ (required)
  "x": 122, "y": 132,          // its FEET — bottom-centre, drives depth (required)
  "look": "The club's outdoor amp. Its fuse slot gapes empty and scorched.",
  "takeable": false,
  "synonyms": "amplifier speaker stack",  // space-separated extra nouns
  "use_text": "You slot the fuse home. The amp THUMPS alive.",
  "needs": "fuse",             // inventory item required to use (else: "It's locked…")
  "wins": false,               // a successful use ends the game with the victory banner
  "requires_flag": "",         // flag that must be true to use (else: "Nothing happens…")
  "sets_flag": "music_on",     // flag set true on a successful use
  "dialogue": []               // §6 — non-empty makes it talkable
}

Everything except name / sprite / x / y defaults, so minimal objects stay minimal. Objects never block movement — solidity is painted, not attached to objects (stamp their footprint with the wall meaning if they should block).

The five load-bearing patterns

PatternRecipe
Key & locktakeable key anywhere + a door/chest with "needs":"key"
Lever & doorlever with "sets_flag":"power_on" + door with "requires_flag":"power_on"
Earn the endingthe finale object stacks needs + requires_flag + "wins":true
Gossip gatea dialogue choice with requires_flag appears only after the deed; its sets_flag unlocks elsewhere (§6)
Fetch chainA needs B, B needs C — each use_text should hint the next link

Case study: The DJ's Lost Fuse

The shipped example (games/lost-fuse/, ~200 lines of JSON) uses every mechanic exactly once:

take fuse            an item          → inventory              (room 0, scrapyard)
use amp              needs: fuse      → sets_flag music_on      (room 1, back street)
talk bouncer         a choice hidden behind requires_flag music_on…
  "I'm with the band."                  → sets_flag on_the_list
use turntable        requires_flag on_the_list → wins: true  (room 2, the club)

Play it before reading its JSON — then read its JSON. It's the fastest way to internalize the whole model.

6. Dialogue trees

An object with a non-empty dialogue array is talkable. Each entry is a node; node 0 is where talk starts. A node says a line and offers numbered choices; each choice jumps to another node (goto) or ends the conversation (goto: -1). A node with no visible choices is a terminal line.

The bouncer from Lost Fuse, in full — a real, working tree:

"dialogue": [
  { "says": "List. Name. No name, no party.",
    "choices": [
      { "text": "C'mon, one dance?", "goto": 1 },
      { "text": "Hear that bass? I fixed your amp. I'm with the band.",
        "goto": 2,
        "requires_flag": "music_on",      // hidden until the amp works
        "sets_flag": "on_the_list" },     // saying it unlocks the finale
      { "text": "Fine.", "goto": -1 }
    ] },
  { "says": "No list, no dance. The list is life.",
    "choices": [ { "text": "Harsh.", "goto": -1 } ] },
  { "says": "...That WAS you? Respect, fixer. You're on the list.",
    "choices": [ { "text": "Let's go.", "goto": -1 } ] }
]

Choices gated by requires_flag are invisible until the flag is set — the numbers the player sees always match what they can pick. This is how NPCs "notice" what you've done. Design note: give gated choices a line that makes the causality delicious ("Hear that bass?" only exists while bass is audible).

Writing tip: nodes are cheap. Give wrong turns a little personality ("The list is life.") — dead ends carry more character-per-byte than the golden path.

7. Flags: real puzzles

Flags are named booleans living for one play session (the manifest can preset them). Three things touch them:

Combined with needs (items), you can build arbitrarily long chains: item → machine → flag → gossip → flag → finale. State lives in the engine, not in your prose — you never have to trust the player read anything.

Choose flag names like music_on, met_wizard, bridge_down — sentences you can read in JSON six months later. Headless surfaces can set or inspect any flag ({"cmd":"set_flag",…}, {"cmd":"query"}), which makes puzzle chains testable in CI (§10).

8. The art pipeline

MRPGI never blits full-colour images over the game. Every PNG is quantized to the 16-colour EGA palette on load (alpha becomes transparency), so imported art depth-sorts, occludes and walks-behind exactly like hand-painted pixels. Gradients get an optional Bayer dither for that stippled retro look.

Sprite spec

AI-generated sprites

Each theme pack ships gemini_sprites.md with per-sprite prompts. The pattern that works:

STYLE (prepend to every prompt):
16-color EGA pixel art sprite, hard pixels, no anti-aliasing, no gradients,
solid magenta (#FF00FF) background, subject centered, feet touching bottom edge.

SPRITE:
a stout wooden treasure chest with iron bands, closed, 20x15 pixels

Generate, magenta-key the background to transparency, save into sprites/, press R in the editor. The engine's quantizer forgives models that don't quite hold the palette.

9. Sound & music

By default everything is synthesized square waves — PCjr-beeper soul, zero asset files. SFX cues: blip (command sent), pickup, confirm, error, door (room change), win (fanfare). Each room loops one of five ambient moods, set with the strip's button.

Bring your own audio

games/mygame/sfx/door.ogg          # overrides one cue: blip|pickup|confirm|error|door|win
games/mygame/music/eerie.ogg       # overrides a mood by name (calm|eerie|tense|jolly|spooky)…
games/mygame/music/2.ogg           # …or by number 1-5

WAV/OGG/MP3 all load; prefer OGG for music — MP3 encoders pad silence that clicks at the loop seam. Whatever you don't provide keeps its chiptune default. F2 mutes everything.

In the engine's architecture, sound is an event ({"event":"audio","cue":"pickup"}) — front-ends decide how to play it. That's what makes a future Strudel live-coding sidecar a matter of subscribing a browser page to the event stream, not an engine rewrite.

10. Driving it with code

The engine core is fully headless: a game state driven by JSON Commands in and Events out. The GUI is just one client. The mrpgi-headless binary exposes the same bus four more ways.

cargo build -p mrpgi-core          # builds target/debug/mrpgi-headless
mrpgi-headless --help

10a. JSONL stdio — the substrate

One JSON command per line in, events stream out. A real session against Lost Fuse — these are actual engine outputs:

$ mrpgi-headless --game games/lost-fuse
{"event":"room_changed","room":0,"music":2}
{"event":"transcript","line":"Night. A scrapyard. Somewhere above, a party has gone horribly silent. Type 'help'."}
{"cmd":"parse","text":"take fuse"}
{"event":"transcript","line":"> take fuse"}
{"event":"transcript","line":"You take the fuse."}
{"event":"inventory_changed","items":["fuse"]}
{"event":"audio","cue":"pickup"}
{"cmd":"walk_to","x":156,"y":85}
{"event":"transcript","line":"You step into room 1."}
{"event":"audio","cue":"door"}
{"event":"room_changed","room":1,"music":3}
{"event":"ego_moved","x":18,"y":84,"arrived":true}
{"cmd":"query"}
{"event":"state","state":{"game":"The DJ's Lost Fuse","room":1,"ego":[18,84,0],
  "inventory":["fuse"],"won":false,"flags":{},"exits":[2,null,null,0],
  "objects":[{"name":"amp","x":122,"y":132,"takeable":false,"has_dialogue":false},
             {"name":"bouncer","x":80,"y":108,"takeable":false,"has_dialogue":true}], …}}

The engine only advances when told: {"cmd":"tick","n":20} runs one second of fixed game cycles; walk_to ticks until arrival or blocked. The full command list is in the appendix.

10b. Script replay — your quest as a CI test

mrpgi-headless --script wintest.jsonl --game games/mygame > run1.log
mrpgi-headless --script wintest.jsonl --game games/mygame > run2.log
diff run1.log run2.log        # byte-identical, every time
grep -q '"event":"won"' run1.log && echo "quest still winnable"

Under --script the AI lane defaults off, so replays are fully deterministic. Keep a win-path script next to your game; run it after every edit; never ship an unwinnable puzzle again.

10c. Render with no window

mrpgi-headless --render-room games/mygame/rooms/room3.json out.png --game games/mygame

Bakes any RoomDoc to a PNG — props, depth and all. The GUI equivalent for whole-frame ground truth is mrpgi --shot out.png, which saves the actual GPU backbuffer.

10d. HTTP + Python

mrpgi-headless --serve 127.0.0.1:7777 --game games/mygame
RouteDoes
POST /commandone Command JSON → JSON array of Events
GET /statefull snapshot
GET /frame.pngthe current frame
GET /events?since=Nevent log from a cursor → {"next":M,"events":[…]}
POST /rooms/{n}body = RoomDoc (add ?persist to write to disk)
# Python needs nothing but requests:
import requests, json
E = "http://127.0.0.1:7777"
requests.post(f"{E}/command", data=json.dumps({"cmd": "parse", "text": "look"}))
state = requests.get(f"{E}/state").json()
open("frame.png", "wb").write(requests.get(f"{E}/frame.png").content)

11. AIs and MRPGI

This is the headline act: the engine is an MCP server, so Claude (or any MCP client) can author and play games — and see real rendered frames of its own work.

claude mcp add mrpgi -- /path/to/target/debug/mrpgi-headless --mcp --game games/mygame

The tools Claude gets

ToolDoes
mrpgi_parsetype a player command
mrpgi_walk_to / mrpgi_tick / mrpgi_choosemove, advance time, answer dialogue
mrpgi_statefull snapshot: room, inventory, flags, visible objects, exits
mrpgi_render_framereturns a real PNG image — the model looks at the actual frame
mrpgi_upsert_room / mrpgi_upsert_object / mrpgi_paint_strokeauthor: whole rooms, single objects, individual paint ops
mrpgi_save_room / mrpgi_new_game / mrpgi_load_gamepersist, reset, switch games
mrpgi_commandraw bus escape hatch — anything the JSONL surface accepts

Room upserts are validated before they land: a room with no walkable spawn, or an exit pointing at a room that doesn't exist, is rejected with an explanatory error event. Generated lore either loads or fails loudly — it can't half-load.

The loop that makes games from prompts

  1. Author: Claude upserts rooms/objects as JSON.
  2. Look: mrpgi_render_frame — does the room read visually? Fix strokes.
  3. Playtest: mrpgi_parse / mrpgi_walk_to through its own puzzle chain to the win.
  4. Commit: mrpgi_save_room writes JSON to disk. Human plays it in the GUI.

12. Prompt patterns that work

Real templates with real outputs. The formats are strict serde types, so generated JSON either deserializes or fails loudly — which is exactly what makes LLM authoring reliable here.

12a. "Write me a room" (paste-into-any-chatbot version)

Here is a room from my MRPGI game as JSON: [paste rooms/room1.json]

Write ONE new room in exactly this format. It is a rooftop afterparty above
the club. Requirements:
- 160x168 grid; backdrop Rect first, then a Floor rect, then Wall rects with
  a door gap in the SOUTH wall (split it at x 65-95), exits [null,null,2,null]
- night-sky backdrop (color 1), a moon (yellow Ellipse near the top),
  one neon strip (color 13)
- two objects using ONLY these sprites: barrel, sign, villager
- one object should have a look text that rewards climbing all this way
- music: 1 (calm)
Reply with ONLY the JSON.

Actual output (this exact room ships in games/lost-fuse/expansion/roof.jsonl and loads clean):

{"strokes":[
  {"Rect":{"x":0,"y":0,"w":160,"h":168,"color":1,"meaning":"None"}},
  {"Rect":{"x":6,"y":16,"w":148,"h":146,"color":8,"meaning":"Floor"}},
  {"Rect":{"x":0,"y":0,"w":160,"h":16,"color":0,"meaning":"Wall"}},
  {"Rect":{"x":0,"y":160,"w":65,"h":8,"color":0,"meaning":"Wall"}},
  {"Rect":{"x":95,"y":160,"w":65,"h":8,"color":0,"meaning":"Wall"}},
  {"Rect":{"x":0,"y":0,"w":6,"h":168,"color":0,"meaning":"Wall"}},
  {"Rect":{"x":154,"y":0,"w":6,"h":168,"color":0,"meaning":"Wall"}},
  {"Ellipse":{"x":20,"y":2,"w":14,"h":10,"color":14,"meaning":"None"}},
  {"Rect":{"x":30,"y":30,"w":100,"h":4,"color":13,"meaning":"None"}}],
 "spawn":[80,144],
 "exits":[null,null,2,null],
 "objects":[
  {"name":"punch","sprite":"barrel","x":40,"y":126,
   "look":"Afterparty punch. It is fizzing in a way liquids should not.","synonyms":"drink barrel"},
  {"name":"skyline","sprite":"sign","x":110,"y":60,
   "look":"The whole neon city, humming below. Worth saving a party for.","synonyms":"city view"}],
 "music":1}

12b. Drop that lore into a RUNNING game

$ mrpgi-headless --script games/lost-fuse/expansion/roof.jsonl --game games/lost-fuse
{"event":"room_upserted","room":3,"persisted":false}   # validated + live
{"event":"edited","room":2,"strokes":13}                # room 2's north exit now → 3

Same two operations over HTTP (POST /rooms/3) or MCP (mrpgi_upsert_room) extend a session someone is playing right now.

12c. Prompts to give Claude when the MCP server is connected

# the full authoring loop, one sentence:
"Render the current frame, then build a 4-room haunted-lighthouse game:
key & lock, one flag chain, one NPC whose gated dialogue choice unlocks the
finale. Playtest it to the win yourself, render each room so I can see them,
then save all rooms to disk."

# incremental world-building:
"Look at room 2. Add a locked cellar door in the south wall, a new room 5
behind it, and hide the key somewhere that requires talking to the barkeep."

# QA pass:
"Play my game blind from the start. Every time text confuses you or a verb
fails, note it. Win it, then give me the friction list sorted by severity."

# regression testing:
"Play to the win, then write the exact command sequence as a JSONL script I
can replay with --script in CI."

12d. Constraints that raise output quality

13. The web build & cloud saves

The page at /engine/ is the whole engine (game + editor) compiled to WASM — about 1 MB. Differences from native: the AI parser lane is off (no local Ollama in a browser), disk saving is replaced by cloud saves, and the embedded game is fixed at build time.

Accounts

SIGN IN (top right) → pick any username and password → done. Your password is stored only as an argon2 hash; your session is a cookie; your world is a JSON document on the server. Save world snapshots the entire live world (manifest + every room, including unsaved paint); Load world swaps it back into the running engine. There's no password reset yet — pick something you'll remember, or ask the admin to clear your account.

For the curious: how save works

The page can't see the engine's memory, so the SAVE button asks the engine (via a wasm export) to serialize itself; the engine calls back into page JS with the JSON on its next frame; the page PUTs it to /engine/api/world. LOAD reverses the trip. The whole world — three rooms of Lost Fuse — is about 6.5 KB.

14. Engine internals (for devs)

Workspace map

mrpgi-core/            # the engine. ZERO window/audio deps — enforced by the crate boundary
  src/state.rs         # GameState: apply(Command) → Vec<Event>, tick(dt); THE spine
  src/world.rs         # RoomDoc/ObjDef/dialogue data model, World, game folders, WorldBundle
  src/parser.rs        # verb table, verb-noun parser, dialogue state machine
  src/render.rs        # headless compositor → RGBA / PNG
  src/framebuffer.rs   # the dual buffers + band(y) depth table
  src/picture.rs       # draw primitives: rects, lines, flood fill, stamps
  src/sprite.rs        # the ego: movement, collision, walk cycle
  src/assets.rs        # PNG → EGA quantizer, sprite folders
  src/audio.rs         # square-wave synth + cue definitions (content, not output)
  src/ai.rs            # the AI lane (worker thread, Ollama/OpenRouter)
  src/kit.rs           # sample game + starter kit writers
  src/bin/mrpgi-headless/   # JSONL / --script / --render-room / HTTP / MCP
  tests/engine.rs      # parser goldens + full win-path replay determinism test
mrpgi/                 # the macroquad GUI — just another bus client
mrpgi-vault/           # standalone: accounts + world saves for the web (argon2 + tiny_http)

The bus

Everything is GameState::apply(Command) → Vec<Event> plus tick(dt) for wall-clock front-ends. Commands and events are serde enums ({"cmd":"…"} / {"event":"…"}), so every control surface — GUI, stdio, HTTP, MCP, the Sierra menus themselves — is a thin adapter. Options live at the edges; the core neither knows nor cares who's connected.

The AGI trick, precisely

Rooms rasterize into two 160×168 byte buffers: visual (palette indices) and priority. Priority values 4–15 are depth bands — band(y) = min(4 + y*12/168, 15) — so lower on screen means nearer the camera. Values 0–3 are control lines: 0 blocks movement, 1 water, 2 trigger, 3 reserved. The compositor sorts screen objects by the band under their feet and paints each pixel only where object_priority ≥ room_priority. That inequality is the entire walk-behind system.

Determinism

The sim advances in fixed cycles (CYCLE_DT = 1/20 s). Real-time front-ends accumulate frame time into cycles; scripted surfaces send tick counts. No wall clock, no RNG in the core. The test suite plays the sample game to the win twice and asserts the transcripts are byte-identical — if you add nondeterminism, CI will out you.

The vault API (web accounts)

POST /engine/api/signup  {"user","pass"}   # 2-20 chars a-z 0-9 _ - ; argon2id hash at rest
POST /engine/api/login   {"user","pass"}   # session cookie, 30 days, HttpOnly
POST /engine/api/logout
GET  /engine/api/me                        # {"user":"matt"} or 401
GET  /engine/api/world                     # your saved WorldBundle
PUT  /engine/api/world                     # save (≤ 4 MB, must parse as JSON)

Storage is JSON files on disk; sessions are in-memory (a service restart signs everyone out, harmlessly). A WorldBundle is {"manifest":…, "rooms":{"0":RoomDoc,…}} — the same single-file world format used by save/load everywhere.

15. Extending the engine

Add a verb (data only)

New synonyms are pure manifest data (§5). New verb behaviors are one arm in parser.rs::run_command's match — read the "use" arm as the template; it shows item checks, flag gates and win handling in ~20 lines.

Add a Command (worked example)

  1. Add a variant in state.rs: Teleport { x: i32, y: i32 } — serde gives you {"cmd":"teleport","x":80,"y":100} on every surface for free.
  2. Handle it in GameState::apply: clamp, set self.ego.x/y, return an EgoMoved event.
  3. That's it. JSONL, HTTP and mrpgi_command over MCP can all teleport now. Add a dedicated MCP tool in bin/mrpgi-headless/mcp.rs only if you want a nicer schema.

Ideas with prepared landing zones

16. Troubleshooting

SymptomCause / fix
Objects are magenta squaresthat sprite PNG doesn't exist in the game's sprites/ — make it (§8); the object still works
Fill flooded the whole screenyour outline has a gap; Z and seal it
Robot won't walk somewhere obviouspress Lboth; you probably painted wall meaning under a floor-looking colour
Walked into a room and got stuckthe arrival edge is walled; the engine falls back to the room's spawn — set the spawn sensibly (9)
Exit won't triggerdoor gaps must reach the screen border; the trigger zone is the outer ~9 px
AI lane does nothingcheck the status bar's ai: readout; Ollama running? MRPGI_AI_MODEL matches ollama list? It only fires on unknown verbs
No sound on web until you clickbrowsers require a user gesture before audio — touch anything
Web save button does nothingsign in first; the SAVE/LOAD buttons live in the account panel
Typed a letter, a tool changedyou're in the editor and no text field is focused — tool keys are single letters; in Play, letters go to the command line

17. Appendices & reference

A. The EGA palette

#Name#Name
0black 8dark gray
1blue 9light blue
2green 10light green
3cyan 11light cyan
4red 12light red
5magenta 13light magenta
6brown 14yellow
7light gray 15white

B. Command reference (every surface)

// playing
{"cmd":"parse","text":"take key"}      {"cmd":"walk","dir":3}         // 0 stop, 1 N … 8 NW
{"cmd":"move_to","x":80,"y":120}       {"cmd":"walk_to","x":80,"y":120}
{"cmd":"choose","n":1}                 {"cmd":"end_dialogue"}
{"cmd":"tick","n":20}                  {"cmd":"new_game","room":null}
{"cmd":"goto_room","n":2}              {"cmd":"query"}
{"cmd":"set_flag","name":"x","value":true}   {"cmd":"set_var","name":"x","value":1}
// authoring (the same bus the editor uses)
{"cmd":"paint_stroke","stroke":{…}}    {"cmd":"undo"}   {"cmd":"clear_strokes"}
{"cmd":"set_spawn","x":80,"y":140}     {"cmd":"set_exit","dir":1,"target":2}
{"cmd":"set_music","mood":4}           {"cmd":"reload_sprites"}
{"cmd":"upsert_object","room":null,"obj":{…}}   {"cmd":"delete_object","room":null,"name":"x"}
{"cmd":"upsert_room","n":7,"doc":{…},"persist":true}
{"cmd":"save_room","n":null}           {"cmd":"load_game","dir":"games/other"}

C. Event reference

transcript{line}      room_changed{room,music}   inventory_changed{items}
dialogue_open{lines}  dialogue_closed            won
audio{cue}            music{mood}                ego_moved{x,y,arrived}
state{state}          edited{room,strokes}       room_upserted{room,persisted}
room_saved{room,path} game_loaded{name,room}     error{message}

D. The AI lane (native)

MRPGI_AI=ollama            # ollama (default) | openrouter | off
MRPGI_AI_MODEL=gemma3:4b   # your local tag — check `ollama list`
MRPGI_AI_URL=…             # endpoint override
OPENROUTER_API_KEY=…       # for openrouter

The prompt sent to the model contains only the verb whitelist (generated from the live verb table) and the visible object names. It may answer with one short command; that command then runs through the normal deterministic parser. The model cannot invent items, rooms, or outcomes.

E. Keyboard reference card

Global: Tab edit⇄play · F1 CRT · F2 mute · Esc unfocus/leave-chat/quit
Play: arrows/click walk · type + Enter · 16 dialogue · top menus
Edit: 10 tools · L view · [] sprite/kit · ,. brush · -= rooms · Z undo · K save · O load · C clear · R reload sprites

MRPGI — Monster Robot Party Game Interpreter. Built from "let's reimagine Sierra's AGI" into an engine your terminal, your friends and your AIs can all play. Now go make something weird. 🤖

↑ top