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

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 TypeSparseSet<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():

  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<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

# 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.

// ✅ 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 structrecord 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<T>. Iterators skip singleton entities automatically.