feat: initialize OECS project structure

Initializes the OECS repository with the core foundation, including:

- Entity management with 24-bit IDs and 8-bit versioning
- Sparse set-based component storage
- Entity allocation and recycling logic
- Core World API for entity and component lifecycle
- Project scaffolding for source and unit tests
- Design documentation and architecture decision records (ADRs)
This commit is contained in:
2026-07-18 19:03:37 +08:00
commit 34439e0f95
14 changed files with 2122 additions and 0 deletions
+338
View File
@@ -0,0 +1,338 @@
# API Surface: OECS
This document describes the public API surface of the OECS library. It serves
as a contract for implementation and a reference for consumers.
All types reside in the `OECS` namespace unless otherwise noted.
---
## Entity
```csharp
namespace OECS;
public readonly struct Entity : IEquatable<Entity>
{
public static Entity Null { get; } // ID=0, Version=0
public uint Id { get; } // 24-bit identifier
public uint Version { get; } // 8-bit generation
public bool IsNull { get; } // true for Entity.Null
public bool Equals(Entity other);
public override bool Equals(object? obj);
public override int GetHashCode();
public override string ToString(); // "Entity(42:v3)"
public static bool operator ==(Entity left, Entity right);
public static bool operator !=(Entity left, Entity right);
}
```
---
## World
```csharp
namespace OECS;
public class World : IDisposable
{
// --- Lifecycle ---
public World();
// --- Entity Management ---
public Entity CreateEntity();
public void DestroyEntity(Entity entity);
public bool IsAlive(Entity entity);
// --- Component Management ---
public void AddComponent<T>(Entity entity, T component) where T : struct;
public void RemoveComponent<T>(Entity entity) where T : struct;
public ref T GetComponent<T>(Entity entity) where T : struct;
public bool HasComponent<T>(Entity entity) where T : struct;
// --- Singleton ---
public void SetSingleton<T>(T component) where T : struct;
public ref T GetSingleton<T>() where T : struct;
public bool HasSingleton<T>() where T : struct;
public void RemoveSingleton<T>() where T : struct;
// --- Queries ---
public QueryBuilder Query();
// --- Query Execution ---
public void ForEach<T1>(
QueryDescriptor query,
Action<Entity, ref T1> action) where T1 : struct;
// Overloads for 26 component types:
public void ForEach<T1, T2>(QueryDescriptor, Action<Entity, ref T1, ref T2>)
where T1 : struct where T2 : struct;
// ... up to T6
// --- Commands ---
public CommandQueue Commands { get; }
public void ExecuteCommands();
// --- Reactivity ---
public void MarkModified<T>(Entity entity) where T : struct;
public void PostChanges();
public IObservable<EntityChange> ObserveEntityChanges();
public IObservable<EntityChange> ObserveComponentChanges<T>()
where T : struct;
public IObservable<EntityChange> ObserveQuery(QueryDescriptor query);
// --- Relationships ---
public IReadOnlyCollection<Entity> GetSources<T>(Entity target)
where T : struct, IRelationship;
// --- Cleanup ---
public void Dispose();
}
```
---
## QueryBuilder
```csharp
namespace OECS;
public class QueryBuilder
{
public QueryBuilder With<T>() where T : struct;
public QueryBuilder Without<T>() where T : struct;
public QueryDescriptor Build();
}
```
---
## QueryDescriptor
```csharp
namespace OECS;
public class QueryDescriptor
{
public IReadOnlySet<Type> With { get; }
public IReadOnlySet<Type> Without { get; }
}
```
---
## ISystem
```csharp
namespace OECS;
public interface ISystem
{
QueryDescriptor Query { get; }
void Run(World world);
}
```
---
## ITickedSystem
```csharp
namespace OECS;
public interface ITickedSystem : ISystem
{
void Run(World world, Tick tick);
}
```
---
## Tick
```csharp
namespace OECS;
public readonly struct Tick
{
public TickType Type { get; }
public float DeltaTime { get; }
public static Tick Timed(float deltaTime);
public static Tick Logical();
}
public enum TickType
{
Timed,
Logical
}
```
---
## SystemGroup
```csharp
namespace OECS;
public class SystemGroup
{
public void Add(ISystem system);
public void Remove(ISystem system);
public void RunTimed(float deltaTime);
public void RunLogical();
public int Count { get; }
}
```
---
## ICommand
```csharp
namespace OECS;
public interface ICommand
{
void Execute(World world);
}
```
---
## CommandQueue
```csharp
namespace OECS;
public class CommandQueue
{
public void Enqueue<T>(T command) where T : struct, ICommand;
public void ExecuteAll(World world);
public int Count { get; }
public IReadOnlyList<Exception> Errors { get; }
}
```
---
## IRelationship
```csharp
namespace OECS;
public interface IRelationship
{
Entity Source { get; }
Entity Target { get; }
}
```
---
## EntityChange
```csharp
namespace OECS;
public readonly struct EntityChange
{
public Entity Entity { get; }
public ChangeKind Kind { get; }
public Type? ComponentType { get; } // null for entity-level changes
}
public enum ChangeKind
{
EntityAdded,
EntityRemoved,
ComponentAdded,
ComponentRemoved,
ComponentModified
}
```
---
## Usage Example
```csharp
using OECS;
using R3;
// Define components
[MessagePackObject]
public struct Position : IMessagePackSerializationCallbackReceiver
{
[Key(0)] public float X;
[Key(1)] public float Y;
}
[MessagePackObject]
public struct Velocity
{
[Key(0)] public float X;
[Key(1)] public float Y;
}
// Define a system
public class MovementSystem : ITickedSystem
{
public QueryDescriptor Query { get; }
public MovementSystem(World world)
{
Query = world.Query()
.With<Position>()
.With<Velocity>()
.Build();
}
public void Run(World world, Tick tick)
{
float dt = tick.DeltaTime;
world.ForEach(Query, (Entity entity, ref Position pos, ref Velocity vel) =>
{
pos.X += vel.X * dt;
pos.Y += vel.Y * dt;
world.MarkModified<Position>(entity);
});
}
}
// Wire it up
var world = new World();
var group = new SystemGroup();
group.Add(new MovementSystem(world));
// Observe changes
world.ObserveComponentChanges<Position>()
.Subscribe(change => Console.WriteLine($"{change.Entity} moved"))
.AddTo(disposables);
// Create entities
var player = world.CreateEntity();
world.AddComponent(player, new Position { X = 0, Y = 0 });
world.AddComponent(player, new Velocity { X = 1, Y = 0 });
// Run a tick
group.RunTimed(0.016f); // ~60 FPS
```
---
## Internal Types (not part of public API)
These types are implementation details and may change without notice:
| Type | Purpose |
|---|---|
| `SparseSet<T>` | Dense/sparse array pair for component storage. |
| `ComponentStore` | Registry of `SparseSet<T>` instances by type. |
| `ChangeBuffer` | Accumulates `EntityChange` during system run, posts to R3 subjects. |
| `RelationshipIndex` | Reverse lookup from target entity to source entities. |
| `EntityAllocator` | Free-list + bump allocator for entity IDs. |
+336
View File
@@ -0,0 +1,336 @@
# Architecture Decisions: OECS
This document records the key architectural decisions made during the design of
OECS, with the rationale behind each one. It serves as a reference for
contributors and a guardrail against accidental complexity.
---
## ADR-001: Sparse Sets over Archetypes
**Status:** Accepted
**Context:** The ECS needs to store components and support iteration. The two
dominant patterns are archetypes (entities grouped by exact component
signature) and sparse sets (one dense array per component type).
**Decision:** Use sparse sets.
**Rationale:**
- The design prioritizes cheap add/remove operations because reactivity is a
first-class concern. Sparse sets have O(1) add/remove via swap-remove.
- Archetypes require moving an entity to a new archetype when its component
signature changes. This complicates change tracking — you must detect the
move and emit events.
- Sparse sets have worse cache locality for multi-component queries (you probe
multiple arrays), but for an observable-first ECS targeting UI workflows,
query throughput is not the bottleneck.
- Sparse sets are simpler to implement and reason about.
**Consequences:**
- Multi-component queries probe N sparse sets. The smallest set drives
iteration to minimize probes.
- Memory overhead: one `sparse` array per component type, sized to max entity
ID. Acceptable for the expected entity counts (tens of thousands, not
millions).
---
## ADR-002: 32-bit Entity with Version Bits
**Status:** Accepted
**Context:** Entities need to be cheap to copy, comparable, and safe against
use-after-free (accessing a recycled entity ID).
**Decision:** `readonly struct Entity` wrapping a `uint`. Upper 24 bits are the
ID, lower 8 bits are the version.
**Rationale:**
- 24-bit ID space supports ~16.7 million simultaneously alive entities. More
than enough for observable-first use cases (UI entities, game objects in a
typical scene).
- 8-bit version allows 256 generations before an ID wraps. With a free list,
hot IDs are recycled quickly, making version collisions unlikely.
- 32-bit struct is 4 bytes — fits in a register, zero heap allocations.
- `Entity.Null` = `0` (ID=0, version=0). ID 0 is never allocated, so this is
a natural sentinel.
**Alternatives considered:**
- `ulong` (64-bit): More headroom but 2× memory in every array and struct that
references an entity. Overkill for the target scale.
- `Guid`: Heap-allocated when boxed, 16 bytes, not comparable by ref. Rejected
for performance and ergonomics.
**Consequences:**
- `World` must track the current version per ID slot (a `byte[]` parallel to
the free list).
- Entity equality checks both ID and version.
---
## ADR-003: Explicit Query Iteration over Auto-Injection
**Status:** Accepted
**Context:** Systems need to iterate entities matching a component signature.
Two API styles exist: auto-injection (the framework calls the system with the
right components) and explicit iteration (the system calls `ForEach`).
**Decision:** Use explicit iteration via `world.ForEach(query, action)`.
**Rationale:**
- Auto-injection hides the iteration cost. A system that looks like a simple
method is actually O(N) — this is surprising.
- Explicit iteration makes the performance model visible. The system author
sees the `ForEach` call and understands they're iterating.
- Auto-injection requires either code generation or reflection to match
parameters to component types. Explicit iteration uses generics, which are
resolved at compile time.
- Explicit iteration is more flexible: a system can run multiple queries, or
conditionally skip iteration.
**Consequences:**
- Slightly more verbose system code. Acceptable tradeoff for clarity.
- No source generators or reflection needed for system dispatch.
---
## ADR-004: Registration Order for System Execution
**Status:** Accepted
**Context:** Systems need a defined execution order. Options include
registration order, explicit dependency declarations, and stage-based
grouping.
**Decision:** Registration order determines execution order. No dependency
graph.
**Rationale:**
- Registration order is the simplest model that works. It's predictable and
requires no additional API surface.
- For the target use case (observable ECS for UI-heavy applications), the
number of systems is typically small (< 50). Manual ordering is manageable
at this scale.
- Dependency graphs add complexity (cycle detection, topological sort) without
proportional benefit at this scale.
- If needed later, `Before()`/`After()` constraints can be added to
`SystemGroup` without breaking existing code.
**Consequences:**
- System authors must be mindful of registration order.
- `SystemGroup` is a simple ordered list, not a graph.
---
## ADR-005: Manual Marking for Component Modifications
**Status:** Accepted
**Context:** The reactivity system needs to know when a component value changes
so it can notify observers. Structural changes (add/remove) are detectable, but
in-place mutations via `ref T` are not.
**Decision:** Require explicit `world.MarkModified<T>(entity)` calls after
mutating a component. Provide a debug-mode warning when a `ref T` is obtained
but never marked.
**Rationale:**
- C# structs returned by `ref` have no built-in change detection. Wrapping them
in a property-change-notifying container would break `ref` semantics and add
overhead.
- Auto-detection via `IEquatable<T>` comparison is possible but expensive:
every component would be compared every tick, even if unchanged.
- Manual marking puts the cost on the author, where it belongs. The debug
warning catches the most common mistake (forgetting to mark).
**Alternatives considered:**
- **Dirty flag on every component:** Requires a wrapper struct, breaks `ref`
returns, adds per-component memory overhead.
- **Hash-based change detection:** Compute hash on write, compare on post.
Expensive for large components, false positives on hash collisions.
- **Copy-on-write:** Store previous value, compare on post. Doubles memory for
all components.
**Consequences:**
- System authors must remember to call `MarkModified`. The debug warning
mitigates this.
- No per-component memory or CPU overhead for change detection.
---
## ADR-006: Deferred Change Posting
**Status:** Accepted
**Context:** Changes made during a system's `Run` need to be communicated to
observers. Posting immediately would interleave observer callbacks with system
logic, leading to reentrancy bugs.
**Decision:** Accumulate changes during `Run`, post them after `Run` completes.
Post once more after the full tick.
**Rationale:**
- Prevents observers from seeing partially-updated state mid-system.
- Allows batching: multiple changes to the same entity/component are collapsed
into one notification.
- Matches the mental model of "the tick is the atomic unit of work."
**Consequences:**
- Observers always see state after a complete system or tick, never during.
- If an observer needs to react mid-tick, they must split their logic into
multiple systems.
---
## ADR-007: R3 for Reactivity
**Status:** Accepted
**Context:** The ECS needs a reactive programming library for observable
queries and change subscriptions.
**Decision:** Use [R3](https://github.com/Cysharp/R3).
**Rationale:**
- R3 is the de facto standard for reactive programming in modern .NET (the
successor to UniRx).
- It's actively maintained by Cysharp (same author as MessagePack-CSharp).
- Zero-allocation observables, `IObservable<T>` compatible, `AddTo` for
lifecycle management.
- First-party support for `IDisposable` subscription handles — natural fit for
UI lifecycle binding.
**Alternatives considered:**
- **System.Reactive (Rx.NET):** Heavier, more allocation-heavy, less
game-dev-friendly.
- **Custom event system:** Reinventing the wheel. R3 provides operators
(Where, Select, Throttle) for free.
**Consequences:**
- Dependency on R3. Acceptable — it's the same ecosystem as MessagePack.
- Subscribers use standard Rx patterns (`Subscribe`, `AddTo`).
---
## ADR-008: Commands as Serializable Structs
**Status:** Accepted
**Context:** The design calls for a command queue where commands are
serializable and executed deferred.
**Decision:** Commands are `[MessagePackObject]` structs implementing
`ICommand`. They live in a `CommandQueue`, not in ECS sparse sets.
**Rationale:**
- Structs avoid heap allocations per command.
- MessagePack serialization enables networking, replay, and save/load of
command streams.
- Separation from ECS state: commands are transient actions, not persistent
data. Mixing them into sparse sets would blur this distinction.
**Consequences:**
- Commands cannot be queried like components. This is intentional.
- `ICommand` interface on a struct causes boxing if passed as `ICommand`.
Mitigation: `CommandQueue.Enqueue<T>(T command) where T : struct, ICommand`
uses constrained generics to avoid boxing. Internal storage still boxes
(heterogeneous queue), but the enqueue path is allocation-free.
---
## ADR-009: Singleton as Reserved Entity
**Status:** Accepted
**Context:** Singletons (global state like `Time`, `Config`, `InputState`) need
a home in the ECS.
**Decision:** Reserve entity ID `1` as the singleton entity. Provide
convenience accessors (`SetSingleton<T>`, `GetSingleton<T>`). Exclude from
normal queries.
**Rationale:**
- Reuses existing component storage — no separate dictionary or global
variables.
- Query exclusion prevents accidental iteration over singleton data in entity
queries.
- Simpler than a separate "resource" system (as in Bevy). One concept (entity +
components) covers both entities and singletons.
**Consequences:**
- Entity ID `1` is permanently reserved.
- `DestroyEntity` on the singleton entity is a no-op or throws.
---
## ADR-010: Single-Threaded by Default
**Status:** Accepted
**Context:** Should systems run in parallel? Should component access be
thread-safe?
**Decision:** Single-threaded execution. No locks, no `ConcurrentDictionary`,
no parallel scheduling.
**Rationale:**
- The target use case (observable ECS for UI) is inherently single-threaded.
UI frameworks require main-thread access.
- Parallel system execution adds significant complexity: dependency analysis,
component access arbitration, synchronization.
- If needed later, systems can declare read/write access sets for automatic
parallel scheduling. This is an additive change.
**Consequences:**
- All systems run sequentially on the calling thread.
- No thread-safety guarantees. Calling `World` methods from multiple threads is
undefined behavior.
- Simpler implementation, easier debugging.
---
## ADR-011: .NET 8 Target
**Status:** Accepted
**Context:** The library needs a target framework.
**Decision:** Target `net8.0` (LTS).
**Rationale:**
- .NET 8 is the current LTS release with support through November 2026.
- `ref struct` improvements, generic math, and performance enhancements over
.NET 6/7.
- R3 and MessagePack both support .NET 8.
- No need for .NET 9 preview features.
**Consequences:**
- Consumers must be on .NET 8 or later.
- Can use `ref` returns, `readonly struct`, and other modern C# features.
+527
View File
@@ -0,0 +1,527 @@
# 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:
```csharp
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()`:
```csharp
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.
```csharp
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:
```csharp
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
```csharp
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:
```csharp
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:
```csharp
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
```csharp
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.