Table of Contents

Command Buffer Guide

The CommandBuffer enables safe entity modification during iteration. This guide covers when and how to use it.

The Problem

Modifying entities during iteration can invalidate iterators:

// ❌ DANGEROUS: Don't do this!
foreach (var entity in world.Query<Health>())
{
    ref var health = ref world.Get<Health>(entity);
    if (health.Current <= 0)
    {
        world.Despawn(entity);  // Invalidates the iterator!
    }
}

When you despawn an entity or change its components, the underlying storage may be reorganized, breaking active iterators.

The Solution: CommandBuffer

Queue operations for deferred execution:

var buffer = new CommandBuffer();

foreach (var entity in world.Query<Health>())
{
    ref var health = ref world.Get<Health>(entity);
    if (health.Current <= 0)
    {
        buffer.Despawn(entity);  // Safe: queued for later
    }
}

// Execute all queued commands after iteration
buffer.Flush(world);

CommandBuffer Operations

Spawning Entities

var buffer = new CommandBuffer();

// Queue entity creation
var cmd = buffer.Spawn()
    .With(new Position { X = 0, Y = 0 })
    .With(new Velocity { X = 1, Y = 0 });

// Named entities
var namedCmd = buffer.Spawn("Bullet")
    .With(new Position { X = x, Y = y });

// Execute and get real entities
var entityMap = buffer.Flush(world);

// Access created entity using placeholder ID
Entity createdEntity = entityMap[cmd.PlaceholderId];

Despawning Entities

var buffer = new CommandBuffer();

foreach (var entity in world.Query<Health>())
{
    ref readonly var health = ref world.Get<Health>(entity);
    if (health.Current <= 0)
    {
        buffer.Despawn(entity);
    }
}

buffer.Flush(world);

Adding Components

var buffer = new CommandBuffer();

foreach (var entity in world.Query<Position>().With<Enemy>())
{
    ref readonly var pos = ref world.Get<Position>(entity);
    if (IsInPlayerRange(pos))
    {
        // Mark enemy as alerted
        buffer.AddComponent(entity, new Alerted { Time = 0f });
    }
}

buffer.Flush(world);

Removing Components

var buffer = new CommandBuffer();

foreach (var entity in world.Query<Poisoned>())
{
    ref var poison = ref world.Get<Poisoned>(entity);
    poison.Duration -= deltaTime;

    if (poison.Duration <= 0)
    {
        buffer.RemoveComponent<Poisoned>(entity);
    }
}

buffer.Flush(world);

Setting Components

var buffer = new CommandBuffer();

foreach (var entity in world.Query<Position>())
{
    // Queue a position reset
    buffer.SetComponent(entity, new Position { X = 0, Y = 0 });
}

buffer.Flush(world);

Placeholder Entities

When spawning, you can reference new entities before they exist:

var buffer = new CommandBuffer();

// Create parent
var parentCmd = buffer.Spawn()
    .With(new Position { X = 0, Y = 0 });

// Create child referencing parent (using placeholder)
buffer.Spawn()
    .With(new Position { X = 10, Y = 0 })
    .With(new Parent { Entity = new Entity(parentCmd.PlaceholderId, 0) });

// Add component to placeholder entity
buffer.AddComponent(parentCmd.PlaceholderId, new Named { Name = "Parent" });

// Execute
var entityMap = buffer.Flush(world);
Entity parent = entityMap[parentCmd.PlaceholderId];

Common Patterns

Death System

public class DeathSystem : SystemBase
{
    private readonly CommandBuffer buffer = new();

    public override void Update(float deltaTime)
    {
        foreach (var entity in World.Query<Health>())
        {
            ref readonly var health = ref World.Get<Health>(entity);
            if (health.Current <= 0)
            {
                buffer.Despawn(entity);
            }
        }

        buffer.Flush(World);
    }
}

Spawner System

public class SpawnerSystem : SystemBase
{
    private readonly CommandBuffer buffer = new();

    public override void Update(float deltaTime)
    {
        foreach (var entity in World.Query<Spawner>())
        {
            ref var spawner = ref World.Get<Spawner>(entity);
            spawner.Timer -= deltaTime;

            if (spawner.Timer <= 0)
            {
                ref readonly var pos = ref World.Get<Position>(entity);

                buffer.Spawn()
                    .With(new Position { X = pos.X, Y = pos.Y })
                    .With(new Velocity { X = 0, Y = -10 })
                    .With(spawner.Template);

                spawner.Timer = spawner.Interval;
            }
        }

        buffer.Flush(World);
    }
}

Collision Response

public class CollisionSystem : SystemBase
{
    private readonly CommandBuffer buffer = new();

    public override void Update(float deltaTime)
    {
        // Note: Nested iteration requires one snapshot to avoid O(n²) query re-evaluation.
        // Snapshot the collection being modified (projectiles) and iterate the other directly.
        var projectiles = World.Query<Position, Projectile>().ToList();

        foreach (var projectile in projectiles)
        {
            ref readonly var projPos = ref World.Get<Position>(projectile);

            // Iterate enemies directly (better cache locality)
            foreach (var enemy in World.Query<Position, Health>().With<Enemy>())
            {
                ref readonly var enemyPos = ref World.Get<Position>(enemy);

                if (CheckCollision(projPos, enemyPos))
                {
                    // Destroy projectile
                    buffer.Despawn(projectile);

                    // Apply damage
                    ref var health = ref World.Get<Health>(enemy);
                    health.Current -= 10;

                    break;  // Projectile can only hit once
                }
            }
        }

        buffer.Flush(World);
    }
}

State Transitions

public class StateTransitionSystem : SystemBase
{
    private readonly CommandBuffer buffer = new();

    public override void Update(float deltaTime)
    {
        // Transition from Idle to Moving
        foreach (var entity in World.Query<Velocity>().With<IdleState>())
        {
            ref readonly var vel = ref World.Get<Velocity>(entity);
            if (vel.X != 0 || vel.Y != 0)
            {
                buffer.RemoveComponent<IdleState>(entity);
                buffer.AddComponent(entity, default(MovingState));
            }
        }

        // Transition from Moving to Idle
        foreach (var entity in World.Query<Velocity>().With<MovingState>())
        {
            ref readonly var vel = ref World.Get<Velocity>(entity);
            if (vel.X == 0 && vel.Y == 0)
            {
                buffer.RemoveComponent<MovingState>(entity);
                buffer.AddComponent(entity, default(IdleState));
            }
        }

        buffer.Flush(World);
    }
}

Best Practices

Reuse CommandBuffer

public class MySystem : SystemBase
{
    // ✅ Good: Reuse buffer instance
    private readonly CommandBuffer buffer = new();

    public override void Update(float deltaTime)
    {
        // Use buffer...
        buffer.Flush(World);
    }
}

Flush Once Per Update

public override void Update(float deltaTime)
{
    // ✅ Good: Single flush at end
    foreach (var entity in World.Query<A>())
    {
        buffer.Despawn(entity);
    }
    foreach (var entity in World.Query<B>())
    {
        buffer.AddComponent(entity, new C());
    }
    buffer.Flush(World);  // One flush for all operations

    // ❌ Bad: Multiple flushes
    foreach (var entity in World.Query<A>())
    {
        buffer.Despawn(entity);
        buffer.Flush(World);  // Inefficient!
    }
}

Clear on Error

If you need to abort operations:

try
{
    // Queue operations...
    buffer.Flush(World);
}
catch
{
    buffer.Clear();  // Discard queued commands
    throw;
}

When to Use CommandBuffer

Scenario Use CommandBuffer?
Despawning entities during query ✅ Yes
Spawning entities during query ✅ Yes
Adding/removing components during query ✅ Yes
Modifying component values ❌ No (use ref directly)
Reading component values ❌ No (use ref readonly)
After iteration is complete ❌ No (direct operations are fine)

Using CommandBuffer in Plugins

The command buffer system is available through the ICommandBuffer interface in the KeenEyes.Abstractions package, allowing plugins to queue entity operations without depending on KeenEyes.Core:

using KeenEyes;

public class MySystem : ISystem
{
    private readonly ICommandBuffer buffer = new CommandBuffer();
    private IWorld? world;

    public void Initialize(IWorld world)
    {
        this.world = world;
    }

    public void Update(float deltaTime)
    {
        if (world is null) return;

        // Use ICommandBuffer interface for plugin compatibility
        foreach (var entity in world.Query<Health>())
        {
            ref readonly var health = ref world.Get<Health>(entity);
            if (health.Current <= 0)
            {
                buffer.Despawn(entity);
            }
        }

        buffer.Flush(world);
    }

    public void Dispose() { }
    public bool Enabled { get; set; } = true;
}

Interface Benefits

  • Plugin compatibility - Works through IWorld without KeenEyes.Core dependency
  • Testability - Can mock ICommandBuffer for unit tests
  • Decoupling - Plugins don't depend on concrete CommandBuffer implementation

Performance: Zero-Reflection Design

The command buffer uses delegate capture instead of reflection for type information:

// Component value and type info captured at registration time (cold path)
buffer.AddComponent(entity, new Health { Current = 100, Max = 100 });

// Internally: Stored as delegate that captures the component
Action<IWorld, Entity> action = (world, e) => world.Add(e, component);

// Execution (hot path): Direct delegate invocation, no reflection
action(world, entity);

Why This Matters

Traditional reflection approach (slow):

// ❌ Reflection on every command execution
var method = typeof(World).GetMethod("Add").MakeGenericMethod(componentType);
method.Invoke(world, new object[] { entity, component });

Delegate capture approach (fast):

// ✅ Type info captured once at registration, direct invocation at execution
commands.Add(new AddComponentCommand(entity, (w, e) => w.Add(e, component)));

Benefits:

  • Zero reflection overhead in command execution (hot path)
  • Type safety preserved through generics
  • Predictable performance - no hidden costs from reflection

Thread Safety

CommandBuffer is not thread-safe. Each system should use its own buffer, or access must be synchronized externally.

Next Steps