# 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.csproj` targets `net8.0`, references `MessagePack` and `R3`. - `OECS.Tests.csproj` references `OECS` + `xunit` + `FluentAssertions`. ### 1.2 Entity `readonly struct Entity : IEquatable` | 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\ ``` class SparseSet 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` mapping component types to their `SparseSet`. Provides typed generic methods: - `void Add(Entity, T)` - `void Remove(Entity)` - `ref T Get(Entity)` - `bool Has(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: ```csharp Entity CreateEntity(); void DestroyEntity(Entity entity); void AddComponent(Entity entity, T component) where T : struct; void RemoveComponent(Entity entity) where T : struct; ref T GetComponent(Entity entity) where T : struct; bool HasComponent(Entity entity) where T : struct; bool IsAlive(Entity entity); ``` ### 1.6 Tests - Entity creation returns unique IDs. - Entity destruction recycles IDs with incremented version. - `IsAlive` returns 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 With { get; } HashSet Without { get; } } ``` ### 2.2 QueryBuilder Fluent API returned by `World.Query()`: ```csharp world.Query() .With() .With() .Without() .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. ```csharp void ForEach(QueryDescriptor query, Action 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` 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: ```csharp 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 `ExecuteAll` runs 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\ ``` [MessagePackObject] struct Relationship : 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>` 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 ```csharp IReadOnlyCollection GetSources(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 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(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(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 in `Pending`. - 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: ```csharp IObservable ObserveEntityChanges(); IObservable ObserveComponentChanges() where T : struct; IObservable ObserveQuery(QueryDescriptor query); ``` These are backed by `Subject` instances. Subscribers receive batched changes after each `Post()`. ### 5.6 Subscription Lifecycle Subscriptions return `IDisposable`. UI code ties this to component lifecycle: ```csharp world.ObserveComponentChanges() .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. - `MarkModified` posts `ComponentModified`. - 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 ```csharp void SetSingleton(T component) where T : struct; ref T GetSingleton() where T : struct; bool HasSingleton() where T : struct; void RemoveSingleton() 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`/`GetSingleton` round-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 `` 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`: `OECS` - `Description`: "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 1. **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. 2. **World serialization?** Since components are MessagePack-serializable, snapshotting the entire world is feasible. This is a Phase 7+ stretch goal. 3. **Multiple worlds?** The design supports it naturally — `World` is a class, you can instantiate multiple. No cross-world references are supported.