Tutorial
⏱️ ~60 minutes 🎓 Intermediate 🛠️ JavaScript · Canvas 2D · Physics

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.

Prerequisites

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 }),
];

Continue Learning

🛠

Experiment in Playground

Write, run and tweak Three.js + Cannon-es physics directly in your browser — no setup required.

Open Playground → View Simulation ↗