Weapon target acquisition (acquireRange + targetMode)
Every weapon picks its target at fire time by scanning enemies within its acquireRange and scoring them by its targetMode. No target in range, no fire — but cooldown still ticks so the weapon is ready the instant something enters range. Manual mode bypasses the scan and aims by player input.
The two fields
Both fields live on WeaponCoreSpec (data/weapons/_types.ts) and are required for every weapon:
acquireRange: WeaponStat— radius (world px) of the auto-aim search circle, centered on the ship. Stored as{ base, scaling }and resolved to a per-level value viagetWeaponStatAtLevel. Base values cluster in the 150–400 px range for normal weapons (shotgun 151, flame 165, rifle 183, cannon 204, mortar 296, magnetar 350, railgun 380); legendaries reach 600–2000 px (e.g. Star Halo 1300, Hellrain 2000). There is no hard-coded “600 default” — every weapon declares its own.targetMode: TargetMode— which enemy in range to pick. Union of'closest' | 'furthest' | 'flanking' | 'low_hp'in the type def, plus'high_hp'accepted by the runtime scorer.
How a target gets picked
The canonical scorer is WeaponManager.getAutoAimAngle(ship, world, range, …, targetMode) in engine/weapons/weapons.ts. On each fire attempt:
- Spatial-grid query.
enemyGrid.query(ship.x, ship.y, range + 30)returns enemy candidates near the ship. The grid is rebuilt every frame byCollisionResolver.rebuildEnemyGrid— a 30 px slop is added to the query so enemies sitting on the edge of a cell aren’t missed. - Grid-miss fallback. If the grid returns no candidates, the scorer falls back to the full
world.enemieslist. This handles boss fights where the grid can be stale (a weapon fires before the next rebuild) and the early frames after spawn. - Filter. Skip enemies that are
!alive,_frozenForLag,_dying, or still in warp-in (_spawnT > 0.15). - Edge-distance gate. Compute
edgeDist = max(0, centerDist − enemy.radius)and drop anything whereedgeDist > range. Measuring to the closest point on the enemy’s circle (not its center) means larger enemies effectively count as closer — chunky targets get picked up sooner. - Score by
targetMode. Lower score wins (the scorer tracksbestScore = Infinityand replaces onscore < bestScore):closest—score = edgeDist. Default.furthest—score = -edgeDist. Picks the farthest enemy still in range (mortar, railgun — punches the backline).flanking—score = -|sin(angleToEnemy - ship.angle)| * 1000 + edgeDist * 0.001. Prefers enemies perpendicular to ship facing; among equally-sideways enemies, the closer one wins. Used by cannon and revolver for side coverage.high_hp—score = -hp. Tankiest target.low_hp—score = hp. Wounded target (missile — finish them off).
- Aim. Return
atan2(target.y - ship.y, target.x - ship.x)to the caller, which feeds it into bullet/beam spawn.
What happens with no target
If the scorer returns undefined, the fire() path returns before warmup begins (see WeaponManager.fire in weapons.ts). The cooldown timer (weapon.fireTimer) is not reset here — updateCooldowns() decrements it every frame regardless, so the weapon is “loaded” the moment an enemy enters range and will warmup + fire on the next eligible frame.
One exception: weapons with behavior: 'orbit_ring' (the Star Halo legendary) fire continuously on timer without needing a target, because the cast is centered on the player.
Effective range vs raw acquireRange
getWeaponEffectiveRange(weapon) (used by the AI to decide when to start firing as enemies approach, not for the fire-time scorer itself) returns a slightly inflated value:
- Starts from
getWeaponStatAtLevel(def.acquireRange, lvl). - If
areaModeis set, expands to whichever is larger of:orbitRadius,acquireRange + 0.7 * blastRadius, oracquireRange + 0.3 * beamWidth. - Multiplies by 1.05 (a 5% generosity buffer — weapons should err on the side of firing).
- Clamped to a minimum of 50 px.
The fire-time scorer itself uses the raw acquireRange * rangeMult value, not this inflated number.
Multi-target variants
For weapons that fire more than one projectile or beam per volley, two specialized scorers reuse the same scoring rules but pick N distinct targets:
_getMultiTargetAngles(ship, world, range, count, …, targetMode)— scores all enemies in range with the same switch statement asgetAutoAimAngle, sorts ascending by score, takes up tocountunique targets, and returns one aim angle per target. Falls back to duplicating the primary angle when fewer thancountenemies exist. Used by non-spread weapons withprojectileCount > 1so extra shots spread across separate targets instead of stacking on one._getHomingTargets(ship, world, range, count)— same grid query and filter, but always sorts by squared center-distance and assigns specific enemy refs to each homing missile so they track separate targets. Cycles through the available list whencountexceeds live enemy count._fireDirectHitscan(…)— burst rifle’s pure direct-damage path. Same scoring as_getMultiTargetAnglesbut appliesdamageEnemydirectly per shot; leftover shots cycle back to the head of the sorted list so every shot lands while anything is in range.
Manual mode
When the player holds a manual-fire input, bridge.ts calls setManualFireFlag(true) before invoking WeaponManager.fire(). The caller passes its own aimAngle (joystick direction or pointer angle), so the getAutoAimAngle path is skipped entirely — no grid query, no target scoring, no acquireRange check. The flag also tags the bullet with _manualFire so the homing behavior knows to ramp up homing strength over the first half of the bullet’s lifetime, compensating for the player’s potentially imperfect aim.
Why edge distance instead of center distance
Boss enemies and mini-bosses have radii of 60–200 px. Measuring to the enemy’s center would mean a railgun with acquireRange: 380 can’t lock a 150 px-radius mid-boss whose hull is already overlapping the player. Edge distance fixes this — the ship can target anything whose surface is within range, which is the intuitive expectation.
Cross-references
- Target modes — full per-mode tuning rationale and which weapons use which mode: see
gameplay/concepts/target-modes. - Warmup — what happens after a target is acquired but before the bullet spawns: see
gameplay/concepts/weapon-warmup. - Spatial grid — how
enemyGridis built and queried: seeengine/collision/spatial-grid. - Manual mode — joystick/pointer input pipeline: see
gameplay/concepts/manual-fire-mode.