Most "AI games" phone home. Every turn is an API round-trip, every player burns your tokens, and the whole thing dies the day the bill scares you. I wanted the opposite: a text roguelike where the dungeon master is an LLM that runs entirely in the player's browser — no server, no API key, no per-token cost, and it keeps working offline after the first load.
Here's the architecture and the one bug that taught me the most.
The core trick: WebLLM + WebGPU
WebLLM compiles quantized models to WebGPU, so inference runs on the player's GPU. There is no backend at all.
const cdn = "https://esm.run/@mlc-ai/web-llm";
const webllm = await import(/* webpackIgnore: true */ cdn);
const engine = await webllm.CreateMLCEngine(MODEL_ID, {
initProgressCallback: p => setLoading(p.text),
});
First load pulls the weights once (the browser caches them). After that every turn is local and free.
Let the model narrate — never let it adjudicate
A dungeon master should be creative, but it must not be allowed to break the rules. The split that made it stable:
- Narrative comes back as free prose. Let it cook.
- Mechanics — HP delta, items, whether the run ends — come back as a small JSON object the engine validates. The game loop trusts the JSON, not the prose.
My most instructive bug: early on I let the prose drive death detection (regex for "you die"), and the model cheerfully killed players on turn one with pure flavor text — "this could be the end of you" → game over. Moving death to an integer the engine owns (if (hp <= 0)) fixed it instantly.
Rule of thumb: the LLM writes the story; your code keeps the score.
Why on-device is the right default for indie games
- $0 marginal cost — 10 players or 10,000, the server bill is identical: nothing.
- Privacy — choices never leave the device.
- Offline — runs on a plane after first load.
- No rate limits, no leaked keys.
The tradeoff is model size: you run something small enough to load in a tab, so prompt design carries real weight. For a narrative game master that's a fair trade.
One engine, many games (config beats code)
Genre is just a config object — palette, HUD labels, seed scenarios, system prompt. Same engine, swap the config, ship a different game. Adding a genre is data, not a code change, which means a generator can author new ones.
If you want to poke at a live one, the cyberpunk build (NeonHeist) and a few others are up under Games at bestpaid.app — all running on-device.
Happy to go deeper on the JSON-contract prompt or the WebGPU loading UX in the comments.
Top comments (0)