oecs-sharp/docs/implementation-plan.md

14 KiB
Raw Blame History

Implementation Plan: OECS (Observable ECS)

Overview

This document lays out the phased implementation of OECS as a .NET class library (DLL). Each phase produces a shippable increment; later phases build on earlier ones. Architecture decisions are captured in docs/architecture.md.

Target

  • .NET 8 (LTS), net8.0
  • Dependencies: MessagePack (serialization), R3 (reactivity)
  • Output: OECS.dll

Phase 1 — Core Foundation (Week 1)

Goal: Create/destroy entities, add/remove components, iterate sparse sets.

1.1 Project Scaffold

OECS.sln
├── src/OECS/OECS.csproj
└── tests/OECS.Tests/OECS.Tests.csproj
  • OECS.csproj targets net8.0, references MessagePack and R3.
  • OECS.Tests.csproj references OECS + xunit + FluentAssertions.

1.2 Entity

readonly struct Entity : IEquatable<Entity>

Decision Rationale
32-bit uint backing Keeps size small; 24-bit ID (16.7M) + 8-bit version (256 gens) is ample for observable-first use cases.
Entity.Null sentinel (value 0) Entity ID 0 is reserved; version 0 means "never alive."
Id (24-bit) and Version (8-bit) properties Expose for debugging; opaque otherwise.
ToString()"Entity(42:v3)" Debuggability.

Internal constructor; World is the only factory.

1.3 SparseSet<T>

class SparseSet<T> where T : struct
Field Purpose
T[] dense Packed component values (no holes).
Entity[] denseEntities Parallel array: which entity owns each dense slot.
int[] sparse Maps entity ID → dense index; -1 = absent.

Operations: Add(Entity, T), Remove(Entity), ref T Get(Entity), bool Contains(Entity), int Count, void Clear().

dense and sparse arrays grow geometrically (×2) on overflow.

Why sparse sets over archetypes? The design prioritizes cheap add/remove (for reactivity) over raw iteration throughput. Archetypes would require moving entities between archetypes on component change, which complicates change tracking.

1.4 ComponentStore

class ComponentStore

Holds a Dictionary<Type, object> mapping component types to their SparseSet<T>. Provides typed generic methods:

  • void Add<T>(Entity, T)
  • void Remove<T>(Entity)
  • ref T Get<T>(Entity)
  • bool Has<T>(Entity)
  • void RemoveAll(Entity) — called on entity destruction

1.5 World

class World
Responsibility Detail
Entity allocation Free-list of recycled IDs; bump allocator for new IDs.
Component access Delegates to ComponentStore.
Entity destruction Returns ID to free list, increments version, removes all components.

API surface:

Entity CreateEntity();
void DestroyEntity(Entity entity);
void AddComponent<T>(Entity entity, T component) where T : struct;
void RemoveComponent<T>(Entity entity) where T : struct;
ref T GetComponent<T>(Entity entity) where T : struct;
bool HasComponent<T>(Entity entity) where T : struct;
bool IsAlive(Entity entity);

1.6 Tests

  • Entity creation returns unique IDs.
  • Entity destruction recycles IDs with incremented version.
  • IsAlive returns false for destroyed entities.
  • Add/remove/has component round-trips correctly.
  • Sparse set iteration visits all added components.
  • Removing a component mid-iteration is safe (deferred or swap-remove).

Phase 2 — Queries & Systems (Week 2)

Goal: Define queries, register systems, run ticks.

2.1 Query Description

A query is defined by:

  • A set of "with" component types.
  • A set of "without" component types.
class QueryDescriptor
{
    HashSet<Type> With { get; }
    HashSet<Type> Without { get; }
}

2.2 QueryBuilder

Fluent API returned by World.Query():

world.Query()
    .With<Position>()
    .With<Velocity>()
    .Without<Frozen>()
    .Build()  // → QueryDescriptor

2.3 Query Execution

World provides iteration over matching entities. The smallest "with" sparse set is used as the driver; other sets are probed for membership.

void ForEach<T1, T2>(QueryDescriptor query, Action<Entity, ref T1, ref T2> action);

Overloads for 16 component types. The Without filter is checked by probing the corresponding sparse sets.

2.4 ISystem

interface ISystem
{
    QueryDescriptor Query { get; }
    void Run(World world);
}

Systems declare their query and receive the world in Run. They call world.ForEach(query, ...) to iterate.

Alternative considered: auto-injection of component refs. Rejected because it hides the iteration cost and makes the API less explicit. The explicit ForEach call keeps the system author aware of what they're iterating.

2.5 System Registration & Ordering

class SystemGroup
{
    void Add(ISystem system);          // registration order = execution order
    void RunTimed(float deltaTime);    // calls each system's Run
    void RunLogical();                 // calls each system's Run
}

Systems run in registration order. For now, no explicit dependency graph — this is the simplest model that works. If needed later, Before()/After() constraints can be added without breaking the API.

2.6 Tick

readonly struct Tick
{
    TickType Type { get; }   // Timed or Logical
    float DeltaTime { get; } // 0 for logical ticks
}

Passed to systems that opt into it via a separate interface:

interface ITickedSystem : ISystem
{
    void Run(World world, Tick tick);
}

2.7 Tests

  • Query with single component returns matching entities.
  • Query with multiple components returns intersection.
  • Without<T> excludes entities with T.
  • Adding/removing components updates query results.
  • Systems run in registration order.
  • Destroyed entities don't appear in queries.

Phase 3 — Commands (Week 3)

Goal: Serializable command queue with deferred execution.

3.1 ICommand

[MessagePackObject]
interface ICommand
{
    void Execute(World world);
}

Commands are [MessagePackObject] structs implementing ICommand. They are not stored in ECS sparse sets — they live in a queue.

3.2 CommandQueue

class CommandQueue
{
    void Enqueue(ICommand command);
    void ExecuteAll(World world);  // FIFO, clears queue
    int Count { get; }
}

3.3 Integration with World

World owns a CommandQueue. After each system runs (or after the full tick), the queue is drained. This is configurable:

world.ExecuteCommands(); // manual drain

Systems enqueue commands via world.Commands.Enqueue(...).

3.4 Error Handling

If a command's Execute throws, the exception is caught and stored. The queue continues processing remaining commands. After ExecuteAll, any errors are available via CommandQueue.Errors.

3.5 Tests

  • Commands execute in FIFO order.
  • A command can read/write ECS state.
  • A command enqueued during ExecuteAll runs in the same drain cycle.
  • Exceptions are collected, not lost.
  • Serialization round-trip preserves command data.

Phase 4 — Relationships (Week 4)

Goal: Relationship components with auto-managed source/target and reverse lookup.

4.1 Relationship<TSelf, TTarget>

[MessagePackObject]
struct Relationship<TSelf, TTarget> : IRelationship
{
    [Key(0)] Entity Source { get; set; }
    [Key(1)] Entity Target { get; set; }
    // ... payload fields
}

The IRelationship marker interface lets ComponentStore detect relationships and maintain the reverse index.

4.2 Reverse Index

World maintains a Dictionary<Entity, HashSet<Entity>> per relationship type, mapping target → set of source entities.

When a relationship component is added/removed, the index is updated automatically.

4.3 Reverse Lookup API

IReadOnlyCollection<Entity> GetSources<T>(Entity target) where T : struct, IRelationship;

4.4 Cascading Behavior

When an entity is destroyed:

  • All relationships where it is the source are removed (components dropped).
  • All relationships where it is the target are removed (components dropped from source entities).
  • The reverse index is cleaned up.

4.5 Tests

  • Adding a relationship updates the reverse index.
  • Removing a relationship updates the reverse index.
  • Destroying a source entity cleans up its relationships.
  • Destroying a target entity cleans up incoming relationships.
  • Reverse lookup returns correct sources.

Phase 5 — Reactivity (Week 56)

Goal: Change tracking, marking, posting, and R3 observable queries.

5.1 Change Kinds

enum ChangeKind
{
    EntityAdded,
    EntityRemoved,
    ComponentAdded,
    ComponentRemoved,
    ComponentModified
}

struct EntityChange
{
    Entity Entity { get; }
    ChangeKind Kind { get; }
    Type ComponentType { get; } // null for entity-level changes
}

5.2 ChangeSet

class ChangeSet
{
    void MarkEntityAdded(Entity entity);
    void MarkEntityRemoved(Entity entity);
    void MarkComponentAdded(Entity entity, Type componentType);
    void MarkComponentRemoved(Entity entity, Type componentType);
    void MarkComponentModified(Entity entity, Type componentType);

    IReadOnlyList<EntityChange> Changes { get; }
    void Clear();
}

5.3 Automatic vs. Manual Marking

Change Type Marking
Entity created Auto
Entity destroyed Auto
Component added Auto
Component removed Auto
Component modified Manual via world.MarkModified<T>(entity)

Rationale: structural changes are always detectable. Value mutations inside a ref T are not — the sparse set has no way to know the caller changed the value. Requiring an explicit MarkModified call is the simplest correct approach.

Debug aid: In DEBUG builds, ref T Get<T>(Entity) returns a wrapper that tracks whether the value was written. If a system iterates ref T and never calls MarkModified, a warning is logged. This catches the most common mistake.

5.4 Posting Model

class ChangeBuffer
{
    ChangeSet Pending { get; }    // accumulates during system run
    void Post();                  // pushes to R3 subjects, then clears
}
  • During a system's Run, changes accumulate in Pending.
  • After each system's Run, Post() is called automatically.
  • After the full tick, Post() is called once more (for any changes made outside systems, e.g., during command execution).
  • When not in a system run, changes are not posted automatically — the caller must call world.PostChanges().

5.5 R3 Integration

World exposes observables:

IObservable<EntityChange> ObserveEntityChanges();
IObservable<EntityChange> ObserveComponentChanges<T>() where T : struct;
IObservable<EntityChange> ObserveQuery(QueryDescriptor query);

These are backed by Subject<EntityChange> instances. Subscribers receive batched changes after each Post().

5.6 Subscription Lifecycle

Subscriptions return IDisposable. UI code ties this to component lifecycle:

world.ObserveComponentChanges<Health>()
    .Subscribe(change => UpdateHealthBar(change))
    .AddTo(componentDisposables); // R3's AddTo

5.7 Tests

  • Entity creation posts EntityAdded.
  • Entity destruction posts EntityRemoved.
  • Component add/remove posts corresponding changes.
  • MarkModified posts ComponentModified.
  • Changes are batched per Post() call.
  • Subscribers receive changes in order.
  • Disposing a subscription stops notifications.

Phase 6 — Singletons (Week 6)

Goal: Singleton entity and ergonomic accessors.

6.1 Singleton Entity

World reserves entity ID 1 as the singleton entity. It is never destroyed and is excluded from normal queries by default.

6.2 Singleton Accessors

void SetSingleton<T>(T component) where T : struct;
ref T GetSingleton<T>() where T : struct;
bool HasSingleton<T>() where T : struct;
void RemoveSingleton<T>() where T : struct;

These are convenience wrappers around AddComponent/GetComponent on the singleton entity.

6.3 Query Exclusion

Queries automatically exclude the singleton entity. If a user genuinely wants to include it, they can query it by its entity ID directly.

6.4 Tests

  • SetSingleton/GetSingleton round-trips.
  • Singleton entity does not appear in normal queries.
  • Removing a singleton works.
  • Singleton survives tick execution.

Phase 7 — Polish & Documentation (Week 7)

7.1 XML Docs

All public API surface gets <summary> XML documentation comments.

7.2 README

Quick-start guide with a minimal example: create world, register system, run tick, observe changes.

7.3 NuGet Packaging

OECS.csproj includes package metadata:

  • PackageId: OECS
  • Description: "Observable ECS for C# — an entity component system focused on a clean reactive API surface."
  • PackageTags: ecs;reactive;observable;gamedev

7.4 CI (optional)

GitHub Actions workflow: build, test, pack.


Dependency Graph

Phase 1 (Core)
  └─→ Phase 2 (Queries & Systems)
        └─→ Phase 3 (Commands)
        └─→ Phase 4 (Relationships)
              └─→ Phase 5 (Reactivity)
                    └─→ Phase 6 (Singletons)
                          └─→ Phase 7 (Polish)

Phases 3 and 4 can be done in parallel; Phase 5 depends on both.


Open Questions

  1. Parallel system execution? Deferred. The design is single-threaded by default. If needed, systems could declare read/write component access for automatic parallel scheduling — but this adds significant complexity.

  2. World serialization? Since components are MessagePack-serializable, snapshotting the entire world is feasible. This is a Phase 7+ stretch goal.

  3. Multiple worlds? The design supports it naturally — World is a class, you can instantiate multiple. No cross-world references are supported.