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 theEntityPoolandEnemySpawnercomponents. 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 itsactiveflag. 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:
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.
| Cost | What create-and-destroy pays, per spawn and per death | Pooled equivalent |
|---|---|---|
| Garbage | createEntity 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 churn | Every 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.

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:
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:
So registering a bullet pool is one call:
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:
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:
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.
| Callback | Runs | Job | Never |
|---|---|---|---|
build(e) | Once per slot, at registerPool | add the components; let resource systems build the mesh | touches per-spawn data |
reset(e, data) | On every acquire | Re-aim the slot: position, HP, lifetime, direction | adds 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:
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.

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:
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:
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:
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.

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.
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.