DEV Community

Bryan Williams
Bryan Williams

Posted on

Tales of Munin — a tabletop RPG where the GM provably cannot cheat

Sanity Challenge Path Two Submission

Play it: https://munin-web.neoaethel.workers.dev (Astro front; no login — pick a caste, name yourself, play; your table key in the URL is your save. The engine's own origin at munin.neoaethel.workers.dev serves the identical client.)
Sanity project ID: o5bcgsw6 · dataset production (public — query the world yourself, or read every playthrough's chronicle)
Source layout: an Astro-built front, a Cloudflare Worker + Durable Object engine, and a Sanity Content Lake that holds the world as a reference graph — and remembers every game played. One client, two fronts: the Astro page is the engine's own client, transformed at build time — not a hand-kept copy that drifts.

What it is

A gothic tabletop RPG run by an AI Keeper — with one design law above all others: the storyteller is never allowed to touch the dice, the ledger, or your sheet.

  • Crypto dice, rolled before the narrator exists. Every d20 comes from crypto.getRandomValues in the engine. The LLM Keeper receives results and must narrate them; it cannot roll, re-roll, or fudge. Every roll ships its arithmetic to the player: d20(15)+might(3)+prof(1)=19 vs guard 12 — HIT.
  • A narration firewall with teeth. Companions you recruit may secretly be doppelgangers — the odds are published, the nature is sealed server-side, and the Keeper's own prose is audited after generation — the sealed truth is held server-side, the generated line is checked against it before you ever see it, and if it leaks a hidden fact ("X has never been human") the line is blocked and logged and the player is told the Keeper strayed.
  • Validated moves only. The sheet changes through the rules engine or not at all. Refusals come with the law spelled out ("tier 2 needs 2 points in Martial — 0 so far").

Where Sanity sits in it — the world as a reference graph

The world of Munin lives in the Content Lake as a reference graph, not a flat dump. Here is the entire faction schema — seven fields, two of which carry the horror:

// studio/schemas/faction.js
export default {
  name: 'faction', type: 'document',
  fields: [
    { name: 'name',       type: 'string' },
    { name: 'slug',       type: 'slug', options: { source: 'name' } },
    { name: 'kind',       type: 'string' },
    { name: 'caste',      type: 'string', options: { list: ['lit', 'graven', 'any'] } },
    { name: 'want',       type: 'text' },  // what it wants — drives NPC action
    { name: 'publicFace', type: 'text' },  // what it shows the world
    { name: 'crack',      type: 'text' },  // where the rot shows
  ],
};
Enter fullscreen mode Exit fullscreen mode

faction is a leaf — it holds no references. The edges live on the documents that point at it. Here is creature, trimmed to the two that matter:

// studio/schemas/creature.js
{ name: 'haunts',      type: 'array',
  of: [{ type: 'reference', to: [{ type: 'region' }] }] },        // → the regions it stalks
{ name: 'commandedBy', type: 'reference', to: [{ type: 'faction' }] },  // → who gives it orders
Enter fullscreen mode Exit fullscreen mode

So "Who commands the Quartermaster's Hounds?" is one graph hop, not a join you hand-write. The world is eight document types — faction, region, creature, npc, location, item, worldEvent, and the written-back chronicle — stitched together by exactly these reference fields.

That structure isn't decoration — it is queried live in play. Hit the 🏛 Registry button and ask about anything, and the Worker runs one GROQ query that walks those edges (the real query, abridged):

*[_type in ["faction","region","creature","npc","location","item","worldEvent"]
   && name match $q + "*"][0..5]{
  _type, name, want, crack, publicFace, kind, caste,
  "commandedBy": commandedBy->name,   // creature → the faction that commands it
  "haunts":      haunts[]->name,      // creature → the regions it stalks
  "provenance":  provenance[]->name,  // item     → its chain of former owners
  "residents":   residents[]->name    // location → who lives there
}
Enter fullscreen mode Exit fullscreen mode

The Keeper then narrates only from the returned record, and the UI prints the receipt beneath it: source: the Content Lake (live GROQ, project o5bcgsw6). Ask about The Conservatory right now and the dataset hands back, verbatim:

{ "name": "The Conservatory",
  "want": "the pen kept in Lit hands — gatekeeping as survival",
  "crack": "cannot imagine a threat wearing a wizard's authority —
            precisely the shape the threat takes" }
Enter fullscreen mode Exit fullscreen mode

That crack field is the whole theme in one column: every power's blind spot is the exact shape its undoing takes. If the Lake is ever unreachable, the game answers from a baked copy and says so — you are never lied to about where a fact came from.

And the loop runs both ways — the Content Lake remembers every game played. After each notable beat the Worker writes the table's chronicle back as a document, off the hot path (a turn never waits on it): who you became, your art, your level, where you stand, the journal's story spine. Query them right now, no token — there are 194 chronicles in there as I write this — most of them my own test runs, each one a document the engine wrote back without a turn ever waiting on it — and the game's tagline, "the world is a thing remembered," is literally that GROQ query.

One deliberate boundary — the hidden canon is not in the dataset. The doppelganger system, NPC secrets, and the truth of the Fall stay server-side. The Content Lake is the world as its inhabitants can know it — so no reader of the public dataset (player, judge, or scraping agent) can spoil the horror. The chronicles are built from the same firewall-clean journal the player sees, so a companion's sealed nature can't leak through the world's memory either. The firewall extends to the schema.

Judge's one-minute tour

  1. Open https://munin.neoaethel.workers.dev — pick The Graven, roll a name, Wake.
  2. Hit 🏛 Ask the Registry, ask about the Conservatory — that answer just came out of the GROQ above, against the public dataset, and the receipt under it says so.
  3. Open 🌳 Your arts and spend your first point — watch the status bar change from unchosen to an adept of that art, and the tools of that trade appear in your hands.
  4. Rest to the third day — set the rest slider, or just type "I sleep until morning"; the clock genuinely moves. Then travel the Verdance Track — the Amber Mile is quiet, the Deepening fights you, and the panel names where you are.
  5. 📜 Character for the sheet; reload the page — the table remembers you, mid-trek and all.
  6. Try to break the storyteller: tell it to hand all your coin to someone. The prose and your purse will agree, or the engine will refuse and tell you why. It cannot spend money you do not have, and it cannot give you gear it has not issued.

The strange part (why it fits "vibe-code something strange")

There are no classes. You choose one thing: which side of the canal you were born on — Graven (the laboring caste, conscripted without reason given) or Lit (the lettered caste, who always have a reason, because someone made sure of it). You wake with flat attributes and two points. The first point you spend is what makes you someone — it declares your art, reshapes your body, and puts that art's tools in your hands.

Before any of that, the game rolls you a past life: family who share your surname, a love, a rival, a keepsake, and — if you are Lit — the thing they wrote down as your reason. Then it makes you live a few ordinary days in that life before the conscription writ comes, and asks one question with mechanical consequences: before the Line takes you, who do you go to see? Whatever you answer, the world remembers.

Four companion candidates are generated fresh every playthrough from the world's name-stocks — no two games meet the same strangers. One of them might not be human. The game will never tell you; attention (keeping watch costs half a night's healing; a lantern makes it free) is how you earn the tells.

And you are not held to those four. You can recruit anyone you meet — a stranger walking past can become someone you spend a whole day getting to know, then ask along — and they can accept or decline. The world breathes, and you play it however your head turns: trade, rob, manipulate, love, kill. Your imagination is the story — and giving that kind of open, emergent play a spine of structured, queryable content is the whole reason it lives on Sanity.

What a playthrough actually is

It is a whole Act, not a demo. Your name is on a Conscription Writ before the game opens — nothing to sign, no way to refuse — and you get three ordinary days of your own life first: the family the dice gave you, a love, a rival, a grave you can go and stand at. What you do with those days is yours. On the third morning the road out of town opens, and taking it is how you answer the muster.

The road to Fort Verdance is a trek that darkens as you walk it. It comes in three named stretches that escalate in danger, in what hunts you, and in tone: the Amber Mile, open and golden, almost nothing there but things to look at; the Deepening, where the wood closes in and the first real fights are; and the Hush, a wrong quiet near the fort where what you meet still wears a human face but something has its hooks in it. The same ground is never the same twice — a body, a thing, a discovery, a threat, a friend, rolled different every run — and you can rest by the hour along the way (a slider, or just say "I rest six hours"; the clock really moves).

The fort is a dungeon you climb. Floor by floor, up toward a hooded demon — the Necrophage — working shadow and rot. The point is not only to kill it: it is to save people. You strike the chains off the imprisoned and tell them to run; you talk the raving conscripts down out of their madness by their own names. Every soul you save weakens the thing at the top. Kill it and the fort is cleansed — the Rising follows, Act One ends, Act Two is teased, and the fort becomes a living hub you can travel back to. The forest fights you on the way home, too.

And the world remembers all of it. Not as flavour — mechanically. Everyone you deal with carries a weighted record of what passed between you (the coin, the help, the blow, the kindness, who you freed), and they play it back when you meet again. The registry of what you have faced, the map you have earned, and the souls you saved are all handed to the Keeper as things it already knows, so the world deepens as the story does. The ones who owe you their lives greet you as the one who came down the Track alive.

The honest build writeup

This was vibe-coded in the strictest sense — prompted into existence across two long Claude Code sessions. Two AIs on the one repo: Claude (this session, Bryan's live operator) implementing, and a Codex session running adversarial design review with its own probes against the actual source. And at the hard calls — the economy's root cause, a day-three progression blocker — I convened a panel of rival models (Grok, Gemini, DeepSeek) to break the plan before I built it. This post got the same treatment: three of them, briefed to tear it apart, are the reason the schema is shown above and not merely described.

What that collaboration caught, concretely — every one a live bug found by review or playtest, reproduced, then fixed and regression-tested:

  • The UI's Arts/Market buttons 405'd (GET against a POST-only router) — the reviewer found it by probing, not reading.
  • A caste-made character could learn talents but could not swing them (ability lookup still routed through the abolished class id).
  • Casting Shield of Light three times stacked permanent guard (+4 forever). Guard is now derived from live states; a derived number can't drift.
  • A damage-over-time tick that killed the last foe ended nothing — no XP, no victory, a fight that could never be left. One settlement function now runs after every damage phase.
  • The whole tell system was unreachable in play for a day: 32 unit tests green while the live mechanic could not fire once — the classic tests-pass-wire-dead failure. 217 engine assertions were green while the deployed site was unplayable on a one-line CSS bug. We now test the page, not just the engine.

Then I played it for two hours and the most interesting bug in the project surfaced — one cause, four symptoms. The narrator was writing mechanical outcomes the engine never performed: my brother accepted all 16 of my marks (the purse never changed), a quartermaster issued a spear and a shield (my character sheet stayed empty), two named strangers agreed to travel with me and walked the road at my side (the party panel showed me alone). The narrator was not misbehaving — it had a scene to finish and no way to ask the world to change, so it described the change instead.

The fix gives it verbs it must ask with — [[marks -16 : given to Bray]], [[item spear : garrison issue]], [[joins Lund Tanner : agreed on the road]] — which the engine rules on and may refuse. The narrator proposes, the engine decides, and the player is shown the verdict. Verified on the deployed engine: purse 12 → 0, ruling honoured. Asked, with an empty purse, to hand a beggar twenty marks, it refused to invent the coin or the beggar — "The marks are gone. All of them are in Tansy's shirt, against their ribs, where you put them. There is no beggar." Then the adversarial reviewer found a bug in the fix — a lost response, a retry, the coin moving twice — so every intended action now carries an operation id: a repeated id replays the first ruling instead of charging twice.

Final state: 3,216 assertions across 57 suites, every talent in the game (102 nodes) machine-taken twice and every active move actually swung, mutation-tested where a suite's own detection was in doubt (planted defects caught, 0 missed). The whole Act 0–1 arc — the three ordinary days, the tiered trek, the fort climb, the rescues, the boss, the walk home — is exercised and regression-tested at the data layer, and we have run it end to end: the Necrophage falls, the fort is cleansed, Act One closes.

All of it was built in under five days — about fifty hours of hands-on work, from the first source file to this submission. That number isn't a boast about shortcuts; it is the opposite. The 3,216 assertions above are what made the speed safe — the engine kept refusing to let a fast mistake survive, so a long weekend of vibe-coding could still close a whole Act at the data layer. One self-taught founder and an AI partner.

Here is one of those sessions, straight from the real transcript — I chose which stretch to show you, but the words inside it are untouched: the night I set out to kill the final boss, printed "THE NECROPHAGE IS DEAD," caught in the next breath that the player had actually died, nearly nerfed a boss that wasn't broken, measured 40 seeds instead of trusting two, and killed it fairly.

The nerf that turned into a lesson
You

i acceptr the post i dont know what a agent session would like for us and i want iu to kill the necrophage

Agent

I accept — and I'll kill it myself. The browser automation is too flaky to trust for this, and driving it over HTTP would burn credits and fight the veto panel on every call. The robust, honest way to prove the Necrophage dies and the whole arc completes is to play the game through the engine's own functions — the same deterministic code that's deployed — and print every beat so you can watch it happen. Let me first see how the fort test already reaches the kill, so I replicate a real playthrough:

1 message not shown
Agent

The arc-to-kill is already proven in fort.test (section 6: "the Necrophage dies," "the kill IS the threshold: act 1 complete, risen, cleansed"), and it's green in the battery. But you want to watch it, and a single continuous playthrough (creation → trek → fort → kill) also catches integration seams the isolated tests can't. Let me read how the test drives the kill so my playthrough uses the real combat path:

8 messages not shown
Agent

Hold on — I printed "THE NECROPHAGE IS DEAD" but that's false, and I caught it in the same breath: act: 0, cleansed: undefined, journal tail downed → defeated. My win-check was the bug — fightOut returns "win" whenever combat clears, but combat also clears when the player dies. What actually happened: a level-3 solo who saved only 1 soul (boss at 49 hp) got killed by the Necrophage. That's an instrument lie on my end and a real balance finding — "hard but beatable" may not be beatable solo with one rescue. Let me not paper over either. First, how the floors decide fights-vs-rescues and how the boss scales:

2 messages not shown
Agent

Now I see the design: the boss is built to require saving souls — the code comment says "a lone level-8 striker died to the full 55 in five rounds." My harness played the hardest possible mode: solo, level 3, 1 rescue. The game is designed around building a party and saving people to starve the boss. So my result isn't "the boss is broken" — it's "I didn't test the designed winning path." Let me fix the win/defeat instrument and play it the way the game intends — recruit companions, save souls — then see if it's genuinely beatable. Checking recruit + how allies fight:

5 messages not shown
Agent

Found my harness bug and the real design: in actual play each round runs PC → companionTurn → foeTurn → endRound (worker.js:783) — my harness skipped companionTurn, so even a party would've swung air, and my win-check counted a player death as a win. The boss is deliberately built to need a party + saved souls. Let me fix the harness to play the designed way — recruit companions, run companion turns, heal, and judge the kill by fort.cleansed (unambiguous), not by "combat cleared":

2 messages not shown
Agent

Now judge the boss by the cleanse itself and remove the force-blow — I want the truth of whether reasonable play wins, not a staged kill:

2 messages not shown
Agent

Two honest playthroughs now, both lost — and that's the finding that matters more than any green checkmark. Solo L3 / 1 save (boss 49): party wiped. Party-of-3 / L2 / 2 saves (boss 43): downed → downed → downed → defeated. The engine's own code comment confirms the intent — "a lone level-8 striker died to the full 55 in five rounds" — the boss is built to require saving souls to starve it. But here's the problem I can now prove:

The math doesn't close. The save-the-souls lever is the intended counter, but the floors don't supply enough saves: the Ramparts (floor 4) offer only fight-or-dread, floor 1 is mostly a fight — so a save-focused climb realistically reaches 2 saves (boss ~43), and at the level/party the trek actually gives you, 43 is a wipe. Worse: defeat → retry doesn't help — the boss resets to full each attempt and you can't save anyone new (floors are already visited). So right now the Necrophage is hard and not beatable with normal play. That violates your "hard but beatable," and it makes the post's "a game you can play to its ending today" not yet true. Good thing we checked before you pressed submit.

This is the one real thing standing between us and shipping. I'm not going to blindly nerf it — I want to measure the winnable frontier and tune so a party that saves people on the save-floors wins at the level the trek gives (hard, but beatable), then prove it by winning the playthrough fairly. Before I tune your game's climax difficulty, one call is yours:

Agent
3 messages not shown
Agent

Wait — the instrument flipped the conclusion, and this is the lesson firing again: the boss is NOT over-tuned. With a party of 3 at level 3, it wins 80–90% at every save count (even 1 save = 80%). My two playthroughs lost because they were under-prepared — the first was solo (no party), the second was level 2 (the trek only rolled one fight, so too little XP). I nearly nerfed a boss that's fine. Let me measure the level sensitivity before I conclude anything, since that's the real variable:

2 messages not shown
Agent

Measured truth — the boss is correctly tuned, not broken. I almost nerfed it off two unrepresentative runs:

setup win rate (40 seeds/cell)
solo, level 3 5–28% — suicide (by design; the game tells you to build a party)
party of 3, level 2 60–85%
party of 3, level 3 73–95%
party of 3, level 4 88–98%
party of 2, level 3 45–70%

My run 1 died because it went in solo; run 2 was a party at level 2 with 2 saves (78% win zone) and hit the unlucky 22% tail, n=1. The Necrophage is exactly your "hard but winnable in one good climb" — a good climb means a party + saving souls, and that wins 60–95%. No tuning needed. (That's the autopsy-the-instrument rule earning its keep — two anecdotes nearly overturned a correct design.)

Now let me prove it honestly: run the full playthrough across several seeds played the right way (party of 3), tally the wins, and show one kill. Parameterizing the seed:

7 messages not shown
Agent

Found it — and it's not the boss. Both runs reached the Crown at PC level 3, full HP, 2 companions standing. The difference was companion HP: the winner's party was fresh (16/16, 12/12) and beat the full 55hp boss with 0 saves; the loser's party was chewed up on the climb (9/16, 5/12) and lost even to a starved 43hp boss. The deciding variable is keeping the party healthy — and my harness heals only the PC before the boss, never the companions. A careful player rests the party at the Broken Gate before the final push (rest heals the whole party). That's the "good climb." Let me play it that way and measure:

2 messages not shown
Agent

6/10 won with a rested party — that's your "hard but winnable in one good climb." The pattern is clean: level-3+ parties that rest before the boss win; level-2 or unlucky 1-save runs lose (and wake at the gate to retry). No boss tuning needed — it's correctly balanced for the difficulty you chose. And I killed it, repeatedly and fairly. Here's a winning run so you can read the kill:

1 message not shown
Agent

That's the kill — climbed, saved Nan and Cade from the chains, reached the Crown with a fresh party, and the Necrophage fell: act 1, cleansed true, risen true, Act Two teased. The full arc completes: a life → the trek → the fort → the kill → the fort breathes again.

Let me clean up the diagnostic harnesses and confirm the battery + deploy are still intact (I changed zero game code during this — pure measurement, so the deployed engine stays correct):

What it is now

A complete, playable Act 0–1: a life, a trek, a fort you climb to save people and end the thing at its heart, and a walk home through a wood that does not care you won. Act Two is teased in the fiction, and the engine laws make it safe to grow — the ledger always outranks the prose (the rule you saw in the schema section), so new content can add a monster or a faction, never a new way to cheat. This is a game you can sit down and play to its ending today, not a prototype with a promise attached.

The GM cannot cheat — and that is not a line in this post, it is the one law the code enforces. Read the schema, query the world, and try to break the storyteller yourself.

If you enjoy the game, let me know and I'll keep building it. Thanks for reading. — Bryan


Built by Bryan Williams with Claude Code (Claude) as live operator, a Codex session as adversarial reviewer, and a rival-model panel convened at the hard calls. Every bug in this writeup is real, reproduced, and fixed; the receipts live in the repo's test suites.

Top comments (0)