Object pools

Every high-churn entity in the engine — anything that spawns and dies hundreds of times per second — lives in a pre-allocated pool rather than being constructed on demand. Allocation only happens at module load. The steady-state spawn/death cycle reuses slots, so the hot path generates zero garbage and the GC stays quiet. The pool size is a hard wall: when the pool is full, new entries are silently dropped.

There is no shared “EntityPool” abstraction. Each system owns its own pool module with the layout that fits its access pattern. The pattern is replicated across five sites:

PoolModuleCapacityStorage shape
Player bulletsengine/weapons/weapons.ts (_bulletRecycleBin) + world.playerBullets80 hard / 100 soft (sub-bullets)Array of plain objects; dead bullets recycled via releaseBullet
XP orbsengine/world/xp-orbs.ts1024Structure-of-arrays — eleven parallel Float32Array / Int32Array / Uint8Array columns
Particlesengine/vfx/particles.ts (_pool) + world.particles400 active (CFG.MAX_PARTICLES) + 600 idle (POOL_MAX)Array of typed Particle objects; pool stack of dead instances
Death debrisengine/bridge-death-debris.ts (deathDebris)60 (_MAX_DEBRIS)Array of typed DeathDebris objects; no idle pool — slot reused via length-based cap
Damage numbersengine/vfx/particles.ts (DmgNumbersworld.dmgNumbers)15 (DMG_CAP)Array of literal objects spawned/spliced; tight cap is the throttle

The five sites split into three storage shapes — columnar typed arrays, recycled object pools, and length-capped object arrays — and each shape exists because the access pattern of that entity wanted it.

The columnar shape (XP orbs)

xp-orbs.ts is the most aggressive of the five. The orb has eleven per-instance fields (x, y, vx, vy, amount, radius, spawnAge, pullAccum, magTime, life, flags), and each field is its own typed array of length 1024:

const _x = new Float32Array(MAX_ORBS);
const _y = new Float32Array(MAX_ORBS);
const _amount = new Int32Array(MAX_ORBS);
const _flags = new Uint8Array(MAX_ORBS);
// ... etc
let _count = 0;

Active orbs occupy indices [0, _count). There is no concept of “free slot” — when an orb dies, the last active orb gets swapped into the dead slot via _swapIntoSlot(dst, src), then _count-- and _flags[last] = 0. The whole storage block is allocated once at module load and never grows.

The win is two-fold: (1) zero allocation on spawn/death, and (2) every per-frame pass — magnet, merge, collection, draw, off-screen cull — reads the eleven columns as contiguous memory blocks, which is cache-friendly enough to keep 500+ active orbs at 60 fps. Pool exhaustion is silent: if (_count >= MAX_ORBS) return; and the new orb is dropped.

The recycled-object shape (bullets, particles)

Bullets and particles need richer per-entity state (variable-length arrays, references to enemies, behavior flags). They use plain objects with a recycle stack of dead instances:

  • Particles keep a _pool: Particle[] of dead instances. _acquire() pops from _pool if non-empty, else allocates a fresh { x, y, vx, vy, l, ml, sz, r, g, b, a, tp }. _release(p) pushes p back onto _pool (capped at POOL_MAX = 600). Active particles live in world.particles (capped at CFG.MAX_PARTICLES = 400); spawn drops new particles when the active array is at cap.
  • Bullets use the same pattern with a wrinkle — _bulletRecycleBin holds reset dead bullets that retain their full field set so V8 hidden classes stay stable. spawnBullet pops a recycled bullet and Object.assigns the new state on top. Detailed contract in bullet-pool-recycle.

The recycled-object shape is the right call when the entity has many sparsely-set fields (particle tp discriminates between spark / smoke / exhaust / star with type-specific physics) or when the spawn template is dynamic (bullet behaviors carry behavior-specific fields that vary per weapon). Typed-array columns would force every possible field to live in every slot.

The length-capped shape (debris, damage numbers)

Death debris and damage numbers don’t keep a separate idle pool. They just cap the live array length:

  • DebrisdeathDebris.push(...) inside a while (deathDebris.length < _MAX_DEBRIS) loop. Dead debris (life <= 0) gets swapRemoved out of the array. No idle stack of dead DeathDebris instances exists; the GC handles dead slots.
  • Damage numbersif (world.dmgNumbers.length >= DMG_CAP) return; then world.dmgNumbers.push({...}). Dead numbers swapRemove out.

The length-cap shape is acceptable when peak spawn rate is low enough that GC churn is invisible. Debris caps at 60 with each enemy death spawning ~5-10 shards (radius-scaled, archetype-multiplied); damage numbers cap at 15, so the entire array fits inside a single cache line of pointers. Neither hits the spawn rate where a recycle stack would matter.

Removal: swap-with-last

All four array-backed pools (orbs, particles, debris, damage numbers, plus active bullets in world.playerBullets) use swap-with-last removal:

arr[i] = arr[arr.length - 1];
arr.length = last;   // or arr.pop()

This is swapRemove(arr, i) in engine/core/utils.ts. Cost is O(1); order is not preserved. Every per-frame update loop iterates backward so that a swap into slot i is guaranteed to bring in a slot already processed this frame:

for (let i = arr.length - 1; i >= 0; i--) {
  // ... update, possibly remove
}

The orb pool open-codes this as _removeAt(i) against its columnar layout — same idea, eleven column-copies plus a decrement instead of one pointer-copy plus a pop.

Pool exhaustion

Every pool fails silent — new spawns are dropped, no error, no log. The drop rates are tuned so it never happens in normal play:

PoolDrop triggerDrop behavior
Player bulletsworld.playerBullets.length >= 80spawnBullet returns undefined
XP orbs_count >= 1024spawn early-returns
Particlesworld.particles.length >= MAX_PARTICLESadd early-returns; spark/smoke also gated at 80% of cap to reserve headroom for important effects
Death debrisdeathDebris.length >= _MAX_DEBRISspawnDeathDebris loop exits early
Damage numbersworld.dmgNumbers.length >= 15add early-returns

The XP orb pool has a second line of defense: on-screen / off-screen merge passes that collapse orbs into their neighbors. This keeps live count well below the 1024 cap even during boss explosions that fire hundreds of orbs at once. The other four pools rely purely on the spawn-drop, because none of them have a useful “merge” semantic.

Why no shared abstraction

The five pools differ in storage shape, capacity, reset behavior, and update cadence. Forcing them through a single Pool<T> interface would either lose the columnar layout (and the cache-coherent magnet/merge passes that depend on it) or smear the bullet-specific hidden-class contract across systems that don’t need it. The pattern is duplicated five times because the duplication is cheap and the unification is expensive — three lines of pool boilerplate per module is not a real cost.