Learn Creative Coding (#73) - Physics in 3D: Cannon.js
Back in episode 18 we built a simple physics system from scratch -- springs, friction, basic collision response. Objects bounced, springs pulled, friction slowed things down. But it was all 2D, and we wrote every force calculation by hand. That works for small systems but falls apart when you need rigid body dynamics, rotational physics, stacking, constraints, and collision detection between complex shapes. You'd be writing a physics engine from scratch, and that's a multi-year project on its own.
Cannon.js (specifically the maintained cannon-es fork) is a lightweight JavaScript physics engine that handles all of that. It runs a full rigid body simulation: gravity, mass, velocity, angular momentum, collision detection, friction, restitution, constraints. You create a physics world, add bodies with shapes, step the simulation each frame, and copy the resulting positions and rotations back to your Three.js meshes. The physics engine does the math. Three.js does the rendering. They don't know about each other -- you're the bridge between them.
This episode covers the full setup: creating a physics world, syncing rigid bodies with Three.js meshes, materials and contact properties, collision events, constraints for mechanical systems, stacking and demolition, force fields, and a creative exercise where we build a chain-reaction Rube Goldberg machine entirely from code.
Setting up cannon-es
Cannon-es is an npm package. In a bundled project (Vite, webpack, etc.) you install it normally:
// npm install cannon-es
import * as CANNON from 'cannon-es';
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// --- Three.js scene setup ---
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a12);
const camera = new THREE.PerspectiveCamera(
60, window.innerWidth / window.innerHeight, 0.1, 200
);
camera.position.set(0, 8, 16);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
scene.add(new THREE.AmbientLight(0x222244, 0.8));
const sun = new THREE.DirectionalLight(0xffeedd, 2.0);
sun.position.set(8, 15, 5);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -15;
sun.shadow.camera.right = 15;
sun.shadow.camera.top = 15;
sun.shadow.camera.bottom = -15;
scene.add(sun);
// --- Cannon.js physics world ---
const world = new CANNON.World({
gravity: new CANNON.Vec3(0, -9.82, 0)
});
world.broadphase = new CANNON.SAPBroadphase(world);
world.allowSleep = true;
The physics world has gravity (9.82 m/s^2 downward, same as Earth), a broadphase collision detection algorithm (SAPBroadphase is faster than the default for scenes with many bodies), and sleep enabled (bodies that stop moving go to sleep and skip simulation steps -- important for performance when you have lots of stacked objects).
SAPBroadphase (Sweep and Prune) sorts bodies along each axis and skips collision checks between bodies that don't overlap on any axis. For scenes with dozens to hundreds of bodies, this is significantly faster than NaiveBroadphase which checks every pair. The choice doesn't affect visual results, only performance.
The sync pattern: physics body + Three.js mesh
The core pattern with Cannon.js is simple. Every physics object has two representations: a CANNON.Body (invisible, handles physics) and a THREE.Mesh (visible, handles rendering). Each frame you step the physics, then copy position and quaternion from the body to the mesh.
// ground plane -- static body (mass = 0)
const groundBody = new CANNON.Body({
type: CANNON.Body.STATIC,
shape: new CANNON.Plane()
});
groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
world.addBody(groundBody);
const groundMesh = new THREE.Mesh(
new THREE.PlaneGeometry(30, 30),
new THREE.MeshStandardMaterial({ color: 0x333344, roughness: 0.9 })
);
groundMesh.rotation.x = -Math.PI / 2;
groundMesh.receiveShadow = true;
scene.add(groundMesh);
// dynamic sphere -- falls under gravity
const radius = 0.5;
const sphereBody = new CANNON.Body({
mass: 1,
shape: new CANNON.Sphere(radius),
position: new CANNON.Vec3(0, 8, 0)
});
world.addBody(sphereBody);
const sphereMesh = new THREE.Mesh(
new THREE.SphereGeometry(radius, 24, 24),
new THREE.MeshStandardMaterial({
color: 0x4488cc,
roughness: 0.3,
metalness: 0.2
})
);
sphereMesh.castShadow = true;
scene.add(sphereMesh);
The ground is a static body (mass 0, or type STATIC) -- it doesn't move but other bodies collide with it. The CANNON.Plane is infinite, which is convenient for floors. The sphere has mass 1 and starts at y=8. Gravity will pull it down.
Now the animation loop that syncs them:
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
// step the physics simulation
world.step(1 / 60, delta, 3);
// sync: copy physics state to Three.js meshes
sphereMesh.position.copy(sphereBody.position);
sphereMesh.quaternion.copy(sphereBody.quaternion);
controls.update();
renderer.render(scene, camera);
}
animate();
world.step(fixedTimeStep, deltaTime, maxSubSteps) advances the simulation. The fixed time step of 1/60 keeps the physics deterministic regardless of frame rate. The delta time and max sub steps handle variable frame rates -- if a frame takes longer than 1/60s, the engine runs multiple sub-steps to catch up, capped at 3 to avoid a spiral of death if the simulation lags badly.
position.copy() and quaternion.copy() work because Cannon.js and Three.js use compatible Vec3 and Quaternion formats. The .copy() method reads x, y, z (and w for quaternions) from the source object. Clean and simple.
Drop this in a browser and the sphere falls, hits the ground, bounces a little, and settles. Physics in 6 lines of sync code.
Body types: static, dynamic, kinematic
Cannon.js has three body types and each behaves differently:
Static (mass = 0): doesn't move, doesn't respond to forces or collisions from other bodies, but other bodies collide with it. Floors, walls, platforms, obstacles. These are cheap -- the engine skips most calculations for them.
Dynamic (mass > 0): fully simulated. Affected by gravity, forces, impulses, collisions. The sphere from above is dynamic. This is what you use for everything that should "live" in the physics world.
Kinematic: controlled by code (you set position/velocity directly) but affects dynamic bodies on contact. Think a moving platform or a giant pendulum arm -- it follows a path you define and pushes dynamic bodies out of the way, but isn't affected by them in return. Set via type: CANNON.Body.KINEMATIC.
// kinematic platform that moves side to side
const platformBody = new CANNON.Body({
type: CANNON.Body.KINEMATIC,
shape: new CANNON.Box(new CANNON.Vec3(2, 0.2, 1))
});
world.addBody(platformBody);
const platformMesh = new THREE.Mesh(
new THREE.BoxGeometry(4, 0.4, 2),
new THREE.MeshStandardMaterial({ color: 0x886644, roughness: 0.7 })
);
platformMesh.castShadow = true;
platformMesh.receiveShadow = true;
scene.add(platformMesh);
// in animation loop:
// platformBody.position.x = Math.sin(t * 0.8) * 4;
// platformBody.position.y = 3;
Notice the shape dimensions. CANNON.Box takes half-extents -- a Box(2, 0.2, 1) is 4 units wide, 0.4 tall, 2 deep. Three.js BoxGeometry takes full dimensions. So CANNON half-extents of (2, 0.2, 1) match Three.js dimensions of (4, 0.4, 2). This trips everyone up the first time. Keep it in mind or you'll have physics shapes that are half the size of your visible meshes.
Materials and contact properties
When two bodies collide, how bouncy and how slippery is the contact? That's controlled by materials and contact materials:
const rubberMaterial = new CANNON.Material('rubber');
const iceMaterial = new CANNON.Material('ice');
const metalMaterial = new CANNON.Material('metal');
const groundMaterial = new CANNON.Material('ground');
// rubber on ground: high friction, medium bounce
world.addContactMaterial(new CANNON.ContactMaterial(
rubberMaterial, groundMaterial, {
friction: 0.8,
restitution: 0.5
}
));
// ice on ground: almost no friction, no bounce
world.addContactMaterial(new CANNON.ContactMaterial(
iceMaterial, groundMaterial, {
friction: 0.02,
restitution: 0.05
}
));
// metal on metal: medium friction, high bounce
world.addContactMaterial(new CANNON.ContactMaterial(
metalMaterial, metalMaterial, {
friction: 0.4,
restitution: 0.7
}
));
// apply material to a body
sphereBody.material = rubberMaterial;
groundBody.material = groundMaterial;
Friction is 0-1 (0 = frictionless ice, 1 = sticky rubber). Restitution is bounciness: 0 = dead stop on impact, 1 = perfect elastic bounce (ball returns to its original height). Values above 1 are technically possible and make objects gain energy on bounce -- chaotic but fun for creative coding.
Each ContactMaterial defines the interaction between a specific pair of materials. If two bodies collide and you haven't defined a ContactMaterial for their material pair, Cannon.js uses defaults (friction 0.3, restitution 0.0). Defining explicit pairs lets you make rubber-on-ice behave differently from rubber-on-metal, which is where the physical realism (or deliberate unrealism) comes from.
Collision events
Bodies fire events when they collide. You can listen for these to trigger sounds, spawn particles, change colors, or any other game-logic response:
sphereBody.addEventListener('collide', function (event) {
// event.body is the other body involved
const contact = event.contact;
// impact velocity
const impactVelocity = contact.getImpactVelocityAlongNormal();
if (Math.abs(impactVelocity) > 1.5) {
// strong hit -- flash the sphere red
sphereMesh.material.emissive.set(0xff2222);
sphereMesh.material.emissiveIntensity = 2.0;
// contact point in world coordinates
const contactPoint = new THREE.Vector3();
contactPoint.copy(contact.bi === sphereBody ? contact.ri : contact.rj);
contactPoint.add(sphereMesh.position);
// spawn sparks at contact point (your particle system from ep065)
// spawnSparks(contactPoint, Math.abs(impactVelocity));
}
});
// in the animation loop, decay the flash:
// sphereMesh.material.emissiveIntensity *= 0.92;
getImpactVelocityAlongNormal() gives you a number representing how hard the collision was -- negative means the bodies are approaching (the useful value), positive means they're already separating. Taking the absolute value gives impact strength. A gentle landing might be 0.5. A hard drop from height could be 8+. Threshold at 1.5 to filter out micro-contacts from resting on the ground.
The contact point comes from contact.ri or contact.rj -- these are relative to each body's position, so add the body's world position to get the world-space contact point. This is where you'd spawn impact particles, play a collision sound whose volume scales with impact velocity, or leave a scorch mark.
Stacking and demolition
One of the most satisfying physics demos: stack boxes into a tower, then knock it over with a projectile. The physics engine handles all the cascading collisions, rotations, and settling automatically. We just set up the initial conditions.
const objects = []; // array of { body, mesh } pairs
function createBox(x, y, z, w, h, d, material) {
const halfW = w / 2, halfH = h / 2, halfD = d / 2;
const body = new CANNON.Body({
mass: 1,
shape: new CANNON.Box(new CANNON.Vec3(halfW, halfH, halfD)),
position: new CANNON.Vec3(x, y, z),
material: material
});
world.addBody(body);
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(w, h, d),
new THREE.MeshStandardMaterial({
color: new THREE.Color().setHSL(Math.random() * 0.15 + 0.05, 0.6, 0.45),
roughness: 0.6
})
);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
objects.push({ body, mesh });
return { body, mesh };
}
// build a tower: 10 layers of 3 boxes each, alternating orientation
const boxMat = new CANNON.Material('box');
world.addContactMaterial(new CANNON.ContactMaterial(
boxMat, groundMaterial, { friction: 0.6, restitution: 0.1 }
));
world.addContactMaterial(new CANNON.ContactMaterial(
boxMat, boxMat, { friction: 0.5, restitution: 0.05 }
));
for (let layer = 0; layer < 10; layer++) {
const y = 0.25 + layer * 0.5;
for (let col = 0; col < 3; col++) {
if (layer % 2 === 0) {
// east-west orientation
createBox(-0.55 + col * 0.55, y, 0, 0.5, 0.5, 1.7, boxMat);
} else {
// north-south orientation
createBox(0, y, -0.55 + col * 0.55, 1.7, 0.5, 0.5, boxMat);
}
}
}
30 boxes stacked in a Jenga-like tower. Each layer alternates direction so the tower interlocks. Low restitution (0.05 between boxes) means they don't bounce much -- they settle into a stable stack. Medium friction (0.5) keeps them from sliding off each other.
Now shoot a ball at it:
function shootProjectile() {
const projectileRadius = 0.4;
const body = new CANNON.Body({
mass: 5, // heavier than the boxes
shape: new CANNON.Sphere(projectileRadius),
position: new CANNON.Vec3(8, 3, 0),
material: metalMaterial
});
// launch velocity toward the tower
body.velocity.set(-15, 2, 0);
world.addBody(body);
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(projectileRadius, 16, 16),
new THREE.MeshStandardMaterial({
color: 0xcc2222,
roughness: 0.2,
metalness: 0.6
})
);
mesh.castShadow = true;
scene.add(mesh);
objects.push({ body, mesh });
}
// fire on click
document.addEventListener('click', shootProjectile, { once: true });
The projectile has mass 5 (five times heavier than each box) and launches at velocity (-15, 2, 0) -- fast horizontal with a slight upward arc. When it hits the tower, the physics engine resolves all the collisions: the impact transfers momentum to the hit boxes, which slam into their neighbors, which bump into their neighbors... the whole tower topples, rotates, scatters. Every box finds its own resting position. No code needed for any of that -- the engine handles the entire cascade.
Update the sync loop to handle all objects:
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
world.step(1 / 60, delta, 3);
for (const obj of objects) {
obj.mesh.position.copy(obj.body.position);
obj.mesh.quaternion.copy(obj.body.quaternion);
}
controls.update();
renderer.render(scene, camera);
}
Constraints: building mechanical systems
Constraints connect two bodies with rules about how they can move relative to each other. Cannon.js has several types, and each one enables different mechanical structures:
// PointToPointConstraint -- ball joint
// two bodies connected at a point, free to rotate in all directions
// like a pendulum bob on a string, or a chain link connection
const pivotA = new CANNON.Vec3(0, -0.5, 0); // attach point on body A
const pivotB = new CANNON.Vec3(0, 0.5, 0); // attach point on body B
const p2p = new CANNON.PointToPointConstraint(
bodyA, pivotA,
bodyB, pivotB
);
world.addConstraint(p2p);
// HingeConstraint -- door hinge
// rotation around a single axis only
const hinge = new CANNON.HingeConstraint(bodyA, bodyB, {
pivotA: new CANNON.Vec3(0, 0, -0.5),
pivotB: new CANNON.Vec3(0, 0, 0.5),
axisA: new CANNON.Vec3(0, 1, 0),
axisB: new CANNON.Vec3(0, 1, 0)
});
world.addConstraint(hinge);
// LockConstraint -- rigid connection
// two bodies move as one rigid unit
const lock = new CANNON.LockConstraint(bodyA, bodyB);
world.addConstraint(lock);
// DistanceConstraint -- maintains fixed distance
// like a rigid rod or a taut rope
const dist = new CANNON.DistanceConstraint(bodyA, bodyB, 2.0);
world.addConstraint(dist);
The pivot points are in local coordinates of each body. So pivotA: (0, -0.5, 0) means "attach at the bottom of body A" and pivotB: (0, 0.5, 0) means "attach at the top of body B." When the constraint is active, those two world-space points are forced to overlap (for PointToPoint) or maintain their relative position (for Hinge, Lock, Distance).
Building a chain
Constraints make chains, bridges, and pendulums simple. Here's a chain of spheres hanging from a fixed point:
function createChain(anchorX, anchorY, anchorZ, linkCount) {
const linkRadius = 0.15;
const linkSpacing = 0.35;
const chainObjects = [];
// anchor: static body at the top
const anchor = new CANNON.Body({
type: CANNON.Body.STATIC,
position: new CANNON.Vec3(anchorX, anchorY, anchorZ),
shape: new CANNON.Sphere(0.1)
});
world.addBody(anchor);
let prevBody = anchor;
for (let i = 0; i < linkCount; i++) {
const y = anchorY - (i + 1) * linkSpacing;
const linkBody = new CANNON.Body({
mass: 0.3,
shape: new CANNON.Sphere(linkRadius),
position: new CANNON.Vec3(anchorX, y, anchorZ),
linearDamping: 0.1,
angularDamping: 0.3
});
world.addBody(linkBody);
const linkMesh = new THREE.Mesh(
new THREE.SphereGeometry(linkRadius, 12, 12),
new THREE.MeshStandardMaterial({
color: new THREE.Color().setHSL(0.08, 0.5, 0.4),
roughness: 0.35,
metalness: 0.4
})
);
linkMesh.castShadow = true;
scene.add(linkMesh);
// connect to previous body
const constraint = new CANNON.DistanceConstraint(
prevBody, linkBody, linkSpacing
);
world.addConstraint(constraint);
objects.push({ body: linkBody, mesh: linkMesh });
prevBody = linkBody;
}
return chainObjects;
}
createChain(0, 10, 0, 12);
12 links hanging from a static anchor point. Each link is connected to the previous one by a DistanceConstraint that maintains the spacing. Gravity pulls the chain straight down. Give any link a push (apply a velocity or force) and the whole chain swings, each link affecting the next. The damping values (linearDamping and angularDamping) make the chain lose energy over time so it eventually comes to rest instead of swinging forever.
Chains are one of those things that look incredibly complex but are trivially simple with constraints. A pendulum is just a chain with one link. A bridge is a chain laid horizontal between two anchors with planks attached. A ragdoll is a branching chain with hinge constraints at joints.
Force fields
Applying forces to bodies each frame creates force fields -- regions of the scene where physics objects experience persistent pushes. Central gravity, wind, vortexes, explosions:
function applyForceField(objects, t) {
for (const obj of objects) {
if (obj.body.type !== CANNON.Body.DYNAMIC) continue;
const pos = obj.body.position;
// central attraction -- pull toward (0, 5, 0)
const center = new CANNON.Vec3(0, 5, 0);
const toCenter = new CANNON.Vec3();
center.vsub(pos, toCenter);
const dist = toCenter.length();
if (dist > 0.5) {
toCenter.normalize();
toCenter.scale(3.0 / (dist * dist + 1), toCenter);
obj.body.applyForce(toCenter);
}
// wind -- constant horizontal push
const wind = new CANNON.Vec3(
Math.sin(t * 0.5) * 2.0,
0,
Math.cos(t * 0.7) * 1.5
);
obj.body.applyForce(wind);
// vortex -- tangential force around Y axis
const tangent = new CANNON.Vec3(-pos.z, 0, pos.x);
tangent.normalize();
tangent.scale(1.5, tangent);
obj.body.applyForce(tangent);
}
}
applyForce adds a force for one simulation step -- you need to call it every frame for continuous effects. Central attraction uses inverse-square falloff (the + 1 in the denominator prevents division by zero when objects are at the center). Wind is a time-varying constant direction. The vortex uses the tangent vector (perpendicular to the radial direction) to spin objects around the Y axis.
Combine all three and objects spiral inward while being blown around by shifting winds. Add some visual feedback -- a subtle wireframe sphere at the attraction center, particle trails following the wind direction -- and you've got a physics-driven abstract sculpture. The art is in tuning the force magnitudes and offsets until the motion feels right.
Dominoes
A line of dominoes is one of the purest physics chain-reaction demos. Place thin boxes upright with precise spacing, tip the first one, and watch the cascade:
function createDominoes(path, count) {
const dominoWidth = 0.1;
const dominoHeight = 0.6;
const dominoDepth = 0.3;
const halfW = dominoWidth / 2;
const halfH = dominoHeight / 2;
const halfD = dominoDepth / 2;
const dominoMat = new CANNON.Material('domino');
world.addContactMaterial(new CANNON.ContactMaterial(
dominoMat, dominoMat, { friction: 0.3, restitution: 0.05 }
));
world.addContactMaterial(new CANNON.ContactMaterial(
dominoMat, groundMaterial, { friction: 0.5, restitution: 0.0 }
));
for (let i = 0; i < count; i++) {
const t = i / (count - 1);
const point = path(t);
const nextPoint = path(Math.min(t + 0.01, 1));
// direction along path
const dx = nextPoint.x - point.x;
const dz = nextPoint.z - point.z;
const angle = Math.atan2(dx, dz);
const body = new CANNON.Body({
mass: 0.3,
shape: new CANNON.Box(new CANNON.Vec3(halfW, halfH, halfD)),
position: new CANNON.Vec3(point.x, halfH, point.z),
material: dominoMat
});
body.quaternion.setFromEuler(0, angle, 0);
world.addBody(body);
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(dominoWidth, dominoHeight, dominoDepth),
new THREE.MeshStandardMaterial({
color: new THREE.Color().setHSL(t * 0.3 + 0.55, 0.5, 0.45),
roughness: 0.5
})
);
mesh.castShadow = true;
scene.add(mesh);
objects.push({ body, mesh });
}
}
// spiral path
function spiralPath(t) {
const angle = t * Math.PI * 6; // 3 full turns
const radius = 1.5 + t * 4;
return {
x: Math.cos(angle) * radius,
z: Math.sin(angle) * radius
};
}
createDominoes(spiralPath, 80);
80 dominoes arranged in a spiral. Each one is oriented to face along the path direction so when it falls, it tips into the next one. The path function parameterizes the curve -- swap spiralPath for any function that returns {x, z} and the dominoes follow it. A sine wave, a figure eight, a heart shape, branching paths where one domino triggers two lines...
To start the cascade, push the first domino:
// tip the first domino
objects[0].body.applyImpulse(
new CANNON.Vec3(0.3, 0, 0),
new CANNON.Vec3(0, 0.3, 0) // apply at the top for maximum torque
);
The impulse at the top of the domino creates torque that tips it forward. It hits the second domino, which hits the third, and the wave propagates around the spiral. The spacing is critical -- too far apart and the falling domino doesn't reach the next one. Too close and they jam up. The ratio of spacing to domino height needs to be roughly 0.5-0.7 for reliable cascading. That's something you tune by watching the simulation run.
Performance considerations
Cannon.js handles hundres of rigid bodies comfortably at 60fps. The main costs are:
Collision detection: scales roughly with the number of body pairs that could potentially collide. SAPBroadphase reduces this dramatically compared to NaiveBroadphase by eliminating pairs that don't overlap on any axis.
Simple shapes are faster: Sphere, Box, Plane, and Cylinder have optimized collision routines. ConvexPolyhedron (arbitrary convex shapes) is significantly slower. Trimesh (arbitrary triangle meshes) is the slowest and only works as a static body. For dynamic bodies, approximate complex shapes with combinations of simple ones.
Sleep mode: allowSleep = true on the world means bodies that have been still for a while stop being simulated. This is huge for stacking scenarios -- once the tower settles, those 30 boxes cost almost nothing. They wake up automatically when another body collides with them.
Solver iterations: world.solver.iterations controls accuracy. Default is 10. Higher values mean more accurate stacking and constraint behavior but cost more CPU. For creative coding, 10 is usually fine. If stacks jitter or constraints stretch, bump it to 20.
// performance tuning
world.solver.iterations = 10;
world.solver.tolerance = 0.001;
world.allowSleep = true;
world.broadphase = new CANNON.SAPBroadphase(world);
For truly large scenes (500+ bodies), consider removing bodies that fall below the scene or fly too far away. Dead bodies that fell off the edge still cost simulation time if they're not cleaned up:
// cleanup: remove bodies that fell off the world
for (let i = objects.length - 1; i >= 0; i--) {
if (objects[i].body.position.y < -20) {
world.removeBody(objects[i].body);
scene.remove(objects[i].mesh);
objects[i].mesh.geometry.dispose();
objects[i].mesh.material.dispose();
objects.splice(i, 1);
}
}
Creative exercise: Rube Goldberg machine
Allez, time to put it all together. A chain reaction machine: dominoes tip a ball off a ledge, the ball rolls down a ramp, hits a stack of boxes that scatter, one box swings a pendulum, the pendulum knocks more dominoes. All placed with code, all simulated with physics. A single push starts it and the whole thing plays out on its own.
// --- Rube Goldberg machine setup ---
// materials
const woodMat = new CANNON.Material('wood');
const stoneMat = new CANNON.Material('stone');
world.addContactMaterial(new CANNON.ContactMaterial(
woodMat, stoneMat, { friction: 0.5, restitution: 0.15 }
));
world.addContactMaterial(new CANNON.ContactMaterial(
woodMat, woodMat, { friction: 0.4, restitution: 0.1 }
));
world.addContactMaterial(new CANNON.ContactMaterial(
stoneMat, groundMaterial, { friction: 0.7, restitution: 0.05 }
));
world.addContactMaterial(new CANNON.ContactMaterial(
woodMat, groundMaterial, { friction: 0.5, restitution: 0.1 }
));
// helper: create static platform
function createPlatform(x, y, z, w, h, d) {
const body = new CANNON.Body({
type: CANNON.Body.STATIC,
shape: new CANNON.Box(new CANNON.Vec3(w / 2, h / 2, d / 2)),
position: new CANNON.Vec3(x, y, z),
material: stoneMat
});
world.addBody(body);
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(w, h, d),
new THREE.MeshStandardMaterial({ color: 0x555566, roughness: 0.8 })
);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
objects.push({ body, mesh });
}
// helper: create dynamic box
function createDynamicBox(x, y, z, w, h, d, mat, color) {
const body = new CANNON.Body({
mass: 0.5,
shape: new CANNON.Box(new CANNON.Vec3(w / 2, h / 2, d / 2)),
position: new CANNON.Vec3(x, y, z),
material: mat
});
world.addBody(body);
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(w, h, d),
new THREE.MeshStandardMaterial({ color: color, roughness: 0.5 })
);
mesh.castShadow = true;
scene.add(mesh);
objects.push({ body, mesh });
return { body, mesh };
}
// Stage 1: elevated platform with dominoes
createPlatform(-6, 3, 0, 6, 0.3, 2);
for (let i = 0; i < 8; i++) {
createDynamicBox(
-8.5 + i * 0.45, 3.45, 0,
0.08, 0.6, 0.25, woodMat, 0x8b6b4a
);
}
// Stage 2: ball at the end of the domino line
const ballBody = new CANNON.Body({
mass: 1.5,
shape: new CANNON.Sphere(0.3),
position: new CANNON.Vec3(-4.2, 3.5, 0),
material: stoneMat
});
world.addBody(ballBody);
const ballMesh = new THREE.Mesh(
new THREE.SphereGeometry(0.3, 20, 20),
new THREE.MeshStandardMaterial({
color: 0xcc3333,
roughness: 0.2,
metalness: 0.5
})
);
ballMesh.castShadow = true;
scene.add(ballMesh);
objects.push({ body: ballBody, mesh: ballMesh });
// Stage 3: ramp from platform down to ground level
const rampBody = new CANNON.Body({
type: CANNON.Body.STATIC,
shape: new CANNON.Box(new CANNON.Vec3(2, 0.1, 0.8)),
position: new CANNON.Vec3(-2, 1.8, 0),
material: stoneMat
});
rampBody.quaternion.setFromEuler(0, 0, Math.PI * 0.15);
world.addBody(rampBody);
const rampMesh = new THREE.Mesh(
new THREE.BoxGeometry(4, 0.2, 1.6),
new THREE.MeshStandardMaterial({ color: 0x666677, roughness: 0.7 })
);
rampMesh.castShadow = true;
rampMesh.receiveShadow = true;
scene.add(rampMesh);
objects.push({ body: rampBody, mesh: rampMesh });
// Stage 4: box tower at the bottom of the ramp
for (let layer = 0; layer < 4; layer++) {
for (let col = 0; col < 2; col++) {
createDynamicBox(
1.5 + col * 0.55, 0.25 + layer * 0.5, 0,
0.5, 0.5, 0.5, woodMat,
new THREE.Color().setHSL(0.1 + layer * 0.05, 0.5, 0.45).getHex()
);
}
}
// Stage 5: pendulum triggered by flying box debris
const pendulumAnchor = new CANNON.Body({
type: CANNON.Body.STATIC,
position: new CANNON.Vec3(4, 6, 0),
shape: new CANNON.Sphere(0.05)
});
world.addBody(pendulumAnchor);
const pendulumBob = new CANNON.Body({
mass: 3,
shape: new CANNON.Sphere(0.4),
position: new CANNON.Vec3(4, 2.5, 0),
material: stoneMat
});
world.addBody(pendulumBob);
const pendulumMesh = new THREE.Mesh(
new THREE.SphereGeometry(0.4, 20, 20),
new THREE.MeshStandardMaterial({
color: 0x44aacc,
roughness: 0.15,
metalness: 0.6
})
);
pendulumMesh.castShadow = true;
scene.add(pendulumMesh);
objects.push({ body: pendulumBob, mesh: pendulumMesh });
// connect pendulum to anchor
const pendulumConstraint = new CANNON.DistanceConstraint(
pendulumAnchor, pendulumBob, 3.5
);
world.addConstraint(pendulumConstraint);
// Stage 6: more dominoes on the other side for the pendulum to hit
for (let i = 0; i < 10; i++) {
createDynamicBox(
5.5 + i * 0.4, 0.3, 0,
0.08, 0.6, 0.25, woodMat, 0x6b8b4a
);
}
The machine flows: dominoes (stage 1) tip into each other, the last one nudges the ball (stage 2), the ball rolls down the ramp (stage 3), crashes into the box tower (stage 4), scattered boxes hit the pendulum bob (stage 5), the pendulum swings and knocks over the second line of dominoes (stage 6).
Start the whole thing with one push on the first domino:
document.addEventListener('click', () => {
objects[0].body.applyImpulse(
new CANNON.Vec3(0.2, 0, 0),
new CANNON.Vec3(0, 0.3, 0)
);
}, { once: true });
Every stage depends on the previous one's physics output. The ball needs enough momentum from the domino push to roll off the platform and down the ramp. The ramp angle needs to accelerate the ball enough to scatter the tower. The tower pieces need to fly far enough to reach the pendulum. Tuning these spatial relationships is the creative challenge -- move things a few centimeters and a stage might fail. That's the joy of Rube Goldberg machines: precision in setup, chaos in execution.
Add a slow orbiting camera and some bloom post-processing from ep069, and you've got something genuinely cinematic. One click, then sit back and watch the whole machine play out. The physics engine does all the hard work.
What's ahead
We've added real rigid body physics to our 3D toolkit -- gravity, collisions, materials, constraints, force fields. Objects have mass now. They fall, bounce, stack, swing, topple, and cascade. The creative possibilities when your code generates not just shapes but physical systems are huge.
Further on in this arc we'll get into building full 3D environments with terrain, sky, and atmosphere. The physics from today combined with procedural geometry from ep066 and instancing from ep070 means we can create entire worlds that feel tangible.
Allez, wa weten we nu allemaal?
- Cannon.js (
cannon-esnpm package) is a rigid body physics engine that pairs with Three.js. You create aCANNON.Worldwith gravity, addCANNON.Bodyobjects with shapes (Sphere, Box, Plane, Cylinder), step the simulation each frame withworld.step(), and copybody.positionandbody.quaternionto your Three.js meshes. The two systems are completely independent -- you're the sync layer - Body types: static (mass 0, doesn't move, acts as scenery), dynamic (mass > 0, fully simulated under gravity and forces), kinematic (code-controlled position that pushes dynamic bodies). CANNON.Box takes half-extents, Three.js BoxGeometry takes full dimensions -- remember to multiply/divide by 2
- Materials and ContactMaterial define friction (0-1, how slippery) and restitution (0-1, how bouncy) for each pair of colliding material types. Without explicit ContactMaterial, defaults apply (friction 0.3, restitution 0.0). Rubber on ice behaves differently from metal on metal -- the whole tactile feel comes from these two numbers
- Collision events via
body.addEventListener('collide', callback)give you impact velocity, contact point, and the other body. Use impact strength to trigger proportional effects -- soft landings are quiet, hard impacts flash and spark - Constraints connect bodies: PointToPointConstraint (ball joint, free rotation), HingeConstraint (single-axis rotation, doors and joints), LockConstraint (rigid weld), DistanceConstraint (maintains fixed spacing, ropes and chains). Pivot points are in each body's local coordinates
- Chains are just a loop of DistanceConstraints between consecutive bodies with a static anchor at the top. Pendulums are one-link chains. Bridges are horizontal chains. The constraint solver handles all the tension and swing math automatically
- Force fields: call
body.applyForce(vec)every frame for persistent forces. Central attraction (inverse-square toward a point), wind (time-varying constant direction), vortex (tangential force around an axis). Combine for complex motion patterns - Dominoes: thin boxes placed along a parametric path, oriented to face the path direction. Tip the first one with
applyImpulseat the top for maximum torque. Spacing-to-height ratio of 0.5-0.7 gives reliable cascading - Performance: SAPBroadphase for efficient collision culling,
allowSleep = trueso resting bodies cost nothing, simple shapes (Sphere, Box) over ConvexPolyhedron. Clean up bodies that fall below the world. Cannon.js handles hundreds of bodies comfortably
Sallukes! Thanks for reading.
X