oecs-sharp/.agents/skills/developing-oecs/SKILL.md

191 lines
7.7 KiB
Markdown

---
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 | Manual `MarkModified` for value changes | No per-component overhead |
| 006 | Deferred change posting | Prevents mid-system reentrancy |
| 007 | R3 for reactivity | Zero-allocation, UI lifecycle-friendly |
| 008 | Commands as serializable structs in a queue | Not ECS state |
| 009 | Singleton as reserved entity (ID 1) | Reuses component storage |
| 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<T>`. Exposes `GetSet(Type)` for internal use.
Public API on `World` delegates here with generic type resolution.
### `SparseSet<T>` — 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.
### `EntityIterator.Select1/2/3<T...>` — ref struct iterators
Zero-allocation iterators. Drive from the smallest sparse set to minimize
probes. Support `foreach` via `GetEnumerator()` returning `this`. Must call
`world.BeginIteration()` / `EndIteration()` for pending mutation flushing.
Always skip singleton entity (ID 1).
### `SystemGroup` — system orchestration
Manages an ordered list of `ISystem`. `RunAll()`:
1. Drain commands (pre-tick).
2. For each system: run it → drain commands → post changes.
3. 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<Card>`, `SetSingleton<GameState>`).
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
- **Forgetting `MarkModified` after mutating a `ref` component.** Changes won't
propagate to R3 subscribers. Debug builds have a warning for this.
- **Modifying components during iteration.** Pending mutations are flushed at
the end of iteration (via `BeginIteration`/`EndIteration`). 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.
- **Entity ID 1 is the singleton.** Don't destroy it. Iterators skip it
automatically.