Object Pools

24 min read

Free-list object pools for managed C# instances and Unity objects that let you eliminate GC spikes from bullets, particles, audio sources, and enemies by paying the allocation cost once during loading and recycling instances forever after.

Summary

Object pooling keeps freed instances in a free list and hands them back out instead of allocating fresh ones. When a class is created and destroyed many times per second (bullets, damage numbers, particle bursts, AI requests), that churn is what makes the garbage collector stall; a pool removes the churn by reusing instances. The allocation cost is paid up front during loading, and the steady-state cost is a pointer pop and a state reset.

Scylla Core provides two pool implementations that share the same shape but specialize on the storage type.:

  • ScyllaGenericObjectPool<T> pools any reference type and is the right pick for plain managed objects (StringBuilder, List<T>, custom request structs, work items).
  • ScyllaObjectPool<T> constrains T to UnityEngine.Object and is the right pick for pooled Unity instances: prefab clones, sprites, materials, anything created with UnityEngine.Object.Instantiate.

Both implementations are LIFO free lists with optional OnGet/OnRelease/OnDestroy callbacks, an optional CollectionCheck debug mode that detects double-release, optional thread-safe wrappers, and a capacity ceiling that destroys overflow instances rather than letting the pool grow without bound.

The Unity pool has one quality-of-life behavior worth calling out up front. For GameObject and Component instances, Get automatically calls gameObject.SetActive(true) and Release automatically calls gameObject.SetActive(false). This is the desired behavior for almost every prefab pool, so the framework runs it without needing an OnGet/OnRelease callback. Callbacks remain available for state resets that go beyond enabling and disabling the GameObject (resetting transform, clearing component fields, rewinding animators), but the activation toggle does not need to be written by hand.

The pools live inside the Scylla.Core.Structures namespace alongside the rest of the collections and implement the IScyllaCollection<T> interface, so they participate in the broader contract (Count, IsEmpty, Capacity, CopyTo, enumeration). Pooled tweens are already handled by ScyllaTween internally and need no separate pool; see the Tween page.

Use object pools for:

  • Projectiles, particles, hit effects, and short-lived gameplay objects.
  • Floating damage numbers and combat text.
  • AI work items, pathfinding requests, ability events, and any per-frame command struct.
  • Reused string builders, list builders, and other allocation-heavy helpers.
  • Network message envelopes and serialization buffers.
  • Any class or Unity object instantiated more than a few times per second.

Skip the pool when:

  • The type is a small struct that lives on the stack.
  • The type is allocated rarely (the pool overhead exceeds the GC cost).
  • The lifetime is unbounded and individual instances are kept indefinitely (a pool does not save anything; a flat list works better).

Features

The features below apply to both pool implementations except where noted.

  • Two specialized implementations. ScyllaGenericObjectPool<T> for plain managed types and ScyllaObjectPool<T> for Unity objects. The two share most of the API and most of the builder; differences are minimal and intentional.
  • Fluent builder. CreateBuilder() exposes WithCreateFunc, WithCapacity, WithMaxSize, OnGet, OnRelease, OnDestroy, CollectionCheck, AllowCreate, and Synchronized setters. The Unity variant adds WithPrefab; the generic variant adds DisposeOnDestroy.
  • Get and TryGet. Get() throws when the pool is empty and AllowCreate is false; TryGet(out T) returns false instead. Use TryGet whenever the pool may legitimately be empty (cap-by-design pools, exhaustion-tolerant code).
  • Auto-activation for Unity components. ScyllaObjectPool<T> calls gameObject.SetActive(true) on Get and gameObject.SetActive(false) on Release automatically when T is GameObject or Component. No callback required for the common case.
  • Lifecycle callbacks. OnGet, OnRelease, and OnDestroy run at the obvious points. OnGet is the right place to reset per-use state; OnRelease is the right place to clear references that should not survive the pool round trip; OnDestroy runs when an instance is permanently destroyed (overflow, Clear(destroy: true), or IDisposable.Dispose for the generic pool’s DisposeOnDestroy mode).
  • Capacity hint plus hard ceiling. WithCapacity(n) is the initial free-list size and a hint for early allocations. WithMaxSize(n) is a hard ceiling: instances released when the pool is already at MaxSize are destroyed instead of stored, so steady-state memory is bounded.
  • Prewarm. Prewarm(count) allocates count instances and parks them in the free list. Call during loading screens so the first frame of gameplay does not pay the allocation cost.
  • Bulk control. Clear() empties the free list; Clear(destroy: true) additionally destroys every parked instance, invoking OnDestroy (and IDisposable.Dispose for generic pools with DisposeOnDestroy).
  • Double-release detection. CollectionCheck(true) enables an O(n) check inside Release that throws when the same instance is released twice. Useful in development; disable for shipping builds.
  • Externally-created instance ingestion. TryAdd(T item) lets callers hand pre-existing instances to the pool (for example, items recovered from a scene at startup). Symmetric TryRemove(out T) and TryPeek(out T) cover the read side.
  • IScyllaCollection<T> conformance. The pools expose Count, CountAll, IsEmpty, Capabilities, ThreadSafety, SyncRoot, CopyTo(Span<T>), and enumeration. Standard tooling that operates on collections works on pools.
  • Synchronized wrappers. Every pool exposes AsSynchronized(object syncRoot = null) and the builder’s Synchronized setter produces the same wrapper directly. The wrapper serializes every call with a lock; compound operations still need explicit SyncRoot use.
  • DisposeOnDestroy (generic pool only). When the pooled type implements IDisposable, set DisposeOnDestroy(true) to call Dispose() automatically when the instance is destroyed (overflow or Clear(true)).

Pool types

TypeStoresNotes
ScyllaGenericObjectPool<T>Any reference typeDefault for plain C# objects. Requires WithCreateFunc (no default factory). Optional DisposeOnDestroy(true) for types that implement IDisposable.
ScyllaObjectPool<T>T : UnityEngine.ObjectDefault for prefab-clone pools. WithPrefab(prefab) sets a default factory that runs Object.Instantiate(prefab); WithCreateFunc overrides that factory. Automatic SetActive(true/false) on Get/Release for GameObject and Component. Overflow runs Object.Destroy (or DestroyImmediate outside play mode).

Both implementations are LIFO free lists. The most recently released instance is the next one returned by Get, which keeps cache locality warm for short ping-pong patterns (release then immediately get again).

Recipes

These recipes cover every public surface in the pool system. Each one is a self-contained answer to a gameplay or architecture problem, so jump to whichever matches what you are building. Start with “Pick the right pool for your object type” if you are new to the API; the remaining recipes assume you already have a pool in hand.

Pick the right pool for your object type

Problem. You have some frequently-created object in your game and you want to pool it, but you are not sure which of the two pool types to reach for. Choosing wrong typically means a refactor later or missing a key feature (like auto-activation) that the right type gives you for free.

Solution. The decision tree is short. If the object is a UnityEngine.Object (a prefab clone, a sprite, a material), use ScyllaObjectPool<T>. If it is a plain managed class (StringBuilder, a custom request struct, a list), use ScyllaGenericObjectPool<T>. If it is a plain managed class that also implements IDisposable, use ScyllaGenericObjectPool<T> with .DisposeOnDestroy(true).

For tweens specifically, no pool is needed; ScyllaTween runs its own internal pool. See the Tween page for the prewarm helper.

Pool bullets for a bullet-hell shooter

Problem. You are building a bullet-hell shooter and every time the player fires a volley, a fresh Bullet component is Instantiated, and when it expires, it is Destroyd. During a dense wave this happens hundreds of times per second, the GC pressure spikes visibly in the profiler, and frame time starts stuttering exactly when the game is most intense.

Solution. A ScyllaObjectPool<Bullet> wraps the bullet prefab and reuses instances across volleys. You prewarm during scene load so the first frame of combat is free, and the pool’s auto-activation takes care of SetActive(true/false) without any extra callback. State you need to reset between shots (velocity, impact state) goes in OnGet.

public class Weapon : MonoBehaviour
{
    [SerializeField] private Bullet _bulletPrefab;

    private ScyllaObjectPool<Bullet> _bullets;


    private void Awake()
    {
        _bullets = ScyllaObjectPool<Bullet>.CreateBuilder()
            .WithPrefab(_bulletPrefab)        /* Default factory: Object.Instantiate(_bulletPrefab).  */
            .WithCapacity(32)                 /* Initial free-list size.                              */
            .WithMaxSize(256)                 /* Bullets beyond 256 in flight are destroyed.          */
            .OnGet(b => b.ResetVelocity())    /* Per-instance state reset when leaving the pool.      */
            .OnRelease(b => b.ClearImpact())  /* Clear references before the instance is parked.      */
            .Build();

        /* Prewarm 64 bullets during scene load so the first combat frame pays nothing. */
        _bullets.Prewarm(64);
    }


    public void Fire(Vector3 origin, Vector3 velocity)
    {
        /* SetActive(true) runs automatically because Bullet is a Component;
         * no OnGet callback needed for the activation itself. */
        var bullet = _bullets.Get();
        bullet.transform.position = origin;
        bullet.Launch(velocity);
    }


    public void OnBulletExpired(Bullet bullet)
    {
        /* SetActive(false) runs automatically before the OnRelease callback fires. */
        _bullets.Release(bullet);
    }
}

Reuse enemy instances across waves

Problem. Say you are building a wave-based survival game or a twin-stick shooter. Between waves, enemies are destroyed and fresh ones are created for the next wave; with dozens of enemies per wave the allocations add up, the GC stalls at exactly the worst moment (the start of a new wave), and the editor profiler shows a spike every 30 seconds like clockwork.

Solution. Pool the enemy component. Between waves you call Release on every enemy that died; at the start of the next wave you call Get to revive them. The pool’s OnGet callback resets each enemy’s health, position, and AI state to match the wave definition, so the player never sees a “returning” enemy in an inconsistent state.

public class EnemyPool : MonoBehaviour
{
    [SerializeField] private Enemy _enemyPrefab;

    private ScyllaObjectPool<Enemy> _enemies;


    private void Awake()
    {
        _enemies = ScyllaObjectPool<Enemy>.CreateBuilder()
            .WithPrefab(_enemyPrefab)
            .WithCapacity(50)
            .WithMaxSize(200)
            .OnGet(e =>
            {
                /* Full state reset so the enemy looks fresh to the player. */
                e.ResetHealth();
                e.ResetAI();
                e.ClearStatusEffects();
            })
            .OnRelease(e =>
            {
                /* Clear any component references so they do not keep scene
                 * objects alive while the enemy is parked in the free list. */
                e.ClearTarget();
            })
            .Build();

        /* Prewarm the first wave's budget during the loading screen. */
        _enemies.Prewarm(50);
    }


    public Enemy SpawnEnemy(Vector3 position)
    {
        /* OnGet fires first (health/AI reset), then SetActive(true) runs automatically. */
        var enemy = _enemies.Get();
        enemy.transform.position = position;
        return enemy;
    }


    public void RecycleEnemy(Enemy enemy)
    {
        /* SetActive(false) runs first, then OnRelease clears the target reference. */
        _enemies.Release(enemy);
    }
}

Avoid GC spikes from particle-style hit effects

Problem. You want visual feedback on every hit in your action game: a spark effect, a screen shake impulse object, a floating damage number. These objects are very short-lived (they play their effect and disappear in under a second), but spawning and destroying them per-hit creates a constant drumbeat of small allocations that the GC eventually decides to clean up, choosing the worst possible moment mid-combo.

Solution. Pool the effect prefabs. The auto-activation behavior is the key quality-of-life feature here: when T is Component, Get calls SetActive(true) and Release calls SetActive(false) without any callback needed. The effect itself drives its own return-to-pool call (typically at the end of its animation or particle system).

/* A pool of spark-effect prefabs for melee hit feedback. */
var sparkPool = ScyllaObjectPool<SparkEffect>.CreateBuilder()
    .WithPrefab(sparkEffectPrefab)
    .WithCapacity(16)
    .WithMaxSize(64)
    .OnGet(s => s.ResetTimer())   /* Restart the effect's internal countdown. */
    .Build();

/* Spawn a spark at the hit point. SetActive(true) fires automatically. */
var spark = sparkPool.Get();
spark.transform.position = hitPoint;
spark.transform.rotation = hitNormal;

/* Inside SparkEffect, when the animation finishes: */
/* sparkPool.Release(this); - SetActive(false) fires automatically. */

Pool plain C# objects to eliminate hot-path allocations

Problem. Your game allocates plain C# objects in a hot path: temporary StringBuilder instances for building log lines or UI strings, scratch List<int> instances for a path query, or custom command structs passed through your event pipeline. None of these are Unity objects, but they still trigger GC pressure when they are created and discarded per-frame.

Solution. ScyllaGenericObjectPool<T> covers any reference type. The builder requires a WithCreateFunc factory (there is no default factory for plain C# types); everything else is optional. This recipe covers the fundamental Get/Release pattern and the pool’s properties.

/* A pool of reusable StringBuilder instances for UI label formatting. */
var sbPool = ScyllaGenericObjectPool<StringBuilder>.CreateBuilder()
    .WithCreateFunc(() => new StringBuilder(256))   /* Required. Factory invoked on pool miss.         */
    .WithCapacity(8)                                /* Initial free-list size; grows automatically.    */
    .WithMaxSize(32)                                /* Hard ceiling; releases over this are destroyed. */
    .OnGet(sb => sb.Clear())                        /* Reset state when the instance leaves the pool.  */
    .OnRelease(sb => { })                           /* No extra cleanup needed before storing back.    */
    .CollectionCheck(false)                         /* Off by default; enable during development.      */
    .Build();

/* Take an instance. Pool miss creates a new StringBuilder; pool hit returns the free-list top. */
var sb = sbPool.Get();
sb.Append("Wave ");
sb.Append(waveNumber);
waveLabel.text = sb.ToString();

/* Return it. OnRelease runs first, then the instance is stored (or destroyed if at MaxSize). */
var stored = sbPool.Release(sb);

/* Use TryGet whenever the pool may legitimately be empty by design. */
if (sbPool.TryGet(out var another))
{
    FormatTooltip(another);
    sbPool.Release(another);
}

/* Inspect pool state for diagnostics or UI. */
var inFreeList = sbPool.Count;     /* Instances currently parked and available.              */
var totalLive  = sbPool.CountAll;  /* Pooled plus outstanding (created but not yet released). */
var capacity   = sbPool.Capacity;  /* Current free-list capacity (may have grown past hint). */
var ceiling    = sbPool.MaxSize;   /* The hard cap set at construction.                      */

Prewarm a pool to eliminate first-frame allocation spikes

Problem. You have a pool set up for bullets, hit effects, or enemies, but you are still seeing an allocation spike on the first frame of gameplay, because that is when the pool misses and creates its initial instances. A bullet-hell pattern or a big enemy wave makes this first-frame spike obvious: a stutter exactly when the action starts.

Solution. Prewarm(count) pre-allocates instances during loading, before the player is watching. Call it from your scene initialization, a loading screen handler, or anywhere the current frame is not a gameplay frame.

/* During loading: allocate 64 enemies up front so the first wave spawns instantly. */
var created = _enemies.Prewarm(64);

/* The return value tells you how many were actually created. Prewarm respects MaxSize
 * and stops once the pool is full, so you will not accidentally overshoot your budget. */
Log.Info($"Pre-warmed {created} enemy instances", LogCategory.Core);

Reset per-use state cleanly with lifecycle callbacks

Problem. You are pooling an audio source and it carries several fields that need to be reset on every reuse: the clip, volume, pitch, loop flag. If you forget to reset one, the next use of that instance inherits the previous use’s settings. Centralizing the reset in one place avoids the “I reset volume but forgot to reset loop” bug that shows up in a late QA session.

Solution. The three lifecycle callbacks (OnGet, OnRelease, OnDestroy) cover every moment the pool touches an instance. The table below shows when each fires and what it is for; the code shows an audio source pool that uses all three.

CallbackRuns onOrder
OnGetGet/TryGetAfter the instance is retrieved (or created) and after Unity-pool auto-activation. Right place to reset state.
OnReleaseReleaseBefore Unity-pool auto-deactivation and before storage in the free list. Right place to clear references.
OnDestroyPool overflowBefore Object.Destroy (Unity pool) or before IDisposable.Dispose (generic pool with DisposeOnDestroy). Also runs from Clear(destroy: true).
/* A pool of audio sources for one-shot sound effects with explicit per-use state reset. */
var audioPool = ScyllaObjectPool<AudioSource>.CreateBuilder()
    .WithPrefab(audioSourcePrefab)
    .WithCapacity(8)
    .WithMaxSize(24)
    .OnGet(s =>
    {
        /* Every field that varies per-use is reset here, so every consumer
         * gets a clean source regardless of what the previous use set. */
        s.clip   = null;
        s.volume = 1f;
        s.pitch  = 1f;
        s.loop   = false;
    })
    .OnRelease(s =>
    {
        /* Clear the clip reference before parking so the pool does not hold
         * the audio clip in memory between uses. */
        s.Stop();
        s.clip = null;
    })
    .OnDestroy(s =>
    {
        /* Diagnostic hook: log when the pool overflows so you know to raise
         * MaxSize or reduce concurrent voice count. */
        Log.Warning("Audio pool overflow; destroying source", LogCategory.Core);
    })
    .Build();

Clean up a pool between levels or on scene unload

Problem. You have a bullet or enemy pool that should be fully cleaned up when a level unloads, but you need to understand the difference between “drop the free list” and “actually destroy everything.” Getting this wrong on a Unity pool leaves inactive GameObjects parked in the scene as invisible orphans.

Solution. Both pools expose two Clear overloads. The parameterless Clear() drops the free list without destroying the instances; the destroy: true overload destroys them. For Unity pools, Clear(destroy: true) is almost always what you want.

/* Soft reset: drop the free list, but instances already parked stay alive.
 * The GC can reclaim generic instances; Unity GameObjects are not GC-tracked
 * and will accumulate as inactive scene orphans. Avoid for Unity pools. */
sbPool.Clear();

/* Full teardown: destroy every parked instance and invoke OnDestroy (and
 * IDisposable.Dispose for generic pools with DisposeOnDestroy). Call this
 * from a scene unload handler or from a module Shutdown method. */
audioPool.Clear(destroy: true);

Cap active enemies or voices with a fixed-budget pool

Problem. There is always a system in a game that needs a hard cap on how many instances can ever exist at once: a maximum number of active enemies so the CPU budget stays bounded, a maximum number of simultaneous audio voices to avoid a cacophony, a maximum number of decal instances so the scene does not accumulate thousands of bullet holes. You want the pool to enforce that cap and give you a clean way to react when it is hit.

Solution. AllowCreate(false) builds a pool that never allocates new instances. Get throws when the pool is empty; TryGet returns false and lets you apply backpressure cleanly. Seed the fixed budget with Prewarm before setting the flag.

/* An AI pathfinding request pool with a hard budget of 64 concurrent requests. */
var requests = ScyllaGenericObjectPool<PathRequest>.CreateBuilder()
    .WithCreateFunc(() => new PathRequest())   /* Used by Prewarm; not reached after that. */
    .WithCapacity(64)
    .WithMaxSize(64)
    .Build();

/* Seed to full capacity during scene initialization. */
requests.Prewarm(64);

/* Use TryGet to apply backpressure instead of throwing on exhaustion. */
if (!requests.TryGet(out var req))
{
    /* The budget is full: defer this request to the next frame, drop it,
     * or log a warning so you know the cap needs adjusting. */
    Log.Warning("Pathfinding request pool exhausted; deferring work", LogCategory.Core);
}
else
{
    req.Setup(origin, destination, agentID);
    pathfinder.Enqueue(req);
    /* The pathfinder calls requests.Release(req) when done. */
}

Share a pool across background worker threads

Problem. Your game fans out work to worker threads: a background pathfinder that borrows scratch List<int> buffers, a content streamer that borrows byte buffer chunks, or an AI system that queues work items from a job. Pools are not thread-safe by default, and calling Get/Release concurrently from multiple threads corrupts the internal free list.

Solution. Wrap the pool in its synchronized variant. The wrapper serializes individual Get and Release calls with a lock. For compound “TryGet, work, Release” sequences that must be atomic, hold SyncRoot explicitly.

/* Wrap an existing pool for concurrent access by worker threads. */
var syncedBuffers = bufferPool.AsSynchronized();

/* Or build with synchronization from the start. */
var sharedBuffers = ScyllaGenericObjectPool<List<int>>.CreateBuilder()
    .WithCreateFunc(() => new List<int>(64))
    /* Synchronized() wraps the result in SynchronizedScyllaGenericObjectPool. */
    .Synchronized()
    .Build();

/* Safe for concurrent Get/Release from any thread. */
if (sharedBuffers.TryGet(out var buf))
{
    /* Do the worker-thread work with buf. */
    sharedBuffers.Release(buf);
}

/* For atomic compound operations, hold SyncRoot explicitly. */
lock (sharedBuffers.SyncRoot)
{
    if (sharedBuffers.TryGet(out var atomicBuf))
    {
        ProcessAndRelease(atomicBuf, sharedBuffers);
    }
}

Adopt scene-placed instances into a pool at startup

Problem. You are building a level where the designer has already placed some instances in the scene: pre-placed enemies at their patrol positions, decoration props that should be recyclable, scattered pickups that the designer positioned by hand. You want the pool to manage all of them, but you do not want to destroy and re-instantiate what is already there.

Solution. TryAdd(T item) hands a pre-existing instance to the pool. At scene load, collect the designer-placed instances, deactivate them so they match the pool’s expectation for parked items, and add them to the free list.

/* Collect all scene-placed enemies into the pool at scene load so the pool can
 * manage them alongside enemies it creates itself on pool miss. */
foreach (var existing in FindObjectsByType<Enemy>())
{
    /* Parked instances in a Unity pool are expected to be inactive. */
    existing.gameObject.SetActive(false);
    _enemies.TryAdd(existing);
}

API Sketch

The public surface across both pools. See the API reference for full signatures, remarks, and exception contracts on every member.

namespace Scylla.Core.Structures
{
    /* ------------------------------------------------------------------ */
    /* GENERIC POOL                                                       */
    /* ------------------------------------------------------------------ */

    public sealed class ScyllaGenericObjectPool<T> : IScyllaCollection<T>
    {
        public ScyllaGenericObjectPool(
            Func<T> createFunc = null,
            Action<T> onGet = null,
            Action<T> onRelease = null,
            Action<T> onDestroy = null,
            bool collectionCheck = false,
            bool allowCreate = true,
            bool disposeOnDestroy = false,
            int maxSize = int.MaxValue,
            int capacity = 16);

        public int Count { get; }      /* Number of inactive items in the free list. */
        public int CountAll { get; }   /* Pooled + outstanding (created but not yet released). */
        public int Capacity { get; }   /* Current free-list capacity (may have grown beyond the initial hint). */
        public int MaxSize { get; }    /* Hard ceiling. */
        public bool IsEmpty { get; }

        public T    Get();
        public bool TryGet(out T instance);
        public bool Release(T instance);
        public int  Prewarm(int count);
        public bool Contains(T instance, IEqualityComparer<T> comparer = null);

        public bool TryAdd(T item);
        public bool TryRemove(out T item);
        public bool TryPeek(out T item);
        public int  CopyTo(Span<T> destination);
        public int  CopyTo(T[] destination, int destinationIndex);

        public void Clear();
        public void Clear(bool destroy);

        public SynchronizedScyllaGenericObjectPool AsSynchronized(object syncRoot = null);
        public Enumerator GetEnumerator();
        public static Builder CreateBuilder();
    }

    /* ------------------------------------------------------------------ */
    /* UNITY OBJECT POOL                                                  */
    /* ------------------------------------------------------------------ */

    public sealed class ScyllaObjectPool<T> : IScyllaCollection<T> where T : UnityEngine.Object
    {
        public ScyllaObjectPool(
            T prefab = null,
            Func<T> createFunc = null,
            Action<T> onGet = null,
            Action<T> onRelease = null,
            Action<T> onDestroy = null,
            bool collectionCheck = false,
            bool allowCreate = true,
            int maxSize = int.MaxValue,
            int capacity = 4);

        /* Same surface as ScyllaGenericObjectPool<T>, with the following differences:
           - Constructor takes a `prefab` instead of `disposeOnDestroy`.
           - The default factory (when createFunc is null) runs Object.Instantiate(prefab).
           - Get/Release automatically run gameObject.SetActive(true/false) for
             GameObject and Component instances.
           - Destruction goes through Object.Destroy (or DestroyImmediate outside play mode). */

        public SynchronizedScyllaObjectPool AsSynchronized(object syncRoot = null);
        public Enumerator GetEnumerator();
        public static Builder CreateBuilder();
    }
}

See the API reference for full signatures, remarks, and exception contracts on every member.

Builder reference

Generic pool builder

SetterDefaultRequiredEffect
WithCreateFunc(fn)(none)Yes (when AllowCreate(true))Factory invoked on pool miss. Without a create function and with AllowCreate(true), the constructor throws.
WithCapacity(int)16NoInitial free-list size. Grows automatically; this is a hint to reduce early reallocations.
WithMaxSize(int)int.MaxValueNoHard ceiling. Releases over this count destroy the instance instead of storing it.
OnGet(action)(none)NoCallback after a hit or new instance is returned. Right place to reset state.
OnRelease(action)(none)NoCallback before the instance is stored back. Right place to clear references.
OnDestroy(action)(none)NoCallback before an instance is permanently destroyed (overflow, Clear(true), or DisposeOnDestroy Dispose).
CollectionCheck(bool)falseNoEnables an O(n) double-release detection in Release. Throws when the same instance is released twice. Development only.
AllowCreate(bool)trueNoWhen false, Get throws on miss and TryGet returns false. Use with Prewarm to enforce a fixed budget.
DisposeOnDestroy(bool)falseNoWhen true and T implements IDisposable, Dispose() is called when the instance is destroyed (overflow or Clear(true)).
Synchronized(object)offNoWraps the result in SynchronizedScyllaGenericObjectPool. Optional syncRoot shares the lock with caller code.

Unity object pool builder

Same as the generic pool, with these differences:

SetterEffect
WithPrefab(T)Source prefab for the default factory. When set and WithCreateFunc is not, the framework’s default factory runs UnityEngine.Object.Instantiate(prefab) on pool misses.
WithCreateFunc(fn)Overrides WithPrefab. Useful for custom parenting, addressable loading, or runtime variant selection. When both are absent and AllowCreate(true), the constructor throws.
DisposeOnDestroyNot available. Unity objects are destroyed via Object.Destroy automatically; DisposeOnDestroy does not apply.

Best Practices

  • Pair every Get with a matching Release. Forgetting is the most common pool bug. The pool grows until it hits MaxSize, then settles into an allocate-on-get / destroy-on-release thrash that defeats the entire point of pooling.
  • Reset state in OnGet, not in calling code. The pool itself does not zero anything. Centralize reset logic in the OnGet callback so every consumer gets a clean instance without remembering the reset step.
  • Pick MaxSize against measured peak load, not against worst-case worry. The point of the ceiling is to bound memory; setting it ten times higher than the actual peak just defers the problem. Profile, set MaxSize to roughly 1.5x the observed peak, and let overflow destroy excess instances.
  • Prewarm during loading screens, never on the first gameplay frame. Frame 1 is exactly where the allocation cost matters; that is the cost prewarm exists to move.
  • Enable CollectionCheck in development builds, disable it for shipping. The check is O(n) on every release; it is correct to keep it on while features are stabilizing and correct to turn it off once the release-and-double-release patterns are known-clean.
  • Skip the pool for small structs and rarely-allocated objects. Pooling a Vector2 is meaningless. Pooling a class that is allocated once per minute is more bookkeeping than benefit.
  • Use TryGet whenever the pool may be empty by design. Get throws when AllowCreate(false) and the free list is empty. TryGet returns false and lets the caller back-pressure cleanly.
  • Trust the Unity pool’s auto-activation for components. gameObject.SetActive(true/false) runs automatically for GameObject and Component instances. An explicit OnGet(b => b.gameObject.SetActive(true)) is redundant and easy to forget to keep in sync.
  • Prefer BuildConcrete() when the calling code uses pool-specific helpers. AsSynchronized and Prewarm are not on the IScyllaCollection<T> interface. Build() returns the interface and hides them.
  • Always pass destroy: true to Clear on Unity pools. Unity does not GC GameObjects. The parameterless Clear() drops the free list but leaves the inactive instances in the scene as orphans.
  • Hold SyncRoot for compound operations on synchronized pools. Per-call locks are not enough; a “TryGet, work, Release” sequence on a shared pool is racy without an outer lock (pool.SyncRoot).
  • Use the generic pool’s DisposeOnDestroy for IDisposable types. Without it, destroyed instances skip Dispose and leak unmanaged resources held inside the pooled class. With it, overflow and Clear(true) clean up correctly.

Pitfalls