Skip to content

nitzangames/Physics2D

Repository files navigation

Physics2D

A lightweight 2D rigid body physics engine for JavaScript canvas games. Zero dependencies, ES modules.

Quick Start

<canvas id="canvas" width="800" height="600"></canvas>
<script type="module">
  import { World, Body, Circle, Rectangle, Triangle, Capsule, Edge, Vec2 } from './index.js';

  const canvas = document.getElementById('canvas');
  const ctx = canvas.getContext('2d');

  // Create a world with gravity (pixels/s^2, Y-down)
  const world = new World({ gravity: new Vec2(0, 500) });

  // Add a ball
  const ball = new Body({
    shape: new Circle(20),
    position: new Vec2(400, 100),
    mass: 1,
    restitution: 0.6,
    friction: 0.3
  });
  world.addBody(ball);

  // Add a static floor
  const floor = new Body({
    shape: new Edge(new Vec2(-400, 0), new Vec2(400, 0)),
    position: new Vec2(400, 550),
    isStatic: true
  });
  world.addBody(floor);

  // Game loop
  let lastTime = performance.now();
  function loop(time) {
    const dt = (time - lastTime) / 1000;
    lastTime = time;

    world.step(dt);

    ctx.clearRect(0, 0, 800, 600);
    for (const body of world.bodies) {
      body.drawDebug(ctx);  // or draw your own sprites using body.renderPosition
    }
    requestAnimationFrame(loop);
  }
  requestAnimationFrame(loop);
</script>

Shapes

Circle

new Circle(radius)

Rectangle

new Rectangle(width, height)

Centered on the body position.

Triangle

new Triangle(
  new Vec2(0, -20),   // top vertex
  new Vec2(20, 20),   // bottom-right
  new Vec2(-20, 20)   // bottom-left
)

Vertices are relative to the body position. Must be in counter-clockwise winding order (in screen coordinates where Y points down, this means the vertices go clockwise visually).

Capsule

new Capsule(length, radius)

A pill shape: a line segment of length with semicircles of radius on each end. The segment runs along the body's local X axis, centered on the body position.

Edge

new Edge(new Vec2(-200, 0), new Vec2(200, 0))

A static line segment. Start and end points are relative to the body position. Edges should only be used with isStatic: true. They have no volume and infinite inertia.

Edges are one-sided: they detect collisions from one side only (the left side of the direction from start to end). For a horizontal floor, use left-to-right: new Edge(new Vec2(-width, 0), new Vec2(width, 0)).

Body

const body = new Body({
  shape: new Circle(20),       // required
  position: new Vec2(100, 50), // required
  mass: 1,                     // default: 0 (use with isStatic)
  restitution: 0.2,            // bounciness, 0-1, default: 0.2
  friction: 0.3,               // surface friction, 0-1, default: 0.3
  isStatic: false,             // default: false
  angle: 0,                    // initial rotation in radians, default: 0
  userData: null,              // arbitrary game data, default: null
  collisionGroup: 0xFFFF,     // bitmask: what group(s) this body belongs to
  collisionMask: 0xFFFF,      // bitmask: what group(s) this body collides with
  isSensor: false,             // detect overlaps without physics, default: false
  linearDamping: 0,            // velocity drag, 0 = none, higher = more drag
  angularDamping: 0            // rotation drag, 0 = none
});

Properties

Property Type Description
position Vec2 Current physics position (updated at fixed rate)
renderPosition Vec2 Interpolated position for smooth drawing
velocity Vec2 Linear velocity (pixels/s)
angle number Current rotation (radians)
renderAngle number Interpolated rotation for smooth drawing
angularVelocity number Rotational speed (radians/s)
mass number Mass (0 for static bodies)
inverseMass number 1/mass (0 for static bodies)
inertia number Rotational inertia (auto-computed from shape)
restitution number Bounciness (0 = no bounce, 1 = full bounce)
friction number Surface friction
isStatic boolean Static bodies don't move
shape Shape The collision shape
userData any Game data (default null, engine never touches it)
collisionGroup number Bitmask: which layer(s) this body belongs to (default 0xFFFF)
collisionMask number Bitmask: which layer(s) this body can collide with (default 0xFFFF)
isSensor boolean Detect overlaps without physical collision (default false)
isSleeping boolean Whether the body is asleep (read-only, use sleep()/wake())
force Vec2 Accumulated force (cleared each step)
torque number Accumulated torque (cleared each step)
linearDamping number Velocity drag per second (default 0)
angularDamping number Rotation drag per second (default 0)

Static vs Dynamic

  • Dynamic bodies (isStatic: false, with mass > 0): affected by gravity, collisions, and forces.
  • Static bodies (isStatic: true): never move. Used for floors, walls, platforms. Don't need a mass.

Drawing

For rendering, always use body.renderPosition and body.renderAngle instead of body.position and body.angle. The render values are interpolated between physics steps for smooth display regardless of frame rate.

// Custom rendering example
ctx.save();
ctx.translate(body.renderPosition.x, body.renderPosition.y);
ctx.rotate(body.renderAngle);
ctx.drawImage(sprite, -width / 2, -height / 2);
ctx.restore();

Or use the built-in debug wireframe:

body.drawDebug(ctx); // green=dynamic, gray=static, blue dashed=sensor, dark gray=sleeping

For full world debug rendering with optional overlays:

import { drawDebugWorld } from './index.js';

world.debug.drawAABBs = true;      // show bounding boxes
world.debug.drawVelocities = true;  // show velocity vectors

drawDebugWorld(ctx, world);  // draws all bodies + enabled overlays

World

const world = new World({
  gravity: new Vec2(0, 981),  // default: (0, 981) — roughly Earth gravity in pixels
  fixedDt: 1 / 60            // default: 1/60 — physics timestep
});

Methods

world.addBody(body);     // add a body (auto-resolves spawn overlaps)
world.removeBody(body);  // remove a body
world.clear();           // remove all bodies
world.step(dt);          // advance simulation by dt seconds
world.raycast(origin, direction, maxDist, mask?);  // cast a ray, returns hits

world.onCollision = (bodyA, bodyB, contact) => {};  // collision callback

body.setPosition(x, y);   // teleport body (updates render pos, wakes if sleeping)
body.setVelocity(x, y);   // set velocity (wakes if sleeping)

Adding Bodies

addBody automatically resolves overlaps with existing bodies, so newly spawned objects won't get stuck inside each other.

The Step Function

Call world.step(dt) once per frame, passing the real frame delta time:

let lastTime = performance.now();
function loop(time) {
  const dt = (time - lastTime) / 1000;
  lastTime = time;
  world.step(dt);
  // ... draw ...
  requestAnimationFrame(loop);
}

The engine uses a fixed internal timestep (default 1/60s) with an accumulator. This means:

  • Physics is deterministic regardless of frame rate
  • Large dt values are clamped to 100ms to prevent spiral-of-death
  • renderPosition and renderAngle are interpolated for smooth display between physics steps

Reading Bodies

world.bodies  // array of all bodies in the simulation

Vec2

Immutable 2D vector. All operations return new instances.

const v = new Vec2(x, y);   // construct (defaults to 0, 0)

v.add(other)       // vector addition
v.sub(other)       // vector subtraction
v.scale(scalar)    // scalar multiplication
v.dot(other)       // dot product
v.cross(other)     // 2D cross product (scalar)
v.length()         // magnitude
v.lengthSq()       // squared magnitude (faster, use for comparisons)
v.normalize()      // unit vector (returns zero vector if length is 0)
v.negate()         // flip direction
v.perp()           // perpendicular vector (-y, x)
v.rotate(angle)    // rotate by angle in radians

Vec2.lerp(a, b, t)      // linear interpolation
Vec2.distance(a, b)     // distance between two points

Forces and Impulses

// Continuous force (applied over time, cleared each physics step)
body.applyForce(new Vec2(0, -500));  // e.g., jetpack thrust

// Instant impulse (immediate velocity change)
body.applyImpulse(new Vec2(100, -200));  // e.g., explosion knockback

// Impulse at a specific world point (adds angular velocity too)
body.applyImpulse(new Vec2(50, 0), new Vec2(body.position.x, body.position.y - 10));

Collision Filtering

Use bitmask layers to control which bodies collide:

const PLAYER = 0x0001;
const ENEMY  = 0x0002;
const BULLET = 0x0004;
const WALL   = 0x0008;

const player = new Body({
  shape: new Circle(15),
  position: new Vec2(100, 100),
  mass: 1,
  collisionGroup: PLAYER,       // "I am a player"
  collisionMask: ENEMY | WALL   // "I collide with enemies and walls"
});

Two bodies collide only when both agree: (a.group & b.mask) && (b.group & a.mask).

Collision Callbacks

world.onCollision = (bodyA, bodyB, contact) => {
  if (bodyA.userData === 'bullet' || bodyB.userData === 'bullet') {
    // handle bullet hit
  }
};

Set userData on bodies to identify them in callbacks:

const enemy = new Body({ ..., userData: { type: 'enemy', hp: 100 } });

Sensors / Triggers

Sensors detect overlaps without physical collision:

const pickupZone = new Body({
  shape: new Circle(30),
  position: new Vec2(200, 300),
  isSensor: true,
  isStatic: true,
  userData: 'coin'
});

world.onCollision = (a, b) => {
  if (a.userData === 'coin' || b.userData === 'coin') {
    // player touched the coin!
  }
};

Sensors are drawn with dashed blue outlines in debug mode.

Body Sleeping

Bodies that stop moving automatically fall asleep to save CPU. Sleeping bodies:

  • Skip gravity and force integration
  • Skip collision detection with other sleeping bodies
  • Wake up when hit by an awake body, or when applyForce/applyImpulse is called
body.sleep();  // force sleep
body.wake();   // force wake

// Tune sleep thresholds on the world
world.sleepVelocityThreshold = 0.5;  // pixels/s
world.sleepAngularThreshold = 0.05;  // radians/s
world.sleepTimeThreshold = 0.5;      // seconds below thresholds before sleeping

Sleeping bodies are drawn in dark gray in debug mode.

Raycasting

Cast a ray and find what it hits:

const hits = world.raycast(origin, direction, maxDistance, mask?);
// Returns: [{ body, point, normal, distance }, ...] sorted by distance

// Example: shoot a ray to the right
const hits = world.raycast(
  new Vec2(100, 300),  // origin
  new Vec2(1, 0),      // direction (auto-normalized)
  500                   // max distance in pixels
);

if (hits.length > 0) {
  const nearest = hits[0];
  console.log('Hit', nearest.body.userData, 'at', nearest.point, 'dist:', nearest.distance);
}

// Filter by collision layer
const hits = world.raycast(origin, dir, 500, 0x0002); // only hit group 0x0002

Rectangle Stacking

Rectangle stacking (boxes on boxes, boxes on edge floors) is stable for multi-row structures. Four things work together to make this possible:

  1. 2-point contact manifolds via Sutherland-Hodgman edge clipping — face-to-face contacts produce two contact points instead of one, eliminating the spurious torque that collapses tall stacks.
  2. Warm starting — accumulated normal and friction impulses are cached per contact pair and carried across frames, seeding the next frame's solver with the previous equilibrium. Stacks converge in a few iterations instead of many.
  3. Split velocity/position solver — velocity constraints and positional correction run as separate passes (Sequential Impulse with accumulated-impulse clamping for velocity; Baumgarte stabilization with slop for position).
  4. Default 120 Hz internal timestepfixedDt defaults to 1/120 (up from 1/60). Halving the per-step velocity makes the solver dramatically better at multi-level stacks. A typical game calling world.step(1/60) just runs two internal sub-steps per frame; cost is still well under 1 ms for typical scenes.

Sleeping bodies resting on static geometry are skipped in the collision loop so residual solver noise doesn't wake settled structures.

If you have a performance-critical scene with few stacked bodies, you can pass fixedDt: 1/60 back to the World constructor to halve the step count.

Collision Behavior

Material Combining

When two bodies collide:

  • Restitution: max(a.restitution, b.restitution) — the bouncier surface wins
  • Friction: sqrt(a.friction * b.friction) — geometric mean

Supported Collision Pairs

All shape combinations are supported:

Circle Rectangle Triangle Capsule Edge
Circle Yes Yes Yes Yes Yes
Rectangle - Yes Yes Yes Yes
Triangle - - Yes Yes Yes
Capsule - - - Yes Yes
Edge - - - - No*

*Edges don't collide with other edges (both are static).

Damping

Damping simulates drag (air resistance, water, etc.) by reducing velocity each frame:

// Slow-moving underwater object
const fish = new Body({ ..., linearDamping: 3, angularDamping: 2 });

// Floaty space feel (very low damping)
const asteroid = new Body({ ..., linearDamping: 0.1 });

A value of 0 means no damping. Higher values = more drag. A value of 1 removes roughly 63% of velocity per second.

Safe Body Removal

It's safe to call world.removeBody(body) inside collision callbacks — removals are deferred until after the physics step completes:

world.onCollision = (a, b) => {
  if (a.userData === 'bullet') world.removeBody(a);  // safe!
};

Performance Stats

world.step(dt);
console.log(world.stats);
// { bodyCount: 50, activeCount: 12, sleepingCount: 35, collisionPairs: 8, stepTimeMs: 0.42 }

Tips

  • Use pixels for all measurements. Gravity of Vec2(0, 500) works well for most games.
  • Keep body counts reasonable (under ~100). The broad phase is O(n^2).
  • Edges are one-sided. If objects pass through from the wrong side, flip the start/end points.
  • For walls, use edges rather than thin static rectangles — edges are cheaper and more reliable.
  • Set restitution: 0 for objects that should settle quickly (crates, characters).
  • Set restitution: 0.8+ for bouncy objects (balls, pinball bumpers).
  • The collision solver and integration loop use zero-allocation inline math. Vec2 also provides mutable methods (addMut, subMut, scaleMut, set, copy) for your own hot paths.

Running the Demo

python3 -m http.server 8080
# Open http://localhost:8080/demo.html
  • Click to spawn random shapes
  • Right-click to cast a ray from center toward the mouse (red line + red dots at hit points)
  • Bodies fall asleep when still (turn dark gray) and wake on collision
  • The dashed blue circle is a sensor zone that flashes red when objects enter

Running Tests

npm test

About

A lightweight 2D rigid body physics engine for JavaScript canvas games. Zero dependencies, ES modules.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors