8.5 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 | 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<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.
WorldQueryExtensions.Select1..Select6<T...> — ref struct iterators
Zero-allocation ref struct enumerators returned by world.Select<T1..T6>().
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<T>() (first match).
Query<T1..T6> — generic query descriptors
Structs that encode With types as generic parameters. Without<W>() 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():
- Drain commands (pre-tick).
BeginBatching()— outer batching scope for the tick.- For each system: run via
Runextension (nested batch) → drain commands → flush pending mutations → post changes. EndBatching()— auto-marks + flushes remaining.- 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
- Using
ValNwhen you meant to mutate.ValNreturns aref readonly— writes through it still work but won't be tracked for auto-dirty-marking. UseRefNfor 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. TheMessagePackAnalyzercatches most issues at compile time. - Singletons each have their own entity allocated automatically by
SetSingleton<T>. Iterators skip singleton entities automatically.