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.
| Property | Fan-spread | _multiTargetAngles |
|---|---|---|
| Aim point | One enemy | N enemies (one per shot) |
| Heading math | aimAngle + t * spreadRad | atan2(target_i.y - ship.y, target_i.x - ship.x) |
| Trigger field | spreadDeg > 0 or randomSpread = true | spreadDeg = 0 AND randomSpread unset AND perpendicularLayout unset AND count > 1 |
| Resolver call | None (uses pre-resolved aimAngle) | _getMultiTargetAngles(...) once per fire event |
| Per-shot timing | Simultaneous | Staggered (see Stagger and homing) |
| Damage distribution | Concentrated on primary target | Spread 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 weapon | Required state for opt-in |
|---|---|
Volley shot count (count) | Greater than 1 |
spreadDeg (after radian conversion) | 0 |
randomSpread | False or unset |
perpendicularLayout | False 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 event | Action on _multiTargetAngles |
|---|---|
| 1 — Resolve volley count | Compute base count + More Projectiles bonus + double-shot proc into count |
| 2 — Evaluate trigger | If all four trigger conditions hold, set _multiTargetAngles to the resolver result, else leave it null |
| 3 — Score candidates | Resolver 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 pick | Sort ascending by score, take top min(count, scored.length) enemies, push atan2 heading for each |
| 5 — Fallback | While the angles array is shorter than count, push a copy of angles[0] (or 0 radians if the array is empty) |
| 6 — Shot loop | For each i in 0..count, branch on _multiTargetAngles[i] first; fall through to spread or default aimAngle otherwise |
| 7 — Stagger | First shot fires this frame; shot i is delayed by i × staggerDelay (see Stagger and homing) |
| 8 — Echo proc | If 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.
| Condition | Spec field | Required value |
|---|---|---|
| More than one shot in the volley | (derived from base count + More Projectiles bonus + double-shot proc) | shot count > 1 |
| No angular spread cone | spreadDeg | 0 (or unset) |
| No random-spread flag | randomSpread | false (or unset) |
| No perpendicular layout flag | perpendicularLayout | false (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.
| Step | Rule |
|---|---|
| Candidate pool | Every 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. |
| Scoring | The 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. |
| Selection | The 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. |
| Order | Shot 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 fallback | If 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.
| Quantity | Value |
|---|---|
| Stagger delay between shots | The weapon’s staggerDelaySec, or 0.1 seconds when the field is unset |
| First-shot timing | Instant (no delay) |
Shot i timing | First-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.
| Weapon | Family | spreadDeg | Primary targetMode | How extras enter the volley |
|---|---|---|---|---|
| Rifle | projectile | 0 | closest | More Projectiles stack |
| Coilgun | projectile | 0 (set) | closest | More Projectiles stack |
| Missile | homing | unset (defaults to 0) | low-HP | Level-based count (2 at L1, 3 at L5, 4 at L10, 5 at L15, 6 at L20) plus More Projectiles |
| Railgun | sniper | unset (defaults to 0) | varies | More Projectiles stack |
| Mortar | explosive | unset (defaults to 0) | varies | More Projectiles stack |
| Flame (single embers path) | projectile | unset | — | Not applicable — flame uses coneWidth random spread, so it never enters multi-target |
| Disc, sweep, fire-ring | orbit / sweep | unset (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) | projectile | 0 (set) | closest | Cadence 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) | projectile | 0 (set) | closest | Single 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’slow_hptarget 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 withclosestfor 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.