Inspiration

Every AI dungeon master I have played with does the same thing: it describes a world it also controls. If it says you took six damage, you took six damage until it forgets, and then you didn't. There is nothing underneath the sentences. The model is the referee, the rulebook, the dice and the narrator all at once, so none of those things quite exist.

WebMCP changes what an agent is on a page. It stops being a thing that writes about your app and becomes a thing that operates it, through controls the page itself defines. That felt like the right shape for a Dungeon Master: someone who can genuinely act on the world, but who does not own the rules.

So I built the underneath first a real game with real state and then gave the AI five ways in, and no other way at all.

What it does

Dungeon Crawl is a single-page text adventure with a chapel to explore, a lamplighter to find, and an AI Dungeon Master sitting beside you.

You play with buttons: Attack, Explore, Talk, or a free-form line of your own. The DM plays with five registered WebMCP tools: it reads the game state, spawns encounters, resolves combat rounds, awards items, and moves the story on. Every call it makes changes the screen you are looking at, in the moment it makes it.

Dungeon-Crawl-how-it-works: https://docs.google.com/presentation/d/1j55D6dXDGQCK6EJ541I9T9pUSiPNuVqW/edit?usp=sharing&ouid=116709698709972850916&rtpof=true&sd=true

Flow of the Dungeon : https://drive.google.com/file/d/1K_KQOtsLircpPqm4rHiFApVwMvjwydnH/view?usp=sharing

Three things make it more than an AI with a game attached:

  • It can be refused. Ask for a second fight while one is still alive and the tool returns "The turned verger is already fighting you here. Resolve that first." That is the game's rule, inside the tool, refusing the call not the model being polite.
  • It can be overruled. The page keeps its own opinion about what you can survive, computed from health, level and how many rounds have gone badly in a row, and treats it as a ceiling. The DM may always go gentler, never harder. Ask for hard while hurt and you get "You asked for hard; the player is hurt (12/20), so this came out easy."
  • It can write. Once the seven hand-written rooms are exhausted, advance_story lets the DM invent one and the prose arrives as tool arguments, styled by a house voice brief carried in the schema itself. Rooms the DM wrote appear on the map with a ✦, so you can always see which parts of the dungeon were written for you and which were written on the spot.

How I built it

React 19, Vite and Tailwind v4. No backend, no database, about 1,850 lines of source.

The architecture is one idea: there is exactly one way to change the world, and both players use it. A room's Attack/Explore/Talk verb is a pure function of state returning an effects bundle { dm, hp, xp, item, reveal }. A tool call builds the same kind of bundle from its arguments. Both go into applyEffects(state, effects), which is the only mutation in the application, and into the same reducer. Combat narrows further: combatRound() is a single resolver, and the Attack button and the resolve_combat tool both call it. A test asserts the button and the tool leave the player and the enemy on identical health from the same starting state, because "the agent plays by the same rules" ought to be a property you can run rather than a claim in a README.

The prose the DM writes comes through the tool schema, not a side-channel API call. This is a static page in a public repo, so a model API key shipped in the bundle is a key published and the agent calling the tools is already a language model, so asking it for the sentences removes the secret, the round trip, and the second voice that would have to be matched to the first. What it sends is sanitised (markdown stripped, whitespace collapsed, length capped) and shown to the player verbatim.

Challenges I ran into

A try/catch that caught nothing. React StrictMode double-mounts effects in development, so the second pass met tool names the first pass had registered, and the console filled with InvalidStateError: Duplicate tool name. I wrapped registration in a try/catch and moved on and the errors kept coming, because registerTool returns a rejecting promise, not a thrown error. A try/catch around an async call catches nothing. The fix is await, and it is now covered by four tests against a mock context that rejects duplicates the way the real one does.

A difficulty curve that killed the player. My first heuristic gave a healthy level-1 player "hard" and the hard enemy deals 7 a round against 20 health while taking four rounds to kill. The playtest died in four rounds. Hard now requires level 3 as well as health.

A finale nobody could pick up. Seven rooms hand out eight items against a six-slot pack, so a thorough player filled it before the last room and the game silently refused them the item the whole story points at. Only found it because the playtest logged inventory counts.

A backspace character in a regular expression. A \b got mangled into a literal 0x08 byte, so a word-boundary test never matched and free-form input produced room names like "The And Light Every". It was invisible in every editor and grep od -c found it.

Dried blood is unreadable on deep ink. The palette's accent scored 1.80:1 against the background, and it was carrying the combat numbers. I computed every text style in the app and added a fifth token the same blood with a lamp on it, at 4.60:1 used only for the system voice.

What I learned

The tool descriptions are the product. They are the only thing the model ever sees: not the code, not the UI, not the state. A tool that behaves perfectly and describes itself vaguely is a tool an agent will use wrongly, and the fix is always writing, never logic.

I also learned how much better refusals feel than validation. Every tool that says no says what to do instead, in a sentence and an agent that is told why it was refused corrects itself on the next call rather than retrying the same thing.

What's next

Multiplayer, with two agents and one human the same pipeline already supports more than one actor. And an authored campaign format, so someone who cannot write JavaScript can drop in rooms of their own and let the DM extend them.

Built With

Share this project:

Updates

Submission history