Adaptations from the software-rendering playbook, at MRPCI's level of the stack: - pic.rs: fBm noise fill, jittered Voronoi fill (with mortar edges) and scatter — seeded, deterministic, Bayer-dithered through palette ramps - particles.rs: rain/snow/embers layer on its own RNG stream; RoomDoc gains `weather`; visuals can never perturb gameplay replays - font.rs: built-in 5x7 font stamped in index space; headless frames are now annotated (room chip, score, dialogue windows, transcript strip) so MCP render_frame shows what a player actually reads (--raw to skip) - render.rs: compose split into index-space compositing + palette blit; effective_palette() exports the cycle-LUT-applied strip - GUI: frame ships to the GPU as indices + a 256x1 palette texture; a fragment shader does the lookup plus CRT curvature/scanlines/vignette and room-change fades - Neon Precinct: fBm night sky + stars, Voronoi sidewalk & wet cobbles, fBm asphalt, rain in Neon Row and Rain Alley Verified: replay determinism (byte-identical events x2), 25/25 win path, GUI boots with the shader. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5.8 KiB
Driving MRPCI programmatically
The engine core (mrpci-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 mrpci-core # builds target/debug/mrpci-headless
mrpci-headless --help
--game DIR points any mode at a game folder. Useful boot flags:
--seed N (deterministic RNG), --room N, --ticks N,
--render-room out.png (annotated: room chip, score, dialogue/transcript
text stamped with the built-in font; add --raw for the bare scene),
--screens dir/.
RoomDoc extras beyond the basics: procedural paint ops fbm_fill
(ramp/scale/octaves/seed), voronoi_fill (cell/colors/edge_color/edge/seed),
scatter (colors/density/seed) — all deterministic by seed — and a
weather field (0 none, 1 rain, 2 snow, 3 embers) whose particles run on a
separate RNG stream (visual-only; event replays unaffected).
1. JSONL stdio (the substrate)
One JSON command per line on stdin, events stream back on stdout.
Lines starting with # are comments (script files too).
{"cmd":"verb_at","verb":"look","x":100,"y":120}
{"cmd":"verb_at","verb":"do","x":268,"y":112}
{"cmd":"use_item_at","item":"keycard","x":286,"y":138}
{"cmd":"parse","text":"use keycard on locker"}
{"cmd":"move_to","x":150,"y":150}
{"cmd":"walk_to","x":316,"y":150}
{"cmd":"tick","n":30}
{"cmd":"choose","n":1}
{"cmd":"end_dialogue"}
{"cmd":"query"}
{"cmd":"new_game"}
{"cmd":"goto_room","n":2}
{"cmd":"set_flag","name":"locker_open","value":true}
{"cmd":"set_var","name":"drip_armed","value":0}
{"cmd":"set_seed","seed":41}
{"cmd":"save_game","slot":"street"}
{"cmd":"restore_game","slot":"street"}
{"cmd":"upsert_room","n":7,"doc":{"spawn":[160,170]},"persist":true}
{"cmd":"upsert_script","file":"room7.rhai","source":"fn on_enter() { say(\"hi\"); }"}
{"cmd":"save_room"}
{"cmd":"load_game","dir":"games/other"}
{"cmd":"reload_assets"}
Notes:
- The engine only advances when told:
tickruns fixed 1/30s cycles (movement, timers, triggers, NPC wander).walk_toticks until arrival. verb_atverbs:walk,look,do,talk. Distant do/talk/take targets are walked to first (the pending verb fires on arrival).parseaccepts the same things a player types:look at sign,pick up the keycard,use keycard on locker,talk to sergeant,inventory,save,score.upsert_roomvalidates before committing (walkable spawn, exits and door hotspots must point at rooms that exist) and rejects bad lore with anerrorevent.- Events:
transcript,room_changed,inventory_changed,dialogue_open/dialogue_closed,score_changed,died(checkpoint rewind follows),won,audio(cue name),music(mood),ego_moved,state,saved_game/restored,room_upserted,room_saved,game_loaded,error.
Script replay (deterministic)
mrpci-headless --game games/neon-precinct --script play.jsonl > events.log
Fixed cycles + seeded RNG + tick-based timers ⇒ the same script produces byte-identical events on any machine, any speed. That's the golden-test workflow for CI — and the reason none of the classic Sierra speed bugs can exist here.
2. HTTP
mrpci-headless --game games/neon-precinct --serve 8093
POST /command one Command JSON → JSON array of Events
GET /state StateSnapshot
GET /frame.png current frame, headless-rendered (cycles applied)
GET /screen/priority.png depth bands, visualized
GET /screen/control.png walls red, water blue
GET /screen/hotspot.png hotspot id map
GET /events?since=N cursor-polled event log
POST /rooms/{n}[?persist] body = RoomDoc JSON (the lore drop)
3. MCP (Claude as player, tester, co-author)
claude mcp add mrpci -- /path/to/mrpci-headless --mcp --game /path/to/games/neon-precinct
Tools: mrpci_verb, mrpci_use_item, mrpci_parse, mrpci_walk_to,
mrpci_choose, mrpci_tick, mrpci_state, mrpci_render_frame,
mrpci_render_screen, mrpci_save, mrpci_restore, mrpci_new_game,
mrpci_load_game, mrpci_upsert_room, mrpci_upsert_script,
mrpci_save_room, mrpci_command (escape hatch).
mrpci_render_frame / mrpci_render_screen return real PNGs, so a model
can look at the frame — or the invisible screens — it just authored.
The rhai scripting surface
Hooks a room script (or main.rhai, merged underneath) may define:
fn on_enter() // room became live (also after death-rewind!)
fn on_exit() // leaving the room
fn on_verb(verb, noun) // FIRST refusal on every verb; return true = handled
// verb is "look"/"do"/"talk"/"take"/"use:<item>"/...
fn on_trigger(name) // ego stepped into a trigger hotspot
fn on_tick() // every cycle (keep it light)
fn any_name() // timer target for after(ticks, "any_name")
API: say(t), say_by(who,t), flag(n), set_flag(n,v), val(n),
set_val(n,v), has(item), give(item), remove_item(item), room(),
ego_x(), ego_y(), goto_room(n), place_ego(x,y), walk_ego(x,y),
freeze_ego(b), npc_walk(name,x,y), npc_place(name,x,y),
npc_freeze(name,b), play(cue), music(mood), points(n), win(),
die(text), after(ticks, "fn"), pal_cycle(i, on), rand(n).
Reads see a snapshot; writes queue as effects applied after the hook
returns. A script can print nonsense but cannot corrupt the sim, and a
runaway loop is killed at 50k ops. rand is the engine's seeded xorshift —
scripts stay replay-deterministic.