MRPGI/docs/CONTROL.md
type-two 755168a969 Sierra soul for game authors: scoring, deaths, gated exits, room prose, visibility flags
New authoring surface (all serde-defaulted; old games load unchanged):
- RoomDoc: name + enter_text (announced on entry), exit_flags/exit_blocked
  (per-direction flag gates with authored refusal lines, latched per attempt)
- ObjDef: points (once per room+name), consumes, kills, visible_flag,
  hidden_by_flag; DlgChoice: points (latched on sets_flag flip), kills
  (goto node's says = death text)
- GameManifest: max_score enables scoring; GUI shows the classic
  'Score: X of Y' in the menu bar; score_changed + died on the bus
- Death restarts the day, keeping the cause at the top of the fresh transcript

Reviewed by an adversarial multi-agent pass; confirmed findings fixed:
corner push-back no longer slingshots through a perpendicular open exit
(and checks the landing pixel), blocked messages latch per attempt,
kill choices pay no points/flags, new_game re-zeroes the score on the bus,
AI-lane prompts respect object visibility. Deliberate behavior change:
drop/consumes now emit inventory_changed (was a silent client desync).

Built for MORPQUEST (games/… ssh://…/monster/mrpquest.git), which uses all
of it: 250-point budget, four gated exits, two deaths, vanishing Beckies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:30:32 +10:00

7.4 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: 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)

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) plus optional name (shown on entry and in look), enter_text (entry prose), exit_flags (["","side-door-open","",""] — that direction refuses until the flag is true) and exit_blocked (what refusing says; "" = a stock line).

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"}}

ObjDefname, 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). Sierra extras, all optional: points (awarded once per room — on take for takeables, on first successful use otherwise), consumes (a successful use removes the needs item from inventory), kills (a successful use is death: the day restarts, use_text is the death text), visible_flag (object exists only while the flag is true) and hidden_by_flag (object disappears once the flag is true). Dialogue choices also take points (awarded when their sets_flag first flips true — the flag is the latch) and kills (death; the goto node's says is printed as the death text). Precedence: kills preempts points/wins/sets_flag — a death pays nothing and wins nothing. The points budget is the author's contract: nothing stops the sum of points exceeding max_score; audit your own game. Note (v2 behavior change): drop and consumes now emit inventory_changed — previously drop silently desynced client inventory displays.

game.json{"name":"...", "start_room":0, "intro_text":"...", "verbs":[{"name":"take","words":["yoink"]}], "flags":{"met_wizard":false}, "max_score":250} (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. max_score turns scoring on: the GUI shows the classic Score: X of Y and score_changed events fire; 0 or absent = scoring off. Death emits died followed by the usual new_game events.)

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=...