Efe Anidi
Tools

AI-assisted prototyping with Three.js: a workflow that fits in one file

Using an LLM as a pair programmer to go from idea to a small walkable 3D space in an afternoon: one index.html, an import map, a design-notes block, one change at a time, and hooks for AI narration and generative audio.

10 October 2026 · 14 min read

tools

A good 3D experiment can start the same way every time: a single index.html, a pinned copy of three.js pulled from a CDN, and a language model open in another window, writing most of the code while you make the design calls. No bundler, no package.json, no folder structure to explain. The whole prototype fits in one paste, which turns out to matter more than any clever prompt. When the model can see everything, it stops guessing, and when you can see everything, you can tell quickly whether its answer is right.

What this approach builds best is not a game in the full sense but a place: a small courtyard you can walk around, a few landmarks to look at, a camera that follows you. Exploration is the oldest promise of 3D, and it is also where generative work is heading. Text models can already narrate a space and audio models can score it; generative video and interactive film are pushing towards worlds that are rendered rather than modelled. A hand-built walkable box is the control experiment for all of that. It tells you what moving through a space feels like before anything is generated, and it gives you somewhere to hang an AI narrator and generated audio later. This workflow is built for that: the model helps build the stage quickly, and the stage is designed from the start to host AI content.

Why a single file works so well with an LLM

The constraint is the feature. A one-file prototype has three properties that make it unusually good for pairing with a model.

It fits in context. A 150-line file is a few thousand tokens. You can paste it in full on every turn, so the model never works from a stale or partial picture of your code. Most bad LLM edits come from the model inventing code it could not see: a helper that does not exist, a variable named slightly differently, an import that was never there.

There is no build step to go wrong. Modern browsers support ES modules and import maps natively. An import map lets you write import * as THREE from 'three' exactly as you would in a bundled project, while the browser fetches the module from a CDN. There is no node_modules, no version drift between what the model assumes and what is installed, and no config file for it to hallucinate options into.

Diffs are small and legible. You can read a change, paste it, refresh, and know within seconds whether it worked.

The one firm rule: pin the version. Write three@0.170.0, not three@latest. Models have seen code from many three.js releases, and the library has changed a lot. Pinning lets you say “this is three r170” so the model can reason about which APIs exist.

You do need to serve the file over HTTP rather than opening it from disk, because module scripts are blocked on file:// URLs in most browsers. Any static server will do: npx serve ., python -m http.server, or your editor’s live preview.

The starter file

This is the minimal starting file. It sets up a scene, renderer and lights, a small courtyard, three landmarks with simple collision, a player moved with WASD, a camera that trails behind, and a label that names whichever landmark you are standing next to. It is deliberately plain. Its job is to be a correct foundation the model can extend without having to get the basics right first.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Courtyard</title>
  <style>
    html, body { margin: 0; height: 100%; overflow: hidden; background: #111; }
    #label { position: fixed; left: 16px; bottom: 16px; color: #111; font: 16px system-ui, sans-serif; }
  </style>
  <script type="importmap">
  {
    "imports": {
      "three": "https://unpkg.com/three@0.170.0/build/three.module.js",
      "three/addons/": "https://unpkg.com/three@0.170.0/examples/jsm/"
    }
  }
  </script>
</head>
<body>
<div id="label"></div>
<script type="module">
/* DESIGN NOTES (keep this block current; paste the whole file every time)
   Engine: three r170 via import map. No build step. WebGLRenderer.
   Goal: a small courtyard to walk around; the player finds and looks at landmarks.
   Conventions: y is up; ground is y = 0; units are metres; speeds are per second.
   DONE: scene, resize, WASD movement, bounds, landmark collision, follow camera, proximity label.
   TODO: [ ] mouse look  [ ] discovered-landmarks list  [ ] ambient sound per landmark
   DO NOT: change the three version, add a bundler, or use deprecated Geometry.
*/
import * as THREE from 'three';

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x9fb4c7);
scene.fog = new THREE.Fog(0x9fb4c7, 15, 40);

const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 200);
const CAM_OFFSET = new THREE.Vector3(0, 5, 8);
camera.position.copy(CAM_OFFSET);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);

scene.add(new THREE.HemisphereLight(0xffffff, 0x445533, 1.5));
const sun = new THREE.DirectionalLight(0xffffff, 2);
sun.position.set(5, 10, 4);
scene.add(sun);

const AREA = 10; // half-width of the walkable square
const ground = new THREE.Mesh(
  new THREE.PlaneGeometry(AREA * 2, AREA * 2),
  new THREE.MeshStandardMaterial({ color: 0x6b7a4f })
);
ground.rotation.x = -Math.PI / 2;
scene.add(ground);

const landmarks = [
  { name: 'Watchtower', x: -6, z: -6, w: 2,   h: 7,   d: 2,   color: 0x8a7f6a },
  { name: 'Fountain',   x:  0, z: -4, w: 2.5, h: 0.8, d: 2.5, color: 0x5a8fa8 },
  { name: 'Archive',    x:  5, z:  3, w: 3,   h: 3,   d: 2,   color: 0xa86a4a },
].map((l) => {
  const mesh = new THREE.Mesh(
    new THREE.BoxGeometry(l.w, l.h, l.d),
    new THREE.MeshStandardMaterial({ color: l.color })
  );
  mesh.position.set(l.x, l.h / 2, l.z);
  scene.add(mesh);
  return { name: l.name, box: new THREE.Box3().setFromObject(mesh) };
});

const player = new THREE.Mesh(
  new THREE.BoxGeometry(0.6, 1.6, 0.6),
  new THREE.MeshStandardMaterial({ color: 0xf2f2f2 })
);
player.position.y = 0.8;
scene.add(player);

const keys = new Set();
addEventListener('keydown', (e) => keys.add(e.code));
addEventListener('keyup', (e) => keys.delete(e.code));

const SPEED = 4; // metres per second, a brisk walk
const move = new THREE.Vector3();
const prev = new THREE.Vector3();
const camTarget = new THREE.Vector3();
const playerBox = new THREE.Box3();
const playerSize = new THREE.Vector3(0.6, 1.6, 0.6);
const label = document.getElementById('label');

function movePlayer(dt) {
  move.set(
    (keys.has('KeyD') ? 1 : 0) - (keys.has('KeyA') ? 1 : 0),
    0,
    (keys.has('KeyS') ? 1 : 0) - (keys.has('KeyW') ? 1 : 0)
  );
  if (move.lengthSq() === 0) return;
  move.normalize().multiplyScalar(SPEED * dt);

  prev.copy(player.position);
  player.position.add(move);
  player.position.x = THREE.MathUtils.clamp(player.position.x, -AREA + 0.3, AREA - 0.3);
  player.position.z = THREE.MathUtils.clamp(player.position.z, -AREA + 0.3, AREA - 0.3);

  playerBox.setFromCenterAndSize(player.position, playerSize);
  if (landmarks.some((l) => l.box.intersectsBox(playerBox))) player.position.copy(prev);
}

function followCamera(dt) {
  camTarget.copy(player.position).add(CAM_OFFSET);
  camera.position.lerp(camTarget, 1 - Math.exp(-4 * dt)); // frame-rate independent smoothing
  camera.lookAt(player.position);
}

function updateLabel() {
  const near = landmarks.find((l) => l.box.distanceToPoint(player.position) < 1.5);
  const text = near ? near.name : '';
  if (label.textContent !== text) label.textContent = text;
}

addEventListener('resize', () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
});

const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
  const dt = Math.min(clock.getDelta(), 0.1); // clamp after tab switches
  movePlayer(dt);
  followCamera(dt);
  updateLabel();
  renderer.render(scene, camera);
});
</script>
</body>
</html>

A few choices here are deliberate, because they pre-empt the most common problems later:

  • The import map defines both three and three/addons/, so any addon the model suggests (PointerLockControls, GLTFLoader) can be imported with the path used in the official examples.
  • Movement is multiplied by dt, so walking speed is the same at 60Hz and 144Hz. The delta is clamped so a backgrounded tab does not teleport the player through a wall when it comes back.
  • The camera eases towards a point behind and above the player using 1 - Math.exp(-k * dt) rather than a fixed lerp factor, so the smoothing feels the same at any frame rate. In an exploration prototype the camera is the experience; a jittery camera ruins a space faster than ugly geometry does.
  • Collision is the crudest thing that works: try the move, and if the new box overlaps a landmark, revert. It feels sticky against walls, which is fine for a prototype and an obvious candidate for a later “make the player slide along walls” request.
  • The proximity label is the seed of everything interesting. Right now it prints a name. Later the same hook can trigger a line of AI narration, a change in generated ambient audio, or an entry in a discovered-places list. A narrator should follow the split from the text game engine piece: the scene code decides that the player discovered the Archive, and the model only describes it, so a slow or odd reply never moves the player or changes the world.
  • Scratch vectors (move, prev, camTarget, playerBox) are created once, not every frame, so the loop produces no garbage.

The design-notes block is the model’s memory

The comment at the top of the script is the most useful habit in this whole workflow. Chat sessions drift. After twenty turns the model has seen eight versions of the file and a lot of discussion, and it starts mixing them up. A short block at the top of the current file anchors it to what is true now.

Keep it to a handful of lines with a fixed shape:

  • Engine and version. Stops the model reaching for APIs from other releases.
  • Goal. One sentence about what the prototype is for. “A courtyard to walk around and find landmarks” steers suggestions towards camera, pacing and sightlines rather than scores and enemies.
  • Conventions. Axis orientation, units, where the ground is, whether speeds are per second or per frame. These are exactly the assumptions a model gets wrong silently.
  • DONE and TODO. A running checklist. Tick items off yourself, and ask the model to update the block as part of every change.
  • DO NOT. Hard constraints. “No bundler.” “Do not rename player.” “Keep collision as Box3 for now.”

Because the block travels inside the file, it survives starting a new chat: paste the file into a fresh session and the model is back up to speed in one message.

The prompting loop

The loop itself is simple, and its strength comes from being strict about it. The model writes code fast; your job is to keep each step small enough to judge:

  1. Paste the whole current file.
  2. Ask for one change, stated in terms of observable behaviour.
  3. Ask for either a full file or a clearly located diff.
  4. Run it. Watch the console as well as the canvas.
  5. Report what happened, in plain terms, and go again.

One change at a time feels slow but is faster overall: when a bundled request breaks, neither you nor the model knows which part did it. For files under about 200 lines, ask for the full file; beyond that, ask for a diff anchored to function names and apply it yourself, so you read every line that goes in.

These are the most reusable templates.

This is a three.js r170 prototype in a single index.html using an import map (full file below). Add one feature: when the player first comes within 1.5 m of a landmark, add its name to a “Discovered” list in the top-left corner. Keep the existing structure and naming. Update the DESIGN NOTES block. Return the complete file.

Same file below. Bug: the camera ends up inside the Watchtower when the player walks behind it, and the console shows no errors. Explain the cause in two sentences, then give the smallest diff that fixes it. Do not change anything else.

Before writing code: propose two ways to add mouse look to this file (for example pointer lock versus click-and-drag orbiting), with the trade-offs of each in three lines. Wait for a choice, then implement it.

The third template is underrated. Asking for options before code keeps you in charge of design decisions, and in an exploration prototype the camera and look controls are the main design decision.

When reporting results, be concrete. “It doesn’t work” gets a guess. “The list appears but adds the Fountain twice when the player walks round it, and the console shows nothing” gets a fix.

Common LLM mistakes with three.js, and how to catch them

Three.js has a long history, and models have read all of it. The same handful of errors appear again and again.

Outdated APIs. THREE.Geometry was removed in r125; everything is BufferGeometry now. BoxBufferGeometry and friends were folded into BoxGeometry. Older colour-management settings such as renderer.outputEncoding have been replaced by outputColorSpace. If the model writes one of these, it is pattern-matching on old tutorials. The fix is to state the version in the prompt and in the design notes, and to tell it explicitly that Geometry does not exist.

Wrong addon import paths. You will see three/examples/js/controls/OrbitControls.js (the old non-module scripts, since removed), three/examples/jsm/... without a matching import-map entry, or a bare import { OrbitControls } from 'three'. With the import map above, the correct form is:

import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

Forgetting resize and pixel ratio. Code that sets the renderer size once gives stretched geometry after a resize and a blurry canvas on high-density screens. Keep both in the starter so the model never has to remember them.

Frame-rate dependent movement and smoothing. position.x += 0.1 per frame moves twice as fast on a 120Hz monitor, and camera.position.lerp(target, 0.1) per frame follows twice as tightly. Anything that moves or eases should be expressed in terms of dt.

Unnormalised input. Diagonal movement at roughly 1.41 times the speed. New movement code from the model often reintroduces it, especially when it rewrites movement to be camera-relative.

Allocating in the loop. new THREE.Vector3() inside the update every frame creates garbage and, eventually, stutters.

Keep the console open, and skim each change for Geometry, examples/js/, and any number added to a position without dt.

Symptom Likely cause Fix
Blank page, console shows “Failed to resolve module specifier” Missing or misspelt import map entry, or addon imported without the three/addons/ mapping Add both three and three/addons/ to the import map; check the paths match the pinned version
Blank page, CORS or module errors File opened via file:// Serve over HTTP with any static server
THREE.Geometry is not a constructor Model used a pre-r125 API Use BufferGeometry or built-in geometries like BoxGeometry
Scene stretches when the window resizes No resize handler, or updateProjectionMatrix not called Update camera.aspect, call updateProjectionMatrix(), then renderer.setSize()
Walking or camera feels different on some monitors Per-frame movement or lerp factor Scale by clock delta; use 1 - Math.exp(-k * dt) for smoothing
Diagonal movement too fast Input vector not normalised move.normalize() before scaling
Everything black No lights with MeshStandardMaterial Add a hemisphere and directional light, or use MeshBasicMaterial for testing

Performance basics worth knowing early

Prototypes rarely need optimisation, but a few habits keep you out of trouble and are easy to ask the model for by name.

Draw calls matter more than triangle counts. Each mesh is usually a draw call. A few hundred is fine; several thousand will hurt, especially on phones. renderer.info.render.calls gives the current number.

Instancing for repeated objects. Explorable spaces fill up with repeated props: trees, lamp posts, fence sections, rocks. If you have many copies of the same thing, use InstancedMesh: one geometry, one material, one draw call, and a matrix per instance. This scatters trees in a ring outside the walkable square, which makes a small space feel larger while the fog hides where the world ends:

const count = 300;
const trees = new THREE.InstancedMesh(
  new THREE.ConeGeometry(0.6, 2, 8),
  new THREE.MeshStandardMaterial({ color: 0x2f5d3a }),
  count
);
const dummy = new THREE.Object3D();
for (let i = 0; i < count; i++) {
  const angle = Math.random() * Math.PI * 2;
  const radius = 13 + Math.random() * 15; // always outside the 10 m half-width area
  dummy.position.set(Math.cos(angle) * radius, 1, Math.sin(angle) * radius);
  dummy.scale.setScalar(0.7 + Math.random() * 0.8);
  dummy.updateMatrix();
  trees.setMatrixAt(i, dummy.matrix);
}
trees.instanceMatrix.needsUpdate = true;
scene.add(trees);

Share geometry and materials. Three unique landmarks can have their own materials; repeated props should not. Models often create a new material per mesh, which is wasteful and blocks batching later.

Dispose what you remove. scene.remove(mesh) does not free GPU memory. If you are rebuilding the space or swapping models, call dispose() on geometries, materials and textures you no longer use, but only once nothing else shares them.

When to graduate to Vite and modules

The single file is a prototyping tool, not an architecture. Move to a Vite project with separate modules when any of these become true:

  • The file passes roughly 400 to 500 lines. Pasting the whole thing starts to cost too much context, and the model’s edits get less precise.
  • You need npm packages that are awkward from a CDN, such as a physics engine with WebAssembly, or you want TypeScript.
  • Assets multiply. Once there are several models, textures and sounds, a proper asset pipeline and dev server pay for themselves.
  • The space has proved its idea and is worth building on it rather than throw it away.

The migration is mechanical, and a good job to give the model: npm create vite@latest, npm install three@0.170.0, delete the import map, and split the script into modules such as player.js, world.js, camera.js and input.js. Bare import * as THREE from 'three' and three/addons/... imports keep working, now resolved from the installed package. The design-notes block becomes a NOTES.md, and the habit changes from “paste the whole file” to “paste the notes plus the modules involved”. One change at a time still applies.

Takeaways

  • A single index.html with an import map and a pinned three.js version is the fastest way to prototype a 3D space with an LLM, because the model can always see the whole program.
  • Start from a correct, plain starter that already handles resize, pixel ratio, delta time, normalised input and a frame-rate-independent follow camera.
  • For exploration, treat the camera and a proximity hook as first-class: they are where AI narration, generative audio and other generated content will attach later. Let the scene decide what happened and the model describe it.
  • Keep a design-notes block at the top of the file with version, goal, conventions, DONE/TODO and DO NOT. It is the model’s memory and survives fresh chats.
  • Ask for one behavioural change per turn, run it with the console open, and report results concretely.
  • Watch for the usual LLM errors: removed Geometry APIs, wrong addon paths, per-frame movement and smoothing, and allocations in the loop.
  • Move to Vite and modules once the file outgrows a comfortable paste or the space has earned a longer life.