14 KiB
Implementation Plan: OECS (Observable ECS)
Overview
This document lays out the phased implementation of OECS as a .NET class library
(DLL). Each phase produces a shippable increment; later phases build on earlier
ones. Architecture decisions are captured in docs/architecture.md.
Target
- .NET 8 (LTS),
net8.0 - Dependencies:
MessagePack(serialization),R3(reactivity) - Output:
OECS.dll
Phase 1 — Core Foundation (Week 1)
Goal: Create/destroy entities, add/remove components, iterate sparse sets.
1.1 Project Scaffold
OECS.sln
├── src/OECS/OECS.csproj
└── tests/OECS.Tests/OECS.Tests.csproj
OECS.csprojtargetsnet8.0, referencesMessagePackandR3.OECS.Tests.csprojreferencesOECS+xunit+FluentAssertions.
1.2 Entity
readonly struct Entity : IEquatable<Entity>
| Decision | Rationale |
|---|---|
32-bit uint backing |
Keeps size small; 24-bit ID (16.7M) + 8-bit version (256 gens) is ample for observable-first use cases. |
Entity.Null sentinel (value 0) |
Entity ID 0 is reserved; version 0 means "never alive." |
Id (24-bit) and Version (8-bit) properties |
Expose for debugging; opaque otherwise. |
ToString() → "Entity(42:v3)" |
Debuggability. |
Internal constructor; World is the only factory.
1.3 SparseSet<T>
class SparseSet<T> where T : struct
| Field | Purpose |
|---|---|
T[] dense |
Packed component values (no holes). |
Entity[] denseEntities |
Parallel array: which entity owns each dense slot. |
int[] sparse |
Maps entity ID → dense index; -1 = absent. |
Operations: Add(Entity, T), Remove(Entity), ref T Get(Entity),
bool Contains(Entity), int Count, void Clear().
dense and sparse arrays grow geometrically (×2) on overflow.
Why sparse sets over archetypes? The design prioritizes cheap add/remove (for reactivity) over raw iteration throughput. Archetypes would require moving entities between archetypes on component change, which complicates change tracking.
1.4 ComponentStore
class ComponentStore
Holds a Dictionary<Type, object> mapping component types to their
SparseSet<T>. Provides typed generic methods:
void Add<T>(Entity, T)void Remove<T>(Entity)ref T Get<T>(Entity)bool Has<T>(Entity)void RemoveAll(Entity)— called on entity destruction
1.5 World
class World
| Responsibility | Detail |
|---|---|
| Entity allocation | Free-list of recycled IDs; bump allocator for new IDs. |
| Component access | Delegates to ComponentStore. |
| Entity destruction | Returns ID to free list, increments version, removes all components. |
API surface:
Entity CreateEntity();
void DestroyEntity(Entity entity);
void AddComponent<T>(Entity entity, T component) where T : struct;
void RemoveComponent<T>(Entity entity) where T : struct;
ref T GetComponent<T>(Entity entity) where T : struct;
bool HasComponent<T>(Entity entity) where T : struct;
bool IsAlive(Entity entity);
1.6 Tests
- Entity creation returns unique IDs.
- Entity destruction recycles IDs with incremented version.
IsAlivereturns false for destroyed entities.- Add/remove/has component round-trips correctly.
- Sparse set iteration visits all added components.
- Removing a component mid-iteration is safe (deferred or swap-remove).
Phase 2 — Queries & Systems (Week 2)
Goal: Define queries, register systems, run ticks.
2.1 Query Description
A query is defined by:
- A set of "with" component types.
- A set of "without" component types.
class QueryDescriptor
{
HashSet<Type> With { get; }
HashSet<Type> Without { get; }
}
2.2 QueryBuilder
Fluent API returned by World.Query():
world.Query()
.With<Position>()
.With<Velocity>()
.Without<Frozen>()
.Build() // → QueryDescriptor
2.3 Query Execution
World provides iteration over matching entities. The smallest "with" sparse
set is used as the driver; other sets are probed for membership.
void ForEach<T1, T2>(QueryDescriptor query, Action<Entity, ref T1, ref T2> action);
Overloads for 1–6 component types. The Without filter is checked by probing
the corresponding sparse sets.
2.4 ISystem
interface ISystem
{
QueryDescriptor Query { get; }
void Run(World world);
}
Systems declare their query and receive the world in Run. They call
world.ForEach(query, ...) to iterate.
Alternative considered: auto-injection of component refs. Rejected because it
hides the iteration cost and makes the API less explicit. The explicit
ForEach call keeps the system author aware of what they're iterating.
2.5 System Registration & Ordering
class SystemGroup
{
void Add(ISystem system); // registration order = execution order
void RunTimed(float deltaTime); // calls each system's Run
void RunLogical(); // calls each system's Run
}
Systems run in registration order. For now, no explicit dependency graph —
this is the simplest model that works. If needed later, Before()/After()
constraints can be added without breaking the API.
2.6 Tick
readonly struct Tick
{
TickType Type { get; } // Timed or Logical
float DeltaTime { get; } // 0 for logical ticks
}
Passed to systems that opt into it via a separate interface:
interface ITickedSystem : ISystem
{
void Run(World world, Tick tick);
}
2.7 Tests
- Query with single component returns matching entities.
- Query with multiple components returns intersection.
Without<T>excludes entities with T.- Adding/removing components updates query results.
- Systems run in registration order.
- Destroyed entities don't appear in queries.
Phase 3 — Commands (Week 3)
Goal: Serializable command queue with deferred execution.
3.1 ICommand
[MessagePackObject]
interface ICommand
{
void Execute(World world);
}
Commands are [MessagePackObject] structs implementing ICommand. They are
not stored in ECS sparse sets — they live in a queue.
3.2 CommandQueue
class CommandQueue
{
void Enqueue(ICommand command);
void ExecuteAll(World world); // FIFO, clears queue
int Count { get; }
}
3.3 Integration with World
World owns a CommandQueue. After each system runs (or after the full tick),
the queue is drained. This is configurable:
world.ExecuteCommands(); // manual drain
Systems enqueue commands via world.Commands.Enqueue(...).
3.4 Error Handling
If a command's Execute throws, the exception is caught and stored. The queue
continues processing remaining commands. After ExecuteAll, any errors are
available via CommandQueue.Errors.
3.5 Tests
- Commands execute in FIFO order.
- A command can read/write ECS state.
- A command enqueued during
ExecuteAllruns in the same drain cycle. - Exceptions are collected, not lost.
- Serialization round-trip preserves command data.
Phase 4 — Relationships (Week 4)
Goal: Relationship components with auto-managed source/target and reverse lookup.
4.1 Relationship<TSelf, TTarget>
[MessagePackObject]
struct Relationship<TSelf, TTarget> : IRelationship
{
[Key(0)] Entity Source { get; set; }
[Key(1)] Entity Target { get; set; }
// ... payload fields
}
The IRelationship marker interface lets ComponentStore detect relationships
and maintain the reverse index.
4.2 Reverse Index
World maintains a Dictionary<Entity, HashSet<Entity>> per relationship
type, mapping target → set of source entities.
When a relationship component is added/removed, the index is updated automatically.
4.3 Reverse Lookup API
IReadOnlyCollection<Entity> GetSources<T>(Entity target) where T : struct, IRelationship;
4.4 Cascading Behavior
When an entity is destroyed:
- All relationships where it is the source are removed (components dropped).
- All relationships where it is the target are removed (components dropped from source entities).
- The reverse index is cleaned up.
4.5 Tests
- Adding a relationship updates the reverse index.
- Removing a relationship updates the reverse index.
- Destroying a source entity cleans up its relationships.
- Destroying a target entity cleans up incoming relationships.
- Reverse lookup returns correct sources.
Phase 5 — Reactivity (Week 5–6)
Goal: Change tracking, marking, posting, and R3 observable queries.
5.1 Change Kinds
enum ChangeKind
{
EntityAdded,
EntityRemoved,
ComponentAdded,
ComponentRemoved,
ComponentModified
}
struct EntityChange
{
Entity Entity { get; }
ChangeKind Kind { get; }
Type ComponentType { get; } // null for entity-level changes
}
5.2 ChangeSet
class ChangeSet
{
void MarkEntityAdded(Entity entity);
void MarkEntityRemoved(Entity entity);
void MarkComponentAdded(Entity entity, Type componentType);
void MarkComponentRemoved(Entity entity, Type componentType);
void MarkComponentModified(Entity entity, Type componentType);
IReadOnlyList<EntityChange> Changes { get; }
void Clear();
}
5.3 Automatic vs. Manual Marking
| Change Type | Marking |
|---|---|
| Entity created | Auto |
| Entity destroyed | Auto |
| Component added | Auto |
| Component removed | Auto |
| Component modified | Manual via world.MarkModified<T>(entity) |
Rationale: structural changes are always detectable. Value mutations inside a
ref T are not — the sparse set has no way to know the caller changed the
value. Requiring an explicit MarkModified call is the simplest correct
approach.
Debug aid: In DEBUG builds, ref T Get<T>(Entity) returns a wrapper that
tracks whether the value was written. If a system iterates ref T and never
calls MarkModified, a warning is logged. This catches the most common
mistake.
5.4 Posting Model
class ChangeBuffer
{
ChangeSet Pending { get; } // accumulates during system run
void Post(); // pushes to R3 subjects, then clears
}
- During a system's
Run, changes accumulate inPending. - After each system's
Run,Post()is called automatically. - After the full tick,
Post()is called once more (for any changes made outside systems, e.g., during command execution). - When not in a system run, changes are not posted automatically — the
caller must call
world.PostChanges().
5.5 R3 Integration
World exposes observables:
IObservable<EntityChange> ObserveEntityChanges();
IObservable<EntityChange> ObserveComponentChanges<T>() where T : struct;
IObservable<EntityChange> ObserveQuery(QueryDescriptor query);
These are backed by Subject<EntityChange> instances. Subscribers receive
batched changes after each Post().
5.6 Subscription Lifecycle
Subscriptions return IDisposable. UI code ties this to component lifecycle:
world.ObserveComponentChanges<Health>()
.Subscribe(change => UpdateHealthBar(change))
.AddTo(componentDisposables); // R3's AddTo
5.7 Tests
- Entity creation posts
EntityAdded. - Entity destruction posts
EntityRemoved. - Component add/remove posts corresponding changes.
MarkModifiedpostsComponentModified.- Changes are batched per
Post()call. - Subscribers receive changes in order.
- Disposing a subscription stops notifications.
Phase 6 — Singletons (Week 6)
Goal: Singleton entity and ergonomic accessors.
6.1 Singleton Entity
World reserves entity ID 1 as the singleton entity. It is never destroyed
and is excluded from normal queries by default.
6.2 Singleton Accessors
void SetSingleton<T>(T component) where T : struct;
ref T GetSingleton<T>() where T : struct;
bool HasSingleton<T>() where T : struct;
void RemoveSingleton<T>() where T : struct;
These are convenience wrappers around AddComponent/GetComponent on the
singleton entity.
6.3 Query Exclusion
Queries automatically exclude the singleton entity. If a user genuinely wants to include it, they can query it by its entity ID directly.
6.4 Tests
SetSingleton/GetSingletonround-trips.- Singleton entity does not appear in normal queries.
- Removing a singleton works.
- Singleton survives tick execution.
Phase 7 — Polish & Documentation (Week 7)
7.1 XML Docs
All public API surface gets <summary> XML documentation comments.
7.2 README
Quick-start guide with a minimal example: create world, register system, run tick, observe changes.
7.3 NuGet Packaging
OECS.csproj includes package metadata:
PackageId:OECSDescription: "Observable ECS for C# — an entity component system focused on a clean reactive API surface."PackageTags:ecs;reactive;observable;gamedev
7.4 CI (optional)
GitHub Actions workflow: build, test, pack.
Dependency Graph
Phase 1 (Core)
└─→ Phase 2 (Queries & Systems)
└─→ Phase 3 (Commands)
└─→ Phase 4 (Relationships)
└─→ Phase 5 (Reactivity)
└─→ Phase 6 (Singletons)
└─→ Phase 7 (Polish)
Phases 3 and 4 can be done in parallel; Phase 5 depends on both.
Open Questions
-
Parallel system execution? Deferred. The design is single-threaded by default. If needed, systems could declare read/write component access for automatic parallel scheduling — but this adds significant complexity.
-
World serialization? Since components are MessagePack-serializable, snapshotting the entire world is feasible. This is a Phase 7+ stretch goal.
-
Multiple worlds? The design supports it naturally —
Worldis a class, you can instantiate multiple. No cross-world references are supported.