Lesson 42 - Three.js Game Development

Three.js Collision Detection: Triggers, AABBs, Rays, and Swept Motion

Collision is a family of tests, not one universal function. We choose the cheapest shape that correctly answers each gameplay question and avoid allocations in the hottest loops.

IntermediateThree.js80-105 minutesCollisionRaycasting

1. Use squared distance for triggers

Pickups and interaction zones rarely need exact mesh contact. Squared distance avoids a square root and makes the intended radius obvious.

const interactionRadiusSq = 1.5 * 1.5;

function collectNearby(playerPosition, pickups) {
  for (const pickup of pickups) {
    if (!pickup.active) continue;
    if (playerPosition.distanceToSquared(pickup.mesh.position) <= interactionRadiusSq) {
      pickup.active = false;
      pickup.mesh.visible = false;
    }
  }
}

How this code works

Execution flow

The interaction radius is squared once, then each active pickup compares distanceToSquared against that threshold. A successful trigger changes gameplay state and hides the mesh without reallocating the pickup or removing array entries mid-loop.

Key Three.js decisions

Squared distance avoids the square root needed for exact distance and is ideal for radius comparisons. The active flag separates reusable gameplay state from mesh visibility and supports object pooling or later respawn.

Adapt it

Use larger radii for touch-friendly collectibles, separate enter and exit radii for stable zones, or compare horizontal XZ distance when vertical separation should not matter. Spatial partitioning becomes useful when pickup counts reach thousands.

Watch out

Comparing squared distance to an unsquared radius makes the trigger size wrong and difficult to diagnose. Hiding a mesh alone is insufficient if update and collision code does not also check its active state.

2. Update Box3 bounds without guessing

Box3 can derive world-space bounds from a mesh hierarchy. Cache boxes and update them only when relevant objects move.

const playerBox = new THREE.Box3();
const obstacleBox = new THREE.Box3();

function overlapsObstacle(player, obstacle) {
  player.updateWorldMatrix(true, false);
  obstacle.updateWorldMatrix(true, true);
  playerBox.setFromObject(player);
  obstacleBox.setFromObject(obstacle);
  return playerBox.intersectsBox(obstacleBox);
}

How this code works

Execution flow

World matrices are refreshed for player and obstacle hierarchies, each cached Box3 is rebuilt from visible objects, and intersectsBox returns whether their world-space axis-aligned volumes overlap.

Key Three.js decisions

Box3.setFromObject accounts for child meshes and transforms, which is safer than hand-entered dimensions during early prototypes. Cached Box3 instances avoid allocating new boxes for every collision query.

Adapt it

Precompute static obstacle boxes once and update only dynamic actors each frame. For rotated long objects, use Box3 as a broad phase followed by a more precise local test or physics shape when false positives affect gameplay.

Watch out

setFromObject can be expensive on deep animated hierarchies and its axis-aligned result expands as objects rotate. If collisions feel too large, display Box3Helper and replace the visual hierarchy with a deliberately sized collision proxy.

3. Raycast with layers

Raycaster layers prevent aim tests from hitting decoration or UI helpers. Reuse the raycaster and result array to avoid a new allocation on every shot.

const raycaster = new THREE.Raycaster();
const hits = [];
raycaster.layers.set(2);

function aim(origin, direction, targets) {
  raycaster.set(origin, direction);
  raycaster.far = 120;
  hits.length = 0;
  raycaster.intersectObjects(targets, true, hits);
  return hits[0] ?? null;
}

How this code works

Execution flow

A persistent Raycaster receives origin and direction, limits maximum distance, clears a reusable hit array, and intersects only target objects on layer two. The nearest hit or null becomes the aiming result.

Key Three.js decisions

Raycaster layers keep decoration, particles, and helpers out of gameplay queries. Reusing the results array reduces garbage, and recursive intersection supports imported models whose actual meshes sit below a Group root.

Adapt it

Create separate layers for world cover, enemies, interactables, and camera blockers. A weapon can first raycast cover, then enemies up to the cover distance; a mouse picker can derive its ray with setFromCamera.

Watch out

Directions must be normalized or distance behavior becomes confusing. Forgetting to enable the same layer on target meshes returns no hits, while recursive queries over an entire complex scene can become a CPU bottleneck.

4. Sweep fast movement in smaller steps

A fast player can cross a thin obstacle between frames. Divide the intended displacement into bounded steps and stop at the last safe point.

const candidate = new THREE.Vector3();

function moveSwept(position, displacement, isBlocked) {
  const steps = Math.max(1, Math.ceil(displacement.length() / 0.35));
  const step = displacement.clone().multiplyScalar(1 / steps);
  for (let i = 0; i < steps; i += 1) {
    candidate.copy(position).add(step);
    if (isBlocked(candidate)) return false;
    position.copy(candidate);
  }
  return true;
}

How this code works

Execution flow

The desired displacement is divided into segments no longer than the collision tolerance. Each candidate position advances one segment, is tested, and becomes the new position only when safe; the first blocked segment stops movement.

Key Three.js decisions

Sweeping approximates continuous collision using repeated discrete queries and prevents tunneling through thin geometry. A reusable candidate Vector3 limits allocation, while segment length makes accuracy and cost explicit.

Adapt it

Use this for fast projectiles, dashes, or moving platforms when a full physics engine is unnecessary. Improve response by sliding the remaining displacement along a collision normal instead of simply returning false at the first obstacle.

Watch out

Calling displacement.clone inside a very hot loop still allocates once per move; cache the step vector for many actors. Too-large segments tunnel, while extremely small segments multiply collision work and can freeze during extreme velocities.

5. Real Supagames example

Production source: biggames/peak-hopper/engine/physics/collision-obstacles.js

Play the published example: Peak Hopper

Peak Hopper separates terrain, obstacle, projectile, and contact queries. Fracture Forest uses similarly focused tests for world interaction rather than asking a full physics engine to solve every trigger.

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.
Previous: Game cameras Next: Cannon-es physics