The handover plan (docs/HANDOVER.md), P0-P6, in one pass. One insight powers it: CLI/HTTP/MCP/Python is one feature, not four — a headless core driven by Commands in, Events out, with every surface a thin adapter. - mrpgi-core: the whole sim as a library with zero macroquad (the workspace boundary is the guardrail). GameState::apply/tick, fixed 1/20s cycles, deterministic replays, headless RGBA/PNG rendering. - mrpgi-headless: JSONL stdio REPL, --script replay (AI lane off = byte-identical), --render-room PNG, --serve HTTP (state/command/ events cursor/frame.png), --mcp JSON-RPC stdio server whose render_frame returns a real PNG so a model sees its own moves. - Games are folders: game.json manifest (all fields default — legacy dirs still load), data-driven verb table feeding both the parser and the AI whitelist, flags with requires_flag/sets_flag gating on objects and dialogue choices, live UpsertRoom lore ingestion with validation. - mrpgi (GUI): now one consumer of the bus; editor commits through the same commands MCP uses; audio became core events with per-game sfx/ music/ file overrides beating the chiptune synth. - Tests: parser goldens + a full win-path playthrough asserting byte-identical transcripts across runs. Deleted: the old fused src/ (root is now a virtual workspace), the dead demo Scene/Assets path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6.0 KiB
Driving MRPGI programmatically
The engine core (mrpgi-core) is fully headless: a GameState driven by
JSON Commands in and Events out, over your choice of surface. The GUI is
just one more consumer of the same bus.
GUI (macroquad) ─┐
CLI / stdio ─────┤
HTTP ────────────┼──▶ Command ▶ GameState ▶ Event
MCP (Claude) ────┤
Python ──────────┘
Everything below uses one binary:
cargo build -p mrpgi-core # builds target/debug/mrpgi-headless
mrpgi-headless --help
--game DIR points any mode at a game folder (default: the current dir).
Game = a folder
games/mygame/
game.json # manifest: name, start_room, intro_text, verbs, flags
rooms/room0.json # RoomDoc: strokes, spawn, exits [N,E,S,W], objects, music
sprites/*.png # auto-quantized to EGA; filename = sprite name
sfx/door.ogg # optional: overrides the built-in chiptune per cue
music/calm.ogg # optional: overrides a music mood (by name or index 1-5)
Every manifest field defaults, so a bare rooms/ folder is already a valid
game. Write starter content with --sample (3-room robot party) or --kit
(fantasy world + object archetype catalog).
1. JSONL stdio (the substrate)
mrpgi-headless --game games/mygame
One JSON command per line on stdin, events stream back on stdout:
{"cmd":"parse","text":"take key"}
{"cmd":"walk_to","x":150,"y":90}
{"cmd":"tick","n":20}
{"cmd":"choose","n":1}
{"cmd":"query"}
{"cmd":"new_game"}
{"cmd":"goto_room","n":2}
{"cmd":"set_flag","name":"power_on","value":true}
{"cmd":"paint_stroke","stroke":{"Rect":{"x":0,"y":0,"w":160,"h":16,"color":0,"meaning":"Wall"}}}
{"cmd":"upsert_object","obj":{"name":"gem","sprite":"potion","x":90,"y":120,"look":"Shiny.","takeable":true}}
{"cmd":"upsert_room","n":7,"doc":{"strokes":[],"spawn":[80,140]},"persist":true}
{"cmd":"save_room"}
{"cmd":"load_game","dir":"games/other"}
Notes:
- The engine only advances when told:
tickruns fixed 1/20s cycles (movement, room transitions, AI results).walk_toticks until arrival. upsert_roomvalidates before committing (walkable spawn, exits must point at rooms that exist) and rejects bad lore with anerrorevent.- Events look like
{"event":"transcript","line":"You take the key."},{"event":"room_changed","room":1,"music":4},{"event":"won"},{"event":"audio","cue":"pickup"},{"event":"state","state":{...}}.
Script replay (deterministic)
mrpgi-headless --script play.jsonl --game games/mygame > events.log
Replays a command log through the fixed-step engine. The AI lane defaults to
off under --script, so the same script always produces byte-identical
events — that's the golden-test workflow for CI.
Render a room to PNG (no window)
mrpgi-headless --render-room games/mygame/rooms/room0.json out.png --game games/mygame
2. HTTP
mrpgi-headless --serve 127.0.0.1:7777 --game games/mygame
| Route | Does |
|---|---|
POST /command |
body = one Command JSON → JSON array of resulting Events |
GET /state |
full snapshot: room, ego, inventory, flags, objects, exits |
GET /frame.png |
the current frame, headless-rendered |
GET /events?since=N |
event log from cursor N → {"next":M,"events":[...]} |
POST /rooms/{n} |
body = RoomDoc JSON (add ?persist to write to disk) |
Python needs no client library:
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)
3. MCP (Claude authors & plays)
claude mcp add mrpgi -- /path/to/target/debug/mrpgi-headless --mcp --game games/mygame
Tools: mrpgi_parse, mrpgi_walk_to, mrpgi_choose, mrpgi_tick,
mrpgi_state, mrpgi_render_frame (returns a real PNG image — the model
sees the frame it authored), mrpgi_new_game, mrpgi_load_game,
mrpgi_upsert_room, mrpgi_upsert_object, mrpgi_paint_stroke,
mrpgi_save_room, and mrpgi_command (raw bus escape hatch).
The loop that makes a whole game from prompts: upsert rooms/objects as JSON →
mrpgi_render_frame to look at them → play the game with mrpgi_parse /
mrpgi_walk_to to test the puzzle chain → mrpgi_save_room to commit.
Authoring JSON reference
RoomDoc — {"strokes":[...], "spawn":[x,y], "exits":[N,E,S,W], "objects":[...], "music":0-5}
(exit entries are room numbers or null; music: 0 off, 1 calm, 2 eerie, 3 tense, 4 jolly, 5 spooky)
Stroke (paint ops; meaning is "None" | "Floor" | "Wall" | "Water" | "Trigger", colors are EGA 0-15):
{"Rect": {"x":0,"y":0,"w":160,"h":16,"color":0,"meaning":"Wall"}}
{"Ellipse": {"x":10,"y":40,"w":30,"h":20,"color":2,"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]],"color":4,"meaning":"None","size":2}}
{"Image": {"name":"tree","x":60,"y":80,"meaning":"None"}}
ObjDef — name, sprite, x, y plus optional look, takeable, synonyms
(space-separated), use_text, needs (item that unlocks it), wins,
requires_flag / sets_flag (puzzle gating), dialogue (nodes of
{"says":"...","choices":[{"text":"...","goto":1,"requires_flag":"","sets_flag":""}]},
goto:-1 ends).
game.json — {"name":"...", "start_room":0, "intro_text":"...", "verbs":[{"name":"take","words":["yoink"]}], "flags":{"met_wizard":false}}
(manifest verbs merge extra synonyms into the built-in table; the AI parser's
whitelist is generated from the same table so they can never drift.)
The AI parser lane
Free text the deterministic parser doesn't understand goes to a small LLM that may only translate it into an already-legal command, which is then re-run through the real logic — the AI can never invent state.
MRPGI_AI=ollama|openrouter|off MRPGI_AI_MODEL=... MRPGI_AI_URL=...