Instantiate and Destroy are the obvious tools for spawning game objects. They’re also a reliable source of frame spikes when you’re calling them every few seconds.
The Problem
The first version of the Endless Runner spawned obstacles with Instantiate() and removed them with Destroy(). Each chunk is a scrolling world segment that populates a set of obstacle slots when it enters the camera view, then clears them when it exits. On a modern PC the frame times looked fine — but Unity’s profiler told a different story. Every time a new chunk scrolled into view and populated its obstacle slots, there was a GC allocation spike. On lower-end devices those spikes turned into visible hitches.
The cause is straightforward: Instantiate allocates memory for the new GameObject and all its components. Destroy doesn’t free that memory immediately — it queues the object for destruction, and the garbage collector reclaims it later in a stop-the-world pause. The more objects you spawn and destroy per second, the more GC pressure you generate. In an endless runner where chunks stream continuously, that pressure compounds.
The Pool Implementation
The goal was a single allocation site at startup with zero allocations during normal gameplay. A Queue<GameObject> rather than a generic Pool<T> keeps it simple — every pooled object in this project is a GameObject, so the type parameter buys nothing. Each pool manages one prefab type; a static PoolManager maps prefab references to their pools.
// ObjectPooling/ObjectPool.cs
public class ObjectPool
{
readonly GameObject _prefab;
readonly Queue<GameObject> _pool = new();
readonly Transform _parent;
public ObjectPool(GameObject prefab, int initialSize, Transform parent)
{
_prefab = prefab;
_parent = parent;
for (int i = 0; i < initialSize; i++)
_pool.Enqueue(CreateNew());
}
public GameObject Get()
{
var obj = _pool.Count > 0 ? _pool.Dequeue() : CreateNew();
obj.SetActive(true);
return obj;
}
public void Release(GameObject obj)
{
obj.SetActive(false);
obj.transform.SetParent(_parent);
_pool.Enqueue(obj);
}
GameObject CreateNew()
{
var obj = Object.Instantiate(_prefab, _parent);
obj.SetActive(false);
// Tag the object so it can release itself
obj.AddComponent<PooledObject>().Pool = this;
return obj;
}
}
PooledObject is a component tag that holds a reference back to its pool. Obstacles call Release() on themselves — they don’t need to know which pool they came from or how to reach PoolManager:
// ObjectPooling/PooledObject.cs
public class PooledObject : MonoBehaviour
{
public ObjectPool Pool { get; set; }
public void Release()
{
// If this object has children that are also pooled, release them first
foreach (var child in GetComponentsInChildren<PooledObject>(true))
if (child != this) child.Release();
Pool?.Release(gameObject);
}
}
A static PoolManager initialises pools at scene load and is the only place that hands out or takes back objects:
// ObjectPooling/PoolManager.cs
public static class PoolManager
{
static readonly Dictionary<GameObject, ObjectPool> _pools = new();
public static void CreatePool(GameObject prefab, int size, Transform parent)
{
if (!_pools.ContainsKey(prefab))
_pools[prefab] = new ObjectPool(prefab, size, parent);
}
public static GameObject Get(GameObject prefab)
{
if (_pools.TryGetValue(prefab, out var pool))
return pool.Get();
Debug.LogWarning($"No pool for {prefab.name} — falling back to Instantiate");
return Object.Instantiate(prefab);
}
public static void Release(GameObject prefab, GameObject obj)
{
if (_pools.TryGetValue(prefab, out var pool))
pool.Release(obj);
else
Object.Destroy(obj);
}
}
Chunk.OnEnable() calls PoolManager.Get() to populate its obstacle slots; Chunk.OnDisable() calls Release() on each obstacle, returning them to their pools. The chunk itself is the consumer — it doesn’t care what’s in the pool, only that it can borrow and return.
What I Learned
Pool sizing is the main trade-off. Pre-allocate too few and you get cache misses that fall back to Instantiate, defeating the purpose. Pre-allocate too many and you waste memory on objects that never get used. With the correct size, GC allocation during chunk population drops to near-zero after the initial warm-up — Instantiate is only called on a cache miss, which doesn’t happen during normal gameplay. The right number depends on the maximum concurrent active count, which you need to measure or calculate from your spawn rates.
PoolManager being static is the design I’d change. A static class can’t be injected or mocked — any test that exercises spawning implicitly exercises the pool. An IPoolManager interface with the current static class as the default implementation would cost one extra line per call site and make ObstacleSpawner fully testable in isolation.
PooledObject as a self-release tag removes coupling in the right direction. Obstacles don’t need to know which pool they came from or how to call PoolManager — they call Release() on themselves. That’s a useful pattern beyond pooling: components that clean themselves up without needing an external manager.
GetComponentsInChildren<PooledObject>(true) on release is an implicit contract. If a parent obstacle has pooled child objects — particles, debris — they get returned automatically. But if a child outlives its parent between OnDisable and Release, you get a double-release. Worth knowing before you rely on this with complex prefabs.
The pool is boring infrastructure now, which means it’s doing its job.