Vol. 07 · Dispatch2026-07-02

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.

by MetaWorldOS Engineering
Three.jsReact Three FiberRapierTypeScriptGameDev

TL;DR — A marble runner in the browser is a small game with a lot of physics traps. (1) Every moving obstacle must be a kinematic rigid body driven by setNextKinematicTranslation, not a setTranslation teleport, or your marble will phase through it. (2) The jump button has to gate on a downward ray-cast — anything simpler produces a double-jump exploit within 30 seconds of playtesting. (3) The level is not a growing list; it’s a fixed window of ~15 blocks that shift as the player advances. (4) Camera smoothing looks premium and costs one Vector3.lerp per frame — as long as you never allocate the target vector inside useFrame. This post walks through the actual code from /gamecenter/3d-race.

Live demo: /gamecenter · engine tree: src/features/gamecenter/games/3d-race/


The Snooker post (threejs-snooker-aim-line-and-rapier) was about a bounded physics problem — 22 balls, one table, closed rules. This is the opposite: an unbounded obstacle course where the world scrolls forever, obstacles animate on independent phases, and the only physics body under player control is a single IcosahedronGeometry(0.3, 1) marble. Different constraints, different mistakes.

Four files carried most of the iteration:

3d-race/
├── Player.tsx     ← marble body, jump ray-cast, camera smoothing
├── Level.tsx      ← procedural blocks, kinematic obstacles, infinite recycler
├── Experience.tsx ← Physics wrapper, lighting, key bindings
└── store/useGame  ← phase state (idle / ready / playing / ended)

Nothing else really moved after week one. Here’s what mattered.


1. Kinematic bodies exist for exactly this reason

The mistake almost every R3F+Rapier tutorial makes with moving obstacles is to give them type="dynamic" and animate position in useFrame. That works — until the marble arrives. Rapier’s solver treats a dynamic body’s position as an output of forces and constraints; when you overwrite it every frame, the solver has no idea the object is moving. Contact resolution against a “stationary” object that just teleported into the marble’s space produces exactly the artifact you’d expect: the marble squirts out at random.

The correct pattern — the one every one of our obstacles uses — is a kinematic-position body driven by setNextKinematicTranslation. From Level.tsx, the horizontally-swinging axe:

export function BlockAxe({ position = [0, 0, 0] }: BlockProps) {
  const obstacle = useRef<RapierRigidBody>(null)
  const [speed] = useState(() => (Math.random() + 1) * (Math.random() < 0.5 ? -1 : 1))

  useFrame((state) => {
    if (!obstacle.current) return
    const elapsedTime = state.clock.getElapsedTime()
    obstacle.current.setNextKinematicTranslation({
      x: position[0] + Math.sin(elapsedTime * speed),
      y: position[1] + 0.8,
      z: position[2],
    })
  })

  return (
    <RigidBody ref={obstacle} type="kinematicPosition" position={[0, 0.3, 0]} />
    // ... mesh
  )
}

Two things worth stating out loud:

setNextKinematicTranslation (not setTranslation). The Next variant queues the pose for the upcoming physics step. Rapier can then compute the swept motion of the collider between “now” and “then”, and the marble’s contact resolution accounts for the obstacle’s velocity. Using plain setTranslation on a kinematic body still teleports — you’d get the exact bug we were trying to avoid.

speed is memoized with useState(() => ...), not Math.random() in the render body. If you compute speed inline, every re-render of the parent generates a new value and the obstacle’s phase resets. useState with an initializer runs once, and stays. This is a React idiom, not a physics one, but it’s the shape of half the “why is my animation stuttering” bugs in R3F code.

The rotational obstacle — the classic sweeping bar, BlockSpinner — has one extra micro-optimization: it reuses a Quaternion and Euler per instance instead of allocating them each frame:

const spinRotation = useRef(new THREE.Quaternion())
const spinEuler = useRef(new THREE.Euler())

useFrame((state) => {
  const elapsedTime = state.clock.getElapsedTime()
  spinEuler.current.set(0, elapsedTime * speed, 0)
  spinRotation.current.setFromEuler(spinEuler.current)
  obstacle.current!.setNextKinematicRotation(spinRotation.current)
})

Fifteen blocks × two allocations × 60 fps = 1,800 Quaternions per second going straight to the GC. On a mid-range Chromebook that’s a measurable jank pattern. Reusing scratch objects is the single biggest R3F perf lever after “don’t rebuild geometry on every render.”


2. Grounded jump is a ray-cast, not a boolean flag

The naive jump implementation is a canJump flag flipped false on jump and true on collision. It’s wrong for two reasons: contact events fire on side walls too (so you can wall-jump forever), and the flag can go stale if the marble leaves the ground without a contact event (walking off a ledge). Every “why can I double-jump” bug traces back to this.

The correct check is a downward ray-cast from just below the marble’s center, and it’s four lines in Player.tsx:

const jump = () => {
  const b = getBody()
  if (!b) return
  const origin = b.translation()
  origin.y -= 0.31                        // marble radius = 0.3 + tiny epsilon
  const direction = { x: 0, y: -1, z: 0 }
  const ray = new rapier.Ray(origin, direction)
  const hit = world.castRay(ray, 0.1, true)   // 10 cm probe

  if (hit && hit.timeOfImpact < 0.1) {
    b.applyImpulse({ x: 0, y: 0.5, z: 0 }, true)
  }
}

The 0.1 distance is the whole trick. It’s short enough that mid-air jumps fail (nothing is within 10 cm below the marble), and long enough that a marble bouncing off a slightly uneven surface still counts as grounded. timeOfImpact < 0.1 is defensive redundancy — castRay(_, 0.1, _) already caps the ray, but the explicit check makes the invariant readable.

Two things I’ve seen people get wrong here:

  • Casting from the marble’s center instead of the surface. If you don’t offset by radius, the ray starts inside the collider and the first hit is the marble itself. Rapier’s castRay has a filterExcludeCollider argument for this, but subtracting radius + ε from the origin is simpler and doesn’t require plumbing collider handles.
  • Jumping on applyForce instead of applyImpulse. Force integrates over time; a one-frame force at 60 Hz produces a barely-perceptible bump. Impulse is instantaneous velocity change, which is what a jump actually is.

The other side of Player.tsx — the movement — is deliberately not a rigid body pose set. It’s impulses:

const impulseStrength = 0.5 * delta
const torqueImpulseStrength = 0.5 * delta
if (forward)  { impulse.z -= impulseStrength; torqueImpulse.x -= torqueImpulseStrength }
if (left)     { impulse.x -= torqueImpulseStrength; torqueImpulse.z += torqueImpulseStrength }
// ...
b.applyImpulse(impulse, true)
b.applyTorqueImpulse(torqueImpulse, true)

Applying both a linear impulse and a torque impulse is what makes the marble roll instead of slide. Without the torque, it looks like a hovering ball. Without the linear impulse, it looks like a ball spinning in place on an ice rink. Both together is the marble physics your muscle memory expects. Multiplying by delta is not optional — without it, a 144 Hz monitor gives the marble 2.4× the acceleration of a 60 Hz one.


3. Infinite level, fixed memory

The instinct for an “endless” course is to keep pushing new blocks onto an array as the player advances. That works for about ninety seconds, then the array is 500 items long, React is diffing 500 keyed children per frame, and Rapier is stepping 500 kinematic bodies. The FPS graph tells you exactly when.

The recycling pattern is dead simple: keep a fixed-size window of ~15 blocks, and when the player passes the first one, shift it to the end.

useFrame(() => {
  if (!playerRef.current) return
  const playerZ = playerRef.current.translation().z
  const firstBlock = blocks[0]

  if (firstBlock && playerZ < firstBlock.position[2] - 4) {
    setBlocks((prev) => {
      const newBlocks = [...prev.slice(1)]
      const lastBlock = prev[prev.length - 1]
      newBlocks.push({
        id: lastBlock.id + 1,
        type: types[Math.floor(Math.random() * types.length)],
        position: [0, 0, lastBlock.position[2] - 4],
      })
      return newBlocks
    })
  }
})

The id grows monotonically and is used as the React key. That matters — if you keyed by array index, React would diff the same DOM position across two logically different blocks and Rapier would inherit the previous body’s velocity. New id → new key → fresh mount → fresh rigid body. Rapier’s cleanup happens via the R3F unmount path, no manual world.removeRigidBody needed.

The floor color for each block is derived from its Z coordinate, not its id:

function floorFromZ(z: number): THREE.MeshStandardMaterial {
  const idx = Math.abs(Math.round(z / 4)) % dopamineFloorMaterials.length
  return dopamineFloorMaterials[idx]
}

Deriving from Z means the palette doesn’t rotate when a block recycles — the color stays associated with the position, not the block instance. Otherwise you’d see the same block flash a new color every time it teleported to the far end. Small detail, but the animation stutter is very visible if you get it wrong.

The safety wall — the invisible floor beneath the whole track — is the last piece. It has to be long enough that the marble can’t fall off the side, but making it 200 units long ships 200 units of collision volume. Instead we make it follow the player:

export function BlockWall({ length = 4, playerRef }) {
  const colliderRef = useRef<RapierRigidBody>(null)

  useFrame(() => {
    if (!colliderRef.current || !playerRef.current) return
    const playerZ = playerRef.current.translation().z
    colliderRef.current.setTranslation({ x: 0, y: 0, z: playerZ }, true)
  })

  return (
    <RigidBody ref={colliderRef} type="fixed" friction={1} restitution={0.2}>
      <CuboidCollider args={[2, 0.1, 2 * length + 4]} />
    </RigidBody>
  )
}

Note this one does use setTranslation — the wall is a type="fixed" static body, not kinematic, and the marble never touches it under normal play. It’s a fallback net. The teleport is fine because nothing is actively resolving contacts against it in the frame it moves.


4. Camera smoothing that doesn’t allocate

The camera in Player.tsx follows the marble with an offset and a lerp. Two lines of “look nice,” two lines of “don’t leak”:

// module scope — allocated once per session
const _cameraPosition = new THREE.Vector3()
const _cameraTarget = new THREE.Vector3()

// component scope — the smoothed values that persist across frames
const [smoothCameraPosition] = useState(() => new THREE.Vector3(10, 10, 10))
const [smoothCameraTarget] = useState(() => new THREE.Vector3())

useFrame((state) => {
  const bodyPosition = b.translation()
  _cameraPosition.copy(bodyPosition as THREE.Vector3)
  _cameraPosition.z += 2.25
  _cameraPosition.y += 0.5

  _cameraTarget.copy(bodyPosition as THREE.Vector3)
  _cameraTarget.y += 0.25

  smoothCameraPosition.lerp(_cameraPosition, 0.1)
  smoothCameraTarget.lerp(_cameraTarget, 0.1)

  state.camera.position.copy(smoothCameraPosition)
  state.camera.lookAt(smoothCameraTarget)
})

The two module-scope vectors are the temporaries — they’re overwritten every frame with copy, never allocated. The two useState-held vectors are the state — they persist frame-to-frame and get lerped toward the target. The 0.1 lerp factor is not framerate-independent (a purist would use 1 - Math.pow(0.9, delta * 60)), but for a marble game the difference is invisible, and one fewer Math.pow per frame is one fewer thing to explain.

If you’re wondering why the smoothed vectors are held in useState rather than useRef, it’s a taste call — either works. useState with a lazy initializer signals this value is game state that survives re-renders, which is what these are, whereas useRef reads more like a mutable escape hatch.


What we didn’t build

Physically driven skinned characters. A rigging pipeline for the marble → rig → character path is genuinely fun to build and completely wrong for this game. The marble is the character. Every extra bone between input and physics is a source of latency you’ll never win back.

Persistent leaderboards per obstacle mix. The seed is randomized, and comparing runs across seeds is meaningless. We considered fixing the seed for a “daily challenge” and might still, but it’s a game-design decision, not an engineering one.

A shader for the “candy” look. The dopamine palette is seven MeshStandardMaterials with roughness: 0.55. Total. A custom shader was on the roadmap, then we shipped the MeshStandard version, saw it looked delicious in the browser, and closed the ticket. Materials are almost always the wrong place to spend your first week of polish.


The whole loop, one page

input (WASD/arrows/space)
        │
        ▼                                         ┌── obstacles animate via
Player.useFrame ── impulse + torque impulse       │     setNextKinematicTranslation
        │                                          │     each useFrame
        ▼                                          │
Rapier.step(dt)  ◄─────────────────────────────────┘
        │
        ├─► marble position, rotation
        │
        ▼
Level.useFrame ── recycle first block if playerZ < firstBlockZ - 4
        │
        ▼
BlockWall.useFrame ── setTranslation to trail the marble
        │
        ▼
Camera lerp ── two allocation-free scratch vectors + two persistent state vectors
        │
        ▼
render

The subtlety is that everything the player perceives as “the level moves” is actually two things: the marble moves forward under impulses, and the camera lerps behind it. Nothing scrolls. The level appears to scroll because the fixed-size window of blocks is spaced along the marble’s path and the camera tracks the marble’s Z. This is the same trick every side-scroller has used for forty years, ported to R3F.


Coda

R3F + Rapier is genuinely a great stack for this kind of game — declarative scene, imperative physics ref access, and no scene-graph bookkeeping. The traps, and there are traps, are almost all shaped like “I forgot which layer owns this piece of state.” Position is owned by the physics body. Rotation is owned by the physics body. Camera targets are owned by React state. Colors are owned by module-scope material objects. When those ownership boundaries stay clean, the code stays under 1,000 lines and runs at 60 fps on hardware you’d expect to see in an internet café. When they blur, everything is one big useEffect and you can’t figure out why the marble stutters.

Live demo: /gamecenter — pick 3D Race, hold forward, and try to double-jump. You can’t, and now you know why.

— Read Next —

推荐延伸阅读

更多关于 3D 渲染、物理仿真与网页游戏架构的深度技术解析

Three.js

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.

阅读全文
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.

阅读全文
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.

阅读全文

订阅工程深潜文章 — 约每月一篇,没有营销邮件。