Lesson 25
Three.js: simple 3D games without writing raw WebGL.
Three.js is useful when your game idea needs depth: a small arena, a rolling ball, a hover racer, a gallery, a maze or a 3D object viewer with game rules.
1. The core 3D trio
A Three.js scene needs three core pieces: a scene, a camera and a renderer. The scene contains objects. The camera decides what the player sees. The renderer draws the scene into a canvas. That structure is different from 2D canvas, where you usually draw directly every frame.
For games, think of Three.js as a display and spatial toolkit. You still design the game loop, input, collision, scoring, UI and level rules. Three.js gives you meshes, materials, lights, cameras and WebGL rendering.
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x10162a);
const camera = new THREE.PerspectiveCamera(65, width / height, 0.1, 1000);
camera.position.set(0, 8, 12);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
document.getElementById("game").appendChild(renderer.domElement);
Source pattern: Supagames mostly uses canvas and Phaser for 2D, while Three.js is the natural next tool when a game needs scenes, cameras, meshes and lights instead of flat drawing.
2. Meshes are geometry plus material
A mesh combines shape and appearance. A cube can become a crate, a player marker, a pickup or a wall. Early prototypes can use simple boxes and spheres. Good gameplay does not require detailed models on day one.
function makePlayer() {
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x4dd7ff });
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(0, 0.5, 0);
mesh.userData = { hp: 3, speed: 5 };
scene.add(mesh);
return mesh;
}
const player = makePlayer();
`userData` is convenient for small prototypes, but larger games should store gameplay state in your own objects and keep meshes as visual representations. That makes testing easier.
3. Lights make shapes readable
Without light, many materials render black. A simple scene can use ambient light plus one directional light. For games, readability matters more than realism. The player should understand walls, pickups and hazards immediately.
const ambient = new THREE.AmbientLight(0xffffff, 0.35);
scene.add(ambient);
const sun = new THREE.DirectionalLight(0xffffff, 1.1);
sun.position.set(5, 10, 6);
scene.add(sun);
const floor = new THREE.Mesh(
new THREE.BoxGeometry(18, 0.2, 18),
new THREE.MeshStandardMaterial({ color: 0x26304d })
);
floor.position.y = -0.1;
scene.add(floor);
If the game is dark, add deliberate highlights. "Atmospheric" should not mean unreadable.
4. The animation loop still matters
Three.js renders the scene, but you still need update logic. Use `requestAnimationFrame`, compute delta time and update movement before rendering. Keep UI outside the 3D render loop when possible.
let last = performance.now();
function animate(now) {
const dt = Math.min(0.033, (now - last) / 1000);
last = now;
updatePlayer(player, input, dt);
updatePickups(dt);
camera.position.x += (player.position.x - camera.position.x) * 0.08;
camera.position.z += (player.position.z + 10 - camera.position.z) * 0.08;
camera.lookAt(player.position);
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
The same lessons from 2D apply: cap delta time, avoid creating new objects every frame and keep state readable.
5. Simple collision with boxes and distance
Full 3D physics engines exist, but many simple games can start with distance checks and axis-aligned boxes. Use distance for coins and spherical triggers. Use boxes for walls and crates. Upgrade later only if the mechanic needs it.
function collectNearby(player, pickups) {
for (let i = pickups.length - 1; i >= 0; i--) {
const pickup = pickups[i];
if (player.position.distanceTo(pickup.position) < 1.1) {
scene.remove(pickup);
pickups.splice(i, 1);
score += 10;
}
}
}
function boxBlocked(nextPosition, blocks) {
return blocks.some(block => {
return Math.abs(nextPosition.x - block.position.x) < 1 &&
Math.abs(nextPosition.z - block.position.z) < 1;
});
}
For a maze, this is often enough. For rolling physics, ragdolls or vehicles, use a physics library, but accept the extra complexity only when the game benefits.
6. 3D game QA checklist
- The camera shows the next action, not only the player's back.
- Objects have strong silhouettes and colors.
- Controls are explained because 3D movement can confuse new players.
- Collision rules are simple enough to predict.
- The game still has HTML title, description, controls and useful page content.
- Performance is tested on mobile or a low-end laptop before publishing.
Three.js can make a game feel impressive quickly, but depth adds camera and readability problems. Solve those first, then add visual ambition.
7. Keep the first 3D prototype tiny
The fastest way to finish a Three.js game is to limit the first prototype to one verb. Collect lights, dodge cubes, roll through gates or aim a turret. Do not start with an open world, inventory, cutscenes and complex models. Build one arena, one player object, one goal and one failure condition. Once that loop feels good, add a second arena or a new enemy.
Use simple names for world objects: `blocks`, `pickups`, `hazards`, `exits`. When something goes wrong, you can inspect a small array instead of searching through scene children. Keep UI in HTML so restart buttons, score text and instructions remain accessible. Three.js is the renderer; your game state should still be plain JavaScript that you understand.