How I built VOYAGE — a text-adventure game where a language model narrates the world, tracks your inventory, and runs a story that is different every time you play.
The idea
Text adventures were the first video games. In the 1970s, Colossal Cave
Adventure described a world with prose and waited for you to type
"look", "take the lamp", "go north". The story was hardcoded — a finite map you could eventually exhaust.
Modern LLMs change that. The dungeon master doesn't have to be a script; it can be a model that generates the world on the fly. The player types an action, the model narrates the consequence, updates the game state, and the story branches in directions nobody scripted.
The hard problems aren't the UI. They are:
- How do you keep a persistent, consistent game state (HP, inventory, location) when the model is stateless?
- How do you stop the model from "forgetting" the format as the conversation grows?
- How do you talk to an LLM endpoint from a browser that refuses to send CORS headers?
This post walks through how I solved all three.
The stack
| Concern | Choice |
|---|---|
| Language | TypeScript (strict) |
| UI | React 18 |
| Build | Vite 5 |
| LLM |
chatjimmy.ai/api/chat (Llama 3.1 8B) |
| Persistence | localStorage |
| Production server | Small Node proxy (server.mjs) |
The runtime dependency footprint is tiny — just react and react-dom.
Everything else is the browser, Node's built-ins, and a well-crafted prompt.
Architecture at a glance
The SPA is intentionally "dumb". All the intelligence lives in a single
DM loop that runs every turn.
The DM loop
Every turn follows the same pipeline (in App.tsx):
// 1. Snapshot the current messages + state
// 2. Rebuild the system prompt with the latest STATE block
// 3. Append the player's action (plus a format reminder)
const raw = await callDM(working);
// 4. Parse the machine-readable STATE line out of the reply
const parsed = parseStateReply(raw, workingState);
// 5. Strip the STATE line before rendering; fall back gracefully
const narrative = parsed ? parsed.narrative : raw;
const nextState = parsed ? parsed.state : workingState;
// 6. Detect win/lose
const ending = detectEnding(narrative);
That's it. The whole game is a loop: narrate → wait for input → narrate.
The core trick: state as a machine-readable line
An LLM is stateless — it only sees the tokens you give it. To maintain a
game, the model must carry state through the prompt itself. I call this the
in-prompt state block (the reliable approach for long games, versus just
passing raw history which drifts).
Before each turn, the app injects the current state into the system prompt:
CURRENT STATE:
STATE: HP=100, inventory=[], location=village edge, flags={}
The model is told to reply with narration plus a machine-readable update
line at the very end:
STATE: HP=90, inventory=[iron sword], location=blacksmith's forge, flags={door_open:false}
The app regexes that line out, stores it, and strips it before rendering.
The player never sees it.
const STATE_RE =
/STATE:\s*HP=(\d+),\s*inventory=\[([^\]]*)\],\s*location=(.*?),\s*flags=\{(.*?)\}/i;
export function parseStateReply(reply, fallback) {
const match = reply.match(STATE_RE);
if (!match) return null; // model omitted it → keep last state
const [, hpRaw, invRaw, locRaw, flagsRaw] = match;
const hp = Math.max(0, Math.min(fallback.maxHp, parseInt(hpRaw, 10) || fallback.hp));
const inventory = invRaw.split(",").map(s => s.trim()).filter(Boolean);
const narrative = reply.replace(STATE_RE, "").trim();
return { state: { hp, maxHp: fallback.maxHp, inventory, location: locRaw.trim(), flags: {} }, narrative };
}
Making the model actually do it
Small models (this one is 8B) are notoriously bad at remembering complex
output formats. In early testing it silently dropped the STATE: line on
most turns, which would make the sidebar lie.
The fix was twofold:
- A forceful "OUTPUT FORMAT" section in the system prompt, placed right before the current state.
- A per-turn reminder appended to every player message:
[OOC reminder: after your narration, end your reply with exactly one line:
STATE: HP=<int>, inventory=[comma-separated], location=<string>, flags={key:value}.
Always include it.]
The result was dramatic — STATE-line reliability went from "sometimes" to
5 out of 5 turns in the integration test. This is a great general lesson:
when a model won't follow a format, repeat the requirement at the point of
use, not just in a far-away system prompt.
The CORS problem every LLM SPA hits
The endpoint returns no Access-Control-Allow-Origin header and responds
as text/event-stream. A browser SPA literally cannot fetch it directly —
the browser blocks the request before it even sends.
The clean fix is a same-origin proxy. The app calls a relative
/api/chat; a tiny Node server serves the static build and forwards the
request to the real endpoint, adding CORS headers on the way back:
// server.mjs (abridged)
const upstreamReq = https.request("https://chatjimmy.ai/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json", "Content-Length": body.length },
}, (upstreamRes) => {
res.writeHead(upstreamRes.statusCode || 200, {
"Content-Type": "text/plain; charset=utf-8",
"Access-Control-Allow-Origin": "*",
});
upstreamRes.pipe(res);
});
The same relative path works in development via the Vite proxy:
// vite.config.ts
server: { proxy: { "/api/chat": { target: "https://chatjimmy.ai", changeOrigin: true } } }
The endpoint also appends a <|stats|>…<|/stats|> telemetry footer to every
reply, which is stripped client-side:
export function stripStatsFooter(text) {
return text.replace(/<\|stats\|>[\s\S]*?<\|[^>]*stats\|>/g, "").trim();
}
Persistence: refresh-proof adventures
The entire session — config, state, message history, and the visible log —
is serialized to localStorage on every change and restored on boot:
// storage.ts
export function saveSession(session) {
localStorage.setItem(KEY, JSON.stringify(session));
}
export function loadSession() {
const raw = localStorage.getItem(KEY);
return raw ? JSON.parse(raw) : null;
}
So a player can close the tab, come back hours later, and pick up exactly
where they left off. Long games are kept bounded by trimming history to the
last ~20 messages plus the state block, so token usage doesn't explode.
UI / UX choices
- Genre-flavored theming via CSS variables — fantasy is purple/gold, sci-fi is cyan, horror is red, etc. One variable swap re-themes the app.
- DM narration as styled prose blocks, not chat bubbles — it reads like a book, because it is one.
- Player actions as small italic chips, visually subordinate to the DM.
- Animated HP bar that eases down when the DM deals damage.
- Typing indicator while the model "thinks".
- Mobile-friendly: input pinned to the bottom, history scrolls.
What I learned
- Prompt engineering is the game engine. The "code" of this app is mostly a well-structured system prompt plus a robust parser. Getting the format right and reinforcing it at the point of use mattered more than any UI detail.
- Graceful degradation beats strict parsing. When the model omits the state line, keep the last state and move on. The game should never crash because an 8B model got lazy.
- Same-origin proxies are the universal CORS escape hatch. If an LLM API won't set CORS headers, don't fight it — proxy it.
- A tiny dependency footprint is a feature. Two runtime deps and Node's built-ins made this trivial to run anywhere.
Try it & contribute
git clone https://github.com/harishkotra/voyage.git
cd voyage
npm install
npm run dev # http://localhost:5173
# or
npm run build && npm run serve # http://localhost:4173
The repo includes an integration test (scripts/dmtest.ts) and a
headless-browser smoke test (scripts/smoke.mjs) so contributors can verify
the live loop without guessing.
Good first contributions: multiple model support, true token streaming,
multiple save slots, achievements, custom genres, a rendered world map.
Code & more: https://www.dailybuild.xyz/project/246-voyage


Top comments (0)