Vol. 07 · Dispatch2026-07-06

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.

by MetaWorldOS Engineering
Three.jsRapierR3FPhysicsTypeScript

TL;DR — Rapier detects contacts once per step() and exposes the results three ways. The raw way: enable ActiveEvents.COLLISION_EVENTS on your colliders and drain them from an EventQueue after 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. onCollisionEnter and onIntersectionEnter are 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: onCollisionEnter on the RigidBody. handleCollision reads payload.other.rigidBody.userData for 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.drainCollisionEvents in 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 + drainIntersectionEvents if 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:

  1. 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.
  2. Are you draining the correct queue? Sensor pairs go through drainIntersectionEvents, solid pairs through drainCollisionEvents.
  3. 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 fire onIntersectionEnter.
  4. 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.
  5. 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.

— Read Next —

Recommended Dispatches

More engineering deep-dives into 3D rendering, physics simulation, and game architecture

Three.js

From a keyboard press to an anchor impulse — Tank Battle's throttle chain

A tank's throttle in Tank Battle is not "one number that becomes engine force". It's a chain — key or joystick input → gear index into GEAR_KMH → target left/right track speeds with a steer differential → slew-limited actual track speeds → per-anchor drive impulses. Auto-shift decides gear from commanded track speed, not from the physics body's velocity, which produces one deliberate quirk. Here's every step.

Read dispatch
Three.js

A four-anchor raycast suspension for a tank in raw Rapier

Tank Battle's chassis rides on four downward raycasts, each of which contributes a spring impulse, a track-drive impulse, and a lateral friction impulse. It's the standard raycast-vehicle pattern from Unity's Wheel Collider or BeamNG, written straight against @dimforge/rapier3d-compat with no vehicle plugin. Here's the whole loop, the constants, the front/rear drive split, and the lateral-friction cliff that makes it feel like a tank.

Read dispatch
Three.js

A React Three Fiber + Rapier marble runner — kinematic obstacles, grounded jumps, and an infinite level that never allocates

Four things that separate a marble runner that feels good from one that feels off — kinematic bodies driven by setNextKinematicTranslation (not setTranslation), a ray-cast grounded jump that can't be spammed mid-air, an "infinite" level that recycles blocks in place instead of growing an array, and per-frame camera lerp with reused scratch vectors. Straight from the /gamecenter 3D Race source.

Read dispatch

Recibe un email cuando publiquemos un nuevo artículo — análisis técnicos, ~una vez al mes.