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:

OrderStepNotes
1Add enemy to b.hits setPrevents the same bullet from re-hitting the same enemy.
2Stamp _lastDmgTag on enemyPulled from WEAPON_MAP[weaponId].damageTag; falls back to bullet.
3damageEnemy(e, b.dmg, ...)Applies shred, affix filters, boss caps, knockback, life steal, floating damage number, and fires damage_dealt.
4Fire bullet_hit signalCarries enemy eid, damage, and primary damage tag.
5Fire secondary bullet_hitOnly when the weapon spec defines secondaryDamageTag distinct from the primary tag.
6Telemetry recordtelemetry.recordWeaponHit(weaponId, dmg) when weaponId is present.
7_applyBonusExplosionCurrently a no-op stub (bigger-area horizontal removed in v3.26).
8_applyExplosiveBlastAoE damage and VFX for arch === 'explosive' || 'homing'; bypasses primary target via b.hits.
9_spawnImpactVfxImpact 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.
10Pierce decrement or bullet deathpierceCount-- 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:

SlotDispatcherWhen it runs
update(b, dt, world, ship)BulletBehaviors.updateBulletEvery frame from updateBullets after position integration.
onDeath(b, world, ship)BulletBehaviors.deathBulletWhen b.l <= 0 during the same updateBullets sweep, immediately before the bullet is swap-removed.
onHit(b, enemy, world, ship)BulletBehaviors.hitBulletDefined 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:

FieldPurpose
priorityDefaulted to 0 on register; not consulted by the current iteration order (handlers run in _behaviors array order).
_registryPlain 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:

ModeResolver entryDamageTelemetryBonus explosionExplosive blastImpact VFXPierceNotes
first_hit (default)inline loopyesyesyesyesyesyesBullet dies on first hit unless pierceCount > 0.
pierce_allinline loopyesyesyesyesyesyesLegacy; behaves identically to first_hit with high pierce.
target_onlyinline loopyesyesyesyesyesyesSkips any enemy whose _id does not match b._targetId.
beam_traceresolveBeamTraceyesyesyesnoyesnoHits all enemies within beamWidth of the line in one frame.
chain_arcresolveChainArcyes (first target)yes (first target)nonoimpact ring + ghost arcnoFirst 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)resolveLineCollisionyesyesnonoyes + spark burstnoTriggered when both _ball1X and _ball2X are present; uses _contactCooldowns map per enemy.
Shield arc (weaponId === 'barrier')skippedhandled in behaviorhandled in behaviornonohandled in behaviornoCollision 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.