Skip to content

Collisions & triggers

Solid colliders block and report collisions; sensor colliders overlap and fire trigger callbacks
Solid contacts report collisions; sensor overlaps fire triggers.

createCollisionSystem(rapierWorld, eventQueue) (packages/engine/src/physics/collision-system.ts) drains Rapier’s event queue once per tick, in postPhysics. Register a handler:

const collisions = createCollisionSystem(rapierWorld, eventQueue);
collisions.onCollision((handleA, handleB, started) => {
// started === true on contact begin, false on contact end
});
// each postPhysics: collisions.drain();

A handler receives the two collider handles and a started boolean. Events are buffered first, then handlers run after the drain — so it’s safe to create/destroy bodies inside a handler without corrupting Rapier’s iteration. Resolve a handle back to its entity with physics.getByColliderHandle(handle) (see the registry).

Triggers are sensor overlaps, handled in your own code

Section titled “Triggers are sensor overlaps, handled in your own code”

A sensor collider (Rapier’s setSensor(true)) overlaps without blocking, and its overlap events arrive through the same collision handler as everything else. There is no engine-side callback framework in between: your world code registers one handler, resolves the handles, and branches on whether a sensor was involved.

collisions.onCollision((a, b, started) => {
const nodeA = physics.getByColliderHandle(a);
const nodeB = physics.getByColliderHandle(b);
if (!nodeA || !nodeB) return;
if (started) onZoneEnter(nodeA, nodeB);
else onZoneExit(nodeA, nodeB);
});

In an R3F world this registration lives in a component’s mount effect (with the unsubscribe in its cleanup), so a zone’s enter/exit logic sits beside the mesh and collider it belongs to. Collision worlds never span roots — both nodes of an overlap always belong to the same world.

The running scene showing colliders interacting under the physics debug overlay
Colliders interacting at runtime (debug overlay on).