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.
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.