528 lines
14 KiB
Markdown
528 lines
14 KiB
Markdown
# 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 1–6 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 5–6)
|
||
|
||
**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.
|