Lesson 43 - Three.js Game Development

Three.js with Cannon-es: Physics Bodies, Heightfields, and Stable Steps

Three.js draws the world; Cannon-es simulates mass, velocity, and contact. We will keep those responsibilities separate and synchronize them without introducing frame-rate-dependent physics.

Intermediate to advancedThree.js85-115 minutesCannon-esPhysics

1. Create a stable physics world

Use gravity, broadphase, and sleep settings as explicit game configuration. Fixed stepping belongs in the main loop, not inside the renderer.

import * as THREE from "three";
import * as CANNON from "cannon-es";

const world = new CANNON.World({ gravity: new CANNON.Vec3(0, -18, 0) });
world.broadphase = new CANNON.SAPBroadphase(world);
world.allowSleep = true;
world.defaultContactMaterial.friction = 0.35;

function simulate(dt) {
  world.step(1 / 60, dt, 4);
}

How this code works

Execution flow

Cannon-es creates a World with downward gravity, replaces the default broadphase with sweep-and-prune, enables sleeping, and configures baseline friction. The game loop later advances this world using a fixed internal step and bounded substeps.

Key Three.js decisions

Three.js remains the renderer while Cannon-es owns physical state. SAPBroadphase performs well when many bodies move modestly, sleeping removes settled bodies from active work, and explicit contact defaults give every unconfigured pair predictable behavior.

Adapt it

Lower gravity for floaty platforming, increase it for responsive racing suspension, and add named ContactMaterials for ice, tires, or mud. Keep world setup in one module so levels cannot silently choose incompatible physics constants.

Watch out

Passing an uncapped frame delta can force too many substeps after a stall. If bodies jitter while resting, inspect fixed-step timing, solver iterations, body scale, and contact parameters before increasing damping everywhere.

2. Pair a visual mesh with a body

The mesh is not the collider. Give each object a body and a mesh, then copy transforms after simulation. Keep body dimensions aligned with the visible silhouette.

const playerMesh = new THREE.Mesh(
  new THREE.SphereGeometry(0.55, 24, 16),
  new THREE.MeshStandardMaterial({ color: 0x56d6ff })
);
const playerBody = new CANNON.Body({
  mass: 2,
  shape: new CANNON.Sphere(0.55),
  position: new CANNON.Vec3(0, 5, 0),
});
world.addBody(playerBody);
scene.add(playerMesh);

How this code works

Execution flow

The sample builds a Three.js sphere for appearance and a Cannon-es sphere body for simulation, gives the body mass and initial position, and registers each object with its owning system. They remain separate until synchronization after stepping.

Key Three.js decisions

Matching sphere radii keeps contact aligned with the silhouette, while mass greater than zero makes the Cannon body dynamic. The visual material can change freely without altering physics, and collision shape complexity can stay lower than model complexity.

Adapt it

Pair a character model with a capsule body, a vehicle mesh with a compound chassis, or a destructible prop with a simple box. Store both references in one entity object so reset and disposal cannot forget half of the pair.

Watch out

Adding only the mesh produces visuals with no collision; adding only the body creates an invisible obstacle. Mismatched units or origins cause hovering and sinking, so inspect both transforms with debug wireframes before adding offsets.

3. Apply intent as force or impulse

Set horizontal force for sustained control and reserve impulse for discrete actions such as jumping. Avoid teleporting a dynamic body every frame.

function drive(intent) {
  const force = 28;
  playerBody.force.x += intent.move.x * force;
  playerBody.force.z -= intent.move.y * force;
}

function jump() {
  if (Math.abs(playerBody.velocity.y) < 0.15) {
    playerBody.applyImpulse(new CANNON.Vec3(0, 8.5, 0));
  }
}

How this code works

Execution flow

Each simulation step converts movement intent into horizontal force accumulated on the body. Jump checks near-zero vertical velocity as a simple grounded approximation, then applies one upward impulse for an immediate velocity change.

Key Three.js decisions

Force represents sustained acceleration and naturally interacts with mass, while impulse represents a discrete event such as jumping or recoil. applyImpulse lets Cannon-es resolve the resulting motion instead of teleporting the body through contacts.

Adapt it

Clamp horizontal speed for arcade control, transform force by camera orientation, and replace the velocity check with a downward contact or ray test. Use impulses for explosions and knockback with direction derived from the impact source.

Watch out

Vertical velocity near zero is not a reliable grounded test at the top of a jump or against a ceiling. Repeated jump input can also apply multiple impulses unless the action is edge-triggered and grounded state comes from actual contact data.

4. Synchronize after each step

Copy the body transform into Three.js after physics. If a model has a visual offset, parent it under a neutral group rather than corrupting the physics transform.

function syncPhysics() {
  playerMesh.position.copy(playerBody.position);
  playerMesh.quaternion.copy(playerBody.quaternion);
}

function frame() {
  const dt = Math.min(clock.getDelta(), 0.05);
  drive(intent);
  simulate(dt);
  syncPhysics();
  renderer.render(scene, camera);
  requestAnimationFrame(frame);
}

How this code works

Execution flow

Input applies forces, Cannon-es advances the world, and syncPhysics copies the resulting body position and quaternion into the Three.js mesh. Only after synchronization does the renderer draw the current physical state.

Key Three.js decisions

Cannon vectors and quaternions expose compatible x, y, z, and w fields, so Three.js copy methods can transfer values directly. Keeping synchronization after physics prevents the picture from being one simulation step behind collisions.

Adapt it

For many entities, store body-mesh pairs and loop through only active dynamic bodies. Add interpolation between previous and current body transforms when rendering faster than the fixed physics rate.

Watch out

Copying mesh transforms back into dynamic bodies every frame fights the solver and creates jitter. Kinematic objects are different: update their body intentionally before stepping and still synchronize visual state in one documented direction.

5. Real Supagames example

Production source: js/environment-engine/physics/world.js

Play the published example: Valley Racer

The environment engine creates an optional Cannon-es world and terrain collider, then lets games attach their own dynamic bodies. The examples keep the same separation while using a single rolling player body.

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: Collision detection Next: GLB assets and loading