I built an AI narrator into Project Zomboid. Every lie it told was mine.

Over the last two days I built an AI narrator into Project Zomboid. You press F7, the game freezes, a 1993 handheld opens on screen, and a language model writes the next page of your story — live, from the actual state of your save. Not a summary. A page of prose about the person you are playing, in the room you are standing in, about the thing that just happened to you.

It works. It is on GitHub, public domain, and you can play it tonight. But the interesting part of these two days was not getting it to write. It was watching it lie, and discovering — every single time — that the lie was mine.

The idea

I wanted something like The Neverending Story. Three roles, and they never swap:

The player is the reader of the book. The narrator is the voice telling the reader about the story. The game is what cannot be changed about the world.

Everything else follows from that. The game is fact — the narrator may not invent an object, a room, a person or an item that the game did not put there. The player is intent — anything I write in the device’s notebook is an instruction and it is obeyed without argument. And the narrator is voice only: it may invent motive, memory, mood and weather of the mind, but it may never invent world, and it never tells me what to do.

That third rule is the one that makes it a story rather than a hint system. A page ends on something the survivor has realised they want, left undone. Never advice.

The device

The first idea was handwriting on a notepad. It was unreadable. So the mod ships a fictional piece of period hardware instead: the Premium Tech. Pilot 3000, a slate-grey PDA with a reflective LCD, in the house colours of the CRT televisions you find lying around Knox County. It has a to-do list, a notebook, a settings page and an archive of every page it has ever written for you.

It is drawn entirely from primitives — no textures, no art files, no assets to keep in sync — so it scales cleanly from a laptop to a 4K monitor and the type stays on an exact character grid at every zoom step. There is a green power button on the case, because switching a machine off should not live on the screen it is switching off.

Under it, the mod is Java rather than Lua, running through ZombieBuddy. That costs a Steam launch option, but it buys a state reader that can look at anything in the game and a model request that runs on a background thread — so the game never stutters while a page is being written.

Broken design of UI

Then it started lying

Page two. My survivor Ryan had walked upstairs into a bathroom. The narrator wrote this:

The mirror above the sink reflected a face he barely recognised, pale under the harsh bathroom light, with dark circles already forming under his eyes. He reached out and turned the cold tap.

Four lies in two sentences. There is no mirror — the game never reported one. He is not tired; he had been awake fourteen minutes. And I never touched a tap. The narrator had reached into the world and moved my character’s hands.

My instinct was to blame the model and tighten the prompt. That instinct was wrong, and it stayed wrong for two days. Here is what had actually happened.

To stop the narrator inventing movement, I compare the survivor’s position against the last page and, if they have not moved, the prompt says so in capital letters. Ryan had gone up a flight of stairs: two tiles sideways, one floor up. My check measured flat distance. Two tiles is under the threshold. So the same request contained both of these:

change block: "They are now in a different room: bathroom."
write block:  "THE SURVIVOR HAS NOT MOVED since the last page.
               They are standing where they were."

Handed two facts that cannot both be true, the model split the difference. It accepted the new room, then obeyed “hasn’t moved” by keeping him in one spot and having him do small things there. Turn a tap. Splash his face. That is not a writing failure. That is a model reconciling a contradiction I wrote.

The same pattern kept repeating, and it became the lesson of the whole project.

  • I picked up a bum bag with a stranger’s wallet, comb and house key already inside it. The narrator wrote him packing those things into it. Because my code flattened the inventory diff into one list — eight items appeared at once, so of course it wrote him gathering them. It could not tell one full bag arriving from eight objects being picked up.
  • A refrigerator followed him upstairs. He was in a room called livingroom on the ground floor, then a different room also called livingroom one floor up. Nothing told the narrator they were different rooms, so it brought the fridge along — and then repeated it on the next page, because by then its own earlier page was in the history saying there was a fridge. An invention that survives one page becomes evidence for the next.
  • A tree branch ended up “across his knees”. The state said the branch was in carrying, which means somewhere on his person — a pocket, a bag. The narrator read that as “in his hands”. Reasonable. The field was badly named.
First tests of UI and content

The audit

Halfway through I stopped writing features and audited what the narrator was actually being told, checking every claim against the game’s own files rather than my memory of them — javap against the game jar, the sandbox presets, the map folder, the radio data.

It was thinner than it looked. Three whole subjects were not in the prompt at all. The best single finding: the game already decides what is worth noticing, and I was throwing it away. Project Zomboid has moodles — twenty-six named states with severity tiers, in the player’s own vocabulary. I was sending the model raw numbers like stress: 0.41 and making it invent a threshold. The game will simply say “Stressed, level 2”. Nine of those twenty-six were invisible to every number I read, including wet, hypothermia and windchill — exactly the conditions my own prompt told the narrator to dramatise.

My favourite bug of the two days also came out of that audit. I had added a reading for whether the mains power was still on, and on the very first morning of a fresh save it reported the power dead. It is seven in the morning on day one. The grid does not fail for a fortnight.

I had used IsoGridSquare.haveElectricity(). That does not mean “is the mains on here”. It means “is a generator running on this square”. The game’s own vehicle menu gave it away by using both in one condition:

(AllowExteriorGenerator and square:haveElectricity())
    or square:hasGridPower()

hasGridPower() is the mains. Caught before it wrote a single page about a dead refrigerator in a house with the lights still on — which would have been the mod inventing world, and my fault, not the model’s.

Two more from the same pass. A house alarm went off and the narrator could not hear it — the loudest, most consequential thing in the early game, completely invisible. It turned out every active sound in the world is sitting in WorldSoundManager with a position, a radius and a repeating flag, which is precisely what an alarm is. And when I found a bus and sat in the driver’s seat, the page had me standing on a road turning the key over in my hand. The vehicle was in the state — as "Base.fhqB10M_Riv", buried under co-ordinates, with nothing saying I was inside it.

First time we get text on the screen

A list needs three answers

Not every problem was a bug. The device has a to-do list — the narrator proposes things, and I own the list. Ticking an item teaches it “that mattered”. Striking it out teaches it “never offer this again”.

Except the thought I actually had, over and over, was neither of those. It was good idea, wrong hour. With two gestures that thought had nowhere to go, so I was striking out things I genuinely wanted to do later — and teaching the story to never mention them again.

So there is a third gesture now. Shelving an item keeps it in the book and teaches the narrator nothing about my taste; it only says “not this hour”. Small change, and the one I am most pleased with, because it came from using the thing rather than designing it.

Where it landed

Fourteen builds in two days. This is a page from near the end — I am sitting in a Volvo B10M with almost no fuel, a crowd in the yard, and something dragging itself past the door:

Ryan sits behind the wheel of the Volvo, both hands on it though nothing turns, the key already in the ignition where his fingers left it. Through the windshield the yard is full of them.

The noise is close, closer than the glass should allow, a wet dragging sound from somewhere just past the door. He does not look for its source.

The gauge needle sits low, closer to empty than he wants to read it. The engine has not caught yet. He needs this thing to start before the noise outside becomes hands on the doors.

Every detail there is real. The make of the bus, the key in the ignition, the fuel level, the crowd, the sound — all read out of the running game. He does not look for its source is the narrator obeying a rule about not inventing what it cannot see. And nothing in it happened that I did not do myself.

Take it

The whole thing is on GitHub: github.com/managementboy/pzstory-narrator — source, the built mod, and a development folder with the full audit and notes on the traps.

It is released under CC0 — public domain, as far as German law allows an author to give their rights away. No copyright is claimed. Fork it, ship it inside your own mod, rename it, sell it, credit nobody. It works with Claude, Gemini, and anything speaking the OpenAI API, including a local model through Ollama if you would rather nothing left your machine.

Two honest warnings, both in the README. It sends a snapshot of your game state to whichever provider you configure — that is the entire mechanism. And it costs money on a paid provider. There is no warranty of any kind; it is a hobby project written over two days.

I built it with Claude as a pair. That is worth saying plainly, and it is also where the lesson lands. The model wrote the code and the model wrote the prose, and nearly every time the prose came out wrong the cause was upstream — a field with a misleading name, two facts that contradicted each other, a sense the state simply did not have. The narrator was never lying. It was answering honestly from what it had been given, and what it had been given was wrong.


Vorheriger Beitrag