Skip to content

Systems · Patterns · core

Object Pooling

create everything up front, then recycle instead of allocate

Pre-allocate bullets, effects and enemies once, then hand out and return them. No allocation or GC spikes mid-fight, and a hard cap on live objects.

View .md
Interactive visual

The turret fires orange projectiles from a fixed pool. Each cube in the row behind it is one pool slot: cyan = in use, gray = free. The allocation counter never moves; when every slot is busy the turret flashes red and the shot is dropped, or the oldest projectile is recycled. Raise the fire rate or shrink the pool to hit the limit.

What it is

Spawning a bullet with new Mesh() and destroying it later costs an allocation, scene-graph work and, in JavaScript, garbage that the collector must clean up at a moment you do not choose. An object pool creates N objects at load time. acquire() hands out a free one, release() gives it back. Nothing is created or destroyed during play.

The usual structure is an array of objects plus a free list: a stack of free indices. Both operations are O(1). Released objects are reset (position, velocity, timers, visibility) so they come back clean. In Three.js, hiding a pooled mesh (visible = false) or writing it into an InstancedMesh slot is far cheaper than adding and removing it from the scene.

Decide what happens when the pool runs dry: drop the request (fine for cosmetic sparks), recycle the oldest live object (good for decals and shell casings), or grow (allocates, so log it and size the pool better). Pools pair naturally with a particle system, which is a pool of tiny structs.

Key formulas

Pool size from Little's law

λ = spawns per second, L = average lifetime in seconds, k ≈ 1.2–1.5 headroom for bursts.

Rules of thumb

  • Size from data: 10 shots/s × 1.5 s lifetime = 15 live; allocate 20.
  • Warm the pool during loading, not on the first shot. Also pre-compile materials so the first use doesn't hitch.
  • Track allocations and peakActive in a debug overlay. After warm-up allocations should stay flat.
  • Reset in release(), not in acquire(), so a free object never holds stale references (targets, callbacks) that keep other objects alive.
  • Pool only things that churn: projectiles, hit effects, damage numbers, audio voices. A boss spawned once does not need a pool.

Implementation

Index-based pool with a free-list stack:
class Pool<T> {
  items: T[] = []
  active: boolean[] = []
  private free: number[] = []

  constructor(create: () => T, size: number, private reset: (t: T) => void) {
    for (let i = size - 1; i >= 0; i--) {
      this.items[i] = create()
      this.active[i] = false
      this.free.push(i)
    }
  }
  acquire(): number {
    const i = this.free.pop()
    if (i === undefined) return -1      // exhausted: caller decides
    this.active[i] = true
    return i
  }
  release(i: number) {
    if (!this.active[i]) return         // guard double release
    this.active[i] = false
    this.reset(this.items[i])
    this.free.push(i)
  }
}
Pooled bullets as hidden meshes:
const bullets = new Pool(() => {
  const m = new THREE.Mesh(bulletGeo, bulletMat)
  m.visible = false
  scene.add(m)                          // added once, never removed
  return m
}, 32, (m) => { m.visible = false })

function fire(origin: THREE.Vector3) {
  const i = bullets.acquire()
  if (i < 0) return
  bullets.items[i].position.copy(origin)
  bullets.items[i].visible = true
}

Common mistakes

  • Forgetting to reset state, so a recycled enemy spawns with last life's health or a still-running timer.
  • Releasing the same object twice, which puts its index on the free list twice and later hands one object to two owners.
  • Keeping references to a released object (homing target, UI marker). Use the index plus a generation counter to detect stale handles.
  • Calling scene.add/scene.remove on acquire and release. Toggle visible or write instance matrices instead.
  • Pooling everything, which adds complexity and memory for objects that are created once.

Prompt

Write a generic `Pool<T>` in TypeScript for a Three.js game. Constructor takes a factory, a capacity and a reset callback; all objects are created up front. `acquire()` returns a slot index or -1 using a free-list stack (O(1), no allocation); `release(i)` resets the object, ignores double releases and pushes the index back. Add an optional overflow policy: 'drop' | 'recycleOldest' | 'grow' (track spawn order for recycleOldest), and counters for allocations and peak active. Use it for bullets as pre-added hidden meshes. Write Vitest tests: 1000 acquire/release cycles keep allocations at capacity, exhaustion returns -1, double release is ignored, recycleOldest returns the earliest-acquired slot.

Further reading

Updated 2026-11-01