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:
- An in-engine paint app. You draw rooms — look and meaning together — and walk them seconds later.
- Games are folders of JSON + PNGs. Hand-editable, git-friendly, zip-and-share-able, LLM-writable.
- A forgiving verb-noun parser plus an optional local AI lane that maps free English onto commands the game already allows — the AI can bend language, never the rules.
- A fully headless core. The same engine runs with no window: pipe JSON commands in a terminal, call it over HTTP from Python, or hand the controls to Claude over MCP — including rendering real PNG frames so the AI can see what it built.
- Deterministic replays. Fixed 20-cycles-per-second simulation; the same command script always produces byte-identical results. Your quest's win path can be a CI test.
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
mkdir -p games/mygame && cargo run -p mrpgi -- --sample --game games/mygame— a working skeleton.- Open it:
cargo run -p mrpgi -- --game games/mygame. You boot into the editor. - Paint over room 0. Pick a colour, pick a meaning (floor = walkable, wall = blocked), draw. Tab to walk it. Tab back. K saves.
- Press 0 (object tool), click somewhere, and fill in the inspector fields: name, look text, use text. Toggle takeable or wins.
- Set an exit on the top strip (E: 1), press >, paint room 1.
- 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.
| Verb | Also answers to | Does |
|---|---|---|
look | l, examine, x, inspect, check, read, view, see, study | describe the room, or one thing |
take | get, grab, pick, snatch, steal, pocket, collect, nab | put a takeable object in your inventory |
use | open, unlock, activate, push, pull, turn, operate | the puzzle verb — locks, levers, wins |
talk | speak, ask, chat, greet, say | start a conversation |
drop | discard, leave, put | remove from inventory |
inventory | inv, i, items, bag | what you're carrying |
help | ?, commands, verbs | list 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
1–6 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:
- LOOK — one of 16 EGA colours. What players see.
- MEANING — what the pixels are: floor (walkable, auto-depth), wall (blocked), water / trigger (reserved control lines), or none (change the look only, leave behavior alone).
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.
Tools
| Key | Tool | Notes |
|---|---|---|
| 1 | brush | freehand; size with , . |
| 2 | line | click points; right-click or Enter commits the polyline |
| 3 | rect | drag a filled rectangle — your workhorse for rooms |
| 4 | fill | flood-fill a sealed region; a huge-spill warning means your outline leaks |
| 5 | pick | eyedropper |
| 6 | erase | back to blank + walkable |
| 7 | image | stamp a sprite from the game's sprites/; [ ] choose, R reload — stamped pixels bake the current meaning underneath (a tree stamped with wall is solid) |
| 8 | oval | filled ellipse |
| 9 | spawn | where the robot appears (cyan circle) |
| 0 | object | live 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
| Pattern | Recipe |
|---|---|
| Key & lock | takeable key anywhere + a door/chest with "needs":"key" |
| Lever & door | lever with "sets_flag":"power_on" + door with "requires_flag":"power_on" |
| Earn the ending | the finale object stacks needs + requires_flag + "wins":true |
| Gossip gate | a dialogue choice with requires_flag appears only after the deed; its sets_flag unlocks elsewhere (§6) |
| Fetch chain | A 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).
7. Flags: real puzzles
Flags are named booleans living for one play session (the manifest can preset them). Three things touch them:
- an object's successful
use→sets_flag - a picked dialogue choice →
sets_flag - gates: object
requires_flag(blocksusewith "Nothing happens. Something else must come first.") and choicerequires_flag(hides the choice)
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
- PNG with transparency, drawn at game scale — props are ~12–28 px tall. No upscaling, draw 1:1.
- Filename is the id:
sprites/orc.png↔"sprite":"orc". Missing sprites show as a magenta placeholder (the object still works). - Bottom-centre is the feet. Stand the subject on the canvas's bottom edge; the feet drive depth.
- EGA colours are ideal but optional — the engine snaps whatever you feed it. Palette files for Aseprite/Inkscape ship in
palettes/.
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.
{"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
| Route | Does |
|---|---|
POST /command | one Command JSON → JSON array of Events |
GET /state | full snapshot |
GET /frame.png | the current frame |
GET /events?since=N | event 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
| Tool | Does |
|---|---|
mrpgi_parse | type a player command |
mrpgi_walk_to / mrpgi_tick / mrpgi_choose | move, advance time, answer dialogue |
mrpgi_state | full snapshot: room, inventory, flags, visible objects, exits |
mrpgi_render_frame | returns a real PNG image — the model looks at the actual frame |
mrpgi_upsert_room / mrpgi_upsert_object / mrpgi_paint_stroke | author: whole rooms, single objects, individual paint ops |
mrpgi_save_room / mrpgi_new_game / mrpgi_load_game | persist, reset, switch games |
mrpgi_command | raw 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
- Author: Claude upserts rooms/objects as JSON.
- Look:
mrpgi_render_frame— does the room read visually? Fix strokes. - Playtest:
mrpgi_parse/mrpgi_walk_tothrough its own puzzle chain to the win. - Commit:
mrpgi_save_roomwrites 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
- Name the sprites that exist. Models invent sprite names otherwise; missing sprites render magenta.
- Name the flags that exist (or say "invent flags, prefix them
lh_"). - Ask for the walled-room skeleton (§5) explicitly — models that improvise geometry forget door gaps.
- One room per reply. Whole games in one shot degrade; the upsert-validate loop is your friend.
- "Reply with ONLY the JSON" — and pipe through
--render-roomto see it before you keep it.
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)
- 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. - Handle it in
GameState::apply: clamp, setself.ego.x/y, return anEgoMovedevent. - That's it. JSONL, HTTP and
mrpgi_commandover MCP can all teleport now. Add a dedicated MCP tool inbin/mrpgi-headless/mcp.rsonly if you want a nicer schema.
Ideas with prepared landing zones
- Water & triggers: control lines 1–2 are already painted and saved;
step_cycleis where they'd bite. - Strudel music: subscribe a browser page to
GET /eventsand translatemusic/audioevents into live-coded patterns. The event stream already exists. - Pathfinding:
sprite.rssteering is deliberately naive; A* over the priority buffer is a contained swap. - Directional ego views:
Viewalready models loops-of-cels; the robot just only has one loop.
16. Troubleshooting
| Symptom | Cause / fix |
|---|---|
| Objects are magenta squares | that sprite PNG doesn't exist in the game's sprites/ — make it (§8); the object still works |
| Fill flooded the whole screen | your outline has a gap; Z and seal it |
| Robot won't walk somewhere obvious | press L → both; you probably painted wall meaning under a floor-looking colour |
| Walked into a room and got stuck | the arrival edge is walled; the engine falls back to the room's spawn — set the spawn sensibly (9) |
| Exit won't trigger | door gaps must reach the screen border; the trigger zone is the outer ~9 px |
| AI lane does nothing | check 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 click | browsers require a user gesture before audio — touch anything |
| Web save button does nothing | sign in first; the SAVE/LOAD buttons live in the account panel |
| Typed a letter, a tool changed | you'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 | ||
|---|---|---|---|---|---|
| 0 | black | 8 | dark gray | ||
| 1 | blue | 9 | light blue | ||
| 2 | green | 10 | light green | ||
| 3 | cyan | 11 | light cyan | ||
| 4 | red | 12 | light red | ||
| 5 | magenta | 13 | light magenta | ||
| 6 | brown | 14 | yellow | ||
| 7 | light gray | 15 | white |
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 · 1–6 dialogue · top menus
Edit: 1–0 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. 🤖