PURPOSE

Central registry of bullet behaviors and the per-frame bullet update loop. A bullet is a plain object carrying a _behaviors: string[] tag list; this module owns the registry of named handlers (update / onDeath / onHit) that read tagged behaviors off the bullet and mutate its state. Ported from the legacy 06a-bullet-behaviors.js. Also exports the compatibility updateBullets loop used by tests, with a swap-and-pop expiry path that recycles dead bullets through the WeaponManager pool.

OWNS

  • BulletBehaviorHandler interface (priority?, update?, onDeath?, onHit?).
  • BulletBehaviors singleton with:
    • _registry — name-keyed map of handlers.
    • register(name, handler) — installs a handler and defaults priority to 0.
    • get(name) — handler or null.
    • updateBullet(b, dt, world, ship) — iterates b._behaviors and invokes each handler’s update.
    • deathBullet(b, world, ship) — iterates b._behaviors and invokes each handler’s onDeath.
    • hitBullet(b, enemy, world, ship) — iterates b._behaviors and invokes each handler’s onHit. Dormant slot: no caller in the engine invokes this method. Behaviors with onHit (bounce, quad_burst) currently receive their hit callback only by direct invocation paths elsewhere, not through this dispatcher.
  • BULLET_BEHAVIOR_MAP — compatibility alias of _registry for tests.
  • updateBullets(world, ship, game, dt) — compatibility test loop: decrements b.l, advances position by velocity, runs BulletBehaviors.updateBullet, then if b.l <= 0 runs BulletBehaviors.deathBullet and removes the bullet via swap-and-pop (bullets[i] = bullets[last]; bullets.length = last). The shipping engine uses bridge.ts’s parallel implementation that additionally calls releaseSet(b.hits) + WeaponManager.releaseBullet(b) to recycle the bullet.
  • Internal _hslToRgb(h, s, l) helper for rainbow sparkle particle colors (Star Halo).
  • Internal _detonateMine(b, world) helper that paints a 3-layer blast (white-hot inner + purple core + deep purple spread) plus smoke, applies AoE damage with 1 - 0.7 * min(1, dist / blastR) falloff inside blastR + enemy.radius, records telemetry.recordWeaponHit, and sets b.l = 0.02 for the explosion flash frame. MINE_BLAST_MULT = 1.8 multiplier defines blast radius as 1.8× the trigger radius.
  • Registered behaviors (35 total, in source order):
    • Simple cosmetic / accumulator: blink, buzzsaw, periodicRing, phaseAura, toxicZone.
    • Original mine: mine (idle proximity check, primed detonation timer, VFX only — damage is commented out).
    • Tracking: homing (locked-target steering with _homingDelaySec, _missileAccelRate, _targetMode = 'low_hp', manual-fire bypass, infinite-homing snap at homingStrength >= 500).
    • Boomerang / linger / mine: boomerang_return, linger_out, deploy_mine (uses _detonateMine, includes triggered phase that flies at the tripping enemy at 600 px/s).
    • Multi-hit reflector: bounce — only handler with an active onHit, decrements _bounceLeft and redirects toward next-nearest enemy within 200 px (or random reflection if none).
    • Burst coordinator: burst_fire — invisible follower bullet that fires _burstShotsLeft hitscan beam_trace sub-bullets at _burstIntraSec intervals with locked target + ±1.5° jitter; bails to a 0.08 s shutdown lifetime when shots run out.
    • Lifetime-event spawners: empWave (onDeath spawns an emp_ring zone bullet), aoe_finish (onDeath blast + falloff damage — suppressed on cancelled projectiles where b.hits.size === 0 unless the behavior list includes arc_mortar or artillery_rain).
    • Tracking: cannon_track (refreshes _detonatePosX/Y while target lives, redirects toward last-known position on death, expires within 20 px).
    • Mortars / mines: arc_mortar (parabolic flight with _arcHeight rendered offset, suppresses b.rad to 0 during flight so landing-only collision works), gravity_mine (multi-phase state machine: travel → arming → armed visible dotted circle → triggered spin-down → void pop pulse + detonate; pulse radius = _pullRadius, knockback impulse = _pullForce).
    • Swept-arc weapons: orbit (swords) — accelerating power-curve rotation (speedMult = 0.3 + 0.7 * pow(min(1, lifePct/0.7), 0.6)), swept-arc angular hitbox between _prevOrbitAngle and _orbitAngle per frame, per-enemy contact cooldown via _contactCooldowns: Map<enemy, hitTime> with _contactCooldownSec default 0.18 s. Guards init on _contactCooldowns (not _orbitAge) because the recycle path nulls the map but _orbitAge previously survived recycle and broke the swept-arc — fixed in response to Sentry 7440118273.
    • Chain lightning: chain_sequential — processes subsequent jumps after resolveChainArc resolves the first hit; gates on _chainFirstResolved, uses enemyGrid.query from the last hit position, applies damageEnemy + applyChainExplosion per jump, multiplies damage by _chainFalloff (default 0.88) each step.
    • Tesla / cone / shield arcs: tesla_line (two balls spread apart perpendicular to fire direction, midpoint b.x/y for spatial-grid placement, collision in resolver), cone_beam_dot (apex tracks ship, point-in-cone test using cosA/sinA axis + tanHalf projection, per-enemy _coneHitCooldowns map, three-layer flame particle emission every frame), shield_arc (orbits ship and detonates on enemy contact with a knockback radius pushing enemies away from ship center).
    • Beam-trace lifetime gates: beam_linger — within _beamHitboxSec window clears b.hits each frame for re-hits; past the window collapses b.rad to 0 so the resolver bails while the beam stays alive for the visual fade.
    • Legendary coordinators: orbit_ring (Star Halo — streamspinburst_outexplode phases, stars are objects on b._ringStarsList, per-star contact cooldown = spinDuration / spins, rainbow HSL hue ramp keyed to star index + game.time), beam_decay (Railstorm — initial beam-trace + 2 s decay window with scheduled cascade explosions biased toward ship end via pow(random, 2.2), scar pulses spawned on death via spawnDelayedAoE).
    • Artillery: artillery_rain (Hellrain — telegraphfall phases, telegraph rad = 0, fall is 0.18 s vertical from 700 px above, on impact spawns dust ring + dirt fountain + impact rings + a spawnDelayedAoE cluster bomblet at 0.4 s).
    • Visual trail-only updates: mega_bullet_trail (brass slug — onDeath slipstream wake oval damage at 25% dmg behind the bullet using enemyGrid.query), plasma_arc, quad_burst (with onHit explosion VFX), fire_trail (Trailblazer — dense three-layer fire + smoke), phoenix_pulse (tracks ship, ember swarm + ship-attached fire spew + wing plume), plasma_mortar_trail.
    • Persistent-zone spawners: plasma_mortar_land (onDeath spawns a plasma_fire_zone bullet with _behaviors: ['plasma_fire_zone'] lasting 3.0 s + impact shockwave + 3 plasma_arc droplet bullets), plasma_fire_zone (radial tick damage every 1/_zoneTickRate).
    • Aerial bomber: carpet_bomber — coordinator follows ship, drops _cbBombsTotal AoE bombs over _cbDuration along a horizontal sweep; each impact paints a multi-layer fire blast and spawns a fire_patch_zone bullet with _behaviors: ['fire_patch_zone'] lasting 2.5 s. Also calls spawnFlameZone from custom-handlers.
    • Lingering ground patches: fire_patch_zone — radial tick damage with spatial-grid query (enemyGrid.query(b.x, b.y, r + 30)).

READS FROM

  • WorldState (world.playerBullets, world.enemies) from core/types.
  • game and ship singletons from core — passed to damageEnemy, used by behaviors that follow the ship (burst_fire, cone_beam_dot, orbit, orbit_ring, phoenix_pulse, carpet_bomber).
  • Particles from vfx/particles — every visual emission goes through Particles.add / Particles.burst.
  • acquireSet from core/set-pool — pooled Set for b.hits on spawned sub-bullets.
  • damageEnemy from combat/damage — primary damage application API.
  • telemetry from telemetry/collector — every damage call is followed by telemetry.recordWeaponHit(b.weaponId, dmg).
  • PostFx from vfx/post-fximpact_ring and ghost_arc postfx primitives.
  • ExplosionFX and AoeExplosion from vfx/explosion-fx — vector-explosion spawn used by aoe_finish, mine detonations, orbit_ring, shield_arc.
  • enemyGrid from core/spatial-grid — used by mine, homing, chain_sequential, fire_patch_zone, mega_bullet_trail to avoid full world.enemies scans.
  • applyChainExplosion from combat/collision-resolver — invoked per chain jump.
  • spawnFlameZone and spawnDelayedAoE from effects/custom-handlerscarpet_bomber bomblet zones and orbit_ring / beam_decay / artillery_rain echo pulses.

PUSHES TO

  • Mutates bullet state in place (position, velocity, lifetime, behavior-specific _* scratch fields).
  • Pushes new bullet objects into world.playerBullets for behavior side effects: periodicRing (nova ring pulses), burst_fire (hitscan beam sub-shots), empWave (emp ring), plasma_mortar_land (fire zone + 3 plasma droplets), carpet_bomber (fire patch zones). All such pushes gate on world.playerBullets.length < 100 before appending.
  • Calls damageEnemy(e, dmg, game, shipState, world) directly from update for swept-hitbox weapons (orbit, cone_beam_dot, shield_arc, chain_sequential, gravity_mine pulse, tesla_line’s onDeath, orbit_ring sweep + explode, beam_decay cascade, fire_patch_zone, plasma_fire_zone, artillery_rain impact via aoe_finish, mega_bullet_trail onDeath wake, _detonateMine).
  • Records telemetry.recordWeaponHit(b.weaponId, dmg) after every damageEnemy call.
  • Spawns visual primitives: Particles.add, Particles.burst, PostFx.spawn('impact_ring' | 'ghost_arc'), AoeExplosion.spawn, ExplosionFX.haloRing.
  • Invokes external echo spawners (spawnFlameZone, spawnDelayedAoE) without owning their bullet objects.

DOES NOT

  • Does not own the bullet pool. The recycle bin (_bulletRecycleBin, capped at 512) lives in weapons.ts. WeaponManager.releaseBullet clears reference fields (_homingTarget, _contactCooldowns, _chainHitSet, etc.) and lazy-init flags so recycled bullets pass behavior init guards.
  • Does not own the global active-bullet cap. WeaponManager.spawnBullet enforces world.playerBullets.length >= 80 for primary spawns. The inline pushes inside behaviors enforce a softer < 100 cap before appending sub-bullets.
  • Does not own the per-bullet collision pipeline (point-in-circle, beam-trace line, etc.). That lives in combat/collision-resolver; behaviors only sweep their own enemy lists for swept-hitbox shapes.
  • Does not modify ship state. Ship reads are read-only.
  • Does not render anything; emits particles + postfx primitives only.
  • Does not check destructibles (_canHitDestructibles is passed through to spawned sub-bullets for the collision-resolver to read, never tested here).
  • BulletBehaviors.hitBullet is registered as an API surface but is unreferenced anywhere in the engine — currently a dormant slot. Handlers’ onHit callbacks fire only via paths outside this dispatcher.
  • Does not validate behavior names on register or guard against id collisions (later registrations would silently overwrite).
  • Does not own the bullets-are-recycled distinction: behaviors must self-detect “fresh” state via cleared scratch fields (e.g. if (!b._contactCooldowns)), because the recycle path in weapons.ts is what zeros those fields.

Signals

  • None. Behaviors are synchronous mutators. No event emission or subscription. Telemetry calls (telemetry.recordWeaponHit) are one-way reports.

Entry points

  • BulletBehaviors.register(name, handler) — module-side-effect: every built-in registers at import time.
  • BulletBehaviors.updateBullet(b, dt, world, ship) — called once per bullet per frame from the engine’s bullet loop (in bridge.ts) and from the compatibility updateBullets loop here.
  • BulletBehaviors.deathBullet(b, world, ship) — called when b.l <= 0 in both loops, before the bullet is recycled.
  • BulletBehaviors.hitBullet(b, enemy, world, ship) — exposed but uncalled. See DOES NOT.
  • BulletBehaviors.get(name) — public lookup.
  • updateBullets(world, ship, game, dt) — compatibility test loop with the swap-and-pop expiry path. The shipping per-frame loop lives in bridge.ts and does the same work plus releaseSet(b.hits) + WeaponManager.releaseBullet(b).
  • BULLET_BEHAVIOR_MAP — re-export of _registry for tests.

Pattern notes

  • Module-side-effect registration: importing the module populates BulletBehaviors._registry with all 35 behaviors before any caller can ask for them.
  • Singleton-as-object pattern (not a class), with handler methods referenced via BulletBehaviors._registry rather than this, safe under destructured callbacks.
  • Behavior tagging: bullets carry _behaviors: string[]. Each frame, each tag is looked up and the matching handler’s update runs in array order. Multiple behaviors stack (e.g. [arc_mortar, aoe_finish, plasma_mortar_land] for a plasma mortar shell: flies the arc, detonates on land, spawns the fire zone, all from one bullet).
  • Lazy-init pattern: behaviors check if (!b._stateName) or if (b._stateName === undefined) on first frame. The pool recycle path (releaseBullet in weapons.ts) explicitly clears these so init re-fires. Sentry 7440118273 was caused by _orbitAge surviving recycle while _contactCooldowns was nulled — orbit was switched to guard on _contactCooldowns instead.
  • Swap-and-pop recycle path (in the compatibility loop and mirrored in bridge.ts): iterating backward through world.playerBullets, when b.l <= 0 swap the last element into the current slot and shrink the array (bullets[i] = bullets[last]; bullets.length = last). O(1) per removal. Safe because the swapped element was at a higher index than i so it gets processed on the next iteration of the descending loop.
  • Spawn-immunity gate: nearly every enemy scan in this file filters with (e as any)._spawnT > 0.15 — enemies in their first ~150 ms of spawn animation are skipped. Telegraphs and AoE detonations don’t damage just-spawned enemies. damageEnemy is never called against an enemy where this gate fails.
  • Dead-enemy gate: !e.alive || e._frozenForLag || e._dying is the canonical guard. _frozenForLag is the lag-spike enemy-freezing flag; _dying is the death-animation flag.
  • Pierce/hit tracking via b.hits: Set<enemy>. Behaviors that should re-hit (sword orbit, fire patches, boomerang return leg) call b.hits.clear() themselves on the appropriate tick boundary. Beam-trace re-hits via beam_linger clearing b.hits each frame within the hitbox window.
  • Sub-bullet spawn gate: if (world.playerBullets.length < 100) before .push(...). This is a soft cap that lives inline at every spawn site; the primary spawn cap (>= 80) lives in WeaponManager.spawnBullet. Crossing 100 silently drops sub-bullets.
  • Color decomposition: many handlers parse b.c1 / b.c2 hex strings inline (parseInt(b.c1.slice(1, 3), 16) etc.) to seed particle colors. No central color util.
  • priority field on handlers is set but never used by the dispatcher. Handlers execute in _behaviors array order, not by priority.
  • Coordinator pattern: burst_fire, orbit_ring, carpet_bomber, and cone_beam_dot are “coordinator” bullets — invisible or pinned to the ship — that drive emergent visual + damage effects without using their own bullet body as a hitbox. Their b.rad is suppressed and their behavior owns scheduling + sub-spawns.
  • ECHO pattern: legendary-tier weapons append a delayed secondary effect by calling spawnDelayedAoE inside an onDeath handler (beam_decay’s Lingering Scar, artillery_rain’s Cluster Bomblet, orbit_ring’s Sparkle Wake, carpet_bomber’s Bomb Pyre, plasma_mortar_land’s Plasma Droplets).
  • AoE damage falloff convention: 1 - 0.7 * min(1, dist / blastR) (100% at center, 30% at edge), applied as Math.ceil(baseDmg * falloff) with a Math.max(1, ...) floor. aoe_finish uses the same shape but with _aoePct (default 0.5) instead of full base damage. beam_decay’s cascade uses 0.6 instead of 0.7. mega_bullet_trail’s slipstream wake uses an oval test (along-axis projection ≤ length/2 + radius, perpendicular ≤ halfW + radius).
  • Manual-fire bypass: _manualFire flag on bullets disables homing entirely (homing returns early) or enables a 0→100% homing ramp over the first 50% of lifetime (used when player taps to fire instead of letting AI fire).
  • Orbit lifetime integral: comment in orbit notes that the ease-in speed curve 0.3 + 0.7 * accelPct^0.6 integrates to ~0.816 of full base × lifetime, so a lifetime-multiplier of ≥ 1/0.816 ≈ 1.23 is required for the blade to clear a full 2π sweep within its lifetime.
  • Comment-coded version markers indicate perf-tuning history: v5.156.5 particle gate on orbit, v5.156.3 density cut on fire_patch_zone, v5.156.10 enemyGrid.query swap in fire_patch_zone, v5.139 Railstorm VFX trim, v5.156.5 global hard cap. Treated as historical annotations, not runtime config.