logo

Babylon.js Market

By Lawrence

9 minutes

Pools and Spawners

TL;DR — Making and destroying entities at runtime spikes your frame time right when the action peaks. This lesson swaps that for object pooling: world.registerPool / acquire / release, plus the EntityPool and EnemySpawner components. Now spawning a bullet, or a whole waveA timed batch of enemies sent at the player, usually with a lull before the next, harder batch., costs a flag flip instead of a new allocation. New idea here: "things that exist" and "things in play" are two different states. Instead of destroying an entity, you park it by flipping its active flag. The rest of the course leans on that move.

Last lesson you built a coin-collector from seven library components and one PickupSystem. It ended with two rough edges. First, eight coins placed by hand in JSON. Second, a hit-flash that creates and destroys an entity on every hit. Both are the same problem, just in slow motion. Now the design wants enemies that arrive in waves. It also wants a Galaga clone, the old space shooter, where holding down fire fills the screen with bullets. That same make-an-entity-then-destroy-it habit spikes the frame time exactly when the action peaks. The burst that should feel the best is the one that stutters. Here is the version that hitches:

// Every shot: a brand new entity, a brand new mesh
fire() {
  const bullet = this.world!.createEntity();
  bullet.addTag('bullet');
  bullet.add(new MeshPrimitiveComponent({ primitive: 'sphere', diameter: 0.3 }));
  bullet.add(new BulletComponent({ speed: 18 }));
}

// Every hit or miss: destroy it all again
expire(bullet: Entity) {
  this.world!.removeEntity(bullet); // entity destroyed, mesh disposed
}

The same createEntity and removeEntity you have used all course are the biggest avoidable performance drain in an arcade game. The fix flips one flag on something you built before the game started.

The three costs of create-and-destroy

Spawning and despawning look cheap because each call is one line. But three separate costs come due on the same busy frames, and they add up.

CostWhat create-and-destroy pays, per spawn and per deathPooled equivalent
GarbagecreateEntity allocates the entity and its component instances, and the membership-change reactions below allocate event payloads on top; destroy orphans all of it. Eight bullets a second plus enemies plus particles is a steady diet for the collector — until it stops the world to chew.Allocated once, up front. Zero per spawn.
GPUGraphics processing unit — the chip that draws the scene; allocating and freeing its mesh resources mid-frame is costly.MeshPrimitiveSystem builds a real mesh in onEntityAdded — vertex buffers, a material, a draw callOne instruction to the GPU to render a piece of geometry; building and tearing down the resources behind one mid-frame is expensive. — and disposes it on death. Building and tearing down GPU resources mid-frame dwarfs the cost of moving a mesh that already exists.The mesh already exists; you toggle its visibility.
System churnEvery membership change fires onEntityAdded / onEntityRemoved across every system: Shadow checks if it casts, physics checks if it collides, HealthBar checks if it needs a bar. All of it, every spawn, every death.A single active flag flip; query sets update with no per-system work.

That third row is the one people miss. The ripple through unrelated systems never shows up in your code. And it grows with how many systems your scene runs. The busier your game, the more each spawn costs. The fix lives in World.

Entity pool diagram on a dark background, neon style: a grid of ten dim grey parked entity slots labeled bullet-0 through bullet-9, three of them glowing bright cyan as active, with a green acquire arrow pulling a dim slot up into the active row and a magenta release arrow returning a bright slot back down to the parked grid, forming a closed recycling loop

Parking entities instead of destroying them

Pay the allocation cost once, for a fixed number of entities, before the action starts. After that, "spawning" wakes a parked entity, and "dying" parks it again. Nothing gets allocated, and nothing gets disposed. The whole mechanism is three methods on World. Here they are, straight from the source:

@babylonjsmarket/ecs/src/ECS/World/World.ts
registerPool(name: string, options: EntityPoolOptions): void {
  if (this.pools.has(name)) return;
  const runtime: EntityPoolRuntime = { reset: options.reset, free: [], active: [] };
  this.pools.set(name, runtime);
  for (let i = 0; i < options.size; i++) {
    // Name pooled entities after their pool (`enemy-0`, `enemy-1`, …) so they
    // read clearly in entity inspectors / debuggers instead of `entity-N`.
    const entity = this.createEntity(`${name}-${i}`);
    this.entityPoolName.set(entity.id, name);
    options.build(entity);
    entity.active = false; // park: leaves all systems, resources kept
    runtime.free.push(entity);
  }
}

/**
 * Acquire a parked entity from the named pool and bring it into play: runs
 * the pool's `reset(entity, data)`, then re-activates it (re-adding it to
 * matching systems). When the pool is exhausted, the oldest live entity is
 * recycled. Returns null only if no pool is registered under `name`.
 */
acquire(name: string, data?: unknown): Entity | null {
  const runtime = this.pools.get(name);
  if (!runtime) return null;
  let entity = runtime.free.pop();
  if (!entity) {
    // Exhausted — recycle the oldest live one.
    entity = runtime.active.shift();
    if (!entity) return null; // size 0
    entity.active = false; // park before reset so re-activate is a clean cycle
  }
  runtime.reset?.(entity, data);
  entity.active = true; // unpark: re-added to matching systems
  runtime.active.push(entity);
  return entity;
}

/**
 * Return a pool-owned entity to its pool — removed from play (`active =
 * false`) but kept alive for reuse. No-op (returns false) for entities that
 * aren't pool-owned.
 */
release(entity: Entity): boolean {
  const name = this.entityPoolName.get(entity.id);
  if (name === undefined) return false;
  const runtime = this.pools.get(name);
  if (!runtime) return false;
  const i = runtime.active.indexOf(entity);
  if (i >= 0) runtime.active.splice(i, 1);
  if (!runtime.free.includes(entity)) {
    entity.active = false;
    runtime.free.push(entity);
  }
  return true;
}

A pool keeps two buckets. free holds the parked slots that are ready to reuse. active holds the ones in play. Look at what registerPool really does. It builds size entities right now, names them after the pool, then sets entity.active = false on each one and pushes it into free. That is the whole trick. Parking is the same active flag you met in the query lesson. The World pulls inactive entities out of every system's query set for you, with no pool tag and no extra component. The options you hand it are a small shape:

@babylonjsmarket/ecs/src/ECS/World/World.ts
export interface EntityPoolOptions {
  /** Number of entities to pre-allocate. Caps the concurrent live count. */
  size: number;
  /**
   * Build one pooled entity's components. Runs once per slot at registration,
   * while the entity is active (so resource-owning systems create their
   * meshes/handles), before it's parked.
   */
  build: (entity: Entity) => void;
  /**
   * Reset a recycled entity to spawn state. Runs on each `acquire`, just before
   * the entity re-enters play. Receives the `data` passed to `acquire`. Use it
   * to set position, HP, etc. — never to add/remove components.
   */
  reset?: (entity: Entity, data?: unknown) => void;
}

So registering a bullet pool is one call:

world.registerPool('bullet', {
  size: 100, // pre-allocate 100 slots, once
  build: (e) => {
    // Runs ONCE per slot, while the entity is active, so the mesh gets made.
    e.addTag('bullet');
    e.add(new MeshPrimitiveComponent({ primitive: 'sphere', diameter: 0.3 }));
    e.add(new BulletComponent({ speed: 18 }));
  },
  reset: (e, data) => {
    // Runs on EVERY acquire — re-aim this slot from the spawn data.
    // dx, dz = the direction to fire across the ground plane.
    const d = data as { x: number; y: number; z: number; dx: number; dz: number };
    e.get(MeshPrimitiveComponent)!.position = [d.x, d.y, d.z];
    const b = e.get(BulletComponent)!;
    b.direction = [d.dx, 0, d.dz];
    b.lifetime = 1.2; // restart the expiry countdown
  },
});

Firing is now acquire. It un-parks a free slot, runs your reset with the spawn data, and re-activates the entity so it rejoins every matching query set:

const bullet = world.acquire('bullet', { x, y, z, dx, dz });

Death is where pooling becomes almost free to adopt. removeEntity checks whether the entity belongs to a pool. If it does, it releases the entity, parkingTaking an entity out of play without destroying it, by flipping its active flag to false so every query set drops it. it back, instead of destroying it. So your existing death code does not change:

// Your existing death code, identical to before:
world.removeEntity(bullet);

Every "on hit, remove the entity" line from the earlier lessons quietly becomes recycling logic. The call sites stay the same.

build runs once, reset runs every spawn

Getting the split between build and reset right is the important part, and the comments in EntityPoolOptions above say why. build runs while the entity is active, so MeshPrimitiveSystem creates the mesh before the entity goes dormant. The mesh then survives parking. Acquire and releaseReturn a live pooled entity to its pool — parked and kept alive — instead of destroying it. toggle visibility, never GPU allocation, because the resource-owning systems (MeshPrimitiveSystem, HealthBar, Shadow) hide the entity instead of disposing it when it parks. That hiding is a contract those systems opt into. It is not free behavior. A resource-owning system you write yourself will, by default, dispose its handle in onEntityRemoved and rebuild it in onEntityAdded. That brings back the exact GPU churn pooling is meant to kill. To pool it, keep the handle when !entity.active and reuse it on re-activate. Also make the park-hide call a different adapter method than your per-frame write, so the two do not collide on the same frame. reset runs on every acquirePull a parked entity from a pool, re-aim it from spawn data, and bring it back into play. and does one job: re-aim the slot. Never add or remove a component in reset. That re-triggers the exact query churn pooling exists to avoid.

CallbackRunsJobNever
build(e)Once per slot, at registerPooladd the components; let resource systems build the meshtouches per-spawn data
reset(e, data)On every acquireRe-aim the slot: position, HP, lifetime, directionadds or removes components

Size the pool for peak concurrentThe most of a thing that can ever be alive at the same instant; the number a pool must be sized for.: the most that can ever be alive at one time. If you size it too small, acquire recycles the oldest live entity (the code above does runtime.active.shift()). Your oldest enemy blinks out and becomes your newest one, and players notice.

Parked entities keep their tags, and that has one sharp edge. The manual lookups from the Composing Gameplay lesson do not filter for you. world.getEntitiesByTag('enemy') returns the parked entities too. So any scan you write by hand needs a guard:

// Without the filter, your collision check hits 26 "enemies"
// when only 6 are actually in play:
const live = world.getEntitiesByTag('enemy').filter((e) => e.active);

Forget the guard and you get the classic pooling bug. Bullets explode against invisible parked enemies sitting at the world origin: the scene's [0, 0, 0] point, where un-positioned entities start out. Or reach for world.query({ tags: ['enemy'] }), which drops inactive entities for you.

Side-by-side frame time comparison chart on a dark background, neon style: left panel labeled create and destroy shows a jagged green frame time line with tall red garbage collection spikes aligned to bullet burst icons; right panel labeled pooled shows the same bullet bursts above a flat smooth cyan frame time line

Pools as scene data

Pools follow the same rule as the Scenes as Data lesson. A scene is meant to be data, so you should not have to write a build closure in TypeScript. The arcade library ships an EntityPool component that declares a pool right in JSON, with each pooled entity's component set spelled out inline:

{
  "EnemyPool": {
    "components": {
      "EntityPool": {
        "pool": "enemy",
        "size": 12,
        "tags": ["enemy"],
        "components": {
          "MeshPrimitive": { "primitive": "sphere", "diameter": 1.2, "color": [0.85, 0.25, 0.35] },
          "Health": { "hp": 2 },
          "Enemy": { "speed": 2.5 }
        }
      }
    }
  },
  "NestLeft": {
    "components": {
      "MeshPrimitive": { "primitive": "box", "size": 1, "position": [-8, 0.5, 6] },
      "EnemySpawner": { "pool": "enemy", "interval": 1.2, "maxAlive": 6 }
    }
  },
  "World": {
    "components": { "Enemy": {}, "Health": {} }
  }
}

EntityPoolSystem reads that inner components map and calls world.registerPool for you. build adds the listed components. reset re-applies their config, so HP and speed snap back to spec on every reuse. Spawning and despawning are events, never direct calls. You emit a request, and the pool emits the answer. The vocabulary is declared once in source, the way the events lesson showed:

@babylonjsmarket/arcade/src/Components/EntityPool/EntityPool.core.ts
export const EntityPoolEvents = {
  /** Emitted once per pool after it finishes pre-allocating. `{ pool, size }`. */
  READY: 'entityPool.ready',
  /** Emitted when a slot is acquired and placed. `{ pool, entityId, spawnerId? }`. */
  SPAWNED: 'entityPool.spawned',
  /**
   * Emitted during reset (after config re-apply + positioning) so game systems
   * can apply per-spawn dynamics. `{ pool, entityId, data }` (data = spawn event).
   */
  ACQUIRED: 'entityPool.acquired',
  /** Emitted when an entity is parked back into the pool. `{ pool, entityId }`. */
  RELEASED: 'entityPool.released',
  /** Emitted when a spawn can't be served (every slot live). `{ pool }`. */
  EXHAUSTED: 'entityPool.exhausted',
} as const

export const EntityPoolInputEvents = {
  /** Place a live entity from a pool. `{ pool, x, y, z, spawnerId?, ...overrides }`. */
  SPAWN: 'entityPool.spawn',
  /** Park the entity `entityId` back into its pool. `{ entityId }`. */
  RELEASE: 'entityPool.release',
} as const

You emit entityPool.spawn to ask for a slot, and the pool answers with entityPool.spawned. You emit entityPool.release to park one, and the pool confirms with entityPool.released. entityPool.exhausted is not a back-pressure signal. A full pool never refuses a spawn. acquire recycles its oldest live entity (the runtime.active.shift() above), so entityPool.spawned still fires. exhausted only fires when acquire returns null, which happens for a size-0 or unregistered pool. That is a misconfiguration signal, not a sign the action got too heavy. Nothing holds a reference to anything. The bus carries it all.

One wrinkle comes from where the SceneLoader looks. Registering a component tells the loader that this component type exists, so it can build it from JSON. The loader only scans the components on the scene's top-level entities to decide what to register. An inner components map is data the pool reads, not a live entity the loader walks. So a component that appears only inside a pool's inner components map, like Enemy and Health above, would never get registered. That is what the bare Enemy: {} and Health: {} on the World entity are for. They form a marker entityAn entity carrying a bare component declaration whose only job is to make the SceneLoader register that component. whose only job is to make the loader register those classes.

How the spawner counts its live entities through the bus

EnemySpawner is the part that draws from a pool. Park it at a position, give it a pool name and how often to fire, and it sends spawn requests on a timer. It also caps itself at maxAlive. Here is the interesting part: the spawner holds no reference to any enemy, yet it still knows its own live count. It counts through the bus. Here is its system, both halves:

@babylonjsmarket/arcade/src/Components/EnemySpawner/EnemySpawner.ts
protected onInitialize(): void {
  // The pool confirms a spawn and tells us which spawner asked for it —
  // bump that spawner's live count.
  this.listen(
    EntityPoolEvents.SPAWNED,
    (data: { entityId: string; spawnerId?: string }) => {
      if (!data.spawnerId) return
      for (const spawner of this.entities) {
        if (spawner.id !== data.spawnerId) continue
        const s = spawner.get(EnemySpawnerComponent)
        if (s) this.getRuntime(s).aliveIds.add(data.entityId)
        break
      }
    },
  )
  // Any un-spawn (death OR pool recycle) frees a slot — drop it from
  // whichever spawner owned it.
  this.listen(EntityPoolEvents.RELEASED, (data: { entityId?: string }) => {
    const id = data?.entityId
    if (!id) return
    for (const spawner of this.entities) {
      const s = spawner.get(EnemySpawnerComponent)
      if (!s) continue
      if (this.getRuntime(s).aliveIds.delete(id)) break
    }
  })
}

protected onUpdate(dt: number): void {
  const w = this.world
  if (!w) return
  for (const spawner of this.entities) {
    const s = spawner.get(EnemySpawnerComponent)
    const m = spawner.get(MeshPrimitiveComponent)
    if (!s || !m) continue
    const rt = this.getRuntime(s)
    s.timer += dt
    if (s.timer < s.interval || rt.aliveIds.size >= s.maxAlive) continue
    s.timer = 0
    // Hand the spawn to the pool. It places a reused enemy and echoes
    // `entityPool.spawned` (with our spawner id) so the cap stays accurate.
    // hp/speed/color ride along for games that apply per-spawn overrides via
    // the pool's `entityPool.acquired` event.
    this.eventBus.emit(EntityPoolInputEvents.SPAWN, {
      pool: s.pool,
      x: m.position[0],
      y: m.position[1] + 0.5,
      z: m.position[2],
      spawnerId: spawner.id,
      hp: s.enemyHp,
      speed: s.enemySpeed,
      color: s.enemyColor,
    })
  }
}

The cap is the clever part. onUpdate only fires a spawn when rt.aliveIds.size < s.maxAlive, and that count stays accurate through the bus. The spawner tags each entityPool.spawn with its own spawner.id. The pool echoes entityPool.spawned back with that id, and onInitialize adds the new entity to that spawner's aliveIds set. When the enemy dies, entityPool.released fires and the id drops out of the set, which frees a slot. The spawner keeps a number, and the bus keeps it correct. This is the Events, Not References pattern, paying off in a counter that never goes stale.

Look at the query line at the top of this system: { required: [EnemySpawnerComponent, MeshPrimitiveComponent] }. The spawner needs a mesh because it reads m.position to know where to spawn. The nest's box is its launch point.

Event flow diagram on a dark background, neon style: an EnemySpawner node on the left emits an entityPool.spawn arrow in green to a central EntityPool node holding parked entity slots, which emits an entityPool.spawned arrow in cyan back to the spawner and lights up an enemy entity; a bullet icon strikes the enemy and a magenta entityPool.released arrow flows from the pool back to the spawner decrementing its live counter

Controlling waves by changing spawner fields

Galaga sends waves with a pause between them, not a steady trickle. You do not need a new component for that. The spawner re-reads interval and maxAlive every frame, so any system that changes those two fields is a wave controller.

class WaveSystem extends System {
  protected query = { required: [EnemySpawnerComponent, MeshPrimitiveComponent] };
  private kills = 0;
  private wave = 1;
  private calm = 0; // seconds left in the lull; 0 = wave is running

  protected onInitialize(): void {
    // Every release is a death or recycle — count it toward the wave.
    // listen() auto-disposes the subscription when the system is removed.
    this.listen(EntityPoolEvents.RELEASED, () => {
      if (this.calm > 0) return; // already between waves
      this.kills++;
      if (this.kills >= this.wave * 8) this.startCalm();
    });
  }

  private startCalm(): void {
    this.kills = 0;
    this.wave++;
    this.calm = 3; // three seconds of breathing room
    for (const e of this.entities) {
      e.get(EnemySpawnerComponent)!.maxAlive = 0; // gate closed — spawner idles
    }
  }

  // Counting the lull with dt (not setTimeout) keeps it on the same clock as
  // the spawner: it pauses with world.pause() and can't fire after removal.
  protected onUpdate(dt: number): void {
    if (this.calm <= 0) return;
    this.calm -= dt;
    if (this.calm > 0) return;
    for (const e of this.entities) {
      const s = e.get(EnemySpawnerComponent)!;
      s.maxAlive = 4 + this.wave * 2; // bigger wave
      s.interval = Math.max(0.4, 1.2 - this.wave * 0.1); // faster pulses
    }
  }
}

Setting maxAlive = 0 stalls the spawner right away. Its onUpdate hits rt.aliveIds.size >= s.maxAlive and skips. Counting the lull down with dt reopens the gate with new difficulty numbers, on the same clock the spawner already runs on. Wave five with twenty enemies costs the same allocations as wave one with six: zero. (When a scene ends, removeAllEntities force-destroys the pooled entities for you. You never dismantle a pool by hand.)

Pooling the coin drops from the pickup lesson

Back in Composing Gameplay you built a pickup loop with coins placed in the scene JSON. The moment enemies should drop coins on death, you are back to spawning at runtime, and back to churn. The fix is simple. Declare an EntityPool named coin, and in your drop logic emit entityPool.spawn instead of building an entity. The pickup system does not change at all. A collected coin already left through removeEntity, and because the coin is now pool-owned, that same call releases it (parks it) for the next drop instead of destroying it.

Here is the idea hiding under the performance win: "things that exist" and "things in play" are two different things. Entities are the full list. active is which ones are actually in play. Once that split clicks, endless bullets, endless waves, and piles of loot all become one cheap operation: flipping a flag on something you built before the game started.

Next: Tune It Live — mount the free viz layer to watch the pool work, then put a slider on a live spawner field and tune the game feel without a reload.

Was this page helpful?

We read every note — tell us what's working and what isn't.

↑↓ NavigateEnter SelectEsc CloseCtrl+K Open Search