A technical deep-dive into building a stateful, streaming LLM game with React, TypeScript, and one very non-standard chat API.
Most "AI games" demos are a chat window with a system prompt. That gets
you a conversation, not a game. The hard part of an AI dungeon master
isn't narration — models are great at narration. The hard part is
state: if the LLM is the whole game engine, something has to remember
that you picked up the rusty key three turns ago, that the door is now
open, and that the warg bite took you to 60 HP — and that something has
to be reliable.
This post walks through how I built The Dungeon Master, a text
adventure where a llama-3.1-8B model narrates, tracks HP/inventory/
location/flags, and drives the story to a win or a death — and the three
engineering problems at the heart of it:
- Talking to an API that lies about its content type (streaming)
- Getting structured data out of a model that's writing prose (state)
- Making the UI feel like a game, not a chat log (design)
The stack
Deliberately boring, deliberately small:
-
React 19 + TypeScript (strict) — hooks only. This app has exactly
one piece of complex state (the turn loop), and
useReducerfelt like ceremony for it. - Vite 7 — dev server, bundler, and secretly the most important player: the CORS fix (more on that below).
-
Zero other runtime dependencies. No UI kit, no markdown renderer,
no state library. The entire theme is one hand-rolled stylesheet using
@propertyandcolor-mix().
The whole app is ~1,400 lines including CSS. Small is a feature: it means
every line is load-bearing and readable in one sitting.
Architecture
┌────────────────────────── Browser ──────────────────────────┐
│ │
│ StartScreen ──▶ App (turn loop) ──▶ GameOverScreen │
│ │ │
│ ┌────────────┼─────────────┐ │
│ ▼ ▼ ▼ │
│ Sidebar History log Composer (action / hint) │
│ │
│ src/lib/game.ts src/lib/chatClient.ts │
│ · genre defs · streamChat() │
│ · systemPrompt() · stripStats() │
│ · splitDMReply() · listModels() │
│ · trimForApi() │
│ │ │ │
│ └────── localStorage ◄───┘ │
│ (dm.save.v1) │
└──────────────────────────┬──────────────────────────────────┘
│ POST /api/chat (same-origin)
▼
┌───────────────────── Vite dev/preview ──────────────────────┐
│ proxy: /api ──▶ https://chatjimmy.ai │
└──────────────────────────┬──────────────────────────────────┘
│
▼
┌──────────────────── chatjimmy.ai ───────────────────────────┐
│ POST /api/chat → plain-text chunked stream, terminated │
│ by a <|stats|>{…}<|/stats|> sentinel │
│ GET /api/models → { data: [{ id: "llama3.1-8B" }] } │
└─────────────────────────────────────────────────────────────┘
Two modules do all the real work: chatClient.ts (transport) and
game.ts (domain). Everything else is presentation.
Problem 1: An API that lies about its content type
The endpoint is POST https://chatjimmy.ai/api/chat. There was no
client library — just a hint that the endpoint existed. So I probed it
with curl, and the first three attempts all returned:
{"success":false,"error":"Selected model is required"}
?model=, model, modelId, selectedModel — all rejected. The site
itself is a Next.js app, so I pulled its JS bundles and grepped them for
api/chat. That revealed the real shape:
{
"chatOptions": { "selectedModel": "llama3.1-8B", "systemPrompt": "", "topK": 8 },
"messages": [{ "role": "user", "content": "hi" }],
"stream": true
}
And with that, the model answered. But the response was weirder than
expected. The headers say:
content-type: text/event-stream; charset=utf-8
…which made me expect SSE framing: data: {"delta": …} lines. Instead
the body is raw text chunks concatenated in order, terminated by a
sentinel:
You push open the door.<|stats|>{"done":true,"decode_tokens":40,…}<|/stats|>
So the client is a manual read loop, not an EventSource. The trick is
to strip the sentinel on every frame, because the stats blob arrives
mid-stream and would otherwise flicker into the UI:
const STATS_SENTINEL = '<|stats|>'
export function stripStats(text: string): string {
const i = text.indexOf(STATS_SENTINEL)
return i === -1 ? text : text.slice(0, i)
}
export async function streamChat({ messages, model, onDelta, signal }: StreamChatOptions) {
const res = await fetch(`${API_BASE}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chatOptions: { selectedModel: model }, messages, stream: true }),
signal,
})
if (!res.ok) return parseError(res)
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let raw = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
raw += decoder.decode(value, { stream: true })
onDelta?.(stripStats(raw)) // full-so-far, sentinel already removed
}
return stripStats(raw + decoder.decode())
}
The CORS twist: the API sends no CORS headers at all — no
Access-Control-Allow-Origin on the preflight, nothing. A browser
calling it cross-origin is dead on arrival. The clean fix is to make the
call same-origin and put the cross-origin hop on the server side:
// vite.config.ts
const proxy = {
'/api': {
target: 'https://chatjimmy.ai',
changeOrigin: true,
secure: true,
},
}
export default defineConfig({
plugins: [react()],
server: { proxy, port: 5173 },
preview: { proxy, port: 4173 },
})
The browser talks to localhost:5173/api/chat; Vite forwards it. The
same config covers dev and the production preview, and
VITE_CHAT_API_BASE exists as an escape hatch if you self-host a proxy
elsewhere.
Problem 2: Structured state from a prose machine
This is the core of the whole build. The approach — the one that actually
holds up — is state-in, state-out:
State-in. Before every DM turn, the system prompt carries the current
state as a compact line:
CURRENT STATE: HP=80, inventory=[rusty key, torch], location=cave entrance, flags={door_open:false}
State-out. The system prompt requires the model to end every reply
with the same format:
STATE: HP=75, inventory=[rusty key, torch, sword], location=goblin camp, flags={door_open:true}
The prompt does double duty as game rules:
export function systemPrompt(genre: GenreId, difficulty: Difficulty, state: GameState): string {
const g = GENRES.find((x) => x.id === genre)!
return [
'You are the Dungeon Master of a text adventure game. Narrate the world in vivid second-person prose, 1-3 short paragraphs per turn. Always end your narration with an implicit prompt for the player\'s next action.',
'',
`SETTING: ${g.setting}`,
`WIN CONDITION: ${g.winCondition}`,
`GENRE: ${g.label.toLowerCase()}. DIFFICULTY: ${difficulty} (${DIFFICULTIES.find((x) => x.id === difficulty)!.detail}).`,
'',
'RULES:',
'- Track and evolve the game state every turn. State format (always include on your turn, on its own line at the END of your reply):',
' STATE: HP=<int>, inventory=[comma-separated], location=<string>, flags={key:value}',
'- HP starts at 100. Combat, traps, and hazards reduce it. Reaching 0 HP means the player dies — narrate a dramatic death and say "GAME OVER".',
// …
`CURRENT STATE: HP=${state.hp}, inventory=[${state.inventory.join(', ')}], location=${state.location}, flags={${serializeFlags(state.flags)}}`,
].join('\n')
}
Each genre carries its own setting seed and — critically — a concrete
win condition. "Have fun" produces games that never end; "recover the
lost crown of the elder kings and escape the mountain with it" produces
games that do.
Parsing it defensively
The parser assumes the model will try to comply but might fumble the
format, so every field is optional and independently matched:
export function parseStateLine(line: string): GameState | null {
const hpMatch = line.match(/HP\s*=\s*(-?\d+)/i)
const invMatch = line.match(/inventory\s*=\s*\[([^\]]*)\]/i)
const locMatch = line.match(/location\s*=\s*([^,}\n]+)/i)
const flagMatch = line.match(/flags\s*=\s*\{([^}]*)\}/i)
if (!hpMatch && !invMatch && !locMatch) return null
// …build the GameState, clamping HP to 0–100, tolerating missing fields
}
And splitDMReply() takes the last STATE: line — the model
sometimes restates state mid-narration, and the latest one wins — strips
it from the prose, and detects the ending markers:
export function splitDMReply(raw: string): SplitReply {
const matches = [...raw.matchAll(/^[ \t*>]*STATE\s*:[^\n]*/gim)]
let narration = raw
let nextState: GameState | null = null
if (matches.length > 0) {
const last = matches[matches.length - 1]
nextState = parseStateLine(last[0])
narration = (raw.slice(0, last.index) + raw.slice(last.index + last[0].length)).trim()
}
let outcome: Outcome | null = null
if (/\bYOU\s+WIN\b/i.test(narration)) outcome = 'win'
else if (/\bGAME\s+OVER\b/i.test(narration)) outcome = 'lose'
else if (nextState && nextState.hp <= 0) outcome = 'lose'
return { narration, nextState, outcome }
}
The failure story is the important part:
| Model behavior | App behavior |
|---|---|
Omits the STATE: line |
Keep previous state, render the narration, game continues |
Emits two STATE: lines |
Last one wins |
| Emits garbage in a field | Field falls back to its previous/default value |
| HP drifts above 100 or below 0 | Clamped to 0–100 |
Says GAME OVER mid-story |
Ending screen, run summary, Play Again |
Why not function calling or JSON mode? Because the endpoint doesn't
expose either — it's a single fixed model behind a fixed route. The
regex protocol is the honest answer to the constraints, and it's more
robust than it sounds: the format is short, linear, and the model is
reminded of it every single turn.
Long games: trimming history
Option B ("just send everything") drifts and eventually overflows. The
compromise: keep the opening scene (it establishes the world) plus the
last ~20 messages, and lean on the injected state block to carry
everything older:
export function trimForApi(messages: ChatMessage[], keep = 20): ChatMessage[] {
if (messages.length <= keep) return messages
return [messages[0], ...messages.slice(messages.length - keep + 1)]
}
Problem 3: Making it feel like a game
Three choices did most of the work:
1. Narration ≠ chat bubbles. The DM's prose renders as bordered
prose blocks with a left accent rule and a small "wax seal" diamond —
book-like, not messenger-like. Player actions are italic mono chips on
the right. The visual asymmetry is the game grammar.
2. Type encodes the fiction. Three typefaces with three jobs:
Cinzel (engraved roman caps) for ritual moments — title, buttons, GAME
OVER; EB Garamond for the narration, like a book being read aloud;
JetBrains Mono for the machine side — stats, chips, state values. Serif
for the human voice, mono for the engine.
3. The room has a light source. A radial "candle glow" sits at the
top of the screen and takes the genre's accent color — fantasy burns
violet-gold, sci-fi burns cyan, horror burns red. Switching genres
cross-fades the whole room, because the accent is registered as a real
color:
@property --accent {
syntax: '<color>';
inherits: true;
initial-value: #c9a962;
}
.app {
transition: --accent 700ms ease; /* the glow literally fades */
}
Without @property, custom properties are strings and the transition
snaps. With it, the browser interpolates the color — one line of CSS for
a full-theme cross-fade.
Everything else is derived from the single accent with color-mix():
.action-chip {
background: color-mix(in srgb, var(--accent) 13%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent);
}
Plus the hygiene floor: every animation is gated behind
prefers-reduced-motion, focus rings use the accent, the HP gauge
animates width with a color ramp (green → amber → red), and the layout
collapses to a single column under 760px.
The turn loop
All of it comes together in one async function in App.tsx:
const full = await streamChat({
messages: [
{ role: 'system', content: systemPrompt(g, d, baseState) },
...trimForApi(withUser.slice(0, -1)),
],
signal: controller.signal,
onDelta: patchLast, // streams into the last message
})
const { narration, nextState, outcome } = splitDMReply(full)
patchLast(narration) // replace raw text with clean prose
if (nextState) setState(nextState) // sidebar updates
if (detected) setOutcome(detected) // maybe show the ending screen
Note the optimistic message append: the user's action and an empty
assistant bubble go into state before the fetch, so the UI is
instantly responsive — and on network failure the turn rolls back,
restoring the player's action to the input so nothing is lost.
Persistence is one effect: the entire save (genre, difficulty, messages,
state, turn, outcome) serializes to localStorage under a versioned key
(dm.save.v1), and a matching effect restores it on load. Refresh
mid-dungeon and you're still mid-dungeon.
What I'd build next
-
Dice mechanics — a
ROLL: d20+2=14line parsed the same way asSTATE:, gating risky actions on real randomness. -
The model picker —
listModels()is already in the client and unused; a dropdown is ten minutes of work. -
An ASCII map panel — same protocol, third line:
MAP:. -
Unit tests for the parser —
game.tsis pure functions; the highest-value first test is a table of malformedSTATE:lines.
The meta-lesson
The model is not the game engine. The model is a very talented
narrator who can't be trusted with bookkeeping. Once you accept that,
the architecture writes itself: a strict text protocol for state, a
defensive parser that assumes good-faith failure, app-side clamping and
fallbacks everywhere, and the model free to do the thing it's actually
good at — making you believe, for a few turns, that there's a dungeon
down there.
Code & more: https://www.dailybuild.xyz/project/247-the-dungeon-master



Top comments (0)