Building an AI text game engine: rules own the truth, the model tells the story
The tool stack behind an AI-narrated text game: a deterministic rules engine, an LLM interpreter and narrator, schema-validated structured output, memory and save files, and scripted playthroughs and evals to keep the model honest.
8 October 2026 · 12 min read
The first text game most people build with a language model is a single prompt: “You are a dungeon master. The player is in a cave.” It is impressive for ten minutes. Then the player picks up a sword they already dropped, walks through a door that was locked, and talks to a character who died two scenes ago. Nothing is wrong with the model’s prose. The problem is that the model is being asked to do two jobs at once: tell the story and remember the facts. It is good at the first and unreliable at the second.
The fix is an architecture rather than a better prompt, and it is the foundation of a reliable AI text game. In this design, a small deterministic engine owns every fact about the world, and the model is confined to two narrow roles: turning what the player typed into proposed actions, and turning what actually happened into prose. This article walks through that stack piece by piece, vendor-neutral, with a TypeScript core you can run.
The split: rules own the truth, the model owns the words
Text adventures have always had a parser, a world model and a printer. Infocom’s games, Inform, and modern narrative tools such as ink and Twine all separate the state of the world from the text that describes it. AI text games do not change that structure; they replace the rigid parser and the hand-written printer with a model, and keep the world model. Designing one is mostly deciding where that boundary sits.
The turn loop looks like this:
- Interpret. The player types free text. A model call converts it into one or more proposed actions in a fixed JSON shape.
- Validate. The application checks the JSON against a schema. Invalid output is rejected or retried; it never reaches the game.
- Resolve. A pure function, the reducer, applies each action to the current state under the game’s rules. It returns a new state and a list of events: things that actually happened, including refusals.
- Narrate. A second model call receives the events and the player-visible state, and writes the prose.
- Persist. The action and its events are appended to a log; the state is saved.
The important property is that the model never writes to state. It can propose that the player opens a door; only the reducer decides whether the door opens. When the narrator writes “the door swings open”, that is because an unlocked event exists, not because the story felt like it.
| Responsibility | Owner | Why |
|---|---|---|
| Inventory, locations, flags, health, time | Rules engine | Must be consistent and saveable |
| Which actions are legal | Rules engine | Must be testable and fair |
| Mapping free text to actions | Model, validated by schema | Language understanding is the model’s strength |
| Descriptions, dialogue, tone | Model | Variety and voice |
| Randomness | Engine with a seeded generator | Reproducible bugs and replays |
The rules engine: state, actions and a reducer
The engine is plain code with no model calls, written as a reducer: (state, action) => { state, events }. Reducers are easy to test, easy to replay from a log, and make it obvious where every change comes from.
State should be boring data: rooms, exits, items, character records, flags, a turn counter, a random seed. No prose. If a fact matters to play, it is a field; if it is only colour, the narrator can invent it each time.
Larger games add a state machine above the reducer for phases or scenes: exploration, conversation, combat, a cutscene. Each phase permits a different set of actions. That is a cheap and effective guard; during a conversation, a move action can simply be outside the schema the interpreter is given.
Rules are where design lives. “Can the player use the key on the door?” is a rule. Keep rules in data or small functions rather than in prompts. A rule stated in a prompt is a suggestion; a rule in the reducer is a fact.
Structured output: ask for intents, validate everything
The interpreter’s output is the riskiest boundary in the system, because it is model output that drives code. Three layers of defence work well together.
Constrain generation where you can. Most hosted model APIs now offer some form of structured output, either JSON mode, schema-constrained output or tool and function calling, where you supply a JSON Schema and the model’s output is shaped to fit it. For local models, inference servers such as llama.cpp support grammar-constrained decoding, and libraries such as Outlines do the same for other runtimes. These reduce malformed output a great deal, but the guarantees vary by provider and model, so treat them as a filter, not a proof.
Validate in your own code, always. Whatever the provider promises, parse and validate the result yourself. In TypeScript, use Zod: the same schema gives you runtime validation, static types and, in Zod 4, a JSON Schema via z.toJSONSchema() to hand to the provider. One source of truth for the shape of an action.
Retry with the error, then fall back. When validation fails, send the validation error back to the model once and ask for a corrected response. If it fails again, ask the player to rephrase. Do not loop indefinitely, and log every failure; the failure rate is one of your most useful metrics.
Here is the core of an engine: a Zod schema for actions, the state and event types, a reducer that enforces the rules, and a turn function that validates model output before folding it through the reducer. It needs only zod (version 4) and runs as-is with a TypeScript runner such as tsx.
import { z } from "zod";
// 1. What the model may propose. Anything else is rejected before it touches state.
const Action = z.discriminatedUnion("type", [
z.object({ type: z.literal("move"), to: z.string() }),
z.object({ type: z.literal("take"), item: z.string() }),
z.object({ type: z.literal("use"), item: z.string(), on: z.string() }),
z.object({ type: z.literal("talk"), npc: z.string(), topic: z.string().max(80) }),
]);
type Action = z.infer<typeof Action>;
const Turn = z.object({ actions: z.array(Action).min(1).max(3) });
type Turn = z.infer<typeof Turn>;
// 2. The state the engine owns. The model never writes to this directly.
interface Room { exits: string[]; items: string[]; npcs: string[] }
interface GameState {
turn: number;
location: string;
inventory: string[];
rooms: Record<string, Room>;
flags: Record<string, boolean>;
}
// 3. Events are facts the narrator is allowed to describe.
type GameEvent =
| { kind: "moved"; from: string; to: string }
| { kind: "took"; item: string }
| { kind: "unlocked"; what: string }
| { kind: "talked"; npc: string; topic: string }
| { kind: "rejected"; action: Action; reason: string };
type Result = { state: GameState; events: GameEvent[] };
const reject = (state: GameState, action: Action, reason: string): Result =>
({ state, events: [{ kind: "rejected", action, reason }] });
// 4. The reducer: pure, deterministic, and the only place rules live.
function apply(state: GameState, action: Action): Result {
const room = state.rooms[state.location];
switch (action.type) {
case "move": {
if (!room.exits.includes(action.to)) return reject(state, action, `no way to ${action.to} from here`);
return { state: { ...state, location: action.to },
events: [{ kind: "moved", from: state.location, to: action.to }] };
}
case "take": {
if (!room.items.includes(action.item)) return reject(state, action, `${action.item} is not here`);
const rooms = { ...state.rooms,
[state.location]: { ...room, items: room.items.filter((i) => i !== action.item) } };
return { state: { ...state, rooms, inventory: [...state.inventory, action.item] },
events: [{ kind: "took", item: action.item }] };
}
case "use": {
if (!state.inventory.includes(action.item)) return reject(state, action, `you do not have ${action.item}`);
if (action.item === "brass key" && action.on === "cellar door" && state.location === "kitchen"
&& !state.flags.cellarOpen) {
const rooms = { ...state.rooms, kitchen: { ...room, exits: [...room.exits, "cellar"] } };
return { state: { ...state, rooms, flags: { ...state.flags, cellarOpen: true } },
events: [{ kind: "unlocked", what: "cellar door" }] };
}
return reject(state, action, "nothing happens");
}
case "talk": {
if (!room.npcs.includes(action.npc)) return reject(state, action, `${action.npc} is not here`);
return { state, events: [{ kind: "talked", npc: action.npc, topic: action.topic }] };
}
default: {
const unreachable: never = action;
throw new Error(`unhandled action ${JSON.stringify(unreachable)}`);
}
}
}
// 5. Validate the model's raw text, then fold the actions through the reducer.
function parseTurn(raw: string): { ok: true; turn: Turn } | { ok: false; error: string } {
let json: unknown;
try { json = JSON.parse(raw); } catch { return { ok: false, error: "response was not valid JSON" }; }
const parsed = Turn.safeParse(json);
return parsed.success ? { ok: true, turn: parsed.data } : { ok: false, error: z.prettifyError(parsed.error) };
}
function runTurn(state: GameState, turn: Turn): Result {
let current = state;
const events: GameEvent[] = [];
for (const action of turn.actions) {
const result = apply(current, action);
current = result.state;
events.push(...result.events);
if (result.events.some((e) => e.kind === "rejected")) break; // stop at the first refusal
}
return { state: { ...current, turn: current.turn + 1 }, events };
}
// Example: a model response for "grab the key and open the cellar".
const start: GameState = {
turn: 0, location: "kitchen", inventory: [], flags: {},
rooms: {
kitchen: { exits: ["hall"], items: ["brass key"], npcs: [] },
hall: { exits: ["kitchen"], items: [], npcs: ["housekeeper"] },
cellar: { exits: ["kitchen"], items: ["lantern"], npcs: [] },
},
};
const raw = '{"actions":[{"type":"take","item":"brass key"},{"type":"use","item":"brass key","on":"cellar door"},{"type":"move","to":"cellar"}]}';
const parsed = parseTurn(raw);
if (parsed.ok) {
const { state, events } = runTurn(start, parsed.turn);
console.log(state.location, state.inventory, events);
} else {
console.log("Retry with:", parsed.error);
}
Running it prints the player in the cellar holding the key, with three events: took, unlocked, moved. If you add a fourth action, or an action type the schema does not know, parseTurn returns a readable error such as “Too big: expected array to have <=3 items”, which is exactly what you send back to the model on a retry.
Two details are worth copying. The never check in the default branch means adding a new action type to the schema without handling it is a compile error. And rejections are events, not exceptions: “you do not have the brass key” is a legitimate outcome the narrator should describe, often in character.
The narrator: turning events into prose
The narrator call receives three things: the events from this turn, the player-visible slice of state (current room, visible items, characters present, inventory), and a short style brief. It does not receive hidden state, the full log or the rules.
That restriction is deliberate. A narrator that can see a hidden trapdoor will eventually mention it. A narrator that is told only “event: took brass key; room: kitchen; exits: hall, cellar” cannot leak what it does not know, and has very little room to contradict the engine.
Give the narrator two firm instructions: describe every event in the list, and do not state any change to the world that is not in the list. It may embellish freely (the smell of the kitchen, the weight of the key) as long as the embellishment is not something the player could later act on. If a detail becomes actionable, it belongs in state. The same event list can drive other generators too: an unlocked event is as good a cue for a generative audio sting as it is for a sentence of prose.
For dialogue, the same split applies at a smaller scale. The engine decides what a character knows and what they are willing to reveal on a given topic; the model decides how they say it. The broader questions of character memory and goals are their own subject, covered in the piece on LLM-powered NPCs; here the point is only that a character’s knowledge is data the engine controls.
Memory, summaries and save files
Context windows are large now, but sending the whole history every turn is still slow, expensive and counterproductive: the model gives too much weight to old, irrelevant text. Text games need three distinct kinds of memory.
- State is the truth: everything the reducer reads. It is small, structured and always sent in its player-visible form.
- The event log is the history: every validated action and the events it produced, append-only. It is the source for replays, debugging and saves.
- The narrative summary is a compressed account of what has happened, written by a model every so many turns and used only to keep the narrator’s tone and references consistent. It is never used to decide what is true.
If the summary and the state disagree, the state wins, and the summary is regenerated.
Save files follow directly. A save is a format version, the random seed, the initial state identifier and the event log, optionally with a snapshot of current state to make loading fast. Because the reducer is deterministic, replaying the log from the initial state reproduces the current state exactly, and a mismatch between replay and snapshot is a bug detector you get for free. Never store model prose as the source of truth in a save; store the actions, and regenerate prose if you need it.
Testing: scripted playthroughs and evals
The split makes testing tractable, because most of the system no longer involves a model.
Unit-test the reducer. It is a pure function. Given this state and this action, assert these events. Cover every rule and every rejection path. These tests run in milliseconds and catch most regressions.
Scripted playthroughs. Write a sequence of actions that completes the game, or a chapter of it, and assert key checkpoints. Run it against the reducer directly, with no model involved. This is the text-game equivalent of a speedrun route and tells you immediately when a rule change has made the game unwinnable.
Interpreter evals. Build a fixture set of player inputs and the actions you expect: “grab the key” maps to take brass key; “go downstairs” maps to move cellar when the cellar is open; “dance” maps to nothing valid. Run the interpreter against it whenever you change model, prompt or schema, and track the pass rate and the schema-failure rate. A small set of a hundred or so cases, grown from real player inputs, is far more useful than none.
Narrator checks. Prose is harder to test, but contradictions are not. A cheap automated check compares the narration with the event list: does it mention an item the player does not have, or an exit that does not exist? A second model can act as a judge for tone and faithfulness on a sample of turns. Treat judge scores as a trend, not a verdict.
Replays from real sessions. Because every session is a seed plus a log, a bug report can be a save file. Load it, replay it, and the bug happens again. That alone justifies the architecture.
Takeaways
- Do not ask the model to remember facts. In an AI text game, a deterministic rules engine owns state; the model interprets input and narrates outcomes.
- Make the reducer the only place rules live: pure, testable, and fed only validated actions. Treat refusals as events the narrator describes.
- Constrain model output with whatever structured-output feature your provider or runtime offers, but always validate in your own code against one schema, and retry once with the error.
- Give the narrator events and player-visible state only, and forbid it from stating changes that are not in the event list.
- Keep state, an append-only event log and a narrative summary as separate kinds of memory. Saves are seed plus log; the state wins every disagreement.
- Test the engine with unit tests and scripted playthroughs, the interpreter with input-to-action evals, and the narrator with contradiction checks against events.
