Object Pooling for Game Performance: A Practical Guide for Bullets, Particles, Enemies, and UI Feedback

If your roguelike spawns bullets, particles, enemies, loot, floating damage numbers, or temporary UI effects every second, object pooling is one of the first performance patterns worth checking. This guide explains when pooling helps, when it makes code worse, and how to apply it across common game engines.

The Core Claim

Object pooling is not a universal optimization. It is useful when the same kind of short-lived object is created and destroyed repeatedly during gameplay.

Good candidates:

Poor candidates:

The purpose of pooling is to trade allocation churn for predictable reuse.

Why Games Use Pools

Real-time games care about frame consistency. Even if average performance looks fine, repeated allocation and cleanup can create visible spikes.

Pooling helps by:

  1. Creating objects ahead of time or reusing existing ones.
  2. Keeping inactive objects ready for the next request.
  3. Resetting state instead of constructing a new object.
  4. Avoiding bursts of allocation during combat-heavy moments.

In a roguelike, this matters most when many events happen at once: room entry, enemy waves, chain reactions, projectile patterns, loot explosions, or area damage ticks.

The Basic Shape

type Reset<T> = (item: T) => void;
type Create<T> = () => T;

class ObjectPool<T> {
  private available: T[] = [];
  private active = new Set<T>();

  constructor(
    private create: Create<T>,
    private reset: Reset<T>,
    initialSize = 32,
    private maxSize = 256
  ) {
    for (let i = 0; i < initialSize; i++) {
      this.available.push(this.create());
    }
  }

  acquire(): T | null {
    const item = this.available.pop() ?? this.createIfAllowed();
    if (!item) return null;
    this.active.add(item);
    return item;
  }

  release(item: T) {
    if (!this.active.delete(item)) return;
    this.reset(item);
    this.available.push(item);
  }

  private createIfAllowed(): T | null {
    if (this.active.size + this.available.length >= this.maxSize) {
      return null;
    }
    return this.create();
  }
}

The important part is not the exact code. The important part is the lifecycle:

create or prewarm -> acquire -> activate -> use -> deactivate -> reset -> release

Reset Rules Matter More Than the Pool

Most pooling bugs come from incomplete reset logic. A pooled object remembers everything you forget to clear.

For a projectile, reset all of this:

For a floating damage number, reset:

If the reset function becomes larger and riskier than the constructor, the object may not be a good pooling candidate.

Engine Notes

Phaser

Phaser Arcade Physics groups can act as practical pools. Use inactive objects, reactivate them when needed, and disable them again when their lifetime ends. This is especially useful for bullets, hit effects, and damage-number text objects.

Unity

Unity provides UnityEngine.Pool.ObjectPool<T>, which is a strong default for projectiles, particles, and temporary gameplay objects. Pair it with clear OnGet, OnRelease, and OnDestroy behavior. Unity's managed memory guidance is also relevant: reducing per-frame allocations reduces pressure on garbage collection.

Unreal Engine

Unreal projects often implement actor or component pools for expensive spawn/despawn flows. Keep pooled actors clearly inactive when released: collision off, tick disabled if possible, hidden, detached from old owners, and reset before reuse.

Godot

Godot projects can pool nodes by keeping inactive instances under a manager node. Disable processing, visibility, and collision when released. Re-enable only what the object needs when acquired.

Capacity Planning

Do not guess forever. Measure peak demand and set capacity from observed gameplay.

Recommended workflow:

  1. Start with a small pool.
  2. Log when the pool expands or fails to acquire.
  3. Play the highest-density encounter.
  4. Record the peak active count.
  5. Set the warm size near the normal peak.
  6. Set the hard max above the extreme peak.
  7. Decide what happens when the pool is exhausted.

Common exhaustion strategies:

StrategyUse whenRisk
Drop requestCosmetic effectsMissing feedback
Reuse oldestDamage numbers, particlesVisual popping
Expand up to maxBullets, enemiesMemory growth
Fail loudly in devCore gameplay objectsRequires fallback

Anti-Patterns

Anti-patternSymptomFix
Pooling everythingCode becomes harder without measurable gainsPool only high-churn object types
No hard maxMemory grows during extreme encountersAdd max capacity and exhaustion behavior
Incomplete resetOld state appears in new objectsCentralize reset and test reuse
Hidden subscriptionsReleased object still reacts to eventsUnsubscribe on release
Pool owns gameplay decisionsPool decides damage, target, or reward logicKeep pools responsible only for lifecycle
One giant generic poolObject-specific reset rules get lostUse one pool per object type

Checklist

A Practical Roguelike Example

Start with three pools:

  1. ProjectilePool for bullets, arrows, spells, and thrown weapons.
  2. DamageNumberPool for combat feedback.
  3. HitEffectPool for sparks, splashes, and impact visuals.

Do not start by pooling enemies unless spawning is already measured as a real cost. Enemies often carry AI state, pathfinding state, equipment, buffs, and event subscriptions, which makes reset logic more dangerous.

References

  1. Robert Nystrom. "Object Pool." Game Programming Patterns.
  2. Unity Technologies. UnityEngine.Pool.ObjectPool<T> scripting API.
  3. Unity Technologies. "Understanding automatic memory management."
  4. Phaser documentation. Phaser.Physics.Arcade.Group.