Weapon Fire Pipeline

Every active gameplay frame, the bridge runs each equipped weapon through the same fixed pipeline. The pipeline is shared by auto and manual modes; manual mode just substitutes the aim source and gates entry on player input.

Per-frame entry point

The bridge’s weapons pass (bridge.ts, the diagBeginPass('weapons') block) drives the loop:

  1. WeaponManager.updateCooldowns(ship, dt) — decrements every weapon’s fireTimer toward 0 and decays flame heat. This runs whether the ship is stalled or not, so cooldowns stay current and weapons are ready the instant a stall ends.
  2. If ship.stalled is true (burnout), clear every weapon’s _manualTrigger and skip all firing for the frame. Cooldowns still ticked above, so no recovery debt.
  3. ShipRecoil.resetAnticipation() — wipes the per-frame anticipation accumulator so each warming weapon writes its own squeeze contribution.
  4. For each weapon in ship.weapons (and only while !ship.stalled): resolve its effective range, pick the fire mode branch, and call WeaponManager.fire(ship, world, weapon, dt, aimAngle, game, slotIndex).

The same dt is passed all the way through — fire-rate timing is deterministic regardless of frame length.

Mode branch (per weapon)

Each weapon carries fireMode: 'auto' | 'manual' (default auto). The bridge branches before calling fire.

Auto mode

  • Read the weapon’s targetMode from its def (closest, furthest, flanking, high_hp, low_hp; default closest).
  • Call WeaponManager.getAutoAimAngle(ship, world, weaponRange, slotIndex, gameTime, targetMode).
  • getAutoAimAngle queries the spatial enemy grid (enemyGrid.query) within range + 30, falls back to the full enemy list on grid miss, skips warping/dying/frozen enemies, then scores each candidate by mode (edge distance, sideways angle, hp).
  • If no enemy is found, aimAngle is undefined — but weapons flagged _alwaysForward (Barrier) substitute ship.angle so they keep firing as defensive cover.
  • Pass aimAngle into WeaponManager.fire. If it’s still undefined, the fire call short-circuits at the cooldown check (see below).

Manual mode

  • Manual mode bypasses target search entirely. The aim angle is ship.angle + (weapon.defaultAngle * DEG2RAD) — a fixed offset from ship facing baked into the weapon def.
  • setManualFireFlag(true) is set before the fire call (and cleared after), which tags any bullets the call spawns with _manualFire so the homing behaviors can carve out manual shots.
  • Two sub-cases:
    • Warmup in progress (weapon._warmingUp): keep ticking it every frame with the current ship-facing aim. Never reuse weapon._aimAngle here — that may hold a stale auto-aim from before the player switched to manual.
    • Trigger this frame (weapon._manualTrigger): clear the trigger flag (no buffering — discarded if cooldown isn’t ready), and if fireTimer <= 0, call fire once.
  • After a successful manual fire (weapon.fireTimer jumped up), record telemetry and emit the weapon_fire Sig event.

Inside WeaponManager.fire

Once invoked, fire runs the same stages for every weapon regardless of mode:

  1. Tick active warmup. If weapon._warmingUp is true, accumulate _warmupTimer by dt, recompute _warmupProgress, cache the current aimAngle into _aimAngle, and apply pre-fire anticipation (ShipRecoil.anticipate) scaled by shipPulseStrength. If the timer hasn’t reached getWarmupDuration(weapon), return immediately. If it has, clear warmup flags, set _preWarmFlash = 1.0, fall back to the cached aim if the live one is now undefined, and bail out (no fire this frame) if there’s still no aim — except orbit_ring weapons, which fire centered on the player.
  2. Cooldown / locked check. If not flashing and weapon.fireTimer > 0, return — the weapon is locked. Otherwise check aimAngle: if it’s undefined and the weapon isn’t orbit_ring, return (no target, stay dormant).
  3. Start warmup or fire immediately. If getWarmupDuration(weapon) > 0.05, set _warmingUp = true, seed _warmupArch/_warmupC1/_warmupC2 from the def for VFX, and return — the actual fire happens once warmup completes. Otherwise skip straight to fire by setting _preWarmFlash = 1.0.
  4. Resolve damage. Compute lvl (curved) and rawLvl (integer). Multiply baseline damage by the level damage curve, horizontals (damage_<tag> + damage_all), per-weapon dmgPerH, the early-level nerf, any per-weapon earlyLevelDamageBuff, plus the flat +1 per horizontal. Apply the artifact _empowerMult (decrementing _empowerShots) and, if the ship has the starpower exclusive state, multiply by STAR_POWER_DAMAGE_MULT = 2.0.
  5. Reset cooldown. Compute finalFireRate = baseRate * (1 + ratePerH * hDmg) * fireRateNerf. Set weapon.cooldownMax = 1 / finalFireRate (with FALLBACK_COOLDOWN = 0.5 when the rate is 0), apply per-shot fireRateJitter if defined, and assign weapon.fireTimer = weapon.cooldownMax. Crash on non-finite cooldowns — bad config beats a silent dead weapon.
  6. Dispatch to behavior path. Branch on def.behavior first (tesla_line, shield_arc, arc_mortar/plasma_mortar, orbit, flame_stream, cone_beam_dot, burst_fire, flame_drop, orbit_ring, beam_decay, artillery_rain, phoenix_aura, carpet_bomber, quad_burst), then def.collisionMode (beam_trace, chain_arc), and finally fall through to the default fireProjectiles path. Each dispatch spawns its own bullets/beams/zones with the resolved damage and aim.
  7. Per-weapon echoes (e.g. lgd_flak_rifle casing scatter) run after dispatch on the same fire call.

Stall, lock, and heat gates

  • Stall (burnout). When ship.stalled is true the bridge loop skips the entire per-weapon block, but updateCooldowns still runs first so fireTimer keeps ticking down. Manual triggers are cleared so the player can’t queue a shot through the stall.
  • Lock. weapon.fireTimer > 0 is the only “locked” gate — both modes return immediately if the cooldown isn’t elapsed. Manual triggers in this state are discarded silently with no buffering.
  • No-target gate. Auto mode passes undefined when nothing is in range; fire exits at the cooldown check without consuming the cooldown. _alwaysForward weapons (Barrier) and orbit_ring casts (Star Halo) bypass this gate.
  • Heat. Flame weapons accumulate _flameHeat while firing; updateCooldowns decays it back toward 0 once _flameLastFireTime is more than 0.2 s old. Heat itself doesn’t gate the fire call — it’s read by the flame-stream fire path to modulate output.
  • Star Power. The exclusive starpower state checked in step 4 doubles damage on every shot for its duration. It doesn’t change cadence — only the resolved damage.

Echo and delayed shots

After all weapons have fired this frame, the bridge separately advances tickDelayedShots(world, dt) to play out echo bullets queued by fireProjectiles (Echo Double / Echo Triple horizontals push delayed clones onto the _delayedShots list with their own staggered delays). These spawn on their own schedule and don’t re-enter the per-weapon pipeline.

Source

  • engine/weapons/weapons.tsWeaponManager.fire, updateCooldowns, getAutoAimAngle, setManualFireFlag, tickDelayedShots, and every behavior-specific fire* path.
  • engine/bridge.ts — the per-frame 'weapons' pass that drives the pipeline.