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); both finite
pixelsPerMeter: 50, // conversion factor, default: 50; finite and > 0
}));

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); finite
linearDamping: 5, // velocity drag; finite and >= 0
angularDamping: 1, // rotation drag; finite and >= 0
ccd: true, // also stop against moving bodies at high speed
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.

rb.setType(type) switches the type at runtime: a dead enemy becomes "static" so nothing pushes it and it pushes nothing, a carried crate becomes "kinematic" while held, a door becomes "dynamic" when it is knocked off its hinges. Linear and angular velocity are cleared by the switch; locks, gravity scale, damping, colliders and mass are kept, and the drawn pose is the pose at the switch. rb.type reads the current one.

import { Component } from "@yagejs/core";
import { RigidBodyComponent } from "@yagejs/physics";
class EnemyHealth extends Component {
private readonly rb = this.sibling(RigidBodyComponent);
onDeath() {
this.rb.setType("static"); // a corpse nothing can shove around
}
}

ColliderComponent defines the collision shape. Add it after RigidBodyComponent.

The required component order is: Transform → RigidBodyComponent → ColliderComponent.

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, RigidBodyComponent } from "@yagejs/physics";
// Box
entity.add(new ColliderComponent({
shape: { type: "box", width: 64, height: 32 },
restitution: 0.5, // bounciness; finite and >= 0
friction: 0.3, // finite and >= 0
density: 1, // affects mass for dynamic bodies; finite and >= 0
// contactSkin: 1, // rest about 1px off contact surfaces; finite and >= 0
}));
// Circle
entity.add(new ColliderComponent({
shape: { type: "circle", radius: 16 },
}));
// Capsule (vertical by default — pass axis: "x" for horizontal).
// halfHeight is half the straight section; each cap adds radius, so this
// collider stands 2 * (20 + 10) = 60px tall. halfHeight: 0 is a circle.
// Boxes and circles take outer dimensions.
entity.add(new ColliderComponent({
shape: { type: "capsule", halfHeight: 20, radius: 10, axis: "y" },
}));
// Convex polygon: at least 3 vertices, not all on one line. 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,
}));

Use parts when one body needs several collision shapes. Each part has its own shape, offset, and rotation. Settings outside parts apply to every shape, and Rapier adds their mass together:

import { ColliderComponent } from "@yagejs/physics";
entity.add(new ColliderComponent({
parts: [
{ shape: { type: "box", width: 48, height: 16 } },
{
shape: { type: "circle", radius: 8 },
offset: { x: 24, y: 0 },
},
],
friction: 0.3,
}));

collider.colliderCount gives the number of parts. Overlap queries check all parts and return an entity once even when several parts overlap it.

Collider geometry follows the entity’s Transform scale automatically. A scale set before the collider is added applies immediately. Later changes to the entity or an ancestor apply at the next physics step. Part offsets scale too, and a scale change recalculates body mass from density.

Uniform positive scale keeps Rapier’s box, circle, capsule, polygon, and polyline shapes. Non-uniform and mirrored scale transform polygons and polylines exactly. A plain box uses its four transformed corners. Circles, capsules, and rounded boxes use a 32-point convex outline because Rapier has no matching scaled primitive. If either scale axis is zero, the colliders are disabled and contribute no mass until both axes are non-zero again.

All dimensions are in pixels; rotation is in radians.

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

import { ColliderComponent } from "@yagejs/physics";
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. The body’s mass is the rounded footprint’s area at the configured density, so rounding takes off only the four corner pieces. Angular inertia is the inner rectangle’s, scaled by the same area ratio, which is an approximation.

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:

import { ColliderComponent } from "@yagejs/physics";
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. The ColliderComponents left behind no longer hold a collider handle, so their remaining calls do nothing rather than reaching whatever collider Rapier creates next.

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

import type { Entity } from "@yagejs/core";
import { ColliderComponent, 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
declare const coinEntity: Entity; // has a Transform and a RigidBodyComponent
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.

raycast, castShape, queryShape, and queryRadius take the same layer pair as a collider, packed into one filterGroups number. CollisionLayers.interactionGroups(membership, filter) builds it:

import { Transform } from "@yagejs/core";
import { PhysicsWorldKey } from "@yagejs/physics";
const world = scene.use(PhysicsWorldKey);
const pos = entity.get(Transform).worldPosition;
const hit = world.raycast(pos, { x: 0, y: 1 }, 40, {
filterGroups: CollisionLayers.interactionGroups(LAYER_PLAYER, LAYER_WALL),
});

That casts as if the ray were on the player layer looking for walls, so the two-way rule above applies to it: a wall is reported only if LAYER_PLAYER is in that wall’s own mask. To report every collider on a layer regardless of which layers its own mask names, pass 0xffff as the membership (a collider with mask: 0 matches no query and is never reported):

const walls = world.queryRadius(pos, 120, {
filterGroups: CollisionLayers.interactionGroups(0xffff, LAYER_WALL),
});

Passing a layers.define() value straight to filterGroups matches nothing. A layer bit sits in the lower 16 bits, which the packed form reads as the filter half, leaving the membership at 0 — and a query with no membership bit fails the layer test against every collider. The query returns null or an empty array with no error. Omit filterGroups to skip the layer test only; the other query options, such as excludeEntity and the sensor mode described below, still apply.

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.

Events are collected after every physics step and delivered after that step. A scene running above timeScale 1 steps more than once per tick and receives every transition, in order, each with its own step’s contact data. Handlers see Transforms synced to the step that produced the contact, and a handler’s setVelocity or destroy takes effect before the next step of the same tick.

import { ColliderComponent, RigidBodyComponent } from "@yagejs/physics";
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
// event.selfShapeIndex / otherShapeIndex — participating compound parts
});

A collider can change kind while it is live. setSensor(true) turns a solid collider into a sensor, setSensor(false) turns it back:

import type { Entity } from "@yagejs/core";
import { ColliderComponent } from "@yagejs/physics";
declare const enemy: Entity;
enemy.get(ColliderComponent).setSensor(true); // dead: stop blocking bullets

The collider is recreated with the new flag. Every pair it is in ends with a stop (or exit) at the next step and re-forms as the new kind, so a solid box flipped to a sensor falls through what it was resting on, and a sensor flipped to solid while overlapping something is pushed out to rest. The body’s mass, the contact filter, and every registered handler are unchanged. A call that does not change the flag does nothing. Dev builds warn when the flip leaves handlers of the silenced kind registered: onCollision on a sensor, onTrigger on a solid.

For non-sensor colliders, use onCollision instead:

import { ColliderComponent } from "@yagejs/physics";
const collider = entity.get(ColliderComponent);
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:

import { RigidBodyComponent } from "@yagejs/physics";
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:

declare function dealDamage(amount: number): void;
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:

import { Component } from "@yagejs/core";
class HealthComponent extends Component {
hp = 100;
}
const overlapping = collider.getOverlapping();
const enemies = collider.getOverlapping({ tags: ["enemy"] });
const components = collider.getOverlappingComponents(HealthComponent);

This reports an overlap when at least one of the two colliders is a sensor: true, two sensors included. Two solid colliders never report each other here, however deeply they penetrate — for solid-vs-solid contact such as contact damage, use onCollision. It is the one query that is about sensors: the world queries below (raycast, castShape, queryShape, queryRadius) skip sensors unless you ask for them.

The bodies count as well as the flags. Two colliders that both sit on static bodies never report each other, whatever their sensor flags: at least one of the two bodies has to be kinematic or dynamic. Sensor pairs do report on static against kinematic, on kinematic against kinematic, and on dynamic against dynamic. So give a trigger zone a kinematic body whenever the collider it has to notice sits on a static body — a detector laid over a wall, a zone that reacts to a fixed piece of scenery. A zone that only ever sees the player or another moving body can stay static.

Trigger events carry no contact data, because a sensor overlap has no solver contact. contactWith measures the geometry of any pair at its current pose, sensors included:

import type { Vec2 } from "@yagejs/core";
declare function spawnSparks(at: Vec2, facing: Vec2): void;
collider.onTrigger((ev) => {
const contact = collider.contactWith(ev.otherCollider, ev);
if (contact) spawnSparks(contact.otherPoint, contact.normal.scale(-1));
});

The result has point on this collider’s surface, otherPoint on the other’s, a unit normal from this collider toward the other, and a distance in pixels between the two points. Overlapping shapes report a negative distance, the penetration depth, and a normal along the shortest way out. Passing the event’s selfShapeIndex and otherShapeIndex measures the shape pair that fired it; without them, a compound collider reports the closest pair among all its parts. prediction (pixels, default 0) also reports pairs separated by up to that distance; further apart, or when either component has no live collider, the result is undefined.

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

import { Vec2 } from "@yagejs/core";
import { RigidBodyComponent } from "@yagejs/physics";
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);

getVelocity() returns an immutable value you can keep or share. When you read both velocity coordinates every frame, create a Vec2Buffer once and reuse it:

import { Vec2Buffer } from "@yagejs/core";
const velocity = new Vec2Buffer(); // keep between updates
rb.getVelocityInto(velocity); // writes x and y in pixels per second

getVelocityInto overwrites and returns the same buffer without constructing a Vec2. Both coordinates come from one Rapier read. Read again to refresh the buffer; its values do not follow later changes to the body. Without a live body, the method writes (0, 0).

Rapier limits how fast a body can move. Linear speed stops at 400 meters per second, which is 400 × pixelsPerMeter px/s: 20,000 px/s at the default 50. Rotation stops at 45° per physics step, about 47 rad/s at the default 60 steps per second. A faster velocity is clamped at the next step.

Physics runs at the scene’s effective time scale. An entity excluded from a slow-motion effect still has its velocity integrated at the slowed rate, so a component running at full speed writes a velocity the world then applies too slowly. Scale velocity writes by the ratio of the two rates:

import { SceneTimeKey, Vec2 } from "@yagejs/core";
import { RigidBodyComponent } from "@yagejs/physics";
const rb = this.entity.get(RigidBodyComponent);
const dir = new Vec2(1, 0);
const speed = 200; // px/s
const time = this.use(SceneTimeKey);
const world = time.effectiveScale;
const factor = world > 0 ? time.effectiveScaleForUpdates(this.entity) / world : 1;
rb.setVelocity(dir.scale(speed * factor));

The factor is 1 while the scene is frozen — nothing integrates then, so nothing needs compensating.

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.

On a static body, rb.setPosition and rb.setRotation move the Transform with the body, so the sprite follows the teleport. Nothing else writes a static body’s Transform.

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:

import { Transform } from "@yagejs/core";
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

Both positions can also be read into buffers you keep between updates:

import { Transform, Vec2Buffer } from "@yagejs/core";
import { RigidBodyComponent } from "@yagejs/physics";
const rb = entity.get(RigidBodyComponent);
const simulated = new Vec2Buffer();
const drawn = new Vec2Buffer();
rb.getPositionInto(simulated);
entity.get(Transform).getWorldPositionInto(drawn);

Each method overwrites and returns its buffer in world pixels without constructing a Vec2. rb.getPositionInto reads both coordinates from the simulation together. Without a live body, it reads the entity’s Transform world position, like rb.position.

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.

A dynamic body’s Transform is written by physics every frame. A write to it while the entity is active is overwritten before the next step and never reaches the body — move a dynamic body with setVelocity, applyImpulse, or rb.setPosition.

A position or rotation written to the Transform while the entity is inactive teleports the body to that world pose when the entity is enabled again. This works for static, dynamic, and kinematic bodies, including writes during dormant setup of a prewarmed pool member:

import { Transform, type Entity } from "@yagejs/core";
declare const bullet: Entity; // has a Transform and a RigidBodyComponent
const x = 400;
const y = 300;
bullet.setActive(false);
// ...later, before putting it back in play
bullet.get(Transform).setPosition(x, y);
bullet.setActive(true); // the body is at (x, y), not where it slept

Position and rotation are checked independently. An unchanged value keeps the body’s current value.

An active static body does not follow Transform writes. Use rb.setPosition and rb.setRotation to move it immediately. Both update the Transform too, except rotation when syncRotation is false.

EntityPool activates a member before calling its onAcquire hook. Use the body methods in that hook to place a static or dynamic member immediately:

import { Entity } from "@yagejs/core";
import { RigidBodyComponent } from "@yagejs/physics";
class Crate extends Entity {
onAcquire(x: number, y: number) {
this.get(RigidBodyComponent).setPosition(x, y);
}
}

Cast a ray to find the first entity hit. Sensor colliders are skipped, so a ray reports surfaces rather than trigger zones — pass sensors: "include" to see both, or "only" for the trigger zones alone:

import { Transform } from "@yagejs/core";
import { CollisionLayers, PhysicsWorldKey } from "@yagejs/physics";
const layers = new CollisionLayers();
const LAYER_PLAYER = layers.define("player");
const LAYER_WALL = layers.define("wall");
const world = this.use(PhysicsWorldKey);
const origin = this.entity.get(Transform).worldPosition;
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
}

filterGroups is the packed layer pair described under Filtering a world query.

A common use case is ground detection for platformers. A coin’s trigger zone underfoot is not ground, and the default sensor mode keeps it out of the answer:

import { Component, Transform } from "@yagejs/core";
import { PhysicsWorldKey } from "@yagejs/physics";
class GroundCheck extends Component {
private readonly transform = this.sibling(Transform);
isGrounded(playerHeight: number): boolean {
const world = this.use(PhysicsWorldKey);
return world.raycast(
this.transform.worldPosition,
{ x: 0, y: 1 },
playerHeight / 2 + 2,
) !== null;
}
}

Every world query reports live colliders at their current pose, including ones created, re-shaped, enabled, disabled, or teleported since the last physics step — a wall spawned this frame blocks a ray cast in the same frame. Refreshing the query index costs one zero-duration physics step, which moves nothing and advances no simulated time, on a frame that both changed colliders and queried.

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:

import { Component, Transform } from "@yagejs/core";
import { PhysicsWorldKey } from "@yagejs/physics";
class Mover extends Component {
private readonly transform = this.sibling(Transform);
update() {
const world = this.use(PhysicsWorldKey);
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. With stopAtPenetration: true (the default), a shape that already overlaps something where it starts reports that hit at distance: 0. Sensors are skipped here too; sensors: "include" and "only" work as on raycast. A shape with a dimension that is not finite and above 0 throws.

For ledge or step clearance, a resting character may slightly overlap a wall or floor. Set stopAtPenetration: false to check movement out of that overlap:

import { PhysicsWorldKey, RigidBodyComponent } from "@yagejs/physics";
const world = this.use(PhysicsWorldKey);
const rb = this.entity.get(RigidBodyComponent);
const player = this.entity;
const shape = { type: "box", width: 24, height: 40 } as const;
const outward = { x: 0, y: -1 }; // up, out of the floor
const distance = 4;
const hit = world.castShape(shape, rb.position, outward, distance, {
excludeEntity: player,
stopAtPenetration: false,
});

The cast still reports obstacles farther along the route and movement deeper into the initial overlap. If the move requires a clear destination, also check that position with queryShape. The option affects only the cast; body collisions still apply normally.

Use this to test whether a fast-falling player has a floor along its path this frame:

import { Component, Transform } from "@yagejs/core";
import { PhysicsWorldKey, RigidBodyComponent } from "@yagejs/physics";
class FastFall extends Component {
private readonly rb = this.sibling(RigidBodyComponent);
private readonly transform = this.sibling(Transform);
private readonly footShape = { type: "box", width: 20, height: 4 } as const;
update(dt: number) {
const world = this.use(PhysicsWorldKey);
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:

import { PhysicsWorldKey } from "@yagejs/physics";
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:

import { Component } from "@yagejs/core";
import { InputManagerKey } from "@yagejs/input";
import { RigidBodyComponent } from "@yagejs/physics";
class JumpGravity extends Component {
private readonly rb = this.sibling(RigidBodyComponent);
private readonly input = this.service(InputManagerKey);
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.

Change damping or lock axes at runtime:

rb.setLinearDamping(6); // slow linear velocity
rb.setAngularDamping(2); // slow rotation
rb.setEnabledTranslations(true, false); // lock Y axis
rb.lockRotations(true); // lock rotation

Damping must be finite and at least 0. A locked axis ignores forces, impulses, and contacts; a velocity written with setVelocity still moves the body along it. These calls, like setGravityScale and setType, work before entity.add() — the values apply when the body is created:

import { RigidBodyComponent } from "@yagejs/physics";
const rb = new RigidBodyComponent({ type: "dynamic" });
rb.setLinearDamping(6);
rb.lockRotations(true);
entity.add(rb); // the body is created with damping and rotation locked

The damping setters also work while the entity is inactive. They update the dormant body without enabling or waking it.

Change bounciness and friction without replacing the collider:

collider.setRestitution(0.8);
collider.setFriction(0.1);

Both values must be finite and at least 0. Restitution above 1 is valid. Each call updates every part in a compound collider. You can call either method before entity.add() or while the entity is inactive. An inactive collider stays disabled. The values are retained when setSensor recreates the collider.

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:

const newHeadShape = { type: "circle", radius: 10 } as const;
collider.setShape(
{ type: "box", width: 24, height: 24 },
{ offset: { x: 0, y: -12 } },
); // crouch while the body origin stays at the feet
collider.setShape(newHeadShape, { index: 1 }); // compound part 1

A shape with a dimension that is not finite and above 0 throws before anything is stored, so a bad computed height (standing - shrink gone negative) leaves the live collider and the component’s config untouched. index defaults to 0; an index that does not name a part also throws before anything changes. Pass offset to change the selected part’s body-local offset with its shape. The coordinates use authored pixels before Transform scale. Omitting offset keeps the current value, while { x: 0, y: 0 } resets the part to the body origin. Both coordinates are required and finite. The component copies the supplied object. An invalid offset or a scaled value that is not finite also throws before the config or live collider changes.

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 });

Changing a shape or offset can leave the collider overlapping other geometry. Check the region that the target collider will newly occupy before applying the change.

A feet-origin body can crouch and stand without moving the body. Keep each box’s bottom at the origin by setting its offset to negative half its height. Before standing, query the headroom between the crouched and standing top edges:

import {
ColliderComponent,
PhysicsWorldKey,
RigidBodyComponent,
} from "@yagejs/physics";
const world = this.use(PhysicsWorldKey);
const rb = this.entity.get(RigidBodyComponent);
const collider = this.entity.get(ColliderComponent);
const STAND_WIDTH = 24;
const CROUCH_HEIGHT = 24;
const STAND_HEIGHT = 48;
const clearanceHeight = STAND_HEIGHT - CROUCH_HEIGHT;
const standing = {
type: "box",
width: STAND_WIDTH,
height: STAND_HEIGHT,
} as const;
const clearance = {
type: "box",
width: STAND_WIDTH,
height: clearanceHeight,
} as const;
const feet = rb.position;
const blocked = world.queryShape(
clearance,
{
x: feet.x,
y: feet.y - (STAND_HEIGHT + CROUCH_HEIGHT) / 2,
},
{ excludeEntity: this.entity },
).length > 0;
if (!blocked) {
collider.setShape(standing, {
offset: { x: 0, y: -STAND_HEIGHT / 2 },
});
}

Querying the full standing box would also touch the floor and can report a false blocker. If side contacts need a small tolerance, reduce the clearance box width by that deliberate inset; keep the target collider at its full width.

queryShape sees colliders that appeared or changed since the last physics step, so a ceiling spawned this frame already blocks the stand-up. It skips sensor colliders, so a pickup’s trigger zone overhead does not.

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.

import type { Entity } from "@yagejs/core";
import { ColliderComponent } from "@yagejs/physics";
declare const platform: Entity; // has a Transform and a static RigidBodyComponent
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.

Changing a body’s velocity just before contact is detected does not prevent it from landing after approaching from above. Teleporting, resizing, or re-enabling a collider inside the platform does not count as landing; the configured margin still applies.

The direction is in the platform body’s local frame, so a rotated platform carries its solid face with it. Any non-zero vector with finite components works; a zero vector throws when the collider is constructed. margin is how deep (in pixels) a body may already overlap the face and still count as landing; it must be finite:

import type { OneWayConfig } from "@yagejs/physics";
const oneWay: OneWayConfig = {
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:

import type { Entity } from "@yagejs/core";
import { ColliderComponent } from "@yagejs/physics";
declare const player: Entity; // standing on a one-way platform
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.

Practical notes:

  • A fast body cannot cross a static platform between two physics steps: it is swept against static colliders every step. A kinematic platform is swept only for a body with ccd: true (see Continuous Collision Detection below). The sweep respects one-way behavior, including drop-through.
  • oneWay is part of the collider config. It has no effect on sensor: true colliders, since sensors never produce solid contacts.
  • World queries (raycast, castShape, queryShape, and queryRadius) test geometry. They do not apply oneWay, dropThrough, or contact filters, so a query hit alone does not mean a platform supports the rider.

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:

import type { Entity } from "@yagejs/core";
import { ColliderComponent } from "@yagejs/physics";
declare const wall: Entity;
// 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 current step. A pair can already overlap when first detected after movement in an earlier step. If game code has since changed velocity, that velocity cannot reliably tell you which side the body came from. Use the built-in oneWay behavior for platforms.

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.
  • While any collider in the world has a filter (a oneWay platform counts), every step reads every collider’s pose and velocity before stepping. That cost grows with the number of colliders in the world.
  • 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, both run for every candidate pair, and 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. Register custom filters during normal component setup whenever the scene is constructed.
  • Filters apply to solid contact pairs only; sensor overlap is unaffected.

Clear a filter with setContactFilter(null).

A fast dynamic body is swept along its path against static colliders every step, so it stops at a thin wall or floor instead of passing through it between two steps.

Kinematic and dynamic bodies are not swept by default. Enable CCD on a fast body that has to stop against them, such as a bullet that must hit a moving platform or another body:

import { RigidBodyComponent } from "@yagejs/physics";
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 diagnostics use the engine-scope PhysicsWorldManagerKey instead.

Joints connect bodies to make swings, hinges, sliding platforms, and welded structures. Both rigid bodies must already belong to the same scene and their entities must be active.

A rope limits the distance between its attachment points. A spring pulls those points toward a rest length:

import { InputManagerKey } from "@yagejs/input";
import { PhysicsWorldKey, type RigidBodyComponent } from "@yagejs/physics";
// Bodies of active entities in this scene.
declare const playerBody: RigidBodyComponent;
declare const anchorBody: RigidBodyComponent;
declare const companionBody: RigidBodyComponent;
const world = scene.use(PhysicsWorldKey);
const input = scene.use(InputManagerKey);
const tether = world.addJoint(playerBody, anchorBody, {
type: "rope",
length: 140,
});
world.addJoint(playerBody, companionBody, {
type: "spring",
restLength: 80,
stiffness: 40,
damping: 4,
});
if (input.isJustPressed("release")) tether.remove();

Every joint accepts anchorA and anchorB: attachment points in each body’s local pixels, defaulting to the body origin. collide controls whether the connected bodies collide with each other. It defaults to false for fixed joints and true for the other types. Collision layers still apply.

A fixed joint holds its anchors together and keeps the two bodies at the same angle. Set the anchors to the intended attachment point so the solver does not need to pull separated anchors together:

declare const wallBody: RigidBodyComponent;
declare const brickBody: RigidBodyComponent;
const weld = world.addJoint(wallBody, brickBody, {
type: "fixed",
anchorA: { x: 0, y: 20 },
anchorB: { x: 0, y: 0 },
});

Here the brick’s origin attaches 20 pixels below the wall body’s origin.

A revolute joint holds the anchors together but lets one body rotate around the other. The rotating body needs fixedRotation: false (the default). A motor can drive a windmill sail around a static tower:

declare const towerBody: RigidBodyComponent; // static
declare const sailBody: RigidBodyComponent; // dynamic, fixedRotation: false
const hub = world.addJoint(towerBody, sailBody, {
type: "revolute",
motor: { velocity: 2, damping: 10 }, // radians per second
});
hub.setMotor({ velocity: -2, damping: 10 });

Use limits for a drawbridge. The limits measure body B’s angle relative to body A, in radians:

declare const bankBody: RigidBodyComponent;
declare const bridgeBody: RigidBodyComponent;
const bridge = world.addJoint(bankBody, bridgeBody, {
type: "revolute",
anchorB: { x: -60, y: 0 },
limits: { min: -Math.PI / 2, max: 0 },
});

A prismatic joint allows movement along a rail and holds the bodies at the same angle. Its axis is local to body A and is normalized automatically. Limits and motor position use pixels of anchor separation along that axis:

declare const railBody: RigidBodyComponent;
declare const platformBody: RigidBodyComponent;
const elevator = world.addJoint(railBody, platformBody, {
type: "prismatic",
axis: { x: 0, y: 1 },
limits: { min: 0, max: 240 },
motor: { position: 120, stiffness: 40, damping: 8 },
});
elevator.setMotor({ position: 240, stiffness: 40, damping: 8 });

Revolute and prismatic joints accept motor and support setMotor(). The joint must be attached when calling setMotor(). Each call replaces the motor settings. Specify position, velocity, or both; omitted targets, stiffness, and damping default to 0. Revolute velocity uses radians/s; prismatic velocity uses pixels/s. A velocity-only motor needs positive damping to move.

Every numeric setting must be finite. Lengths, spring stiffness and damping, and motor stiffness and damping must be at least 0. Limits require min <= max, and the prismatic axis must be non-zero. Spring stiffness uses mass/s² and spring damping uses mass/s. Motor stiffness and damping are acceleration-based. Both are passed to the solver without conversion. Retune them after changing pixelsPerMeter.

Remove a joint from a collision handler when an impact exceeds the game’s threshold:

import { ColliderComponent } from "@yagejs/physics";
const brickCollider = brickBody.entity.get(ColliderComponent);
brickCollider.onCollision((event) => {
if (event.started && (event.contactImpulse ?? 0) > 100) {
weld.remove();
}
});

For an authored collapse, create pre-cut pieces with static bodies and call pieceBody.setType("dynamic") on the pieces the hit releases. That approach lets game rules choose which pieces fall without measuring joint forces.

attached is true while a joint is live. remove() is safe to call more than once. Destroying or disabling either entity detaches its joints; re-enabling it does not restore them. For a pooled entity, create joints in onAcquire, when the entity is active. addJoint throws for inactive entities.

Joints are runtime objects. Save stable body ids, joint configurations, and broken state in the game’s state root. Recreate the connections while constructing the scene after load.

Coding agents: fetch https://yage.dev/llms.txt first and prefer the Markdown references it links over these HTML pages. This page's Markdown counterpart is /llms/packages/physics.md.