Imagine playing a 3D WebGL game in your browser, hitting an EDIT button, typing "make the camera lower and double the player's thrust", and watching the running game smoothly update in real time — without a page refresh, without a bundler, and without writing a single line to disk until you're ready to commit.
Here is a look under the hood at how we built an in-browser AI game development loop using Native ES Modules, Blob URL graph rewriting, IndexedDB virtual overlays, and client-side LLM tool execution.
🎯 The Three Hard Problems
Building an AI-assisted live editing loop inside a browser game sounds straightforward until you hit the browser's lower-level constraints:
-
Native ES Module Caching: Modern browsers permanently cache ESM imports by resolved URL. Adding cache-busting queries (
./main.js?v=2) only updates the entry point; its nested imports (./physics.js,./camera.js) still resolve to stale, cached modules. - WebGL Context Exhaustion: Browsers strictly cap WebGL contexts (often 8–16 max). If your hot reload recreates the renderer on every edit, your tab crashes after a few prompts.
-
Context Window & Disk Pollution: Sending a whole 70KB codebase on every LLM prompt costs a fortune in tokens. Conversely, letting an LLM blindly overwrite files on disk turns your
git statusinto chaos whenever the model hallucinates.
🏗️ The Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ BROWSER CLIENT │
│ │
│ 1. User Prompt: "increase boost speed" │
│ 2. Client posts history to /api/agent │
│ 3. Model returns toolCalls: read_file -> patch_file │
│ 4. Client executes tools against IndexedDB overrides │
│ 5. Client calls reload(): │
│ ├── Calls previousInstance.getState() │
│ ├── Calls previousInstance.dispose() (frees GPU) │
│ ├── Rewrites module imports -> Blob URLs │
│ └── Calls nextInstance.init({ renderer, state }) │
└──────────────────────────────▲──────────────────────────────┘
│ HTTP Tool Loop
┌──────────────────────────────▼──────────────────────────────┐
│ DENO / HONO BACKEND │
│ │
│ - Holds OpenRouter API Key (never leaked to browser) │
│ - Proxies generateText() with Vercel AI SDK │
│ - Declarative tools without execute() (Client runs them) │
└─────────────────────────────────────────────────────────────┘
1. Dynamic ESM Graph Rewriting with Blob URLs
When you ask the AI to change code, the updated file exists only in memory. How do you import a module graph containing in-memory code where dependencies point to each other instead of disk?
In shared/engine/loader.js, we walk the relative dependency tree depth-first and synthesize Blob URLs:
// loader.js (simplified)
const SPECIFIER = /(\bfrom\s*|\bimport\s*)(['"])([^'"]+)\2/g;
const isRelative = (s) => s.startsWith('./') || s.startsWith('../') || s.startsWith('/');
export async function buildModuleGraph(entryUrl, { overrides = {} } = {}) {
const built = new Map(); // resolved url -> blob url
async function build(url) {
const resolved = new URL(url, location.href).href;
if (built.has(resolved)) return built.get(resolved);
// 1. Grab in-memory override or fetch from disk
const key = new URL(resolved).pathname;
let source = overrides[resolved] ?? overrides[key] ?? await (await fetch(resolved)).text();
// 2. Recursively resolve relative dependencies first (depth-first)
const deps = new Map();
for (const [, , , spec] of source.matchAll(SPECIFIER)) {
if (isRelative(spec) && !deps.has(spec)) {
deps.set(spec, await build(new URL(spec, resolved).href));
}
}
// 3. Rewrite relative import paths to their dependency's Blob URL
const rewritten = source.replace(SPECIFIER, (match, kw, quote, spec) =>
deps.has(spec) ? `${kw}${quote}${deps.get(spec)}${quote}` : match
);
// 4. Create an ephemeral Blob URL for this module
const blobUrl = URL.createObjectURL(new Blob([rewritten], { type: 'text/javascript' }));
built.set(resolved, blobUrl);
return blobUrl;
}
const url = await build(entryUrl);
return { url, built };
}
Why this is magic:
-
Bare specifiers like
import * as THREE from 'three'orimport '#engine/loop.js'are left untouched, continuing to resolve cleanly through the native HTML<script type="importmap">. Third-party vendor libraries are cached and shared. -
Blob URLs are revoked immediately after
import(/* @vite-ignore */ url)completes, preventing memory leaks across dozens of reloads.
2. Browser-Side Tool Execution (Inverting the Agent Loop)
Typically, AI coding tools run on a server that modifies local files. Here, we inverted that: the browser executes the tools.
Why? Because the authoritative source of truth during a live session is the browser's IndexedDB, not disk. If the server tried to read the file, it would read a stale version.
In server/src/tools.ts, the backend tools have no server execution code (execute). They simply declare schemas to the model:
// server/src/tools.ts
export const TOOLS = {
list_files: tool({ ... }),
read_file: tool({ inputSchema: jsonSchema<{ path: string }>({ ... }) }),
patch_file: tool({ inputSchema: jsonSchema<{ path: string; search: string; replace: string }>({ ... }) }),
reload: tool({ description: 'Hot-swap the game with current edits.' }),
};
When the model returns tool calls, the browser (shared/engine/chat.js) runs them against its virtual storage:
// shared/engine/chat.js (inside browser)
const tools = {
async patch_file({ path, search, replace }) {
const current = await currentSources();
const source = current[path];
// Strict match validation: prevent silent hallucinations
const hits = source.split(search).length - 1;
if (hits === 0) return `search text not found in ${path}. Copy exact text.`;
if (hits > 1) return `search text matches ${hits} times; include more surrounding lines.`;
await saveEdit({ path, source: source.replace(search, replace), game: gameId });
return `patched ${path}`;
},
async reload() {
try {
await game.reload({ overrides: await overridesFor(gameId) });
return 'reload ok — the game is running your changes.';
} catch (error) {
return `reload FAILED: ${error.message}\nThe game is broken. Read the file and fix it.`;
}
}
};
Using surgical text replacement via patch_file instead of rewriting entire modules drops input/output token usage by ~90%. A tweak that used to cost $0.05 now costs ~$0.005 on fast models like GLM 5.3 Flash or Gemini 2.5 Flash.
3. Preserving WebGL Context & State Across Swaps
To swap game logic without blowing up Three.js or resetting player position, we enforce an explicit lifecycle contract:
// Game Module Contract
export function init(ctx) {
// ctx: { renderer, canvas, state }
return {
update(dt, elapsed) { /* frame logic */ },
dispose() {
// Must free Three.js geometries, textures, and event listeners!
},
getState() {
// Optional: snapshot position, seed, score to carry into the next reload
return { seed: world.seed, playerPos: player.position.toArray() };
}
};
}
The host (shared/engine/host.js):
- Keeps one single
WebGLRendererfor the lifetime of the page. - Calls
instance.getState()to grab state before teardown. - Calls
instance.dispose()to deallocate GPU buffers and remove listeners. - Dynamically imports the new Blob URL module graph.
- Invokes
init({ renderer, canvas, state: carriedState }).
The procedural world rebuilds in milliseconds right where you left off.
4. Virtual Sandboxing: Zero Disk Pollution
Edits are stored in IndexedDB (arcade-vault/edits).
- REVERT: Discards IndexedDB records and reloads the original files from disk.
- EXPORT: When you're happy with what the AI built, clicking EXPORT compiles the edits into a shell heredoc snippet copied straight to your clipboard:
cat > games/gravpulse/src/camera.js <<'VAULT_EOF'
// ... your updated code ...
VAULT_EOF
You paste it into your terminal, test it, and commit with git.
💡 Key Takeaways
-
You don't always need a bundler: Native ES Modules combined with
URL.createObjectURLallow rich in-browser code evaluation and module linking. - Move tool execution to the client when the browser holds the state, keeping the backend a lightweight, stateless token proxy.
- Targeted patching beats full rewrites for agentic editing loops — it's faster, cheaper, and resilient against hallucinated code truncations.
Check out the open-source implementation and playable games on GitHub!
Top comments (0)