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 }),
];
Frequently Asked Questions
What will I learn in this tutorial?
Rigid body physics from scratch in JavaScript: integration, AABB collision detection, impulse resolution, friction and restitution.
What topics are covered in this tutorial?
This tutorial covers: Body data structure, Symplectic Euler integration, AABB collision detection, Impulse-based collision response, Friction, Fixed timestep loop, Render & complete demo.
What tools and technologies does this tutorial use?
This tutorial uses JavaScript, Canvas 2D, Physics.
How long does this tutorial take?
This tutorial takes approximately 60 minutes to complete.
What prerequisites do I need before starting?
Before starting, you should have: Basic JavaScript classes, arrays, and the canvas 2D API; Understanding of vectors (addition, scalar multiply, dot product); No prior physics engine knowledge required.