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, hard 0.88 per-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 accelCurve shapes (linear, ease_in, ease_out, instant), including hovercraft mode where thrust uses targetAngle instead of angle.
  • Drag application across all dragCurve shapes (see Pattern notes) and the low-speed stopSpringAmt glide weakening.
  • Soft speed cap via lerp toward effectiveMax plus the hard ABSOLUTE_SPEED_CAP = 5000 u/s ceiling.
  • Vehicle-feel camera shakes: one-shot stop-shake when speed crosses down through STOP_SPEED_THRESHOLD, and continuous heat-shake above heatShakeThreshold.
  • 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 direct ship.x += vx*dt writes are a boot/test fallback.
  • Does not read raw input devices — it consumes the already-resolved playerInput.isThrusting and ship.targetAngle set 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 compatibility updateCamera re-export forwards to Camera.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 tickPlayerStates inside _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 from Physics.update in both the stalled-early-return path and the normal end-of-frame path.
  • Physics._updateInvuln(dt) — invoked from Physics.update in both paths; also calls tickPlayerStates(ship, dt) for the player state machine.
  • updateShipPhysics(dt) — compatibility wrapper that forwards to Physics.update.
  • updateCamera(dt) — compatibility wrapper that lazy-requires and calls Camera.update(dt).
  • shakeCamera(intensity, duration) — compatibility wrapper that lazy-requires camera state and Camera, then writes shake fields directly on camera.

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) — uses baseDragArg unchanged. 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_loadedpow(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_loadedpow(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% of maxSpeed, drag’s “loss component” (1 - baseDragArg) is scaled by glideMul = 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 by 0.4 + 1.2 * speedFrac (0.4× at rest, 1.6× at top). Slow off the line, faster as speed builds.
  • ease_out — multiplied by 1.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 to cos/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.