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

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.

🛠

Experiment in Playground

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

Open Playground → View Simulation ↗