Vol. 07 · Dispatch2026-07-06

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.

by MetaWorldOS Engineering
Three.jsRapierPhysicsTypeScriptGameDev

TL;DR — Tank Battle’s throttle path is five hops. Keyboard/touch → throttleIn and steerIn, both in [-1, 1]. Gear index → speed cap (GEAR_KMH: [-12, 0, 8, 16, 26, 38, 52] for R, N, 1…5). Cap × throttle → per-side targetForward. Steer × gear-scaled coefficient → per-side diff. targetL/R = targetForward ± diff, then a slew-rate limiter (acc = 12 * dt) approaches them. The final p.leftTrack and p.rightTrack numbers are what the 4-anchor suspension reads to compute drive impulses. And the auto-shift feedback loop uses commanded track speed, not physics body velocity, so a tank pinned against a wall will happily upshift while going nowhere.

Code: src/features/gamecenter/games/tank-battle/engine.ts, updatePlayer (line 1801) and onKey shift handling (line 1706).

The suspension post covered how a p.leftTrack and p.rightTrack number turn into physical impulses at four anchor points on the tank body. This post is the chain in the other direction: how a W keypress becomes those two numbers. It’s more interesting than “throttleIn * MAX_SPEED” because tanks steer with differential track speed, not steering angle, and because the vehicle has an H-pattern gearbox that gates how fast the tracks are allowed to spin.

The gear table

// H-pattern gearbox. Index into GEAR_KMH: 0=R, 1=N, 2..6 = 1st..5th.
const GEAR_KMH: number[] = [-12, 0, 8, 16, 26, 38, 52]
const kmhToMs = (v: number) => v / 3.6

Seven entries. Reverse tops out at 12 km/h in the wrong direction, neutral is zero, and the five forward gears go 8 / 16 / 26 / 38 / 52 km/h. The values aren’t a linear ramp — the ratio between successive gears grows (1st→2nd is 2×, 4th→5th is ~1.4×), which is what a real H-pattern gearbox does. In neutral the tank commands zero forward, but as you’ll see below it can still spin its tracks against each other for on-the-spot pivoting.

gearIdx starts at 1 (neutral) on spawn or reset. It’s mutated in exactly two places: the onKey handler for W/S (manual shift), and inside updatePlayer on the auto-shift branch.

Input capture

Every frame, updatePlayer collects two normalized inputs:

let throttleKey = 0
if (this.keys.has('w') || this.keys.has('arrowup')) throttleKey += 1
if (this.keys.has('s') || this.keys.has('arrowdown')) throttleKey -= 1
let steerKey = 0
if (this.keys.has('a') || this.keys.has('arrowleft')) steerKey -= 1
if (this.keys.has('d') || this.keys.has('arrowright')) steerKey += 1

const mvLen = this.moveVec.length()
const throttleIn = mvLen > 0.05 ? clamp(this.moveVec.y, -1, 1) : throttleKey
const steerIn = mvLen > 0.05 ? clamp(this.moveVec.x, -1, 1) : steerKey

The moveVec is a joystick vector filled by touch input. Priority: joystick if it’s off-center (>0.05 dead zone), otherwise keyboard. That means a player using both simultaneously gets joystick behavior — deliberately, because on hybrid devices (a laptop with a touchscreen), a keyboard input while the finger is on the joystick is usually accidental.

Both signals end up in [-1, 1]. throttleIn = 1 is full forward. steerIn = 1 is right. Nothing about “gear” or “speed” enters at this layer.

From gear to target track speed

const gearKmh = this.gearKmh[this.gearIdx]
const targetForward = kmhToMs(gearKmh) * clamp(throttleIn, this.gearIdx === 0 ? -1 : 0, 1)

Two things going on. gearKmh is signed — negative for reverse, zero for neutral, positive for forward gears. kmhToMs(gearKmh) gives m/s.

The clamp is the interesting part. In every gear except reverse, throttle is clamped to [0, 1] — you can’t press S to add negative throttle to a forward gear. In reverse (gearIdx === 0), the clamp is [-1, 1], letting S become throttle-in-reverse. This is why in the game, “reverse” isn’t just a matter of pressing S while stopped — you have to actually shift into R with S first, and only then does S become “reverse throttle”.

Result: targetForward is a signed m/s target for how fast both tracks want to move together.

From steer to differential

Two paths. In neutral, the tank pivots on the spot:

const isNeutral = this.gearIdx === 1
const pivotSpeed = kmhToMs(6)   // 1.67 m/s
if (isNeutral) {
  targetL = steerIn * pivotSpeed
  targetR = -steerIn * pivotSpeed
}

steerIn = +1 (right) → targetL = +1.67, targetR = -1.67. Left track goes forward, right track goes backward, tank yaws right on its own axis. This matches how a real tank does an in-place turn.

In every other gear, the steer becomes a fraction of gear speed subtracted from one side and added to the other:

} else {
  const diff = kmhToMs(Math.abs(gearKmh)) * this.steerCoef * steerIn
  targetL = targetForward + diff
  targetR = targetForward - diff
}

steerCoef is 0.55 by default (with a modding hook that scales by merged.turnTorque / 0.35, letting a config tweak turning responsiveness). At 3rd gear (gearKmh = 26, kmhToMs = 7.2 m/s), full-right steer produces diff = 7.2 * 0.55 * 1 ≈ 4.0 m/s. targetL = 7.2 + 4.0 = 11.2, targetR = 7.2 - 4.0 = 3.2. Left faster, right slower, tank yaws right while continuing to move forward. The tighter the turn (higher |steerIn|) the more asymmetric the track speeds.

The Math.abs(gearKmh) matters — in reverse, we want steering to feel identical (right stick still turns you right, geometrically), and using the signed value would flip the sign of the differential and give you inverted steering. Absolute value keeps it consistent.

The slew-rate limiter

Tank tracks aren’t massless. The commanded target speeds don’t get applied to p.leftTrack and p.rightTrack directly; they’re approached at a bounded rate:

const acc = 12 * dt   // 12 m/s² per axis
p.leftTrack += clamp(targetL - p.leftTrack, -acc, acc)
p.rightTrack += clamp(targetR - p.rightTrack, -acc, acc)

Twelve m/s² is the rate at which the commanded track speed can change. Not the tank’s acceleration — that’s determined by mass, drive force, friction, all downstream. This is a slew rate on the driver’s-input signal itself. Without it, a full-throttle keypress would step-jump both tracks from 0 to 14 m/s in a single frame, and the physics-side anchor impulses would try to yank the tank instantaneously up to that speed, which would either lift the front of the tank on the wheelie or slip the tracks entirely.

12 m/s² is the value where key presses feel snappy but not violent. Lower would feel spongy; higher would introduce the yank-and-slip artifact.

The consumer

That’s where the input chain ends. p.leftTrack and p.rightTrack are m/s values, and they’re what the anchor loop reads:

const isFront = i < 2
const isLeft = (i % 2 === 0)
const trackSpeed = isLeft ? p.leftTrack : p.rightTrack
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,
)

Every anchor gets the track speed for its side. The impulse magnitude is trackSpeed * DRIVE_FORCE_COEF * driveWeight. This is not force = mass × acceleration; it’s a proportional response — faster commanded tracks apply larger impulses per frame. The physical acceleration that emerges depends on mass, ground friction, hill grade, and everything else the anchor loop is contending with.

Auto-shift, and one honest quirk

The gearbox is manual by keystroke (W upshifts, S downshifts) but also has an autoshift branch inside updatePlayer:

if (this.gearIdx >= 2 && this.gearIdx < this.gearKmh.length - 1) {
  const cap = this.gearKmh[this.gearIdx]
  const speed = this.playerSpeedKmh()
  if (throttleIn > 0.7 && speed > cap * 0.95) this.gearIdx++
}
if (this.gearIdx > 2) {
  const speed = Math.abs(this.playerSpeedKmh())
  const prev = this.gearKmh[this.gearIdx - 1]
  if (speed < prev * 0.6) this.gearIdx--
}

Upshift when: current gear is 1st through 4th, throttle is deep (>0.7), current speed is above 95% of the current gear’s cap. Downshift when: current gear is 3rd or higher and speed dropped below 60% of the previous gear’s cap.

Here’s the quirk. playerSpeedKmh is not the physics body’s velocity:

private playerSpeedKmh(): number {
  const p = this.player
  return ((p.leftTrack + p.rightTrack) / 2) * 3.6
}

It’s the average commanded track speed, converted to km/h. The commanded track speed is what the driver asked for, not what the physics body actually achieved.

If a tank is fully accelerated in 5th gear on flat ground with nothing in front of it, commanded and achieved will be very close — the anchor drive impulses successfully accelerated the body up to the commanded speed. But if the tank drives into a wall, or into a slope steep enough that friction can’t push it up, the physics body’s velocity drops toward zero while the commanded track speed stays at whatever the driver requested. In that case playerSpeedKmh reports the request, not the reality.

Practically, this shows up as: a tank pushed against a wall while the driver holds W will keep upshifting (because the commanded speed keeps rising through gear caps) even though the tank isn’t moving. Once the wall clears, the tank is already commanding 5th-gear track speeds and takes off.

That’s not a bug we’ve seen worth fixing. It matches how you’d feel driving a tank in reality — you press the throttle, the transmission does what it does, the fact that you’re stuck against a wall isn’t information the transmission has. If we wanted the “gear reflects real speed” behavior, we’d substitute body.linvel().length() * 3.6 for the commanded average, and the whole thing would gate on Rapier’s velocity report. It’d be one line, and we’d lose this “fun” property.

Where in the chain to intervene

If you were adjusting the game’s feel, each step of this chain is a different lever:

  • Slower/faster shifting response — change the auto-shift thresholds (0.95 for upshift, 0.6 for downshift). Currently the tank shifts up eagerly and downshifts reluctantly, which reads as “willing to hold gears”.
  • Tighter/looser turning — change steerCoef (default 0.55) or pivotSpeed (default 6 km/h). Higher steerCoef → sharper turns but the tracks fight harder against the ground. Higher pivotSpeed → in-neutral pivoting is faster.
  • Snappier/spongier controls — change acc (default 12). This is the “the commanded track speed responds slower than a hair-trigger” knob.
  • Different top speed — replace GEAR_KMH. The values are consumed only in this one file, so a whole different gearbox character (long-legged, short-legged, more or fewer gears) is a table swap.
  • Different drive-force coupling — change DRIVE_FORCE_COEF (in the anchor loop). This is separate from the input chain — if the tank feels underpowered even at max commanded track speed, this is the knob.

The chain is only worth this much layering because a tank isn’t a car. If it were a car we’d have “throttle × max_force → engine impulse” and “steer angle → constraint on wheels” and be done. Tanks steer via track differential, and track differential lives at the same layer as track speed, so they have to be computed together — and the gear-cap has to gate that computation, or else you’d lose the whole feel of “1st gear is careful, 5th gear is out of control”.

— Read Next —

Recommended Dispatches

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

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.

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

Get an email when we publish a new post — engineering deep-dives, ~once a month, no marketing.