diff --git a/src/OECS/CommandQueue.cs b/src/OECS/CommandQueue.cs
new file mode 100644
index 0000000..09d2a8f
--- /dev/null
+++ b/src/OECS/CommandQueue.cs
@@ -0,0 +1,74 @@
+namespace OECS;
+
+///
+/// A FIFO queue of instances with deferred execution.
+///
+/// Commands are executed in the order they were enqueued. If a command's
+/// throws, the exception is caught and stored;
+/// remaining commands continue to execute.
+///
+/// Commands enqueued during are processed in the
+/// same drain cycle — the queue keeps draining until empty.
+///
+public class CommandQueue
+{
+ private readonly List _commands = new();
+ private readonly List _errors = new();
+
+ ///
+ /// Number of commands currently waiting in the queue.
+ ///
+ public int Count => _commands.Count;
+
+ ///
+ /// Exceptions thrown by commands during the most recent call.
+ /// Cleared at the start of each drain cycle.
+ ///
+ public IReadOnlyList Errors => _errors;
+
+ ///
+ /// Adds a command to the end of the queue.
+ ///
+ public void Enqueue(ICommand command)
+ {
+ _commands.Add(command);
+ }
+
+ ///
+ /// Executes all queued commands in FIFO order against the given world.
+ ///
+ /// The queue is fully drained — commands enqueued by other commands during
+ /// this call are also executed before the method returns.
+ ///
+ public void ExecuteAll(World world)
+ {
+ _errors.Clear();
+
+ int index = 0;
+ while (index < _commands.Count)
+ {
+ var command = _commands[index];
+ index++;
+
+ try
+ {
+ command.Execute(world);
+ }
+ catch (Exception ex)
+ {
+ _errors.Add(ex);
+ }
+ }
+
+ _commands.Clear();
+ }
+
+ ///
+ /// Clears all queued commands without executing them.
+ ///
+ public void Clear()
+ {
+ _commands.Clear();
+ _errors.Clear();
+ }
+}
diff --git a/src/OECS/ICommand.cs b/src/OECS/ICommand.cs
new file mode 100644
index 0000000..571029f
--- /dev/null
+++ b/src/OECS/ICommand.cs
@@ -0,0 +1,14 @@
+namespace OECS;
+
+///
+/// A serializable command that can be enqueued and executed against a .
+/// Implementations should be [MessagePackObject] structs so they can be
+/// serialized and replayed.
+///
+public interface ICommand
+{
+ ///
+ /// Executes the command against the given world.
+ ///
+ void Execute(World world);
+}
diff --git a/src/OECS/SystemGroup.cs b/src/OECS/SystemGroup.cs
index 0e4043b..bf424ef 100644
--- a/src/OECS/SystemGroup.cs
+++ b/src/OECS/SystemGroup.cs
@@ -68,6 +68,13 @@ public class SystemGroup
{
system.Run(_world);
}
+
+ // Drain commands after each system so subsequent systems
+ // see the effects of commands enqueued by prior systems.
+ _world.ExecuteCommands();
}
+
+ // Drain any remaining commands (e.g., those enqueued outside systems).
+ _world.ExecuteCommands();
}
}
\ No newline at end of file
diff --git a/src/OECS/World.cs b/src/OECS/World.cs
index 7e62c55..dd62cdb 100644
--- a/src/OECS/World.cs
+++ b/src/OECS/World.cs
@@ -10,11 +10,13 @@ public class World : IDisposable
{
private readonly EntityAllocator _allocator;
private readonly ComponentStore _components;
+ private readonly CommandQueue _commands;
public World()
{
_allocator = new EntityAllocator();
_components = new ComponentStore();
+ _commands = new CommandQueue();
}
// ── Entity Management ────────────────────────────────────────────
@@ -89,6 +91,24 @@ public class World : IDisposable
return _components.Has(entity);
}
+ // ── Commands ──────────────────────────────────────────────────────
+
+ ///
+ /// The command queue for deferred, serializable commands.
+ /// Systems enqueue commands via world.Commands.Enqueue(...).
+ ///
+ public CommandQueue Commands => _commands;
+
+ ///
+ /// Manually drains the command queue, executing all pending commands.
+ /// Called automatically by after each system
+ /// and after the full tick.
+ ///
+ public void ExecuteCommands()
+ {
+ _commands.ExecuteAll(this);
+ }
+
// ── Queries ──────────────────────────────────────────────────────
///
diff --git a/tests/OECS.Tests/CommandTests.cs b/tests/OECS.Tests/CommandTests.cs
new file mode 100644
index 0000000..a3626a2
--- /dev/null
+++ b/tests/OECS.Tests/CommandTests.cs
@@ -0,0 +1,286 @@
+using FluentAssertions;
+using MessagePack;
+using Xunit;
+
+namespace OECS.Tests;
+
+// ── Test components ──────────────────────────────────────────────────
+
+[MessagePackObject]
+public struct TestPosition : IMessagePackSerializationCallbackReceiver
+{
+ [Key(0)] public float X { get; set; }
+ [Key(1)] public float Y { get; set; }
+
+ public void OnBeforeSerialize() { }
+ public void OnAfterDeserialize() { }
+}
+
+[MessagePackObject]
+public struct TestHealth : IMessagePackSerializationCallbackReceiver
+{
+ [Key(0)] public int Value { get; set; }
+
+ public void OnBeforeSerialize() { }
+ public void OnAfterDeserialize() { }
+}
+
+// ── Test commands ────────────────────────────────────────────────────
+
+[MessagePackObject]
+public struct SpawnCommand : ICommand
+{
+ [Key(0)] public float X;
+ [Key(1)] public float Y;
+ [Key(2)] public int Health;
+
+ public void Execute(World world)
+ {
+ var entity = world.CreateEntity();
+ world.AddComponent(entity, new TestPosition { X = X, Y = Y });
+ world.AddComponent(entity, new TestHealth { Value = Health });
+ }
+}
+
+[MessagePackObject]
+public struct MoveCommand : ICommand
+{
+ [Key(0)] public Entity Entity;
+ [Key(1)] public float Dx;
+ [Key(2)] public float Dy;
+
+ public void Execute(World world)
+ {
+ ref var pos = ref world.GetComponent(Entity);
+ pos.X += Dx;
+ pos.Y += Dy;
+ }
+}
+
+[MessagePackObject]
+public struct DestroyCommand : ICommand
+{
+ [Key(0)] public Entity Entity;
+
+ public void Execute(World world)
+ {
+ world.DestroyEntity(Entity);
+ }
+}
+
+[MessagePackObject]
+public struct FailingCommand : ICommand
+{
+ [Key(0)] public string Message;
+
+ public void Execute(World world)
+ {
+ throw new InvalidOperationException(Message);
+ }
+}
+
+[MessagePackObject]
+public struct ChainedCommand : ICommand
+{
+ [Key(0)] public float X;
+ [Key(1)] public float Y;
+
+ public void Execute(World world)
+ {
+ var entity = world.CreateEntity();
+ world.AddComponent(entity, new TestPosition { X = X, Y = Y });
+
+ // Enqueue a follow-up command during execution.
+ world.Commands.Enqueue(new MoveCommand
+ {
+ Entity = entity,
+ Dx = 10,
+ Dy = 20
+ });
+ }
+}
+
+// ── Test class ───────────────────────────────────────────────────────
+
+public class CommandTests
+{
+ [Fact]
+ public void Commands_ExecuteInFifoOrder()
+ {
+ var world = new World();
+
+ world.Commands.Enqueue(new SpawnCommand { X = 1, Y = 2, Health = 100 });
+ world.Commands.Enqueue(new SpawnCommand { X = 3, Y = 4, Health = 200 });
+
+ world.ExecuteCommands();
+
+ var query = world.Query().With().With().Build();
+ var positions = new List<(float X, float Y, int Health)>();
+
+ world.ForEach(query, (Entity e, ref TestPosition pos, ref TestHealth hp) =>
+ {
+ positions.Add((pos.X, pos.Y, hp.Value));
+ });
+
+ positions.Should().BeEquivalentTo([
+ (1, 2, 100),
+ (3, 4, 200)
+ ]);
+ }
+
+ [Fact]
+ public void Command_CanReadWriteEcsState()
+ {
+ var world = new World();
+ var entity = world.CreateEntity();
+ world.AddComponent(entity, new TestPosition { X = 0, Y = 0 });
+
+ world.Commands.Enqueue(new MoveCommand { Entity = entity, Dx = 5, Dy = 10 });
+ world.ExecuteCommands();
+
+ ref var pos = ref world.GetComponent(entity);
+ pos.X.Should().Be(5);
+ pos.Y.Should().Be(10);
+ }
+
+ [Fact]
+ public void Command_EnqueuedDuringExecuteAll_RunsInSameDrainCycle()
+ {
+ var world = new World();
+
+ world.Commands.Enqueue(new ChainedCommand { X = 0, Y = 0 });
+ world.ExecuteCommands();
+
+ var query = world.Query().With().Build();
+ var positions = new List<(float X, float Y)>();
+
+ world.ForEach(query, (Entity e, ref TestPosition pos) =>
+ {
+ positions.Add((pos.X, pos.Y));
+ });
+
+ positions.Should().BeEquivalentTo([(10, 20)]);
+ }
+
+ [Fact]
+ public void Exceptions_AreCollected_NotLost()
+ {
+ var world = new World();
+
+ world.Commands.Enqueue(new SpawnCommand { X = 1, Y = 2, Health = 100 });
+ world.Commands.Enqueue(new FailingCommand { Message = "boom" });
+ world.Commands.Enqueue(new SpawnCommand { X = 3, Y = 4, Health = 200 });
+
+ world.ExecuteCommands();
+
+ var query = world.Query().With().Build();
+ var count = 0;
+ world.ForEach(query, (Entity e, ref TestPosition pos) => count++);
+ count.Should().Be(2);
+
+ world.Commands.Errors.Should().HaveCount(1);
+ world.Commands.Errors[0].Should().BeOfType()
+ .Which.Message.Should().Be("boom");
+ }
+
+ [Fact]
+ public void Serialization_RoundTrip_PreservesCommandData()
+ {
+ var original = new SpawnCommand { X = 42, Y = 99, Health = 500 };
+
+ var bytes = MessagePackSerializer.Serialize(original);
+ var deserialized = MessagePackSerializer.Deserialize(bytes);
+
+ deserialized.X.Should().Be(42);
+ deserialized.Y.Should().Be(99);
+ deserialized.Health.Should().Be(500);
+ }
+
+ [Fact]
+ public void Command_CanDestroyEntity()
+ {
+ var world = new World();
+ var entity = world.CreateEntity();
+ world.AddComponent(entity, new TestPosition { X = 1, Y = 2 });
+
+ world.Commands.Enqueue(new DestroyCommand { Entity = entity });
+ world.ExecuteCommands();
+
+ world.IsAlive(entity).Should().BeFalse();
+ }
+
+ [Fact]
+ public void Clear_DiscardsPendingCommands()
+ {
+ var world = new World();
+
+ world.Commands.Enqueue(new SpawnCommand { X = 1, Y = 2, Health = 100 });
+ world.Commands.Count.Should().Be(1);
+
+ world.Commands.Clear();
+ world.Commands.Count.Should().Be(0);
+
+ world.ExecuteCommands();
+
+ var query = world.Query().With().Build();
+ var count = 0;
+ world.ForEach(query, (Entity e, ref TestPosition pos) => count++);
+ count.Should().Be(0);
+ }
+
+ [Fact]
+ public void Commands_AreDrainedAfterEachSystem()
+ {
+ var world = new World();
+ var group = new SystemGroup(world);
+
+ group.Add(new CommandEnqueueSystem(world, new SpawnCommand { X = 1, Y = 2, Health = 100 }));
+
+ var observer = new EntityCountObserver(world);
+ group.Add(observer);
+
+ group.RunLogical();
+
+ observer.SeenCount.Should().Be(1);
+ }
+
+ private class CommandEnqueueSystem : ISystem
+ {
+ private readonly World _world;
+ private readonly ICommand _command;
+
+ public QueryDescriptor Query { get; }
+
+ public CommandEnqueueSystem(World world, ICommand command)
+ {
+ _world = world;
+ _command = command;
+ Query = world.Query().With().Build();
+ }
+
+ public void Run(World world)
+ {
+ _world.Commands.Enqueue(_command);
+ }
+ }
+
+ private class EntityCountObserver : ISystem
+ {
+ public int SeenCount { get; private set; }
+
+ public QueryDescriptor Query { get; }
+
+ public EntityCountObserver(World world)
+ {
+ Query = world.Query().With().Build();
+ }
+
+ public void Run(World world)
+ {
+ world.ForEach(Query, (Entity e, ref TestPosition pos) =>
+ {
+ SeenCount++;
+ });
+ }
+ }
+}