Entity Pooling
Problem
You're spawning and despawning many entities (bullets, particles, enemies) and want to avoid allocation overhead.
Solution
Pool Component
[TagComponent]
public partial struct Pooled : ITagComponent { }
[Component]
public partial struct PoolMember : IComponent
{
public int PoolId;
}
[TagComponent]
public partial struct PooledActive : ITagComponent { }
Entity Pool Manager
public sealed class EntityPool
{
private readonly World world;
private readonly int poolId;
private readonly Func<EntityBuilder, EntityBuilder> entitySetup;
private readonly Queue<Entity> available = new();
private int totalCreated;
public EntityPool(World world, int poolId, Func<EntityBuilder, EntityBuilder> setup)
{
this.world = world;
this.poolId = poolId;
this.entitySetup = setup;
}
public Entity Get()
{
Entity entity;
if (available.Count > 0)
{
// Reuse pooled entity
entity = available.Dequeue();
world.Add<PooledActive>(entity);
}
else
{
// Create new entity
entity = entitySetup(world.Spawn())
.WithTag<Pooled>()
.WithTag<PooledActive>()
.With(new PoolMember { PoolId = poolId })
.Build();
totalCreated++;
}
return entity;
}
public void Return(Entity entity)
{
if (!world.IsAlive(entity))
return;
if (!world.Has<Pooled>(entity))
return; // Not a pooled entity
// Deactivate
world.Remove<PooledActive>(entity);
// Reset to default state (optional: add Reset component for systems to handle)
available.Enqueue(entity);
}
public void Prewarm(int count)
{
for (int i = 0; i < count; i++)
{
var entity = entitySetup(world.Spawn())
.WithTag<Pooled>()
.With(new PoolMember { PoolId = poolId })
.Build();
available.Enqueue(entity);
totalCreated++;
}
}
public int AvailableCount => available.Count;
public int TotalCount => totalCreated;
}
Pool Manager Singleton
public sealed class PoolManager
{
private readonly World world;
private readonly Dictionary<int, EntityPool> pools = new();
private int nextPoolId;
public PoolManager(World world)
{
this.world = world;
}
public EntityPool CreatePool(Func<EntityBuilder, EntityBuilder> setup)
{
var pool = new EntityPool(world, nextPoolId++, setup);
pools[pool.GetHashCode()] = pool;
return pool;
}
public void ReturnAll()
{
foreach (var entity in world.Query<PoolMember>().With<PooledActive>())
{
ref readonly var member = ref world.Get<PoolMember>(entity);
if (pools.TryGetValue(member.PoolId, out var pool))
{
pool.Return(entity);
}
}
}
}
Usage: Bullet Pool
public class WeaponSystem : SystemBase
{
private EntityPool bulletPool = null!;
public override void Initialize()
{
var poolManager = World.GetSingleton<PoolManager>();
bulletPool = poolManager.CreatePool(builder => builder
.With(new Position())
.With(new Velocity())
.With(new Lifetime { Remaining = 5f })
.WithTag<Bullet>());
// Pre-create 100 bullets
bulletPool.Prewarm(100);
}
public void Fire(Position origin, Velocity direction)
{
var bullet = bulletPool.Get();
// Configure the pooled entity
ref var pos = ref World.Get<Position>(bullet);
pos = origin;
ref var vel = ref World.Get<Velocity>(bullet);
vel = direction;
ref var lifetime = ref World.Get<Lifetime>(bullet);
lifetime.Remaining = 5f;
}
}
Lifetime System (Returns to Pool)
public class LifetimeSystem : SystemBase
{
private PoolManager poolManager = null!;
public override void Initialize()
{
poolManager = World.GetSingleton<PoolManager>();
}
public override void Update(float deltaTime)
{
foreach (var entity in World.Query<Lifetime>().With<PooledActive>())
{
ref var lifetime = ref World.Get<Lifetime>(entity);
lifetime.Remaining -= deltaTime;
if (lifetime.Remaining <= 0)
{
ref readonly var member = ref World.Get<PoolMember>(entity);
// Return to pool instead of despawning
ReturnToPool(entity);
}
}
}
private void ReturnToPool(Entity entity)
{
World.Remove<PooledActive>(entity);
// The PoolManager will re-add to available queue
}
}
Why This Works
Avoiding Archetype Churn
Without pooling:
Spawn()- Allocates entity, adds to archetypeDespawn()- Removes from archetype, frees entity ID
With pooling:
Get()- AddsPooledActivetag (minimal archetype change)Return()- RemovesPooledActivetag
The entity stays in similar archetypes, reducing memory shuffling.
Tag-Based Activation
Using PooledActive tag means:
- Active entities:
Query<Position, Velocity>().With<PooledActive>() - Inactive entities:
Query<Pooled>().Without<PooledActive>() - All pooled:
Query<Pooled>()
Systems naturally skip inactive entities with proper query filters.
Prewarm for Consistent Performance
Calling Prewarm(100) during initialization:
- Creates all entities upfront
- Avoids allocation spikes during gameplay
- Makes frame times more consistent
Component Reset
Pooled entities keep their components but values may be stale. Options:
- Reset in
Get()method (shown above) - Use a
Resetcomponent that systems process - Overwrite all values when activating
Variations
Auto-Return on Collision
public class BulletCollisionSystem : SystemBase
{
public override void Update(float deltaTime)
{
var buffer = World.GetCommandBuffer();
foreach (var bullet in World.Query<Bullet, Position>().With<PooledActive>())
{
ref readonly var pos = ref World.Get<Position>(bullet);
// Check collision
if (Physics.CheckHit(pos, out var hit))
{
// Deal damage
buffer.Add(hit.Entity, new DamageReceived { Amount = 10 });
// Return to pool
buffer.Remove<PooledActive>(bullet);
}
}
buffer.Execute();
}
}
Multiple Pool Types
public static class Pools
{
public static EntityPool Bullets { get; private set; } = null!;
public static EntityPool Particles { get; private set; } = null!;
public static EntityPool Enemies { get; private set; } = null!;
public static void Initialize(PoolManager manager)
{
Bullets = manager.CreatePool(b => b
.With(new Position())
.With(new Velocity())
.WithTag<Bullet>());
Particles = manager.CreatePool(b => b
.With(new Position())
.With(new ParticleData())
.With(new Lifetime()));
Enemies = manager.CreatePool(b => b
.With(new Position())
.With(new Health { Current = 100, Max = 100 })
.With(new AIState())
.WithTag<Enemy>());
Bullets.Prewarm(200);
Particles.Prewarm(1000);
Enemies.Prewarm(50);
}
}
Dynamic Pool Growth
public sealed class GrowingEntityPool
{
private readonly int growthIncrement;
public GrowingEntityPool(World world, int poolId,
Func<EntityBuilder, EntityBuilder> setup,
int initialSize = 100,
int growthIncrement = 50)
{
this.growthIncrement = growthIncrement;
Prewarm(initialSize);
}
public Entity Get()
{
if (available.Count == 0)
{
// Auto-grow when depleted
Prewarm(growthIncrement);
}
return GetFromQueue();
}
}
Pool Statistics
public struct PoolStats
{
public int TotalCreated;
public int Available;
public int Active;
public int PeakActive;
}
public sealed class EntityPool
{
private int peakActive;
public Entity Get()
{
// ... existing code ...
int active = totalCreated - available.Count;
if (active > peakActive)
peakActive = active;
return entity;
}
public PoolStats GetStats() => new PoolStats
{
TotalCreated = totalCreated,
Available = available.Count,
Active = totalCreated - available.Count,
PeakActive = peakActive
};
}
// Debug system to display pool stats
public class PoolDebugSystem : SystemBase
{
public override void Update(float deltaTime)
{
var stats = Pools.Bullets.GetStats();
DebugUI.Text($"Bullets: {stats.Active}/{stats.TotalCreated} (peak: {stats.PeakActive})");
}
}
Component-Based Pooling
Alternative approach using only components (no external manager):
[Component]
public partial struct PooledEntity : IComponent
{
public int PoolType;
public bool IsActive;
}
public class ComponentPoolSystem : SystemBase
{
public Entity GetFromPool(int poolType)
{
// Find inactive entity of matching type
foreach (var entity in World.Query<PooledEntity>())
{
ref var pooled = ref World.Get<PooledEntity>(entity);
if (pooled.PoolType == poolType && !pooled.IsActive)
{
pooled.IsActive = true;
return entity;
}
}
// No available entity - create new one
return CreateNewPooledEntity(poolType);
}
public void ReturnToPool(Entity entity)
{
ref var pooled = ref World.Get<PooledEntity>(entity);
pooled.IsActive = false;
// Reset other components as needed
}
}
Performance Considerations
| Approach | Spawn Cost | Memory | Query Overhead |
|---|---|---|---|
| No pooling | High (allocation) | Optimal | None |
| Tag-based pooling | Low (tag add) | Higher (inactive entities) | Minimal |
| Disable components | Low | Higher | Systems must check |
Pooling trades memory for consistent frame times. Best for:
- High spawn/despawn rate (bullets, particles)
- Performance-critical scenarios
- Avoiding GC pressure
See Also
- Entity Spawning - Standard spawning patterns
- Batch Operations - Bulk entity operations
- Spatial Queries - Efficient collision detection