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.
TL;DR — Tank Battle’s throttle path is five hops. Keyboard/touch →
throttleInandsteerIn, 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-sidetargetForward. Steer × gear-scaled coefficient → per-sidediff.targetL/R = targetForward ± diff, then a slew-rate limiter (acc = 12 * dt) approaches them. The finalp.leftTrackandp.rightTracknumbers 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) andonKeyshift 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) orpivotSpeed(default 6 km/h). HighersteerCoef→ sharper turns but the tracks fight harder against the ground. HigherpivotSpeed→ 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”.