# @yagejs/physics

Depends on `@yagejs/core`. Rapier2D physics with pixel-based API. All values in pixels.

## Setup

```ts yage-context="engine"
import { PhysicsPlugin } from "@yagejs/physics";

engine.use(
  new PhysicsPlugin({
    gravity: { x: 0, y: 980 }, // px/s², default (0, 980); both finite
    pixelsPerMeter: 50, // default 50; finite and > 0
  }),
);
```

## Bundler Setup

`@yagejs/physics` depends on `@dimforge/rapier2d`, which ships a `.wasm` file. With Vite, add `vite-plugin-wasm` to load it:

```ts
// vite.config.ts
import { defineConfig } from "vite";
import wasm from "vite-plugin-wasm";

export default defineConfig({
  plugins: [wasm()],
});
```

That's all that's required for `@yagejs/physics`. See `examples/vite.config.ts` for the canonical reference config.

## Component Ordering

`Transform` → `RigidBodyComponent` → `ColliderComponent` (required order).

Every `ColliderComponent` needs a sibling `RigidBodyComponent`, including a `sensor: true` one. For a trigger you move through its `Transform` (a bobbing pickup), use a `kinematic` body. Omitting the body throws when the collider is added, not at construction.

## RigidBodyComponent

```ts yage-context="entity"
import { RigidBodyComponent } from "@yagejs/physics";

entity.add(
  new RigidBodyComponent({
    type: "dynamic", // "dynamic" | "static" | "kinematic"
    fixedRotation: true,
    gravityScale: 0, // 0 = no gravity; finite (negative floats the body up)
    linearDamping: 5, // finite and >= 0
    angularDamping: 1, // finite and >= 0
    ccd: true, // also sweep against kinematic and dynamic bodies
    lockTranslationX: false,
    syncRotation: true, // sync rotation to Transform (default true)
  }),
);
```

A fast dynamic body is always swept against static colliders, so it stops at a thin wall or floor instead of passing through it between steps. `ccd: true` extends the sweep to kinematic and dynamic colliders: use it for bullets and fast projectiles that must hit moving platforms or other bodies. It costs a little per body.

Rapier caps linear speed at 400 m/s (`400 × pixelsPerMeter` px/s, 20,000 at the default 50) and rotation at 45° per physics step (about 47 rad/s at 60 steps/s). A faster velocity is clamped at the next step.

Methods:

- `setVelocity(v: Vec2Like)` — set linear velocity (px/s). Preferred over impulse.
- `setVelocityX(vx)` / `setVelocityY(vy)` — set single axis
- `getVelocity(): Vec2` — read velocity (px/s), allocates a `Vec2`
- `getVelocityInto(out: Vec2Buffer): Vec2Buffer` — read both velocity coordinates (px/s) into the caller's buffer with one Rapier read and no `Vec2` construction
- `velocityX` / `velocityY` — scalar reads (px/s) that skip the `Vec2` allocation; reading both calls into Rapier twice
- `speed` / `speedSquared` — velocity magnitude (px/s) / squared magnitude, no `Vec2` allocation
- `applyImpulse(v: Vec2Like)` — instant momentum change
- `applyForce(v: Vec2Like)` — continuous force
- `position: Vec2` — read the simulated position (px), allocates a `Vec2`
- `getPositionInto(out: Vec2Buffer): Vec2Buffer` — read both simulated position coordinates (px) into the caller's buffer with one Rapier read and no `Vec2` construction
- `positionX` / `positionY` — scalar reads (px) that skip the `Vec2` allocation; reading both calls into Rapier twice
- `rotation: number` — read the simulated rotation (radians)
- `setPosition(x, y)` — teleport any body type: no interpolation, the drawn pose jumps. A static body's `Transform` moves with it. Writing the `Transform` of a kinematic body instead moves it there smoothly over one step.
- `setRotation(radians)` — teleport rotation; the rotation counterpart of `setPosition`, and it rotates a static body's `Transform` too when `syncRotation` is on
- `setAngularVelocity(v)` / `getAngularVelocity()` — radians/s
- `applyTorque(t)` — rotational force, in Rapier's native units: the value is not converted from pixels, and angular inertia scales with `pixelsPerMeter`⁻⁴, so the same torque spins a body 16× faster at 100 px/m than at 50. Retune after changing the scale, as with spring stiffness.
- `setEnabledTranslations(enableX, enableY)` — lock or unlock translation axes. A locked axis ignores forces, impulses and contacts; `setVelocity` still moves the body along it. Callable before `entity.add()`; the locks apply at body creation.
- `lockRotations(locked)` — lock or unlock rotation. A locked body ignores torques and contact spin; `setAngularVelocity` still turns it. Callable before `entity.add()`.
- `setGravityScale(scale)` / `gravityScale` — per-body gravity multiplier at runtime. `1` is scene gravity, `0` removes it, higher falls faster. Use it for variable jump height and fast-fall, where one body must fall differently from the rest. `scale` must be finite. Callable before `entity.add()`; the value applies at body creation.
- `setLinearDamping(damping)` / `setAngularDamping(damping)` — change velocity drag at runtime. Each value must be finite and >= 0. Callable before `entity.add()` and while the entity is inactive; an inactive body stays disabled.
- `type: BodyType` — read the current body type
- `setType(type)` — switch the body type at runtime: a dead enemy becomes `"static"` so nothing pushes it and it pushes nothing, a carried crate becomes `"kinematic"` while held. 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. Callable before `entity.add()`; the body is created as the new type.

### Reading positions

A dynamic or kinematic body has two positions, and they differ within a frame:

- `entity.get(Transform).worldPosition` — the drawn pose. Blended between the last two fixed steps, so it moves smoothly at the display frame rate and is at most one fixed step behind the simulation. Use it for anything visual: camera follow, HUD markers, spawning effects at a body.
- `rb.position` / `rb.positionX` / `rb.positionY` / `rb.rotation` — the exact simulated pose, as of the last completed fixed step. Use it when a number must match the simulation: distance thresholds, snapping a body to a grid, saving a checkpoint.

Interpolation runs at the start of `Update`, so the `Transform` a component's `update(dt)` 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, not against the `Transform`.

For repeated reads, create `Vec2Buffer` instances from `@yagejs/core` once:

```ts yage-context="entity"
import { Transform, Vec2Buffer } from "@yagejs/core";
import { RigidBodyComponent } from "@yagejs/physics";

const rb = entity.get(RigidBodyComponent);
const velocity = new Vec2Buffer();
const simulated = new Vec2Buffer();
const drawn = new Vec2Buffer();
rb.getVelocityInto(velocity); // px/s
rb.getPositionInto(simulated); // exact simulated world pixels
entity.get(Transform).getWorldPositionInto(drawn); // interpolated world pixels
```

Each `Into` method overwrites and returns the supplied buffer. A later body or
transform change does not update the buffer; call the getter again to refresh
it. Keep `getVelocity()` and `position` for immutable values you retain or
share. Without a live body, `getVelocityInto` writes `(0, 0)` and
`getPositionInto` reads the entity's `Transform` world position, matching the
immutable getters.

### Writing positions

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`.

For every body type, a position or rotation written to the `Transform` while the entity is inactive teleports the body to that world pose on activation. Position and rotation are checked independently; an unchanged value keeps the body's current value. This also applies during dormant setup of a prewarmed pool member.

An active static body does not follow `Transform` writes; `rb.setPosition` / `rb.setRotation` move body and `Transform` together (`setRotation` updates the `Transform` only with `syncRotation: true`). A pool member's `onAcquire` runs after activation: use these body methods there to place static or dynamic members immediately.

### Velocity while the scene is slowed

Physics runs at the scene's effective time scale, and an entity excluded from a slow-motion effect still has its velocity integrated at the slowed rate. Scale velocity writes by the ratio of the two rates:

```ts yage-context="component"
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, so nothing needs compensating.

### Moving kinematic bodies

Write the `Transform` (`setPosition`, `translate`) in `fixedUpdate`; the body reaches the written pose on the next physics step and is drawn interpolated, so the drawn gap to dynamic bodies riding it stays constant. A write from `update()` lands after that frame's interpolation pass: the frame shows the raw pose, then drawing re-blends from the last two steps, so a one-shot write visibly hops — prefer `fixedUpdate`. `rb.setPosition()` / `rb.setRotation()` teleport instead — no smoothing.

## ColliderComponent

```ts yage-context="entity,scene" yage-group="collider"
import { ColliderComponent, CollisionLayers } from "@yagejs/physics";

const layers = new CollisionLayers();
const LAYER_PLAYER = layers.define("player");
const LAYER_WALL = layers.define("wall");

entity.add(
  new ColliderComponent({
    shape: { type: "box", width: 64, height: 32 },
    // shape: { type: "box", width: 64, height: 32, borderRadius: 4 },  // rounded corners, same outer footprint
    // shape: { type: "circle", radius: 16 },
    // shape: { type: "capsule", halfHeight: 20, radius: 10, axis: "y" },   // axis defaults to "y" (vertical); "x" rotates 90°
    // shape: { type: "polygon", vertices: [{x,y}, ...] },                  // closed convex, >= 3 vertices not all on one line; concave input is silently widened by Rapier (dev warning logged)
    // shape: { type: "polyline", vertices: [{x,y}, ...] },                 // chain of segments, >= 2 vertices; supports non-convex; static-only (no inertia)
    restitution: 0.5, // finite and >= 0
    friction: 0.3, // finite and >= 0
    density: 1, // finite and >= 0; default 1
    // contactSkin: 1,    // holds the collider 1px off whatever it touches; finite and >= 0
    sensor: false, // true = trigger (no physical response)
    offset: { x: 0, y: 0 },
    rotation: 0, // radians, relative to the body, about the offset point (axis:"x" capsules: adds to the 90° axis rotation)
    layers: LAYER_PLAYER, // bitmask
    mask: LAYER_WALL, // which layers to interact with
  }),
);
```

One component can attach several ordered shapes to the same body. Each part
has its own shape, offset, and rotation. The other settings apply to every
part, and Rapier sums their mass:

```ts yage-context="entity,scene" yage-group="collider"
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,
    layers: LAYER_PLAYER,
    mask: LAYER_WALL,
  }),
);
```

`collider.colliderCount` is the number of parts. `getOverlapping()` checks all
parts and returns each overlapping entity once.

Collider geometry follows `Transform.worldScale` automatically. The scale in
place when the component is added applies immediately; later local or ancestor
scale writes apply at the next physics step. Scale changes update part offsets
and recompute mass from `density`. A query before the next step sees the last
simulated scale, like position queries.

Positive uniform scale keeps Rapier's native primitives. For non-uniform or
mirrored scale, polygons and polylines transform exactly. Boxes become their
four transformed corners. Circles, capsules, and rounded boxes use a 32-point
convex outline because Rapier has no scaled equivalent for those shapes. A
zero scale axis disables the colliders and removes their mass until both axes
are non-zero again.

A capsule's `halfHeight` is half the straight section; each cap adds `radius`,
so the collider is `2 * (halfHeight + radius)` tall — `{ halfHeight: 20, radius: 10 }`
stands 60 px. `halfHeight: 0` is a circle. Boxes and circles take outer
dimensions.

Every entry that takes a shape (`ColliderComponent`, `setShape`, `castShape`,
`queryShape`, `queryRadius`) throws on a dimension that is not finite and above
0, naming the field: `width`, `height`, `radius` above 0, `halfHeight` at least
0, `borderRadius` at least 0 and smaller than half the shorter side, polygon and
polyline vertex counts and coordinates as above.

`box.borderRadius` rounds the corners. The inner half-extents shrink by the
radius, so the outer footprint stays the configured width and height and a
resting body keeps its height. `0` and `undefined` create a plain box; a radius
that is not smaller than half the shorter side throws. Rounded geometry is used
by collision, `castShape`, and `queryShape`. Mass is the rounded footprint's
area at the configured `density`, so rounding changes it only by the four
corner pieces; angular inertia is the inner rectangle's, scaled by the same
area ratio (an approximation).

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

`contactSkin` holds the collider that many pixels off whatever it touches, so a
resting body sits that far above the surface. When both colliders in a pair set
a skin, the gap is the sum of the two. It affects contacts only, not shape
queries.

A driven box can catch on the junction between two segments of a `polyline` and
stop moving, because Rapier picks a contact normal that opposes the walk
direction. Either option prevents that. Prefer `borderRadius`, since
`contactSkin` also raises the body off the ground.

Events:

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

Events are collected after every physics step and delivered after that step, so a scene running above `timeScale` 1 receives every transition, in order, each with its own step's contact data. Handlers run with 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.

```ts yage-context="entity,scene" yage-group="collider"
const collider = entity.get(ColliderComponent);

collider.onTrigger((ev) => {
  ev.other;
  ev.selfShapeIndex;
  ev.otherShapeIndex;
  ev.entered;
}); // sensor events
collider.onCollision((ev) => {
  ev.other;
  ev.selfShapeIndex;
  ev.otherShapeIndex;
  ev.started;
  // contactNormal/contactPoint/penetrationDepth/contactImpulse(Vector): only
  // on started, non-sensor collisions, and may be absent if no contact
  // manifold is available. A pair can touch along several surfaces at once
  // (a box crossing a polyline corner); the geometry below describes the
  // deepest of those contacts.
  ev.contactNormal; // Vec2, unit, points from this entity toward `other`
  ev.contactPoint; // Vec2, world pixels, the deepest contact's point (not an
  // average); equally deep points are interchangeable
  ev.penetrationDepth; // number, world pixels, that contact's overlap, >= 0
  ev.contactImpulse; // number, magnitude of the solver's contact impulse (no friction),
  // `applyImpulse` units; divide by a dynamic body's getMass() for
  // the speed change that body received, px/s
  ev.contactImpulseVector; // Vec2, the same impulse as a vector, oriented from this entity
  // toward `other` like contactNormal; the push on this body is
  // its negation (scale(-1/getMass()) = a dynamic body's velocity
  // change, px/s)
});
// Both return unsubscribe function
```

Score impacts with `contactImpulse` — velocity read inside the handler is measured after the solver resolved the contact, so hard hits read near zero.

Knockback example:

```ts yage-context="entity,scene" yage-group="collider"
import { RigidBodyComponent } from "@yagejs/physics";

collider.onCollision((ev) => {
  if (!ev.started || !ev.contactNormal) return;
  const knockback = ev.contactNormal.scale(-300); // push this entity away from `other`
  entity.get(RigidBodyComponent).setVelocity(knockback);
});
```

Overlap queries report a pair when at least one of the two colliders is `sensor: true`, two sensors included; two solid colliders never report, however deeply they penetrate. For solid-vs-solid contact (contact damage, say) use `onCollision`. This is the one query that is about sensors: `PhysicsWorld`'s `raycast`, `castShape`, `queryShape` and `queryRadius` skip sensor colliders unless asked for them.

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 kinematic against kinematic, on static against kinematic, and on dynamic against dynamic. Give a trigger zone a kinematic body rather than a static one when the other side is static too.

```ts yage-context="entity,scene" yage-group="collider"
import { Component } from "@yagejs/core";

class Health extends Component {
  hp = 100;
}

collider.getOverlapping(); // Entity[]
collider.getOverlapping({ tags: ["enemy"] }); // filtered
collider.getOverlappingComponents(Health); // Component[]
```

Contact geometry for any pair, sensors included (trigger events carry none):

```ts yage-context="entity,scene" yage-group="collider"
import type { Vec2 } from "@yagejs/core";

declare const other: ColliderComponent; // another entity's collider
declare const selfShapeIndex: number, otherShapeIndex: number;
declare const prediction: number;
declare function spawnSparks(at: Vec2, facing: Vec2): void;

collider.contactWith(other, {
  selfShapeIndex, // measure one shape pair; pass the indices from the event
  otherShapeIndex, // that fired. Omitted: the closest pair among all parts
  prediction, // px, default 0: touching or overlapping only
}); // ColliderContact | undefined (further apart than prediction, or no live collider)
// { point, otherPoint, normal, distance }: world px; point on this collider's
// surface, otherPoint on the other's; normal unit, from this collider toward
// the other (the shortest way out when overlapping); distance negative by the
// penetration depth when overlapping. A geometric query on current poses, no step needed.
collider.onTrigger((ev) => {
  const c = collider.contactWith(ev.otherCollider, ev);
  if (c) spawnSparks(c.otherPoint, c.normal.scale(-1)); // on the other's surface, facing out
});
```

Resizing:

```ts yage-context="entity,scene" yage-group="collider"
const newHeadShape = { type: "circle", radius: 8 } as const;

collider.setShape(
  { type: "box", width: 20, height: 20 },
  { offset: { x: 0, y: -10 } },
); // crouch while the body origin stays at the feet
collider.setShape(newHeadShape, { index: 1 }); // replace compound part 1
```

`setShape(shape, options?)` replaces one shape on the live collider component.
`options.index` defaults to `0`. It must name an existing part.
`options.offset` changes that part's body-local offset in the same operation;
its coordinates use authored pixels before `Transform` scale. Omitting it
keeps the current offset, and `{ x: 0, y: 0 }` resets the part to the body
origin. The body attachment and every `onCollision`/`onTrigger` subscription
survive. Callable before `entity.add()`; the shape and offset apply at collider
creation. A bad shape, index, offset coordinate, or scaled result throws before
anything is stored. The component copies a supplied offset object.

The body keeps its mass. A collider is a collision proxy, not a measure of matter, so a crouching character takes the same `applyImpulse` knockback as a standing one. Pass `{ recomputeMass: true }` when the shape change means genuinely more or less matter and mass should come back from density × the new shape.

```ts yage-context="entity,scene" yage-group="collider"
const small = { type: "box", width: 16, height: 16 } as const;
const big = { type: "box", width: 32, height: 32 } as const;

collider.setShape(small); // same mass
collider.setShape(big, { recomputeMass: true }); // heavier
```

Changing a shape or offset can leave the collider overlapping other geometry.
For a feet-origin character, query only the headroom that standing will newly
occupy. Querying the full standing collider also touches the floor and can
report a false blocker.

```ts yage-context="entity,scene" yage-group="collider"
import { PhysicsWorldKey } from "@yagejs/physics";

const world = scene.use(PhysicsWorldKey);
const rb = entity.get(RigidBodyComponent);
const STAND_WIDTH = 20;
const CROUCH_HEIGHT = 20;
const STAND_HEIGHT = 40;
const clearanceHeight = STAND_HEIGHT - CROUCH_HEIGHT;
const clearance = {
  type: "box",
  width: STAND_WIDTH,
  height: clearanceHeight,
} as const;
const standing = {
  type: "box",
  width: STAND_WIDTH,
  height: STAND_HEIGHT,
} as const;
const feet = rb.position;

const blocked =
  world.queryShape(
    clearance,
    {
      x: feet.x,
      y: feet.y - (STAND_HEIGHT + CROUCH_HEIGHT) / 2,
    },
    { excludeEntity: entity },
  ).length > 0;

if (!blocked) {
  collider.setShape(standing, {
    offset: { x: 0, y: -STAND_HEIGHT / 2 },
  });
}
```

If side contacts need a small tolerance, reduce only the clearance query's
width. Keep the target collider at its full width.

Switching kinds:

```ts yage-context="entity,scene" yage-group="collider"
collider.setSensor(true); // solid → sensor: falls through what it rested on
collider.setSensor(false); // sensor → solid: pushed out to rest
```

`setSensor(bool)` recreates the Rapier collider with the new flag. Every pair it is in ends with a `stop`/`exit` at the next step and re-forms as the new kind. `getMass()`, the contact filter, and every subscription are unchanged; the collider handle changes. 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). Callable before `entity.add()`; the flag applies at collider creation.

Material values can change without recreating the collider:

```ts yage-context="entity,scene" yage-group="collider"
collider.setRestitution(0.8);
collider.setFriction(0.1);
```

Both values must be finite and >= 0; restitution above `1` is valid. Each
setter applies to every part in a compound collider. Calls made before
`entity.add()` apply at creation. Calls made while the entity is inactive
update the dormant colliders without enabling them. The values survive a
later `setSensor` recreation.

Removing just the collider (`entity.remove(ColliderComponent)`) frees the Rapier collider and its internal lookup entries while the sibling body stays alive. Removing the whole entity, or the `RigidBodyComponent`, also removes every attached collider; the `ColliderComponent`s left behind no longer hold a collider handle, so their later calls do nothing.

## One-Way Platforms

```ts yage-context="entity,scene" yage-group="collider"
import type { Entity } from "@yagejs/core";

declare const platform: Entity; // has a Transform and a static RigidBodyComponent
declare const riderCollider: ColliderComponent; // the player's collider

platform.add(
  new ColliderComponent({
    shape: { type: "box", width: 96, height: 8 },
    oneWay: {}, // solid from above, passable from below
    // oneWay: {
    //   direction: { x: 0, y: -1 },          // solid-face direction, body-local; default up; non-zero, both finite
    //   margin: 4,                           // px of overlap that still lands; default 4; finite
    // }
  }),
);

riderCollider.dropThrough(0.2); // this body falls through one-way platforms for 0.2s
riderCollider.isDroppingThrough; // boolean, true while the window is open
```

- A body lands on the face `direction` points at, passes through from every other side, and a body already inside the platform keeps passing until clear — it is never snapped to the surface.
- Landing uses the bodies' actual movement, so changing velocity before contact is detected does not lose an arrival from above. Teleporting, resizing, or re-enabling a collider inside the platform does not count as landing; the configured `margin` still applies.
- `dropThrough(seconds)` is per body: other bodies on the same platform stay supported. Seconds of simulated time (respects pause/timeScale). Wakes a sleeping body. Callable before `entity.add()`.
- `direction` is in the platform body's local frame and rotates with the body.
- A fast body is swept against static platforms every step, so it cannot cross one undetected. Against a kinematic platform, a body that travels more than the platform-plus-body thickness in one step crosses it unless it has `ccd: true`. The sweep honors one-way filtering, including drop-through.
- `oneWay` is part of collider construction. It has no effect on `sensor: true` colliders (dev warning).
- `raycast`, `castShape`, `queryShape`, and `queryRadius` test geometry; they do not apply `oneWay`, `dropThrough`, or contact filters. A query hit alone does not mean a platform supports the rider.

## Contact Filters

Decide per pair, per step, whether two colliders collide. `oneWay` is built on this; use it directly for rules `oneWay` can't express:

```ts yage-context="entity,scene" yage-group="collider"
collider.setContactFilter((contact) => {
  contact.other; // Entity on the other side
  contact.otherCollider; // its ColliderComponent
  contact.selfX;
  contact.selfY;
  contact.selfRotation; // own collider, px / radians
  contact.otherX;
  contact.otherY;
  contact.otherRotation;
  contact.selfVelocityX;
  contact.otherVelocityY; // body velocities, px/s
  contact.dt; // current step, seconds
  return true; // true = solid, false = pass through
});
collider.setContactFilter(null); // remove
```

- Runs inside the physics step for every candidate pair involving the collider, every step. Keep it cheap; don't create or destroy entities, bodies, or colliders from inside it. The `contact` object is reused across calls — read, don't store.
- While any collider in the world has a filter (a `oneWay` platform counts), every step reads every collider's pose and velocity before stepping.
- No contact normal or point exists yet. Positions/velocities are from the start of the current step. A pair first detected after an earlier step crossed a surface can already overlap; current velocity cannot reliably reconstruct its arrival if game code changed that velocity.
- When both colliders in a pair have filters, both run for every candidate pair; the pair is solid only if both return `true`.
- A filter that throws is reported (`Inspector.getErrors().callbackErrors`) once per installed filter and the pair stays solid.
- `setContactFilter` replaces the built-in filter a `oneWay` config installed. Register custom filters during normal component setup whenever the scene is constructed.
- Contact pairs only — sensor/trigger pairs are unaffected.

## CollisionLayers

```ts
import { CollisionLayers } from "@yagejs/physics";

const layers = new CollisionLayers();
const PLAYER = layers.define("player"); // bitmask value
const WALL = layers.define("wall");
// On a collider: layers: PLAYER, mask: WALL | COIN
// On a world query: filterGroups: CollisionLayers.interactionGroups(PLAYER, WALL)
```

A collider takes `layers` and `mask` as two numbers. A world query takes the
same pair packed into one number as `filterGroups`, and
`CollisionLayers.interactionGroups(membership, filter)` builds it: membership
in the upper 16 bits, filter in the lower 16.

Passing a `define()` value straight to `filterGroups` matches nothing. A layer
bit sits in the lower 16 bits, so the packed membership is 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.

## PhysicsWorld

```ts yage-context="scene"
import type { Entity, Vec2Like } from "@yagejs/core";
import {
  PhysicsWorldKey,
  type ColliderShape,
  type QuerySensorMode,
} from "@yagejs/physics";

// Arguments of the calls below
declare const origin: Vec2Like, direction: Vec2Like, maxDistance: number;
declare const shape: ColliderShape, position: Vec2Like, rotation: number;
declare const center: Vec2Like, radius: number, excludeEntity: Entity;
declare const filterGroups: number, sensors: QuerySensorMode, dt: number;

// Scene-scoped key: the physics plugin's `beforeEnter` hook registers
// the active scene's `PhysicsWorld` on its scope; a component resolves the
// same world with `this.use(PhysicsWorldKey)`. Use `PhysicsWorldManagerKey`
// (engine-scope) only for cross-scene enumeration.
const world = scene.use(PhysicsWorldKey);

// Gravity
world.setGravity(0, -980);

// Raycast direction can be any non-zero vector (normalized internally,
// e.g. target.sub(origin) works). A zero-length direction throws.
// filterGroups is a packed membership+filter pair, not a layer bitmask.
// Build it with CollisionLayers.interactionGroups; a raw layer bit matches nothing.
const hit = world.raycast(origin, direction, maxDistance, {
  filterGroups,
  sensors,
});
// hit: { entity, point: Vec2, normal: Vec2, distance } | null

// Overlap queries — what a shape touches where it already stands
world.queryShape(shape, position, {
  rotation,
  filterGroups,
  excludeEntity,
  sensors,
}); // Entity[]
world.queryRadius(center, radius, { filterGroups, excludeEntity, sensors }); // Entity[]
// What an existing collider overlaps: collider.getOverlapping() (see ColliderComponent).

// sensors: "exclude" (default) reports solid colliders only, "include" reports
// both, "only" reports sensors. On raycast, castShape, queryShape, queryRadius.
world.raycast(origin, direction, maxDistance, { sensors: "include" });

// Advance the simulation directly (a scene's PhysicsSystem does this for you).
// dt must be finite and >= 0; 0 rebuilds the query index without moving
// anything. Each step queues its collision events. Code that calls step
// directly must also call processCollisionEvents() to deliver them, or the
// queued pairs build up.
world.step(dt);
world.processCollisionEvents();

// Shape cast — sweep a shape along a direction and report the first hit.
// Same result shape as raycast: `distance` is how far the shape travelled,
// `point` the world contact point, `normal` the surface normal on the entity
// hit. With stopAtPenetration: true (default), an initial overlap reports distance 0.
// Direction is normalized internally; a zero-length direction throws.
const swept = world.castShape(shape, origin, direction, maxDistance, {
  rotation,
  filterGroups,
  excludeEntity, // pass the mover when the sweep starts inside its own collider
  sensors,
  stopAtPenetration: true, // false allows movement out of an initial overlap
});
```

`castShape` accepts `stopAtPenetration?: boolean` (default `true`). Set it to
`false` for clearance checks that move out of shallow wall or floor overlap.
The cast still reports obstacles farther along the route and movement deeper
into the initial overlap. For a move that requires a clear destination, also
check that position with `queryShape`. The option affects only the cast, not
body collisions.

`filterGroups` runs the same two-way test as collider-vs-collider filtering: a
collider is reported only when the query's membership bit is in that collider's
`mask` and that collider's `layers` bit is in the query's filter. So
`interactionGroups(LAYER_PLAYER, LAYER_WALL)` casts as if the ray were the
player, and skips a wall whose own `mask` leaves the player out. Pass `0xffff`
as the membership to report every collider on a layer regardless of which
layers its own `mask` names (a collider with `mask: 0` matches no query and is
never reported):

```ts
import { CollisionLayers } from "@yagejs/physics";

const layers = new CollisionLayers();
const LAYER_PLAYER = layers.define("player");
const LAYER_WALL = layers.define("wall");

// walls whose own mask includes the player layer
const playerWalls = {
  filterGroups: CollisionLayers.interactionGroups(LAYER_PLAYER, LAYER_WALL),
};
// every wall, whichever layers its own mask names
const allWalls = {
  filterGroups: CollisionLayers.interactionGroups(0xffff, LAYER_WALL),
};
```

Omit `filterGroups` to skip the layer test; `sensors` and `excludeEntity` still
apply.

`raycast`, `castShape`, `queryShape` and `queryRadius` skip sensor colliders
unless `sensors` says otherwise, so a ground check or a line of sight reports
surfaces rather than trigger zones. `collider.getOverlapping()` is the
exception: it reports only pairs where at least one side is a sensor, two
sensors included, and never a pair whose colliders both sit on static bodies.

These four and `collider.getOverlapping()` report every live collider at its
current pose. When colliders were created, re-shaped, enabled, disabled or
teleported since the last physics step, the query first runs a zero-duration
step, so a collider spawned this frame is already seen. That step moves nothing and advances no simulated time; contact
events for pairs that already overlap are collected then and arrive at the next
delivery, with `contactImpulse` 0. It costs one extra physics step on a frame
that both changed colliders and queried.

Use `castShape` to test a move before committing to it: carrying a rider on a moving platform, spotting a closing platform before it traps the player, or checking clearance for a fast fall. `queryShape` only reports overlaps at a fixed position and misses anything the shape would pass through on the way.

## Joints

`world.addJoint(bodyA, bodyB, config): JointHandle` connects two different
rigid bodies already added to the same world. Both entities must be active.

```ts yage-context="scene" yage-group="joints"
import { PhysicsWorldKey, type RigidBodyComponent } from "@yagejs/physics";

// Bodies of active entities in this scene
declare const playerBody: RigidBodyComponent, anchorBody: RigidBodyComponent;
declare const companionBody: RigidBodyComponent;
declare const wallBody: RigidBodyComponent, brickBody: RigidBodyComponent;
declare const towerBody: RigidBodyComponent, sailBody: RigidBodyComponent;
declare const bankBody: RigidBodyComponent, bridgeBody: RigidBodyComponent;
declare const railBody: RigidBodyComponent, platformBody: RigidBodyComponent;

const world = scene.use(PhysicsWorldKey);
const rope = world.addJoint(playerBody, anchorBody, {
  type: "rope",
  length: 120, // maximum anchor distance, px
});
const spring = world.addJoint(playerBody, companionBody, {
  type: "spring",
  restLength: 80, // px
  stiffness: 40,
  damping: 4,
});
const weld = world.addJoint(wallBody, brickBody, {
  type: "fixed",
  anchorA: { x: 0, y: 20 },
});
const hub = world.addJoint(towerBody, sailBody, {
  type: "revolute",
  motor: { velocity: 2, damping: 10 }, // rad/s
});
const bridge = world.addJoint(bankBody, bridgeBody, {
  type: "revolute",
  anchorB: { x: -60, y: 0 },
  limits: { min: -Math.PI / 2, max: 0 }, // relative radians, B minus A
});
const elevator = world.addJoint(railBody, platformBody, {
  type: "prismatic",
  axis: { x: 0, y: 1 }, // body A local direction, normalized internally
  limits: { min: 0, max: 240 }, // anchor separation along axis, px
  motor: { position: 120, stiffness: 40, damping: 8 },
});
```

- `rope` caps anchor separation; `spring` pulls toward `restLength`.
- `fixed` aligns the two anchors and holds both bodies at the same angle.
  Place local anchors at the intended attachment point before stepping.
- `revolute` aligns anchors but allows relative rotation. Disable
  `fixedRotation` on the rotating body.
- `prismatic` allows motion along `axis` while holding relative rotation at 0.
- Every type accepts `anchorA?`, `anchorB?` (body-local px, default origin),
  and `collide?` (default `false` for fixed, `true` otherwise).
  `collide` controls contacts between the two connected bodies; collision
  layers still apply.
- Every number must be finite. Lengths and spring/motor stiffness and damping
  must be >= 0. Limits require `min <= max`; `axis`
  must be non-zero.

`JointMotorConfig` has `position?`, `velocity?`, `stiffness?`, `damping?`;
at least one target is required, and omitted fields default to 0. Revolute
position/velocity use radians and rad/s; prismatic uses px and px/s. A
velocity-only motor needs `damping > 0` to move. 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 after changing `pixelsPerMeter`.

```ts yage-context="scene" yage-group="joints"
hub.setMotor({ velocity: -2, damping: 10 }); // replaces all motor settings
weld.attached; // false after removal, body disable or destruction
weld.remove(); // idempotent
```

`setMotor` requires an attached revolute or prismatic joint. Destroying or
disabling either entity detaches the joint; enabling the entity again does not restore
it. For a pooled entity, create the joint in `onAcquire`.

For impact-triggered destruction, remove a joint from a collision handler:

```ts yage-context="scene" yage-group="joints"
import { ColliderComponent } from "@yagejs/physics";

const brickCollider = brickBody.entity.get(ColliderComponent);
brickCollider.onCollision((event) => {
  if (event.started && (event.contactImpulse ?? 0) > 100) weld.remove();
});
```

For authored destruction without joints, create pre-cut pieces as static
bodies, then call `pieceBody.setType("dynamic")` when hit. Save the broken
state in game data and restore the appropriate body types on load.

## Save state

Rapier bodies, colliders, joints, contacts, and callbacks are runtime objects.
Save stable physics facts in the game's state root, then construct components
and joints through normal scene setup after load.
