# Driving MRPGI programmatically The engine core (`mrpgi-core`) is fully headless: a `GameState` driven by JSON `Command`s in and `Event`s 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: ```sh 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) ```sh mrpgi-headless --game games/mygame ``` One JSON command per line on stdin, events stream back on stdout: ```jsonl {"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: `tick` runs fixed 1/20s cycles (movement, room transitions, AI results). `walk_to` ticks until arrival. - `upsert_room` validates before committing (walkable spawn, exits must point at rooms that exist) and rejects bad lore with an `error` event. - 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) ```sh 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) ```sh mrpgi-headless --render-room games/mygame/rooms/room0.json out.png --game games/mygame ``` ## 2. HTTP ```sh 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: ```python 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) ```sh 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): ```json {"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=... ```