Game Programming · June 30, 2026
Object Pooling for Bullets in Shmups: Eliminating Garbage Collection Spikes
A shmup boss fires forty bullets in a single burst, then does it again every two seconds. If each bullet is a freshly allocated heap object, the garbage collector accumulates thousands of dead objects per minute. The inevitable collection pause arrives mid-dodge, frame time spikes, and the player takes a hit that was not their fault. Object pooling eliminates this by treating bullet lifetime as a reset operation rather than an allocation.
Published June 30, 2026
The problem is not that allocation is slow in absolute terms — allocating a small struct takes nanoseconds on modern hardware. The problem is that in a managed runtime (GDScript, C#, Lua, Python, Java), every allocation adds to the garbage the collector must eventually trace and reclaim. The collector does not run on your schedule; it runs when the runtime decides to, often at the worst moment. In a shmup where 200 bullets can be active simultaneously and each lives for one to three seconds, the steady-state allocation rate is easily 100 objects per second. Over a five-minute run, that is 30,000 objects. The GC pause to clean them up is not hypothetical.
The pool pattern
A pool pre-allocates a fixed array of bullet objects at scene load time and marks all of them inactive. When the game needs a bullet, it finds the first inactive entry, configures its position, velocity, and visual properties, marks it active, and returns it. When the bullet leaves the screen or hits something, it is not freed — it is reset and marked inactive, ready to be reused.
The acquire scan is O(n) in the worst case, which sounds inefficient but is cache-friendly for small pools: iterating over 256 tightly packed booleans is a handful of cache lines. For larger pools, a free-list (a stack of indices to inactive objects) reduces acquire to O(1) at the cost of slightly more bookkeeping.
Sizing the pool correctly
Pool capacity must cover the maximum number of bullets simultaneously active at any point in the game, not the average. The sizing exercise is straightforward: identify the densest pattern in the entire game, measure how many bullets it produces per second, multiply by the maximum bullet lifetime in seconds, and add a 25% margin.
| Pattern type | Bullets/second | Max lifetime (s) | Peak simultaneous | Recommended pool |
|---|---|---|---|---|
| Single aimed shot | 4 | 2.5 | 10 | 16 |
| Ring burst (20-count) | 60 | 2.0 | 120 | 160 |
| Dense spiral | 120 | 2.5 | 300 | 400 |
| Full danmaku phase | 200 | 3.0 | 600 | 800 |
When in doubt, run the game with an active counter displaying current pool occupancy and peak usage. Tune the capacity to the recorded peak, not to a theoretical maximum.
Separate pools per bullet type
Using a single pool for all bullet types is tempting but creates fragmentation problems. A boss that fires a dense spiral of small fast bullets alongside a few large slow homing shots will have the small bullets occupy nearly all pool slots; the homing shot requests fail and are silently dropped. Separate pools per bullet type — or at minimum per visual category — ensure each type has guaranteed capacity without wasting memory on over-provisioning a shared pool.
In practice, most shmups need three to five distinct pools: player bullets, standard enemy bullets, large/special enemy bullets, and particle-like debris. Each pool can be sized independently based on its own peak usage calculation.
Returning objects cleanly
The most common bug in pool implementations is partial resets: a bullet returned to the pool still has its previous velocity or a lingering visual effect, so when it is next acquired it briefly appears in the wrong state before being properly configured. The reset function must clear every piece of mutable state the bullet carries. A checklist helps:
- Position reset to off-screen or pool origin
- Velocity zeroed
- Sprite frame and animation reset
- Any attached particle emitters stopped
- Collision layer/mask reset to default
- Active flag set last, after all other state is clean
Setting the active flag last is critical in a multi-threaded context: another thread scanning the pool for available slots must not see an object marked active before its state is fully initialized. Even in single-threaded engines, the discipline of ordering the flag last makes the reset logic easier to audit.
Pooling is one of those optimizations that pays a one-time implementation cost and then disappears as a concern. Once the pools are in place and correctly sized, frame time through the heaviest bullet spray stays flat. The GC still runs for other allocations in the game, but the highest-frequency allocation source — bullets — no longer contributes to it.