Code owns the truth, the model fills slots
LLM Dungeon is a single-player dungeon crawl that runs in a terminal. A pre-made fighter explores a procedural dungeon of five rooms, talks to one NPC in free text, fights a battle on the dungeon's grid, and wins by leaving with the quest item. The code is private, so this page is a case study, with no link and no code.
It has two goals, and when they conflict the game wins. The first is a game I want to play. The second is an experiment: what can a local model on a CPU carry in a real game, and for each piece of work, where does it belong: plain code, the local model, a cloud model, or nowhere near AI? A negative result is a finding, not a reason to force a model into a job. The model is gemma-4-E4B, served locally and run on one ordinary machine's CPU, and the numbers below are CPU numbers unless I say otherwise.
The rule that shapes the rest is in the first design note. A model of about 4B parameters forgets HP, invents dice results and loses track of who stands where. So dice, HP, combat and inventory are deterministic code with a seeded random generator, and the map comes from classic procedural generation, with the model only naming and describing the rooms. An enemy's turn is chosen by code too: a model-chosen turn would cost about 13 seconds each, inferred from a thinking-on agent step in the 1-llm project and not measured in this game.
What the model gets is a slot. There are seven kinds of request. Each has a prompt that code builds from the game's state, an output format, a validator and a fallback.
- Building the world, once, before play. Four requests. Code lays out the dungeon from the seed, decides the quest's facts, picks the encounter and places the objects. The model names the dungeon and its rooms and describes them, writes the quest's title, hook, goal and item name, writes the NPC's name, look and voice, and gives each enemy a trait from a fixed list.
- Free text. When I type something the menu does not offer, the model maps it to one of the legal actions, to an ability check, or to "impossible". For a check it picks the skill and the difficulty, and code rolls the d20.
- Narration. The model is given the outcome the engine decided, as lines without numbers, and writes prose over it, streamed as it arrives. Narration with a digit in it is refused, so the prose stays about what happened and not about the arithmetic.
- NPC replies. The answer is JSON: the reply, then effects. An effect is the NPC revealing a fact it knows, handing over an item it holds, or turning hostile. The engine applies an effect only if the state allows it, and drops and logs the rest.
Code checks every answer before it counts. A response that parses is not yet a valid enemy, so the check is on the payload and not on the status code. An invalid answer gets one retry with the validator's error appended, and a second failure uses a fallback written into the code, such as a built-in room, or an NPC that says nothing, so a bad answer never leaves the game without a next line. The same fallbacks let the whole game run with no model at all, and the session at the bottom of this page is one such run.
A log you can replay, and seeds
Every change to the game is an event, and the state is rebuilt from the events, so there is no second copy of the truth that could drift. A campaign's save folder holds its seed, the frozen world, an append-only events.jsonl with one line per batch of events, and a snapshot of the state. The dice and the model's raw answers are recorded in the events, so replaying a campaign never re-rolls and never calls the model again.
Each batch draws its random numbers from a generator seeded by the campaign's seed and the batch's number, so no generator state has to be saved. A seed repeats its dungeon, and the same inputs on the same seed repeat the game: a test plays one session twice on a seed, with no model, and gets byte-identical event logs. game replay rebuilds the state from the world and the events and checks that it equals the snapshot, and the same test runs it.
The engine and the terminal client talk only through messages, in one process for now. The split is there so that a multiplayer host could be built by adding clients, not by rewriting the engine. Multiplayer is decided and not built, and so are the 2D front end and a public release.
Quests from quest-builder, worlds from a lore tree
game new --quests FILE takes the quest from a quest pack instead of asking the model. A pack is a JSON file of quests, each with a title, a hook, a goal and the item to bring out, and the seed picks one. It is the format quest-builder exports into. The goal names the goal room with a placeholder, {goal_room}, because room names exist only once the dungeon has been generated. A pack replaces one model request, the quest text, and nothing else.
Code owns the truth here as well. A pack quest has to pass the same validator as the model's quest text, once when the pack loads and again with the real room names filled in. If it fails the second time, the game asks the model instead, as if there were no pack.
The one export I have checked came from a run that wrote, critiqued and revised five quests with a cloud model in 92 seconds. One revision failed its schema and kept its first draft, and the export skipped one quest that names no item. The four left pass the game's own validator. I have not played a pack, so I do not know yet whether a hook written for another world reads well in the dungeon the game generates. The hooks do name places that are not in it, a chapel and a goblin warren.
game new --lore DIR gives a campaign a world from a lore pack: folders of zones, cities, villages, districts, buildings and NPCs, each holding markdown written for the model. The dungeon becomes a building beneath a village the seed picks. A model of this size cannot hold a world, and a slot holds 4,096 tokens, so the files load lazily: an NPC's own files when the conversation starts, and a place's or a person's only when the player names it. Code finds the name, since asking the model for a file would be a second model call per reply.
Built: the format, the checker (game lore check), the loading, and one small hand-written example pack, which has run live once. Not built: writing a world, and travelling between its towns, which are later pieces of work. For now the tree pays off as a survivor who knows its region.
What I measured, and what I did not
The experiment half is a placement table: one row per feature, and a verdict that has to rest on a measurement. The design sets the targets before anything is built. A menu action and entering a room are instant, with no model. A free-text action reaches its first narrated word within 15 seconds, and an NPC reply reaches its first word within 10. World generation takes at most five minutes, and at least 95% of model responses are valid. The last target is fun, which I define as playing the game through three times and wanting to play again. A missed target is not a failed project: it sends that row to "not local", or to "differently".
Every call to the model is timed, checked and logged as an event, and game report turns those events into a report of the calls, the time to the first token and the total time (median and 90th percentile), the share valid on the first try, the fallbacks, and whether thinking was on. Thinking is on while the world is generated and off for what happens during play, since a turn that waits for the model to think misses its target.
The NPC row needs a careful read. The report's number for it is a median of 2.7 seconds, and that is the time to the first token of a reply. An NPC reply is JSON with the speech in its first field, so the first token is the opening brace and not a word. The whole reply took a median of 9.7 seconds. The target is for the first word, and this run timed only the brace that comes before it, so the target is not really measured yet. Measuring the first word is the next step.
NPC dialogue from a CPU-only local model
- Expected (written before the run)
NPC dialogue comes from the local model, with an 8K context and staying consistent with the game state as the main risks.
- Measured
On the CPU, the first token of an NPC reply, the opening brace of its JSON rather than its first word, came at a median of 2.7 s (p90 14.0 s, n = 6) against a 10 s first-word target; the whole reply took a median of 9.7 s (p90 19.0 s). All 10 model calls in the run were valid, with no fallbacks. The six NPC replies had a median prompt of 773.5 tokens. The one reply that opened a conversation took 14.0 s to that first token; replies that loaded lore from the campaign took a median of 2.7 s (n = 4).
Verdict: HELD- So
In this run a local model gave valid NPC replies every time, which is the part of the hypothesis it tests. The 10 s target is for the first word, and the run times only the opening brace that comes before it, so the median meets the target on that measure alone; the first reply of a conversation, at 14.0 s, is past it. Consistency with the game state is not scored in this report, so that risk stays open.
The run was small, six NPC replies and ten calls in all, so all ten being valid, nine on the first try and the quest text after one retry, says that the model can do the job, not that it always will. The slow reply was the first of a conversation, which had to read the NPC's lore cold, and the lore design had derived that it would miss the 10-second target. The fix I have in mind is to warm the NPC's slot when the hero enters its room. That is designed and not built. Consistency with the game state, the other risk in the hypothesis, is not scored in this report, so it stays open.
Switching thinking off is also a request setting that a model may ignore. In an earlier run the model reasoned anyway in three of its four NPC replies, which is most of that run's 23.6-second median for a whole reply. So when thinking is off, the game now bans the token that opens the model's reasoning.
Narration is the other request where latency is the risk. A free-text action costs two model calls in a row, one to map the text to an action and one to narrate what the engine decided, and the narration is streamed, so the player reads it as it is written.
Narrating a free-text action locally
- Expected (written before the run)
Narration of what the engine decided comes from the local model, streamed. The main risk is latency: thinking plus reading the prompt.
- Measured
On the CPU, from a free-text action ("Something else...") to the first narrated word took a median of 7.4 s (p90 30.6 s, n = 5, one answered without narration) against a 15 s target. All 5 narration calls were valid, and so were all 22 model calls to it.
Verdict: HELD- So
Latency is the risk that shows: the median meets the target, the slowest call does not.
The same measure on the machine's integrated GPU, with the same weights, gave a median of 2.0 seconds over 37 answers. But 15 of those 37 came back without narration, and the measure includes them, so I do not read 2.0 seconds as narration being fast.
The whole world is generated before play, so that generation never competes with play for the single model. It costs minutes: world generation took a median of 207.7 seconds over two campaigns on the CPU, and 268.9 seconds for the latest campaign, against the five-minute target. Whether the rooms and the NPC are any good is my judgment from playing, and I have played the game through once. My bar is three times, and wanting to play again.
Playing it with no model
This is a short session played with --fallback-only, which starts no model. Every piece of model text in it is therefore a fixed fallback written into the code: the dungeon and room names, the room descriptions, the quest, Odda Flint and his silence. None of it is model output. What the session does show is the engine on its own: the menu of legal actions that code builds, the doors, the initiative order, an enemy that code plays, the d20 rolls and the HP. The seed is 7; the repo's tests show that a seed with the same inputs gives the same session. Lines marked [...] are cut: mostly the map, which the game redraws after every turn, then a few whole turns and one line of the log. The typed input follows each > prompt.
Generating campaign demo (seed 7) [...] Mine Entrance Cold air spills down the adit from the hills. Old rails run into the dark, and a rusted winch leans over the way out. [...] 1) Move… 2) Something else… > 7,18 [...] 1) Move… 2) Open the door at 8,18 3) Something else… > 2 The door opens. [...] Foreman's Shelter A timber-shored niche where someone still keeps a cot, a lamp without oil and a tally of names chalked on the beams. [...] 1) Move… 2) Talk to Odda Flint 3) Something else… > 2 You talk to Odda Flint. Type what you say; /bye to leave. [...] say> Hello. What happened to this mine? Odda Flint says nothing. [...] Combat! Initiative: skeleton-1, you, giant_rat-1. skeleton-1 moves. skeleton-1 hits you. You take 3 piercing damage. Your turn. [...] @ you 9/12 HP · s skeleton-1 (healthy) · x crate · + door · · reachable 1) Move… 2) Attack skeleton-1 (longsword) 3) Attack skeleton-1 (javelin) 4) Dash 5) Disengage 6) Dodge 7) Second Wind 8) End turn > 2 You miss skeleton-1. [...] > /log initiative you: d20 10 +2 = 12 initiative skeleton-1: d20 16 +2 = 18 skeleton-1 -> you shortsword: d20 16 +4 = 20 vs AC 18: hit [...] you -> skeleton-1 longsword: d20 3 +5 = 8 vs AC 13: miss
Odda Flint says nothing because that is the fallback for an NPC reply when there is no model. The skeleton's turn came from code, with no model call. /log prints the last 20 rolls and results the player could see, each roll with its modifier and total, and for an attack the armour class it had to beat and whether it hit. The giant rat stood out of sight, so its initiative roll is not in the log, though the engine rolled it and the event log keeps it. Narration is given the outcome without these numbers.
How I built it
I built it as AI-orchestrated engineering, measured and reviewed. It started as a written design, approved one section at a time, and then a plan for each part, and Claude Code co-authored most of the commits against those plans. The runtime uses only the standard library. There are 495 tests, and they run against a scripted fake model, so they need no model, except a few opt-in ones that use the real one. The lore-tree design was reviewed against the code, then its plan was reviewed, then the finished code twice, and every finding was fixed. The model's answers are trusted nowhere in the game: each one is validated before it counts.
The private repository is called dnd-2d-llm.