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:
commit
34439e0f95
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Build results
|
||||||
|
[Dd]ebug/
|
||||||
|
[Rr]elease/
|
||||||
|
x64/
|
||||||
|
x86/
|
||||||
|
[Bb]in/
|
||||||
|
[Oo]bj/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
*.cache
|
||||||
|
|
||||||
|
# NuGet
|
||||||
|
*.nupkg
|
||||||
|
**/[Pp]ackages/*
|
||||||
|
!**/[Pp]ackages/build/
|
||||||
|
*.nuget.props
|
||||||
|
*.nuget.targets
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Test results
|
||||||
|
TestResults/
|
||||||
|
*.trx
|
||||||
|
*.coverage
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.0.31903.59
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS", "src\OECS\OECS.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.Tests", "tests\OECS.Tests\OECS.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
# Observable ECS in C#.
|
||||||
|
|
||||||
|
An ECS system that focuses on a clean obserable api surface rather than performance.
|
||||||
|
|
||||||
|
## Entity
|
||||||
|
|
||||||
|
Entity should be an opaque integer type.
|
||||||
|
|
||||||
|
Divided into identifer bits and version bits.
|
||||||
|
|
||||||
|
## Component
|
||||||
|
|
||||||
|
c# structs marked for [MessagePack](https://github.com/MessagePack-CSharp/MessagePack-CSharp) serialization.
|
||||||
|
|
||||||
|
Components are in sparse-sets so they can cheaply be added/removed.
|
||||||
|
|
||||||
|
## Relationship
|
||||||
|
|
||||||
|
special components that automanage the source/target.
|
||||||
|
|
||||||
|
can be iterated by target for easy reverse lookup.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
serializable structs like components, not in ecs but in a queue.
|
||||||
|
|
||||||
|
have a `run` that gets called when you consume the command queue.
|
||||||
|
|
||||||
|
## Singletons
|
||||||
|
|
||||||
|
a special entity is used to carry singletons.
|
||||||
|
|
||||||
|
## System
|
||||||
|
|
||||||
|
Queries are composable with `With<TComponent>` and `Without<TComponent>` . Iterable for `ref TComponent`.
|
||||||
|
|
||||||
|
Systems are callbacks registered to queries when a tick happens. Ordering of systems is determined at registration time.
|
||||||
|
|
||||||
|
ticks can be `timed`(with deltaTime) or `logical`(without args).
|
||||||
|
|
||||||
|
## Reactivity
|
||||||
|
|
||||||
|
Use [R3](https://github.com/Cysharp/R3) for reactivity api.
|
||||||
|
|
||||||
|
Queries can be observed for changes. This is different from iteration for entity/component refs; System always run in ticks and not reactively.
|
||||||
|
|
||||||
|
Query subscriptions are not bound to entities but typically bound to, for example, frontend UI lifecycles.
|
||||||
|
|
||||||
|
Changes include entity add/remove, or entity component add/remove.
|
||||||
|
|
||||||
|
Changes need to be marked and then posted.
|
||||||
|
|
||||||
|
### In Systems
|
||||||
|
|
||||||
|
When a `System` makes changes in its `run` function, changes are posted after the `run`.
|
||||||
|
|
||||||
|
When `run` happens in a tick, those changes are posted after the tick.
|
||||||
|
|
||||||
|
When not in a system `run`, changes are not posted automatically.
|
||||||
|
|
||||||
|
Marking components for updates is manual. structural changes are auto marked.
|
||||||
|
|
||||||
|
## Serialization
|
||||||
|
|
||||||
|
Entities/Components can be serialized from `.csv` files or [MasterMemory](https://github.com/Cysharp/MasterMemory) tables.
|
||||||
|
|
@ -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 2–6 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. |
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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 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.
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace OECS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registry of all component sparse sets, keyed by component type.
|
||||||
|
/// Provides typed generic accessors that delegate to the underlying
|
||||||
|
/// <see cref="SparseSet{T}"/> instances.
|
||||||
|
/// </summary>
|
||||||
|
internal class ComponentStore
|
||||||
|
{
|
||||||
|
private readonly Dictionary<Type, ISparseSet> _sets = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or creates the sparse set for component type <typeparamref name="T"/>.
|
||||||
|
/// </summary>
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private SparseSet<T> GetSet<T>() where T : struct
|
||||||
|
{
|
||||||
|
var type = typeof(T);
|
||||||
|
if (!_sets.TryGetValue(type, out var set))
|
||||||
|
{
|
||||||
|
var newSet = new SparseSet<T>();
|
||||||
|
_sets[type] = newSet;
|
||||||
|
return newSet;
|
||||||
|
}
|
||||||
|
return (SparseSet<T>)set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the sparse set for component type <typeparamref name="T"/> if it exists.
|
||||||
|
/// </summary>
|
||||||
|
private SparseSet<T>? GetSetIfExists<T>() where T : struct
|
||||||
|
{
|
||||||
|
if (_sets.TryGetValue(typeof(T), out var set))
|
||||||
|
return (SparseSet<T>)set;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all registered component types.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<Type> ComponentTypes => _sets.Keys;
|
||||||
|
|
||||||
|
public void Add<T>(Entity entity, T component) where T : struct
|
||||||
|
{
|
||||||
|
GetSet<T>().Add(entity, component);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Remove<T>(Entity entity) where T : struct
|
||||||
|
{
|
||||||
|
GetSetIfExists<T>()?.Remove(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ref T Get<T>(Entity entity) where T : struct
|
||||||
|
{
|
||||||
|
return ref GetSet<T>().Get(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Has<T>(Entity entity) where T : struct
|
||||||
|
{
|
||||||
|
return GetSetIfExists<T>()?.Contains(entity) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes all components for the given entity across all component types.
|
||||||
|
/// </summary>
|
||||||
|
public void RemoveAll(Entity entity)
|
||||||
|
{
|
||||||
|
foreach (var set in _sets.Values)
|
||||||
|
{
|
||||||
|
set.Remove(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the sparse set for a given type, or null if not registered.
|
||||||
|
/// Used by query execution to probe sets by Type.
|
||||||
|
/// </summary>
|
||||||
|
public ISparseSet? GetSet(Type componentType)
|
||||||
|
{
|
||||||
|
_sets.TryGetValue(componentType, out var set);
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the count of entities in the sparse set for type <typeparamref name="T"/>.
|
||||||
|
/// Returns 0 if the set does not exist.
|
||||||
|
/// </summary>
|
||||||
|
public int Count<T>() where T : struct
|
||||||
|
{
|
||||||
|
return GetSetIfExists<T>()?.Count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the count of entities in the sparse set for the given type.
|
||||||
|
/// Returns 0 if the set does not exist.
|
||||||
|
/// </summary>
|
||||||
|
public int Count(Type componentType)
|
||||||
|
{
|
||||||
|
return GetSet(componentType)?.Count ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
namespace OECS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An opaque handle to an entity in the ECS world.
|
||||||
|
/// Combines a 24-bit identifier with an 8-bit version to prevent
|
||||||
|
/// use-after-free bugs when entity IDs are recycled.
|
||||||
|
/// </summary>
|
||||||
|
public readonly struct Entity : IEquatable<Entity>
|
||||||
|
{
|
||||||
|
private const uint IdMask = 0x00FF_FFFF; // lower 24 bits
|
||||||
|
private const uint VersionMask = 0xFF00_0000; // upper 8 bits
|
||||||
|
private const int VersionShift = 24;
|
||||||
|
private const uint MaxId = IdMask;
|
||||||
|
private const uint MaxVersion = 0xFF;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A sentinel value representing a null or invalid entity.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly Entity Null = new(0);
|
||||||
|
|
||||||
|
private readonly uint _value;
|
||||||
|
|
||||||
|
internal Entity(uint id, uint version)
|
||||||
|
{
|
||||||
|
_value = (id & IdMask) | ((version & MaxVersion) << VersionShift);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Entity(uint value)
|
||||||
|
{
|
||||||
|
_value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The 24-bit entity identifier.
|
||||||
|
/// </summary>
|
||||||
|
public uint Id => _value & IdMask;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The 8-bit generation version. Incremented each time the ID is recycled.
|
||||||
|
/// </summary>
|
||||||
|
public uint Version => (_value & VersionMask) >> VersionShift;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if this entity is the null sentinel.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsNull => _value == 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new entity with the given version, keeping the same ID.
|
||||||
|
/// </summary>
|
||||||
|
internal Entity WithVersion(uint version) => new(Id, version);
|
||||||
|
|
||||||
|
public bool Equals(Entity other) => _value == other._value;
|
||||||
|
|
||||||
|
public override bool Equals(object? obj) => obj is Entity other && Equals(other);
|
||||||
|
|
||||||
|
public override int GetHashCode() => _value.GetHashCode();
|
||||||
|
|
||||||
|
public override string ToString() => $"Entity({Id}:v{Version})";
|
||||||
|
|
||||||
|
public static bool operator ==(Entity left, Entity right) => left.Equals(right);
|
||||||
|
|
||||||
|
public static bool operator !=(Entity left, Entity right) => !left.Equals(right);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
namespace OECS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages entity ID allocation and recycling.
|
||||||
|
///
|
||||||
|
/// Uses a free-list for recycled IDs and a bump allocator for new IDs.
|
||||||
|
/// Each ID slot tracks a version byte that increments on recycle to
|
||||||
|
/// prevent use-after-free bugs.
|
||||||
|
/// </summary>
|
||||||
|
internal class EntityAllocator
|
||||||
|
{
|
||||||
|
private const int DefaultCapacity = 1024;
|
||||||
|
private const uint FirstValidId = 2; // 0 = Null, 1 = Singleton
|
||||||
|
|
||||||
|
private byte[] _versions;
|
||||||
|
private readonly Stack<uint> _freeIds;
|
||||||
|
private uint _nextId;
|
||||||
|
|
||||||
|
public EntityAllocator(int initialCapacity = DefaultCapacity)
|
||||||
|
{
|
||||||
|
_versions = new byte[initialCapacity];
|
||||||
|
_freeIds = new Stack<uint>();
|
||||||
|
_nextId = FirstValidId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Allocates a new entity. Reuses a free ID if available, otherwise
|
||||||
|
/// bumps the allocator.
|
||||||
|
/// </summary>
|
||||||
|
public Entity Allocate()
|
||||||
|
{
|
||||||
|
uint id;
|
||||||
|
if (_freeIds.Count > 0)
|
||||||
|
{
|
||||||
|
id = _freeIds.Pop();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
id = _nextId++;
|
||||||
|
EnsureCapacity(id);
|
||||||
|
// First time this ID is used — start at version 1.
|
||||||
|
// 0 means "never alive" and is used by the sentinel.
|
||||||
|
_versions[id] = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Entity(id, _versions[id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Frees an entity ID, making it available for reuse.
|
||||||
|
/// Increments the version to invalidate any outstanding references.
|
||||||
|
/// </summary>
|
||||||
|
public void Free(Entity entity)
|
||||||
|
{
|
||||||
|
uint id = entity.Id;
|
||||||
|
EnsureCapacity(id);
|
||||||
|
|
||||||
|
// Increment version (wrap at 255 back to 1; 0 means "never alive").
|
||||||
|
_versions[id]++;
|
||||||
|
if (_versions[id] == 0)
|
||||||
|
_versions[id] = 1;
|
||||||
|
|
||||||
|
_freeIds.Push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if the entity's version matches the current version for its ID.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsAlive(Entity entity)
|
||||||
|
{
|
||||||
|
uint id = entity.Id;
|
||||||
|
if (entity.IsNull) return false;
|
||||||
|
if (id == 1) return true; // Singleton is always alive.
|
||||||
|
if (id >= _versions.Length) return false;
|
||||||
|
return _versions[id] == entity.Version && _versions[id] != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureCapacity(uint id)
|
||||||
|
{
|
||||||
|
if (id >= _versions.Length)
|
||||||
|
{
|
||||||
|
int newSize = Math.Max((int)id + 1, _versions.Length * 2);
|
||||||
|
Array.Resize(ref _versions, newSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>OECS</RootNamespace>
|
||||||
|
<AssemblyName>OECS</AssemblyName>
|
||||||
|
|
||||||
|
<!-- NuGet Package Metadata -->
|
||||||
|
<PackageId>OECS</PackageId>
|
||||||
|
<Version>0.1.0</Version>
|
||||||
|
<Description>Observable ECS for C# — an entity component system focused on a clean reactive API surface.</Description>
|
||||||
|
<PackageTags>ecs;reactive;observable;gamedev</PackageTags>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MessagePack" Version="3.1.3" />
|
||||||
|
<PackageReference Include="R3" Version="1.2.9" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace OECS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Non-generic interface for sparse set operations that don't require
|
||||||
|
/// knowing the component type at compile time (e.g., entity destruction).
|
||||||
|
/// </summary>
|
||||||
|
internal interface ISparseSet
|
||||||
|
{
|
||||||
|
void Remove(Entity entity);
|
||||||
|
int Count { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A sparse set storing components of type <typeparamref name="T"/>.
|
||||||
|
///
|
||||||
|
/// Uses a dense array (packed, no holes) and a sparse array (maps entity ID
|
||||||
|
/// to dense index) for O(1) add, remove, and lookup. Swap-remove keeps
|
||||||
|
/// removals cheap at the cost of iteration order instability.
|
||||||
|
/// </summary>
|
||||||
|
internal class SparseSet<T> : ISparseSet where T : struct
|
||||||
|
{
|
||||||
|
private const int DefaultCapacity = 64;
|
||||||
|
|
||||||
|
private T[] _dense;
|
||||||
|
private Entity[] _denseEntities;
|
||||||
|
private int[] _sparse;
|
||||||
|
private int _count;
|
||||||
|
|
||||||
|
public SparseSet(int initialCapacity = DefaultCapacity)
|
||||||
|
{
|
||||||
|
_dense = new T[initialCapacity];
|
||||||
|
_denseEntities = new Entity[initialCapacity];
|
||||||
|
_sparse = new int[initialCapacity];
|
||||||
|
Array.Fill(_sparse, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of components currently stored.
|
||||||
|
/// </summary>
|
||||||
|
public int Count => _count;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The dense array of component values (first <see cref="Count"/> elements are valid).
|
||||||
|
/// </summary>
|
||||||
|
public T[] Dense => _dense;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The dense array of entity IDs, parallel to <see cref="Dense"/>.
|
||||||
|
/// </summary>
|
||||||
|
public Entity[] DenseEntities => _denseEntities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a component for the given entity. Replaces if already present.
|
||||||
|
/// </summary>
|
||||||
|
public void Add(Entity entity, T component)
|
||||||
|
{
|
||||||
|
EnsureSparseCapacity(entity.Id);
|
||||||
|
|
||||||
|
int index = _sparse[entity.Id];
|
||||||
|
if (index != -1)
|
||||||
|
{
|
||||||
|
// Already exists — replace in place.
|
||||||
|
_dense[index] = component;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
EnsureDenseCapacity(_count + 1);
|
||||||
|
|
||||||
|
index = _count;
|
||||||
|
_dense[index] = component;
|
||||||
|
_denseEntities[index] = entity;
|
||||||
|
_sparse[entity.Id] = index;
|
||||||
|
_count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the component for the given entity. No-op if not present.
|
||||||
|
/// </summary>
|
||||||
|
public void Remove(Entity entity)
|
||||||
|
{
|
||||||
|
if (entity.Id >= (uint)_sparse.Length)
|
||||||
|
return;
|
||||||
|
|
||||||
|
int index = _sparse[entity.Id];
|
||||||
|
if (index == -1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Swap-remove: move the last element into the removed slot.
|
||||||
|
int lastIndex = _count - 1;
|
||||||
|
if (index != lastIndex)
|
||||||
|
{
|
||||||
|
_dense[index] = _dense[lastIndex];
|
||||||
|
_denseEntities[index] = _denseEntities[lastIndex];
|
||||||
|
_sparse[_denseEntities[index].Id] = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
_dense[lastIndex] = default!;
|
||||||
|
_denseEntities[lastIndex] = default;
|
||||||
|
_sparse[entity.Id] = -1;
|
||||||
|
_count--;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a reference to the component for the given entity.
|
||||||
|
/// Throws if the entity does not have this component.
|
||||||
|
/// </summary>
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public ref T Get(Entity entity)
|
||||||
|
{
|
||||||
|
int index = _sparse[entity.Id];
|
||||||
|
return ref _dense[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if the entity has this component.
|
||||||
|
/// </summary>
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public bool Contains(Entity entity)
|
||||||
|
{
|
||||||
|
return entity.Id < (uint)_sparse.Length && _sparse[entity.Id] != -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes all components.
|
||||||
|
/// </summary>
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
Array.Clear(_dense, 0, _count);
|
||||||
|
for (int i = 0; i < _count; i++)
|
||||||
|
{
|
||||||
|
_sparse[_denseEntities[i].Id] = -1;
|
||||||
|
}
|
||||||
|
_count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureSparseCapacity(uint entityId)
|
||||||
|
{
|
||||||
|
if (entityId >= (uint)_sparse.Length)
|
||||||
|
{
|
||||||
|
int newSize = Math.Max((int)entityId + 1, _sparse.Length * 2);
|
||||||
|
Array.Resize(ref _sparse, newSize);
|
||||||
|
Array.Fill(_sparse, -1, _sparse.Length - (newSize - _sparse.Length), newSize - _sparse.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureDenseCapacity(int required)
|
||||||
|
{
|
||||||
|
if (required > _dense.Length)
|
||||||
|
{
|
||||||
|
int newSize = Math.Max(required, _dense.Length * 2);
|
||||||
|
Array.Resize(ref _dense, newSize);
|
||||||
|
Array.Resize(ref _denseEntities, newSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
namespace OECS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The central container for all ECS state.
|
||||||
|
///
|
||||||
|
/// Manages entity lifecycle, component storage, and provides the primary
|
||||||
|
/// API for adding, removing, and querying components.
|
||||||
|
/// </summary>
|
||||||
|
public class World : IDisposable
|
||||||
|
{
|
||||||
|
private readonly EntityAllocator _allocator;
|
||||||
|
private readonly ComponentStore _components;
|
||||||
|
|
||||||
|
public World()
|
||||||
|
{
|
||||||
|
_allocator = new EntityAllocator();
|
||||||
|
_components = new ComponentStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Entity Management ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new entity and returns its handle.
|
||||||
|
/// </summary>
|
||||||
|
public Entity CreateEntity()
|
||||||
|
{
|
||||||
|
return _allocator.Allocate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Destroys an entity, removing all its components and recycling its ID.
|
||||||
|
/// </summary>
|
||||||
|
public void DestroyEntity(Entity entity)
|
||||||
|
{
|
||||||
|
if (!_allocator.IsAlive(entity))
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Singleton entity is never destroyed.
|
||||||
|
if (entity.Id == 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_components.RemoveAll(entity);
|
||||||
|
_allocator.Free(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if the entity is currently alive (not destroyed).
|
||||||
|
/// </summary>
|
||||||
|
public bool IsAlive(Entity entity)
|
||||||
|
{
|
||||||
|
return _allocator.IsAlive(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component Management ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a component to the entity. Replaces the existing component if
|
||||||
|
/// the entity already has one of type <typeparamref name="T"/>.
|
||||||
|
/// </summary>
|
||||||
|
public void AddComponent<T>(Entity entity, T component) where T : struct
|
||||||
|
{
|
||||||
|
ThrowIfNotAlive(entity);
|
||||||
|
_components.Add(entity, component);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the component of type <typeparamref name="T"/> from the entity.
|
||||||
|
/// No-op if the entity does not have the component.
|
||||||
|
/// </summary>
|
||||||
|
public void RemoveComponent<T>(Entity entity) where T : struct
|
||||||
|
{
|
||||||
|
_components.Remove<T>(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a reference to the component of type <typeparamref name="T"/>
|
||||||
|
/// for the given entity. Throws if the entity does not have the component.
|
||||||
|
/// </summary>
|
||||||
|
public ref T GetComponent<T>(Entity entity) where T : struct
|
||||||
|
{
|
||||||
|
return ref _components.Get<T>(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if the entity has a component of type <typeparamref name="T"/>.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasComponent<T>(Entity entity) where T : struct
|
||||||
|
{
|
||||||
|
return _components.Has<T>(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal Access ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The component store. Exposed internally for query execution and
|
||||||
|
/// system infrastructure.
|
||||||
|
/// </summary>
|
||||||
|
internal ComponentStore Components => _components;
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void ThrowIfNotAlive(Entity entity)
|
||||||
|
{
|
||||||
|
if (!_allocator.IsAlive(entity))
|
||||||
|
throw new InvalidOperationException($"Entity {entity} is not alive.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cleanup ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
// Future: dispose R3 subscriptions, etc.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,226 @@
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OECS.Tests;
|
||||||
|
|
||||||
|
public class EntityTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void CreateEntity_ReturnsUniqueIds()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var a = world.CreateEntity();
|
||||||
|
var b = world.CreateEntity();
|
||||||
|
|
||||||
|
a.Should().NotBe(b);
|
||||||
|
a.Id.Should().NotBe(b.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Null_IsSentinel()
|
||||||
|
{
|
||||||
|
Entity.Null.IsNull.Should().BeTrue();
|
||||||
|
Entity.Null.Id.Should().Be(0);
|
||||||
|
Entity.Null.Version.Should().Be(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DestroyEntity_RecyclesId_WithIncrementedVersion()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var original = world.CreateEntity();
|
||||||
|
uint originalId = original.Id;
|
||||||
|
uint originalVersion = original.Version;
|
||||||
|
|
||||||
|
world.DestroyEntity(original);
|
||||||
|
world.IsAlive(original).Should().BeFalse();
|
||||||
|
|
||||||
|
var recycled = world.CreateEntity();
|
||||||
|
recycled.Id.Should().Be(originalId);
|
||||||
|
recycled.Version.Should().BeGreaterThan(originalVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsAlive_ReturnsFalse_ForDestroyedEntity()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
|
||||||
|
world.IsAlive(entity).Should().BeTrue();
|
||||||
|
world.DestroyEntity(entity);
|
||||||
|
world.IsAlive(entity).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsAlive_ReturnsFalse_ForNull()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
world.IsAlive(Entity.Null).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToString_IncludesIdAndVersion()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
|
||||||
|
var str = entity.ToString();
|
||||||
|
str.Should().Contain(entity.Id.ToString());
|
||||||
|
str.Should().Contain(entity.Version.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Equality_ChecksBothIdAndVersion()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var a = world.CreateEntity();
|
||||||
|
world.DestroyEntity(a);
|
||||||
|
var b = world.CreateEntity(); // Same ID, different version
|
||||||
|
|
||||||
|
a.Should().NotBe(b);
|
||||||
|
(a == b).Should().BeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ComponentTests
|
||||||
|
{
|
||||||
|
private struct Position { public float X; public float Y; }
|
||||||
|
private struct Velocity { public float X; public float Y; }
|
||||||
|
private struct Health { public int Value; }
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddAndGetComponent_RoundTrips()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
|
||||||
|
world.AddComponent(entity, new Position { X = 1, Y = 2 });
|
||||||
|
ref var pos = ref world.GetComponent<Position>(entity);
|
||||||
|
|
||||||
|
pos.X.Should().Be(1);
|
||||||
|
pos.Y.Should().Be(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddComponent_ReplacesExisting()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
|
||||||
|
world.AddComponent(entity, new Position { X = 1, Y = 2 });
|
||||||
|
world.AddComponent(entity, new Position { X = 3, Y = 4 });
|
||||||
|
|
||||||
|
ref var pos = ref world.GetComponent<Position>(entity);
|
||||||
|
pos.X.Should().Be(3);
|
||||||
|
pos.Y.Should().Be(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HasComponent_ReturnsCorrectly()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
|
||||||
|
world.HasComponent<Position>(entity).Should().BeFalse();
|
||||||
|
world.AddComponent(entity, new Position());
|
||||||
|
world.HasComponent<Position>(entity).Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoveComponent_RemovesIt()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
|
||||||
|
world.AddComponent(entity, new Position { X = 1, Y = 2 });
|
||||||
|
world.RemoveComponent<Position>(entity);
|
||||||
|
world.HasComponent<Position>(entity).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoveComponent_NoOp_WhenNotPresent()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
|
||||||
|
// Should not throw.
|
||||||
|
world.RemoveComponent<Position>(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MultipleComponents_OnSameEntity()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
|
||||||
|
world.AddComponent(entity, new Position { X = 1, Y = 2 });
|
||||||
|
world.AddComponent(entity, new Velocity { X = 3, Y = 4 });
|
||||||
|
world.AddComponent(entity, new Health { Value = 100 });
|
||||||
|
|
||||||
|
world.HasComponent<Position>(entity).Should().BeTrue();
|
||||||
|
world.HasComponent<Velocity>(entity).Should().BeTrue();
|
||||||
|
world.HasComponent<Health>(entity).Should().BeTrue();
|
||||||
|
|
||||||
|
ref var pos = ref world.GetComponent<Position>(entity);
|
||||||
|
ref var vel = ref world.GetComponent<Velocity>(entity);
|
||||||
|
ref var hp = ref world.GetComponent<Health>(entity);
|
||||||
|
|
||||||
|
pos.X.Should().Be(1);
|
||||||
|
vel.Y.Should().Be(4);
|
||||||
|
hp.Value.Should().Be(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DestroyEntity_RemovesAllComponents()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
|
||||||
|
world.AddComponent(entity, new Position());
|
||||||
|
world.AddComponent(entity, new Velocity());
|
||||||
|
|
||||||
|
world.DestroyEntity(entity);
|
||||||
|
|
||||||
|
// After destroy, the entity is not alive, so component queries
|
||||||
|
// on the stale handle are irrelevant. But a new entity with the
|
||||||
|
// same ID should not have the old components.
|
||||||
|
var newEntity = world.CreateEntity();
|
||||||
|
world.HasComponent<Position>(newEntity).Should().BeFalse();
|
||||||
|
world.HasComponent<Velocity>(newEntity).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetComponent_Throws_WhenNotPresent()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
|
||||||
|
var act = () => world.GetComponent<Position>(entity);
|
||||||
|
act.Should().Throw<IndexOutOfRangeException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddComponent_Throws_WhenEntityNotAlive()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
world.DestroyEntity(entity);
|
||||||
|
|
||||||
|
var act = () => world.AddComponent(entity, new Position());
|
||||||
|
act.Should().Throw<InvalidOperationException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Mutation_ViaRef_IsVisible()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
|
||||||
|
world.AddComponent(entity, new Position { X = 0, Y = 0 });
|
||||||
|
ref var pos = ref world.GetComponent<Position>(entity);
|
||||||
|
pos.X = 42;
|
||||||
|
|
||||||
|
ref var pos2 = ref world.GetComponent<Position>(entity);
|
||||||
|
pos2.X.Should().Be(42);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>OECS.Tests</RootNamespace>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FluentAssertions" Version="6.12.1" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\OECS\OECS.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Loading…
Reference in New Issue