Three.js & cannon-es Game Development
Techniques and patterns for building 3D web games with Three.js (rendering) and cannon-es (physics).
Project Setup
- •Three.js version: ^0.170.0
- •cannon-es version: ^0.20.0
- •Bundler: Vite 6
- •TypeScript: strict mode, ES2022 target
Rendering
Scene Configuration
const scene = new THREE.Scene(); scene.background = new THREE.Color(0x87CEEB); // Sky blue scene.fog = new THREE.Fog(0x87CEEB, 50, 200); // Distance fog
Renderer Settings
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // Cap for performance
Lighting Setup (Platformer Style)
// Ambient (base illumination) new THREE.AmbientLight(0xffffff, 0.5); // Hemisphere (sky + ground bounce) new THREE.HemisphereLight(0x87CEEB, 0x8B4513, 0.4); // Directional sun (shadows) const sun = new THREE.DirectionalLight(0xffffff, 1.2); sun.position.set(50, 80, 30); sun.castShadow = true; sun.shadow.mapSize.set(2048, 2048); sun.shadow.camera.left = -50; sun.shadow.camera.right = 50; sun.shadow.camera.top = 50; sun.shadow.camera.bottom = -50;
Material Best Practices
// Standard lit material (preferred for most objects)
new THREE.MeshStandardMaterial({
color: 0xFF0000,
roughness: 0.8,
metalness: 0.1,
});
// Glowing / emissive material (coins, power-ups)
new THREE.MeshStandardMaterial({
color: 0xFFD700,
metalness: 0.8,
roughness: 0.2,
emissive: 0xFFA000,
emissiveIntensity: 0.3,
});
// Transparent overlay (glow effects, shadows)
new THREE.MeshBasicMaterial({
color: 0xFFD700,
transparent: true,
opacity: 0.15,
});
Common Geometries
| Shape | Three.js | Typical Use |
|---|---|---|
| Box | BoxGeometry(w, h, d) | Platforms, blocks, body parts |
| Sphere | SphereGeometry(r, wSeg, hSeg) | Heads, eyes, projectiles |
| Cylinder | CylinderGeometry(rTop, rBot, h, seg) | Pipes, hats, coins, limbs |
| Plane | PlaneGeometry(w, h) | Shadow decals, flat surfaces |
Shadow Rules
- •
castShadow = trueon all visible character/object meshes - •
receiveShadow = trueon ground and large platforms - •Don't enable shadows on transparent/glow overlays
- •Shadow decals:
PlaneGeometrywith low-opacity darkMeshBasicMaterial,depthWrite: false
Physics (cannon-es)
World Configuration
const world = new CANNON.World({
gravity: new CANNON.Vec3(0, -25, 0), // Strong gravity for platformer
});
world.broadphase = new CANNON.SAPBroadphase(world);
world.defaultContactMaterial.friction = 0.3;
world.defaultContactMaterial.restitution = 0.1;
Body Types
// Static (platforms, walls) — mass = 0
new CANNON.Body({ mass: 0, shape: new CANNON.Box(...) });
// Dynamic (player) — mass > 0
new CANNON.Body({ mass: 1, fixedRotation: true, linearDamping: 0.1 });
// Trigger (collectibles) — no physical collision
new CANNON.Body({ mass: 0, isTrigger: true, collisionResponse: false });
// Kinematic (enemies) — mass = 0, move via position
new CANNON.Body({ mass: 0, collisionResponse: true });
Collision Detection
body.addEventListener('collide', (event: any) => {
const contact = event.contact;
const normal = contact.ni;
// Check collision direction using normal vector
});
Physics-Visual Sync
// Option A: Built-in helper this.syncMeshToBody(); // Option B: Manual (for offset) this.mesh.position.set( this.body.position.x, this.body.position.y - 0.5, // visual offset this.body.position.z );
Camera
Third-Person Orbit Camera
// Spherical coordinates around target const offsetX = Math.sin(rotX) * Math.cos(rotY) * distance; const offsetY = Math.sin(rotY) * distance; const offsetZ = Math.cos(rotX) * Math.cos(rotY) * distance; // Smooth follow with lerp const t = 1 - Math.pow(0.001, deltaTime * smoothSpeed); currentPos.lerp(desiredPos, t); camera.lookAt(targetPos);
Performance Tips
- •Cap
deltaTimeto 0.05s to prevent physics explosion on tab-switch - •Cap
pixelRatioto 2 to avoid excessive GPU workload on retina screens - •Use
SAPBroadphasefor physics (better than defaultNaiveBroadphase) - •Keep polygon counts low: 8-16 segments for cylinders/spheres
- •Reuse materials across objects when colors match
- •Use
THREE.Groupfor complex objects — easier transforms and cleanup
Techniques Added: 2026-02-11
Reliable Ground Detection (cannon-es)
The collision normal direction depends on which body is bi vs bj. Always check contact.bi === this.body to determine the correct sign:
this.body.addEventListener('collide', (event: any) => {
const contact = event.contact;
const normal = contact.ni;
const isBodyA = contact.bi === this.body;
const upDot = isBodyA ? -normal.y : normal.y;
if (upDot > 0.5) {
this.isGrounded = true;
}
});
Pitfall: Using event.body === this.body or checking raw normal.y without body-order correction will give wrong results on platforms.
Pitfall: Do NOT use position-based ground checks like body.position.y < 1.5 — this breaks on elevated platforms. Use collision normals or velocity-based fallback instead.
Velocity-Based Grounded Fallback
Complement collision-based detection with a velocity check for edge cases:
// If velocity.y is near zero, body is resting on something
if (Math.abs(this.body.velocity.y) < 0.3 && !this.isGrounded) {
this.isGrounded = true;
}
// If clearly falling, mark as not grounded
if (this.body.velocity.y < -2) {
this.isGrounded = false;
}
Disabling collisionResponse for Death/Ghost
Toggle body.collisionResponse at runtime to make objects pass through platforms:
this.body.collisionResponse = false; // Ghost mode — falls through everything this.body.collisionResponse = true; // Restore normal collisions
Useful for death animations (body pops up then falls through floor).
Platformer Speed Constants (with gravity -25)
Tuned values that feel right with the project's strong gravity:
| Parameter | Value | Notes |
|---|---|---|
| Walk speed | 14 | Camera-relative |
| Run speed | 22 | Hold Shift |
| Jump force | 13 | Single jump |
| Double jump | 15 | Within 0.4s window |
| Triple jump | 19 | Within 0.4s window |
| Ground pound | -20 | Instant downward velocity |
| Death pop | 12 | Upward velocity on die |
Loading 3D Models with ColladaLoader
The project uses ColladaLoader from three/examples/jsm/loaders/ColladaLoader.js to load .dae (Collada) model files.
Z_UP Container Pattern: ColladaLoader may apply a rotation to convert from Z_UP to Y_UP coordinate systems. If you also need to rotate/animate the model, wrap it in an intermediate THREE.Group container so the loader's correction isn't overwritten:
const container = new THREE.Group(); container.add(collada.scene); parentGroup.add(container);
Model Scaling: External models often use different unit scales. Calculate scale factor as desiredSize / nativeSize (e.g., ~90 native units → 0.02 scale for ~1.8 game units).
Shadow Traversal: Loaded models don't have shadows enabled by default. Traverse all children:
model.traverse((child) => {
if ((child as THREE.Mesh).isMesh) {
(child as THREE.Mesh).castShadow = true;
(child as THREE.Mesh).receiveShadow = true;
}
});
Async Loading Guard: Since model loading is async, use a boolean flag (modelLoaded) and guard any code that depends on the model's presence.
Asset Organization
Store model files and their textures together under public/assets/<name>/. Textures referenced by .dae files are resolved relative to the model file. Current asset structure:
- •
/public/assets/mario/mario.dae— Collada model (primary) - •
/public/assets/mario/mario.fbx— FBX format (alternative) - •
/public/assets/mario/*.png— Textures (color maps, eye variants, detail textures)
Serve assets from public/ so Vite makes them available at the root path (e.g., /assets/mario/mario.dae).