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
BulletBehaviorHandlerinterface (priority?,update?,onDeath?,onHit?).BulletBehaviorssingleton with:_registry— name-keyed map of handlers.register(name, handler)— installs a handler and defaultspriorityto 0.get(name)— handler ornull.updateBullet(b, dt, world, ship)— iteratesb._behaviorsand invokes each handler’supdate.deathBullet(b, world, ship)— iteratesb._behaviorsand invokes each handler’sonDeath.hitBullet(b, enemy, world, ship)— iteratesb._behaviorsand invokes each handler’sonHit. Dormant slot: no caller in the engine invokes this method. Behaviors withonHit(bounce,quad_burst) currently receive their hit callback only by direct invocation paths elsewhere, not through this dispatcher.
BULLET_BEHAVIOR_MAP— compatibility alias of_registryfor tests.updateBullets(world, ship, game, dt)— compatibility test loop: decrementsb.l, advances position by velocity, runsBulletBehaviors.updateBullet, then ifb.l <= 0runsBulletBehaviors.deathBulletand removes the bullet via swap-and-pop (bullets[i] = bullets[last]; bullets.length = last). The shipping engine usesbridge.ts’s parallel implementation that additionally callsreleaseSet(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 with1 - 0.7 * min(1, dist / blastR)falloff insideblastR + enemy.radius, recordstelemetry.recordWeaponHit, and setsb.l = 0.02for the explosion flash frame.MINE_BLAST_MULT = 1.8multiplier 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 athomingStrength >= 500). - Boomerang / linger / mine:
boomerang_return,linger_out,deploy_mine(uses_detonateMine, includestriggeredphase that flies at the tripping enemy at 600 px/s). - Multi-hit reflector:
bounce— only handler with an activeonHit, decrements_bounceLeftand redirects toward next-nearest enemy within 200 px (or random reflection if none). - Burst coordinator:
burst_fire— invisible follower bullet that fires_burstShotsLefthitscanbeam_tracesub-bullets at_burstIntraSecintervals with locked target + ±1.5° jitter; bails to a 0.08 s shutdown lifetime when shots run out. - Lifetime-event spawners:
empWave(onDeath spawns anemp_ringzone bullet),aoe_finish(onDeath blast + falloff damage — suppressed on cancelled projectiles whereb.hits.size === 0unless the behavior list includesarc_mortarorartillery_rain). - Tracking:
cannon_track(refreshes_detonatePosX/Ywhile target lives, redirects toward last-known position on death, expires within 20 px). - Mortars / mines:
arc_mortar(parabolic flight with_arcHeightrendered offset, suppressesb.radto 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_prevOrbitAngleand_orbitAngleper frame, per-enemy contact cooldown via_contactCooldowns: Map<enemy, hitTime>with_contactCooldownSecdefault 0.18 s. Guards init on_contactCooldowns(not_orbitAge) because the recycle path nulls the map but_orbitAgepreviously survived recycle and broke the swept-arc — fixed in response to Sentry 7440118273. - Chain lightning:
chain_sequential— processes subsequent jumps afterresolveChainArcresolves the first hit; gates on_chainFirstResolved, usesenemyGrid.queryfrom the last hit position, appliesdamageEnemy+applyChainExplosionper jump, multiplies damage by_chainFalloff(default 0.88) each step. - Tesla / cone / shield arcs:
tesla_line(two balls spread apart perpendicular to fire direction, midpointb.x/yfor spatial-grid placement, collision in resolver),cone_beam_dot(apex tracks ship, point-in-cone test usingcosA/sinAaxis +tanHalfprojection, per-enemy_coneHitCooldownsmap, 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_beamHitboxSecwindow clearsb.hitseach frame for re-hits; past the window collapsesb.radto 0 so the resolver bails while the beam stays alive for the visual fade. - Legendary coordinators:
orbit_ring(Star Halo —stream→spin→burst_out→explodephases, stars are objects onb._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 viapow(random, 2.2), scar pulses spawned on death viaspawnDelayedAoE). - Artillery:
artillery_rain(Hellrain —telegraph→fallphases, telegraph rad = 0, fall is 0.18 s vertical from 700 px above, on impact spawns dust ring + dirt fountain + impact rings + aspawnDelayedAoEcluster 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 usingenemyGrid.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 aplasma_fire_zonebullet with_behaviors: ['plasma_fire_zone']lasting 3.0 s + impact shockwave + 3plasma_arcdroplet bullets),plasma_fire_zone(radial tick damage every1/_zoneTickRate). - Aerial bomber:
carpet_bomber— coordinator follows ship, drops_cbBombsTotalAoE bombs over_cbDurationalong a horizontal sweep; each impact paints a multi-layer fire blast and spawns afire_patch_zonebullet with_behaviors: ['fire_patch_zone']lasting 2.5 s. Also callsspawnFlameZonefrom custom-handlers. - Lingering ground patches:
fire_patch_zone— radial tick damage with spatial-grid query (enemyGrid.query(b.x, b.y, r + 30)).
- Simple cosmetic / accumulator:
READS FROM
WorldState(world.playerBullets,world.enemies) fromcore/types.gameandshipsingletons fromcore— passed todamageEnemy, used by behaviors that follow the ship (burst_fire,cone_beam_dot,orbit,orbit_ring,phoenix_pulse,carpet_bomber).Particlesfromvfx/particles— every visual emission goes throughParticles.add/Particles.burst.acquireSetfromcore/set-pool— pooledSetforb.hitson spawned sub-bullets.damageEnemyfromcombat/damage— primary damage application API.telemetryfromtelemetry/collector— every damage call is followed bytelemetry.recordWeaponHit(b.weaponId, dmg).PostFxfromvfx/post-fx—impact_ringandghost_arcpostfx primitives.ExplosionFXandAoeExplosionfromvfx/explosion-fx— vector-explosion spawn used byaoe_finish, mine detonations,orbit_ring,shield_arc.enemyGridfromcore/spatial-grid— used bymine,homing,chain_sequential,fire_patch_zone,mega_bullet_trailto avoid fullworld.enemiesscans.applyChainExplosionfromcombat/collision-resolver— invoked per chain jump.spawnFlameZoneandspawnDelayedAoEfromeffects/custom-handlers—carpet_bomberbomblet zones andorbit_ring/beam_decay/artillery_rainecho pulses.
PUSHES TO
- Mutates bullet state in place (position, velocity, lifetime, behavior-specific
_*scratch fields). - Pushes new bullet objects into
world.playerBulletsfor 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 onworld.playerBullets.length < 100before appending. - Calls
damageEnemy(e, dmg, game, shipState, world)directly fromupdatefor swept-hitbox weapons (orbit,cone_beam_dot,shield_arc,chain_sequential,gravity_minepulse,tesla_line’s onDeath,orbit_ringsweep + explode,beam_decaycascade,fire_patch_zone,plasma_fire_zone,artillery_rainimpact viaaoe_finish,mega_bullet_trailonDeath wake,_detonateMine). - Records
telemetry.recordWeaponHit(b.weaponId, dmg)after everydamageEnemycall. - 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 inweapons.ts.WeaponManager.releaseBulletclears 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.spawnBulletenforcesworld.playerBullets.length >= 80for primary spawns. The inline pushes inside behaviors enforce a softer< 100cap 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
shipstate. Ship reads are read-only. - Does not render anything; emits particles + postfx primitives only.
- Does not check destructibles (
_canHitDestructiblesis passed through to spawned sub-bullets for the collision-resolver to read, never tested here). BulletBehaviors.hitBulletis registered as an API surface but is unreferenced anywhere in the engine — currently a dormant slot. Handlers’onHitcallbacks fire only via paths outside this dispatcher.- Does not validate behavior names on
registeror 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 inweapons.tsis 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 (inbridge.ts) and from the compatibilityupdateBulletsloop here.BulletBehaviors.deathBullet(b, world, ship)— called whenb.l <= 0in 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 inbridge.tsand does the same work plusreleaseSet(b.hits)+WeaponManager.releaseBullet(b).BULLET_BEHAVIOR_MAP— re-export of_registryfor tests.
Pattern notes
- Module-side-effect registration: importing the module populates
BulletBehaviors._registrywith all 35 behaviors before any caller can ask for them. - Singleton-as-object pattern (not a class), with handler methods referenced via
BulletBehaviors._registryrather thanthis, safe under destructured callbacks. - Behavior tagging: bullets carry
_behaviors: string[]. Each frame, each tag is looked up and the matching handler’supdateruns 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)orif (b._stateName === undefined)on first frame. The pool recycle path (releaseBulletinweapons.ts) explicitly clears these so init re-fires. Sentry 7440118273 was caused by_orbitAgesurviving recycle while_contactCooldownswas nulled —orbitwas switched to guard on_contactCooldownsinstead. - Swap-and-pop recycle path (in the compatibility loop and mirrored in
bridge.ts): iterating backward throughworld.playerBullets, whenb.l <= 0swap 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 thaniso 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.damageEnemyis never called against an enemy where this gate fails. - Dead-enemy gate:
!e.alive || e._frozenForLag || e._dyingis the canonical guard._frozenForLagis the lag-spike enemy-freezing flag;_dyingis the death-animation flag. - Pierce/hit tracking via
b.hits: Set<enemy>. Behaviors that should re-hit (sword orbit, fire patches, boomerang return leg) callb.hits.clear()themselves on the appropriate tick boundary. Beam-trace re-hits viabeam_lingerclearingb.hitseach 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 inWeaponManager.spawnBullet. Crossing 100 silently drops sub-bullets. - Color decomposition: many handlers parse
b.c1/b.c2hex strings inline (parseInt(b.c1.slice(1, 3), 16)etc.) to seed particle colors. No central color util. priorityfield on handlers is set but never used by the dispatcher. Handlers execute in_behaviorsarray order, not by priority.- Coordinator pattern:
burst_fire,orbit_ring,carpet_bomber, andcone_beam_dotare “coordinator” bullets — invisible or pinned to the ship — that drive emergent visual + damage effects without using their own bullet body as a hitbox. Theirb.radis suppressed and their behavior owns scheduling + sub-spawns. - ECHO pattern: legendary-tier weapons append a delayed secondary effect by calling
spawnDelayedAoEinside anonDeathhandler (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 asMath.ceil(baseDmg * falloff)with aMath.max(1, ...)floor.aoe_finishuses 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:
_manualFireflag on bullets disables homing entirely (homingreturns 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
orbitnotes that the ease-in speed curve0.3 + 0.7 * accelPct^0.6integrates 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.5particle gate onorbit,v5.156.3density cut onfire_patch_zone,v5.156.10enemyGrid.queryswap infire_patch_zone,v5.139Railstorm VFX trim,v5.156.5global hard cap. Treated as historical annotations, not runtime config.