Bullet pool recycle

What this is

The bullet pool recycle is the allocation-reuse contract that backs every player bullet in the game. Player bullets live in world.playerBullets, an array on the world state. When a bullet dies, its object is not discarded — it is reset and pushed onto a module-local recycle bin in weapons.ts. The next spawnBullet call pops from that bin before falling back to {} as any. The purpose is to cut GC pressure during sustained fire — hundreds of bullets per second across all weapons — by keeping the same hidden-class object shapes alive across the spawn/death cycle.

The pool is plain: a const _bulletRecycleBin: any[] at module scope plus a paired counter. Telemetry exposes occupancy via getBulletBinSize(); a healthy value is sustained > 0, which means recycling is actually happening.

The recycle contract

Two arrays, two caps, two paths through the contract.

ArrayLocationCapBehavior at cap
world.playerBullets (active)state.ts80 (hard cap in spawnBullet)New spawn dropped (return undefined)
world.playerBullets (active, behavior-spawned sub-bullets)state.ts100 (soft cap in behaviors)Sub-bullet not spawned
_bulletRecycleBin (dead)weapons.tsMAX_BULLET_RECYCLE = 512Bullet dropped (return); GC reclaims

The hard cap of 80 is the defensive gate on spawnBullet itself. Behavior code that spawns secondary bullets — periodicRing, chain detonations, burst sub-shots — checks the looser 100 limit inline because it bypasses spawnBullet and pushes directly into world.playerBullets.

The lifecycle per bullet:

  1. SpawnWeaponManager.spawnBullet pops from _bulletRecycleBin if non-empty, else allocates {} as any. The recycled object retains every field set by the previous Object.assign, which preserves V8’s hidden class. The new spawn then Object.assigns the fresh state on top.
  2. UpdateupdateBullets (and the bridge’s inline equivalent at bridge.ts:1683) iterates world.playerBullets backward, ticks b.l -= dt, integrates position, runs registered behaviors, then checks b.l <= 0.
  3. Death — on b.l <= 0, the death path runs BulletBehaviors.deathBullet, then releaseSet(b.hits) returns the hit-set to its own pool, then WeaponManager.releaseBullet(b) resets the object and pushes it onto _bulletRecycleBin, then swapRemove(world.playerBullets, i) removes the slot from the active array.
  4. Swap-and-popswapRemove is arr[i] = arr[arr.length - 1]; arr.pop(); — O(1), order not preserved. The backward iteration in updateBullets makes this safe: the element swapped into slot i was already at a higher index this frame and has already been processed.

The reset in releaseBullet is the load-bearing half of the contract. Two categories of field get cleared:

CategoryFieldsWhy
Reference fields → null_homingTarget, _trackEnemy, _targetId, _contactCooldowns, _coneHitCooldowns, _burstTarget, _chainHitSetLets GC collect what they pointed at; prevents stale references from leaking into the next spawn
Lazy-init flags → undefined / 0 / false_orbitAge, _prevOrbitAngle, _returning, _anchored, _minePhase, _minePrimed, _gmPhase, _mineLifeTimer, _armTimer, _lingerTimer, _dmgTick, _flightTime, _ringTick, _phaseTick, _toxicTick, _burstTimer, _burstShotsLeft, _homingAge, _age, _dist, _arcProgress, _detonatePosX, _detonatePosY, _perkUpTimer, _chainFirstResolved, _chainJumpsLeft, _chainTimer, _chainCurrentDmg, _chainSourceX, _chainSourceY, _chainOriginX, _chainOriginY, _chainMaxTotalRangeRe-arms each behavior’s first-frame if (X === undefined) init block on the next spawn

What is not explicitly reset survives across recycle. That is by design for fields the spawn-time Object.assign will overwrite anyway (x, y, vx, vy, dmg, l, _behaviors, etc.) — overwriting them all keeps the hidden class stable. The bug class to watch for is a lazy-init field that the spawn template does not set: it survives recycle, the init guard sees the stale value, and the behavior skips its first-frame setup.

The Sentry-crash history

The cleared-field list in releaseBullet is not theoretical — every entry traces to a behavior that broke in production.

SentrySymptomCauseFix
7440118273b._contactCooldowns.get() crashed on nulltrigger_happy was firing thousands of recycled bullets; stale _orbitAge survived across recycle, so the sword orbit behavior’s init guard (if (b._orbitAge === undefined)) saw a truthy value and skipped the _contactCooldowns = new Map() line. The next contact tick then called .get on the previously-nulled map.Guard on _contactCooldowns (the field that is nulled on release) instead of _orbitAge; explicitly null _contactCooldowns in releaseBullet.

A second, related issue surfaced from the same recycle path: the orbit behavior’s swept-arc calculation read _prevOrbitAngle on frame 1 to compute the angular delta. A recycled slot leaked the prior bullet’s final _prevOrbitAngle, so frame 1 computed a huge ghost arc and instakilled enemies behind the ship at spawn time. Fix: clear _prevOrbitAngle in releaseBullet and set b._prevOrbitAngle = b._orbitAngle inside the same init block, so the first-frame sweep delta is exactly one frame of rotation.

A third recycle bug touched the chain (lightning) bullet: _chainFirstResolved is the gate that drives resolveChainArc. A recycled chain bullet carried _chainFirstResolved = true from the prior life, so collision resolution was skipped, no beam or damage spawned, but the cooldown and chime still fired. Fix: reset _chainFirstResolved = false in releaseBullet.

A fourth, separate bug pattern showed up in the toxicZone behavior — iterating world.enemies directly (which holds the recycled enemy pool, ~250 entries) instead of querying enemyGrid. That was fixed in v5.156.10 by switching to the spatial grid; the enemy recycle pool is conceptually identical to the bullet bin but lives in its own subsystem.

Why this matters

Sustained fire in endgame Princess runs hits ~120 active bullets and burns ~10 ms/frame in the draw pass alone (the source of the 80-bullet hard cap added in v5.156.5). Without recycling, every one of those bullets would allocate a fresh object on spawn and become garbage on death. The bin keeps the working set of bullet objects roughly constant; allocations only happen when the bin is empty or when the bin is full and a spawn happens at the same time as a death.

The deeper reason the contract has to be exact is the V8 hidden-class assumption. The whole point of Object.assigning onto a pre-shaped object is that the engine’s optimized property-access stays on the fast path. If releaseBullet ever fully reset the object (e.g. by clearing it to {}), the recycle would still be cheap from a GC standpoint but would defeat the hidden-class win. That is why reference fields are nulled rather than deleted: the slot stays on the object, only the pointer changes.

The lazy-init reset list is the price of carrying behavior state on the bullet object itself. Each new behavior added to bullets.ts that uses an if (X === undefined) init guard must add X to the reset list in releaseBullet. A behavior that forgets this will work fine in dev (where recycle bin rarely warms up) and crash in production once a high-spawn weapon like trigger_happy cycles the bin enough times to start serving recycled slots to that behavior. Sentry 7440118273 is the canonical example; the long reset list in releaseBullet is the accumulated scar tissue.