Multi-target acquisition

What it is

Multi-target acquisition is the per-shot retargeting rule used by zero-spread, non-perpendicular weapons when their volley contains more than one shot. Instead of fanning the extras angularly around the aim direction, each extra shot peels off and is aimed at a different enemy. The first shot still fires straight on the resolved aim angle; each subsequent shot picks the next-best enemy from the same candidate pool that aim-target selection drew from. The result is that extras don’t waste damage on the primary target — they spread the volley across multiple enemies whenever multiple enemies exist.

The _multiTargetAngles path

In src/starship-survivors/engine/weapons/weapons.ts, the projectile fire function declares a per-fire local _multiTargetAngles: number[] | null. When the four trigger conditions all hold (see below), the engine calls _getMultiTargetAngles(ship, world, acquireRange, count, undefined, gameTime, 'closest') once and stores the returned array. The subsequent shot loop reads _multiTargetAngles[i] for each shot index i and uses that value as the launch heading instead of aimAngle. The beam-fire function uses the same helper for any spec with totalBeams > 1 and the regular (non-hideBeamLine) beam render path, producing one beam heading per shot index.

How it differs from fan-spread

Fan-spread (also called angular spread, used by shotgun, revolver, Trailblazer, and any weapon with spreadDeg > 0 or randomSpread = true) places every shot in the volley at a different angle around the same aim point. The aim point itself is a single enemy, picked once per fire by the primary auto-aim resolver. All shots converge on that one target — they just arrive on slightly different vectors.

The _multiTargetAngles path inverts that. Every shot in the volley is aimed at a different enemy, each with its own heading computed from the ship-to-enemy vector. There is no angular cone, no symmetric pair offset, no (i / (count - 1)) - 0.5 interpolation — just one heading per shot, drawn from the top N candidates in the multi-target resolver’s scored list.

PropertyFan-spread_multiTargetAngles
Aim pointOne enemyN enemies (one per shot)
Heading mathaimAngle + t * spreadRadatan2(target_i.y - ship.y, target_i.x - ship.x)
Trigger fieldspreadDeg > 0 or randomSpread = truespreadDeg = 0 AND randomSpread unset AND perpendicularLayout unset AND count > 1
Resolver callNone (uses pre-resolved aimAngle)_getMultiTargetAngles(...) once per fire event
Per-shot timingSimultaneousStaggered (see Stagger and homing)
Damage distributionConcentrated on primary targetSpread across N targets

The shot loop branches in source order: _multiTargetAngles[i] wins if the array is non-null and i is in range, otherwise randomSpread, then symmetric fan, then default to aimAngle. Echo-volley extras spawned within the same fire event re-use the same _multiTargetAngles array, so every echo shot also retargets across enemies rather than fanning.

Which weapons opt in

A weapon takes the _multiTargetAngles path on a given fire event when all four runtime conditions evaluate true at fire time. The opt-in is purely a function of the resolved volley shot count and the spec flags — there’s no per-weapon allowlist.

Spec field on the firing weaponRequired state for opt-in
Volley shot count (count)Greater than 1
spreadDeg (after radian conversion)0
randomSpreadFalse or unset
perpendicularLayoutFalse or unset

Weapons whose specs satisfy the three flag requirements and whose volley count reaches 2 or more (via level-based base count, More Projectiles step ladder, or the double-shot proc): rifle, coilgun, missile, railgun, mortar, defy (beam path), and burst’s beam-line path. Burst’s hideBeamLine direct-hitscan branch uses a parallel scoring path inside _fireDirectHitscan rather than _multiTargetAngles itself, but the target-pick logic is identical by design — the comment in source states it scores “the same way _getMultiTargetAngles does.”

Weapons whose specs disqualify them regardless of shot count: shotgun and revolver (non-zero spreadDeg), Trailblazer (randomSpread = true), cannon (perpendicularLayout = true), and any orbit, sweep, or fire-ring weapon whose extras are managed as orbit-count rather than volley shots.

Runtime behavior

Step in fire eventAction on _multiTargetAngles
1 — Resolve volley countCompute base count + More Projectiles bonus + double-shot proc into count
2 — Evaluate triggerIf all four trigger conditions hold, set _multiTargetAngles to the resolver result, else leave it null
3 — Score candidatesResolver queries enemyGrid with range + 30 padding, filters out _frozenForLag, _dying, and warp-in enemies whose _spawnT ≤ 0.15 s, and scores survivors by closest mode
4 — Sort and pickSort ascending by score, take top min(count, scored.length) enemies, push atan2 heading for each
5 — FallbackWhile the angles array is shorter than count, push a copy of angles[0] (or 0 radians if the array is empty)
6 — Shot loopFor each i in 0..count, branch on _multiTargetAngles[i] first; fall through to spread or default aimAngle otherwise
7 — StaggerFirst shot fires this frame; shot i is delayed by i × staggerDelay (see Stagger and homing)
8 — Echo procIf echo extras roll, the same _multiTargetAngles array is re-read for the echo volley with its own per-shot delay offset

The array is allocated once per fire event and discarded when the fire function returns. There is no caching across fires, no per-weapon memoization, and no carryover when the trigger conditions change between fires (for example, when a More Projectiles drop lifts the volley above one shot mid-run).

The trigger

The projectile firing path checks four conditions every fire event. All four must be true for the multi-target path to be taken; if any fails, the volley uses spread, random spread, or perpendicular layout instead.

ConditionSpec fieldRequired value
More than one shot in the volley(derived from base count + More Projectiles bonus + double-shot proc)shot count > 1
No angular spread conespreadDeg0 (or unset)
No random-spread flagrandomSpreadfalse (or unset)
No perpendicular layout flagperpendicularLayoutfalse (or unset)

When all four hold, the engine calls the multi-target angle resolver before the shot loop runs and stores one heading per shot index. When any fail, the shot loop falls back to angular fan math or perpendicular spawn-position offsets.

Weapons whose base count is already greater than one (such as Trailblazer’s three rounds per fire) skip the trigger as soon as spreadDeg > 0 or randomSpread = true. Weapons whose base count is one only enter the multi-target path once a More Projectiles stack lifts them past one shot per volley.

How targets are picked

The multi-target angle resolver runs the same enemy filter and scoring pass used for primary auto-aim, then returns one heading for each shot in the volley. The candidate pool, scoring, and fallback all follow these rules.

StepRule
Candidate poolEvery living enemy whose edge-to-edge distance from the ship is at most the weapon’s acquire range. Frozen-for-lag enemies, dying enemies, and warp-in enemies in their first 0.15 s are excluded.
ScoringThe scoring function ranks every candidate by the score formula for the resolver’s target mode. The projectile firing path always passes closest to the resolver — see Target-mode override below.
SelectionThe top N candidates by score are picked, where N is the volley shot count. Each selected enemy contributes one heading: the angle from the ship to that enemy’s center at fire time.
OrderShot index 0 gets the best-scoring enemy, shot index 1 gets the second-best, and so on. For closest-mode scoring this means the primary shot heads at the closest enemy, the first extra at the second-closest, etc.
Duplicate primary fallbackIf the candidate pool is smaller than the volley shot count, the resolver fills the remaining headings with a copy of the primary heading (shot index 0). With zero enemies in range, the primary heading itself is 0 radians and every shot fires due east.

The shot loop applies the resolver’s per-shot heading to each shot in the volley. The first shot fires on the same frame as the fire event; each later shot is delayed by the weapon’s stagger value (see Stagger and homing below).

Target-mode override

The projectile firing path hard-codes the target mode passed to the multi-target resolver as closest, regardless of what the weapon’s spec sets for its primary-aim target mode. This is a deliberate choice — extras spread across the N closest enemies even on weapons whose primary aim uses furthest, low-HP, high-HP, or flanking modes. The primary shot still fires toward the enemy picked by the spec’s target mode (it uses the pre-resolved aim angle, not the resolver’s first heading), so weapons whose primary aim disagrees with closest will see their first shot diverge from the resolver’s shot-0 heading when there’s more than one enemy in range. The beam firing path (defy) takes the same closest default by passing no target mode to the resolver at all.

Stagger and homing

Multi-target volleys never fire simultaneously. The shot loop introduces a stagger delay between successive shots so the volley reads as a sequence rather than a wall of fire.

QuantityValue
Stagger delay between shotsThe weapon’s staggerDelaySec, or 0.1 seconds when the field is unset
First-shot timingInstant (no delay)
Shot i timingFirst-shot time + i × stagger delay

Homing weapons run an additional per-shot target lock alongside multi-target headings. The homing path picks the N closest enemies (its own resolver) and assigns one as the dedicated chase target for each missile. That chase target persists across the missile’s entire lifetime — it only re-locks if the assigned enemy dies. The angle from the multi-target resolver is used as the launch heading; the in-flight seek behavior pulls the missile toward its dedicated chase target after the launch warm-up period. Each homing missile also gets a small ±6 px perpendicular spawn offset so the staggered launch reads as a salvo emerging from the ship’s flanks rather than its centerline.

Which weapons use it

Every base-tag weapon whose spreadDeg field is zero or absent, whose randomSpread flag is unset, and whose perpendicularLayout flag is unset triggers the multi-target path once the volley contains more than one shot. The weapons below match all three conditions and have at least one mechanism (level-based base count > 1 or non-zero entry on the More Projectiles step ladder) that lifts the volley past one shot.

WeaponFamilyspreadDegPrimary targetModeHow extras enter the volley
Rifleprojectile0closestMore Projectiles stack
Coilgunprojectile0 (set)closestMore Projectiles stack
Missilehomingunset (defaults to 0)low-HPLevel-based count (2 at L1, 3 at L5, 4 at L10, 5 at L15, 6 at L20) plus More Projectiles
Railgunsniperunset (defaults to 0)variesMore Projectiles stack
Mortarexplosiveunset (defaults to 0)variesMore Projectiles stack
Flame (single embers path)projectileunsetNot applicable — flame uses coneWidth random spread, so it never enters multi-target
Disc, sweep, fire-ringorbit / sweepunset (defaults to 0)Bonus shots become extra orbiting blades, not extra fan shots — these weapons reach the multi-target check but their volley is one shot per fire event and their orbit count is managed by separate logic
Mega Bullet (lgd_autocannon legendary)projectile0 (set)closestCadence pattern fires three rounds across consecutive frames; each frame’s shot count is one, so multi-target doesn’t trigger from cadence — only from More Projectiles, which legendaries ignore
Wave Gun (lgd_railslug legendary)projectile0 (set)closestSingle shot per fire; multi-target doesn’t trigger

In practice, the workhorse weapons that consistently exercise the multi-target path are rifle, coilgun, missile, and railgun. Each one starts at one shot per fire and reaches the multi-shot trigger only when the More Projectiles stack accumulates enough ranks to lift its step-table entry above zero (see More Projectiles fan for the per-weapon step ladders). Missile is the exception: its level-based base count already rises above one starting at L1, so missile fires through the multi-target path on every fire event from the start of the run.

The four weapons that explicitly opt out of multi-target despite a zero spread field are cannon (perpendicular layout takes the volley), shotgun and revolver (non-zero spreadDeg), and Trailblazer (randomSpread = true). Their extras use the angular fan or perpendicular line primitives instead.

EXTRACT-CANDIDATEs

  • EXTRACT-CANDIDATE: weapons/rifle.md — Per-weapon page should call out that rifle’s multi-shot behavior comes entirely from the More Projectiles stack and walk through a concrete example of extras peeling off to the second-closest, third-closest enemies on a 0.3 s stagger.
  • EXTRACT-CANDIDATE: weapons/missile.md — Per-weapon page should explain the dual resolution: launch heading from the multi-target resolver (closest-based), per-missile homing chase target from the homing resolver (also closest-based), and note that the spec’s low_hp target mode applies only to the primary aim angle, not to extras.
  • EXTRACT-CANDIDATE: weapons/coilgun.md — Per-weapon page should note the 0.3 s stagger and that the violet bolts visibly split toward separate enemies in a wave once the More Projectiles stack lifts the volley above one shot.
  • EXTRACT-CANDIDATE: weapons/railgun.md — Per-weapon page should walk through the More Projectiles step ladder for railgun and show how the long acquire range puts more candidates in the multi-target pool than other weapons.
  • EXTRACT-CANDIDATE: concepts/target-modes.md — Target-modes concept page should add a section noting that the multi-target path overrides the weapon’s target mode with closest for extras, and that this is a known divergence from primary-aim behavior for furthest, low-HP, high-HP, and flanking weapons.
  • EXTRACT-CANDIDATE: concepts/more-projectiles-fan.md — More-projectiles concept page should cross-link this page as the canonical reference for the multi-target arrangement row in its arrangement-mode table.
  • EXTRACT-CANDIDATE: artifacts/echo-generator.md — Echo Generator artifact page should note that adding More Projectiles stacks to a zero-spread weapon converts its volley from concentrated fire to multi-target spread, fundamentally changing single-target vs. crowd-clear identity.