engine/physics/movement.ts
PURPOSE
Per-frame ship motion update — turning, thrust, drag, speed cap, heat accumulation/cooling, burnout (stall), shield regen, and invulnerability timers. Computes new velocity and angle from input and ship stats, then pushes the result to the Rapier physics body (or falls back to direct position writes if Rapier isn’t ready). Faithful TypeScript port of the legacy 09b-physics.js.
OWNS
Physics.update(dt)— top-level per-frame entry point invoked by the main loop.Physics._updateShield(dt)— shield regen state machine (delay → background fill → restore) and assorted hit/flash timers.Physics._updateInvuln(dt)— invulnerability countdown plus a wall-clock watchdog that force-clears stuck invuln after 5 seconds.- Stall spin-down behaviour (exponential angle decay at
0.18^dt, hard0.88per-step velocity damping). - Heat accumulation curve (logarithmic) and the past-95% asymptotic dampener with floor
HEAT_ASYMPTOTE_MIN. - Heat-boost thrust multiplier (
1.0 + 0.35 * hP) and burnout trigger at heat ≥ 100. - Turning lerp with shortest-arc normalization and snap-when-close behaviour, gated by
ship.rotates. - Turn-squash computation (
ship._turnSquash) derived from smoothed angular velocity. - Thrust application across all four
accelCurveshapes (linear,ease_in,ease_out,instant), including hovercraft mode where thrust usestargetAngleinstead ofangle. - Drag application across all
dragCurveshapes (see Pattern notes) and the low-speedstopSpringAmtglide weakening. - Soft speed cap via lerp toward
effectiveMaxplus the hardABSOLUTE_SPEED_CAP = 5000u/s ceiling. - Vehicle-feel camera shakes: one-shot stop-shake when speed crosses down through
STOP_SPEED_THRESHOLD, and continuous heat-shake aboveheatShakeThreshold. - Local constants:
STAR_POWER_BOOST = 1.20,STOP_SPEED_THRESHOLD = 15,HEAT_LOG_A = 12,HEAT_ASYMPTOTE_MIN = 0.3,SQUASH_SENSITIVITY = 0.025,MAX_SQUASH = 0.10,ABSOLUTE_SPEED_CAP = 5000. - Compatibility re-exports:
updateShipPhysics(dt),updateCamera(dt),shakeCamera(intensity, duration).
READS FROM
ship(core state) —vx,vy,x,y,angle,targetAngle,_prevAngle,_smoothAngVel,_turnSquash,_prevSpeed,thrust,maxSpeed,drag,turnSpeed,accelCurve,dragCurve,rotates,stopSpringAmt,stopShakeIntensity,heatShakeIntensity,heatShakeThreshold,heat,heatBurnRate,heatCoolRate,coolingAccel,stalled,stallSpin,burnoutSeverity,shield,shieldMax,shieldRegenTimer,shieldRegenDelay,shieldRegenRate,shieldRegenFillTime,_shieldBackgroundRegen,_shieldRecovering,shieldHitTimer,_shieldBrokenTimer,_hitFlash,_dmgWhiteFlash,_dmgBlueFlash,_hullPulse,_spawnIntroTimer,invulnerable,invulnTimer,_invulnWallTime,_dontStuckInvuln,exclusiveStateTimer,_shootingStarBoostTimer,_phoenixSpeedTimer,hp.playerInput.isThrusting— gates heat accumulation, thrust application, and stop-shake debouncing.game.phase— most behaviour only runs while'playing'; also gates the invuln watchdog.game._rawDt— wall-clock dt used by the invuln watchdog so freezes don’t accumulate.CFG.DRAG_BASE,CFG.SPEED_LERP,CFG.HEAT_COOL_MAX,CFG.HEAT_COOL_RAMP,CFG.HEAT_STALL_DMG— tunables from the central config.hasExclusiveState(ship, 'starpower')— drives Star Power thrust/maxSpeed boost and invuln behaviour.RapierWorld.isReady()— chooses between Rapier sync and direct-position fallback.
PUSHES TO
ship.vx,ship.vy— primary velocity outputs from thrust, drag, speed cap, and stall damping.ship.angle— updated by the turning lerp and by stall spin.ship.x,ship.y— only written in the Rapier-not-ready fallback path; otherwise Rapier owns position.ship.heat,ship.coolingAccel— accumulated by thrust, cooled when idle (with ramp), clamped 0–100.ship.stalled,ship.stallSpin— set on burnout, cleared when heat returns to 0.ship.hp,ship.shield— hp damaged on burnout (CFG.HEAT_STALL_DMG * burnoutSeverity), shield zeroed; partial shield refill in_updateShield.ship._prevAngle,ship._smoothAngVel,ship._turnSquash— turn-squash bookkeeping.ship._prevSpeed,ship._shootingStarBoostTimer,ship._phoenixSpeedTimer— per-frame timer/state bookkeeping.ship.shieldRegenTimer,ship._shieldBackgroundRegen,ship._shieldRecovering,ship.shieldHitTimer,ship._shieldBrokenTimer,ship._hitFlash,ship._dmgWhiteFlash,ship._dmgBlueFlash,ship._spawnIntroTimer,ship._hullPulse— shield/regen and damage-flash timers.ship.invulnerable,ship.invulnTimer,ship._invulnWallTime— invuln state and watchdog.RapierShip.syncToRapier(vx, vy, angle)— writes computed velocity and angle into the physics body each frame.Particles.burst(...)— three bursts on burnout (40 spark, 30 dark dust, 15 smoke).Juice.fire('shield_broken')— fired on burnout alongside the particle stack.Camera.shake(...)— one-shot stop-shake and continuous heat-shake.Sig.fire('stall_start', ...)— signal raised at burnout.
DOES NOT
- Does not integrate position when Rapier is ready — Rapier owns the position step in
loop.ts. The directship.x += vx*dtwrites are a boot/test fallback. - Does not read raw input devices — it consumes the already-resolved
playerInput.isThrustingandship.targetAngleset elsewhere by the input layer. - Does not apply damage from collisions, weapons, or environmental hazards. The only HP write is the burnout self-damage on stall entry.
- Does not handle weapon firing, cooldowns, or ammo.
- Does not run the camera follow/zoom update — it only triggers
Camera.shake. The compatibilityupdateCamerare-export forwards toCamera.update. - Does not own the Star Power, Phoenix, or shooting-star buff lifecycles. It only reads/decays the timers (
_shootingStarBoostTimer,_phoenixSpeedTimer) and consumes the multipliers. - Does not own the player-state machine — it delegates to
tickPlayerStatesinside_updateInvuln. - Does not draw anything; it pushes shake/particle/juice requests to other systems.
- Does not award invulnerability during shield recovery (commented contract: “NO invulnerability is granted at any point during shield recovery”).
Signals
- Emits
Sig.fire('stall_start', 0, 0, ship.heat, 0, '')at the moment heat reaches 100 and the ship enters burnout. - Emits
Juice.fire('shield_broken')alongside the burnout particle stack (shield is zeroed on burnout so the broken-shield juice plays). - Does not listen for any signals — buff timers are set by external systems (Phoenix, shooting-star trails, Star Power) and this file only decays them.
Entry points
Physics.update(dt)— main per-frame entry. Called by the engine loop.Physics._updateShield(dt)— invoked fromPhysics.updatein both the stalled-early-return path and the normal end-of-frame path.Physics._updateInvuln(dt)— invoked fromPhysics.updatein both paths; also callstickPlayerStates(ship, dt)for the player state machine.updateShipPhysics(dt)— compatibility wrapper that forwards toPhysics.update.updateCamera(dt)— compatibility wrapper that lazy-requires and callsCamera.update(dt).shakeCamera(intensity, duration)— compatibility wrapper that lazy-requirescamerastate andCamera, then writes shake fields directly oncamera.
Pattern notes
Drag curve shapes. All curves start from baseDragArg = max(0.001, 1 - ship.drag * CFG.DRAG_BASE), the per-frame velocity-retention multiplier from the legacy formula. speedNorm is current speed divided by maxSpeed, clamped 0–1.
exponential(default) — usesbaseDragArgunchanged. Velocity decays geometrically; classic feel.linear— subtracts a constant brake rate per frame (ship.drag * CFG.DRAG_BASE * 60 * dt) regardless of current speed, then back-computes the equivalent multiplier. Calibrated so time-to-stop matches the exponential curve at mid speeds.front_loaded—pow(baseDragArg, 0.6 + 1.0 * speedNorm). Exponent grows with speed → stronger brake at high speed, weaker at low. Arcade-car feel (hard pedal at the top).back_loaded—pow(baseDragArg, 0.6 + 1.0 * (1 - speedNorm)). Exponent shrinks with speed → weak brake high, strong low. Heavy-cargo feel (hard to stop, hard to start).overshoot— below 25% ofmaxSpeed, drag’s “loss component”(1 - baseDragArg)is scaled byglideMul = 0.4 + 2.4 * speedNorm. The ship glides further at low speed; above the threshold, drag behaves normally. Hovercraft drift.
A separate stopSpringAmt knob further weakens drag below STOP_SPEED_THRESHOLD = 15 u/s when not thrusting, with a floor of 0.05 on the spring multiplier so the ship still eventually stops.
DRAG_BASE constant. Lives in CFG (not in this file). 1 - ship.drag * DRAG_BASE is the per-step velocity-retention factor in the exponential default. Every drag curve is parameterized off this baseline so changing DRAG_BASE re-tunes all curves coherently.
Acceleration shapes. Thrust force is ship.thrust * thrustMod * heatMult * trailBoost * starBoost * phoenixBoost * dt. Where accelCurve is:
linear— applied unchanged.ease_in— multiplied by0.4 + 1.2 * speedFrac(0.4× at rest, 1.6× at top). Slow off the line, faster as speed builds.ease_out— multiplied by1.6 - 1.2 * speedFrac(1.6× at rest, 0.4× at top). Strong launch, tapers near top.instant— bypasses force application entirely; sets velocity directly tocos/sin(thrustAngle) * maxSpeed * (boosts).
For hovercraft hulls (ship.rotates === false), thrust direction is ship.targetAngle (raw input direction) rather than ship.angle — omnidirectional translation while the hull stays fixed.
Turn rate. Turning uses maxTurn = ship.turnSpeed * dt * 60 per frame (so turnSpeed is calibrated in radians-per-frame-at-60fps units). Angle diff is normalized to [-π, π] for shortest-arc; if |angleDiff| <= maxTurn the ship snaps to targetAngle, otherwise it advances by ±maxTurn. Final angle is re-normalized. Turning runs whenever game.phase === 'playing' and ship.rotates !== false — explicitly NOT gated on thrusting (pre-rotation while coasting is part of the legacy feel).
Speed cap layering. Two ceilings stack: (1) per-ship effectiveMax = ship.maxSpeed * heatMult * trailBoost * starBoost * phoenixBoost, applied via lerp(currentSpeed, effectiveMax, dt * CFG.SPEED_LERP) for a soft glide-down; (2) absolute ABSOLUTE_SPEED_CAP = 5000 u/s applied hard. The soft cap can be exceeded transiently (e.g. by impulse) and the absolute cap is the un-overridable ceiling that ram-damage scaling assumes.
Heat curve. Accumulation rate while thrusting: hRate = (0.10 + 0.55 * (1 - ln(1 + hP * 12) / ln(1 + 12))) * 1.5. Logarithmic — fast initial gain, asymptotic toward 0.10 * 1.5 at full heat. Past 95% heat, a quadratic dampener (1 - dampT)^2 (where dampT = (heat - 95) / 5) crushes the gain, with a floor of HEAT_ASYMPTOTE_MIN = 0.3 heat/sec so the bar always creeps to 100%. Cooling uses a ramp: coolingAccel climbs by dt * CFG.HEAT_COOL_RAMP per idle frame up to CFG.HEAT_COOL_MAX, and resets to 1 on every thrusting frame.
Burnout / stall. At heat >= 100: stalled = true, signal stall_start fires, shield is zeroed, hp loses CFG.HEAT_STALL_DMG * burnoutSeverity, stallSpin = 12 + rand*6 rad/s, particles + juice play, function returns. While stalled, spin decays as stallSpin *= 0.18^dt, heat cools at 90% rate, velocity is hard-damped by 0.88 per step (NOT the ship’s normal drag — intentionally much harsher). Stall exits when heat <= 0.
Rapier sync pattern. Velocity and angle are computed in this file; RapierShip.syncToRapier(vx, vy, angle) pushes them to the rigid body, and the actual integration to position happens in the Rapier step inside loop.ts. The if (RapierWorld.isReady()) { sync } else { x += vx*dt; y += vy*dt } block at the end of both the stalled and non-stalled paths is the fallback for tests and boot before Rapier is initialized.
Invuln watchdog. _invulnWallTime accumulates game._rawDt (wall-clock, not scaled game time) whenever dt > 0. Past 5s with game.phase === 'playing' and Star Power inactive, it logs [BUG] ship.invulnerable stuck and force-clears. The ship._dontStuckInvuln flag (set by sandbox/gauntlet runs) bypasses the watchdog entirely and runs an early return before any wall-time accumulation, so a single stale frame can’t trip the warning before the next mode configuration clears the state.