Three ways Rapier tells you two things touched, and when to use which
Rapier surfaces contact detection through three interfaces on top of the same underlying check — the raw event queue, R3F's `onCollisionEnter` prop, and sensor colliders with `onIntersectionEnter`. Same feature, three ergonomics, different tradeoffs. This post walks through each shape with real call sites from Tank Battle and 3D Race, and where each one belongs.
TL;DR — Rapier detects contacts once per
step()and exposes the results three ways. The raw way: enableActiveEvents.COLLISION_EVENTSon your colliders and drain them from anEventQueueafter step. The R3F way for solid bodies:<RigidBody onCollisionEnter={cb}>. The R3F way for triggers:<CuboidCollider sensor onIntersectionEnter={cb}>. Sensors don’t push back — they’re for pickups, checkpoints, kill boxes.onCollisionEnterandonIntersectionEnterare R3F sugar over the same event queue you’d drain by hand outside R3F. The choice between them is not “declarative vs imperative”, it’s “where does this game object live in your code”.Code paths in this post:
tank-battle/engine.ts,3d-race/Player.tsx,3d-race/Pickup.tsx.
Every physics engine has to answer the question “did these two colliders touch”. Rapier answers it once, inside world.step(). What differs across projects is how you get told about it. This has caused me more grief than the physics itself, because the three surfaces don’t behave identically at the edges.
The three shapes
Shape 1 — raw event queue. You pre-declare ActiveEvents.COLLISION_EVENTS on the colliders you care about, and after each world.step(eventQueue) you drain the queue:
// tank-battle/engine.ts
world.createCollider(
RAPIER.ColliderDesc.ball(BULLET_RADIUS)
.setDensity(1)
.setRestitution(0)
.setActiveEvents(RAPIER.ActiveEvents.COLLISION_EVENTS),
body,
)
// ...later, in the tick:
this.world.step(this.eventQueue)
this.eventQueue.drainCollisionEvents((h1, h2, started) => {
if (!started) return
// h1 and h2 are collider handles — you look them up
})
Shape 2 — R3F onCollisionEnter on a solid body. @react-three/rapier gives you a callback prop:
// 3d-race/Player.tsx
<RigidBody
ref={body}
colliders="ball"
onCollisionEnter={handleCollision}
>
<mesh geometry={marbleGeometry} material={marbleMaterial} />
</RigidBody>
handleCollision gets a payload with the other body pre-resolved:
const handleCollision = (payload: { other: { rigidBody?: RapierRigidBody | null } }) => {
const otherBody = payload.other.rigidBody
if (!otherBody) return
const data = otherBody.userData as { role?: string } | undefined
if (data?.role === 'obstacle') {
useGame.getState().hit()
}
}
Shape 3 — sensor collider with onIntersectionEnter. Same R3F ergonomics but the collider is marked sensor, which means it detects touches without exerting contact forces:
// 3d-race/Pickup.tsx
<RigidBody type="fixed" colliders={false} position={[x, y, z]}>
<CuboidCollider
args={[0.4, 0.4, 0.4]}
sensor
onIntersectionEnter={collect}
/>
<mesh geometry={geometryFor(kind)} material={material} />
</RigidBody>
The pickup floats there. When the marble runs through the box, collect fires. The marble does not bounce off the pickup — that’s the whole point. If you left sensor off, the player would ping-pong off pickups like they were traffic cones.
Underneath, all three go through the same solver-time contact detection. The difference is the ergonomics.
What they get you, and what they don’t
The raw event queue is the only option outside R3F. Tank Battle isn’t an R3F tree, so R3F’s declarative props aren’t available; the drain-loop pattern is what you write.
It also has one property the R3F callbacks don’t: you can batch across contacts before deciding what to do. Say you’re spawning sparks when bullets hit surfaces. In the drain callback you can accumulate all this-frame’s contacts into a temporary array, then post-process — sample the loudest one, coalesce sparks that overlap, cap total spark count. That’s straightforward when you own the loop; it’s much harder from onCollisionEnter where each callback fires independently and doesn’t know what else fired this frame.
The tradeoff: you have to solve the identity problem yourself. The drain callback gives you a ColliderHandle (a u32) — not your game object. So you keep a map:
private bulletByHandle: Map<number, Bullet> = new Map()
// on spawn:
this.bulletByHandle.set(body.handle, bullet)
// on drain:
const p1 = c1?.parent()
const bullet = p1 ? this.bulletByHandle.get(p1.handle) : undefined
// on retire:
this.bulletByHandle.delete(bullet.handle)
Note the c1.parent() step. drainCollisionEvents gives you collider handles; the map is keyed by rigid body handles. There’s a case for keeping either kind of map, but body handles are usually what you want because a game object can own multiple colliders (a compound shape) and you don’t want to duplicate its entry.
The R3F onCollisionEnter prop hides the queue and the handle lookup. payload.other.rigidBody is already resolved to the RapierRigidBody on the other side; you can attach a userData object at declaration time and read it here. In 3d-race/Player.tsx that’s how obstacle detection works — every obstacle’s <RigidBody userData={{ role: 'obstacle' }}> sets a role tag, and the marble’s collision handler checks the role instead of trying to identify the body by reference.
You give up the batching property, and you take on a subtler cost: R3F’s plugin subscribes to the event queue for every mounted <RigidBody> with a callback prop, and calls back per-touch. If you have hundreds of dynamic bodies each with onCollisionEnter, that’s real overhead — memoize the handler with useCallback at minimum. In a game with 10-20 dynamic bodies (the marble and its obstacles), it’s fine.
Sensor colliders solve a different problem. They don’t push back. If your “collision” is really “touch, without any physics response” — pickups, damage volumes, checkpoints, kill planes — sensors are the correct primitive, and using solid colliders with a manual “cancel the reaction” hack is the wrong shape.
There is a subtle asymmetry worth calling out: sensor colliders emit intersection events, not collision events. They fire onIntersectionEnter, not onCollisionEnter. On the raw side that’s drainIntersectionEvents, not drainCollisionEvents. If you’re mixing sensors with solids in the same drain loop, you’ll drain the wrong queue and be baffled when your pickup callback never fires.
Which to reach for
If you’re already an R3F app and the touch is one of these:
- Solid ↔ solid, both stay dynamic:
onCollisionEnteron the RigidBody.handleCollisionreadspayload.other.rigidBody.userDatafor identity. This is the marble+obstacles case. - Trigger volume that shouldn’t push back: sensor collider with
onIntersectionEnter. The pickup case. Don’t be clever about this — solid colliders with hand-cancelled response are a maintenance liability.
If you’re not an R3F app, or the touch pattern needs global batching:
- Raw event queue.
setActiveEvents(RAPIER.ActiveEvents.COLLISION_EVENTS)at collider creation,world.step(eventQueue),eventQueue.drainCollisionEventsin the tick. Bullet impact effects, hit-registration in a shooter, anything that needs “of all this frame’s contacts, do X” — this is the shape that gives you the loop. - Sensor +
drainIntersectionEventsif you need trigger detection outside R3F, or if you want the batching property for triggers specifically.
The “started” flag
Both drain callbacks and both R3F callback props distinguish between contact-begin and contact-end. In the raw callback:
this.eventQueue.drainCollisionEvents((h1, h2, started) => {
if (!started) return // ignore separations
// ...
})
In R3F, they arrive as separate events (onCollisionEnter vs onCollisionExit, onIntersectionEnter vs onIntersectionExit).
Bullets ignore separations because they retire on the first contact. But if you’re modeling a rolling ball on a ramp, or a character standing on a moving platform, or “am I currently inside this trigger” as a persistent state, you need both edges. Skipping onCollisionExit is the standard bug where “the character is technically still on the platform 30 seconds after they walked off, because we never got the exit event” — and it’s usually because someone typed only the enter handler and moved on.
The “sensor emits nothing about the collision force” caveat
The one thing sensors don’t give you is contact geometry. Solid-collider events (both raw and R3F) carry a manifold with normals, penetration, contact points — enough to spawn sparks pointed away from the surface, or apply damage scaled by impact velocity. Sensors give you touched-or-not, and that’s it. If you want “how hard did the ball hit the pickup”, you need a solid collision event, and you need to eat the reaction (or set very low restitution and coefficient of friction so the bounce is imperceptible).
Not all pickups care. The 3D Race gems don’t — they just add coins. But if you were writing “boss weak-point that takes damage proportional to bullet speed”, sensors would be the wrong primitive for the weak point; use a solid collider with events.
Cross-checking your setup
If a callback isn’t firing, in order:
- Is
setActiveEvents(RAPIER.ActiveEvents.COLLISION_EVENTS)set on at least one of the two colliders? Rapier only reports contacts where at least one participant opted in. Setting it on one is usually enough. - Are you draining the correct queue? Sensor pairs go through
drainIntersectionEvents, solid pairs throughdrainCollisionEvents. - Is your collider a sensor when you meant it to be solid, or vice versa? A sensor won’t ever fire
onCollisionEnter; a solid won’t ever fireonIntersectionEnter. - If you’re using R3F, is the
<RigidBody>mounted for both sides? If one side is a plain<mesh>without a physics body, there’s no collider to touch. - Is your handle→game-object map still populated at drain time? Retiring the game object before the drain sees the last event will silently drop it.
The messages don’t help you debug this — Rapier just doesn’t tell you when nothing happened, so a missing setup produces silence, which reads as “the API is broken”. It isn’t. It’s one of the five things above, in that order of frequency.