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) pixelsPerMeter: 50, // conversion factor, default: 50}));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) linearDamping: 5, // velocity drag angularDamping: 1, // rotation drag ccd: true, // continuous collision detection for fast objects lockTranslationX: false, // lock horizontal movement syncRotation: true, // sync physics rotation back to Transform}));Body types:
dynamic— affected by forces, gravity, and collisions. Use for players, projectiles, and physics objects.static— never moves. Use for walls, floors, and platforms.kinematic— moved programmatically, not by forces. Use for moving platforms, elevators, and doors. Move kinematic bodies by setting 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.
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 } from "@yagejs/physics";
// Boxentity.add(new ColliderComponent({ shape: { type: "box", width: 64, height: 32 }, restitution: 0.5, // bounciness (0–1) friction: 0.3, density: 1, // affects mass for dynamic bodies // contactSkin: 1, // rest about 1px off contact surfaces}));
// Circleentity.add(new ColliderComponent({ shape: { type: "circle", radius: 16 },}));
// Capsule (vertical by default — pass axis: "x" for horizontal)entity.add(new ColliderComponent({ shape: { type: "capsule", halfHeight: 20, radius: 10, axis: "y" },}));
// Convex polygon. Rapier silently widens concave input to its convex// hull; a dev warning is logged. Use polyline for non-convex outlines.entity.add(new ColliderComponent({ shape: { type: "polygon", vertices: [{ x: 0, y: -20 }, { x: 20, y: 20 }, { x: -20, y: 20 }], },}));
// Polyline — chain of line segments. Supports non-convex shapes but is// static-only (no mass/inertia). Best for tilemap-driven world boundaries// such as winding water edges or U-shaped walls.entity.add(new RigidBodyComponent({ type: "static" }));entity.add(new ColliderComponent({ shape: { type: "polyline", vertices: [ { x: 0, y: 0 }, { x: 64, y: 0 }, { x: 64, y: 16 }, { x: 16, y: 16 }, { x: 16, y: 48 }, ], },}));
// Offset and rotation position the collider relative to its body — here a// tilted attack hitbox in front of the entity. Rotation is in radians,// about the offset point.entity.add(new ColliderComponent({ shape: { type: "box", width: 60, height: 10 }, offset: { x: 20, y: 0 }, rotation: Math.PI / 4, sensor: true,}));All dimensions are in pixels; rotation is in radians.
Box corners can be rounded while keeping the same outer width and height:
entity.add(new ColliderComponent({ shape: { type: "box", width: 64, height: 32, borderRadius: 4 },}));The outer footprint stays 64×32 and a resting body keeps its height, because the corners are rounded inward. The radius must be smaller than half the shorter side; anything else throws when the collider is built. Shape casts and overlap queries use the rounded geometry too.
Rounding does shrink the flat part of each face, to width - 2 * borderRadius.
A body held up only by the last few pixels of a ledge slides off instead of
standing on it.
contactSkin is the other way to round off a contact. It holds the collider a
set number of pixels away from whatever it touches:
entity.add(new ColliderComponent({ shape: { type: "box", width: 12, height: 44 }, contactSkin: 1,}));A resting body then sits 1px above the ground. When both colliders in a pair set a skin, the gap is the sum of the two. Skins apply to contacts only, not to shape casts or overlap queries.
Both options fix the same problem: a box walking across a polyline terrain
chain can catch on the junction between two segments and stop moving, because
Rapier picks a contact normal that opposes the walk direction. Prefer
borderRadius — it leaves the resting height alone.
Removing a ColliderComponent on its own (entity.remove(ColliderComponent))
frees its Rapier collider while the sibling RigidBodyComponent and body stay
alive. Destroying the whole entity, or removing just the RigidBodyComponent,
also removes every collider attached to that body.
Collision Layers
Section titled “Collision Layers”CollisionLayers lets you control which objects can collide using named layer
bitmasks.
import { CollisionLayers } from "@yagejs/physics";
const layers = new CollisionLayers();const LAYER_PLAYER = layers.define("player");const LAYER_WALL = layers.define("wall");const LAYER_COIN = layers.define("coin");
// Player collides with walls and coinsentity.add(new ColliderComponent({ shape: { type: "circle", radius: 16 }, layers: LAYER_PLAYER, mask: LAYER_WALL | LAYER_COIN,}));
// Coin only collides with playercoinEntity.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.
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.
const collider = new ColliderComponent({ shape: { type: "circle", radius: 10 }, sensor: true,});// A collider needs a sibling RigidBodyComponent, even a sensor — use a// kinematic body for a trigger you position through its Transform.entity.add(new RigidBodyComponent({ type: "kinematic" }));entity.add(collider);
collider.onTrigger((event) => { if (event.entered) { console.log("entered by", event.other.name); } else { console.log("exited by", event.other.name); } // event.otherCollider — the other entity's ColliderComponent});For non-sensor colliders, use onCollision instead:
collider.onCollision((event) => { if (event.started) { console.log("hit", event.other.name); console.log("contact normal:", event.contactNormal); console.log("contact point:", event.contactPoint); console.log("penetration depth:", event.penetrationDepth); console.log("contact impulse:", event.contactImpulse); }});contactNormal, contactPoint, penetrationDepth, contactImpulse, and
contactImpulseVector are set on started, non-sensor collisions:
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:
collider.onCollision((event) => { if (!event.started || !event.contactNormal) return; const knockback = event.contactNormal.scale(-300); entity.get(RigidBodyComponent).setVelocity(knockback);});Use contactImpulse to score impacts — for example, deal damage when the
impulse passes a threshold:
collider.onCollision((event) => { if ( event.started && event.contactImpulse !== undefined && event.contactImpulse > 100 ) { dealDamage(10); }});Reading the body’s velocity inside the handler does not work for this: it measures the velocity after the solver has resolved the contact, so the hardest hits read as the smallest numbers.
Both handlers return an unsubscribe function.
Querying Overlapping Entities
Section titled “Querying Overlapping Entities”You can query which entities currently overlap a sensor collider:
const overlapping = collider.getOverlapping();const enemies = collider.getOverlapping({ tags: ["enemy"] });const components = collider.getOverlappingComponents(HealthComponent);This reports an overlap only when this collider or the overlapping one is a
sensor: true. Two solid colliders never report each other here, however
deeply they penetrate — for solid-vs-solid contact such as contact damage, use
onCollision.
Velocity and Forces
Section titled “Velocity and Forces”Prefer setVelocity for direct control. Use applyImpulse or applyForce
for physics-driven movement.
const rb = entity.get(RigidBodyComponent);
// Direct velocity control (px/s) — best for player 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);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.
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:
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, radiansThe 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.
Raycasting
Section titled “Raycasting”Cast a ray to find the first entity hit:
const world = this.use(PhysicsWorldKey);
const hit = world.raycast( origin, // Vec2Like — start position in pixels { x: 0, y: 1 }, // direction — any non-zero vector (normalized internally) 100, // max distance in pixels { filterGroups: CollisionLayers.interactionGroups(LAYER_PLAYER, LAYER_WALL) },);
if (hit) { console.log(hit.entity); // Entity that was hit console.log(hit.point); // Vec2 — hit position in pixels console.log(hit.normal); // Vec2 — surface normal console.log(hit.distance); // number — distance in pixels}A common use case is ground detection for platformers:
const grounded = world.raycast( this.transform.worldPosition, { x: 0, y: 1 }, playerHeight / 2 + 2,) !== null;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:
const hit = world.castShape( { type: "box", width: 24, height: 40 }, // the shape to sweep this.transform.worldPosition, // where it starts, in pixels { x: 0, y: 1 }, // direction — any non-zero vector 120, // how far to sweep, in pixels { excludeEntity: this.entity }, // skip the mover's own colliders);
if (hit) { hit.entity; // Entity that was hit hit.distance; // how far the shape travelled before contact, in pixels hit.point; // Vec2 — world contact point hit.normal; // Vec2 — surface normal on the entity that was hit}The result has the same shape as a raycast hit. A shape that already overlaps
something where it starts reports that hit at distance: 0.
Use this to test a move before committing to it. A fast-falling player can land on the floor it would otherwise pass through in one frame:
const travel = this.rb.velocityY * dt;if (travel > 0) { // only while falling const pos = this.transform.worldPosition; const ground = world.castShape( this.footShape, pos, { x: 0, y: 1 }, travel, { excludeEntity: this.entity }, );
if (ground) { // setPosition on the body, not the Transform: physics writes the // Transform back every step for dynamic bodies. this.rb.setPosition(pos.x, pos.y + ground.distance); this.rb.setVelocityY(0); }}queryShape answers a different question: what a shape touches where it already
stands. It misses anything the shape would pass through on the way.
Gravity Control
Section titled “Gravity Control”Change gravity at runtime via the PhysicsWorld:
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:
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”Lock or unlock axes at runtime:
rb.setEnabledTranslations(true, false); // lock Y axisrb.lockRotations(true); // lock rotationResizing 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:
collider.setShape({ type: "box", width: 24, height: 24 }); // crouchcollider.setShape({ type: "box", width: 24, height: 48 }); // standThe body keeps the mass it already had. A collider describes where a body
collides, not how much matter is in it, so a character who crouches takes the
same knockback from applyImpulse as one standing upright. When the shape
change does mean more or less matter — an object that grows, or one that loses
a chunk — ask for the mass back from density and the new shape:
collider.setShape({ type: "circle", radius: 48 }, { recomputeMass: true });Shrinking is always safe. Growing is not: nothing is pushed out of the way, so a collider can end up overlapping a ceiling it did not touch while crouched. Check for clearance first.
A collider is centred on its body’s origin unless you give it an offset, so a
character who grows upward while keeping their feet planted also has to move up
by half the height they gained. Test the standing box where it will actually
sit, not where the crouched one sits:
const CROUCH_HEIGHT = 24;const STAND_HEIGHT = 48;const rise = (STAND_HEIGHT - CROUCH_HEIGHT) / 2;
const standing = { type: "box", width: 24, height: STAND_HEIGHT } as const;const pos = this.transform.worldPosition;
const blocked = world.queryShape( standing, { x: pos.x, y: pos.y - rise }, { excludeEntity: this.entity },).length > 0;
if (!blocked) { collider.setShape(standing); this.rb.setPosition(pos.x, pos.y - rise);}Testing the standing box at the crouched position instead would report the floor as a blocker every time the character is grounded, and they could never stand up.
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.
platform.add(new ColliderComponent({ shape: { type: "box", width: 96, height: 8 }, oneWay: {},}));With no options, the solid face points up ({ x: 0, y: -1 }). Bodies falling
onto the platform land; bodies coming from below, or from the side, pass
through. A body that is already inside the platform is let out rather than
snapped to the surface, so turning on the behavior mid-overlap never launches
anything.
The direction is in the platform body’s local frame, so a rotated platform
carries its solid face with it. margin is how deep (in pixels) a body may
already overlap the face and still count as landing:
oneWay: { direction: { x: 0, y: -1 }, // default: solid face up margin: 4, // default}For the down-jump — the player presses down and falls through the platform they are standing on — give the rider’s collider a drop-through window:
player.get(ColliderComponent).dropThrough(0.2); // seconds of simulated 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.
Two practical notes:
- A very fast body can cross a thin platform entirely between two physics
steps and is never detected. That is ordinary tunneling — give the body
ccd: true(see Continuous Collision Detection below). The CCD sweep respects one-way behavior, including drop-through. oneWayis part of the collider config, so it survives save/load. It has no effect onsensor: truecolliders, since sensors never produce solid contacts.
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:
// A wall that only blocks entities tagged "enemy".wall.get(ColliderComponent).setContactFilter((contact) => contact.other.tags.has("enemy"),);The filter runs inside the physics step, before any contact exists, so there
is no contact normal or contact point to read. Instead the contact object
carries both sides’ positions, rotations, and velocities as plain numbers,
plus the other side’s Entity and ColliderComponent. All values are from
the start of the step — for a body that crossed a surface mid-step, they tell
you which side it came from.
A few rules to keep filters healthy:
- The filter runs for every candidate pair involving the collider, every step. Keep it cheap, and never create or destroy entities, bodies, or colliders from inside it.
- The
contactobject is reused across calls. Read what you need and return; don’t store it. - When both colliders in a pair have filters, the pair is solid only if both
return
true. - A filter that throws is reported through the error boundary
(
Inspector.getErrors().callbackErrors) and the pair stays solid — the conservative default. The report fires once per installed filter, so a persistently throwing filter can’t flood the log; installing a new filter arms it again. - Setting a filter on a collider configured with
oneWayreplaces the built-in one-way filter. Filters are functions, so they are not saved: after a load,oneWaycolliders get their built-in filter back, and custom filters must be registered again. - Filters apply to solid contact pairs only; sensor overlap is unaffected.
Clear a filter with setContactFilter(null).
Continuous Collision Detection
Section titled “Continuous Collision Detection”Enable CCD on fast-moving bodies to prevent tunneling through thin colliders:
entity.add(new RigidBodyComponent({ type: "dynamic", ccd: true,}));CCD adds a small performance cost — only enable it for bullets, fast projectiles, or small objects that move at high speed.
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
enumeration (save system, debug inspector) use the engine-scope
PhysicsWorldManagerKey instead.
Joints
Section titled “Joints”Joints connect two rigid bodies in the same scene. Lengths and anchor points
use pixels. A rope limits the distance between two bodies; a spring pulls them
toward its restLength and also resists relative motion.
const world = this.use(PhysicsWorldKey);
const tether = world.addJoint(playerBody, anchorBody, { type: "rope", length: 140,});
// A static anchor makes a simple swing. Release the rope when the player lets go.if (input.isJustPressed("release")) tether.remove();
world.addJoint(playerBody, companionBody, { type: "spring", restLength: 80, stiffness: 40, damping: 4,});addJoint returns a handle with an attached flag and a remove() method
that is safe to call more than once. The handle detaches when either jointed
entity is destroyed or disabled (for example, released to a pool); re-enabling
the entity does not restore the joint. Lengths and anchors are always in
pixels. stiffness and damping are mass-relative and passed to the solver
unconverted — collider mass depends on pixelsPerMeter, so retune them after
changing the scale.
Joints are not saved: after a snapshot restore, recreate them (for example in
afterRestore).