--- name: developing-oecs description: Develop and modify the OECS entity component system library itself. Use this when changing the core ECS types (World, Entity, ComponentStore, SparseSet, query execution, commands, relationships, reactivity, singletons, serialization) or the OECS.SourceGen incremental generator. --- # Developing OECS OECS is a single-threaded, observable-first ECS for C#. It targets `net8.0` (C# 12). The source lives under `OECS/` and the incremental source generator is under `OECS.SourceGen/`. ## Project Layout ``` OECS.sln ├── Directory.Build.props # Shared: net8.0, ImplicitUsings, Nullable, C# 12 ├── OECS/ │ └── OECS.csproj # Core library + NuGet metadata ├── OECS.SourceGen/ │ └── OECS.SourceGen.csproj # Roslyn incremental source generator ├── OECS.Tests/ # Unit tests for OECS itself ├── Game.Blackjack/ # Integration test: blackjack game ├── Game.Blackjack.Tests/ # Blackjack tests (unit + snapshot + play) ├── Game.TicTacToe/ # Integration test: tic-tac-toe game ├── Game.TicTacToe.Tests/ ├── docs/ # Architecture, API surface, implementation plan └── nupkgs/ # Built NuGet packages ``` ## Core Architecture Read `docs/architecture.md` for the full rationale behind each design decision. Key ADRs to keep in mind when changing the library: | ADR | Decision | Why | |---|---|---| | 001 | Sparse sets over archetypes | Cheap add/remove for reactivity | | 002 | 32-bit Entity (24 ID + 8 version) | Fits in register, enough headroom | | 003 | Explicit query iteration | Visible cost model, no code-gen needed | | 004 | Registration order for systems | Simplest model that works at this scale | | 005 | Auto-tracking in batching scopes | Eliminates manual `MarkModified` in systems | | 006 | Deferred mutation batching | Prevents mid-system reentrancy, safe iteration | | 007 | R3 for reactivity | Zero-allocation, UI lifecycle-friendly | | 008 | Commands as serializable structs in a queue | Not ECS state | | 009 | Singleton per component type | Each singleton gets its own entity | | 010 | Single-threaded by default | Simpler, no locks needed | | 011 | .NET 8 target | LTS through Nov 2026 | | 012 | Public types required for MessagePack | Build-time validation via analyzer | ## Key Types ### `World` — the public entry point Everything routes through `World`. It owns: - `EntityAllocator _allocator` — entity lifecycle (create, destroy, recycle). - `ComponentStore _components` — generic sparse set registry. - `RelationshipIndex _relationships` — reverse lookup for relationships. - `ChangeBuffer _changes` — accumulates and posts changes to R3 subjects. - `CommandQueue _commands` — deferred command execution. ### `ComponentStore` — sparse set registry Maps `Type` → `SparseSet`. Exposes `GetSet(Type)` for internal use. Public API on `World` delegates here with generic type resolution. ### `SparseSet` — per-type component storage Dense/sparse array pair. `dense[]` is packed values, `denseEntities[]` is parallel entity IDs, `sparse[]` maps entity ID → dense index (-1 = absent). Uses swap-remove for O(1) deletion. ### `WorldQueryExtensions.Select1..Select6` — ref struct iterators Zero-allocation `ref struct` enumerators returned by `world.Select()`. Drive from the smallest sparse set to minimize probes. Support `foreach` via `GetEnumerator()` returning `this`. Expose `Entity`, `Ref1`..`RefN` (mutable, tracked for auto-dirty-marking), and `Val1`..`ValN` (read-only, untracked). Constructor/dispose manage `BeginBatching()`/`EndBatching()` for pending mutation flushing. Singleton entities are excluded via `IsSingletonEntity()`. Also provides `FindEntity()` (first match). ### `Query` — generic query descriptors Structs that encode `With` types as generic parameters. `Without()` fluent method adds exclusion filters without changing the arity. Replaces the old `QueryBuilder` / `QueryDescriptor` / `ForEachAction` pattern. ### `SystemGroup` — system orchestration Manages an ordered list of `ISystem`. `RunAll()`: 1. Drain commands (pre-tick). 2. `BeginBatching()` — outer batching scope for the tick. 3. For each system: run via `Run` extension (nested batch) → drain commands → flush pending mutations → post changes. 4. `EndBatching()` — auto-marks + flushes remaining. 5. Drain commands + post changes (post-tick). ### `WorldSerializer` — save/load Uses the source-generated `ComponentRegistry` to discover component types without reflection. Serializes each entity's components to MessagePack blobs. On load, fixates `IRelationship.Source` to match the owning entity. ## Source Generator (`OECS.SourceGen`) A Roslyn incremental source generator that: 1. Scans for all invocations of World methods with generic type arguments (e.g., `AddComponent`, `SetSingleton`). 2. Generates a `ComponentRegistry` class with `ComponentDescriptor[]` listing every discovered type. 3. Each descriptor includes the type name, `Type` reference, and static lambdas for serialization/deserialization via MessagePack. The generator must be rebuilt before `OECS.csproj` packs into a NuGet package (see the `BuildSourceGenerator` target in `OECS.csproj`). ## API Surface Contract The public API surface documented in `docs/api-surface.md` is the contract. Any change to a public type signature must update the doc. Check for drift regularly — the doc should match the code exactly, not the other way around. ## Dependencies | Package | Version | Purpose | |---|---|---| | `MessagePack` | 3.1.7 | Binary serialization for components, snapshots | | `MessagePackAnalyzer` | 3.1.7 | Compile-time validation of `[MessagePackObject]` types | | `R3` | 1.2.9 | Reactive observables for change subscriptions | | `Microsoft.CodeAnalysis.CSharp` | via SourceGen | Roslyn incremental generator API | ## Building & Testing ```bash # Build solution dotnet build OECS.sln # Run OECS core tests dotnet test OECS.Tests/OECS.Tests.csproj # Run blackjack integration tests dotnet test Game.Blackjack.Tests/Game.Blackjack.Tests.csproj # Pack NuGet dotnet pack OECS/OECS.csproj -c Release -o nupkgs ``` ## Adding a New Feature 1. Check ADRs in `docs/architecture.md` — does the change fit the existing decisions? If it contradicts one, write a new ADR or revise the existing one. 2. Implement the feature in `OECS/`. 3. If it touches public API, update `docs/api-surface.md`. 4. Add tests in `OECS.Tests/`. 5. Update `docs/implementation-plan.md` if the phase descriptions need adjusting. 6. Verify the blackjack game still works (`dotnet test Game.Blackjack.Tests`). ## Changing the Source Generator 1. Modify `OECS.SourceGen/`. 2. Build it explicitly: `dotnet build OECS.SourceGen/OECS.SourceGen.csproj`. 3. The DLL at `OECS.SourceGen/bin/Debug/netstandard2.0/OECS.SourceGen.dll` is referenced as an analyzer by downstream projects. 4. Test with a game project to verify component registry is generated correctly. ## Conventions ### Component and command types Game-level components and commands should be `public record struct` types with explicit fields/properties — **not** positional syntax. Positional record structs produce `init`-only properties that can't be mutated through `ref T`, which breaks the core OECS pattern of in-place component mutation. ```csharp // ✅ Correct: explicit fields, ref-mutable [MessagePackObject] public record struct Card { [Key(0)] public Suit Suit; [Key(1)] public Rank Rank; } // ❌ Wrong: positional syntax produces init-only properties [MessagePackObject] public record struct Card([Key(0)] Suit Suit, [Key(1)] Rank Rank); ``` Tag components (no data) can be plain `struct` — `record struct` adds no value when there are no fields to compare. ## Common Pitfalls - **Using `ValN` when you meant to mutate.** `ValN` returns a `ref readonly` — writes through it still work but won't be tracked for auto-dirty-marking. Use `RefN` for any component you intend to mutate. - **Modifying components during iteration.** Pending mutations are flushed at the end of the batching scope. Adding/removing components mid-iteration is safe — they're deferred. - **Type not public or missing `[MessagePackObject]`.** Serialization will fail. The `MessagePackAnalyzer` catches most issues at compile time. - **Singletons each have their own entity allocated automatically by `SetSingleton`.** Iterators skip singleton entities automatically.