# 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` (C# 12) - Dependencies: `MessagePack` (serialization), `R3` (reactivity) - Output: `OECS.dll` - Source generator: `OECS.SourceGen.dll` (component registry for serialization) --- ## Phase 1 — Core Foundation (Week 1) **Goal:** Create/destroy entities, add/remove components, iterate sparse sets. ### 1.1 Project Scaffold ``` OECS.sln ├── OECS/OECS.csproj ├── OECS.SourceGen/OECS.SourceGen.csproj ├── OECS.Tests/OECS.Tests.csproj ├── Game.Blackjack/Blackjack.csproj ├── Game.Blackjack.Tests/Game.Blackjack.Tests.csproj └── Game.TicTacToe/... ``` - `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)` - `T Read(Entity)` — copy without auto-mark - `bool TryGet(Entity, out T)` - `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; T ReadComponent(Entity entity) where T : struct; bool TryGetComponent(Entity entity, out T value) 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 { IReadOnlySet With { get; } IReadOnlySet 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 two iteration styles: **ForEach callbacks** (1–6 component types): ```csharp void ForEach(QueryDescriptor query, ForEachAction action); ``` **Ref struct iterators** via `EntityIterator.Select()` extensions (1–3 component types): ```csharp using var iter = world.Select(query); while (iter.MoveNext()) { ... } ``` Both accept an optional `QueryDescriptor`. When omitted, all entities with the given component types are iterated (no `Without` filter). The smallest "with" sparse set is used as the driver; other sets are probed for membership. The singleton entity (ID 1) is always skipped. ### 2.4 ISystem ``` interface ISystem { void Run(World world); } ``` Systems do **not** declare a query on the interface. Instead, they either read singletons directly or build queries internally and iterate with `ForEach` / `Select`. This keeps the interface minimal and gives systems full flexibility. **Why no auto-injection?** The explicit API makes the iteration cost visible and avoids the need for source generators or reflection for system dispatch. ### 2.5 ITickedSystem ``` interface ITickedSystem : ISystem { void Run(World world, Tick tick); } ``` Systems that need tick metadata (delta time or logical tick marker) implement this. Both `Run` overloads must be implemented; `SystemGroup` dispatches to the appropriate one at runtime. ### 2.6 System Registration & Ordering ``` class SystemGroup { void Add(ISystem system); // registration order = execution order void Remove(ISystem system); void RunTimed(float deltaTime); void RunLogical(); int Count { get; } } ``` Systems run in registration order. `SystemGroup` automatically: - Drains commands before the tick, after each system, and after the full tick. - Posts changes after each system and after the full tick. ### 2.7 Tick ``` readonly struct Tick { TickType Type { get; } // Timed or Logical float DeltaTime { get; } // 0 for logical ticks } ``` ### 2.8 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. - `ITickedSystem` receives correct tick data. --- ## Phase 3 — Commands (Week 3) **Goal:** Serializable command queue with deferred execution. ### 3.1 ICommand ``` interface ICommand { void Execute(World world); } ``` Commands are structs implementing `ICommand`. They are typically `[MessagePackObject]` for serialization. Not stored in ECS sparse sets — they live in a queue. ### 3.2 CommandQueue ``` class CommandQueue { void Enqueue(T command) where T : struct, ICommand; void ExecuteAll(World world); // FIFO, fully drains queue void Clear(); void ClearErrors(); int Count { get; } IReadOnlyList Errors { get; } } ``` `Enqueue` uses a constrained generic to avoid boxing at the call site. Commands enqueued during `ExecuteAll` are processed in the same drain cycle. ### 3.3 Integration with World `World` owns a `CommandQueue`. After each system runs (or after the full tick), the queue is drained automatically by `SystemGroup`. Manual drain is also available: ```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 IRelationship ``` interface IRelationship { Entity Source { get; } Entity Target { get; } } ``` ### 4.2 Relationship\ ``` [MessagePackObject] struct Relationship : IRelationship where TSelf : struct where TTarget : struct { [Key(0)] Entity Source { get; set; } [Key(1)] Entity Target { get; set; } } ``` The `IRelationship` marker interface lets `ComponentStore` detect relationships and maintain the reverse index. The `TSelf`/`TTarget` phantom types differentiate relationship kinds at the type level. Alternatively, implement `IRelationship` directly on your own struct (see the Blackjack `Holds` and `InDeck` types). ### 4.3 Reverse Index `World` maintains a reverse index per relationship type, mapping target → set of source entities. Updated automatically when relationship components are added/removed. ### 4.4 Reverse Lookup API ```csharp IReadOnlyCollection GetSources(Entity target) where T : struct, IRelationship; ``` ### 4.5 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.6 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 ChangeBuffer ``` class ChangeBuffer { void Mark<...>(...); void Post(); // pushes to R3 subjects, then clears } ``` ### 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. **`ReadComponent`** (returns a copy) and **`ReadSingleton`** never auto-mark. Use these when you only need to inspect state without signaling changes. ### 5.4 Posting Model - During a system's `Run`, changes accumulate in a pending buffer. - After each system's `Run`, `Post()` is called automatically by `SystemGroup`. - After the full tick, `Post()` is called once more. - 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 Observable ObserveEntityChanges(); Observable ObserveComponentChanges() where T : struct; Observable 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 (all iterators skip ID 1). ### 6.2 Singleton Accessors ```csharp void SetSingleton(T component) where T : struct; ref T GetSingleton() where T : struct; T ReadSingleton() where T : struct; bool HasSingleton() where T : struct; void RemoveSingleton() where T : struct; ``` These are convenience wrappers around `AddComponent`/`GetComponent` on the singleton entity. `GetSingleton` returns a `ref` (auto-marks during iteration); `ReadSingleton` returns a copy (never auto-marks). ### 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 — Source Generator & Serialization (Week 7) **Goal:** Compile-time component registry for serialization without reflection. ### 7.1 OECS.SourceGen A Roslyn incremental source generator that scans for all types used as generic arguments to `World` methods (`AddComponent`, `GetComponent`, `SetSingleton`, etc.) and generates a `ComponentRegistry` class with per-type serialize/deserialize callbacks via MessagePack. ### 7.2 WorldSerializer ```csharp static class WorldSerializer { static void Save(World world, Stream stream); static void Load(World world, Stream stream); } ``` Uses `ComponentRegistry` to serialize/deserialize all entities and their components to/from a MessagePack stream. `IRelationship.Source` is fixed up to the owning entity on load. ### 7.3 Snapshot Types `WorldSnapshot`, `EntitySnapshot`, and `ComponentEntry` are the public serialization DTOs. --- ## Phase 8 — Polish & Documentation (Week 8) ### 8.1 XML Docs All public API surface gets `` XML documentation comments. ### 8.2 README Quick-start guide with a minimal example: create world, register system, run tick, observe changes. ### 8.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` - Bundles `OECS.SourceGen.dll` as an analyzer for consumers. ### 8.4 Testing Games Test games (`Game.Blackjack`, `Game.TicTacToe`) serve as integration tests and design validation. See `docs/testing-games.md` for the testing strategy. --- ## Dependency Graph ``` Phase 1 (Core) └─→ Phase 2 (Queries & Systems) └─→ Phase 3 (Commands) └─→ Phase 4 (Relationships) └─→ Phase 5 (Reactivity) └─→ Phase 6 (Singletons) └─→ Phase 7 (Source Gen & Serialization) └─→ Phase 8 (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. **Multiple worlds?** The design supports it naturally — `World` is a class, you can instantiate multiple. No cross-world references are supported. 3. **Component import from CSV/MasterMemory?** The `design.md` mentions this as a potential feature. Not yet implemented.