Vol. 07 · Dispatch2026-07-06

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.

by MetaWorldOS Engineering
Three.jsRapierPhysicsTypeScriptGameDev

TL;DR — Tank Battle uses vanilla three.js + @dimforge/rapier3d-compat, no @react-three/rapier, no vehicle plugin. The player is a single dynamic Rapier body with four body-relative “anchor” points laid out at the corners of the hull. Each frame: from every anchor we cast a ray straight down, and if it hits ground within RAY_MAX, we apply three impulses at that anchor point — a spring impulse (Hooke + damping), a track-drive impulse along the hull’s forward vector, and a lateral friction impulse with a static/kinetic cliff. That’s the whole vehicle model. It’s the pattern behind Unity’s Wheel Collider and every raycast-vehicle Bullet demo, ported to Rapier’s imperative API in about 50 lines.

Code: src/features/gamecenter/games/tank-battle/engine.ts

The two earlier physics posts on this blog — snooker’s aim-line + Rapier and the R3F + Rapier marble runner — both use @react-three/rapier, the declarative wrapper. That’s the right choice when your scene is already an R3F tree; you get <RigidBody> and <CylinderCollider> as first-class children of your meshes and you never touch a wasm handle directly.

Tank Battle isn’t an R3F tree. It’s a 2800-line vanilla three.js engine — desert environment, IBL, procedural tank rig, custom H-pattern gearbox, particles, post-processing — and reparenting all of that into R3F just to get declarative colliders would have been a bigger refactor than the physics work itself. So we went the other direction: raw Rapier, world.step() in the tick, colliders and bodies created imperatively, no React involved.

That’s not a rebellion, that’s just picking the right layer. Raw Rapier is exactly as verbose as you would expect a wasm binding to be. What’s less obvious is what “vehicle physics” means when the API doesn’t hand you a WheelCollider primitive. The rest of this post is the answer.

The one dynamic body

The tank is one rigid body. Not one body per wheel, not four bodies with joints — one body, hull-shaped, sitting above the ground.

const bodyDesc = RAPIER.RigidBodyDesc.dynamic()
  .setTranslation(0, 1, 0)
  .setLinearDamping(0.3)
  .setAngularDamping(1.5)
const pb = world.createRigidBody(bodyDesc)

world.createCollider(
  RAPIER.ColliderDesc.cuboid(1.25, 0.42, 2.75)
    .setDensity(2)
    .setActiveEvents(RAPIER.ActiveEvents.COLLISION_EVENTS),
  pb,
)

The cuboid half-extents are (1.25, 0.42, 2.75) — 2.5m wide, 0.84m tall, 5.5m long. That’s roughly a Tiger I hull (which is what the visual rig is modeled on). The angular damping is high — 1.5 — because a tank without high angular damping oscillates like a pendulum every time it climbs a bump; this is the single biggest “it feels tanky” knob in the file.

Note what’s not here: no wheels, no tracks, no suspension links. Rapier has a built-in Vehicle implementation but it lives at a level of abstraction that doesn’t help us — it wants a chassis + N wheel bodies + wheel definitions, and it makes assumptions about steering that don’t match how a tank steers (skid, not Ackerman). We want the raycast pattern, and it’s short enough to write by hand.

The four anchors

The suspension is four points fixed in body-space:

private playerAnchors: THREE.Vector3[] = [
  new THREE.Vector3(-1.4, -0.42,  1.76),  // front-left
  new THREE.Vector3( 1.4, -0.42,  1.76),  // front-right
  new THREE.Vector3(-1.4, -0.42, -1.76),  // back-left
  new THREE.Vector3( 1.4, -0.42, -1.76),  // back-right
]

y=−0.42 is exactly the bottom of the cuboid — the anchors are on the underside of the hull. The x=±1.4 is 15 cm outboard of the collider’s ±1.25, so raycasts don’t self-intersect the hull, and the z=±1.76 splits the length across the two sets of road wheels visible in the visual rig. Whether the anchors align exactly with the visual wheels doesn’t actually matter — the rig is decoration, the raycasts are physics — but we chose values that made the rig-anchor coincidence a happy default when we later added debug lines.

Every frame we transform each anchor into world space using the body’s current pose:

const bodyMat = new THREE.Matrix4().compose(
  new THREE.Vector3(bt.x, bt.y, bt.z),                    // body translation
  new THREE.Quaternion(qx, qy, qz, qw),                   // body rotation
  new THREE.Vector3(1, 1, 1),
)
// ...
const worldA = localA.clone().applyMatrix4(bodyMat)

Then we cast a ray from that world-space point, straight down, up to RAY_MAX = 1.0 meters:

const ray = new RAPIER.Ray(
  { x: worldA.x, y: worldA.y, z: worldA.z },
  { x: 0, y: -1, z: 0 },
)
const hit = this.world!.castRay(
  ray, RAY_MAX, true,
  undefined, undefined, undefined, body,   // ← exclude self
)

The last argument excludes the tank’s own hull collider from the raycast — otherwise the ray would immediately hit the underside of the very cuboid it’s cast from. This is one of Rapier’s less-documented parameters and one you’ll spend an evening on if you don’t know about it.

hit.timeOfImpact is the ray’s t value at the hit — because the ray direction is normalized {0,-1,0}, timeOfImpact is the distance in meters from the anchor to the ground beneath it.

The spring impulse

Standard Hooke + viscous damping, per anchor:

const REST_LEN = 0.16
const SPRING_K = 400000
const SPRING_C = 30000

const compression = REST_LEN - hitDist    // >0 when compressed
const anchorDown = -anchorVY              // downward velocity of anchor
const springForce = Math.max(0, SPRING_K * compression - SPRING_C * anchorDown)
if (springForce > 0) {
  body.applyImpulseAtPoint(
    { x: 0, y: springForce * dt, z: 0 },
    { x: worldA.x, y: worldA.y, z: worldA.z }, true,
  )
}

The Math.max(0, ...) clamp is important: a spring cannot pull the tank down toward the ground when it’s above rest length. Physically the wheel would just leave the ground, and the anchor stops contributing until it re-lands.

SPRING_K = 400000 and SPRING_C = 30000 are tuning-round numbers — they’re not derived from any target frequency, they’re what ended up in the file after the usual iteration on ride height and landing behavior. There’s no shortcut for this kind of tuning; the standard loop is roughly (a) find K that gives sane sag on flat ground, (b) find C that damps the pogo-oscillation on landing without killing the ride, © drive up a dune and check for wallow or bucking. Any suspension author will recognize it.

The one non-obvious detail is anchorVY. The anchor is not the body’s center of mass — it’s offset — so its velocity has a rotational contribution:

const rx = worldA.x - bt.x, ry = worldA.y - bt.y, rz = worldA.z - bt.z
const anchorVX = linvel.x + angvel.y * rz - angvel.z * ry
const anchorVY = linvel.y + angvel.z * rx - angvel.x * rz
const anchorVZ = linvel.z + angvel.x * ry - angvel.y * rx

That’s v = v_body + ω × r written out. Skip the cross product and the body’s roll and pitch stop damping properly — the tank rocks like a metronome after every hop, because the damping term never sees the anchor’s actual downward velocity, only the CoM’s.

Track drive

The tank drives via two virtual tracks. p.leftTrack and p.rightTrack are numbers in roughly [−1, 1] representing throttle per side. The player’s input maps W to both tracks forward, A/D to differential (left forward + right back for a left turn), and so on.

For each anchor we contribute a forward-directed drive impulse:

const forwardVec = new THREE.Vector3(0, 0, 1)
  .applyQuaternion(new THREE.Quaternion(qx, qy, qz, qw))
forwardVec.y = 0
if (forwardVec.lengthSq() > 1e-6) forwardVec.normalize()

const isFront = i < 2
const isLeft = (i % 2 === 0)
const trackSpeed = isLeft ? p.leftTrack : p.rightTrack
const driveWeight = isFront ? 0.6 : 0.4
const driveF = trackSpeed * DRIVE_FORCE_COEF * driveWeight
body.applyImpulseAtPoint(
  {
    x: forwardVec.x * driveF * dt,
    y: 0,
    z: forwardVec.z * driveF * dt,
  },
  { x: worldA.x, y: worldA.y, z: worldA.z }, true,
)

Two design choices in there.

forwardVec.y = 0 after the quaternion rotation projects the drive vector onto the horizontal plane, so a tank tilted forward on a slope still drives horizontally rather than into the ground (or into the sky, on a downslope). Without this the tank climbs slopes strangely — decelerating on the way up because a chunk of the drive force is going into the ground.

The driveWeight split — 0.6 to the two front anchors, 0.4 to the two rear — is a stability tweak. Applying equal drive at all four anchors gives you a moment about the CoM whose sign depends on how far behind the CoM the rear anchors are; on this hull that pitches the nose down under acceleration, which feels weightless. Weighting forward pulls the moment closer to the CoM. The tank’s average forward force is unchanged, but it now leans back under acceleration like a heavy vehicle does. Total weight sums to 2.0 (0.6 × 2 anchors × 2 sides + 0.4 × 2 anchors × 2 sides), so DRIVE_FORCE_COEF = 2000 gives a peak drive-line impulse budget of 4000 per side, or 8000 total, matching the top gear ratio in GEAR_KMH (52 km/h).

Because the drive impulses are applied at the anchor points (not the CoM), they also generate a small yaw torque when the two sides differ — which is exactly how skid steering works. We didn’t add “steering” as a separate concept anywhere. Differential track speed → asymmetric drive impulses at anchors → yaw. Tank turns.

Lateral friction, with a cliff

The last impulse per anchor is the one that makes the tank feel planted. Without it, driving forward and then trying to turn produces a fighter-jet-in-space slide because Rapier has no idea the anchor points are supposed to be gripping the ground.

const dotF = anchorVX * forwardVec.x + anchorVZ * forwardVec.z
const lateralX = anchorVX - forwardVec.x * dotF
const lateralZ = anchorVZ - forwardVec.z * dotF
const lateralSpeed = Math.hypot(lateralX, lateralZ)
const bite = lateralSpeed < FRICTION_STATIC_THRESHOLD ? 1.0 : SLIP_COEF
body.applyImpulseAtPoint(
  {
    x: -lateralX * bite * massPerAnchor,
    y: 0,
    z: -lateralZ * bite * massPerAnchor,
  },
  { x: worldA.x, y: worldA.y, z: worldA.z }, true,
)

Decompose the anchor’s velocity into the along-forward component (dotF) and the lateral remainder. Apply an impulse that opposes the lateral velocity, scaled by bite and the anchor’s share of the mass.

The bite value is a step function:

const SLIP_COEF = 0.75
const FRICTION_STATIC_THRESHOLD = 2  // m/s
const bite = lateralSpeed < FRICTION_STATIC_THRESHOLD ? 1.0 : SLIP_COEF

Below 2 m/s of sideways motion, the anchor grips fully — the lateral impulse cancels the sideways velocity in one step. Above 2 m/s, it only cancels 75%, so the tank keeps sliding a bit each frame, which decays exponentially. This is the “static vs kinetic friction” model in its dumbest possible form, and it does an enormous amount of work for the feel of the vehicle:

  • At low lateral speed (turning gently while driving), the tank tracks — its rear doesn’t fishtail, because the grip is total.
  • At high lateral speed (yanking the sticks hard, or being nudged by a bullet impact), the tank breaks loose and slides, decaying back to grip over about a second.

The 2 m/s number isn’t physical. Coulomb friction between two solids as Rapier implements it doesn’t correspond to what “a track on sand” is doing anyway — the shear behavior of a flexible track over granular ground is off in a completely different direction. So we’re doing the friction ourselves at the anchor, with our own model, and the threshold is a knob that trades “handles cleanly at low speed” against “breaks loose realistically when yanked”. 2 m/s is where those two feels overlap on this hull; on a lighter or heavier vehicle it would want a different number.

Stepping the world and drainng events

world.step(eventQueue) is once per frame. Bullets are dynamic ball colliders that emit COLLISION_EVENTS when they hit anything, and we drain those events immediately after step:

this.world.step(this.eventQueue)
this.eventQueue.drainCollisionEvents((h1, h2, started) => {
  if (!started) return
  const c1 = this.world!.getCollider(h1)
  const c2 = this.world!.getCollider(h2)
  const p1 = c1?.parent()
  const p2 = c2?.parent()
  let bullet: Bullet | undefined
  if (p1) bullet = this.bulletByHandle.get(p1.handle)
  if (!bullet && p2) bullet = this.bulletByHandle.get(p2.handle)
  if (!bullet || !bullet.alive) return
  // ...spawn sparks / smoke, retire the bullet
})

The mapping problem — “Rapier gives me a collider handle, I need my Bullet object” — is one Rapier does not solve for you. You keep your own map:

private bulletByHandle: Map<number, Bullet> = new Map()

and you insert into it right after createRigidBody returns. The handle is a stable u32; the map only has to cover live bullets, which is usually under 20. When the bullet is retired we delete the entry so the map doesn’t grow forever.

started is true for touch-begin and false for touch-end; we ignore touch-end because a bullet retires on the first contact anyway. If you’re modeling something that stays in contact — a rolling ball on a ramp — this parameter matters, and drainng only started events silently misses the moment things separate.

What raw Rapier does not give you

A short list, because these are the questions I’ve seen come up:

  • No wheel colliders. You do this yourself with raycasts, as above.
  • No character controller in the same file — Rapier ships one (KinematicCharacterController) and it’s fine, but it’s a separate object you have to know to look for.
  • No “get all colliders in radius” primitive. You use world.intersectionsWith(shape, translation, rotation, filter) for the shape query, or issue N raycasts if that’s easier.
  • No coroutines or scheduling. world.step() is one tick; if you want fixed-timestep with variable-render, you write the accumulator loop yourself.

Every one of those is a plugin @react-three/rapier gives you a nicer skin over. Every one of those is fifty lines of TypeScript to write yourself if you’re already in a vanilla three.js engine. Which one is right for your project depends on where the bulk of your existing scene code lives — reparenting a mature vanilla scene into R3F just to get declarative colliders is usually a bigger refactor than writing the raycast code by hand.

— Read Next —

推荐延伸阅读

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

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