7.7 KiB
| name | description |
|---|---|
| developing-oecs | 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():
- Drain commands (pre-tick).
- For each system: run it → drain commands → post changes.
- 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:
- Scans for all invocations of World methods with generic type arguments
(e.g.,
AddComponent<Card>,SetSingleton<GameState>). - Generates a
ComponentRegistryclass withComponentDescriptor[]listing every discovered type. - Each descriptor includes the type name,
Typereference, 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
# 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
- 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. - Implement the feature in
OECS/. - If it touches public API, update
docs/api-surface.md. - Add tests in
OECS.Tests/. - Update
docs/implementation-plan.mdif the phase descriptions need adjusting. - Verify the blackjack game still works (
dotnet test Game.Blackjack.Tests).
Changing the Source Generator
- Modify
OECS.SourceGen/. - Build it explicitly:
dotnet build OECS.SourceGen/OECS.SourceGen.csproj. - The DLL at
OECS.SourceGen/bin/Debug/netstandard2.0/OECS.SourceGen.dllis referenced as an analyzer by downstream projects. - 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.
// ✅ 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
MarkModifiedafter mutating arefcomponent. 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. TheMessagePackAnalyzercatches most issues at compile time. - Entity ID 1 is the singleton. Don't destroy it. Iterators skip it automatically.