A lightweight 2D rigid body physics engine for JavaScript canvas games. Zero dependencies, ES modules.
<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>new Circle(radius)new Rectangle(width, height)Centered on the body position.
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).
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.
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)).
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
});| 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) |
- Dynamic bodies (
isStatic: false, withmass > 0): affected by gravity, collisions, and forces. - Static bodies (
isStatic: true): never move. Used for floors, walls, platforms. Don't need a mass.
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=sleepingFor 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 overlaysconst world = new World({
gravity: new Vec2(0, 981), // default: (0, 981) — roughly Earth gravity in pixels
fixedDt: 1 / 60 // default: 1/60 — physics timestep
});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)addBody automatically resolves overlaps with existing bodies, so newly spawned objects won't get stuck inside each other.
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
dtvalues are clamped to 100ms to prevent spiral-of-death renderPositionandrenderAngleare interpolated for smooth display between physics steps
world.bodies // array of all bodies in the simulationImmutable 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// 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));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).
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 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.
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/applyImpulseis 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 sleepingSleeping bodies are drawn in dark gray in debug mode.
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 0x0002Rectangle stacking (boxes on boxes, boxes on edge floors) is stable for multi-row structures. Four things work together to make this possible:
- 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.
- 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.
- 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).
- Default 120 Hz internal timestep —
fixedDtdefaults to1/120(up from1/60). Halving the per-step velocity makes the solver dramatically better at multi-level stacks. A typical game callingworld.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.
When two bodies collide:
- Restitution:
max(a.restitution, b.restitution)— the bouncier surface wins - Friction:
sqrt(a.friction * b.friction)— geometric mean
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 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.
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!
};world.step(dt);
console.log(world.stats);
// { bodyCount: 50, activeCount: 12, sleepingCount: 35, collisionPairs: 8, stepTimeMs: 0.42 }- 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: 0for 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.
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
npm test