Owns the Three.js r160+ layer of a browser 3D game: scene graph, glTF with DRACO/KTX2, animation, Rapier or cannon-es, EffectComposer, dispose, and Vite. Use when starting or fixing a vanilla Three.js/WebGL web game (r3f only if asked). Not for the HTML5 game shell (browser-game-architecture), cross-engine FPS diagnosis (webgl-performance-tuning), Godot/Unity, or 2D Canvas/Pixi.
Own the Three.js-specific layer of a browser 3D game: renderer and scene-graph setup, the game loop, the glTF asset pipeline, input, physics integration, animation, postprocessing, memory discipline, and the Vite toolchain that ships it. Engine-agnostic design theory and the surrounding web-app shell belong to sibling skills — this skill makes the Three.js part correct, fast, and leak-free.
When to Use
Starting a new browser 3D game with vanilla Three.js (Vite + TypeScript scaffold)
Structuring the game loop: fixed-timestep simulation, render interpolation, pause/timescale, tab-away safety
Production builds with Vite: decoder files, base paths, code splitting, compression
Adding a light WebXR mode to an existing Three.js game
Trigger keywords: three.js, threejs, webgl game, web game 3d, gltf, EffectComposer, Rapier three, Vite three, setAnimationLoop, InstancedMesh, BatchedMesh, WebGPURenderer, r3f, react-three-fiber.
Do not use
Offline / film rendering (Blender Cycles, path tracers) — out of scope entirely
React apps: only reach for @react-three/fiber if the user explicitly asks for React — see "React (r3f) policy" below. Default is vanilla Three.js
2D-only games — Canvas2D/Pixi territory; Three.js is overkill
Godot / Unity / native engines — use the godot-* and engine-specific skills
Pure shader authoring / VFX deep-dives — game-technical-art-vfx
Generic web-app concerns (routing, auth, backend) — not a game skill problem
Bundled references — load on demand
Do not paste these wholesale into context. Load the specific file when the task demands it:
File
Load when…
reference.md
You need API details: renderer options, color management, cameras, lights/shadows, materials, geometry, instancing, textures, loaders, animation system, raycasting, postprocessing, audio, timing, dispose semantics, artifacts/gotchas, debugging toolkit
architecture.md
You are structuring the codebase: Game class, fixed-timestep loop, state machine, entities/systems, event bus, input action mapping, physics sync, asset manager, level lifecycle, pooling, TypeScript patterns
recipes.md
You need copy-paste code: Vite+TS scaffold, renderer + resize, loop class, loader stack, Rapier character controller, touch joystick, gamepad, bloom composer, pooling, deep-dispose, debug rig, production build
examples.md
You need a worked end-to-end reference: complete minimal game, animated glTF character with state machine, Rapier physics playground, mobile endless-runner skeleton
This skill owns the Three.js implementation of those concerns; siblings own the engine-agnostic theory.
Prerequisites
Node.js 18+ and npm
A modern browser with WebGL 2 support (Chrome/Edge/Firefox/Safari)
Three.js r160 or newer installed (npm i three)
TypeScript types (npm i -D @types/three)
Vite as the dev server / bundler (npm create vite@latest)
Windows host is primary. Use PowerShell for all CLI commands. Path separators in examples use / for cross-tool compatibility, but Windows backslash \ is equally valid in PowerShell.
Version contract (r160+, assume ~r165)
Write and expect modern API. When copying code from old tutorials, rewrite these red flags on sight:
Legacy (pre-r152/r155)
Modern (r160+)
import X from 'three/examples/jsm/...'
import X from 'three/addons/...'
renderer.outputEncoding = THREE.sRGBEncoding
renderer.outputColorSpace = THREE.SRGBColorSpace (already the default)
texture.encoding = THREE.sRGBEncoding
texture.colorSpace = THREE.SRGBColorSpace
renderer.physicallyCorrectLights = true
Default behavior; useLegacyLights is removed (r165)
renderer.gammaOutput / gammaFactor
Gone — color management handles it
THREE.Geometry
THREE.BufferGeometry only
GammaCorrectionShader at end of composer
OutputPass (r154+)
Other r160+ facts to rely on:
Color/albedo/emissive textures need texture.colorSpace = THREE.SRGBColorSpace; normal/roughness/metalness/AO/data textures stay linear (NoColorSpace). GLTFLoader sets this correctly for you.
BatchedMesh (r159+) batches different geometries sharing one material into one draw call; InstancedMesh repeats one geometry.
renderer.setAnimationLoop(fn) is the canonical loop driver (required for WebXR; equivalent to rAF otherwise).
WebGPURenderer + TSL node materials exist and are maturing; target WebGLRenderer for shipping games, note WebGPU as forward-looking only.
Procedure
1. Scaffold the project
npm create vite@latest my-game -- --template vanilla-ts
cd my-game
npm i three
npm i -D @types/three
npm i @dimforge/rapier3d-compat # only if physics chosen (see table below)
For the full scaffold (index.html, CSS, vite.config, entry, resize handling) load recipes.md.
Canonical pattern — fixed-timestep simulation with clamped frame delta and render-side interpolation:
const FIXED_DT = 1 / 60;
const MAX_FRAME = 0.25; // tab-away / breakpoint guard
let accumulator = 0;
let last = performance.now();
renderer.setAnimationLoop(() => {
const now = performance.now();
const frame = Math.min((now - last) / 1000, MAX_FRAME);
last = now;
accumulator += frame;
input.poll(); // one snapshot per frame
while (accumulator >= FIXED_DT) {
simulate(FIXED_DT); // gameplay + physics.step(FIXED_DT)
accumulator -= FIXED_DT;
}
const alpha = accumulator / FIXED_DT;
syncVisuals(alpha); // interpolate body → mesh transforms
updatePresentation(frame); // mixers, camera damp, particles, HUD
renderer.render(scene, camera); // or composer.render()
});
Loop rules:
Never step physics with a variable dt.
Clamp the frame delta or a background tab will explode the simulation on return.
Call clock.getDelta() at most once per frame (better: own the timing as above).
Pause = stop accumulating, keep rendering.
Full loop class with timescale and visibilitychange handling → load recipes.md. Where the loop lives in the codebase → load architecture.md.
4. Set up the asset pipeline (glTF-first)
One format: .glb (binary glTF). It carries meshes, PBR materials, skins, morphs, and animations. FBX/OBJ only as intermediate DCC formats — convert before shipping. Authoring rules → game-assets-pipeline.
Compression: Draco (smallest geometry, one-time decode cost) or Meshopt (near-Draco size, much faster decode, also compresses animation) — prefer Meshopt via gltf-transform optimize. Textures: KTX2/Basis (stays compressed on GPU — the only fix for texture memory, not just download size).
Decoders:DRACOLoader and KTX2Loader need their decoder/transcoder folders copied into public/ (from node_modules/three/examples/jsm/libs/). MeshoptDecoder is a pure JS/WASM module import. Wiring recipe → recipes.md.
Preload via a typed manifest before gameplay; show progress from LoadingManager. Loading mid-gameplay causes hitches (decode + GPU upload). Warm shaders with renderer.compileAsync(scene, camera) after load.
Never .clone() a skinned mesh — use SkeletonUtils.clone() (three/addons/utils/SkeletonUtils.js).
Cache by URL; share geometries/materials across instances; refcount before disposing shared assets (see Memory rules below).
5. Choose and wire collision / physics
Situation
Choice
Puzzle/menu-driven, no dynamics
No physics. Transforms + MathUtils + distance checks
Character vs static level, picking, hitscan
three-mesh-bvh — BVH-accelerated raycast/shapecast against level geometry; write your own kinematics
Rigid-body dynamics, stacks, joints, robust character controller
Rapier (@dimforge/rapier3d-compat) — fast WASM, ships a KinematicCharacterController (slopes, steps, snap-to-ground). Default for real physics
Tiny bundle, simple dynamics, WASM disallowed
cannon-es — pure JS, easy API, slower, weaker trimesh support
Legacy ammo.js codebase
Maintain only; don't start new projects on it
Integration rules:
The physics world steps inside the fixed update with FIXED_DT.
Copy body transforms to meshes after stepping (physics owns dynamic transforms — never write mesh positions back except for kinematic bodies).
Build colliders from simplified shapes (capsule/box/hull), not render meshes.
Never scale a mesh to "resize" its collider.
Sync architecture → load architecture.md. Full Rapier setup + character controller → load recipes.md.
6. Wire input
Use Pointer Events (pointerdown/move/up + setPointerCapture) — one code path for mouse, touch, and pen. Never mix in mousedown/touchstart handlers alongside.
Abstract device → action map (moveX/moveY/jump/fire), then gameplay reads a per-frame snapshot. Enables rebinding, gamepad, touch, and replays without touching game code. Pattern → load architecture.md.
Gamepad API is poll-based: read navigator.getGamepads() once per frame inside input.poll(); apply a dead zone (~0.15).
Keyboard: track event.code (layout-independent) in a Set; ignore repeats; clear the set on blur.
Pointer lock (canvas.requestPointerLock()) for FPS-style mouselook; must be called from a user gesture; listen for pointerlockchange to pause on Esc.
7. Mobile pass
touch-action: none on the canvas CSS or the browser will scroll/zoom instead of sending you pointermoves.
Clamp DPR to 1.5–2; expose a render-scale setting (renderer.setPixelRatio is your cheapest quality knob).
Virtual joystick = two pointer regions (left stick, right look) tracked by pointerId — hand-rolled recipe in recipes.md; multi-touch means you must track pointers by id, never "the" pointer.
Budgets drop hard: ≤100–150 draw calls, ≤300k triangles, one shadow-casting light or baked/blob shadows, skip postprocessing on low-end.
Verify with renderer.info.memory (geometries/textures counts) — it must return to baseline after a load→unload cycle. If it climbs, you leak.
Full app teardown (SPA route change): also renderer.dispose(), renderer.forceContextLoss(), drop the canvas.
Per-frame allocations are the other memory sin: no new Vector3() / .clone() in the loop — hoist scratch temps to module scope and .copy() into them.
10. Production build with Vite
Ensure decoder files (DRACO, KTX2) are in public/ and referenced by absolute path.
Set base in vite.config.ts if the game is served from a subpath.
Use code splitting for large assets or optional modes (e.g., WebXR).
Enable compression plugins (vite-plugin-compression for gzip/brotli).
Run npm run build and verify the output serves correctly from the target path.
Full production build config → load recipes.md.
11. WebXR (light mode, if requested)
renderer.xr.enabled = true, add VRButton/XRButton from three/addons/webxr/.
Drive everything through renderer.setAnimationLoop (rAF does not fire in XR sessions).
Per-eye rendering roughly doubles GPU cost: drop postprocessing first, keep DPR at 1, use renderer.xr.setFramebufferScaleFactor to trade sharpness for frame rate.
Controllers via renderer.xr.getController(i) with select events.
Treat XR as a bonus mode, not the primary target, unless the user says otherwise.
Scene graph rules that prevent 80% of beginner bugs:
An Object3D's position/quaternion/scale are local to its parent. World transform lives in matrixWorld, updated during render (or via updateWorldMatrix(true, false) when you need it mid-frame).
parent.add(child) re-parents and changes world position unless you use parent.attach(child) (keeps world transform).
Nothing renders without: a camera in a sane position, a light (for lit materials), and geometry with a material. Debug "black screen" in that order.
One renderer, one canvas, one loop. Multiple scenes/cameras are fine (HUD pass, minimap) — multiple renderers are not.
Performance budgets & triage
Symptom
First suspects
High CPU, low GPU
Draw calls (renderer.info.render.calls), per-frame allocation/GC, matrix updates on thousands of static objects (matrixAutoUpdate = false), unbatched raycasting
High GPU, frame drops at high DPR
Fill rate/overdraw (transparent layers, post passes), shadow map size, DPR unclamped
Default answer is vanilla Three.js. Only if the user explicitly wants React, switch to @react-three/fiber + drei and keep the paradigm pure — never drive a vanilla imperative scene from React state per frame, and never setState inside useFrame. Concept mapping when translating:
Vanilla
r3f
scene.add(mesh)
JSX: <mesh> declares graph; unmount auto-disposes
renderer.setAnimationLoop
useFrame((state, delta) => ...) with useRef mutation
Manual loaders + cache
useGLTF / useLoader (suspense, cached)
Manual resize
<Canvas> handles it
This skill's loop/physics/memory rules
Still apply — fixed step via accumulator inside useFrame, @react-three/rapier for physics
Pitfalls
new Vector3/Quaternion/Matrix4 (or .clone()) inside the loop — GC churn; hoist scratch objects.
Per-entity new Material()/new Geometry() for identical entities — share and instance.
Stepping physics with variable frame dt — non-deterministic, explodes on hitches.
Unclamped setPixelRatio(devicePixelRatio) — a DPR-3 phone renders 9× the pixels.
Removing meshes without disposing (leak), or disposing shared assets in use (breakage) — refcount.
Loading assets on demand mid-gameplay — preload via manifest; hitches are a design failure.
Raycasting the whole scene recursively every frame — target lists, layers, or three-mesh-bvh.
clock.getDelta() called from multiple places — each call resets it; own timing centrally.
No frame-delta clamp — returning from a background tab teleports/explodes everything.
transparent: true everywhere — overdraw + sort popping; use alphaTest for cutouts.
Shadow maps on every light / 4096 maps by default — one sun-shadow + cheats first.
Updating DOM HUD (innerText, styles) every frame unconditionally — write on change only.
Copy-pasting pre-r152 tutorial code without applying the Version-contract table above.
scene.traverse() per frame to find objects — cache references at spawn time.
matrixAutoUpdate left on for thousands of static objects.
Sequential await load(); await load(); — Promise.all the manifest.
Mixing r3f and vanilla idioms in one codebase.
Using .clone() on a skinned mesh instead of SkeletonUtils.clone() — broken skinning.
Forgetting texture.colorSpace = THREE.SRGBColorSpace for color textures loaded outside GLTFLoader.
Not handling webglcontextlost — the game silently dies on context switch.
Verification
Run through this checklist before calling a task done:
renderer.info.memory returns to baseline after a level load→unload cycle
renderer.info.render.calls within budget on the heaviest scene
60 fps with DevTools 6× CPU throttle (or explicit 30 fps mobile target held)
No per-frame allocations in the hot path (DevTools allocation sampling while idling in-game)
Touch: joystick + camera work simultaneously (multi-touch by pointerId), page doesn't scroll
Tab-away 30s → return: no physics explosion, audio muted while hidden
Production vite build served from a subpath works (decoder files, base path)
Ship gate run via web-game-release-review
Quick verification commands
# Confirm Three.js version is r160+
npm ls three
# Run the dev server
npm run dev
# Production build
npm run build
# Preview the production build
npm run preview
Expected npm ls three output:three@0.160.0 or higher.
Expected renderer.info.memory check (in console during gameplay):
console.log(renderer.info.memory); // { geometries: N, textures: M }
After a full load→unload cycle, N and M must return to their pre-load baseline. If they climb, there is a leak.
Progress checklist (working a task)
Scaffold or locate project; confirm three version ≥ r160 and Vite config (load recipes.md)
Renderer defaults + resize + DPR clamp in place
Fixed-timestep loop owns all timing; delta clamped
Physics choice made from the table and wired in the fixed step
Asset manifest preloads everything; decoders wired; skinned clones via SkeletonUtils
1---2name: threejs-game-development3description: Owns the Three.js r160+ layer of a browser 3D game: scene graph, glTF with DRACO/KTX2, animation, Rapier or cannon-es, EffectComposer, dispose, and Vite. Use when starting or fixing a vanilla Three.js/WebGL web game (r3f only if asked). Not for the HTML5 game shell (browser-game-architecture), cross-engine FPS diagnosis (webgl-performance-tuning), Godot/Unity, or 2D Canvas/Pixi.4---56# threejs-game-development
78Own the Three.js-specific layer of a browser 3D game: renderer and scene-graph setup, the game loop, the glTF asset pipeline, input, physics integration, animation, postprocessing, memory discipline, and the Vite toolchain that ships it. Engine-agnostic design theory and the surrounding web-app shell belong to sibling skills — this skill makes the *Three.js* part correct, fast, and leak-free.
910## When to Use
1112- Starting a new browser 3D game with vanilla Three.js (Vite + TypeScript scaffold)
13- Structuring the game loop: fixed-timestep simulation, render interpolation, pause/timescale, tab-away safety
14- Loading and managing assets: glTF + DRACO / KTX2 / Meshopt, preload manifests, caching, cloning skinned meshes
15- Choosing and wiring collision/physics: none vs raycast-only vs three-mesh-bvh vs Rapier vs cannon-es
16- Character movement, camera follow, animation crossfades, raycast picking
17- Touch controls and mobile-safe render settings for WebGL games
18- Postprocessing chains (EffectComposer) that survive resize and DPR changes
19- Fixing leaks, hitches, GC churn, or WebGL context loss; dispose semantics
20- Production builds with Vite: decoder files, base paths, code splitting, compression
21- Adding a light WebXR mode to an existing Three.js game
2223**Trigger keywords:** three.js, threejs, webgl game, web game 3d, gltf, EffectComposer, Rapier three, Vite three, setAnimationLoop, InstancedMesh, BatchedMesh, WebGPURenderer, r3f, react-three-fiber.
2425### Do not use
2627- **Offline / film rendering** (Blender Cycles, path tracers) — out of scope entirely
28- **React apps**: only reach for `@react-three/fiber` if the user explicitly asks for React — see "React (r3f) policy" below. Default is vanilla Three.js
29- **2D-only games** — Canvas2D/Pixi territory; Three.js is overkill
30- **Godot / Unity / native engines** — use the `godot-*` and engine-specific skills
31- **Pure shader authoring / VFX deep-dives** — `game-technical-art-vfx`
32- **Generic web-app concerns** (routing, auth, backend) — not a game skill problem
3334### Bundled references — load on demand
3536Do not paste these wholesale into context. Load the specific file when the task demands it:
3738| File | Load when… |
39|---|---|
40| `reference.md` | You need API details: renderer options, color management, cameras, lights/shadows, materials, geometry, instancing, textures, loaders, animation system, raycasting, postprocessing, audio, timing, dispose semantics, artifacts/gotchas, debugging toolkit |
41| `architecture.md` | You are structuring the codebase: Game class, fixed-timestep loop, state machine, entities/systems, event bus, input action mapping, physics sync, asset manager, level lifecycle, pooling, TypeScript patterns |
42| `recipes.md` | You need copy-paste code: Vite+TS scaffold, renderer + resize, loop class, loader stack, Rapier character controller, touch joystick, gamepad, bloom composer, pooling, deep-dispose, debug rig, production build |
43| `examples.md` | You need a worked end-to-end reference: complete minimal game, animated glTF character with state machine, Rapier physics playground, mobile endless-runner skeleton |
4445### Routing to sibling skills
4647| Need | Skill |
48|---|---|
49| Overall web-game shell: menus, saves, meta-loop, session flow | `browser-game-architecture` |
50| Lighting theory, PBR channel rules, post look-dev, quality tiers | `game-3d-rendering` |
51| DCC export, glTF authoring, texture budgets, import hygiene | `game-assets-pipeline` |
52| Camera *feel*: follow rigs, shake, framing (engine-agnostic) | `game-camera-system` |
53| Input abstraction patterns (engine-agnostic) | `game-input-handling` |
54| Profiling methodology and tooling discipline | `game-performance-profiling` |
55| Shipping: perf audit, compatibility, release checklist | `web-game-release-review` |
5657This skill owns the Three.js *implementation* of those concerns; siblings own the engine-agnostic theory.
5859## Prerequisites
6061- Node.js 18+ and npm
62- A modern browser with WebGL 2 support (Chrome/Edge/Firefox/Safari)
63- Three.js r160 or newer installed (`npm i three`)
64- TypeScript types (`npm i -D @types/three`)
65- Vite as the dev server / bundler (`npm create vite@latest`)
66- **Windows host is primary.** Use PowerShell for all CLI commands. Path separators in examples use `/` for cross-tool compatibility, but Windows backslash `\` is equally valid in PowerShell.
6768## Version contract (r160+, assume ~r165)
6970Write and expect modern API. When copying code from old tutorials, rewrite these red flags on sight:
7172| Legacy (pre-r152/r155) | Modern (r160+) |
73|---|---|
74| `import X from 'three/examples/jsm/...'` | `import X from 'three/addons/...'` |
75| `renderer.outputEncoding = THREE.sRGBEncoding` | `renderer.outputColorSpace = THREE.SRGBColorSpace` (already the default) |
76| `texture.encoding = THREE.sRGBEncoding` | `texture.colorSpace = THREE.SRGBColorSpace` |
77| `renderer.physicallyCorrectLights = true` | Default behavior; `useLegacyLights` is removed (r165) |
78| `renderer.gammaOutput / gammaFactor` | Gone — color management handles it |
79| `THREE.Geometry` | `THREE.BufferGeometry` only |
80| GammaCorrectionShader at end of composer | `OutputPass` (r154+) |
8182Other r160+ facts to rely on:
8384- Color/albedo/emissive textures need `texture.colorSpace = THREE.SRGBColorSpace`; normal/roughness/metalness/AO/data textures stay linear (`NoColorSpace`). `GLTFLoader` sets this correctly for you.
85- `BatchedMesh` (r159+) batches *different* geometries sharing one material into one draw call; `InstancedMesh` repeats *one* geometry.
86- `renderer.setAnimationLoop(fn)` is the canonical loop driver (required for WebXR; equivalent to rAF otherwise).
87- `WebGPURenderer` + TSL node materials exist and are maturing; target `WebGLRenderer` for shipping games, note WebGPU as forward-looking only.
8889## Procedure
9091### 1. Scaffold the project
9293```powershell
94npm create vite@latest my-game -- --template vanilla-ts
95cd my-game
96npm i three
97npm i -D @types/three
98npm i @dimforge/rapier3d-compat # only if physics chosen (see table below)
99```
100101For the full scaffold (index.html, CSS, vite.config, entry, resize handling) load `recipes.md`.
102103### 2. Configure renderer defaults
104105```ts
106const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
107renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // ALWAYS clamp DPR
108renderer.setSize(window.innerWidth, window.innerHeight);
109renderer.toneMapping = THREE.ACESFilmicToneMapping;
110renderer.shadowMap.enabled = true;
111renderer.shadowMap.type = THREE.PCFSoftShadowMap;
112```
113114### 3. Build the game loop (fixed-timestep)
115116Canonical pattern — fixed-timestep simulation with clamped frame delta and render-side interpolation:
117118```ts
119const FIXED_DT = 1 / 60;
120const MAX_FRAME = 0.25; // tab-away / breakpoint guard
121let accumulator = 0;
122let last = performance.now();
123124renderer.setAnimationLoop(() => {
125 const now = performance.now();
126 const frame = Math.min((now - last) / 1000, MAX_FRAME);
127 last = now;
128 accumulator += frame;
129130 input.poll(); // one snapshot per frame
131 while (accumulator >= FIXED_DT) {
132 simulate(FIXED_DT); // gameplay + physics.step(FIXED_DT)
133 accumulator -= FIXED_DT;
134 }
135 const alpha = accumulator / FIXED_DT;
136 syncVisuals(alpha); // interpolate body → mesh transforms
137 updatePresentation(frame); // mixers, camera damp, particles, HUD
138 renderer.render(scene, camera); // or composer.render()
139});
140```
141142**Loop rules:**
143- Never step physics with a variable dt.
144- Clamp the frame delta or a background tab will explode the simulation on return.
145- Call `clock.getDelta()` at most once per frame (better: own the timing as above).
146- Pause = stop accumulating, keep rendering.
147148Full loop class with timescale and `visibilitychange` handling → load `recipes.md`. Where the loop lives in the codebase → load `architecture.md`.
149150### 4. Set up the asset pipeline (glTF-first)
151152- **One format: `.glb`** (binary glTF). It carries meshes, PBR materials, skins, morphs, and animations. FBX/OBJ only as intermediate DCC formats — convert before shipping. Authoring rules → `game-assets-pipeline`.
153- **Compression:** Draco (smallest geometry, one-time decode cost) or Meshopt (near-Draco size, much faster decode, also compresses animation) — prefer Meshopt via `gltf-transform optimize`. Textures: KTX2/Basis (stays compressed on GPU — the only fix for texture *memory*, not just download size).
154- **Decoders:** `DRACOLoader` and `KTX2Loader` need their decoder/transcoder folders copied into `public/` (from `node_modules/three/examples/jsm/libs/`). `MeshoptDecoder` is a pure JS/WASM module import. Wiring recipe → `recipes.md`.
155- **Preload via a typed manifest** before gameplay; show progress from `LoadingManager`. Loading mid-gameplay causes hitches (decode + GPU upload). Warm shaders with `renderer.compileAsync(scene, camera)` after load.
156- **Never `.clone()` a skinned mesh** — use `SkeletonUtils.clone()` (`three/addons/utils/SkeletonUtils.js`).
157- Cache by URL; share geometries/materials across instances; refcount before disposing shared assets (see Memory rules below).
158159### 5. Choose and wire collision / physics
160161| Situation | Choice |
162|---|---|
163| Puzzle/menu-driven, no dynamics | **No physics.** Transforms + `MathUtils` + distance checks |
164| Character vs static level, picking, hitscan | **three-mesh-bvh** — BVH-accelerated raycast/shapecast against level geometry; write your own kinematics |
165| Rigid-body dynamics, stacks, joints, robust character controller | **Rapier** (`@dimforge/rapier3d-compat`) — fast WASM, ships a `KinematicCharacterController` (slopes, steps, snap-to-ground). Default for real physics |
166| Tiny bundle, simple dynamics, WASM disallowed | **cannon-es** — pure JS, easy API, slower, weaker trimesh support |
167| Legacy ammo.js codebase | Maintain only; don't start new projects on it |
168169**Integration rules:**
170- The physics world steps **inside the fixed update** with `FIXED_DT`.
171- Copy body transforms to meshes after stepping (physics owns dynamic transforms — never write mesh positions back except for kinematic bodies).
172- Build colliders from simplified shapes (capsule/box/hull), not render meshes.
173- Never scale a mesh to "resize" its collider.
174175Sync architecture → load `architecture.md`. Full Rapier setup + character controller → load `recipes.md`.
176177### 6. Wire input
178179- Use **Pointer Events** (`pointerdown/move/up` + `setPointerCapture`) — one code path for mouse, touch, and pen. Never mix in `mousedown`/`touchstart` handlers alongside.
180- Abstract device → **action map** (`moveX/moveY/jump/fire`), then gameplay reads a per-frame snapshot. Enables rebinding, gamepad, touch, and replays without touching game code. Pattern → load `architecture.md`.
181- Gamepad API is poll-based: read `navigator.getGamepads()` once per frame inside `input.poll()`; apply a dead zone (~0.15).
182- Keyboard: track `event.code` (layout-independent) in a `Set`; ignore repeats; clear the set on `blur`.
183- Pointer lock (`canvas.requestPointerLock()`) for FPS-style mouselook; must be called from a user gesture; listen for `pointerlockchange` to pause on Esc.
184185### 7. Mobile pass
186187- `touch-action: none` on the canvas CSS or the browser will scroll/zoom instead of sending you pointermoves.
188- Clamp DPR to 1.5–2; expose a render-scale setting (`renderer.setPixelRatio` is your cheapest quality knob).
189- Virtual joystick = two pointer regions (left stick, right look) tracked by pointerId — hand-rolled recipe in `recipes.md`; multi-touch means you must track pointers by id, never "the" pointer.
190- Budgets drop hard: ≤100–150 draw calls, ≤300k triangles, one shadow-casting light or baked/blob shadows, skip postprocessing on low-end.
191- Handle `visibilitychange` (pause + mute), `orientationchange`/resize, and WebGL `webglcontextlost`/`restored` (prevent default, rebuild).
192- Audio and fullscreen require a user gesture: unlock `AudioContext` on first tap.
193- Test with real CPU throttling (DevTools 6×) — desktop GPUs hide sins.
194195### 8. Postprocessing (EffectComposer)
196197- Use `EffectComposer` with `OutputPass` (r154+) as the final pass — do not use `GammaCorrectionShader`.
198- Ensure the composer survives resize: call `composer.setSize(w, h)` and `composer.setPixelRatio(dpr)` alongside the renderer resize.
199- On mobile/low-end, skip postprocessing entirely or reduce to a single pass.
200201Bloom composer recipe → load `recipes.md`.
202203### 9. Memory & dispose discipline
204205Removing an object from the scene frees **nothing** on the GPU. The rules:
2062071. GPU resources live in `BufferGeometry`, `Material`, `Texture`, `WebGLRenderTarget`, and skeleton bone textures. Each needs `.dispose()` explicitly.
2082. Disposing a material does **not** dispose its textures — walk the material's texture slots.
2093. Shared assets: refcount. Disposing a geometry/material still used elsewhere causes silent re-upload or broken rendering.
2104. Level teardown checklist: stop the loop's spawners → remove event listeners → free physics bodies → traverse and deep-dispose the level subtree → clear pools → verify. Deep-dispose utility → load `recipes.md`.
2115. Verify with `renderer.info.memory` (geometries/textures counts) — it must return to baseline after a load→unload cycle. If it climbs, you leak.
2126. Full app teardown (SPA route change): also `renderer.dispose()`, `renderer.forceContextLoss()`, drop the canvas.
213214Per-frame allocations are the other memory sin: no `new Vector3()` / `.clone()` in the loop — hoist scratch temps to module scope and `.copy()` into them.
215216### 10. Production build with Vite
217218- Ensure decoder files (DRACO, KTX2) are in `public/` and referenced by absolute path.
219- Set `base` in `vite.config.ts` if the game is served from a subpath.
220- Use code splitting for large assets or optional modes (e.g., WebXR).
221- Enable compression plugins (`vite-plugin-compression` for gzip/brotli).
222- Run `npm run build` and verify the output serves correctly from the target path.
223224Full production build config → load `recipes.md`.
225226### 11. WebXR (light mode, if requested)
227228- `renderer.xr.enabled = true`, add `VRButton`/`XRButton` from `three/addons/webxr/`.
229- Drive everything through `renderer.setAnimationLoop` (rAF does not fire in XR sessions).
230- Per-eye rendering roughly doubles GPU cost: drop postprocessing first, keep DPR at 1, use `renderer.xr.setFramebufferScaleFactor` to trade sharpness for frame rate.
231- Controllers via `renderer.xr.getController(i)` with `select` events.
232- Treat XR as a bonus mode, not the primary target, unless the user says otherwise.
233234## Mental model: anatomy of a frame
235236```
237poll input (snapshot devices once)
238 → fixed-step simulation ×N (gameplay logic, physics.step(FIXED_DT))
239 → sync visuals (copy/interpolate body transforms → meshes)
240 → variable-rate update (AnimationMixer, camera damping, particles, UI)
241 → render (renderer or EffectComposer)
242```
243244Scene graph rules that prevent 80% of beginner bugs:
245246- An `Object3D`'s `position/quaternion/scale` are **local** to its parent. World transform lives in `matrixWorld`, updated during render (or via `updateWorldMatrix(true, false)` when you need it mid-frame).
247- `parent.add(child)` re-parents and *changes world position* unless you use `parent.attach(child)` (keeps world transform).
248- Nothing renders without: a camera in a sane position, a light (for lit materials), and geometry with a material. Debug "black screen" in that order.
249- One renderer, one canvas, one loop. Multiple scenes/cameras are fine (HUD pass, minimap) — multiple renderers are not.
250251## Performance budgets & triage
252253| Symptom | First suspects |
254|---|---|
255| High CPU, low GPU | Draw calls (`renderer.info.render.calls`), per-frame allocation/GC, matrix updates on thousands of static objects (`matrixAutoUpdate = false`), unbatched raycasting |
256| High GPU, frame drops at high DPR | Fill rate/overdraw (transparent layers, post passes), shadow map size, DPR unclamped |
257| Hitches/stutters | Mid-gameplay loading, shader compilation (warm with `compileAsync`), GC pauses, texture decode/upload |
258| Memory climbs per level | Missing dispose (rule 5 above) |
259260**Targets:** desktop web ≤ ~1000 draw calls / ≤ 2–3M tris; mobile web ≤ ~150 calls / ≤ 300k tris; 16.6ms frame with ≥ 4ms headroom.
261262**Weapons:** `InstancedMesh`/`BatchedMesh`, `BufferGeometryUtils.mergeGeometries` for static clumps, LOD, frustum-friendly scene structure, shared materials, KTX2 textures, render-scale slider. Methodology → `game-performance-profiling`; ship gate → `web-game-release-review`.
263264## React (r3f) policy
265266Default answer is **vanilla Three.js**. Only if the user explicitly wants React, switch to `@react-three/fiber` + `drei` and keep the paradigm pure — never drive a vanilla imperative scene from React state per frame, and never `setState` inside `useFrame`. Concept mapping when translating:
267268| Vanilla | r3f |
269|---|---|
270| `scene.add(mesh)` | JSX: `<mesh>` declares graph; unmount auto-disposes |
271| `renderer.setAnimationLoop` | `useFrame((state, delta) => ...)` with `useRef` mutation |
272| Manual loaders + cache | `useGLTF` / `useLoader` (suspense, cached) |
273| Manual resize | `<Canvas>` handles it |
274| This skill's loop/physics/memory rules | Still apply — fixed step via accumulator inside `useFrame`, `@react-three/rapier` for physics |
275276## Pitfalls
2772781. `new Vector3/Quaternion/Matrix4` (or `.clone()`) inside the loop — GC churn; hoist scratch objects.
2792. Per-entity `new Material()`/`new Geometry()` for identical entities — share and instance.
2803. Stepping physics with variable frame dt — non-deterministic, explodes on hitches.
2814. Unclamped `setPixelRatio(devicePixelRatio)` — a DPR-3 phone renders 9× the pixels.
2825. Removing meshes without disposing (leak), or disposing shared assets in use (breakage) — refcount.
2836. Loading assets on demand mid-gameplay — preload via manifest; hitches are a design failure.
2847. Raycasting the whole scene recursively every frame — target lists, `layers`, or three-mesh-bvh.
2858. `clock.getDelta()` called from multiple places — each call resets it; own timing centrally.
2869. No frame-delta clamp — returning from a background tab teleports/explodes everything.
28710. `transparent: true` everywhere — overdraw + sort popping; use `alphaTest` for cutouts.
28811. Shadow maps on every light / 4096 maps by default — one sun-shadow + cheats first.
28912. Updating DOM HUD (`innerText`, styles) every frame unconditionally — write on change only.
29013. Copy-pasting pre-r152 tutorial code without applying the Version-contract table above.
29114. `scene.traverse()` per frame to find objects — cache references at spawn time.
29215. `matrixAutoUpdate` left on for thousands of static objects.
29316. Sequential `await load(); await load();` — `Promise.all` the manifest.
29417. Mixing r3f and vanilla idioms in one codebase.
29518. Using `.clone()` on a skinned mesh instead of `SkeletonUtils.clone()` — broken skinning.
29619. Forgetting `texture.colorSpace = THREE.SRGBColorSpace` for color textures loaded outside `GLTFLoader`.
29720. Not handling `webglcontextlost` — the game silently dies on context switch.
298299## Verification
300301Run through this checklist before calling a task done:
302303- [ ] `renderer.info.memory` returns to baseline after a level load→unload cycle
304- [ ] `renderer.info.render.calls` within budget on the heaviest scene
305- [ ] 60 fps with DevTools 6× CPU throttle (or explicit 30 fps mobile target held)
306- [ ] No per-frame allocations in the hot path (DevTools allocation sampling while idling in-game)
307- [ ] Touch: joystick + camera work simultaneously (multi-touch by pointerId), page doesn't scroll
308- [ ] Tab-away 30s → return: no physics explosion, audio muted while hidden
309- [ ] Context-lost handler present; resize/orientation handled; DPR clamped
310- [ ] Production `vite build` served from a subpath works (decoder files, base path)
311- [ ] Ship gate run via `web-game-release-review`
312313### Quick verification commands
314315```powershell
316# Confirm Three.js version is r160+
317npm ls three
318319# Run the dev server
320npm run dev
321322# Production build
323npm run build
324325# Preview the production build
326npm run preview
327```
328329**Expected `npm ls three` output:** `three@0.160.0` or higher.
330331**Expected `renderer.info.memory` check (in console during gameplay):**
332```ts
333console.log(renderer.info.memory); // { geometries: N, textures: M }
334```
335After a full load→unload cycle, N and M must return to their pre-load baseline. If they climb, there is a leak.
336337## Progress checklist (working a task)
3383391. [ ] Scaffold or locate project; confirm three version ≥ r160 and Vite config (load `recipes.md`)
3402. [ ] Renderer defaults + resize + DPR clamp in place
3413. [ ] Fixed-timestep loop owns all timing; delta clamped
3424. [ ] Physics choice made from the table and wired in the fixed step
3435. [ ] Asset manifest preloads everything; decoders wired; skinned clones via SkeletonUtils
3446. [ ] Input action map covers keyboard + pointer + touch (+ gamepad if asked)
3457. [ ] Dispose path written *with* the spawn path, not after
3468. [ ] Mobile pass: budgets, touch-action, visibilitychange, audio unlock
3479. [ ] Run the Verification checklist above before calling it done
348349## Related skills
350351- `browser-game-architecture` — web-game shell, menus, saves, meta-loop
352- `game-3d-rendering` — lighting theory, PBR, post look-dev, quality tiers
353- `game-assets-pipeline` — DCC export, glTF authoring, texture budgets
354- `game-camera-system` — camera feel: follow rigs, shake, framing
355- `game-input-handling` — input abstraction patterns (engine-agnostic)
356- `game-performance-profiling` — profiling methodology and tooling
357- `web-game-release-review` — shipping: perf audit, compatibility, release checklist
Run npx skillmds@latest add kayforkind/threejs-game-development in your terminal (requires Node.js), paste this page's agent-chat prompt into Claude, Cursor, or any MCP-connected agent, or download the SKILL.md file and copy it into your agent's skills directory.
Owns the Three.js r160+ layer of a browser 3D game: scene graph, glTF with DRACO/KTX2, animation, Rapier or cannon-es, EffectComposer, dispose, and Vite. Use when starting or fixing a vanilla Three.js/WebGL web game (r3f only if asked). Not for the HTML5 game shell (browser-game-architecture), cross-engine FPS diagnosis (webgl-performance-tuning), Godot/Unity, or 2D Canvas/Pixi. It is listed under Coding & Dev Tools on SkillMD.
This skill has not completed SkillMD's automated safety review yet. Capability flags: docs only. SkillMD never runs a skill's scripts for you; review the SKILL.md before installing.
This skill is tagged as working with Claude Code, Claude.ai, OpenAI Codex. SKILL.md is an open format, so most agents that read a skills directory can load it too.
Yes. Installing skills from SkillMD is free, and the skill stays under its author's original license.
Kayforkind (@kayforkind) published this skill. Their other Agent Skills are listed on their SkillMD profile.