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.
| Array | Location | Cap | Behavior at cap |
|---|---|---|---|
world.playerBullets (active) | state.ts | 80 (hard cap in spawnBullet) | New spawn dropped (return undefined) |
world.playerBullets (active, behavior-spawned sub-bullets) | state.ts | 100 (soft cap in behaviors) | Sub-bullet not spawned |
_bulletRecycleBin (dead) | weapons.ts | MAX_BULLET_RECYCLE = 512 | Bullet 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:
- Spawn —
WeaponManager.spawnBulletpops from_bulletRecycleBinif non-empty, else allocates{} as any. The recycled object retains every field set by the previousObject.assign, which preserves V8’s hidden class. The new spawn thenObject.assigns the fresh state on top. - Update —
updateBullets(and the bridge’s inline equivalent atbridge.ts:1683) iteratesworld.playerBulletsbackward, ticksb.l -= dt, integrates position, runs registered behaviors, then checksb.l <= 0. - Death — on
b.l <= 0, the death path runsBulletBehaviors.deathBullet, thenreleaseSet(b.hits)returns the hit-set to its own pool, thenWeaponManager.releaseBullet(b)resets the object and pushes it onto_bulletRecycleBin, thenswapRemove(world.playerBullets, i)removes the slot from the active array. - Swap-and-pop —
swapRemoveisarr[i] = arr[arr.length - 1]; arr.pop();— O(1), order not preserved. The backward iteration inupdateBulletsmakes this safe: the element swapped into slotiwas 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:
| Category | Fields | Why |
|---|---|---|
Reference fields → null | _homingTarget, _trackEnemy, _targetId, _contactCooldowns, _coneHitCooldowns, _burstTarget, _chainHitSet | Lets 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, _chainMaxTotalRange | Re-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.
| Sentry | Symptom | Cause | Fix |
|---|---|---|---|
| 7440118273 | b._contactCooldowns.get() crashed on null | trigger_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.