Joints, sleeping & CCD
Connect physics bodies with distance, revolute, weld, prismatic, wheel and mouse joints, let resting bodies sleep to save CPU, and stop fast projectiles tunnelling with continuous collision.
Joints, sleeping & CCD
This chapter builds on Physics basics — a PhysicsWorld stepped from Scene.fixedUpdate, with static and dynamic bodies. Here we connect those bodies with joints, let bodies at rest sleep, and stop fast bodies from tunnelling with continuous collision.
Joints
A Joint is a constraint between two bodies, solved alongside contacts in the sub-step loop. The pattern is always the same: construct the joint, then register it with world.addJoint(...). Remove it with world.removeJoint(joint), which wakes both bodies so they respond to the lost constraint.
const joint = world.addJoint(new RevoluteJoint({ bodyA, bodyB, anchor }));
// ...later...
world.removeJoint(joint);Many joints take a hertz (and dampingRatio) pair: leaving hertz at 0 makes the constraint rigid, while hertz > 0 turns it into a damped spring at that frequency. Anchors are given in world space at construction and stored body-locally, so they travel with the bodies afterwards.
Distance joints
A DistanceJoint holds two anchor points a fixed length apart — a rigid rod by default, or a spring with hertz > 0:
const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } });
const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } }));
const bob = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 150 }, colliders: [{ shape: new BoxShape(16, 16) }] }));
// Rigid rod: holds the bob exactly 100px from the anchor.
world.addJoint(new DistanceJoint({ bodyA: anchor, bodyB: bob, length: 100 }));
// Or a soft spring that sags and bobs under gravity:
world.addJoint(new DistanceJoint({ bodyA: anchor, bodyB: bob, length: 100, hertz: 2.5, dampingRatio: 1 }));Specifying minLength and/or maxLength turns it into a rope/limit: the bodies move freely while the separation is within the band and the joint only engages (rigidly) at the bounds:
const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } });
const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } }));
const bob = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 50 }, colliders: [{ shape: new BoxShape(16, 16) }] }));
// A rope: the bob falls freely until it reaches 100px, then the rope holds.
world.addJoint(new DistanceJoint({ bodyA: anchor, bodyB: bob, maxLength: 100 }));Revolute joints
A RevoluteJoint pins a shared anchor point on two bodies — a hinge they rotate freely about. Enable a motor to drive the relative angular velocity, or a limit to clamp the swing:
const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } });
const anchor = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } }));
const arm = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 70, y: 0 }, colliders: [{ shape: new BoxShape(100, 10) }] }));
// A free hinge at the origin - the arm swings under gravity.
world.addJoint(new RevoluteJoint({ bodyA: anchor, bodyB: arm, anchor: { x: 0, y: 0 } }));
// A powered hinge - a motor driving it toward 5 rad/s, capped torque:
world.addJoint(new RevoluteJoint({ bodyA: anchor, bodyB: arm, anchor: { x: 0, y: 0 }, enableMotor: true, motorSpeed: 5, maxMotorTorque: 1e8 }));
// A limited hinge - the relative angle is clamped to ±45°:
world.addJoint(new RevoluteJoint({ bodyA: anchor, bodyB: arm, anchor: { x: 0, y: 0 }, enableLimit: true, lowerAngle: -Math.PI / 4, upperAngle: Math.PI / 4 }));Weld joints
A WeldJoint rigidly locks the relative position and orientation of two bodies, so they move as one rigid body. Both locks default to rigid; set linearHertz / angularHertz for a springy weld:
const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } });
const a = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(20, 20) }] }));
const b = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 24, y: 0 }, colliders: [{ shape: new BoxShape(20, 20) }] }));
world.addJoint(new WeldJoint({ bodyA: a, bodyB: b }));Prismatic joints
A PrismaticJoint constrains a body to slide along one axis relative to another — perpendicular translation and rotation are locked. It takes an axis (normalised internally) and supports a linear motor (maxMotorForce) and translation limits:
const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } });
const rail = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 0 } }));
const slider = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(20, 20) }] }));
world.addJoint(
new PrismaticJoint({
bodyA: rail,
bodyB: slider,
anchor: { x: 0, y: 0 },
axis: { x: 1, y: 0 }, // slide horizontally
enableMotor: true,
motorSpeed: 100,
maxMotorForce: 1e8,
enableLimit: true,
lowerTranslation: 0,
upperTranslation: 200,
}),
);Wheel joints
A WheelJoint is the vehicle primitive: the wheel is free to spin, sprung along a suspension axis (a soft spring via hertz / dampingRatio), and locked laterally. A rotation motor drives it:
const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } });
const chassis = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(120, 20) }] }));
const wheel = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 30 }, colliders: [{ shape: new CircleShape(10) }] }));
world.addJoint(
new WheelJoint({
bodyA: chassis,
bodyB: wheel,
anchor: { x: 0, y: 30 },
axis: { x: 0, y: 1 }, // suspension travels vertically
hertz: 5,
dampingRatio: 0.7,
enableMotor: true,
motorSpeed: 20,
maxMotorTorque: 1e6,
}),
);Mouse joints
A MouseJoint softly pulls a single body’s grab point toward a movable target — the cursor-drag primitive. It is a single-body constraint; reassign target each frame to drag, and bound the pull with maxForce (so heavy bodies lag):
const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } });
const body = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new BoxShape(20, 20) }] }));
const drag = world.addJoint(new MouseJoint({ body, target: { x: 0, y: 0 }, hertz: 5, dampingRatio: 0.7, maxForce: 10000 }));
drag.target = { x: 50, y: -30 }; // update from the pointer position each frameBoth jointed bodies share one sleep island, so a jointed pair sleeps and wakes together — which brings us to sleeping.
Sleeping
Sleep is decided per island, not per body
Bodies that are jointed or touching belong to one island, so allowSleep = false on a single body keeps every body connected to it awake too, and a group only settles once all of its members qualify. Reason about sleep at the island level, not the individual body.
A body that has stayed below the velocity thresholds long enough is put to sleep: it stops integrating and is skipped by the solver until something wakes it. In a scene with many resting bodies — a settled stack, scattered debris — this is a large CPU saving, and it removes the last traces of resting jitter.
Sleeping is island-aware: connected bodies (touching contacts and joints form an island) sleep and wake as a unit, so a tower never half-sleeps. A body wakes the instant it is touched by an awake body, hit by an applyImpulse/applyForce, or moved with setTransform.
Low velocity alone is not enough. A body that landed hard is overlapping the ground by more than one step of push-out, and the solver corrects that overlap at a speed below the sleep threshold — so an island also stays awake while any of its solid contacts still carries more penetration than a resting contact keeps. A body therefore always comes to rest on the surface rather than embedded in it, at the cost of a fraction of a second of extra simulation after a fast impact. Sensor overlaps and contacts a contactModifier disabled are not solved and never delay sleep.
It is on by default; tune it through PhysicsWorld options:
const world = new PhysicsWorld({
gravity: { x: 0, y: 1000 },
enableSleeping: true, // default; set false to never sleep
sleepLinearVelocity: 5, // px/s - at or below this a body is a sleep candidate
sleepAngularVelocity: 0.06, // rad/s
timeToSleep: 0.5, // seconds below the thresholds before sleeping
});Per body, opt a single body out with allowSleep = false (it, and its whole island, stay awake), read isSleeping, or force it awake with wake():
body.allowSleep = false; // this body - and its island - never sleepsContinuous collision (bullet mode)
The solver runs detection once per fixed step, so a body that travels farther than an obstacle is thick in a single step can pass straight through it — tunnelling. For fast projectiles, flag the body as a bullet (isBullet) and it is swept along its motion each step against every other body; if the sweep would cross a surface, the body is clamped just short of it and its velocity is resolved about the surface normal:
const world = new PhysicsWorld({ gravity: { x: 0, y: 0 } });
// A thin wall the projectile would otherwise skip over.
world.add(new PhysicsBody({ type: 'static', position: { x: 200, y: 0 }, colliders: [{ shape: new BoxShape(4, 400) }] }));
// A fast bullet - swept each step so it stops at the wall instead of tunnelling.
const bullet = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, isBullet: true, colliders: [{ shape: new CircleShape(6) }] }));
bullet.linearVelocityX = 6000; // ~100px per fixed step - far more than the 4px wall is thickisBullet is a plain flag you can toggle at runtime (bullet.isBullet = true). The impact response is a velocity reflect about the true surface normal: a non-bouncy body slides along the surface (keeping its tangential velocity), while a bouncy one (restitution near 1) rebounds elastically. The swept test runs against static, kinematic and dynamic bodies; sensors never block.
The whole shape is swept, not just its centre, so a large body cannot clip a corner its centre line happened to miss. The cast is exact for a translation and covers every solid shape against every shape kind:
| Moving shape | Swept against |
|---|---|
CircleShape, CapsuleShape, PolygonShape/BoxShape |
circle, capsule, polygon/box, segment, chain |
SegmentShape, ChainShape |
not swept as the moving shape |
Boundary geometry is a target, never a bullet
A segment or a chain is level structure: it is what a bullet is swept against, and it is never swept itself. A fast kinematic boundary can still cross a body within one step — give anything that moves that fast a solid collider instead. The rotation of a bullet is applied before the cast, not swept through it, so a fast spin is still resolved by the discrete solver.
Contact modifier
Filters decide whether two colliders may touch at all; the contact modifier decides what a contact that already exists does this step. It runs once per solid contact per fixed step, after collision detection and before the solver, and it is where one-way platforms, conditional friction and per-pair material overrides live.
A world has at most one modifier — it mutates simulation state, so a multi-listener signal would make the result depend on registration order:
const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } });
world.contactModifier = contact => {
// Ice: no friction wherever the icy collider is involved.
if (contact.colliderA.isSensor || contact.colliderB.isSensor) {
return;
}
contact.friction = 0;
contact.restitution = 0.1;
};enabled, friction and restitution are the three controls. Everything else on the contact — the two colliders, the two bodies, the A→B normal, the manifold point count and the deepest penetration — is read-only, and the object itself is reused for every contact, so read what you need inside the callback rather than storing it.
The values are re-derived from the two colliders before every step (√(fA × fB) for friction, max(rA, rB) for restitution), so a change applies to that step only and nothing leaks into the next one.
Setting enabled = false skips the contact in the solver: no impulse, no push-out. The contact is still geometrically touching, so onCollisionStart/onCollisionEnd keep describing the real geometry — the modifier changes the response, not the detection. A disabled contact also does not join its two bodies into one sleeping island, and its warm-start impulses are dropped, so re-enabling it starts from zero instead of releasing a stale impulse.
That is the whole of a one-way platform. Sample the direction of travel at the start of the step and ignore the contact while the character is moving upward:
const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } });
const platform = world.add(new PhysicsBody({ type: 'static', position: { x: 0, y: 300 }, colliders: [{ shape: new BoxShape(400, 10) }] }));
const player = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 100 }, colliders: [{ shape: new BoxShape(20, 40) }] }));
let jumpingUp = false;
world.contactModifier = contact => {
const involvesPlatform = contact.bodyA === platform || contact.bodyB === platform;
if (involvesPlatform && jumpingUp) {
contact.enabled = false;
}
};
// In your update, before stepping the world:
jumpingUp = player.linearVelocityY < -10;Sample velocity before the step, not inside the modifier
Reading player.linearVelocityY inside the callback looks equivalent but is not: the solver’s push-out gives a resting body a hair of upward velocity, which flips the rule on and off every step and leaves the character sunk into the platform. Decide once per step from the state you already have.
The modifier never sees sensor overlaps — a sensor produces no contact to solve. Use onSensorEnter/onSensorExit for those.
What the solver is built for
ExoJS Physics ships one solver configuration, tuned for plausible, stable, cheap game physics. It is not a scientific integrator, and it does not grow a quality switch: the advanced options you already met — subStepCount, contactHertz, dampingRatio, fixedDelta, enableSleeping, isBullet — are the whole tuning surface.
That default has a shape worth knowing, because it decides whether a scene will look right or merely finite:
Inside the envelope. Gravity and accelerations in the range ordinary 2D games use. Shapes within a couple of orders of magnitude of each other. Mass differences up to roughly two orders of magnitude inside one pile of touching bodies. Stacks tens of bodies tall. Fast bodies flagged isBullet. Here a visible failure is a bug, not a limit — report it.
Degraded but stable. Push any of those further and accuracy gets visibly worse while the simulation stays finite: resting contacts sit deeper, a squeezed light body sinks into what supports it, a tall stack under a heavy load compresses. Nothing explodes, nothing freezes half-buried, but it stops looking convincing.
Out of scope. Guaranteed non-penetration, mass ratios in the thousands against thin geometry, rotation-only continuous collision, shapes spanning many orders of magnitude in one world, or a result that has to match an offline reference. These are jobs for a specialised engine, and ExoJS deliberately lets them go rather than growing a mode for them.
Four behaviours follow from the design, and knowing them explains most surprises:
| Behaviour | What you see |
|---|---|
| Contacts rest inside a small tolerance | Bodies overlap their support by a fraction of a px, on purpose — that overlap is what keeps the contact alive between steps. A single-point contact (a ball, a capsule end) rests slightly deeper than a flat one, and the gap grows with gravity. |
| Detection is discrete | A body that crosses more than roughly half a barrier’s thickness in one fixed step can end up on the wrong side of it. That is what isBullet exists for. |
| The sweep is translation-only | A body whose rotation alone carries it past an obstacle within one step is not swept, isBullet or not. |
| Tolerances are absolute lengths | A 2 px shape experiences the same slop as a 200 px one, so tiny bodies look mushy while huge ones look exact. Scale the world, not the tolerance. |
Mass ratio is about thickness, not the ratio
A heavy body resting on a light one degrades gradually. What decides whether it degrades or fails is how thick the supporting geometry is compared to the lighter body: a light body pressed against a boundary thinner than itself is the case that gives way first, and it gives way by sinking through. Give thin platforms a collider thicker than the smallest body that will ever be pressed against them.
Where to go next
- API reference:
DistanceJoint,RevoluteJoint,WeldJoint,PrismaticJoint,WheelJoint,MouseJointandPhysicsWorld. - Start here: Physics basics covers worlds, bodies, colliders, stepping and sprite binding.


