diff --git a/src/OECS/ChangeBuffer.cs b/src/OECS/ChangeBuffer.cs
new file mode 100644
index 0000000..c25842e
--- /dev/null
+++ b/src/OECS/ChangeBuffer.cs
@@ -0,0 +1,131 @@
+using R3;
+
+namespace OECS;
+
+///
+/// Buffers changes during system execution and posts them to R3 subjects
+/// when is called.
+///
+/// Subscribers receive batched changes: all changes accumulated since the
+/// last call are pushed in a single burst.
+///
+internal class ChangeBuffer
+{
+ private readonly ChangeSet _pending = new();
+
+ private readonly Subject _entitySubject = new();
+ private readonly Dictionary> _componentSubjects = new();
+ private readonly Dictionary> _querySubjects = new();
+
+ ///
+ /// The change set currently accumulating. Cleared after each .
+ ///
+ public ChangeSet Pending => _pending;
+
+ ///
+ /// Pushes all pending changes to R3 subjects, then clears the pending set.
+ ///
+ public void Post()
+ {
+ if (_pending.Count == 0)
+ return;
+
+ var changes = _pending.Changes;
+
+ foreach (var change in changes)
+ {
+ // Push to the global entity subject.
+ _entitySubject.OnNext(change);
+
+ // Push to component-specific subjects.
+ if (change.ComponentType != null)
+ {
+ if (_componentSubjects.TryGetValue(change.ComponentType, out var compSubject))
+ {
+ compSubject.OnNext(change);
+ }
+ }
+
+ // Push to matching query subjects.
+ foreach (var (query, subject) in _querySubjects)
+ {
+ if (ChangeMatchesQuery(change, query))
+ {
+ subject.OnNext(change);
+ }
+ }
+ }
+
+ _pending.Clear();
+ }
+
+ ///
+ /// Returns an observable that emits all entity changes.
+ ///
+ public Observable ObserveEntityChanges()
+ {
+ return _entitySubject;
+ }
+
+ ///
+ /// Returns an observable that emits changes for a specific component type.
+ ///
+ public Observable ObserveComponentChanges(Type componentType)
+ {
+ if (!_componentSubjects.TryGetValue(componentType, out var subject))
+ {
+ subject = new Subject();
+ _componentSubjects[componentType] = subject;
+ }
+ return subject;
+ }
+
+ ///
+ /// Returns an observable that emits changes matching the given query.
+ ///
+ public Observable ObserveQuery(QueryDescriptor query)
+ {
+ if (!_querySubjects.TryGetValue(query, out var subject))
+ {
+ subject = new Subject();
+ _querySubjects[query] = subject;
+ }
+ return subject;
+ }
+
+ ///
+ /// Disposes all R3 subjects.
+ ///
+ public void Dispose()
+ {
+ _entitySubject.Dispose();
+ foreach (var subject in _componentSubjects.Values)
+ {
+ subject.Dispose();
+ }
+ _componentSubjects.Clear();
+ foreach (var subject in _querySubjects.Values)
+ {
+ subject.Dispose();
+ }
+ _querySubjects.Clear();
+ }
+
+ private static bool ChangeMatchesQuery(EntityChange change, QueryDescriptor query)
+ {
+ // Entity-level changes: match if the entity was added/removed and
+ // the query has any "with" types (we can't know component state for
+ // removed entities, so we only match added entities that would satisfy
+ // the query — but we don't have component data here).
+ // For simplicity, we only match component-level changes against queries.
+ if (change.ComponentType == null)
+ return false;
+
+ // A component change matches a query if the component type is in the
+ // query's With set and not in the Without set.
+ if (query.Without.Contains(change.ComponentType))
+ return false;
+
+ return query.With.Contains(change.ComponentType);
+ }
+}
diff --git a/src/OECS/ChangeKind.cs b/src/OECS/ChangeKind.cs
new file mode 100644
index 0000000..1b7f9ea
--- /dev/null
+++ b/src/OECS/ChangeKind.cs
@@ -0,0 +1,33 @@
+namespace OECS;
+
+///
+/// The kind of change that occurred in the ECS world.
+///
+public enum ChangeKind
+{
+ ///
+ /// A new entity was created.
+ ///
+ EntityAdded,
+
+ ///
+ /// An entity was destroyed.
+ ///
+ EntityRemoved,
+
+ ///
+ /// A component was added to an entity.
+ ///
+ ComponentAdded,
+
+ ///
+ /// A component was removed from an entity.
+ ///
+ ComponentRemoved,
+
+ ///
+ /// A component's value was modified. Must be explicitly marked
+ /// via .
+ ///
+ ComponentModified
+}
diff --git a/src/OECS/ChangeSet.cs b/src/OECS/ChangeSet.cs
new file mode 100644
index 0000000..0c3b0f7
--- /dev/null
+++ b/src/OECS/ChangeSet.cs
@@ -0,0 +1,83 @@
+namespace OECS;
+
+///
+/// Accumulates entries during a system run.
+///
+/// Deduplication: if the same (entity, kind, componentType) change is marked
+/// multiple times, only one entry is kept. Entity-level changes (Added/Removed)
+/// take precedence over component-level changes for the same entity.
+///
+internal class ChangeSet
+{
+ private readonly List _changes = new();
+ private readonly HashSet<(Entity Entity, ChangeKind Kind, Type? ComponentType)> _dedup = new();
+
+ ///
+ /// All accumulated changes in insertion order.
+ ///
+ public IReadOnlyList Changes => _changes;
+
+ ///
+ /// Number of changes in this set.
+ ///
+ public int Count => _changes.Count;
+
+ ///
+ /// Records that an entity was created.
+ ///
+ public void MarkEntityAdded(Entity entity)
+ {
+ TryAdd(new EntityChange(entity, ChangeKind.EntityAdded));
+ }
+
+ ///
+ /// Records that an entity was destroyed.
+ ///
+ public void MarkEntityRemoved(Entity entity)
+ {
+ TryAdd(new EntityChange(entity, ChangeKind.EntityRemoved));
+ }
+
+ ///
+ /// Records that a component was added to an entity.
+ ///
+ public void MarkComponentAdded(Entity entity, Type componentType)
+ {
+ TryAdd(new EntityChange(entity, ChangeKind.ComponentAdded, componentType));
+ }
+
+ ///
+ /// Records that a component was removed from an entity.
+ ///
+ public void MarkComponentRemoved(Entity entity, Type componentType)
+ {
+ TryAdd(new EntityChange(entity, ChangeKind.ComponentRemoved, componentType));
+ }
+
+ ///
+ /// Records that a component's value was modified.
+ /// Must be called explicitly by user code via .
+ ///
+ public void MarkComponentModified(Entity entity, Type componentType)
+ {
+ TryAdd(new EntityChange(entity, ChangeKind.ComponentModified, componentType));
+ }
+
+ ///
+ /// Clears all accumulated changes.
+ ///
+ public void Clear()
+ {
+ _changes.Clear();
+ _dedup.Clear();
+ }
+
+ private void TryAdd(EntityChange change)
+ {
+ var key = (change.Entity, change.Kind, change.ComponentType);
+ if (_dedup.Add(key))
+ {
+ _changes.Add(change);
+ }
+ }
+}
diff --git a/src/OECS/EntityChange.cs b/src/OECS/EntityChange.cs
new file mode 100644
index 0000000..581edeb
--- /dev/null
+++ b/src/OECS/EntityChange.cs
@@ -0,0 +1,45 @@
+namespace OECS;
+
+///
+/// Describes a single change that occurred in the ECS world.
+///
+public readonly struct EntityChange : IEquatable
+{
+ ///
+ /// The entity that was affected.
+ ///
+ public Entity Entity { get; }
+
+ ///
+ /// The kind of change.
+ ///
+ public ChangeKind Kind { get; }
+
+ ///
+ /// The component type involved, or null for entity-level changes
+ /// ( / ).
+ ///
+ public Type? ComponentType { get; }
+
+ internal EntityChange(Entity entity, ChangeKind kind, Type? componentType = null)
+ {
+ Entity = entity;
+ Kind = kind;
+ ComponentType = componentType;
+ }
+
+ public bool Equals(EntityChange other)
+ {
+ return Entity.Equals(other.Entity)
+ && Kind == other.Kind
+ && ComponentType == other.ComponentType;
+ }
+
+ public override bool Equals(object? obj) => obj is EntityChange other && Equals(other);
+
+ public override int GetHashCode() => HashCode.Combine(Entity, Kind, ComponentType);
+
+ public override string ToString() => ComponentType == null
+ ? $"{Kind} {Entity}"
+ : $"{Kind} {ComponentType.Name} on {Entity}";
+}
diff --git a/src/OECS/SystemGroup.cs b/src/OECS/SystemGroup.cs
index bf424ef..4cd638d 100644
--- a/src/OECS/SystemGroup.cs
+++ b/src/OECS/SystemGroup.cs
@@ -72,9 +72,16 @@ public class SystemGroup
// Drain commands after each system so subsequent systems
// see the effects of commands enqueued by prior systems.
_world.ExecuteCommands();
+
+ // Post changes after each system so subscribers see
+ // incremental updates.
+ _world.PostChanges();
}
// Drain any remaining commands (e.g., those enqueued outside systems).
_world.ExecuteCommands();
+
+ // Post any remaining changes (e.g., from command execution).
+ _world.PostChanges();
}
}
\ No newline at end of file
diff --git a/src/OECS/World.cs b/src/OECS/World.cs
index c4e1364..f7cb418 100644
--- a/src/OECS/World.cs
+++ b/src/OECS/World.cs
@@ -1,3 +1,6 @@
+using System.Diagnostics;
+using R3;
+
namespace OECS;
///
@@ -12,6 +15,15 @@ public class World : IDisposable
private readonly ComponentStore _components;
private readonly CommandQueue _commands;
private readonly RelationshipIndex _relationships;
+ private readonly ChangeBuffer _changes;
+
+#if DEBUG
+ // Tracks entities whose components were accessed via GetComponent
+ // but never had MarkModified called. Used to warn about missing
+ // MarkModified calls after a system runs.
+ private readonly HashSet<(Entity Entity, Type ComponentType)> _accessedComponents = new();
+ private readonly HashSet<(Entity Entity, Type ComponentType)> _markedModified = new();
+#endif
public World()
{
@@ -19,16 +31,20 @@ public class World : IDisposable
_components = new ComponentStore();
_commands = new CommandQueue();
_relationships = new RelationshipIndex();
+ _changes = new ChangeBuffer();
}
// ── Entity Management ────────────────────────────────────────────
///
/// Creates a new entity and returns its handle.
+ /// Automatically marks an change.
///
public Entity CreateEntity()
{
- return _allocator.Allocate();
+ var entity = _allocator.Allocate();
+ _changes.Pending.MarkEntityAdded(entity);
+ return entity;
}
///
@@ -63,6 +79,8 @@ public class World : IDisposable
_components.RemoveAll(entity);
_allocator.Free(entity);
+
+ _changes.Pending.MarkEntityRemoved(entity);
}
///
@@ -96,6 +114,7 @@ public class World : IDisposable
}
}
+ bool isNew = !_components.Has(entity);
_components.Add(entity, component);
// Update relationship index.
@@ -103,6 +122,10 @@ public class World : IDisposable
{
_relationships.OnAdded(typeof(T), entity, rel.Target);
}
+
+ // Mark change.
+ if (isNew)
+ _changes.Pending.MarkComponentAdded(entity, typeof(T));
}
///
@@ -124,15 +147,26 @@ public class World : IDisposable
}
}
+ bool existed = _components.Has(entity);
_components.Remove(entity);
+
+ if (existed)
+ _changes.Pending.MarkComponentRemoved(entity, typeof(T));
}
///
/// Returns a reference to the component of type
/// for the given entity. Throws if the entity does not have the component.
+ ///
+ /// After mutating the component through this reference, you must call
+ /// so that observers are notified.
+ /// In DEBUG builds, a warning is logged if you forget.
///
public ref T GetComponent(Entity entity) where T : struct
{
+#if DEBUG
+ _accessedComponents.Add((entity, typeof(T)));
+#endif
return ref _components.Get(entity);
}
@@ -144,6 +178,63 @@ public class World : IDisposable
return _components.Has(entity);
}
+ // ── Change Tracking ──────────────────────────────────────────────
+
+ ///
+ /// Marks a component of type on the given entity
+ /// as modified. Must be called after mutating a component via
+ /// so that observers are notified.
+ ///
+ public void MarkModified(Entity entity) where T : struct
+ {
+ _changes.Pending.MarkComponentModified(entity, typeof(T));
+
+#if DEBUG
+ _markedModified.Add((entity, typeof(T)));
+#endif
+ }
+
+ ///
+ /// Posts all pending changes to R3 subscribers, then clears the pending set.
+ /// Called automatically by after each system
+ /// and after the full tick.
+ ///
+ public void PostChanges()
+ {
+#if DEBUG
+ WarnUnmarkedModifications();
+#endif
+ _changes.Post();
+ }
+
+ ///
+ /// Returns an observable that emits all entity changes (adds, removes,
+ /// component adds, component removes, component modifications).
+ ///
+ public Observable ObserveEntityChanges()
+ {
+ return _changes.ObserveEntityChanges();
+ }
+
+ ///
+ /// Returns an observable that emits changes for a specific component type
+ /// (added, removed, or modified).
+ ///
+ public Observable ObserveComponentChanges() where T : struct
+ {
+ return _changes.ObserveComponentChanges(typeof(T));
+ }
+
+ ///
+ /// Returns an observable that emits changes matching the given query.
+ /// Only component-level changes whose component type is in the query's
+ /// "with" set and not in the "without" set are emitted.
+ ///
+ public Observable ObserveQuery(QueryDescriptor query)
+ {
+ return _changes.ObserveQuery(query);
+ }
+
// ── Relationships ────────────────────────────────────────────────
///
@@ -273,6 +364,30 @@ public class World : IDisposable
public void Dispose()
{
- // Future: dispose R3 subscriptions, etc.
+ _changes.Dispose();
}
+
+#if DEBUG
+ ///
+ /// Logs warnings for any components that were accessed via
+ /// but never had
+ /// called.
+ ///
+ private void WarnUnmarkedModifications()
+ {
+ foreach (var (entity, componentType) in _accessedComponents)
+ {
+ if (!_markedModified.Contains((entity, componentType)))
+ {
+ Debug.WriteLine(
+ $"[OECS] Warning: Component '{componentType.Name}' on {entity} " +
+ $"was accessed via GetComponent but MarkModified was never called. " +
+ $"Observers will not be notified of the change.");
+ }
+ }
+
+ _accessedComponents.Clear();
+ _markedModified.Clear();
+ }
+#endif
}
diff --git a/tests/OECS.Tests/ReactivityTests.cs b/tests/OECS.Tests/ReactivityTests.cs
new file mode 100644
index 0000000..66eebe7
--- /dev/null
+++ b/tests/OECS.Tests/ReactivityTests.cs
@@ -0,0 +1,284 @@
+using FluentAssertions;
+using R3;
+using Xunit;
+
+namespace OECS.Tests;
+
+public class ReactivityTests
+{
+ private struct Position { public float X; public float Y; }
+ private struct Health { public int Value; }
+ private struct Frozen { }
+
+ ///
+ /// Helper that collects changes into a list and exposes an R3 Observer.
+ ///
+ private sealed class ChangeCollector : IObserver
+ {
+ public List Changes = new();
+
+ public void OnNext(EntityChange value) => Changes.Add(value);
+ public void OnError(Exception error) { }
+ public void OnCompleted() { }
+ }
+
+ [Fact]
+ public void EntityCreation_PostsEntityAdded()
+ {
+ var world = new World();
+ var collector = new ChangeCollector();
+ using var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver());
+
+ var entity = world.CreateEntity();
+ world.PostChanges();
+
+ collector.Changes.Should().ContainSingle()
+ .Which.Should().Match(c =>
+ c.Entity == entity && c.Kind == ChangeKind.EntityAdded);
+ }
+
+ [Fact]
+ public void EntityDestruction_PostsEntityRemoved()
+ {
+ var world = new World();
+ var entity = world.CreateEntity();
+ world.PostChanges(); // Clear pending
+
+ var collector = new ChangeCollector();
+ using var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver());
+
+ world.DestroyEntity(entity);
+ world.PostChanges();
+
+ collector.Changes.Should().ContainSingle()
+ .Which.Should().Match(c =>
+ c.Entity == entity && c.Kind == ChangeKind.EntityRemoved);
+ }
+
+ [Fact]
+ public void ComponentAdd_PostsComponentAdded()
+ {
+ var world = new World();
+ var entity = world.CreateEntity();
+ world.PostChanges(); // Clear pending
+
+ var collector = new ChangeCollector();
+ using var sub = world.ObserveComponentChanges().Subscribe(collector.ToObserver());
+
+ world.AddComponent(entity, new Position { X = 1, Y = 2 });
+ world.PostChanges();
+
+ collector.Changes.Should().ContainSingle()
+ .Which.Should().Match(c =>
+ c.Entity == entity &&
+ c.Kind == ChangeKind.ComponentAdded &&
+ c.ComponentType == typeof(Position));
+ }
+
+ [Fact]
+ public void ComponentRemove_PostsComponentRemoved()
+ {
+ var world = new World();
+ var entity = world.CreateEntity();
+ world.AddComponent(entity, new Position { X = 1, Y = 2 });
+ world.PostChanges(); // Clear pending
+
+ var collector = new ChangeCollector();
+ using var sub = world.ObserveComponentChanges().Subscribe(collector.ToObserver());
+
+ world.RemoveComponent(entity);
+ world.PostChanges();
+
+ collector.Changes.Should().ContainSingle()
+ .Which.Should().Match(c =>
+ c.Entity == entity &&
+ c.Kind == ChangeKind.ComponentRemoved &&
+ c.ComponentType == typeof(Position));
+ }
+
+ [Fact]
+ public void MarkModified_PostsComponentModified()
+ {
+ var world = new World();
+ var entity = world.CreateEntity();
+ world.AddComponent(entity, new Position { X = 0, Y = 0 });
+ world.PostChanges(); // Clear pending
+
+ var collector = new ChangeCollector();
+ using var sub = world.ObserveComponentChanges().Subscribe(collector.ToObserver());
+
+ world.MarkModified(entity);
+ world.PostChanges();
+
+ collector.Changes.Should().ContainSingle()
+ .Which.Should().Match(c =>
+ c.Entity == entity &&
+ c.Kind == ChangeKind.ComponentModified &&
+ c.ComponentType == typeof(Position));
+ }
+
+ [Fact]
+ public void Changes_AreBatchedPerPost()
+ {
+ var world = new World();
+ var collector = new ChangeCollector();
+ using var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver());
+
+ var a = world.CreateEntity();
+ var b = world.CreateEntity();
+ world.AddComponent(a, new Position { X = 1, Y = 2 });
+ world.AddComponent(b, new Health { Value = 100 });
+
+ // No Post yet — subscribers should not have received anything.
+ collector.Changes.Should().BeEmpty();
+
+ world.PostChanges();
+
+ // All changes arrive in one batch.
+ collector.Changes.Should().HaveCount(4);
+ }
+
+ [Fact]
+ public void Subscribers_ReceiveChangesInOrder()
+ {
+ var world = new World();
+ var collector = new ChangeCollector();
+ using var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver());
+
+ var entity = world.CreateEntity();
+ world.AddComponent(entity, new Position { X = 0, Y = 0 });
+ world.MarkModified(entity);
+ world.RemoveComponent(entity);
+ world.DestroyEntity(entity);
+
+ world.PostChanges();
+
+ collector.Changes.Select(c => c.Kind).Should().Equal(
+ ChangeKind.EntityAdded,
+ ChangeKind.ComponentAdded,
+ ChangeKind.ComponentModified,
+ ChangeKind.ComponentRemoved,
+ ChangeKind.EntityRemoved
+ );
+ }
+
+ [Fact]
+ public void DisposingSubscription_StopsNotifications()
+ {
+ var world = new World();
+ var collector = new ChangeCollector();
+ var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver());
+
+ var entity = world.CreateEntity();
+ world.PostChanges();
+ collector.Changes.Should().HaveCount(1);
+
+ sub.Dispose();
+
+ world.DestroyEntity(entity);
+ world.PostChanges();
+
+ // No new changes after dispose.
+ collector.Changes.Should().HaveCount(1);
+ }
+
+ [Fact]
+ public void ObserveComponentChanges_OnlyReceivesMatchingType()
+ {
+ var world = new World();
+ var posCollector = new ChangeCollector();
+ var hpCollector = new ChangeCollector();
+
+ using var sub1 = world.ObserveComponentChanges().Subscribe(posCollector.ToObserver());
+ using var sub2 = world.ObserveComponentChanges().Subscribe(hpCollector.ToObserver());
+
+ var entity = world.CreateEntity();
+ world.AddComponent(entity, new Position { X = 1, Y = 2 });
+ world.AddComponent(entity, new Health { Value = 100 });
+ world.PostChanges();
+
+ posCollector.Changes.Should().HaveCount(1);
+ posCollector.Changes[0].ComponentType.Should().Be(typeof(Position));
+
+ hpCollector.Changes.Should().HaveCount(1);
+ hpCollector.Changes[0].ComponentType.Should().Be(typeof(Health));
+ }
+
+ [Fact]
+ public void ObserveQuery_OnlyReceivesMatchingChanges()
+ {
+ var world = new World();
+ var query = world.Query().With().Without().Build();
+ var collector = new ChangeCollector();
+
+ using var sub = world.ObserveQuery(query).Subscribe(collector.ToObserver());
+
+ var entity = world.CreateEntity();
+ world.AddComponent(entity, new Position { X = 1, Y = 2 }); // Should match
+ world.AddComponent(entity, new Frozen()); // Should NOT match (not in With)
+ world.AddComponent(entity, new Health { Value = 100 }); // Should NOT match (not in With)
+ world.PostChanges();
+
+ collector.Changes.Should().HaveCount(1);
+ collector.Changes[0].ComponentType.Should().Be(typeof(Position));
+ }
+
+ [Fact]
+ public void Deduplication_PreventsDuplicateChanges()
+ {
+ var world = new World();
+ var collector = new ChangeCollector();
+ using var sub = world.ObserveComponentChanges().Subscribe(collector.ToObserver());
+
+ var entity = world.CreateEntity();
+ world.AddComponent(entity, new Position { X = 0, Y = 0 });
+ world.MarkModified(entity);
+ world.MarkModified(entity); // Duplicate
+ world.MarkModified(entity); // Duplicate
+ world.PostChanges();
+
+ // Should have exactly 2 changes: ComponentAdded + ComponentModified (deduplicated).
+ collector.Changes.Should().HaveCount(2);
+ collector.Changes[0].Kind.Should().Be(ChangeKind.ComponentAdded);
+ collector.Changes[1].Kind.Should().Be(ChangeKind.ComponentModified);
+ }
+
+ [Fact]
+ public void Changes_ArePostedAfterEachSystem_InSystemGroup()
+ {
+ var world = new World();
+ var group = new SystemGroup(world);
+
+ var collector = new ChangeCollector();
+ using var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver());
+
+ // System that creates an entity.
+ group.Add(new EntityCreatorSystem(world));
+
+ // Before running, no changes should be posted.
+ collector.Changes.Should().BeEmpty();
+
+ group.RunLogical();
+
+ // After the system group runs, changes should be posted.
+ collector.Changes.Should().Contain(c => c.Kind == ChangeKind.EntityAdded);
+ }
+
+ private class EntityCreatorSystem : ISystem
+ {
+ private readonly World _world;
+
+ public QueryDescriptor Query { get; }
+
+ public EntityCreatorSystem(World world)
+ {
+ _world = world;
+ Query = world.Query().With().Build();
+ }
+
+ public void Run(World world)
+ {
+ _world.CreateEntity();
+ }
+ }
+}