Object Pooling
Eliminate garbage collection stutters by pre-creating objects and recycling them instead of using Instantiate and Destroy at runtime.
The Problem: Instantiate and Destroy Are Expensive
In our infinite runner, chunks, obstacles, and collectibles constantly appear in front of the player and disappear behind them. The most obvious way to handle this is to call Instantiate() when you need a new object and Destroy() when you're done with it. This works, but it has a serious performance problem.
Every time you call Instantiate(), Unity has to:
- Allocate memory on the managed heap for the new GameObject and all its components.
- Copy all component data from the prefab to the new instance.
- Initialize the object (call Awake, OnEnable, etc.).
- Register it with Unity's internal systems (physics, rendering, etc.).
Every time you call Destroy(), Unity has to:
- Unregister the object from all internal systems.
- Call cleanup methods (OnDisable, OnDestroy).
- Mark the memory as free for garbage collection.
The real killer is step 4 of Destroy: garbage collection. C# uses a garbage collector (GC) to clean up memory that's no longer in use. The GC doesn't run continuously — it runs in bursts, and when it runs, it pauses your game. On mobile devices, a GC spike can cause a visible stutter that lasts 10-50 milliseconds. In a fast-paced runner where the player needs to react instantly, even a single frame of stutter can cause a death that feels unfair.
In our runner, we might spawn 3-5 obstacles and 10-15 coins per chunk, with chunks spawning every 1-2 seconds. That's potentially 20 Instantiate calls and 20 Destroy calls every couple of seconds. Without pooling, this generates continuous garbage, triggering GC collections every few seconds. On a phone, this makes the game feel laggy and unresponsive — exactly what kills a runner game.
The Solution: Object Pooling
Object pooling is a pattern where instead of creating and destroying objects at runtime, you pre-create a batch of objects when the game starts, disable them (so they're invisible and inactive), and then reuse them when needed.
Here's the lifecycle of a pooled object:
- Pre-creation (warm-up) — At game start, create 10 coin objects. Set them all to inactive (
gameObject.SetActive(false)). They exist in memory but are invisible and do nothing. - Get from pool (spawn) — When you need a coin, take one from the pool. Set it to active, position it, and call its setup method. This is essentially free — the object already exists.
- Return to pool (despawn) — When the player passes the coin (or collects it), don't destroy it. Instead, set it to inactive and put it back in the pool. It's now available for reuse.
- Repeat — The same 10 coin objects get used over and over, forever. Zero memory allocations after the initial warm-up.
Think of a restaurant. Without pooling, every time a customer orders water, the restaurant buys a new glass, serves the water, and then throws the glass in the trash when the customer leaves. With pooling, the restaurant has a cabinet of glasses. When a customer needs water, a glass comes out of the cabinet. When they're done, the glass is washed and put back. The restaurant never needs to buy new glasses (after the initial purchase), and no glass ever goes to the landfill.
The IPoolable Interface
Before building the pool itself, we need a contract that pooled objects must follow. When an object is taken from the pool, it needs to initialize itself. When it's returned, it needs to clean up. We define this contract as an interface.
If you're new to interfaces: an interface is a promise. Any class that "implements" an interface promises to have certain methods. Our pool doesn't care whether it's holding coins, obstacles, or chunks — it only cares that they implement IPoolable.
namespace InfiniteRunner.Core
{
/// <summary>
/// Interface for objects managed by the object pool.
/// Any MonoBehaviour that wants to be pooled must implement this.
/// </summary>
public interface IPoolable
{
/// <summary>
/// Called when the object is taken from the pool and "spawned" into the world.
/// Use this instead of Awake/Start for initialization that should happen
/// every time the object is reused, not just the first time.
///
/// Example: reset health to max, enable colliders, start animations.
/// </summary>
void OnSpawn();
/// <summary>
/// Called when the object is returned to the pool and "despawned."
/// Use this to clean up any state so the object is ready for reuse.
///
/// Example: stop particles, cancel coroutines, reset position.
/// </summary>
void OnDespawn();
}
}This interface has only two methods:
OnSpawn()— Called when the object leaves the pool and enters the game world. This is where you reset the object to its default state: enable its colliders, reset any timers, set its visual appearance, etc. Think of it as a replacement forStart()that runs every time the object is reused, not just the first time.OnDespawn()— Called when the object is removed from the game world and returned to the pool. This is where you stop any running coroutines, disable particle effects, and clean up anything that shouldn't persist to the next use.
With pooled objects, Awake() and Start() only run once — when the object is first created during warm-up. They do NOT run again when the object is reused. This is a common beginner mistake: putting reset logic in Start() and wondering why objects have stale data on their second use. Always put per-spawn initialization in OnSpawn().
Building the Generic ObjectPool<T> Class
Now for the pool itself. We'll make it generic (using <T>) so the same pool class can hold any type of component: coins, obstacles, chunks, particles, or anything else.
using System.Collections.Generic;
using UnityEngine;
namespace InfiniteRunner.Core
{
/// <summary>
/// A generic object pool that manages reusable instances of a prefab.
/// T must be a MonoBehaviour that implements IPoolable.
///
/// Usage:
/// var coinPool = new ObjectPool<Coin>(coinPrefab, parent, 20);
/// Coin coin = coinPool.Get(); // Take from pool
/// coinPool.Return(coin); // Return to pool
/// </summary>
/// <typeparam name="T">The component type on the pooled prefab.</typeparam>
public class ObjectPool<T> where T : MonoBehaviour, IPoolable
{
private readonly T prefab;
private readonly Transform parentContainer;
private readonly Queue<T> availableObjects;
private readonly List<T> allObjects;
private readonly int maxSize;
private readonly bool canGrow;
/// <summary>
/// How many objects are currently in the pool (inactive, waiting to be used).
/// </summary>
public int AvailableCount => availableObjects.Count;
/// <summary>
/// How many objects are currently active in the scene (taken from pool).
/// </summary>
public int ActiveCount => allObjects.Count - availableObjects.Count;
/// <summary>
/// Total objects managed by this pool (active + available).
/// </summary>
public int TotalCount => allObjects.Count;
/// <summary>
/// Creates a new object pool.
/// </summary>
/// <param name="prefab">The prefab to instantiate. Must have component T.</param>
/// <param name="parent">Parent transform for pooled objects (keeps Hierarchy clean).</param>
/// <param name="initialSize">How many objects to pre-create at startup.</param>
/// <param name="maxSize">Maximum pool size. 0 = unlimited.</param>
/// <param name="canGrow">If true, pool creates new objects when empty.
/// If false, Get() returns null when pool is exhausted.</param>
public ObjectPool(
T prefab,
Transform parent,
int initialSize = 10,
int maxSize = 0,
bool canGrow = true)
{
this.prefab = prefab;
this.parentContainer = parent;
this.maxSize = maxSize;
this.canGrow = canGrow;
availableObjects = new Queue<T>(initialSize);
allObjects = new List<T>(initialSize);
// Warm up: pre-create the initial batch
WarmUp(initialSize);
}
/// <summary>
/// Pre-creates objects and adds them to the pool.
/// Called automatically by the constructor, but you can call it again
/// to add more objects later (e.g., if you anticipate a busy section).
/// </summary>
/// <param name="count">Number of objects to create.</param>
public void WarmUp(int count)
{
for (int i = 0; i < count; i++)
{
if (maxSize > 0 && allObjects.Count >= maxSize)
{
Debug.LogWarning(
$"ObjectPool<{typeof(T).Name}>: " +
$"Cannot warm up beyond max size ({maxSize})."
);
break;
}
T instance = CreateInstance();
availableObjects.Enqueue(instance);
}
}
/// <summary>
/// Gets an object from the pool. If the pool is empty and canGrow is true,
/// a new object is created. If canGrow is false, returns null.
/// </summary>
/// <returns>An active, initialized pooled object, or null if exhausted.</returns>
public T Get()
{
T instance;
if (availableObjects.Count > 0)
{
// Take from the pool
instance = availableObjects.Dequeue();
}
else if (canGrow)
{
// Pool is empty but allowed to grow
if (maxSize > 0 && allObjects.Count >= maxSize)
{
Debug.LogWarning(
$"ObjectPool<{typeof(T).Name}>: " +
$"Max size ({maxSize}) reached. Cannot grow further."
);
return null;
}
Debug.Log(
$"ObjectPool<{typeof(T).Name}>: " +
$"Pool empty, creating new instance. " +
$"Consider increasing initial size. " +
$"(Total: {allObjects.Count + 1})"
);
instance = CreateInstance();
}
else
{
// Pool is empty and not allowed to grow
Debug.LogWarning(
$"ObjectPool<{typeof(T).Name}>: " +
$"Pool exhausted (size: {allObjects.Count}). " +
$"Returning null."
);
return null;
}
// Activate and initialize the object
instance.gameObject.SetActive(true);
instance.OnSpawn();
return instance;
}
/// <summary>
/// Returns an object to the pool for future reuse.
/// The object is deactivated and its OnDespawn method is called.
/// </summary>
/// <param name="instance">The object to return.</param>
public void Return(T instance)
{
if (instance == null)
{
Debug.LogWarning(
$"ObjectPool<{typeof(T).Name}>: " +
$"Tried to return a null object."
);
return;
}
// Clean up and deactivate
instance.OnDespawn();
instance.gameObject.SetActive(false);
instance.transform.SetParent(parentContainer);
availableObjects.Enqueue(instance);
}
/// <summary>
/// Returns ALL active objects to the pool.
/// Useful when resetting the game (e.g., on game over).
/// </summary>
public void ReturnAll()
{
foreach (T obj in allObjects)
{
if (obj != null && obj.gameObject.activeInHierarchy)
{
Return(obj);
}
}
}
/// <summary>
/// Destroys all objects in the pool and clears internal lists.
/// Call this only when you're completely done with the pool
/// (e.g., scene unload).
/// </summary>
public void Dispose()
{
foreach (T obj in allObjects)
{
if (obj != null)
{
Object.Destroy(obj.gameObject);
}
}
allObjects.Clear();
availableObjects.Clear();
}
// ---- Private Helpers ----
/// <summary>
/// Creates a single new instance, deactivates it, and tracks it.
/// </summary>
private T CreateInstance()
{
T instance = Object.Instantiate(prefab, parentContainer);
instance.gameObject.SetActive(false);
instance.name = $"{prefab.name}_pooled_{allObjects.Count}";
allObjects.Add(instance);
return instance;
}
}
}Let's examine the key design decisions:
Why a Queue?
We use a Queue<T> (first-in, first-out) for available objects rather than a Stack or List. A queue ensures that objects are rotated evenly — the object that's been sitting in the pool the longest gets used next. This prevents a situation where the same one or two objects get reused rapidly while others never get touched. Even distribution means more consistent memory behavior.
Why Track allObjects Separately?
The allObjects list tracks every object the pool has ever created, including ones currently in use. This is essential for ReturnAll() (which needs to find and return active objects) and Dispose() (which needs to destroy everything). The queue only contains available objects, so it can't do these operations alone.
Grow vs. Cap
The canGrow parameter controls what happens when the pool runs out of available objects:
- Grow mode (
canGrow = true) — Creates a new object on demand. This is safer (the game never fails to spawn something) but logs a warning so you know your initial size was too small. Use this during development. - Cap mode (
canGrow = false) — Returns null when exhausted. This is useful for effects like particles where having a hard limit is acceptable — if all particles are in use, it's fine to skip spawning one more.
If you see "Pool empty, creating new instance" in the console, it means your initial pool size is too small. Each time the pool grows, it calls Instantiate(), which is exactly what we're trying to avoid. Increase the initial size until the warnings stop. A good rule of thumb: your initial size should be the maximum number of that object type that can be visible on screen at once, plus a small buffer.
The PoolManager: Central Pool Registry
In our game, we'll have pools for chunks, obstacles, coins, particles, and more. Rather than each system creating and managing its own pools, we create a central manager that holds all pools and provides a single access point.
using System.Collections.Generic;
using UnityEngine;
namespace InfiniteRunner.Core
{
/// <summary>
/// Configuration for a single pool, set in the Inspector.
/// </summary>
[System.Serializable]
public class PoolConfig
{
[Tooltip("A unique identifier for this pool (e.g., 'Coin', 'Obstacle_Low').")]
public string poolId;
[Tooltip("The prefab to pool. Must have a component implementing IPoolable.")]
public GameObject prefab;
[Tooltip("How many instances to pre-create at startup.")]
[Range(1, 200)]
public int initialSize = 10;
[Tooltip("Maximum pool size. 0 = unlimited.")]
public int maxSize = 0;
[Tooltip("If true, the pool creates new objects when empty. " +
"If false, it returns null.")]
public bool canGrow = true;
}
/// <summary>
/// Central manager for all object pools in the game.
/// Attach this to a persistent GameObject in the scene.
///
/// Usage:
/// GameObject coin = PoolManager.Instance.Get("Coin");
/// PoolManager.Instance.Return("Coin", coin);
/// </summary>
public class PoolManager : MonoBehaviour
{
// ---- Singleton ----
private static PoolManager instance;
public static PoolManager Instance
{
get
{
if (instance == null)
{
Debug.LogError(
"PoolManager: No instance found in scene! " +
"Add PoolManager to a GameObject."
);
}
return instance;
}
}
[Header("Pool Configurations")]
[Tooltip("Define all pools here. Each entry creates one pool at startup.")]
[SerializeField] private PoolConfig[] poolConfigs;
// Internal storage: poolId -> pool of GameObjects
private Dictionary<string, Queue<GameObject>> pools;
private Dictionary<string, PoolConfig> configLookup;
private Dictionary<string, Transform> poolContainers;
private Dictionary<string, List<GameObject>> allTracked;
// ---- Lifecycle ----
private void Awake()
{
// Singleton enforcement
if (instance != null && instance != this)
{
Debug.LogWarning("PoolManager: Duplicate instance destroyed.");
Destroy(gameObject);
return;
}
instance = this;
InitializePools();
}
private void OnDestroy()
{
if (instance == this)
{
instance = null;
}
}
// ---- Public API ----
/// <summary>
/// Gets an object from the named pool.
/// The object is activated and its IPoolable.OnSpawn() is called.
/// </summary>
/// <param name="poolId">The pool identifier (must match a PoolConfig).</param>
/// <returns>An active GameObject, or null if the pool is exhausted.</returns>
public GameObject Get(string poolId)
{
if (!pools.ContainsKey(poolId))
{
Debug.LogError($"PoolManager: No pool with id '{poolId}' exists!");
return null;
}
Queue<GameObject> pool = pools[poolId];
GameObject obj;
if (pool.Count > 0)
{
obj = pool.Dequeue();
}
else
{
PoolConfig config = configLookup[poolId];
if (config.canGrow)
{
if (config.maxSize > 0 &&
allTracked[poolId].Count >= config.maxSize)
{
Debug.LogWarning(
$"PoolManager: Pool '{poolId}' at max size " +
$"({config.maxSize}). Cannot grow."
);
return null;
}
obj = CreateInstance(poolId, config);
Debug.Log(
$"PoolManager: Pool '{poolId}' grew to " +
$"{allTracked[poolId].Count} instances."
);
}
else
{
Debug.LogWarning(
$"PoolManager: Pool '{poolId}' exhausted."
);
return null;
}
}
obj.SetActive(true);
// Call OnSpawn on all IPoolable components
IPoolable[] poolables = obj.GetComponents<IPoolable>();
foreach (IPoolable poolable in poolables)
{
poolable.OnSpawn();
}
return obj;
}
/// <summary>
/// Returns an object to the named pool.
/// The object is deactivated and its IPoolable.OnDespawn() is called.
/// </summary>
/// <param name="poolId">The pool identifier.</param>
/// <param name="obj">The GameObject to return.</param>
public void Return(string poolId, GameObject obj)
{
if (obj == null)
{
Debug.LogWarning(
$"PoolManager: Tried to return null to pool '{poolId}'."
);
return;
}
if (!pools.ContainsKey(poolId))
{
Debug.LogError(
$"PoolManager: No pool with id '{poolId}' exists! " +
$"Destroying object instead."
);
Destroy(obj);
return;
}
// Call OnDespawn on all IPoolable components
IPoolable[] poolables = obj.GetComponents<IPoolable>();
foreach (IPoolable poolable in poolables)
{
poolable.OnDespawn();
}
obj.SetActive(false);
obj.transform.SetParent(poolContainers[poolId]);
pools[poolId].Enqueue(obj);
}
/// <summary>
/// Returns all active objects across all pools.
/// Call this on game reset / game over.
/// </summary>
public void ReturnAll()
{
foreach (string poolId in pools.Keys)
{
ReturnAll(poolId);
}
}
/// <summary>
/// Returns all active objects in a specific pool.
/// </summary>
/// <param name="poolId">The pool to clear.</param>
public void ReturnAll(string poolId)
{
if (!allTracked.ContainsKey(poolId)) return;
// Iterate over a copy to avoid modification during iteration
List<GameObject> tracked = allTracked[poolId];
for (int i = tracked.Count - 1; i >= 0; i--)
{
GameObject obj = tracked[i];
if (obj != null && obj.activeInHierarchy)
{
Return(poolId, obj);
}
}
}
/// <summary>
/// Returns pool statistics for debugging.
/// </summary>
/// <param name="poolId">The pool to query.</param>
/// <returns>A formatted string with pool stats.</returns>
public string GetPoolStats(string poolId)
{
if (!pools.ContainsKey(poolId))
return $"Pool '{poolId}' not found.";
int total = allTracked[poolId].Count;
int available = pools[poolId].Count;
int active = total - available;
return $"Pool '{poolId}': {active} active, " +
$"{available} available, {total} total";
}
// ---- Private Helpers ----
private void InitializePools()
{
pools = new Dictionary<string, Queue<GameObject>>();
configLookup = new Dictionary<string, PoolConfig>();
poolContainers = new Dictionary<string, Transform>();
allTracked = new Dictionary<string, List<GameObject>>();
if (poolConfigs == null || poolConfigs.Length == 0)
{
Debug.LogWarning("PoolManager: No pool configs defined!");
return;
}
foreach (PoolConfig config in poolConfigs)
{
if (string.IsNullOrEmpty(config.poolId))
{
Debug.LogError("PoolManager: A pool config has an empty ID!");
continue;
}
if (config.prefab == null)
{
Debug.LogError(
$"PoolManager: Pool '{config.poolId}' has no prefab!"
);
continue;
}
if (pools.ContainsKey(config.poolId))
{
Debug.LogError(
$"PoolManager: Duplicate pool ID '{config.poolId}'!"
);
continue;
}
// Create a container GameObject to keep the Hierarchy organized
GameObject container = new GameObject($"Pool_{config.poolId}");
container.transform.SetParent(transform);
poolContainers[config.poolId] = container.transform;
// Initialize the queue and tracking list
pools[config.poolId] = new Queue<GameObject>(config.initialSize);
configLookup[config.poolId] = config;
allTracked[config.poolId] = new List<GameObject>(config.initialSize);
// Warm up: pre-create objects
for (int i = 0; i < config.initialSize; i++)
{
CreateInstance(config.poolId, config);
}
Debug.Log(
$"PoolManager: Initialized pool '{config.poolId}' " +
$"with {config.initialSize} instances."
);
}
}
private GameObject CreateInstance(string poolId, PoolConfig config)
{
GameObject obj = Instantiate(config.prefab, poolContainers[poolId]);
obj.SetActive(false);
obj.name = $"{config.prefab.name}_{allTracked[poolId].Count}";
allTracked[poolId].Add(obj);
pools[poolId].Enqueue(obj);
return obj;
}
}
}Let's walk through the important pieces:
The PoolConfig Class
The [System.Serializable] attribute makes PoolConfig show up in the Inspector as a configurable entry. Each config defines one pool: a unique ID, the prefab, initial size, max size, and growth behavior. You configure all your pools in one place on the PoolManager component.
The Singleton Pattern
The PoolManager uses the singleton pattern so any script can access it via PoolManager.Instance without needing a reference. We enforce single-instance by destroying duplicates in Awake().
Pool Containers
Each pool gets its own child GameObject (e.g., "Pool_Coin", "Pool_Obstacle_Low") to keep the Hierarchy window organized. Without containers, you'd have hundreds of loose objects cluttering the Hierarchy, making it impossible to find anything during debugging.
The Get/Return API
The API is simple: Get("Coin") gives you a coin, Return("Coin", coinObj) puts it back. The manager handles activation, deactivation, and calling the IPoolable methods automatically.
Create an empty GameObject called "PoolManager" in your scene. Add the PoolManager component. In the Inspector, set the Pool Configs array size and fill in each entry. For our runner, start with pools for: "Chunk_Straight" (initial: 5), "Chunk_Dense" (initial: 3), "Coin" (initial: 30), "Obstacle_Low" (initial: 10), "Obstacle_High" (initial: 10). You'll add more pools as we create more object types in later chapters.
Making Our Chunks Poolable
Let's update the ChunkData class from Chapter 11 to implement IPoolable so chunks can be managed by the pool.
using UnityEngine;
namespace InfiniteRunner.World
{
/// <summary>
/// Holds all metadata for a single world chunk.
/// Now implements IPoolable so it can be managed by the object pool.
/// </summary>
public class ChunkData : MonoBehaviour, InfiniteRunner.Core.IPoolable
{
[Header("Chunk Dimensions")]
[SerializeField] private float chunkLength = 20f;
[Header("Snap Points")]
[SerializeField] private Transform snapPointStart;
[SerializeField] private Transform snapPointEnd;
[Header("Spawn Points")]
[SerializeField] private Transform[] obstacleSpawnPoints;
[SerializeField] private Transform[] collectibleSpawnPoints;
// Track which objects were spawned ON this chunk so we can return them
private readonly System.Collections.Generic.List<GameObject>
spawnedObjects = new System.Collections.Generic.List<GameObject>();
// ---- Public Properties (unchanged from Chapter 11) ----
public float ChunkLength => chunkLength;
public Vector3 StartPosition => snapPointStart.position;
public Vector3 EndPosition => snapPointEnd.position;
public Transform[] ObstacleSpawnPoints => obstacleSpawnPoints;
public Transform[] CollectibleSpawnPoints => collectibleSpawnPoints;
// ---- IPoolable Implementation ----
/// <summary>
/// Called when this chunk is taken from the pool and placed in the world.
/// </summary>
public void OnSpawn()
{
// The chunk is freshly activated.
// Obstacles and collectibles will be spawned separately by the
// WorldGenerator, which calls RegisterSpawnedObject for each one.
}
/// <summary>
/// Called when this chunk is returned to the pool.
/// Returns all objects that were spawned on this chunk back to their pools.
/// </summary>
public void OnDespawn()
{
// Return all spawned objects (obstacles, collectibles) to their pools
foreach (GameObject obj in spawnedObjects)
{
if (obj != null && obj.activeInHierarchy)
{
// Try to determine the pool ID from the object's name
// The PoolManager names objects like "PrefabName_0"
string poolId = GetPoolIdFromObject(obj);
if (!string.IsNullOrEmpty(poolId))
{
InfiniteRunner.Core.PoolManager.Instance.Return(poolId, obj);
}
}
}
spawnedObjects.Clear();
// Reset transform
transform.position = Vector3.zero;
transform.rotation = Quaternion.identity;
}
// ---- Chunk-Specific Methods ----
/// <summary>
/// Register an object (obstacle, collectible) as belonging to this chunk.
/// When the chunk is despawned, all registered objects are returned to pools.
/// </summary>
public void RegisterSpawnedObject(GameObject obj)
{
if (obj != null)
{
spawnedObjects.Add(obj);
}
}
private string GetPoolIdFromObject(GameObject obj)
{
// Objects are named "PrefabName_N" by the pool manager.
// The pool ID matches the prefab name.
string name = obj.name;
int underscoreIndex = name.LastIndexOf('_');
if (underscoreIndex > 0)
{
return name.Substring(0, underscoreIndex);
}
return name;
}
// ---- Validation & Gizmos (unchanged from Chapter 11) ----
private void OnValidate()
{
if (snapPointStart != null && snapPointEnd != null)
{
float calculatedLength = snapPointEnd.localPosition.z
- snapPointStart.localPosition.z;
if (calculatedLength > 0f)
{
chunkLength = calculatedLength;
}
}
}
private void OnDrawGizmos()
{
if (obstacleSpawnPoints != null)
{
Gizmos.color = Color.red;
foreach (Transform point in obstacleSpawnPoints)
{
if (point != null)
{
Gizmos.DrawWireSphere(point.position, 0.3f);
}
}
}
if (collectibleSpawnPoints != null)
{
Gizmos.color = Color.yellow;
foreach (Transform point in collectibleSpawnPoints)
{
if (point != null)
{
Gizmos.DrawWireSphere(point.position, 0.2f);
}
}
}
}
}
}The key addition is the spawnedObjects list. When the World Generator spawns obstacles and coins on a chunk, it calls RegisterSpawnedObject() to tell the chunk "this obstacle belongs to you." When the chunk is despawned, it automatically returns all those objects to their respective pools. This ensures nothing gets orphaned.
Pool Warm-Up: Pre-filling at Game Start
The warm-up phase happens in InitializePools() when the PoolManager starts. All Instantiate() calls happen during the loading screen, before gameplay begins. This means:
- Zero allocations during gameplay (assuming pools are big enough).
- A slightly longer load time (usually under 1 second for our game).
- Smooth, consistent frame rates once playing.
Here's a practical formula for initial pool sizes:
- Chunks: Number of visible chunks at once + 2 buffer. If 5 chunks are visible, pool size = 7.
- Obstacles: Max obstacles per chunk x visible chunks + buffer. If 5 obstacles per chunk and 5 visible chunks, pool size = 30.
- Coins: Max coins per chunk x visible chunks + buffer. If 10 coins per chunk, pool size = 60.
It's better to over-allocate slightly than to trigger runtime growth. A few extra inactive GameObjects cost almost nothing in memory.
Returning Objects to the Pool
Objects get returned to the pool in several situations:
- Chunk passes behind the player — The WorldGenerator detects that a chunk is far enough behind the player and returns it (and all its spawned objects) to the pool.
- Coin is collected — The player picks up a coin, so it's returned to the pool immediately.
- Game over — All active objects across all pools are returned via
PoolManager.Instance.ReturnAll(). - Game restart — Same as game over — return everything and start fresh.
Here's an example of how a collectible coin script would use the pool:
using UnityEngine;
using InfiniteRunner.Core;
namespace InfiniteRunner.Gameplay
{
/// <summary>
/// A collectible coin that can be pooled and reused.
/// Full implementation in Chapter 16.
/// </summary>
public class Coin : MonoBehaviour, IPoolable
{
[SerializeField] private float rotationSpeed = 90f;
private Collider coinCollider;
private void Awake()
{
// Awake runs ONCE when the object is first created (during warm-up).
// Cache component references here.
coinCollider = GetComponent<Collider>();
}
public void OnSpawn()
{
// Runs EVERY TIME the coin is taken from the pool.
// Reset everything to default state.
coinCollider.enabled = true;
transform.rotation = Quaternion.identity;
}
public void OnDespawn()
{
// Runs EVERY TIME the coin is returned to the pool.
// Clean up any active state.
coinCollider.enabled = false;
}
private void Update()
{
// Simple spin animation
transform.Rotate(Vector3.up, rotationSpeed * Time.deltaTime);
}
private void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Player"))
{
// Player collected this coin
// Fire an event (Chapter 7's event system)
// GameEvents.OnCoinCollected?.Invoke();
// Return to pool instead of Destroy
PoolManager.Instance.Return("Coin", gameObject);
}
}
}
}This is the most important rule of object pooling: never, ever call Destroy() on a pooled object. If you destroy it, the pool still has a reference to it, and the next time it tries to reuse that slot, it'll get a null reference. Always use PoolManager.Instance.Return() instead. To enforce this, you could add a component that overrides OnDestroy() and logs an error if it's called on a pooled object.
Debug Visualization
During development, it's very helpful to see how your pools are performing. Let's add a simple debug overlay that shows pool stats on screen.
using UnityEngine;
namespace InfiniteRunner.Core
{
/// <summary>
/// Displays pool statistics as an on-screen overlay during development.
/// Attach to any GameObject. Remove or disable before shipping.
/// </summary>
public class PoolDebugOverlay : MonoBehaviour
{
[SerializeField] private bool showOverlay = true;
[SerializeField] private KeyCode toggleKey = KeyCode.F3;
[Tooltip("Pool IDs to display. Leave empty to show all.")]
[SerializeField] private string[] poolIdsToShow;
private GUIStyle labelStyle;
private GUIStyle headerStyle;
private void Update()
{
if (Input.GetKeyDown(toggleKey))
{
showOverlay = !showOverlay;
}
}
private void OnGUI()
{
if (!showOverlay || PoolManager.Instance == null) return;
// Initialize styles on first use
if (labelStyle == null)
{
labelStyle = new GUIStyle(GUI.skin.label)
{
fontSize = 14,
normal = { textColor = Color.white }
};
headerStyle = new GUIStyle(GUI.skin.label)
{
fontSize = 16,
fontStyle = FontStyle.Bold,
normal = { textColor = Color.cyan }
};
}
float x = 10f;
float y = 10f;
float lineHeight = 20f;
GUI.Label(new Rect(x, y, 400, lineHeight),
"Pool Stats (F3 to toggle)", headerStyle);
y += lineHeight + 5f;
string[] ids = poolIdsToShow != null && poolIdsToShow.Length > 0
? poolIdsToShow
: new string[] {
"Chunk_Straight", "Chunk_Dense", "Chunk_Narrow",
"Chunk_Safe", "Coin", "Obstacle_Low", "Obstacle_High"
};
foreach (string poolId in ids)
{
string stats = PoolManager.Instance.GetPoolStats(poolId);
GUI.Label(new Rect(x, y, 500, lineHeight), stats, labelStyle);
y += lineHeight;
}
}
}
}Press F3 at any time during play to toggle the overlay. You'll see each pool's active, available, and total counts. This is invaluable for tuning pool sizes: play the game for a while, watch the numbers, and adjust your initial sizes until you never see "0 available."
What We Built
Object pooling is one of the most impactful optimizations you can make in a game with lots of spawning and despawning. Here's what we now have:
- IPoolable — A simple interface with
OnSpawn()andOnDespawn()that any poolable object must implement. - ObjectPool<T> — A generic, reusable pool class with warm-up, get/return, growth control, and return-all functionality.
- PoolManager — A central singleton that manages all pools, configured entirely through the Inspector.
- Updated ChunkData — Chunks now implement
IPoolableand track their spawned objects for clean despawning. - PoolDebugOverlay — An on-screen debug display for monitoring pool health.
In the next chapter, we'll put the chunk system and object pooling together to build the World Generator — the system that spawns chunks ahead of the player and recycles them as the player runs forward.
Object pooling is done. Let's commit to our feature branch.
git add .
git commit -m "Implement generic object pooling system"
Notice we're on the feature/world-generation branch. You can verify with git branch — the current branch has an asterisk (*) next to it. All commits we make here are separate from main.