What this is
The single per-frame path that turns a player-bullet-on-enemy contact into a damage event plus its visual and gameplay consequences. One resolver (CollisionResolver) owns the order of operations across every weapon family: standard projectiles, beam traces, chain arcs, target-locked missiles, Tesla line segments, and shield arcs. Bullets also carry a list of _behaviors (strings) that look up handler objects in a registry; the registry defines update, onDeath, and onHit slots so per-bullet logic can extend or modify the base resolver path.
The resolver is rebuilt once per frame against a spatial grid of all alive, tangible enemies. Every collision pass — bullet/enemy, enemy-bullet/player, ship/enemy body — queries that grid for candidates before doing narrow-phase distance checks.
The resolution order
The standard projectile path (first_hit, target_only, pierce_all, default) inside resolvePlayerBulletEnemyCollisions runs every step below in this order for each new hit:
| Order | Step | Notes |
|---|---|---|
| 1 | Add enemy to b.hits set | Prevents the same bullet from re-hitting the same enemy. |
| 2 | Stamp _lastDmgTag on enemy | Pulled from WEAPON_MAP[weaponId].damageTag; falls back to bullet. |
| 3 | damageEnemy(e, b.dmg, ...) | Applies shred, affix filters, boss caps, knockback, life steal, floating damage number, and fires damage_dealt. |
| 4 | Fire bullet_hit signal | Carries enemy eid, damage, and primary damage tag. |
| 5 | Fire secondary bullet_hit | Only when the weapon spec defines secondaryDamageTag distinct from the primary tag. |
| 6 | Telemetry record | telemetry.recordWeaponHit(weaponId, dmg) when weaponId is present. |
| 7 | _applyBonusExplosion | Currently a no-op stub (bigger-area horizontal removed in v3.26). |
| 8 | _applyExplosiveBlast | AoE damage and VFX for arch === 'explosive' || 'homing'; bypasses primary target via b.hits. |
| 9 | _spawnImpactVfx | Impact ring, per-weapon hit shake, per-tag flourish (bullet ricochet, energy arc, fire ember, bomb shrapnel), arch lingers (sniper scar, chain arc, beam exhaust), blood splatter on bullet-tag, legendary echo flame. |
| 10 | Pierce decrement or bullet death | pierceCount-- if any remain; otherwise spawn streak PostFx then set b.l = 0. |
The beam, line, and chain paths follow a trimmed version of this order — they run damage, telemetry, bonus-explosion, and impact VFX, but they do not decrement pierce (beam dies after its single trace; chain hands off to a per-frame behavior; line uses per-enemy contact cooldowns).
Behavior registry
Bullets carry _behaviors: string[]. The registry in bullets.ts exposes:
| Slot | Dispatcher | When it runs |
|---|---|---|
update(b, dt, world, ship) | BulletBehaviors.updateBullet | Every frame from updateBullets after position integration. |
onDeath(b, world, ship) | BulletBehaviors.deathBullet | When b.l <= 0 during the same updateBullets sweep, immediately before the bullet is swap-removed. |
onHit(b, enemy, world, ship) | BulletBehaviors.hitBullet | Defined on the dispatcher but not currently invoked by CollisionResolver. Handlers that register onHit (for example bounce) are dormant under the present resolver wiring — their hit-time effects do not fire from the standard collision path. |
Registry properties:
| Field | Purpose |
|---|---|
priority | Defaulted to 0 on register; not consulted by the current iteration order (handlers run in _behaviors array order). |
_registry | Plain object map keyed by behavior name; exported as BULLET_BEHAVIOR_MAP for tests. |
Practical consequence: damage, status tagging, and VFX consequences of a hit are owned by the resolver, not the registry. Per-bullet behaviors that need to react to a hit must currently piggy-back on update (and inspect state mutated by the resolver, such as b.hits or pierceCount), use onDeath, or be hard-coded inline in the resolver path (as with chain handoff state).
Collision modes intersection
The resolver dispatches on b._collisionMode before running the standard path. Each mode determines which on-hit consequences fire:
| Mode | Resolver entry | Damage | Telemetry | Bonus explosion | Explosive blast | Impact VFX | Pierce | Notes |
|---|---|---|---|---|---|---|---|---|
first_hit (default) | inline loop | yes | yes | yes | yes | yes | yes | Bullet dies on first hit unless pierceCount > 0. |
pierce_all | inline loop | yes | yes | yes | yes | yes | yes | Legacy; behaves identically to first_hit with high pierce. |
target_only | inline loop | yes | yes | yes | yes | yes | yes | Skips any enemy whose _id does not match b._targetId. |
beam_trace | resolveBeamTrace | yes | yes | yes | no | yes | no | Hits all enemies within beamWidth of the line in one frame. |
chain_arc | resolveChainArc | yes (first target) | yes (first target) | no | no | impact ring + ghost arc | no | First target hit inline; subsequent jumps handled by the chain_sequential behavior over multiple frames. Calls applyChainExplosion (currently a no-op stub). |
| Tesla line (no mode flag) | resolveLineCollision | yes | yes | no | no | yes + spark burst | no | Triggered when both _ball1X and _ball2X are present; uses _contactCooldowns map per enemy. |
Shield arc (weaponId === 'barrier') | skipped | handled in behavior | handled in behavior | no | no | handled in behavior | no | Collision geometry owned by the shield_arc behavior, not the resolver. |
The frame loop runs all collision passes against a single shared spatial grid: rebuild grid → player-bullets-vs-enemies → enemy-bullets-vs-player → ship-vs-enemy-bodies → per-enemy cooldown tick-downs. The grid uses a 30-unit radius pad (max enemy collision radius) so narrow-phase rejection stays cheap.