diff --git a/src/OECS/ForEachAction.cs b/src/OECS/ForEachAction.cs
new file mode 100644
index 0000000..2182adb
--- /dev/null
+++ b/src/OECS/ForEachAction.cs
@@ -0,0 +1,39 @@
+namespace OECS;
+
+// Custom delegate types for query iteration with ref parameters.
+// The built-in Action<...> delegates do not support ref parameters.
+
+///
+/// Callback for iterating entities with one component type.
+///
+public delegate void ForEachAction(Entity entity, ref T1 c1) where T1 : struct;
+
+///
+/// Callback for iterating entities with two component types.
+///
+public delegate void ForEachAction(Entity entity, ref T1 c1, ref T2 c2)
+ where T1 : struct where T2 : struct;
+
+///
+/// Callback for iterating entities with three component types.
+///
+public delegate void ForEachAction(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3)
+ where T1 : struct where T2 : struct where T3 : struct;
+
+///
+/// Callback for iterating entities with four component types.
+///
+public delegate void ForEachAction(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3, ref T4 c4)
+ where T1 : struct where T2 : struct where T3 : struct where T4 : struct;
+
+///
+/// Callback for iterating entities with five component types.
+///
+public delegate void ForEachAction(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3, ref T4 c4, ref T5 c5)
+ where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct;
+
+///
+/// Callback for iterating entities with six component types.
+///
+public delegate void ForEachAction(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3, ref T4 c4, ref T5 c5, ref T6 c6)
+ where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct where T6 : struct;
\ No newline at end of file
diff --git a/src/OECS/ISystem.cs b/src/OECS/ISystem.cs
new file mode 100644
index 0000000..0aab965
--- /dev/null
+++ b/src/OECS/ISystem.cs
@@ -0,0 +1,18 @@
+namespace OECS;
+
+///
+/// A system that runs on every tick.
+/// Systems declare a query and receive the world in their method.
+///
+public interface ISystem
+{
+ ///
+ /// The query that defines which entities this system operates on.
+ ///
+ QueryDescriptor Query { get; }
+
+ ///
+ /// Executes the system logic for this tick.
+ ///
+ void Run(World world);
+}
\ No newline at end of file
diff --git a/src/OECS/ITickedSystem.cs b/src/OECS/ITickedSystem.cs
new file mode 100644
index 0000000..7d1addc
--- /dev/null
+++ b/src/OECS/ITickedSystem.cs
@@ -0,0 +1,13 @@
+namespace OECS;
+
+///
+/// A system that receives tick data (delta time or logical tick) in addition
+/// to the world reference.
+///
+public interface ITickedSystem : ISystem
+{
+ ///
+ /// Executes the system logic with tick information.
+ ///
+ void Run(World world, Tick tick);
+}
\ No newline at end of file
diff --git a/src/OECS/QueryBuilder.cs b/src/OECS/QueryBuilder.cs
new file mode 100644
index 0000000..cde58e3
--- /dev/null
+++ b/src/OECS/QueryBuilder.cs
@@ -0,0 +1,39 @@
+namespace OECS;
+
+///
+/// Fluent builder for constructing instances.
+/// Returned by .
+///
+public class QueryBuilder
+{
+ private readonly HashSet _with = new();
+ private readonly HashSet _without = new();
+
+ ///
+ /// Requires entities to have a component of type .
+ ///
+ public QueryBuilder With() where T : struct
+ {
+ _with.Add(typeof(T));
+ return this;
+ }
+
+ ///
+ /// Excludes entities that have a component of type .
+ ///
+ public QueryBuilder Without() where T : struct
+ {
+ _without.Add(typeof(T));
+ return this;
+ }
+
+ ///
+ /// Builds the query descriptor.
+ ///
+ public QueryDescriptor Build()
+ {
+ return new QueryDescriptor(
+ new HashSet(_with),
+ new HashSet(_without));
+ }
+}
\ No newline at end of file
diff --git a/src/OECS/QueryDescriptor.cs b/src/OECS/QueryDescriptor.cs
new file mode 100644
index 0000000..5e98fff
--- /dev/null
+++ b/src/OECS/QueryDescriptor.cs
@@ -0,0 +1,30 @@
+namespace OECS;
+
+///
+/// Describes a query over the ECS world.
+/// Composed of a set of required component types ("with") and
+/// a set of excluded component types ("without").
+///
+public class QueryDescriptor
+{
+ ///
+ /// Component types that must be present on matching entities.
+ ///
+ public IReadOnlySet With { get; }
+
+ ///
+ /// Component types that must NOT be present on matching entities.
+ ///
+ public IReadOnlySet Without { get; }
+
+ internal QueryDescriptor(HashSet with, HashSet without)
+ {
+ With = with;
+ Without = without;
+ }
+
+ ///
+ /// Returns true if this query has no "with" components (matches nothing).
+ ///
+ internal bool IsEmpty => With.Count == 0;
+}
\ No newline at end of file
diff --git a/src/OECS/QueryExecutor.cs b/src/OECS/QueryExecutor.cs
new file mode 100644
index 0000000..b6188e3
--- /dev/null
+++ b/src/OECS/QueryExecutor.cs
@@ -0,0 +1,242 @@
+using System.Runtime.CompilerServices;
+
+namespace OECS;
+
+///
+/// Provides query execution logic for .
+/// Iterates one sparse set as the driver and probes the remaining sets
+/// for membership. The "without" filter is checked after all "with" probes.
+///
+internal static class QueryExecutor
+{
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static bool PassesWithoutFilter(ComponentStore store, Entity entity, IReadOnlySet withoutTypes)
+ {
+ foreach (var type in withoutTypes)
+ {
+ var set = store.GetSet(type);
+ if (set != null && set.Contains(entity))
+ return false;
+ }
+ return true;
+ }
+
+ // ── ForEach overloads ──────────────────────────────────────────────
+
+ public static void ForEach(
+ ComponentStore store,
+ QueryDescriptor query,
+ ForEachAction action)
+ where T1 : struct
+ {
+ var set = store.GetSet(typeof(T1)) as SparseSet;
+ if (set == null) return;
+
+ var dense = set.Dense;
+ var denseEntities = set.DenseEntities;
+ var count = set.Count;
+
+ for (int i = 0; i < count; i++)
+ {
+ var entity = denseEntities[i];
+ if (!PassesWithoutFilter(store, entity, query.Without))
+ continue;
+ action(entity, ref dense[i]);
+ }
+ }
+
+ public static void ForEach(
+ ComponentStore store,
+ QueryDescriptor query,
+ ForEachAction action)
+ where T1 : struct where T2 : struct
+ {
+ var set1 = store.GetSet(typeof(T1)) as SparseSet;
+ if (set1 == null) return;
+ var set2 = store.GetSet(typeof(T2)) as SparseSet;
+ if (set2 == null) return;
+
+ if (set1.Count <= set2.Count)
+ {
+ IterateTwo(set1, set2, store, query.Without, action);
+ }
+ else
+ {
+ IterateTwoSwapped(set2, set1, store, query.Without, action);
+ }
+ }
+
+ private static void IterateTwo(
+ SparseSet driveSet,
+ SparseSet otherSet,
+ ComponentStore store,
+ IReadOnlySet withoutTypes,
+ ForEachAction action)
+ where TDriver : struct where TOther : struct
+ {
+ var dense = driveSet.Dense;
+ var denseEntities = driveSet.DenseEntities;
+ var count = driveSet.Count;
+
+ for (int i = 0; i < count; i++)
+ {
+ var entity = denseEntities[i];
+ if (!otherSet.Contains(entity)) continue;
+ if (!PassesWithoutFilter(store, entity, withoutTypes))
+ continue;
+ action(entity, ref dense[i], ref otherSet.Get(entity));
+ }
+ }
+
+ private static void IterateTwoSwapped(
+ SparseSet driveSet,
+ SparseSet otherSet,
+ ComponentStore store,
+ IReadOnlySet withoutTypes,
+ ForEachAction action)
+ where TDriver : struct where TOther : struct
+ {
+ var dense = driveSet.Dense;
+ var denseEntities = driveSet.DenseEntities;
+ var count = driveSet.Count;
+
+ for (int i = 0; i < count; i++)
+ {
+ var entity = denseEntities[i];
+ if (!otherSet.Contains(entity)) continue;
+ if (!PassesWithoutFilter(store, entity, withoutTypes))
+ continue;
+ action(entity, ref otherSet.Get(entity), ref dense[i]);
+ }
+ }
+
+ public static void ForEach(
+ ComponentStore store,
+ QueryDescriptor query,
+ ForEachAction action)
+ where T1 : struct where T2 : struct where T3 : struct
+ {
+ var set1 = store.GetSet(typeof(T1)) as SparseSet;
+ if (set1 == null) return;
+ var set2 = store.GetSet(typeof(T2)) as SparseSet;
+ if (set2 == null) return;
+ var set3 = store.GetSet(typeof(T3)) as SparseSet;
+ if (set3 == null) return;
+
+ var dense = set1.Dense;
+ var denseEntities = set1.DenseEntities;
+ var count = set1.Count;
+
+ for (int i = 0; i < count; i++)
+ {
+ var entity = denseEntities[i];
+ if (!set2.Contains(entity)) continue;
+ if (!set3.Contains(entity)) continue;
+ if (!PassesWithoutFilter(store, entity, query.Without))
+ continue;
+ action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity));
+ }
+ }
+
+ public static void ForEach(
+ ComponentStore store,
+ QueryDescriptor query,
+ ForEachAction action)
+ where T1 : struct where T2 : struct where T3 : struct where T4 : struct
+ {
+ var set1 = store.GetSet(typeof(T1)) as SparseSet;
+ if (set1 == null) return;
+ var set2 = store.GetSet(typeof(T2)) as SparseSet;
+ if (set2 == null) return;
+ var set3 = store.GetSet(typeof(T3)) as SparseSet;
+ if (set3 == null) return;
+ var set4 = store.GetSet(typeof(T4)) as SparseSet;
+ if (set4 == null) return;
+
+ var dense = set1.Dense;
+ var denseEntities = set1.DenseEntities;
+ var count = set1.Count;
+
+ for (int i = 0; i < count; i++)
+ {
+ var entity = denseEntities[i];
+ if (!set2.Contains(entity)) continue;
+ if (!set3.Contains(entity)) continue;
+ if (!set4.Contains(entity)) continue;
+ if (!PassesWithoutFilter(store, entity, query.Without))
+ continue;
+ action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity));
+ }
+ }
+
+ public static void ForEach(
+ ComponentStore store,
+ QueryDescriptor query,
+ ForEachAction action)
+ where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct
+ {
+ var set1 = store.GetSet(typeof(T1)) as SparseSet;
+ if (set1 == null) return;
+ var set2 = store.GetSet(typeof(T2)) as SparseSet;
+ if (set2 == null) return;
+ var set3 = store.GetSet(typeof(T3)) as SparseSet;
+ if (set3 == null) return;
+ var set4 = store.GetSet(typeof(T4)) as SparseSet;
+ if (set4 == null) return;
+ var set5 = store.GetSet(typeof(T5)) as SparseSet;
+ if (set5 == null) return;
+
+ var dense = set1.Dense;
+ var denseEntities = set1.DenseEntities;
+ var count = set1.Count;
+
+ for (int i = 0; i < count; i++)
+ {
+ var entity = denseEntities[i];
+ if (!set2.Contains(entity)) continue;
+ if (!set3.Contains(entity)) continue;
+ if (!set4.Contains(entity)) continue;
+ if (!set5.Contains(entity)) continue;
+ if (!PassesWithoutFilter(store, entity, query.Without))
+ continue;
+ action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity));
+ }
+ }
+
+ public static void ForEach(
+ ComponentStore store,
+ QueryDescriptor query,
+ ForEachAction action)
+ where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct where T6 : struct
+ {
+ var set1 = store.GetSet(typeof(T1)) as SparseSet;
+ if (set1 == null) return;
+ var set2 = store.GetSet(typeof(T2)) as SparseSet;
+ if (set2 == null) return;
+ var set3 = store.GetSet(typeof(T3)) as SparseSet;
+ if (set3 == null) return;
+ var set4 = store.GetSet(typeof(T4)) as SparseSet;
+ if (set4 == null) return;
+ var set5 = store.GetSet(typeof(T5)) as SparseSet;
+ if (set5 == null) return;
+ var set6 = store.GetSet(typeof(T6)) as SparseSet;
+ if (set6 == null) return;
+
+ var dense = set1.Dense;
+ var denseEntities = set1.DenseEntities;
+ var count = set1.Count;
+
+ for (int i = 0; i < count; i++)
+ {
+ var entity = denseEntities[i];
+ if (!set2.Contains(entity)) continue;
+ if (!set3.Contains(entity)) continue;
+ if (!set4.Contains(entity)) continue;
+ if (!set5.Contains(entity)) continue;
+ if (!set6.Contains(entity)) continue;
+ if (!PassesWithoutFilter(store, entity, query.Without))
+ continue;
+ action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity), ref set6.Get(entity));
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/OECS/SparseSet.cs b/src/OECS/SparseSet.cs
index 70b429f..c351091 100644
--- a/src/OECS/SparseSet.cs
+++ b/src/OECS/SparseSet.cs
@@ -9,6 +9,7 @@ namespace OECS;
internal interface ISparseSet
{
void Remove(Entity entity);
+ bool Contains(Entity entity);
int Count { get; }
}
diff --git a/src/OECS/SystemGroup.cs b/src/OECS/SystemGroup.cs
new file mode 100644
index 0000000..0e4043b
--- /dev/null
+++ b/src/OECS/SystemGroup.cs
@@ -0,0 +1,73 @@
+namespace OECS;
+
+///
+/// Manages a group of systems, running them in registration order
+/// against a specific .
+///
+public class SystemGroup
+{
+ private readonly World _world;
+ private readonly List _systems = new();
+
+ ///
+ /// Creates a system group bound to the given world.
+ ///
+ public SystemGroup(World world)
+ {
+ _world = world ?? throw new ArgumentNullException(nameof(world));
+ }
+
+ ///
+ /// The number of systems in this group.
+ ///
+ public int Count => _systems.Count;
+
+ ///
+ /// Adds a system to the group. Systems execute in the order they are added.
+ ///
+ public void Add(ISystem system)
+ {
+ _systems.Add(system);
+ }
+
+ ///
+ /// Removes a system from the group.
+ ///
+ public void Remove(ISystem system)
+ {
+ _systems.Remove(system);
+ }
+
+ ///
+ /// Runs all systems with a timed tick.
+ /// Systems that implement receive the tick data;
+ /// plain implementations receive only the world.
+ ///
+ public void RunTimed(float deltaTime)
+ {
+ RunAll(Tick.Timed(deltaTime));
+ }
+
+ ///
+ /// Runs all systems with a logical tick.
+ ///
+ public void RunLogical()
+ {
+ RunAll(Tick.Logical());
+ }
+
+ private void RunAll(Tick tick)
+ {
+ foreach (var system in _systems)
+ {
+ if (system is ITickedSystem ticked)
+ {
+ ticked.Run(_world, tick);
+ }
+ else
+ {
+ system.Run(_world);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/OECS/Tick.cs b/src/OECS/Tick.cs
new file mode 100644
index 0000000..ab74ccb
--- /dev/null
+++ b/src/OECS/Tick.cs
@@ -0,0 +1,50 @@
+namespace OECS;
+
+///
+/// Describes the kind of tick being processed.
+///
+public enum TickType
+{
+ ///
+ /// A timed tick, carrying a delta time (e.g., frame-based update).
+ ///
+ Timed,
+
+ ///
+ /// A logical tick, carrying no delta time (e.g., fixed-step simulation).
+ ///
+ Logical
+}
+
+///
+/// Data passed to systems during a tick.
+///
+public readonly struct Tick
+{
+ ///
+ /// The kind of tick.
+ ///
+ public TickType Type { get; }
+
+ ///
+ /// The elapsed time since the last tick, in seconds.
+ /// Always 0 for logical ticks.
+ ///
+ public float DeltaTime { get; }
+
+ private Tick(TickType type, float deltaTime)
+ {
+ Type = type;
+ DeltaTime = deltaTime;
+ }
+
+ ///
+ /// Creates a timed tick with the given delta time.
+ ///
+ public static Tick Timed(float deltaTime) => new(TickType.Timed, deltaTime);
+
+ ///
+ /// Creates a logical tick (no delta time).
+ ///
+ public static Tick Logical() => new(TickType.Logical, 0f);
+}
\ No newline at end of file
diff --git a/src/OECS/World.cs b/src/OECS/World.cs
index c76af7d..7e62c55 100644
--- a/src/OECS/World.cs
+++ b/src/OECS/World.cs
@@ -89,6 +89,85 @@ public class World : IDisposable
return _components.Has(entity);
}
+ // ── Queries ──────────────────────────────────────────────────────
+
+ ///
+ /// Returns a new for constructing queries.
+ ///
+ public QueryBuilder Query() => new();
+
+ ///
+ /// Iterates all entities matching the query, providing ref access to
+ /// one component type.
+ ///
+ public void ForEach(
+ QueryDescriptor query,
+ ForEachAction action)
+ where T1 : struct
+ {
+ QueryExecutor.ForEach(_components, query, action);
+ }
+
+ ///
+ /// Iterates all entities matching the query, providing ref access to
+ /// two component types.
+ ///
+ public void ForEach(
+ QueryDescriptor query,
+ ForEachAction action)
+ where T1 : struct where T2 : struct
+ {
+ QueryExecutor.ForEach(_components, query, action);
+ }
+
+ ///
+ /// Iterates all entities matching the query, providing ref access to
+ /// three component types.
+ ///
+ public void ForEach(
+ QueryDescriptor query,
+ ForEachAction action)
+ where T1 : struct where T2 : struct where T3 : struct
+ {
+ QueryExecutor.ForEach(_components, query, action);
+ }
+
+ ///
+ /// Iterates all entities matching the query, providing ref access to
+ /// four component types.
+ ///
+ public void ForEach(
+ QueryDescriptor query,
+ ForEachAction action)
+ where T1 : struct where T2 : struct where T3 : struct where T4 : struct
+ {
+ QueryExecutor.ForEach(_components, query, action);
+ }
+
+ ///
+ /// Iterates all entities matching the query, providing ref access to
+ /// five component types.
+ ///
+ public void ForEach(
+ QueryDescriptor query,
+ ForEachAction action)
+ where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct
+ {
+ QueryExecutor.ForEach(_components, query, action);
+ }
+
+ ///
+ /// Iterates all entities matching the query, providing ref access to
+ /// six component types.
+ ///
+ public void ForEach(
+ QueryDescriptor query,
+ ForEachAction action)
+ where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct where T6 : struct
+ {
+ QueryExecutor.ForEach(_components, query, action);
+ }
+
// ── Internal Access ───────────────────────────────────────────────
///
diff --git a/tests/OECS.Tests/QueryAndSystemTests.cs b/tests/OECS.Tests/QueryAndSystemTests.cs
new file mode 100644
index 0000000..832273f
--- /dev/null
+++ b/tests/OECS.Tests/QueryAndSystemTests.cs
@@ -0,0 +1,306 @@
+using FluentAssertions;
+using Xunit;
+
+namespace OECS.Tests;
+
+public class QueryTests
+{
+ private struct Position { public float X; public float Y; }
+ private struct Velocity { public float X; public float Y; }
+ private struct Health { public int Value; }
+ private struct Frozen { }
+
+ [Fact]
+ public void Query_WithSingleComponent_ReturnsMatchingEntities()
+ {
+ var world = new World();
+ var a = world.CreateEntity();
+ var b = world.CreateEntity();
+ var c = world.CreateEntity();
+
+ world.AddComponent(a, new Position { X = 1, Y = 2 });
+ world.AddComponent(b, new Position { X = 3, Y = 4 });
+ // c has no Position
+
+ var query = world.Query().With().Build();
+ var results = new List();
+
+ world.ForEach(query, (Entity entity, ref Position pos) =>
+ {
+ results.Add(entity);
+ });
+
+ results.Should().BeEquivalentTo([a, b]);
+ }
+
+ [Fact]
+ public void Query_WithMultipleComponents_ReturnsIntersection()
+ {
+ var world = new World();
+ var a = world.CreateEntity();
+ var b = world.CreateEntity();
+ var c = world.CreateEntity();
+
+ world.AddComponent(a, new Position { X = 1, Y = 2 });
+ world.AddComponent(a, new Velocity { X = 1, Y = 0 });
+
+ world.AddComponent(b, new Position { X = 3, Y = 4 });
+ // b has no Velocity
+
+ world.AddComponent(c, new Velocity { X = 0, Y = 1 });
+ // c has no Position
+
+ var query = world.Query().With().With().Build();
+ var results = new List();
+
+ world.ForEach(query, (Entity entity, ref Position pos, ref Velocity vel) =>
+ {
+ results.Add(entity);
+ });
+
+ results.Should().BeEquivalentTo([a]);
+ }
+
+ [Fact]
+ public void Query_Without_ExcludesEntities()
+ {
+ var world = new World();
+ var a = world.CreateEntity();
+ var b = world.CreateEntity();
+
+ world.AddComponent(a, new Position { X = 1, Y = 2 });
+ world.AddComponent(b, new Position { X = 3, Y = 4 });
+ world.AddComponent(b, new Frozen());
+
+ var query = world.Query().With().Without().Build();
+ var results = new List();
+
+ world.ForEach(query, (Entity entity, ref Position pos) =>
+ {
+ results.Add(entity);
+ });
+
+ results.Should().BeEquivalentTo([a]);
+ }
+
+ [Fact]
+ public void Query_EmptyResult_WhenNoEntitiesMatch()
+ {
+ var world = new World();
+ var query = world.Query().With().Build();
+ var count = 0;
+
+ world.ForEach(query, (Entity entity, ref Position pos) =>
+ {
+ count++;
+ });
+
+ count.Should().Be(0);
+ }
+
+ [Fact]
+ public void Query_RefMutation_IsVisible()
+ {
+ var world = new World();
+ var entity = world.CreateEntity();
+ world.AddComponent(entity, new Position { X = 0, Y = 0 });
+
+ var query = world.Query().With().Build();
+
+ world.ForEach(query, (Entity e, ref Position pos) =>
+ {
+ pos.X = 42;
+ });
+
+ ref var pos = ref world.GetComponent(entity);
+ pos.X.Should().Be(42);
+ }
+
+ [Fact]
+ public void Query_DestroyedEntities_DoNotAppear()
+ {
+ var world = new World();
+ var a = world.CreateEntity();
+ var b = world.CreateEntity();
+
+ world.AddComponent(a, new Position { X = 1, Y = 2 });
+ world.AddComponent(b, new Position { X = 3, Y = 4 });
+
+ world.DestroyEntity(a);
+
+ var query = world.Query().With().Build();
+ var results = new List();
+
+ world.ForEach(query, (Entity entity, ref Position pos) =>
+ {
+ results.Add(entity);
+ });
+
+ results.Should().BeEquivalentTo([b]);
+ }
+
+ [Fact]
+ public void Query_ThreeComponents_Works()
+ {
+ var world = new World();
+ var entity = world.CreateEntity();
+
+ world.AddComponent(entity, new Position { X = 1, Y = 2 });
+ world.AddComponent(entity, new Velocity { X = 3, Y = 4 });
+ world.AddComponent(entity, new Health { Value = 100 });
+
+ var query = world.Query()
+ .With()
+ .With()
+ .With()
+ .Build();
+
+ var count = 0;
+ world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel, ref Health hp) =>
+ {
+ count++;
+ pos.X.Should().Be(1);
+ vel.Y.Should().Be(4);
+ hp.Value.Should().Be(100);
+ });
+
+ count.Should().Be(1);
+ }
+}
+
+public class SystemTests
+{
+ private struct Position { public float X; public float Y; }
+ private struct Velocity { public float X; public float Y; }
+
+ private class MovementSystem : ITickedSystem
+ {
+ public QueryDescriptor Query { get; }
+
+ public MovementSystem(World world)
+ {
+ Query = world.Query().With().With().Build();
+ }
+
+ public void Run(World world) => Run(world, Tick.Logical());
+
+ public void Run(World world, Tick tick)
+ {
+ float dt = tick.DeltaTime;
+ world.ForEach(Query, (Entity entity, ref Position pos, ref Velocity vel) =>
+ {
+ pos.X += vel.X * dt;
+ pos.Y += vel.Y * dt;
+ });
+ }
+ }
+
+ [Fact]
+ public void System_RunTimed_UpdatesComponents()
+ {
+ var world = new World();
+ var group = new SystemGroup(world);
+ var system = new MovementSystem(world);
+ group.Add(system);
+
+ var entity = world.CreateEntity();
+ world.AddComponent(entity, new Position { X = 0, Y = 0 });
+ world.AddComponent(entity, new Velocity { X = 1, Y = 2 });
+
+ group.RunTimed(0.5f);
+
+ ref var pos = ref world.GetComponent(entity);
+ pos.X.Should().Be(0.5f);
+ pos.Y.Should().Be(1.0f);
+ }
+
+ [Fact]
+ public void System_RunLogical_DoesNotUseDeltaTime()
+ {
+ var world = new World();
+ var group = new SystemGroup(world);
+
+ var system = new TestLogicalSystem(world);
+ group.Add(system);
+
+ group.RunLogical();
+
+ system.WasCalled.Should().BeTrue();
+ system.ReceivedTick.Type.Should().Be(TickType.Logical);
+ system.ReceivedTick.DeltaTime.Should().Be(0);
+ }
+
+ private class TestLogicalSystem : ITickedSystem
+ {
+ public QueryDescriptor Query { get; }
+ public bool WasCalled { get; private set; }
+ public Tick ReceivedTick { get; private set; }
+
+ public TestLogicalSystem(World world)
+ {
+ Query = world.Query().With().Build();
+ }
+
+ public void Run(World world) => Run(world, Tick.Logical());
+
+ public void Run(World world, Tick tick)
+ {
+ WasCalled = true;
+ ReceivedTick = tick;
+ }
+ }
+
+ [Fact]
+ public void Systems_RunInRegistrationOrder()
+ {
+ var world = new World();
+ var group = new SystemGroup(world);
+ var order = new List();
+
+ group.Add(new OrderTrackingSystem(world, 1, order));
+ group.Add(new OrderTrackingSystem(world, 2, order));
+ group.Add(new OrderTrackingSystem(world, 3, order));
+
+ group.RunLogical();
+
+ order.Should().BeInAscendingOrder();
+ order.Should().BeEquivalentTo([1, 2, 3]);
+ }
+
+ private class OrderTrackingSystem : ISystem
+ {
+ private readonly int _id;
+ private readonly List _order;
+
+ public QueryDescriptor Query { get; }
+
+ public OrderTrackingSystem(World world, int id, List order)
+ {
+ _id = id;
+ _order = order;
+ Query = world.Query().With().Build();
+ }
+
+ public void Run(World world)
+ {
+ _order.Add(_id);
+ }
+ }
+
+ [Fact]
+ public void System_CanBeRemoved()
+ {
+ var world = new World();
+ var group = new SystemGroup(world);
+ var system = new TestLogicalSystem(world);
+
+ group.Add(system);
+ group.Count.Should().Be(1);
+
+ group.Remove(system);
+ group.Count.Should().Be(0);
+
+ group.RunLogical();
+ system.WasCalled.Should().BeFalse();
+ }
+}