Lesson 49 - Three.js Game Development

Build a Complete Three.js Browser Game: Signal Run Arena

The final project combines the course into one small but complete game: collect every signal crystal, avoid moving sentries, reach the exit, and restart without reloading the page.

Intermediate to advancedThree.js140-190 minutesComplete gameGame architecture

1. Create explicit game state and world groups

State stays in plain JavaScript while Three.js objects live in named groups. Restart can clear a group and reset values without rebuilding the renderer.

import * as THREE from "three";

const state = { mode: "loading", score: 0, lives: 3, crystalsLeft: 0 };
const world = new THREE.Group();
const pickups = new THREE.Group();
const hazards = new THREE.Group();
scene.add(world, pickups, hazards);

const player = new THREE.Mesh(
  new THREE.CapsuleGeometry(0.42, 0.75, 6, 12),
  new THREE.MeshStandardMaterial({ color: 0x54e0d2 })
);
world.add(player);

How this code works

Execution flow

Plain state records mode, score, lives, and remaining objectives, while named Three.js Groups own world, pickup, and hazard visuals. The player mesh is created once and attached to the world group for later movement and reset.

Key Three.js decisions

Separating gameplay state from scene nodes keeps rules testable without WebGL and makes lifecycle ownership visible. Groups allow whole categories to be cleared, hidden, or traversed without searching every scene child by name.

Adapt it

Add dedicated groups for effects, projectiles, and static level geometry, and keep entity arrays beside them for updates. A scene manager can swap complete levels while retaining the renderer, camera, UI, and asset cache.

Watch out

Object3D.clear only detaches children and does not dispose GPU resources. Do not hide critical rules exclusively in userData; when state and meshes disagree, update logic should treat explicit state as authoritative and resynchronize visuals.

2. Build a resettable arena

Spawn pickups and hazards from data. Each object carries only the small metadata needed by collision and update systems.

function resetGame() {
  pickups.clear();
  hazards.clear();
  Object.assign(state, { mode: "playing", score: 0, lives: 3, crystalsLeft: 6 });
  player.position.set(0, 0.8, 8);

  for (const [x, z] of [[-6,-5],[0,-7],[6,-4],[-5,3],[2,2],[7,6]]) {
    const crystal = makeCrystal();
    crystal.position.set(x, 0.8, z);
    pickups.add(crystal);
  }
  spawnSentries(4);
  syncHud();
}

How this code works

Execution flow

resetGame detaches old pickup and hazard children, restores scalar state, returns the player to its spawn, recreates crystals from authored coordinates, spawns sentries, and synchronizes HTML UI before play resumes.

Key Three.js decisions

Data-driven coordinates separate level layout from Mesh construction, while Object.assign makes the reset state easy to audit. Reusing the player and renderer avoids page reload and reveals whether event listeners or resources leak across retries.

Adapt it

Move spawn arrays into level JSON, randomize them with a seed, or select several rounds while preserving one reset contract. Pools can replace clear-and-create when hazards are numerous or contain expensive imported models.

Watch out

clear does not dispose removed meshes, and repeated makeCrystal calls leak if each creates unique geometry or materials. Ensure old sentry update entries are cleared too; otherwise invisible retired enemies can continue colliding or firing.

3. Update movement, hazards, and goals in order

The frame has a visible order: read input, move, resolve pickups and damage, update the camera and UI, then render. Game-over state stops simulation without stopping the page.

function updateGame(dt) {
  if (state.mode !== "playing") return;
  readInput(intent);
  movePlayer(intent, dt);
  updateSentries(dt);
  collectCrystals();
  resolveHazardContact(dt);

  if (state.crystalsLeft === 0 && player.position.distanceTo(exit.position) < 1.4) {
    state.mode = "won";
    message.textContent = "Signal restored!";
    retryButton.hidden = false;
  }
  updateFollowCamera(dt);
  syncHud();
}

How this code works

Execution flow

The update exits outside playing state, reads intent, moves the player, advances sentries, resolves collection and contact damage, checks the final exit condition, then updates camera and HUD. Rendering happens afterward with one coherent state snapshot.

Key Three.js decisions

A fixed update order prevents the camera and UI from observing half-applied gameplay. Explicit mode gates win and retry behavior, while distance to the exit becomes meaningful only after all required crystals are collected.

Adapt it

Insert physics stepping between intent and collision, queue events such as collected or damaged, and process them before UI synchronization. Multi-level games can replace the win assignment with a controlled transition state and loading sequence.

Watch out

Checking victory before movement delays the result by one frame, while applying damage after setting won can produce contradictory states. Avoid updating HTML unnecessarily every frame; sync only changed values when the HUD becomes complex.

4. Own the complete lifecycle

One animation loop and one resize handler live for the game instance. Cleanup cancels the loop, removes listeners, traverses resources, and disposes the renderer.

let frameId;
function frame() {
  const dt = Math.min(clock.getDelta(), 0.05);
  updateGame(dt);
  renderer.render(scene, camera);
  frameId = requestAnimationFrame(frame);
}

retryButton.addEventListener("click", resetGame);
resetGame();
frame();

function destroyGame() {
  cancelAnimationFrame(frameId);
  window.removeEventListener("resize", resize);
  scene.traverse((o) => {
    o.geometry?.dispose();
    if (o.material && !Array.isArray(o.material)) o.material.dispose();
  });
  renderer.dispose();
}

How this code works

Execution flow

One animation request reads capped delta, updates state, renders, and schedules the next frame. The retry button calls reset without reloading, while destroy cancels animation, removes listeners, traverses resources, and disposes the renderer.

Key Three.js decisions

Central lifecycle ownership makes repeated creation and teardown predictable. Optional chaining handles objects without geometry, and renderer disposal releases WebGL programs and context-related caches after scene resources are no longer needed.

Adapt it

Return destroyGame from a factory and call it when navigating between games, closing an editor preview, or replacing a level renderer. Track shared asset-cache ownership so persistent textures survive scene transitions but release on application shutdown.

Watch out

The simplified traversal disposes shared materials multiple times and ignores material arrays and texture maps, so production cleanup needs a deduplicating resource set. Event listeners added with anonymous functions cannot be removed later; retain handler references.

5. Real Supagames example

Production source: biggames/peak-hopper/src/main.js

Play the published example: Peak Hopper

Signal Run uses the same boundaries seen in Supagames big games: scene setup, explicit state, pooled world objects, input intent, fixed update order, collision feedback, UI synchronization, restart, and disposal.

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

  • Adding menus, upgrades, and multiple levels before the collect-avoid-exit loop is fun and understandable.
  • Restarting by reloading the page instead of resetting state, which hides leaked listeners and GPU resources.
  • Putting score and health only inside WebGL, making them harder to read and less accessible on mobile.

7. Build checklist

  • Complete one full run with keyboard, pointer, and touch controls at phone width.
  • Confirm the game has loading, playing, won, lost, and retry states with no ambiguous blank screen.
  • Serve through HTTP, verify module paths, cap pixel ratio, and test a production deployment URL.
  • Add original page copy, controls, canonical metadata, and a useful description around the playable canvas.
Previous: Performance optimization Next: All Supagames lessons