- web/manual.html — 17 chapters: playing, the editor deeply, the authoring cookbook (full JSON reference + the five load-bearing puzzle patterns), dialogue trees, flags, the art pipeline with sprite-generation prompts, sound, driving the engine with code (real JSONL transcripts), AIs + MCP, prompt patterns with actual outputs (the roof room is the real shipped expansion), the web build & cloud saves, engine internals, extension recipes, troubleshooting, and full command/event/palette reference. - Game menu gains "Manual": window.open on the web (via a JS plugin import), the default browser natively. - README tab links the Book; repo README points at it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
850 lines
51 KiB
HTML
850 lines
51 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>The MRPGI Book — user & developer manual</title>
|
||
<meta name="description" content="The complete manual for MRPGI, the Monster Robot Party Game Interpreter: playing, painting, authoring games as JSON, driving the engine from terminals and AIs, and the engine internals.">
|
||
<style>
|
||
:root {
|
||
--bg: #0b0d11; --panel: #12151c; --ink: #cdd5e1; --dim: #7d8798;
|
||
--pink: #ff87d7; --cyan: #5fffd7; --yellow: #ffe95f; --border: #2a3140;
|
||
}
|
||
html { scroll-behavior: smooth; }
|
||
body {
|
||
margin: 0; background: var(--bg); color: var(--ink);
|
||
font: 15px/1.65 ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||
}
|
||
main { max-width: 880px; margin: 0 auto; padding: 24px 20px 120px; }
|
||
h1 { color: var(--pink); font-size: 30px; letter-spacing: 2px; margin: 18px 0 4px; }
|
||
.subtitle { color: var(--dim); margin: 0 0 28px; }
|
||
h2 {
|
||
color: var(--pink); font-size: 21px; letter-spacing: 1px;
|
||
margin: 56px 0 10px; padding-top: 18px; border-top: 1px solid var(--border);
|
||
}
|
||
h3 { color: var(--cyan); font-size: 16px; margin: 30px 0 6px; }
|
||
h4 { color: var(--yellow); font-size: 14px; margin: 22px 0 4px; }
|
||
p, li { margin: 8px 0; }
|
||
a { color: var(--cyan); }
|
||
b { color: #fff; }
|
||
i { color: var(--yellow); font-style: normal; }
|
||
kbd {
|
||
background: #1e232b; border: 1px solid #48505f; border-radius: 4px;
|
||
padding: 0 6px; color: #fff; font: inherit; font-size: 13px;
|
||
}
|
||
code { color: var(--yellow); font-size: 13.5px; }
|
||
pre {
|
||
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
|
||
padding: 14px 16px; overflow-x: auto; font-size: 13px; line-height: 1.5;
|
||
}
|
||
pre code { color: var(--ink); }
|
||
pre .c { color: var(--dim); } /* comment */
|
||
pre .k { color: var(--pink); } /* key / verb */
|
||
pre .s { color: var(--cyan); } /* string / value */
|
||
pre .y { color: var(--yellow); }
|
||
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 13.5px; }
|
||
th, td { border: 1px solid var(--border); padding: 6px 10px; text-align: left; vertical-align: top; }
|
||
th { color: var(--cyan); background: var(--panel); }
|
||
.toc {
|
||
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
|
||
padding: 16px 22px; columns: 2; column-gap: 40px; font-size: 14px;
|
||
}
|
||
.toc a { text-decoration: none; display: block; padding: 2px 0; }
|
||
.toc a:hover { color: var(--pink); }
|
||
.note {
|
||
border-left: 3px solid var(--cyan); background: var(--panel);
|
||
padding: 10px 14px; border-radius: 0 8px 8px 0; margin: 14px 0;
|
||
}
|
||
.warn { border-left-color: var(--pink); }
|
||
.swatch {
|
||
display: inline-block; width: 30px; height: 18px; border-radius: 3px;
|
||
border: 1px solid #000; vertical-align: middle;
|
||
}
|
||
.top { position: fixed; right: 18px; bottom: 18px; background: var(--pink); color: #0b0d11;
|
||
padding: 8px 12px; border-radius: 8px; text-decoration: none; font-weight: bold; }
|
||
.pill { background:#1e232b; border:1px solid var(--border); border-radius:20px; padding:1px 10px; font-size:12px; color:var(--cyan); }
|
||
@media (max-width: 700px) { .toc { columns: 1; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
|
||
<h1>THE MRPGI BOOK</h1>
|
||
<p class="subtitle">Monster Robot Party Game Interpreter — the complete user & developer manual.<br>
|
||
Play it · paint it · write worlds as JSON · drive it from terminals, Python and AIs · read the engine's soul.</p>
|
||
|
||
<p><span class="pill">play now: <a href="./">monsterrobot.games/engine</a></span>
|
||
<span class="pill">engine version 0.1</span>
|
||
<span class="pill">this page is long on purpose</span></p>
|
||
|
||
<nav class="toc">
|
||
<a href="#what">1. What MRPGI is</a>
|
||
<a href="#quick">2. Three quick starts</a>
|
||
<a href="#playing">3. Playing</a>
|
||
<a href="#editor">4. The editor, deeply</a>
|
||
<a href="#cookbook">5. The authoring cookbook</a>
|
||
<a href="#dialogue">6. Dialogue trees</a>
|
||
<a href="#flags">7. Flags: real puzzles</a>
|
||
<a href="#art">8. The art pipeline</a>
|
||
<a href="#audio">9. Sound & music</a>
|
||
<a href="#headless">10. Driving it with code</a>
|
||
<a href="#ai">11. AIs and MRPGI</a>
|
||
<a href="#prompts">12. Prompt patterns that work</a>
|
||
<a href="#web">13. The web build & cloud saves</a>
|
||
<a href="#internals">14. Engine internals (for devs)</a>
|
||
<a href="#extend">15. Extending the engine</a>
|
||
<a href="#trouble">16. Troubleshooting</a>
|
||
<a href="#appendix">17. Appendices & reference</a>
|
||
</nav>
|
||
|
||
<!-- ================================================================ -->
|
||
<h2 id="what">1. What MRPGI is</h2>
|
||
|
||
<p>In the 1980s Sierra built AGI, the engine behind <i>King's Quest</i>,
|
||
<i>Space Quest</i> and <i>Police Quest</i>. Its genius was the
|
||
<b>dual-buffer room</b>: every screen is drawn twice — once as the picture you
|
||
see, once as an invisible map of <b>meaning</b> (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.</p>
|
||
|
||
<p>MRPGI keeps that soul and rebuilds everything around it, new-school:</p>
|
||
|
||
<ul>
|
||
<li><b>An in-engine paint app.</b> You draw rooms — look and meaning together — and walk them seconds later.</li>
|
||
<li><b>Games are folders of JSON + PNGs.</b> Hand-editable, git-friendly, zip-and-share-able, LLM-writable.</li>
|
||
<li><b>A forgiving verb-noun parser</b> plus an optional <b>local AI lane</b> that maps free English onto commands the game already allows — the AI can bend language, never the rules.</li>
|
||
<li><b>A fully headless core.</b> 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 <i>see</i> what it built.</li>
|
||
<li><b>Deterministic replays.</b> 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.</li>
|
||
</ul>
|
||
|
||
<p>The engine is Rust. The window is <a href="https://macroquad.rs">macroquad</a>.
|
||
The web version you're probably reading this next to is the whole engine —
|
||
editor included — compiled to a ~1 MB WASM file.</p>
|
||
|
||
<!-- ================================================================ -->
|
||
<h2 id="quick">2. Three quick starts</h2>
|
||
|
||
<h3>2a. Play in the browser (zero install)</h3>
|
||
<p>Open <a href="./">monsterrobot.games/engine</a>. You're in
|
||
<i>The DJ's Lost Fuse</i>. Walk with arrows or click. Type
|
||
<code>look</code>, <code>take fuse</code>, <code>talk scrap-bot</code>.
|
||
Press <kbd>Tab</kbd> to open the paint tool. Hit <b>SIGN IN</b> (top right) to
|
||
make an account so <b>Save world</b> keeps your edits between visits.</p>
|
||
|
||
<h3>2b. Run it native</h3>
|
||
<pre><code><span class="c"># requires Rust — https://rustup.rs</span>
|
||
git clone <the repo> && cd MRPGI
|
||
cargo run -p mrpgi -- --game games/lost-fuse <span class="c"># the GUI</span>
|
||
cargo run -p mrpgi -- --sample <span class="c"># write + open the 3-room robot demo</span>
|
||
cargo run -p mrpgi -- --kit <span class="c"># the 4-room fantasy starter world</span></code></pre>
|
||
|
||
<h3>2c. Make your first game in ten minutes</h3>
|
||
<ol>
|
||
<li><code>mkdir -p games/mygame && cargo run -p mrpgi -- --sample --game games/mygame</code> — a working skeleton.</li>
|
||
<li>Open it: <code>cargo run -p mrpgi -- --game games/mygame</code>. You boot into the editor.</li>
|
||
<li>Paint over room 0. Pick a colour, pick a <b>meaning</b> (floor = walkable, wall = blocked), draw. <kbd>Tab</kbd> to walk it. <kbd>Tab</kbd> back. <kbd>K</kbd> saves.</li>
|
||
<li>Press <kbd>0</kbd> (object tool), click somewhere, and fill in the inspector fields: name, look text, use text. Toggle <b>takeable</b> or <b>wins</b>.</li>
|
||
<li>Set an exit on the top strip (<b>E: 1</b>), press <kbd>></kbd>, paint room 1.</li>
|
||
<li>Edit <code>games/mygame/game.json</code> — name your game, write the intro line.</li>
|
||
</ol>
|
||
<p>That's a game. Everything past this point is depth.</p>
|
||
|
||
<!-- ================================================================ -->
|
||
<h2 id="playing">3. Playing</h2>
|
||
|
||
<h3>Moving</h3>
|
||
<p>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 <b>exit</b> and you enter
|
||
the next room from the opposite side.</p>
|
||
|
||
<h3>The parser</h3>
|
||
<p>Type a sentence, press <kbd>Enter</kbd>. Filler words
|
||
(<i>the, a, at, please…</i>) 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.</p>
|
||
|
||
<table>
|
||
<tr><th>Verb</th><th>Also answers to</th><th>Does</th></tr>
|
||
<tr><td><code>look</code></td><td>l, examine, x, inspect, check, read, view, see, study</td><td>describe the room, or one thing</td></tr>
|
||
<tr><td><code>take</code></td><td>get, grab, pick, snatch, steal, pocket, collect, nab</td><td>put a takeable object in your inventory</td></tr>
|
||
<tr><td><code>use</code></td><td>open, unlock, activate, push, pull, turn, operate</td><td>the puzzle verb — locks, levers, wins</td></tr>
|
||
<tr><td><code>talk</code></td><td>speak, ask, chat, greet, say</td><td>start a conversation</td></tr>
|
||
<tr><td><code>drop</code></td><td>discard, leave, put</td><td>remove from inventory</td></tr>
|
||
<tr><td><code>inventory</code></td><td>inv, i, items, bag</td><td>what you're carrying</td></tr>
|
||
<tr><td><code>help</code></td><td>?, commands, verbs</td><td>list the verbs</td></tr>
|
||
</table>
|
||
|
||
<p>Games can add their own synonyms (see <a href="#cookbook">the manifest</a>) —
|
||
<i>The DJ's Lost Fuse</i> accepts <code>fix amp</code> because its manifest
|
||
maps <i>fix</i> to <i>use</i>.</p>
|
||
|
||
<h3>The menu bar</h3>
|
||
<p>Along the top, like the classics: <b>Game</b> (New Game, Edit Mode, CRT,
|
||
Sound, Manual, About) and <b>Action</b> (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.</p>
|
||
|
||
<h3>Dialogue</h3>
|
||
<p><code>talk <name></code> opens a conversation. Press
|
||
<kbd>1</kbd>–<kbd>6</kbd> to pick a reply, <kbd>Esc</kbd> 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.</p>
|
||
|
||
<h3>The AI lane (native, optional)</h3>
|
||
<p>When the parser doesn't understand a sentence and a local LLM is
|
||
configured, the sentence is translated into <i>one allowed command</i> and
|
||
re-run through the real parser. You'll see the translation as
|
||
<code>(≈ open crate)</code>. 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 <a href="#appendix">the appendix</a>.</p>
|
||
|
||
<!-- ================================================================ -->
|
||
<h2 id="editor">4. The editor, deeply</h2>
|
||
|
||
<p>Press <kbd>Tab</kbd> anywhere. The left panel holds tools; the top strip
|
||
holds the world (room number, exits, music); the canvas is the room.</p>
|
||
|
||
<h3>The one idea that matters: LOOK and MEANING</h3>
|
||
<p>Every stroke paints up to two layers at once:</p>
|
||
<ul>
|
||
<li><b>LOOK</b> — one of 16 EGA colours. What players see.</li>
|
||
<li><b>MEANING</b> — what the pixels <i>are</i>: <b>floor</b> (walkable, auto-depth),
|
||
<b>wall</b> (blocked), <b>water</b> / <b>trigger</b> (reserved control lines),
|
||
or <b>none</b> (change the look only, leave behavior alone).</li>
|
||
</ul>
|
||
<p>Colour never implies meaning. A brown floor and a brown wall are identical
|
||
to the eye and opposites to the engine. Press <kbd>L</kbd> to cycle the view:
|
||
<b>look</b> (the picture), <b>both</b> (walls tinted red, water blue,
|
||
triggers green), <b>ctrl</b> (the raw meaning map). If you're confused about
|
||
why the robot won't walk somewhere, look at <b>both</b>.</p>
|
||
|
||
<div class="note">A fresh room is <b>entirely walkable</b>. You paint walls
|
||
where you <i>don't</i> want walking — the opposite of most tile editors.</div>
|
||
|
||
<h3>Tools</h3>
|
||
<table>
|
||
<tr><th>Key</th><th>Tool</th><th>Notes</th></tr>
|
||
<tr><td><kbd>1</kbd></td><td>brush</td><td>freehand; size with <kbd>,</kbd> <kbd>.</kbd></td></tr>
|
||
<tr><td><kbd>2</kbd></td><td>line</td><td>click points; <b>right-click or Enter</b> commits the polyline</td></tr>
|
||
<tr><td><kbd>3</kbd></td><td>rect</td><td>drag a filled rectangle — your workhorse for rooms</td></tr>
|
||
<tr><td><kbd>4</kbd></td><td>fill</td><td>flood-fill a sealed region; a huge-spill warning means your outline leaks</td></tr>
|
||
<tr><td><kbd>5</kbd></td><td>pick</td><td>eyedropper</td></tr>
|
||
<tr><td><kbd>6</kbd></td><td>erase</td><td>back to blank + walkable</td></tr>
|
||
<tr><td><kbd>7</kbd></td><td>image</td><td>stamp a sprite from the game's <code>sprites/</code>; <kbd>[</kbd> <kbd>]</kbd> choose, <kbd>R</kbd> reload — stamped pixels bake the current meaning underneath (a tree stamped with <b>wall</b> is solid)</td></tr>
|
||
<tr><td><kbd>8</kbd></td><td>oval</td><td>filled ellipse</td></tr>
|
||
<tr><td><kbd>9</kbd></td><td>spawn</td><td>where the robot appears (cyan circle)</td></tr>
|
||
<tr><td><kbd>0</kbd></td><td>object</td><td>live objects — the game-logic tool (below)</td></tr>
|
||
</table>
|
||
|
||
<p><kbd>Z</kbd> undoes (<b>just Z — no Ctrl</b>). <kbd>C</kbd> clears the room.
|
||
<kbd>K</kbd> saves, <kbd>O</kbd> reloads from disk. Rooms are stroke lists, so
|
||
undo works by replaying every stroke but the last — nothing is ever
|
||
destructively merged.</p>
|
||
|
||
<h3>The object tool & the kit browser</h3>
|
||
<p>With <kbd>0</kbd> active, <kbd>[</kbd> <kbd>]</kbd> flips through 60+
|
||
ready-made archetypes (fantasy, sci-fi, monsters, nightlife, horror — all in
|
||
<code>kit/</code>). 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. <kbd>Enter</kbd> hops to
|
||
the next field, <kbd>Esc</kbd> stops typing.</p>
|
||
|
||
<h3>Rooms and exits</h3>
|
||
<p>The strip's <b>N E S W</b> buttons cycle each edge's destination room.
|
||
<kbd>-</kbd> <kbd>=</kbd> (or <b>< ></b>) move between rooms — moving
|
||
<b>saves the room you're leaving</b>. 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.</p>
|
||
|
||
<!-- ================================================================ -->
|
||
<h2 id="cookbook">5. The authoring cookbook</h2>
|
||
|
||
<p>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 (<a href="#prompts">§12</a>).</p>
|
||
|
||
<h3>A game is a folder</h3>
|
||
<pre><code>games/mygame/
|
||
game.json <span class="c"># the manifest — optional, every field has a default</span>
|
||
rooms/room0.json <span class="c"># one RoomDoc per room</span>
|
||
sprites/*.png <span class="c"># auto-quantized to EGA; filename = sprite name</span>
|
||
sfx/ music/ <span class="c"># optional audio overrides (§9)</span></code></pre>
|
||
|
||
<h3>game.json — the manifest</h3>
|
||
<pre><code>{
|
||
<span class="k">"name"</span>: <span class="s">"The DJ's Lost Fuse"</span>,
|
||
<span class="k">"start_room"</span>: 0,
|
||
<span class="k">"intro_text"</span>: <span class="s">"Night. A scrapyard. Somewhere above, a party has gone horribly silent. Type 'help'."</span>,
|
||
<span class="k">"verbs"</span>: [ { <span class="k">"name"</span>: <span class="s">"use"</span>, <span class="k">"words"</span>: [<span class="s">"plug"</span>, <span class="s">"fix"</span>, <span class="s">"repair"</span>] } ],
|
||
<span class="k">"flags"</span>: {}
|
||
}</code></pre>
|
||
<p><code>verbs</code> merges extra synonyms into the built-in table by
|
||
canonical name. The AI lane's whitelist is generated <i>from the same
|
||
table</i>, so the parser and the AI can never drift apart. <code>flags</code>
|
||
presets flag values for each new game.</p>
|
||
|
||
<h3>RoomDoc — one room</h3>
|
||
<pre><code>{
|
||
<span class="k">"strokes"</span>: [ ...paint operations, replayed in order on load... ],
|
||
<span class="k">"spawn"</span>: [30, 140],
|
||
<span class="k">"exits"</span>: [null, 1, null, null], <span class="c">// N, E, S, W → room number or null</span>
|
||
<span class="k">"objects"</span>: [ ...ObjDefs... ],
|
||
<span class="k">"music"</span>: 2 <span class="c">// 0 off · 1 calm · 2 eerie · 3 tense · 4 jolly · 5 spooky</span>
|
||
}</code></pre>
|
||
|
||
<h3>Strokes — the paint ops</h3>
|
||
<p>Externally tagged: each stroke is an object with exactly one key.
|
||
<code>meaning</code> is <code>"None" | "Floor" | "Wall" | "Water" | "Trigger"</code>;
|
||
<code>color</code> is an EGA index 0–15 (<a href="#appendix">palette table</a>).
|
||
The room grid is <b>160 × 168</b>; y 0–15 is traditionally the "sky/wall"
|
||
band and the playable floor usually starts around y 16.</p>
|
||
<pre><code>{ <span class="k">"Rect"</span>: { "x":0, "y":0, "w":160, "h":16, "color":0, "meaning":"Wall" } }
|
||
{ <span class="k">"Ellipse"</span>: { "x":22, "y":104, "w":34, "h":18, "color":6, "meaning":"None" } }
|
||
{ <span class="k">"Fill"</span>: { "x":80, "y":100, "color":7, "meaning":"Floor" } }
|
||
{ <span class="k">"Line"</span>: { "pts":[[0,80],[159,80]], "color":15 } }
|
||
{ <span class="k">"Brush"</span>: { "pts":[[50,60],[52,61],[54,62]], "color":4, "meaning":"None", "size":2 } }
|
||
{ <span class="k">"Image"</span>: { "name":"tree", "x":60, "y":80, "meaning":"Wall" } }</code></pre>
|
||
|
||
<h4>The standard walled room (copy this)</h4>
|
||
<pre><code>[
|
||
{ "Rect": { "x":0, "y":0, "w":160, "h":168, "color":1, "meaning":"None" } }, <span class="c">// backdrop</span>
|
||
{ "Rect": { "x":6, "y":16, "w":148, "h":146, "color":8, "meaning":"Floor" } }, <span class="c">// floor</span>
|
||
{ "Rect": { "x":0, "y":0, "w":160, "h":16, "color":0, "meaning":"Wall" } }, <span class="c">// N wall</span>
|
||
{ "Rect": { "x":0, "y":160, "w":160, "h":8, "color":0, "meaning":"Wall" } }, <span class="c">// S wall</span>
|
||
{ "Rect": { "x":0, "y":0, "w":6, "h":168, "color":0, "meaning":"Wall" } }, <span class="c">// W wall</span>
|
||
{ "Rect": { "x":154, "y":0, "w":6, "h":70, "color":0, "meaning":"Wall" } }, <span class="c">// E wall, top half…</span>
|
||
{ "Rect": { "x":154, "y":100, "w":6, "h":68, "color":0, "meaning":"Wall" } } <span class="c">// …leaving a door gap y 70–100</span>
|
||
]</code></pre>
|
||
<p>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.</p>
|
||
|
||
<h3>ObjDef — every field</h3>
|
||
<pre><code>{
|
||
<span class="k">"name"</span>: <span class="s">"amp"</span>, <span class="c">// what the parser calls it (required)</span>
|
||
<span class="k">"sprite"</span>: <span class="s">"chest"</span>, <span class="c">// a PNG in sprites/ (required)</span>
|
||
<span class="k">"x"</span>: 122, <span class="k">"y"</span>: 132, <span class="c">// its FEET — bottom-centre, drives depth (required)</span>
|
||
<span class="k">"look"</span>: <span class="s">"The club's outdoor amp. Its fuse slot gapes empty and scorched."</span>,
|
||
<span class="k">"takeable"</span>: false,
|
||
<span class="k">"synonyms"</span>: <span class="s">"amplifier speaker stack"</span>, <span class="c">// space-separated extra nouns</span>
|
||
<span class="k">"use_text"</span>: <span class="s">"You slot the fuse home. The amp THUMPS alive."</span>,
|
||
<span class="k">"needs"</span>: <span class="s">"fuse"</span>, <span class="c">// inventory item required to use (else: "It's locked…")</span>
|
||
<span class="k">"wins"</span>: false, <span class="c">// a successful use ends the game with the victory banner</span>
|
||
<span class="k">"requires_flag"</span>: <span class="s">""</span>, <span class="c">// flag that must be true to use (else: "Nothing happens…")</span>
|
||
<span class="k">"sets_flag"</span>: <span class="s">"music_on"</span>, <span class="c">// flag set true on a successful use</span>
|
||
<span class="k">"dialogue"</span>: [] <span class="c">// §6 — non-empty makes it talkable</span>
|
||
}</code></pre>
|
||
<p>Everything except <code>name / sprite / x / y</code> defaults, so minimal
|
||
objects stay minimal. Objects never block movement — solidity is painted, not
|
||
attached to objects (stamp their footprint with the <b>wall</b> meaning if
|
||
they should block).</p>
|
||
|
||
<h4>The five load-bearing patterns</h4>
|
||
<table>
|
||
<tr><th>Pattern</th><th>Recipe</th></tr>
|
||
<tr><td>Key & lock</td><td>takeable <code>key</code> anywhere + a door/chest with <code>"needs":"key"</code></td></tr>
|
||
<tr><td>Lever & door</td><td>lever with <code>"sets_flag":"power_on"</code> + door with <code>"requires_flag":"power_on"</code></td></tr>
|
||
<tr><td>Earn the ending</td><td>the finale object stacks <code>needs</code> + <code>requires_flag</code> + <code>"wins":true</code></td></tr>
|
||
<tr><td>Gossip gate</td><td>a dialogue choice with <code>requires_flag</code> appears only after the deed; its <code>sets_flag</code> unlocks elsewhere (§6)</td></tr>
|
||
<tr><td>Fetch chain</td><td>A needs B, B needs C — each <code>use_text</code> should hint the next link</td></tr>
|
||
</table>
|
||
|
||
<h3>Case study: <i>The DJ's Lost Fuse</i></h3>
|
||
<p>The shipped example (<code>games/lost-fuse/</code>, ~200 lines of JSON)
|
||
uses every mechanic exactly once:</p>
|
||
<pre><code>take fuse <span class="c">an item → inventory (room 0, scrapyard)</span>
|
||
use amp <span class="c">needs: fuse → sets_flag music_on (room 1, back street)</span>
|
||
talk bouncer <span class="c">a choice hidden behind requires_flag music_on…</span>
|
||
"I'm with the band." <span class="c">→ sets_flag on_the_list</span>
|
||
use turntable <span class="c">requires_flag on_the_list → wins: true (room 2, the club)</span></code></pre>
|
||
<p>Play it before reading its JSON — then read its JSON. It's the fastest way
|
||
to internalize the whole model.</p>
|
||
|
||
<!-- ================================================================ -->
|
||
<h2 id="dialogue">6. Dialogue trees</h2>
|
||
|
||
<p>An object with a non-empty <code>dialogue</code> array is talkable. Each
|
||
entry is a <b>node</b>; node 0 is where <code>talk</code> starts. A node says
|
||
a line and offers numbered choices; each choice jumps to another node
|
||
(<code>goto</code>) or ends the conversation (<code>goto: -1</code>). A node
|
||
with no visible choices is a terminal line.</p>
|
||
|
||
<p>The bouncer from <i>Lost Fuse</i>, in full — a real, working tree:</p>
|
||
<pre><code><span class="k">"dialogue"</span>: [
|
||
{ <span class="k">"says"</span>: <span class="s">"List. Name. No name, no party."</span>,
|
||
<span class="k">"choices"</span>: [
|
||
{ <span class="k">"text"</span>: <span class="s">"C'mon, one dance?"</span>, <span class="k">"goto"</span>: 1 },
|
||
{ <span class="k">"text"</span>: <span class="s">"Hear that bass? I fixed your amp. I'm with the band."</span>,
|
||
<span class="k">"goto"</span>: 2,
|
||
<span class="k">"requires_flag"</span>: <span class="s">"music_on"</span>, <span class="c">// hidden until the amp works</span>
|
||
<span class="k">"sets_flag"</span>: <span class="s">"on_the_list"</span> }, <span class="c">// saying it unlocks the finale</span>
|
||
{ <span class="k">"text"</span>: <span class="s">"Fine."</span>, <span class="k">"goto"</span>: -1 }
|
||
] },
|
||
{ <span class="k">"says"</span>: <span class="s">"No list, no dance. The list is life."</span>,
|
||
<span class="k">"choices"</span>: [ { <span class="k">"text"</span>: <span class="s">"Harsh."</span>, <span class="k">"goto"</span>: -1 } ] },
|
||
{ <span class="k">"says"</span>: <span class="s">"...That WAS you? Respect, fixer. You're on the list."</span>,
|
||
<span class="k">"choices"</span>: [ { <span class="k">"text"</span>: <span class="s">"Let's go."</span>, <span class="k">"goto"</span>: -1 } ] }
|
||
]</code></pre>
|
||
|
||
<p>Choices gated by <code>requires_flag</code> are <b>invisible</b> 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 (<i>"Hear that bass?"</i> only exists
|
||
while bass is audible).</p>
|
||
|
||
<div class="note">Writing tip: nodes are cheap. Give wrong turns a little
|
||
personality ("The list is life.") — dead ends carry more character-per-byte
|
||
than the golden path.</div>
|
||
|
||
<!-- ================================================================ -->
|
||
<h2 id="flags">7. Flags: real puzzles</h2>
|
||
|
||
<p><b>Flags</b> are named booleans living for one play session (the manifest
|
||
can preset them). Three things touch them:</p>
|
||
<ul>
|
||
<li>an object's successful <code>use</code> → <code>sets_flag</code></li>
|
||
<li>a picked dialogue choice → <code>sets_flag</code></li>
|
||
<li>gates: object <code>requires_flag</code> (blocks <code>use</code> with
|
||
<i>"Nothing happens. Something else must come first."</i>) and choice
|
||
<code>requires_flag</code> (hides the choice)</li>
|
||
</ul>
|
||
|
||
<p>Combined with <code>needs</code> (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.</p>
|
||
|
||
<p>Choose flag names like <code>music_on</code>, <code>met_wizard</code>,
|
||
<code>bridge_down</code> — sentences you can read in JSON six months later.
|
||
Headless surfaces can set or inspect any flag
|
||
(<code>{"cmd":"set_flag",…}</code>, <code>{"cmd":"query"}</code>), which makes
|
||
puzzle chains testable in CI (§10).</p>
|
||
|
||
<!-- ================================================================ -->
|
||
<h2 id="art">8. The art pipeline</h2>
|
||
|
||
<p>MRPGI never blits full-colour images over the game. Every PNG is
|
||
<b>quantized to the 16-colour EGA palette</b> 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.</p>
|
||
|
||
<h3>Sprite spec</h3>
|
||
<ul>
|
||
<li><b>PNG with transparency</b>, drawn at game scale — props are ~12–28 px tall. No upscaling, draw 1:1.</li>
|
||
<li><b>Filename is the id</b>: <code>sprites/orc.png</code> ↔ <code>"sprite":"orc"</code>. Missing sprites show as a magenta placeholder (the object still works).</li>
|
||
<li><b>Bottom-centre is the feet.</b> Stand the subject on the canvas's bottom edge; the feet drive depth.</li>
|
||
<li>EGA colours are ideal but optional — the engine snaps whatever you feed it. Palette files for Aseprite/Inkscape ship in <code>palettes/</code>.</li>
|
||
</ul>
|
||
|
||
<h3>AI-generated sprites</h3>
|
||
<p>Each theme pack ships <code>gemini_sprites.md</code> with per-sprite
|
||
prompts. The pattern that works:</p>
|
||
<pre><code><span class="c">STYLE (prepend to every prompt):</span>
|
||
16-color EGA pixel art sprite, hard pixels, no anti-aliasing, no gradients,
|
||
solid magenta (#FF00FF) background, subject centered, feet touching bottom edge.
|
||
|
||
<span class="c">SPRITE:</span>
|
||
a stout wooden treasure chest with iron bands, closed, 20x15 pixels
|
||
</code></pre>
|
||
<p>Generate, magenta-key the background to transparency, save into
|
||
<code>sprites/</code>, press <kbd>R</kbd> in the editor. The engine's
|
||
quantizer forgives models that don't quite hold the palette.</p>
|
||
|
||
<!-- ================================================================ -->
|
||
<h2 id="audio">9. Sound & music</h2>
|
||
|
||
<p>By default everything is <b>synthesized square waves</b> — PCjr-beeper
|
||
soul, zero asset files. SFX cues: <i>blip</i> (command sent), <i>pickup</i>,
|
||
<i>confirm</i>, <i>error</i>, <i>door</i> (room change), <i>win</i> (fanfare).
|
||
Each room loops one of five ambient moods, set with the strip's <b>♪</b>
|
||
button.</p>
|
||
|
||
<h3>Bring your own audio</h3>
|
||
<pre><code>games/mygame/sfx/door.ogg <span class="c"># overrides one cue: blip|pickup|confirm|error|door|win</span>
|
||
games/mygame/music/eerie.ogg <span class="c"># overrides a mood by name (calm|eerie|tense|jolly|spooky)…</span>
|
||
games/mygame/music/2.ogg <span class="c"># …or by number 1-5</span></code></pre>
|
||
<p>WAV/OGG/MP3 all load; <b>prefer OGG for music</b> — MP3 encoders pad
|
||
silence that clicks at the loop seam. Whatever you don't provide keeps its
|
||
chiptune default. <kbd>F2</kbd> mutes everything.</p>
|
||
|
||
<div class="note">In the engine's architecture, sound is an <i>event</i>
|
||
(<code>{"event":"audio","cue":"pickup"}</code>) — 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.</div>
|
||
|
||
<!-- ================================================================ -->
|
||
<h2 id="headless">10. Driving it with code</h2>
|
||
|
||
<p>The engine core is fully headless: a game state driven by JSON
|
||
<b>Commands</b> in and <b>Events</b> out. The GUI is just one client. The
|
||
<code>mrpgi-headless</code> binary exposes the same bus four more ways.</p>
|
||
|
||
<pre><code>cargo build -p mrpgi-core <span class="c"># builds target/debug/mrpgi-headless</span>
|
||
mrpgi-headless --help</code></pre>
|
||
|
||
<h3>10a. JSONL stdio — the substrate</h3>
|
||
<p>One JSON command per line in, events stream out. A real session against
|
||
<i>Lost Fuse</i> — these are actual engine outputs:</p>
|
||
<pre><code>$ mrpgi-headless --game games/lost-fuse
|
||
<span class="s">{"event":"room_changed","room":0,"music":2}</span>
|
||
<span class="s">{"event":"transcript","line":"Night. A scrapyard. Somewhere above, a party has gone horribly silent. Type 'help'."}</span>
|
||
<span class="k">{"cmd":"parse","text":"take fuse"}</span>
|
||
<span class="s">{"event":"transcript","line":"> take fuse"}</span>
|
||
<span class="s">{"event":"transcript","line":"You take the fuse."}</span>
|
||
<span class="s">{"event":"inventory_changed","items":["fuse"]}</span>
|
||
<span class="s">{"event":"audio","cue":"pickup"}</span>
|
||
<span class="k">{"cmd":"walk_to","x":156,"y":85}</span>
|
||
<span class="s">{"event":"transcript","line":"You step into room 1."}</span>
|
||
<span class="s">{"event":"audio","cue":"door"}</span>
|
||
<span class="s">{"event":"room_changed","room":1,"music":3}</span>
|
||
<span class="s">{"event":"ego_moved","x":18,"y":84,"arrived":true}</span>
|
||
<span class="k">{"cmd":"query"}</span>
|
||
<span class="s">{"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}], …}}</span></code></pre>
|
||
|
||
<p>The engine only advances when told: <code>{"cmd":"tick","n":20}</code> runs
|
||
one second of fixed game cycles; <code>walk_to</code> ticks until arrival or
|
||
blocked. The full command list is in <a href="#appendix">the appendix</a>.</p>
|
||
|
||
<h3>10b. Script replay — your quest as a CI test</h3>
|
||
<pre><code>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 <span class="c"># byte-identical, every time</span>
|
||
grep -q '"event":"won"' run1.log && echo "quest still winnable"</code></pre>
|
||
<p>Under <code>--script</code> 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.</p>
|
||
|
||
<h3>10c. Render with no window</h3>
|
||
<pre><code>mrpgi-headless --render-room games/mygame/rooms/room3.json out.png --game games/mygame</code></pre>
|
||
<p>Bakes any RoomDoc to a PNG — props, depth and all. The GUI equivalent for
|
||
whole-frame ground truth is <code>mrpgi --shot out.png</code>, which saves
|
||
the actual GPU backbuffer.</p>
|
||
|
||
<h3>10d. HTTP + Python</h3>
|
||
<pre><code>mrpgi-headless --serve 127.0.0.1:7777 --game games/mygame</code></pre>
|
||
<table>
|
||
<tr><th>Route</th><th>Does</th></tr>
|
||
<tr><td><code>POST /command</code></td><td>one Command JSON → JSON array of Events</td></tr>
|
||
<tr><td><code>GET /state</code></td><td>full snapshot</td></tr>
|
||
<tr><td><code>GET /frame.png</code></td><td>the current frame</td></tr>
|
||
<tr><td><code>GET /events?since=N</code></td><td>event log from a cursor → <code>{"next":M,"events":[…]}</code></td></tr>
|
||
<tr><td><code>POST /rooms/{n}</code></td><td>body = RoomDoc (add <code>?persist</code> to write to disk)</td></tr>
|
||
</table>
|
||
<pre><code><span class="c"># Python needs nothing but requests:</span>
|
||
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)</code></pre>
|
||
|
||
<!-- ================================================================ -->
|
||
<h2 id="ai">11. AIs and MRPGI</h2>
|
||
|
||
<p>This is the headline act: the engine is an <b>MCP server</b>, so Claude
|
||
(or any MCP client) can author <i>and</i> play games — and see real rendered
|
||
frames of its own work.</p>
|
||
|
||
<pre><code>claude mcp add mrpgi -- /path/to/target/debug/mrpgi-headless --mcp --game games/mygame</code></pre>
|
||
|
||
<h3>The tools Claude gets</h3>
|
||
<table>
|
||
<tr><th>Tool</th><th>Does</th></tr>
|
||
<tr><td><code>mrpgi_parse</code></td><td>type a player command</td></tr>
|
||
<tr><td><code>mrpgi_walk_to</code> / <code>mrpgi_tick</code> / <code>mrpgi_choose</code></td><td>move, advance time, answer dialogue</td></tr>
|
||
<tr><td><code>mrpgi_state</code></td><td>full snapshot: room, inventory, flags, visible objects, exits</td></tr>
|
||
<tr><td><code>mrpgi_render_frame</code></td><td><b>returns a real PNG image</b> — the model looks at the actual frame</td></tr>
|
||
<tr><td><code>mrpgi_upsert_room</code> / <code>mrpgi_upsert_object</code> / <code>mrpgi_paint_stroke</code></td><td>author: whole rooms, single objects, individual paint ops</td></tr>
|
||
<tr><td><code>mrpgi_save_room</code> / <code>mrpgi_new_game</code> / <code>mrpgi_load_game</code></td><td>persist, reset, switch games</td></tr>
|
||
<tr><td><code>mrpgi_command</code></td><td>raw bus escape hatch — anything the JSONL surface accepts</td></tr>
|
||
</table>
|
||
|
||
<p>Room upserts are <b>validated</b> 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.</p>
|
||
|
||
<h3>The loop that makes games from prompts</h3>
|
||
<ol>
|
||
<li><b>Author</b>: Claude upserts rooms/objects as JSON.</li>
|
||
<li><b>Look</b>: <code>mrpgi_render_frame</code> — does the room read visually? Fix strokes.</li>
|
||
<li><b>Playtest</b>: <code>mrpgi_parse</code> / <code>mrpgi_walk_to</code> through its own puzzle chain to the win.</li>
|
||
<li><b>Commit</b>: <code>mrpgi_save_room</code> writes JSON to disk. Human plays it in the GUI.</li>
|
||
</ol>
|
||
|
||
<!-- ================================================================ -->
|
||
<h2 id="prompts">12. Prompt patterns that work</h2>
|
||
|
||
<p>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.</p>
|
||
|
||
<h3>12a. "Write me a room" (paste-into-any-chatbot version)</h3>
|
||
<pre><code>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.</code></pre>
|
||
|
||
<p><b>Actual output</b> (this exact room ships in
|
||
<code>games/lost-fuse/expansion/roof.jsonl</code> and loads clean):</p>
|
||
<pre><code>{"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}</code></pre>
|
||
|
||
<h3>12b. Drop that lore into a RUNNING game</h3>
|
||
<pre><code>$ mrpgi-headless --script games/lost-fuse/expansion/roof.jsonl --game games/lost-fuse
|
||
<span class="s">{"event":"room_upserted","room":3,"persisted":false}</span> <span class="c"># validated + live</span>
|
||
<span class="s">{"event":"edited","room":2,"strokes":13}</span> <span class="c"># room 2's north exit now → 3</span></code></pre>
|
||
<p>Same two operations over HTTP (<code>POST /rooms/3</code>) or MCP
|
||
(<code>mrpgi_upsert_room</code>) extend a session someone is playing
|
||
<i>right now</i>.</p>
|
||
|
||
<h3>12c. Prompts to give Claude when the MCP server is connected</h3>
|
||
<pre><code><span class="c"># the full authoring loop, one sentence:</span>
|
||
"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."
|
||
|
||
<span class="c"># incremental world-building:</span>
|
||
"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."
|
||
|
||
<span class="c"># QA pass:</span>
|
||
"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."
|
||
|
||
<span class="c"># regression testing:</span>
|
||
"Play to the win, then write the exact command sequence as a JSONL script I
|
||
can replay with --script in CI."</code></pre>
|
||
|
||
<h3>12d. Constraints that raise output quality</h3>
|
||
<ul>
|
||
<li><b>Name the sprites that exist.</b> Models invent sprite names otherwise; missing sprites render magenta.</li>
|
||
<li><b>Name the flags that exist</b> (or say "invent flags, prefix them <code>lh_</code>").</li>
|
||
<li><b>Ask for the walled-room skeleton</b> (§5) explicitly — models that improvise geometry forget door gaps.</li>
|
||
<li><b>One room per reply.</b> Whole games in one shot degrade; the upsert-validate loop is your friend.</li>
|
||
<li><b>"Reply with ONLY the JSON"</b> — and pipe through <code>--render-room</code> to see it before you keep it.</li>
|
||
</ul>
|
||
|
||
<!-- ================================================================ -->
|
||
<h2 id="web">13. The web build & cloud saves</h2>
|
||
|
||
<p>The page at <a href="./">/engine/</a> 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.</p>
|
||
|
||
<h3>Accounts</h3>
|
||
<p><b>SIGN IN</b> (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. <b>Save world</b> snapshots the
|
||
entire live world (manifest + every room, including unsaved paint);
|
||
<b>Load world</b> 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.</p>
|
||
|
||
<h3>For the curious: how save works</h3>
|
||
<p>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
|
||
<code>/engine/api/world</code>. LOAD reverses the trip. The whole world —
|
||
three rooms of <i>Lost Fuse</i> — is about 6.5 KB.</p>
|
||
|
||
<!-- ================================================================ -->
|
||
<h2 id="internals">14. Engine internals (for devs)</h2>
|
||
|
||
<h3>Workspace map</h3>
|
||
<pre><code>mrpgi-core/ <span class="c"># the engine. ZERO window/audio deps — enforced by the crate boundary</span>
|
||
src/state.rs <span class="c"># GameState: apply(Command) → Vec<Event>, tick(dt); THE spine</span>
|
||
src/world.rs <span class="c"># RoomDoc/ObjDef/dialogue data model, World, game folders, WorldBundle</span>
|
||
src/parser.rs <span class="c"># verb table, verb-noun parser, dialogue state machine</span>
|
||
src/render.rs <span class="c"># headless compositor → RGBA / PNG</span>
|
||
src/framebuffer.rs <span class="c"># the dual buffers + band(y) depth table</span>
|
||
src/picture.rs <span class="c"># draw primitives: rects, lines, flood fill, stamps</span>
|
||
src/sprite.rs <span class="c"># the ego: movement, collision, walk cycle</span>
|
||
src/assets.rs <span class="c"># PNG → EGA quantizer, sprite folders</span>
|
||
src/audio.rs <span class="c"># square-wave synth + cue definitions (content, not output)</span>
|
||
src/ai.rs <span class="c"># the AI lane (worker thread, Ollama/OpenRouter)</span>
|
||
src/kit.rs <span class="c"># sample game + starter kit writers</span>
|
||
src/bin/mrpgi-headless/ <span class="c"># JSONL / --script / --render-room / HTTP / MCP</span>
|
||
tests/engine.rs <span class="c"># parser goldens + full win-path replay determinism test</span>
|
||
mrpgi/ <span class="c"># the macroquad GUI — just another bus client</span>
|
||
mrpgi-vault/ <span class="c"># standalone: accounts + world saves for the web (argon2 + tiny_http)</span></code></pre>
|
||
|
||
<h3>The bus</h3>
|
||
<p>Everything is <code>GameState::apply(Command) → Vec<Event></code>
|
||
plus <code>tick(dt)</code> for wall-clock front-ends. Commands and events are
|
||
serde enums (<code>{"cmd":"…"}</code> / <code>{"event":"…"}</code>), 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.</p>
|
||
|
||
<h3>The AGI trick, precisely</h3>
|
||
<p>Rooms rasterize into two 160×168 byte buffers: <b>visual</b> (palette
|
||
indices) and <b>priority</b>. Priority values 4–15 are depth bands —
|
||
<code>band(y) = min(4 + y*12/168, 15)</code> — 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 <i>feet</i> and paints each pixel only where
|
||
<code>object_priority ≥ room_priority</code>. That inequality is the entire
|
||
walk-behind system.</p>
|
||
|
||
<h3>Determinism</h3>
|
||
<p>The sim advances in fixed cycles (<code>CYCLE_DT = 1/20 s</code>). Real-time
|
||
front-ends accumulate frame time into cycles; scripted surfaces send
|
||
<code>tick</code> 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.</p>
|
||
|
||
<h3>The vault API (web accounts)</h3>
|
||
<pre><code>POST /engine/api/signup {"user","pass"} <span class="c"># 2-20 chars a-z 0-9 _ - ; argon2id hash at rest</span>
|
||
POST /engine/api/login {"user","pass"} <span class="c"># session cookie, 30 days, HttpOnly</span>
|
||
POST /engine/api/logout
|
||
GET /engine/api/me <span class="c"># {"user":"matt"} or 401</span>
|
||
GET /engine/api/world <span class="c"># your saved WorldBundle</span>
|
||
PUT /engine/api/world <span class="c"># save (≤ 4 MB, must parse as JSON)</span></code></pre>
|
||
<p>Storage is JSON files on disk; sessions are in-memory (a service restart
|
||
signs everyone out, harmlessly). A <b>WorldBundle</b> is
|
||
<code>{"manifest":…, "rooms":{"0":RoomDoc,…}}</code> — the same single-file
|
||
world format used by save/load everywhere.</p>
|
||
|
||
<!-- ================================================================ -->
|
||
<h2 id="extend">15. Extending the engine</h2>
|
||
|
||
<h3>Add a verb (data only)</h3>
|
||
<p>New <i>synonyms</i> are pure manifest data (§5). New verb <i>behaviors</i>
|
||
are one arm in <code>parser.rs::run_command</code>'s match — read the
|
||
<code>"use"</code> arm as the template; it shows item checks, flag gates and
|
||
win handling in ~20 lines.</p>
|
||
|
||
<h3>Add a Command (worked example)</h3>
|
||
<ol>
|
||
<li>Add a variant in <code>state.rs</code>: <code>Teleport { x: i32, y: i32 }</code> — serde gives you <code>{"cmd":"teleport","x":80,"y":100}</code> on every surface for free.</li>
|
||
<li>Handle it in <code>GameState::apply</code>: clamp, set <code>self.ego.x/y</code>, return an <code>EgoMoved</code> event.</li>
|
||
<li>That's it. JSONL, HTTP and <code>mrpgi_command</code> over MCP can all teleport now. Add a dedicated MCP tool in <code>bin/mrpgi-headless/mcp.rs</code> only if you want a nicer schema.</li>
|
||
</ol>
|
||
|
||
<h3>Ideas with prepared landing zones</h3>
|
||
<ul>
|
||
<li><b>Water & triggers</b>: control lines 1–2 are already painted and saved; <code>step_cycle</code> is where they'd bite.</li>
|
||
<li><b>Strudel music</b>: subscribe a browser page to <code>GET /events</code> and translate <code>music</code>/<code>audio</code> events into live-coded patterns. The event stream already exists.</li>
|
||
<li><b>Pathfinding</b>: <code>sprite.rs</code> steering is deliberately naive; A* over the priority buffer is a contained swap.</li>
|
||
<li><b>Directional ego views</b>: <code>View</code> already models loops-of-cels; the robot just only has one loop.</li>
|
||
</ul>
|
||
|
||
<!-- ================================================================ -->
|
||
<h2 id="trouble">16. Troubleshooting</h2>
|
||
|
||
<table>
|
||
<tr><th>Symptom</th><th>Cause / fix</th></tr>
|
||
<tr><td>Objects are magenta squares</td><td>that sprite PNG doesn't exist in the game's <code>sprites/</code> — make it (§8); the object still works</td></tr>
|
||
<tr><td>Fill flooded the whole screen</td><td>your outline has a gap; <kbd>Z</kbd> and seal it</td></tr>
|
||
<tr><td>Robot won't walk somewhere obvious</td><td>press <kbd>L</kbd> → <b>both</b>; you probably painted <b>wall</b> meaning under a floor-looking colour</td></tr>
|
||
<tr><td>Walked into a room and got stuck</td><td>the arrival edge is walled; the engine falls back to the room's spawn — set the spawn sensibly (<kbd>9</kbd>)</td></tr>
|
||
<tr><td>Exit won't trigger</td><td>door gaps must reach the screen border; the trigger zone is the outer ~9 px</td></tr>
|
||
<tr><td>AI lane does nothing</td><td>check the status bar's <code>ai:</code> readout; Ollama running? <code>MRPGI_AI_MODEL</code> matches <code>ollama list</code>? It only fires on <i>unknown</i> verbs</td></tr>
|
||
<tr><td>No sound on web until you click</td><td>browsers require a user gesture before audio — touch anything</td></tr>
|
||
<tr><td>Web save button does nothing</td><td>sign in first; the SAVE/LOAD buttons live in the account panel</td></tr>
|
||
<tr><td>Typed a letter, a tool changed</td><td>you're in the editor and no text field is focused — tool keys are single letters; in Play, letters go to the command line</td></tr>
|
||
</table>
|
||
|
||
<!-- ================================================================ -->
|
||
<h2 id="appendix">17. Appendices & reference</h2>
|
||
|
||
<h3>A. The EGA palette</h3>
|
||
<table>
|
||
<tr><th>#</th><th></th><th>Name</th><th>#</th><th></th><th>Name</th></tr>
|
||
<tr><td>0</td><td><span class="swatch" style="background:#000"></span></td><td>black</td>
|
||
<td>8</td><td><span class="swatch" style="background:#555"></span></td><td>dark gray</td></tr>
|
||
<tr><td>1</td><td><span class="swatch" style="background:#00A"></span></td><td>blue</td>
|
||
<td>9</td><td><span class="swatch" style="background:#55F"></span></td><td>light blue</td></tr>
|
||
<tr><td>2</td><td><span class="swatch" style="background:#0A0"></span></td><td>green</td>
|
||
<td>10</td><td><span class="swatch" style="background:#5F5"></span></td><td>light green</td></tr>
|
||
<tr><td>3</td><td><span class="swatch" style="background:#0AA"></span></td><td>cyan</td>
|
||
<td>11</td><td><span class="swatch" style="background:#5FF"></span></td><td>light cyan</td></tr>
|
||
<tr><td>4</td><td><span class="swatch" style="background:#A00"></span></td><td>red</td>
|
||
<td>12</td><td><span class="swatch" style="background:#F55"></span></td><td>light red</td></tr>
|
||
<tr><td>5</td><td><span class="swatch" style="background:#A0A"></span></td><td>magenta</td>
|
||
<td>13</td><td><span class="swatch" style="background:#F5F"></span></td><td>light magenta</td></tr>
|
||
<tr><td>6</td><td><span class="swatch" style="background:#A50"></span></td><td>brown</td>
|
||
<td>14</td><td><span class="swatch" style="background:#FF5"></span></td><td>yellow</td></tr>
|
||
<tr><td>7</td><td><span class="swatch" style="background:#AAA"></span></td><td>light gray</td>
|
||
<td>15</td><td><span class="swatch" style="background:#FFF"></span></td><td>white</td></tr>
|
||
</table>
|
||
|
||
<h3>B. Command reference (every surface)</h3>
|
||
<pre><code><span class="c">// playing</span>
|
||
{"cmd":"parse","text":"take key"} {"cmd":"walk","dir":3} <span class="c">// 0 stop, 1 N … 8 NW</span>
|
||
{"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}
|
||
<span class="c">// authoring (the same bus the editor uses)</span>
|
||
{"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"}</code></pre>
|
||
|
||
<h3>C. Event reference</h3>
|
||
<pre><code>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}</code></pre>
|
||
|
||
<h3>D. The AI lane (native)</h3>
|
||
<pre><code>MRPGI_AI=ollama <span class="c"># ollama (default) | openrouter | off</span>
|
||
MRPGI_AI_MODEL=gemma3:4b <span class="c"># your local tag — check `ollama list`</span>
|
||
MRPGI_AI_URL=… <span class="c"># endpoint override</span>
|
||
OPENROUTER_API_KEY=… <span class="c"># for openrouter</span></code></pre>
|
||
<p>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.</p>
|
||
|
||
<h3>E. Keyboard reference card</h3>
|
||
<p><b>Global:</b> <kbd>Tab</kbd> edit⇄play · <kbd>F1</kbd> CRT · <kbd>F2</kbd> mute · <kbd>Esc</kbd> unfocus/leave-chat/quit<br>
|
||
<b>Play:</b> arrows/click walk · type + <kbd>Enter</kbd> · <kbd>1</kbd>–<kbd>6</kbd> dialogue · top menus<br>
|
||
<b>Edit:</b> <kbd>1</kbd>–<kbd>0</kbd> tools · <kbd>L</kbd> view · <kbd>[</kbd><kbd>]</kbd> sprite/kit · <kbd>,</kbd><kbd>.</kbd> brush · <kbd>-</kbd><kbd>=</kbd> rooms · <kbd>Z</kbd> undo · <kbd>K</kbd> save · <kbd>O</kbd> load · <kbd>C</kbd> clear · <kbd>R</kbd> reload sprites</p>
|
||
|
||
<p style="margin-top:60px; color:var(--dim)">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. 🤖</p>
|
||
|
||
</main>
|
||
<a class="top" href="#">↑ top</a>
|
||
</body>
|
||
</html>
|