Build a Physics Engine from Scratch
No library. Just JavaScript. This tutorial walks through every layer of a minimal rigid body engine: symplectic Euler integration, axis-aligned bounding box (AABB) collision detection, impulse-based collision response with restitution, and friction. The result: a box-stacking and bouncing simulation you built yourself.
- Basic JavaScript classes, arrays, and the canvas 2D API
- Understanding of vectors (addition, scalar multiply, dot product)
- No prior physics engine knowledge required
Body Data Structure
Each physics body needs position, velocity, size, mass, and
restitution (bounciness). Infinite mass (invMass = 0) is
used for static walls:
class Body {
constructor({ x, y, w, h, mass = 1, restitution = 0.5, isStatic = false }) {
this.x = x; this.y = y; // center position
this.w = w; this.h = h; // half-extents (AABB)
this.vx = 0; this.vy = 0; // velocity
this.restitution = restitution;
this.invMass = isStatic ? 0 : 1 / mass;
}
}
Symplectic Euler Integration
Symplectic Euler updates velocity first, then position with the new velocity. This conserves energy better than standard Euler and is the standard for game physics:
const GRAVITY = 980; // px/s²
function integrate(body, dt) {
if (body.invMass === 0) return; // static body — skip
// Apply gravity (force = mass * g → acceleration = g)
body.vy += GRAVITY * dt; // velocity first
// Then update position with new velocity
body.x += body.vx * dt;
body.y += body.vy * dt;
}
Why not standard Euler (pos += vel * dt; vel += accel * dt)? Standard Euler gains energy over time — a bouncing ball gets
higher with each bounce. Symplectic Euler reverses the order and
maintains energy much better.
AABB Collision Detection
Two axis-aligned boxes overlap if and only if they overlap on both axes. The penetration depth on each axis tells us how much to push them apart:
function detectAABB(a, b) {
// Overlap on x axis
const dx = b.x - a.x;
const overlapX = (a.w + b.w) - Math.abs(dx);
if (overlapX <= 0) return null; // no collision
// Overlap on y axis
const dy = b.y - a.y;
const overlapY = (a.h + b.h) - Math.abs(dy);
if (overlapY <= 0) return null; // no collision
// Minimum separation axis — push out along the smaller overlap
let nx, ny, depth;
if (overlapX < overlapY) {
nx = dx < 0 ? -1 : 1;
ny = 0;
depth = overlapX;
} else {
nx = 0;
ny = dy < 0 ? -1 : 1;
depth = overlapY;
}
return { nx, ny, depth }; // collision normal + penetration
}
Impulse-Based Collision Response
An impulse is an instantaneous change in momentum (J = Δ(mv)). Given a contact normal, we compute the impulse magnitude that exactly prevents penetration and adds the desired bounce:
function resolveCollision(a, b, contact) {
const { nx, ny, depth } = contact;
// 1. Positional correction — push bodies apart
const totalInvMass = a.invMass + b.invMass;
if (totalInvMass === 0) return; // both static
const correction = depth / totalInvMass * 0.8; // 0.8 = "slop" avoidance
a.x -= nx * correction * a.invMass;
a.y -= ny * correction * a.invMass;
b.x += nx * correction * b.invMass;
b.y += ny * correction * b.invMass;
// 2. Velocity component along normal
const relVn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (relVn > 0) return; // bodies already separating — no impulse needed
// 3. Restitution coefficient (combined)
const e = Math.min(a.restitution, b.restitution);
// 4. Impulse scalar: j = -(1+e) * relVn / (1/mA + 1/mB)
const j = -(1 + e) * relVn / totalInvMass;
// 5. Apply impulse
a.vx -= j * nx * a.invMass;
a.vy -= j * ny * a.invMass;
b.vx += j * nx * b.invMass;
b.vy += j * ny * b.invMass;
}
The restitution (coefficient of restitution) controls bounciness: 0 = perfectly inelastic (no bounce), 1 = perfectly elastic (full bounce). Real rubber is ~0.8, steel ball bearings ~0.95.
Friction
After the normal impulse, apply a tangential (friction) impulse to slow down sliding:
function applyFriction(a, b, contact, j) {
const { nx, ny } = contact;
// Tangent = perpendicular to normal
const tx = -ny, ty = nx;
const relVt = (b.vx - a.vx) * tx + (b.vy - a.vy) * ty;
const totalInvMass = a.invMass + b.invMass;
if (totalInvMass === 0) return;
const mu = 0.3; // friction coefficient
let jt = -relVt / totalInvMass;
// Clamp to Coulomb friction cone: |jt| ≤ μ * |j|
jt = Math.max(-mu * Math.abs(j), Math.min(mu * Math.abs(j), jt));
a.vx -= jt * tx * a.invMass;
a.vy -= jt * ty * a.invMass;
b.vx += jt * tx * b.invMass;
b.vy += jt * ty * b.invMass;
}
Fixed Timestep Loop
const FIXED_DT = 1 / 120;
let accumulator = 0;
let prevTime = performance.now();
function tick(now) {
requestAnimationFrame(tick);
const elapsed = Math.min((now - prevTime) / 1000, 0.05);
prevTime = now;
accumulator += elapsed;
while (accumulator >= FIXED_DT) {
// Physics step
bodies.forEach(b => integrate(b, FIXED_DT));
// Broad phase + narrow phase collision
for (let i = 0; i < bodies.length; i++) {
for (let j = i + 1; j < bodies.length; j++) {
const c = detectAABB(bodies[i], bodies[j]);
if (c) {
const jImpulse = computeImpulseMagnitude(bodies[i], bodies[j], c);
resolveCollision(bodies[i], bodies[j], c);
applyFriction(bodies[i], bodies[j], c, jImpulse);
}
}
}
accumulator -= FIXED_DT;
}
render();
}
requestAnimationFrame(tick);
Render & Complete Demo
A minimal canvas2D renderer to visualise the boxes:
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const b of bodies) {
ctx.fillStyle = b.invMass === 0 ? '#334155' : '#3b82f6';
ctx.strokeStyle = '#60a5fa';
ctx.lineWidth = 1;
ctx.fillRect(b.x - b.w, b.y - b.h, b.w * 2, b.h * 2);
ctx.strokeRect(b.x - b.w, b.y - b.h, b.w * 2, b.h * 2);
}
}
// Scene setup
const bodies = [
// Floor (static)
new Body({ x: 400, y: 580, w: 400, h: 20, isStatic: true }),
// Left/right walls
new Body({ x: 10, y: 300, w: 10, h: 300, isStatic: true }),
new Body({ x: 790, y: 300, w: 10, h: 300, isStatic: true }),
// Dynamic boxes
new Body({ x: 400, y: 100, w: 25, h: 25, mass: 1, restitution: 0.6 }),
new Body({ x: 390, y: 200, w: 30, h: 20, mass: 2, restitution: 0.3 }),
new Body({ x: 410, y: 300, w: 20, h: 30, mass: 0.5, restitution: 0.8 }),
];