DEV Community

Carlos Rivera
Carlos Rivera

Posted on

How I Built In-Game Hot Reloading with an LLM Chat (Zero Build Steps, Pure ESM)

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:

  1. 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.
  2. 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.
  3. 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 status into 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)  │
 └─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

Why this is magic:

  • Bare specifiers like import * as THREE from 'three' or import '#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.' }),
};
Enter fullscreen mode Exit fullscreen mode

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.`;
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

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() };
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

The host (shared/engine/host.js):

  1. Keeps one single WebGLRenderer for the lifetime of the page.
  2. Calls instance.getState() to grab state before teardown.
  3. Calls instance.dispose() to deallocate GPU buffers and remove listeners.
  4. Dynamically imports the new Blob URL module graph.
  5. 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
Enter fullscreen mode Exit fullscreen mode

You paste it into your terminal, test it, and commit with git.


💡 Key Takeaways

  1. You don't always need a bundler: Native ES Modules combined with URL.createObjectURL allow rich in-browser code evaluation and module linking.
  2. Move tool execution to the client when the browser holds the state, keeping the backend a lightweight, stateless token proxy.
  3. 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)