Skip to content

Physics

YAGE uses Rapier2D under the hood for physics simulation. The @yagejs/physics package wraps Rapier with a pixel-based API — you never need to think about meters. All positions, velocities, and forces are in pixels.

import { PhysicsPlugin } from "@yagejs/physics";
engine.use(new PhysicsPlugin({
gravity: { x: 0, y: 980 }, // pixels/s², default: (0, 980)
pixelsPerMeter: 50, // conversion factor, default: 50
}));

RigidBodyComponent wraps a Rapier rigid body. Add it after Transform.

import { RigidBodyComponent } from "@yagejs/physics";
entity.add(new RigidBodyComponent({
type: "dynamic", // "dynamic" | "static" | "kinematic"
fixedRotation: true, // prevent rotation
gravityScale: 0, // ignore gravity (0 = zero-g, 1 = normal)
linearDamping: 5, // velocity drag
angularDamping: 1, // rotation drag
ccd: true, // continuous collision detection for fast objects
lockTranslationX: false, // lock horizontal movement
syncRotation: true, // sync physics rotation back to Transform
}));

Body types:

  • dynamic — affected by forces, gravity, and collisions. Use for players, projectiles, and physics objects.
  • static — never moves. Use for walls, floors, and platforms.
  • kinematic — moved programmatically, not by forces. Use for moving platforms, elevators, and doors. Move kinematic bodies by setting the Transform position (e.g. transform.setPosition(x, y) or transform.translate(dx, dy)), preferably in fixedUpdate — the body reaches the written pose on the next physics step and is drawn interpolated, so the drawn gap to a dynamic body riding it stays constant. rb.setPosition is the teleport for any body type: use it to move a platform across the map with no in-between frames.

ColliderComponent defines the collision shape. Add it after RigidBodyComponent.

The required component order is: TransformRigidBodyComponentColliderComponent.

Every collider needs a sibling RigidBodyComponent, including a sensor: true one. For a trigger you move through its Transform (a bobbing pickup, say), use a kinematic body. Leaving out the body throws when the collider is added, not when you construct it.

import { ColliderComponent } from "@yagejs/physics";
// Box
entity.add(new ColliderComponent({
shape: { type: "box", width: 64, height: 32 },
restitution: 0.5, // bounciness (0–1)
friction: 0.3,
density: 1, // affects mass for dynamic bodies
// contactSkin: 1, // rest about 1px off contact surfaces
}));
// Circle
entity.add(new ColliderComponent({
shape: { type: "circle", radius: 16 },
}));
// Capsule (vertical by default — pass axis: "x" for horizontal)
entity.add(new ColliderComponent({
shape: { type: "capsule", halfHeight: 20, radius: 10, axis: "y" },
}));
// Convex polygon. Rapier silently widens concave input to its convex
// hull; a dev warning is logged. Use polyline for non-convex outlines.
entity.add(new ColliderComponent({
shape: {
type: "polygon",
vertices: [{ x: 0, y: -20 }, { x: 20, y: 20 }, { x: -20, y: 20 }],
},
}));
// Polyline — chain of line segments. Supports non-convex shapes but is
// static-only (no mass/inertia). Best for tilemap-driven world boundaries
// such as winding water edges or U-shaped walls.
entity.add(new RigidBodyComponent({ type: "static" }));
entity.add(new ColliderComponent({
shape: {
type: "polyline",
vertices: [
{ x: 0, y: 0 },
{ x: 64, y: 0 },
{ x: 64, y: 16 },
{ x: 16, y: 16 },
{ x: 16, y: 48 },
],
},
}));
// Offset and rotation position the collider relative to its body — here a
// tilted attack hitbox in front of the entity. Rotation is in radians,
// about the offset point.
entity.add(new ColliderComponent({
shape: { type: "box", width: 60, height: 10 },
offset: { x: 20, y: 0 },
rotation: Math.PI / 4,
sensor: true,
}));

All dimensions are in pixels; rotation is in radians.

Box corners can be rounded while keeping the same outer width and height:

entity.add(new ColliderComponent({
shape: { type: "box", width: 64, height: 32, borderRadius: 4 },
}));

The outer footprint stays 64×32 and a resting body keeps its height, because the corners are rounded inward. The radius must be smaller than half the shorter side; anything else throws when the collider is built. Shape casts and overlap queries use the rounded geometry too.

Rounding does shrink the flat part of each face, to width - 2 * borderRadius. A body held up only by the last few pixels of a ledge slides off instead of standing on it.

contactSkin is the other way to round off a contact. It holds the collider a set number of pixels away from whatever it touches:

entity.add(new ColliderComponent({
shape: { type: "box", width: 12, height: 44 },
contactSkin: 1,
}));

A resting body then sits 1px above the ground. When both colliders in a pair set a skin, the gap is the sum of the two. Skins apply to contacts only, not to shape casts or overlap queries.

Both options fix the same problem: a box walking across a polyline terrain chain can catch on the junction between two segments and stop moving, because Rapier picks a contact normal that opposes the walk direction. Prefer borderRadius — it leaves the resting height alone.

Removing a ColliderComponent on its own (entity.remove(ColliderComponent)) frees its Rapier collider while the sibling RigidBodyComponent and body stay alive. Destroying the whole entity, or removing just the RigidBodyComponent, also removes every collider attached to that body.

CollisionLayers lets you control which objects can collide using named layer bitmasks.

import { CollisionLayers } from "@yagejs/physics";
const layers = new CollisionLayers();
const LAYER_PLAYER = layers.define("player");
const LAYER_WALL = layers.define("wall");
const LAYER_COIN = layers.define("coin");
// Player collides with walls and coins
entity.add(new ColliderComponent({
shape: { type: "circle", radius: 16 },
layers: LAYER_PLAYER,
mask: LAYER_WALL | LAYER_COIN,
}));
// Coin only collides with player
coinEntity.add(new ColliderComponent({
shape: { type: "circle", radius: 10 },
sensor: true,
layers: LAYER_COIN,
mask: LAYER_PLAYER,
}));

An object on layer A with mask B will only interact with objects on layer B that also have A in their mask.

A sensor collider detects overlaps without producing a physical response. Use sensors for trigger zones, collectibles, and hit detection.

A sensor: true collider fires only onTrigger; a solid collider fires only onCollision. Register the wrong handler and nothing fires — dev builds log a warning when you add it.

const collider = new ColliderComponent({
shape: { type: "circle", radius: 10 },
sensor: true,
});
// A collider needs a sibling RigidBodyComponent, even a sensor — use a
// kinematic body for a trigger you position through its Transform.
entity.add(new RigidBodyComponent({ type: "kinematic" }));
entity.add(collider);
collider.onTrigger((event) => {
if (event.entered) {
console.log("entered by", event.other.name);
} else {
console.log("exited by", event.other.name);
}
// event.otherCollider — the other entity's ColliderComponent
});

For non-sensor colliders, use onCollision instead:

collider.onCollision((event) => {
if (event.started) {
console.log("hit", event.other.name);
console.log("contact normal:", event.contactNormal);
console.log("contact point:", event.contactPoint);
console.log("penetration depth:", event.penetrationDepth);
console.log("contact impulse:", event.contactImpulse);
}
});

contactNormal, contactPoint, penetrationDepth, contactImpulse, and contactImpulseVector are set on started, non-sensor collisions:

  • contactNormal is a unit Vec2 pointing from this entity toward the other entity. Two colliders can touch along several surfaces in the same step. A box crossing a polyline corner touches both segments, and the normal describes the deepest of those contacts, the surface pushing hardest.
  • contactPoint is the world-pixel point of that deepest contact, not an average. A resting box touches at several points at once; when they are equally deep, any one of them can be reported.
  • penetrationDepth is the overlap depth of that deepest contact in world pixels, always >= 0.
  • contactImpulse is the magnitude of the total impulse the solver applied to resolve the contact, in the same units as applyImpulse; friction is not included. Divide it by a dynamic body’s getMass() to get the speed change that body received from the contact, in px/s. A static or kinematic body receives the event too, but no velocity change.
  • contactImpulseVector is the same impulse as a Vec2, pointing from this entity toward the other like contactNormal; its length equals contactImpulse. The push on this body points the opposite way: contactImpulseVector.scale(-1 / getMass()) is a dynamic body’s velocity change in px/s.

All five can be undefined even on a started event, if Rapier has no contact manifold for the pair yet (for example a same-step start-and-stop). The impulse can be 0 when the solver did not need to apply an impulse, such as for a grazing contact. Guard on contactNormal before using any of them:

collider.onCollision((event) => {
if (!event.started || !event.contactNormal) return;
const knockback = event.contactNormal.scale(-300);
entity.get(RigidBodyComponent).setVelocity(knockback);
});

Use contactImpulse to score impacts — for example, deal damage when the impulse passes a threshold:

collider.onCollision((event) => {
if (
event.started &&
event.contactImpulse !== undefined &&
event.contactImpulse > 100
) {
dealDamage(10);
}
});

Reading the body’s velocity inside the handler does not work for this: it measures the velocity after the solver has resolved the contact, so the hardest hits read as the smallest numbers.

Both handlers return an unsubscribe function.

You can query which entities currently overlap a sensor collider:

const overlapping = collider.getOverlapping();
const enemies = collider.getOverlapping({ tags: ["enemy"] });
const components = collider.getOverlappingComponents(HealthComponent);

This reports an overlap only when this collider or the overlapping one is a sensor: true. Two solid colliders never report each other here, however deeply they penetrate — for solid-vs-solid contact such as contact damage, use onCollision.

Prefer setVelocity for direct control. Use applyImpulse or applyForce for physics-driven movement.

const rb = entity.get(RigidBodyComponent);
// Direct velocity control (px/s) — best for player movement
rb.setVelocity(new Vec2(200, 0));
rb.setVelocityX(200); // set X, keep Y
rb.setVelocityY(-300); // set Y, keep X
// Read velocity
const vel = rb.getVelocity(); // Vec2 in px/s
// Reading a single axis or the magnitude — these read numbers straight from
// Rapier without allocating a Vec2, so prefer them on a per-frame read path.
rb.velocityX; // number, px/s
rb.velocityY; // number, px/s
rb.speed; // number, px/s — velocity magnitude
rb.speedSquared; // number — cheaper than speed when only comparing magnitudes
// Impulse (instant momentum change)
rb.applyImpulse(new Vec2(0, -4000));
// Force (continuous, applied per frame)
rb.applyForce(new Vec2(100, 0));
// Rotation
rb.setAngularVelocity(Math.PI);
rb.applyTorque(50);

Use setPosition to teleport a body of any type without interpolation artifacts — no in-between poses are ever drawn:

rb.setPosition(400, 300); // pixels
rb.setRotation(Math.PI / 2); // radians

On a kinematic body, rb.setPosition and transform.setPosition do different things: the first is an instant jump, the second a smooth move the body completes over one physics step. Pick by whether the player should see the travel.

Physics runs at a constant rate that rarely lines up with the display refresh rate, so a dynamic or kinematic body has two positions and they differ within a frame.

The entity’s Transform holds the drawn pose. The engine blends it between the last two physics steps, so it moves smoothly at whatever rate the screen refreshes, and it trails the simulation by at most one physics step. Use it for anything visual — camera follow, a health bar pinned above a character, a dust puff spawned at a body:

const drawn = entity.get(Transform).worldPosition; // smooth, what the player sees

The rigid body holds the simulated pose, as of the last completed physics step. Use it when a number has to match the simulation exactly — a distance threshold, snapping to a grid, writing a checkpoint:

rb.position; // Vec2, pixels
rb.positionX; // number, pixels — skips the Vec2 allocation
rb.positionY; // number, pixels
rb.rotation; // number, radians

The blend happens at the start of Update, before any component update(dt) runs, so the Transform your game logic reads is the one that gets drawn that frame. Raycasts, collision events, and other physics queries always report exact poses: they run inside the simulation rather than against the Transform.

This is also why kinematic movement belongs in fixedUpdate. There, the Transform holds the exact pose your last write reached, so per-tick deltas (transform.translate(speed * dt, 0)) accumulate cleanly. In update() the Transform holds the blended pose instead — write absolute positions there, or compute them from rb.position, and expect the body to apply the write one frame later.

Cast a ray to find the first entity hit:

const world = this.use(PhysicsWorldKey);
const hit = world.raycast(
origin, // Vec2Like — start position in pixels
{ x: 0, y: 1 }, // direction — any non-zero vector (normalized internally)
100, // max distance in pixels
{ filterGroups: CollisionLayers.interactionGroups(LAYER_PLAYER, LAYER_WALL) },
);
if (hit) {
console.log(hit.entity); // Entity that was hit
console.log(hit.point); // Vec2 — hit position in pixels
console.log(hit.normal); // Vec2 — surface normal
console.log(hit.distance); // number — distance in pixels
}

A common use case is ground detection for platformers:

const grounded = world.raycast(
this.transform.worldPosition,
{ x: 0, y: 1 },
playerHeight / 2 + 2,
) !== null;

A raycast is infinitely thin, so it slips past anything narrower than the gap between rays. castShape sweeps a whole shape along a direction and reports the first thing it would hit:

const hit = world.castShape(
{ type: "box", width: 24, height: 40 }, // the shape to sweep
this.transform.worldPosition, // where it starts, in pixels
{ x: 0, y: 1 }, // direction — any non-zero vector
120, // how far to sweep, in pixels
{ excludeEntity: this.entity }, // skip the mover's own colliders
);
if (hit) {
hit.entity; // Entity that was hit
hit.distance; // how far the shape travelled before contact, in pixels
hit.point; // Vec2 — world contact point
hit.normal; // Vec2 — surface normal on the entity that was hit
}

The result has the same shape as a raycast hit. A shape that already overlaps something where it starts reports that hit at distance: 0.

Use this to test a move before committing to it. A fast-falling player can land on the floor it would otherwise pass through in one frame:

const travel = this.rb.velocityY * dt;
if (travel > 0) { // only while falling
const pos = this.transform.worldPosition;
const ground = world.castShape(
this.footShape,
pos,
{ x: 0, y: 1 },
travel,
{ excludeEntity: this.entity },
);
if (ground) {
// setPosition on the body, not the Transform: physics writes the
// Transform back every step for dynamic bodies.
this.rb.setPosition(pos.x, pos.y + ground.distance);
this.rb.setVelocityY(0);
}
}

queryShape answers a different question: what a shape touches where it already stands. It misses anything the shape would pass through on the way.

Change gravity at runtime via the PhysicsWorld:

const world = this.use(PhysicsWorldKey);
world.setGravity(0, -980); // flip gravity upward
world.setGravity(0, 0); // zero gravity

A single body can fall differently from everything else, and can change how it falls at any time:

rb.setGravityScale(0); // floats — no gravity on this body
rb.setGravityScale(2.5); // falls two and a half times as fast
rb.setGravityScale(1); // back to scene gravity
rb.gravityScale; // read the current multiplier

This is how platformers get variable jump height and a fast-fall. Hold the jump button and the player rises under light gravity; release it, or press down, and a heavier multiplier pulls them back down — all without touching gravity for any other body:

override update(): void {
const rising = this.rb.velocityY < 0;
const holdingJump = this.input.isPressed("jump");
if (rising && holdingJump) this.rb.setGravityScale(0.6); // floaty ascent
else if (rising) this.rb.setGravityScale(2.0); // cut the jump short
else this.rb.setGravityScale(1.6); // snappy fall
}

gravityScale can also be set once in RigidBodyConfig when the body is created.

Lock or unlock axes at runtime:

rb.setEnabledTranslations(true, false); // lock Y axis
rb.lockRotations(true); // lock rotation

setShape replaces a collider’s shape in place. The Rapier collider, its attachment to the body, and every collision or trigger handler you registered all survive the swap:

collider.setShape({ type: "box", width: 24, height: 24 }); // crouch
collider.setShape({ type: "box", width: 24, height: 48 }); // stand

The body keeps the mass it already had. A collider describes where a body collides, not how much matter is in it, so a character who crouches takes the same knockback from applyImpulse as one standing upright. When the shape change does mean more or less matter — an object that grows, or one that loses a chunk — ask for the mass back from density and the new shape:

collider.setShape({ type: "circle", radius: 48 }, { recomputeMass: true });

Shrinking is always safe. Growing is not: nothing is pushed out of the way, so a collider can end up overlapping a ceiling it did not touch while crouched. Check for clearance first.

A collider is centred on its body’s origin unless you give it an offset, so a character who grows upward while keeping their feet planted also has to move up by half the height they gained. Test the standing box where it will actually sit, not where the crouched one sits:

const CROUCH_HEIGHT = 24;
const STAND_HEIGHT = 48;
const rise = (STAND_HEIGHT - CROUCH_HEIGHT) / 2;
const standing = { type: "box", width: 24, height: STAND_HEIGHT } as const;
const pos = this.transform.worldPosition;
const blocked = world.queryShape(
standing,
{ x: pos.x, y: pos.y - rise },
{ excludeEntity: this.entity },
).length > 0;
if (!blocked) {
collider.setShape(standing);
this.rb.setPosition(pos.x, pos.y - rise);
}

Testing the standing box at the crouched position instead would report the floor as a blocker every time the character is grounded, and they could never stand up.

A oneWay collider is solid from one side and passable from every other: the player lands on the platform from above, and jumps up through it from below.

platform.add(new ColliderComponent({
shape: { type: "box", width: 96, height: 8 },
oneWay: {},
}));

With no options, the solid face points up ({ x: 0, y: -1 }). Bodies falling onto the platform land; bodies coming from below, or from the side, pass through. A body that is already inside the platform is let out rather than snapped to the surface, so turning on the behavior mid-overlap never launches anything.

The direction is in the platform body’s local frame, so a rotated platform carries its solid face with it. margin is how deep (in pixels) a body may already overlap the face and still count as landing:

oneWay: {
direction: { x: 0, y: -1 }, // default: solid face up
margin: 4, // default
}

For the down-jump — the player presses down and falls through the platform they are standing on — give the rider’s collider a drop-through window:

player.get(ColliderComponent).dropThrough(0.2); // seconds of simulated time

The window is per body: every one-way platform lets this body pass while it is open, and other bodies standing on the same platform stay supported. If the window expires while the body is still inside a platform, the body keeps falling until it is clear and lands on the next solid face it reaches from above. isDroppingThrough reports whether the window is open, which is handy for animation state.

Two practical notes:

  • A very fast body can cross a thin platform entirely between two physics steps and is never detected. That is ordinary tunneling — give the body ccd: true (see Continuous Collision Detection below). The CCD sweep respects one-way behavior, including drop-through.
  • oneWay is part of the collider config, so it survives save/load. It has no effect on sensor: true colliders, since sensors never produce solid contacts.

oneWay is built on a lower-level tool you can use directly: a contact filter, which decides for each candidate pair of colliders, every physics step, whether that pair is solid. Return true to collide normally, false to let the two bodies pass through each other for that step:

// A wall that only blocks entities tagged "enemy".
wall.get(ColliderComponent).setContactFilter((contact) =>
contact.other.tags.has("enemy"),
);

The filter runs inside the physics step, before any contact exists, so there is no contact normal or contact point to read. Instead the contact object carries both sides’ positions, rotations, and velocities as plain numbers, plus the other side’s Entity and ColliderComponent. All values are from the start of the step — for a body that crossed a surface mid-step, they tell you which side it came from.

A few rules to keep filters healthy:

  • The filter runs for every candidate pair involving the collider, every step. Keep it cheap, and never create or destroy entities, bodies, or colliders from inside it.
  • The contact object is reused across calls. Read what you need and return; don’t store it.
  • When both colliders in a pair have filters, the pair is solid only if both return true.
  • A filter that throws is reported through the error boundary (Inspector.getErrors().callbackErrors) and the pair stays solid — the conservative default. The report fires once per installed filter, so a persistently throwing filter can’t flood the log; installing a new filter arms it again.
  • Setting a filter on a collider configured with oneWay replaces the built-in one-way filter. Filters are functions, so they are not saved: after a load, oneWay colliders get their built-in filter back, and custom filters must be registered again.
  • Filters apply to solid contact pairs only; sensor overlap is unaffected.

Clear a filter with setContactFilter(null).

Enable CCD on fast-moving bodies to prevent tunneling through thin colliders:

entity.add(new RigidBodyComponent({
type: "dynamic",
ccd: true,
}));

CCD adds a small performance cost — only enable it for bullets, fast projectiles, or small objects that move at high speed.

Access the active scene’s physics world from any component via the scene-scoped PhysicsWorldKey:

import { PhysicsWorldKey } from "@yagejs/physics";
const world = this.use(PhysicsWorldKey);

Each scene gets its own PhysicsWorld. The physics plugin creates the world in a beforeEnter hook and destroys it in afterExit. For cross-scene enumeration (save system, debug inspector) use the engine-scope PhysicsWorldManagerKey instead.

Joints connect two rigid bodies in the same scene. Lengths and anchor points use pixels. A rope limits the distance between two bodies; a spring pulls them toward its restLength and also resists relative motion.

const world = this.use(PhysicsWorldKey);
const tether = world.addJoint(playerBody, anchorBody, {
type: "rope",
length: 140,
});
// A static anchor makes a simple swing. Release the rope when the player lets go.
if (input.isJustPressed("release")) tether.remove();
world.addJoint(playerBody, companionBody, {
type: "spring",
restLength: 80,
stiffness: 40,
damping: 4,
});

addJoint returns a handle with an attached flag and a remove() method that is safe to call more than once. The handle detaches when either jointed entity is destroyed or disabled (for example, released to a pool); re-enabling the entity does not restore the joint. Lengths and anchors are always in pixels. stiffness and damping are mass-relative and passed to the solver unconverted — collider mass depends on pixelsPerMeter, so retune them after changing the scale.

Joints are not saved: after a snapshot restore, recreate them (for example in afterRestore).