Lesson 36 - Three.js Game Development
Three.js Scene, Camera, and Renderer: Build the First Real Frame
A dependable Three.js game starts with more than a spinning cube. This lesson builds the rendering foundation used by larger Supagames projects and explains every lifecycle decision.
1. Create the core Three.js trio
The scene stores objects, the camera defines the visible volume, and the renderer turns both into pixels. Set the background deliberately so an empty scene never looks like a broken black canvas.
import * as THREE from "three";
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111936);
const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 500);
camera.position.set(0, 3, 7);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.querySelector("#game").append(renderer.domElement);
How this code works
Execution flow
The module creates the Scene first, then a PerspectiveCamera, and finally the WebGLRenderer that draws that camera's view into the selected game container. Color-space configuration happens before the first frame so every later material is displayed consistently.
Key Three.js decisions
Scene is the root of the 3D graph, PerspectiveCamera gives natural depth for a game world, and WebGLRenderer owns the GPU context. SRGBColorSpace is essential for color textures and CSS-selected colors to look close to their authored values.
Adapt it
For a racing game, widen the field of view and move the camera behind the vehicle. For a top-down arena, raise the camera, reduce its field of view, and point it toward the center while keeping the same renderer lifecycle.
Watch out
If the camera aspect stays at its temporary value of 1, the world stretches when the canvas is not square. If the canvas remains black, inspect camera position, object placement, lights, and the developer console before adding more scene objects.
2. Add an object that proves depth
A lit mesh, floor, and directional light reveal camera perspective immediately. Keep the first scene simple enough that a missing object can be diagnosed in seconds.
const cube = new THREE.Mesh(
new THREE.BoxGeometry(1.4, 1.4, 1.4),
new THREE.MeshStandardMaterial({ color: 0x55d6be, roughness: 0.55 })
);
cube.position.y = 0.8;
scene.add(cube);
scene.add(new THREE.HemisphereLight(0xcfe8ff, 0x283020, 1.4));
const sun = new THREE.DirectionalLight(0xffffff, 2.2);
sun.position.set(4, 7, 3);
scene.add(sun);
How this code works
Execution flow
The code builds one mesh from geometry and material, raises it above the floor, then adds hemisphere and directional lighting. During rendering, Three.js transforms the cube's vertices, evaluates both lights, and writes the shaded result to the framebuffer.
Key Three.js decisions
BoxGeometry provides clear edges that reveal perspective, while MeshStandardMaterial responds to physically based light parameters. HemisphereLight fills dark faces cheaply and DirectionalLight establishes a readable sun direction without a light for every object.
Adapt it
Replace the cube geometry with a vehicle, character, or pickup model while retaining the material and lighting experiment. Change roughness and light direction one value at a time to learn how the object's silhouette remains readable in motion.
Watch out
A Standard material renders almost black when no useful light reaches it. If the cube disappears, first swap temporarily to MeshBasicMaterial; if that appears, the camera and geometry are fine and the defect is in the lighting setup.
3. Resize without stretching the world
CSS size and drawing-buffer size are different. Update both the renderer and camera projection, and cap pixel ratio so a high-density phone does not render four times as many pixels as necessary.
function resize() {
const width = renderer.domElement.parentElement.clientWidth;
const height = Math.max(320, window.innerHeight * 0.72);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75));
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
window.addEventListener("resize", resize);
resize();
How this code works
Execution flow
The resize handler reads the container's current CSS dimensions, caps device pixel ratio, updates the renderer buffer, recalculates camera aspect, and rebuilds the projection matrix. It runs once at startup and again whenever the window changes size.
Key Three.js decisions
setSize with false changes the drawing buffer without overwriting carefully managed CSS dimensions. updateProjectionMatrix is required because changing camera.aspect only changes a property; Three.js does not rebuild the projection matrix automatically.
Adapt it
For a fixed-aspect game, calculate a letterboxed width and height before calling setSize. On mobile, subtract safe UI areas from the available height and keep the pixel-ratio cap lower when effects or shadows consume significant GPU time.
Watch out
Forgetting updateProjectionMatrix leaves the old perspective active even though the canvas resized, producing stretched models. Using the uncapped devicePixelRatio on a dense phone can multiply GPU pixel work and cause heat, battery drain, and unstable frame rate.
4. Render, stop, and dispose cleanly
A game page may restart or navigate away. Store the animation request, cancel it, remove listeners, and dispose GPU resources rather than leaving an invisible game running.
let frameId = 0;
function frame(time) {
cube.rotation.y = time * 0.0006;
renderer.render(scene, camera);
frameId = requestAnimationFrame(frame);
}
frameId = requestAnimationFrame(frame);
function destroy() {
cancelAnimationFrame(frameId);
window.removeEventListener("resize", resize);
cube.geometry.dispose();
cube.material.dispose();
renderer.dispose();
}
How this code works
Execution flow
requestAnimationFrame repeatedly invokes frame, updates rotation from the supplied timestamp, renders, and stores the next request identifier. destroy reverses ownership by cancelling the callback, removing the resize listener, and releasing geometry, material, and renderer resources.
Key Three.js decisions
The browser timestamp is used for a harmless visual rotation, while GPU-owned resources are explicitly disposed because JavaScript garbage collection cannot directly release WebGL buffers and programs. Listener removal prevents a retired game instance from responding later.
Adapt it
Wrap these variables in a createGame function and return destroy to support page transitions, level previews, or repeated editor playtests. Larger games should traverse owned groups and dispose only resources that are not shared by an asset cache.
Watch out
Removing a mesh from the scene does not dispose its geometry or material. A leaking game often appears correct for several restarts and then slows down or loses the WebGL context, so compare renderer.info memory counts before and after repeated reset cycles.
5. Real Supagames example
Production source: js/environment-engine/core/engine.js
Play the published example: Valley Racer
The environment engine owns the renderer, camera, scene, clock, resize lifecycle, quality settings, and subsystem updates. We reduce that architecture to a small foundation you can run in one HTML page.
The teaching snippets deliberately isolate one Three.js responsibility at a time. The production file also handles integration, lifecycle, quality settings, and game-specific state, so read it after the smaller examples make sense.
6. Common mistakes
- Creating geometry, materials, vectors, or arrays inside the frame loop and causing avoidable garbage collection pauses.
- Treating the Three.js scene graph as the only source of gameplay state instead of keeping explicit JavaScript state.
- Testing only on a fast desktop and leaving pixel ratio, shadows, and object counts uncapped on mobile devices.
7. Build checklist
- Serve ES modules through HTTP during development instead of opening the page through file://.
- Test resize, pause, restart, and cleanup paths as carefully as the first successful frame.
- Use browser performance tools and an on-screen FPS sample before guessing where a slowdown comes from.
- Verify keyboard, pointer, and touch controls without letting game shortcuts break forms or page navigation.