Physics basics
Add 2D rigid-body physics with the @codexo/exojs-physics library: build a PhysicsWorld, drop bodies onto colliders, and step it from Scene.fixedUpdate.
Physics basics
@codexo/exojs-physics is the official 2D rigid-body engine for ExoJS — a native, warm-started TGS-Soft solver (the Box2D-v3 “soft step”) with shapes, colliders, bodies, contacts, sensors, queries, joints, sleeping and continuous collision. It turns a static scene graph into one where boxes fall, stack, bounce and collide.
Physics is a library, not a registered extension
There is no extensions: [...] entry — you construct a PhysicsWorld yourself and step it. Gravity is in px/s² with +Y pointing down, so a positive y value is “down”.
Note: physics ships as a separate package. Install
@codexo/exojs-physicsalongside@codexo/exojs:npm install @codexo/exojs @codexo/exojs-physics
A library, not an extension
Unlike Tiled or Particles, physics is not an Application extension. It contributes no renderer and no asset type, so there is no extensions: [...] activation. @codexo/exojs is a peer dependency; you construct a PhysicsWorld directly and step it yourself:
const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } });The world holds no module-level state, so any number of worlds run in complete isolation — one per scene, or several side by side. Gravity is in px/s² with +Y pointing down (the ExoJS screen convention), so “down” is a positive y.
Bodies and colliders
A PhysicsBody is a transform plus a mass model; a Collider is the geometry attached to it. A body owns one or more colliders, and its mass, centre of mass and rotational inertia are computed from their shape and density. Every body has a type:
'static'— never moves (floors, walls). Infinite mass.'dynamic'— integrates under gravity, forces and contacts. This is the default.'kinematic'— moves only by the velocity you set; immovable under contacts (moving platforms).
Construct a body freely, then hand it to world.add(...). Colliders can be passed as plain option objects in colliders: [...] — you don’t have to build a Collider instance yourself:
const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } });
// A static floor: an immovable body with a single box collider.
world.add(
new PhysicsBody({
type: 'static',
position: { x: 0, y: 400 },
colliders: [{ shape: new BoxShape(800, 40) }],
}),
);
// A dynamic crate that falls onto the floor.
const crate = world.add(
new PhysicsBody({
type: 'dynamic',
position: { x: 0, y: 0 },
colliders: [{ shape: new BoxShape(32, 32), density: 1, friction: 0.5, restitution: 0.1 }],
}),
);world.add(...) returns the body, so you can keep a reference (crate above) to read its position or drive it later. Collider material is set per collider:
density(default1) — mass per px²; feeds the body’s mass model.friction(default0.2) — Coulomb friction coefficient.restitution(default0) — bounciness in[0, 1].
Four shapes enclose an area and therefore carry mass: BoxShape(width, height), CircleShape(radius), CapsuleShape(x0, y0, x1, y1, radius) — the exact geometry, never a polygon approximation — and PolygonShape(points) for a convex outline:
const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } });
const ball = world.add(
new PhysicsBody({
type: 'dynamic',
position: { x: 0, y: -200 },
colliders: [{ shape: new CircleShape(12), density: 1, restitution: 0.6 }],
}),
);Boundary shapes for level geometry
SegmentShape(x0, y0, x1, y1) is a single edge and ChainShape(points) a connected run of them. Both are boundaries: they have no interior, so they contribute collision only, and a dynamic body carrying nothing but boundary geometry is rejected — give it a solid collider too.
Reach for a chain rather than a list of segments whenever the geometry is one connected surface. The engine owns the shared vertices, so a body sliding along it never snags on a seam, and the whole chain stays one collider: one entry in world.colliders, one collisionStart, one query result, however many of its edges are touched.
const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } });
// A hillside. Drawn left to right, so it is solid from above.
world.add(
new PhysicsBody({
type: 'static',
position: { x: 0, y: 0 },
colliders: [
{
shape: new ChainShape([
{ x: -400, y: 0 },
{ x: 0, y: 40 },
{ x: 400, y: 0 },
]),
},
],
}),
);
world.add(
new PhysicsBody({
type: 'dynamic',
position: { x: -200, y: -100 },
colliders: [{ shape: new CapsuleShape(0, -12, 0, 12, 8), density: 1 }],
}),
);A chain is one-sided: the vertex order decides which side is solid, and a body may pass through it from behind — which is what makes it the right shape for ground you only ever land on. A single SegmentShape has no such order and blocks from both sides; one-way behaviour there is a contact rule, not geometry, and belongs in the contact modifier.
Stepping the world
Physics is caller-driven: nothing moves until you call world.step(seconds). The world accumulates elapsed time into a fixed timestep (default 1 / 60 s) and runs as many fixed sub-steps as needed, so the simulation is frame-rate independent and deterministic — the same inputs replay identically.
That makes Scene.fixedUpdate the right hook to step it. fixedUpdate(delta) runs zero or more times per frame with a constant delta — exactly what a stable simulation wants. Leave camera, UI and purely visual work in update; put world.step(...) in fixedUpdate:
class GameScene extends Scene {
private readonly world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } });
public override init(): void {
this.world.add(
new PhysicsBody({
type: 'static',
position: { x: 0, y: 400 },
colliders: [{ shape: new BoxShape(800, 40) }],
}),
);
}
public override fixedUpdate(delta: Seconds): void {
this.world.step(delta);
}
}The fixed-step size is the application’s fixedTimeStep (seconds, default 1 / 60):
import { Application } from '@codexo/exojs';
const app = new Application({ fixedTimeStep: 1 / 60 });
Step in `fixedUpdate`, not `update`
Stepping inside update works — pass delta the same way — but fixedUpdate runs with a constant delta, which keeps the simulation deterministic regardless of display refresh rate.
Fixed timestep vs. the variable render loop
world.step(frameDeltaSeconds) owns its own fixed-timestep accumulator — it does not assume the delta you pass is already fixed-size. Internally it adds whatever you give it to a running total and converts that into a whole number of 1 / 60 s sub-steps (configurable via fixedDelta), carrying any leftover fraction into the next call. That means both of these are correct:
fixedUpdate(delta)(the recommendation above) — the engine’s own fixed-timestep accumulator already turns the browser’s variablerequestAnimationFramedelta into a constant-rate hook, soworld.steptypically consumes exactly one sub-step per call here.update(delta)— passing the raw, variable per-framedeltastraight from the render loop also works:world.step’s own accumulator still produces the right number of fixed sub-steps (0,1, or several on a slow frame), so the simulation stays frame-rate independent either way. This is the “fixed timestep + accumulator against a variable RAF loop” pattern — ExoJS’s physics package implements the accumulator half of it for you; you only need to feed it a delta, fixed or not.
public override update(delta: Seconds): void {
// Also valid: world.step owns its own accumulator, so a raw variable delta
// from requestAnimationFrame still yields deterministic, frame-rate
// independent physics - you don't have to build your own accumulator.
this.world.step(delta);
}One visible consequence: because bound sprites are written once per world.step call (once per rendered frame), a display refresh rate above the fixed step rate (e.g. 144 Hz rendering against 60 Hz physics) can show the same simulated position across a couple of frames before the next fixed sub-step lands — a slight stutter compared to a fully interpolated renderer. world.timeStepper.alpha ([0, 1)) exposes the leftover sub-step fraction after each step call if you want to interpolate a bound node’s rendered position yourself between the last two fixed states; bindings do not do this automatically.
Binding bodies to sprites
A body is pure simulation — it has no visual. To draw it, link it to a Drawable (such as a Sprite) with a PhysicsBinding. After each step, the binding writes the body’s position and rotation onto the node (the body’s angle is radians; the node’s rotation is set in degrees for you):
const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } });
const sprite = new Sprite(null);
const body = world.add(new PhysicsBody({ type: 'dynamic', position: { x: 0, y: 0 }, colliders: [{ shape: new CircleShape(12) }] }));
world.bind(body, sprite); // sprite now tracks the body every stepworld.attach(node, def) is the one-call shortcut for the common case — it creates a body with a single collider, adds it and binds it in one go:
const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } });
const sprite = new Sprite(null);
world.attach(sprite, { type: 'dynamic', position: { x: 0, y: 0 }, shape: new CircleShape(12), restitution: 0.5 });If you’d rather position things yourself, skip the binding and read the body’s transform after each step — body.x, body.y (world position) and body.angle (radians):
sprite.setPosition(body.x, body.y);
sprite.setRotation(radiansToDegrees(body.angle));Smoothing motion between fixed steps
Physics runs at a fixed rate; the screen does not. At 60 Hz physics on a 144 Hz display most frames show the same fixed state twice, then jump — visible judder on anything that moves fast. The fix is not a faster simulation but interpolation: draw the body somewhere between the last two fixed states, using the fraction of a step the frame landed on.
Every body brackets its most recent fixed step with previousX/previousY/ previousAngle and x/y/angle. Turn on interpolation and bindings place the node between them instead of snapping to the latest one:
const world = new PhysicsWorld({
gravity: { x: 0, y: 1000 },
interpolation: true,
});That is enough when you drive the world yourself with world.step(delta) — the world owns the accumulator, so it already knows the leftover fraction.
A world registered as a system is different: the engine’s scheduler owns the fixed-step accumulator, and fixedUpdate bypasses the world’s own. Point the world at the host’s fraction:
import { Application, SystemOrder } from '@codexo/exojs';
import { PhysicsWorld } from '@codexo/exojs-physics';
const app = new Application({ fixedTimeStep: 1 / 60 });
const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } });
app.systems.add(world, { order: SystemOrder.Physics });
world.interpolation = true;
world.frameAlphaSource = () => app.frameAlpha;
A system-driven world needs frameAlphaSource
Without it the world falls back to its own accumulator, which fixedUpdate never advances — the factor stays 0 and every bound node renders a full fixed step behind. There is deliberately no second clock inside physics to paper over this: two accumulators would drift apart the moment the host clamps or drops a step.
Interpolation is presentation only. Nothing written onto a node is read back, the simulation is bit-identical either way, and body.x/body.y remain the authoritative fixed-step values. The trade is latency: a bound node renders up to one fixed step behind the newest state, which is what makes the motion continuous.
A teleport is not motion, so setTransform() collapses the pair — the node appears at the destination instead of sweeping across the jump.
Worked example: a ball drops onto a floor
Putting it together — a static floor, a dynamic ball bound to a sprite, stepped from fixedUpdate:
class DropScene extends Scene {
private readonly world = new PhysicsWorld({ gravity: { x: 0, y: 1000 } });
private ball!: Sprite;
public override async load(): Promise<void> {
await this.loader.load('image/ball.png');
}
public override init(): void {
// Static floor - an immovable body, no sprite needed.
this.world.add(
new PhysicsBody({
type: 'static',
position: { x: 0, y: 360 },
colliders: [{ shape: new BoxShape(800, 40) }],
}),
);
// Dynamic ball: a sprite plus a body + circle collider, linked by `attach`.
this.ball = new Sprite(this.loader.get('image/ball.png'));
this.addChild(this.ball);
this.world.attach(this.ball, {
type: 'dynamic',
position: { x: 0, y: -200 },
shape: new CircleShape(12),
restitution: 0.5,
});
}
public override fixedUpdate(delta: Seconds): void {
this.world.step(delta);
}
public override draw(context: RenderingContext): void {
context.render(this.root);
}
}The ball falls under gravity, hits the floor, bounces a few times (restitution 0.5) and settles. Because the body is bound to the sprite, the sprite follows automatically — you never touch its position in update.
Performance envelope: broad phase
Collision detection’s broad phase (which colliders are worth a precise test) is a dynamic AABB tree (Box2D-style): each collider’s leaf carries a slightly enlarged (“fat”) bounding box, and the tree is updated incrementally across steps rather than rebuilt from scratch. A collider whose motion stays inside its fat margin costs nothing to re-sync — only colliders that actually move enough to escape their margin trigger a tree update. There’s still a cheap linear check over every live collider each step, but that dominant reinsertion cost tracks how much of the world is actually moving, not how large the world is, and sleeping bodies skip it entirely — scaling comfortably to tens of thousands of simultaneously-live colliders, including dense clusters that would degrade a sort-and-sweep broad phase.
Colliders from a tilemap
@codexo/exojs-tilemap-physics turns tilemap collision geometry into static bodies, so a level authored in Tiled or LDtk becomes solid world without a hand-written loop. It is a separate package because tilemap and physics deliberately do not depend on each other:
npm install @codexo/exojs-tilemap @codexo/exojs-physics @codexo/exojs-tilemap-physics
For a tile layer, TileColliderStreamer owns one static body per chunk-sized partition of the layer and keeps them in sync - building bodies for chunks that load, rebuilding edited ones, destroying evicted ones:
import { PhysicsWorld } from '@codexo/exojs-physics';
import type { TileLayer } from '@codexo/exojs-tilemap';
import { TileColliderStreamer } from '@codexo/exojs-tilemap-physics';
declare const groundLayer: TileLayer;
const world = new PhysicsWorld({ gravity: { x: 0, y: 1600 } });
const colliders = new TileColliderStreamer(world, groundLayer, { friction: 0.8 });
// Tick this from your update loop, before stepping the world.
colliders.sync();
sync() is cheap every frame: with no change since the last call it returns immediately. Call destroy() to remove every body it created.
Tiled authors collision per tile, and that is what the example above reads. LDtk authors it per grid cell with IntGrid, which is not tied to the tiles a layer draws - a collision layer there often renders nothing at all. Pass its cells in and the same bridge covers it:
import { createLdtkIntGridCellSource } from '@codexo/exojs-ldtk';
import { PhysicsWorld } from '@codexo/exojs-physics';
import type { TileLayer } from '@codexo/exojs-tilemap';
import { TileColliderStreamer } from '@codexo/exojs-tilemap-physics';
declare const intGridLayer: TileLayer;
const world = new PhysicsWorld({ gravity: { x: 0, y: 1600 } });
const colliders = new TileColliderStreamer(world, intGridLayer, {
cells: createLdtkIntGridCellSource(intGridLayer),
material: ({ type }) => (type === 'Water' ? { isSensor: true } : null),
});
The string a cell source returns is the value’s authored name, nothing more. Deciding that Water means “sensor” is your call, made in material - the engine never assigns meaning to a classification.
For an object layer - spawn regions, triggers, hand-drawn collision - there is nothing to stream, so it is a one-shot build:
import { PhysicsWorld } from '@codexo/exojs-physics';
import type { ObjectLayer } from '@codexo/exojs-tilemap';
import { buildObjectLayerColliders } from '@codexo/exojs-tilemap-physics';
declare const collisionLayer: ObjectLayer;
const world = new PhysicsWorld({ gravity: { x: 0, y: 1600 } });
const built = buildObjectLayerColliders(world, collisionLayer, {
friction: 0.7,
restitution: 0.05,
});
console.log(`${built.length} static colliders`);
Rectangles become boxes, ellipses become capsules (a circle when round), convex polygons become one polygon each, concave polygons become several convex polygons on one body, and polylines become chains. Points carry no collision area and are skipped.
Two ways to represent a solid tile region
By default a solid run of tiles becomes merged boxes, which keep an interior: queries and sensors work inside them, and a body that starts inside is pushed out. With regionMode: 'outline' the region becomes a closed one-sided chain instead - no internal edges for a sliding body to catch on, but no interior either, so a body spawned inside falls through.
Where to go next
- Joints, sleeping & CCD: the next chapter, Joints, sleeping & CCD, connects bodies with hinges, ropes, motors and springs, explains how resting bodies sleep to save CPU, and shows how to stop fast projectiles from tunnelling.
- API reference:
PhysicsWorld,PhysicsBody,Collider,BoxShape,CircleShape,CapsuleShape,SegmentShape,ChainShapeandPhysicsBinding. - Tilemap collision: Tiled maps covers the authoring side of the per-tile collision shapes the bridge consumes.
- The frame loop: Scenes & lifecycle covers
fixedUpdate,updateanddrawand the order they run in.


