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.
PhysicsPlugin Setup
Section titled “PhysicsPlugin Setup”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}));Rigid Bodies
Section titled “Rigid Bodies”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 theTransformposition (e.g.transform.setPosition(x, y)ortransform.translate(dx, dy)), preferably infixedUpdate— 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.setPositionis 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 }}Colliders
Section titled “Colliders”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";
// Boxentity.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}));
// Circleentity.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.
Collision Layers
Section titled “Collision Layers”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 coinsentity.add(new ColliderComponent({ shape: { type: "circle", radius: 16 }, layers: LAYER_PLAYER, mask: LAYER_WALL | LAYER_COIN,}));
// Coin only collides with playerdeclare const coinEntity: Entity; // has a Transform and a RigidBodyComponentcoinEntity.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.
Filtering a world query
Section titled “Filtering a world query”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.
Sensors and Trigger Events
Section titled “Sensors and Trigger Events”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 bulletsThe 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:
contactNormalis a unitVec2pointing 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.contactPointis 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.penetrationDepthis the overlap depth of that deepest contact in world pixels, always>= 0.contactImpulseis the magnitude of the total impulse the solver applied to resolve the contact, in the same units asapplyImpulse; friction is not included. Divide it by a dynamic body’sgetMass()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.contactImpulseVectoris the same impulse as aVec2, pointing from this entity toward the other likecontactNormal; its length equalscontactImpulse. 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.
Querying Overlapping Entities
Section titled “Querying Overlapping Entities”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.
Measuring a Contact
Section titled “Measuring a Contact”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.
Velocity and Forces
Section titled “Velocity and Forces”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 movementrb.setVelocity(new Vec2(200, 0));rb.setVelocityX(200); // set X, keep Yrb.setVelocityY(-300); // set Y, keep X
// Read velocityconst 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/srb.velocityY; // number, px/srb.speed; // number, px/s — velocity magnituderb.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));
// Rotationrb.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 updatesrb.getVelocityInto(velocity); // writes x and y in pixels per secondgetVelocityInto 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.
Velocity while the scene is slowed
Section titled “Velocity while the scene is slowed”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/sconst 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.
Teleporting
Section titled “Teleporting”Use setPosition to teleport a body of any type without interpolation
artifacts — no in-between poses are ever drawn:
rb.setPosition(400, 300); // pixelsrb.setRotation(Math.PI / 2); // radiansOn 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.
Reading Positions
Section titled “Reading Positions”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 seesThe 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, pixelsrb.positionX; // number, pixels — skips the Vec2 allocationrb.positionY; // number, pixelsrb.rotation; // number, radiansBoth 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.
Which channel moves which body
Section titled “Which channel moves which body”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 RigidBodyComponentconst x = 400;const y = 300;
bullet.setActive(false);// ...later, before putting it back in playbullet.get(Transform).setPosition(x, y);bullet.setActive(true); // the body is at (x, y), not where it sleptPosition 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); }}Raycasting
Section titled “Raycasting”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.
Sweeping a Shape
Section titled “Sweeping a Shape”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 floorconst 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.
Gravity Control
Section titled “Gravity Control”Change gravity at runtime via the PhysicsWorld:
import { PhysicsWorldKey } from "@yagejs/physics";
const world = this.use(PhysicsWorldKey);
world.setGravity(0, -980); // flip gravity upwardworld.setGravity(0, 0); // zero gravityA 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 bodyrb.setGravityScale(2.5); // falls two and a half times as fastrb.setGravityScale(1); // back to scene gravityrb.gravityScale; // read the current multiplierThis 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.
Runtime Body Configuration
Section titled “Runtime Body Configuration”Change damping or lock axes at runtime:
rb.setLinearDamping(6); // slow linear velocityrb.setAngularDamping(2); // slow rotationrb.setEnabledTranslations(true, false); // lock Y axisrb.lockRotations(true); // lock rotationDamping 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 lockedThe damping setters also work while the entity is inactive. They update the dormant body without enabling or waking it.
Runtime Collider Materials
Section titled “Runtime Collider Materials”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.
Resizing a Collider
Section titled “Resizing a 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 feetcollider.setShape(newHeadShape, { index: 1 }); // compound part 1A 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.
One-Way Platforms
Section titled “One-Way Platforms”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 RigidBodyComponentplatform.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 platformplayer.get(ColliderComponent).dropThrough(0.2); // seconds of simulated timeThe 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. oneWayis part of the collider config. It has no effect onsensor: truecolliders, since sensors never produce solid contacts.- World queries (
raycast,castShape,queryShape, andqueryRadius) test geometry. They do not applyoneWay,dropThrough, or contact filters, so a query hit alone does not mean a platform supports the rider.
Contact Filters
Section titled “Contact Filters”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
oneWayplatform counts), every step reads every collider’s pose and velocity before stepping. That cost grows with the number of colliders in the world. - The
contactobject 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
oneWayreplaces 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).
Continuous Collision Detection
Section titled “Continuous Collision Detection”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.
PhysicsWorld Access
Section titled “PhysicsWorld Access”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
Section titled “Joints”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.
Weld bodies together
Section titled “Weld bodies together”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.
Hinges and rotating motors
Section titled “Hinges and rotating motors”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; // staticdeclare 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 },});Sliding platforms
Section titled “Sliding platforms”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.
Destruction on impact
Section titled “Destruction on impact”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.
Joint lifetime and save state
Section titled “Joint lifetime and save state”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.