docs: update documentation and implementation plan

Update the API surface and implementation plan to reflect architectural
changes, including new iterator overloads, system design refinements,
and the introduction of a source generator.
This commit is contained in:
hypercross 2026-07-20 17:44:25 +08:00
parent 10c4caed90
commit b066ac2eba
3 changed files with 269 additions and 126 deletions

View File

@ -139,13 +139,17 @@ public delegate void ForEachAction<T1, T2, T3, T4, T5, T6>(Entity entity, ref T1
Extension methods on `World` for `foreach`-style iteration over queries.
Returns `ref struct` iterators that support zero-allocation iteration with
`ref` access to components. Supports 13 component types.
`ref` access to components.
Two sets of overloads: one accepting an explicit `QueryDescriptor` for
filtering with `Without<T>`, and one without for simple "has component" scans.
```csharp
namespace OECS;
public static class EntityIterator
{
// With explicit QueryDescriptor (supports Without<T> filters).
public static Select1<T1> Select<T1>(
this World world, QueryDescriptor query) where T1 : struct;
@ -156,16 +160,38 @@ public static class EntityIterator
public static Select3<T1, T2, T3> Select<T1, T2, T3>(
this World world, QueryDescriptor query)
where T1 : struct where T2 : struct where T3 : struct;
// Without QueryDescriptor — iterates all entities with the given component(s).
public static Select1<T1> Select<T1>(
this World world) where T1 : struct;
public static Select2<T1, T2> Select<T1, T2>(
this World world)
where T1 : struct where T2 : struct;
public static Select3<T1, T2, T3> Select<T1, T2, T3>(
this World world)
where T1 : struct where T2 : struct where T3 : struct;
}
```
Usage:
The singletons `CurrentEntity`, `Current1`, `Current2`, `Current3` are
exposed on the ref struct iterators directly. Usage:
```csharp
using var iter = world.Select<Position, Velocity>(query);
foreach (ref var item in iter)
// Simple scan: all entities with PlayerHand.
using var iter = world.Select<PlayerHand>();
while (iter.MoveNext())
{
item.Current1.X += item.Current2.X * dt;
Console.WriteLine(iter.CurrentEntity);
}
// With query filter:
var query = world.Query().With<Position>().Without<Frozen>().Build();
using var iter2 = world.Select<Position>(query);
while (iter2.MoveNext())
{
iter2.Current1.X += 1;
}
```
@ -202,20 +228,32 @@ public class QueryDescriptor
## ISystem
A system is a unit of logic that runs during a tick. It receives the `World`
and decides what to do — typically reading singletons, iterating queries,
or enqueuing commands.
```csharp
namespace OECS;
public interface ISystem
{
QueryDescriptor Query { get; }
void Run(World world);
}
```
Systems do **not** declare a query on the interface. Instead, they either
read singletons directly (`world.ReadSingleton<T>()`) or use the iterator /
`ForEach` API with a `QueryDescriptor` they build internally. This keeps
the interface minimal and gives systems full flexibility over what they
inspect at runtime.
---
## ITickedSystem
An optional extension for systems that need tick metadata (delta time or
logical tick marker).
```csharp
namespace OECS;
@ -225,6 +263,10 @@ public interface ITickedSystem : ISystem
}
```
`SystemGroup` checks each system at runtime: if it implements
`ITickedSystem`, the `Run(World, Tick)` overload is called; otherwise
`Run(World)` is called. Both overloads must be implemented.
---
## Tick
@ -266,6 +308,9 @@ public class SystemGroup
}
```
Systems execute in registration order. `SystemGroup` automatically drains
commands and posts changes after each system and after the full tick.
---
## ICommand
@ -279,6 +324,9 @@ public interface ICommand
}
```
Implementations should be `[MessagePackObject]` structs so they can be
serialized and replayed.
---
## CommandQueue
@ -297,6 +345,9 @@ public class CommandQueue
}
```
`Enqueue<T>` uses a constrained generic to avoid boxing at the call site.
Commands enqueued during `ExecuteAll` are processed in the same drain cycle.
---
## IRelationship
@ -324,6 +375,7 @@ namespace OECS;
[MessagePackObject]
public struct Relationship<TSelf, TTarget> : IRelationship
where TSelf : struct where TTarget : struct
{
[Key(0)] public Entity Source { get; set; }
[Key(1)] public Entity Target { get; set; }
@ -359,7 +411,7 @@ public enum ChangeKind
## WorldSerializer
Saves and loads a `World` to/from a MessagePack stream. Components are
serialized by their runtime type.
serialized by their runtime type using the source-generated `ComponentRegistry`.
```csharp
namespace OECS;
@ -393,6 +445,7 @@ public class EntitySnapshot
[Key(0)] public uint Id { get; set; }
[Key(1)] public uint Version { get; set; }
[Key(2)] public ComponentEntry[] Components { get; set; }
[IgnoreMember]
public Entity Entity { get; }
}
@ -410,37 +463,30 @@ public class ComponentEntry
```csharp
using OECS;
using R3;
// Define components
[MessagePackObject]
public struct Position : IMessagePackSerializationCallbackReceiver
public struct Position
{
[Key(0)] public float X;
[Key(1)] public float Y;
public void OnBeforeSerialize() { }
public void OnAfterDeserialize() { }
}
[MessagePackObject]
public struct Velocity : IMessagePackSerializationCallbackReceiver
public struct Velocity
{
[Key(0)] public float X;
[Key(1)] public float Y;
public void OnBeforeSerialize() { }
public void OnAfterDeserialize() { }
}
// Define a system
public class MovementSystem : ITickedSystem
{
public QueryDescriptor Query { get; }
private readonly QueryDescriptor _query;
public MovementSystem(World world)
{
Query = world.Query()
_query = world.Query()
.With<Position>()
.With<Velocity>()
.Build();
@ -452,7 +498,7 @@ public class MovementSystem : ITickedSystem
{
float dt = tick.DeltaTime;
world.ForEach(Query, (Entity entity, ref Position pos, ref Velocity vel) =>
world.ForEach(_query, (Entity entity, ref Position pos, ref Velocity vel) =>
{
pos.X += vel.X * dt;
pos.Y += vel.Y * dt;
@ -466,16 +512,15 @@ var world = new World();
var group = new SystemGroup(world);
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 });
// Observe changes via R3
world.ObserveComponentChanges<Position>()
.Subscribe(change => Console.WriteLine($"{change.Entity} moved"));
// Run a tick
group.RunTimed(0.016f); // ~60 FPS
```
@ -494,4 +539,6 @@ These types are implementation details and may change without notice:
| `ChangeSet` | Deduplicated change accumulator within a single batch. |
| `RelationshipIndex` | Reverse lookup from target entity to source entities. |
| `EntityAllocator` | Free-list + bump allocator for entity IDs. |
| `QueryExecutor` | Query iteration logic with smallest-set driver optimization. |
| `QueryExecutor` | Query iteration logic with smallest-set driver optimization. |
| `ComponentRegistry` | Source-generated registry of all component types for serialization. |
| `ComponentDescriptor` | Source-generated per-type descriptor with serialize/deserialize callbacks. |

View File

@ -8,9 +8,10 @@ ones. Architecture decisions are captured in `docs/architecture.md`.
## Target
- .NET 8 (LTS), `net8.0`
- .NET 8 (LTS), `net8.0` (C# 12)
- Dependencies: `MessagePack` (serialization), `R3` (reactivity)
- Output: `OECS.dll`
- Source generator: `OECS.SourceGen.dll` (component registry for serialization)
---
@ -22,8 +23,12 @@ ones. Architecture decisions are captured in `docs/architecture.md`.
```
OECS.sln
├── src/OECS/OECS.csproj
└── tests/OECS.Tests/OECS.Tests.csproj
├── OECS/OECS.csproj
├── OECS.SourceGen/OECS.SourceGen.csproj
├── OECS.Tests/OECS.Tests.csproj
├── Game.Blackjack/Blackjack.csproj
├── Game.Blackjack.Tests/Game.Blackjack.Tests.csproj
└── Game.TicTacToe/...
```
- `OECS.csproj` targets `net8.0`, references `MessagePack` and `R3`.
@ -76,6 +81,8 @@ Holds a `Dictionary<Type, object>` mapping component types to their
- `void Add<T>(Entity, T)`
- `void Remove<T>(Entity)`
- `ref T Get<T>(Entity)`
- `T Read<T>(Entity)` — copy without auto-mark
- `bool TryGet<T>(Entity, out T)`
- `bool Has<T>(Entity)`
- `void RemoveAll(Entity)` — called on entity destruction
@ -99,6 +106,8 @@ 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;
T ReadComponent<T>(Entity entity) where T : struct;
bool TryGetComponent<T>(Entity entity, out T value) where T : struct;
bool HasComponent<T>(Entity entity) where T : struct;
bool IsAlive(Entity entity);
```
@ -128,8 +137,8 @@ A query is defined by:
```
class QueryDescriptor
{
HashSet<Type> With { get; }
HashSet<Type> Without { get; }
IReadOnlySet<Type> With { get; }
IReadOnlySet<Type> Without { get; }
}
```
@ -147,49 +156,74 @@ world.Query()
### 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.
`World` provides two iteration styles:
**ForEach callbacks** (16 component types):
```csharp
void ForEach<T1, T2>(QueryDescriptor query, Action<Entity, ref T1, ref T2> action);
void ForEach<T1, T2>(QueryDescriptor query, ForEachAction<T1, T2> action);
```
Overloads for 16 component types. The `Without` filter is checked by probing
the corresponding sparse sets.
**Ref struct iterators** via `EntityIterator.Select<T>()` extensions (13 component types):
```csharp
using var iter = world.Select<Position, Velocity>(query);
while (iter.MoveNext()) { ... }
```
Both accept an optional `QueryDescriptor`. When omitted, all entities with
the given component types are iterated (no `Without` filter).
The smallest "with" sparse set is used as the driver; other sets are probed
for membership. The singleton entity (ID 1) is always skipped.
### 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.
Systems do **not** declare a query on the interface. Instead, they either
read singletons directly or build queries internally and iterate with
`ForEach` / `Select`. This keeps the interface minimal and gives systems
full flexibility.
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.
**Why no auto-injection?** The explicit API makes the iteration cost
visible and avoids the need for source generators or reflection for
system dispatch.
### 2.5 System Registration & Ordering
### 2.5 ITickedSystem
```
interface ITickedSystem : ISystem
{
void Run(World world, Tick tick);
}
```
Systems that need tick metadata (delta time or logical tick marker)
implement this. Both `Run` overloads must be implemented; `SystemGroup`
dispatches to the appropriate one at runtime.
### 2.6 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
void Remove(ISystem system);
void RunTimed(float deltaTime);
void RunLogical();
int Count { get; }
}
```
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.
Systems run in registration order. `SystemGroup` automatically:
- Drains commands before the tick, after each system, and after the full tick.
- Posts changes after each system and after the full tick.
### 2.6 Tick
### 2.7 Tick
```
readonly struct Tick
@ -199,16 +233,7 @@ readonly struct Tick
}
```
Passed to systems that opt into it via a separate interface:
```
interface ITickedSystem : ISystem
{
void Run(World world, Tick tick);
}
```
### 2.7 Tests
### 2.8 Tests
- Query with single component returns matching entities.
- Query with multiple components returns intersection.
@ -216,6 +241,7 @@ interface ITickedSystem : ISystem
- Adding/removing components updates query results.
- Systems run in registration order.
- Destroyed entities don't appear in queries.
- `ITickedSystem` receives correct tick data.
---
@ -226,31 +252,38 @@ interface ITickedSystem : ISystem
### 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.
Commands are structs implementing `ICommand`. They are typically
`[MessagePackObject]` for serialization. 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
void Enqueue<T>(T command) where T : struct, ICommand;
void ExecuteAll(World world); // FIFO, fully drains queue
void Clear();
void ClearErrors();
int Count { get; }
IReadOnlyList<Exception> Errors { get; }
}
```
`Enqueue<T>` uses a constrained generic to avoid boxing at the call site.
Commands enqueued during `ExecuteAll` are processed in the same drain cycle.
### 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:
the queue is drained automatically by `SystemGroup`. Manual drain is also
available:
```csharp
world.ExecuteCommands(); // manual drain
@ -279,36 +312,48 @@ available via `CommandQueue.Errors`.
**Goal:** Relationship components with auto-managed source/target and reverse
lookup.
### 4.1 Relationship\<TSelf, TTarget\>
### 4.1 IRelationship
```
interface IRelationship
{
Entity Source { get; }
Entity Target { get; }
}
```
### 4.2 Relationship\<TSelf, TTarget\>
```
[MessagePackObject]
struct Relationship<TSelf, TTarget> : IRelationship
where TSelf : struct where TTarget : struct
{
[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.
and maintain the reverse index. The `TSelf`/`TTarget` phantom types
differentiate relationship kinds at the type level.
### 4.2 Reverse Index
Alternatively, implement `IRelationship` directly on your own struct (see the
Blackjack `Holds` and `InDeck` types).
`World` maintains a `Dictionary<Entity, HashSet<Entity>>` per relationship
type, mapping target → set of source entities.
### 4.3 Reverse Index
When a relationship component is added/removed, the index is updated
automatically.
`World` maintains a reverse index per relationship type, mapping target →
set of source entities. Updated automatically when relationship components
are added/removed.
### 4.3 Reverse Lookup API
### 4.4 Reverse Lookup API
```csharp
IReadOnlyCollection<Entity> GetSources<T>(Entity target) where T : struct, IRelationship;
```
### 4.4 Cascading Behavior
### 4.5 Cascading Behavior
When an entity is destroyed:
- All relationships where it is the **source** are removed (components dropped).
@ -316,7 +361,7 @@ When an entity is destroyed:
from source entities).
- The reverse index is cleaned up.
### 4.5 Tests
### 4.6 Tests
- Adding a relationship updates the reverse index.
- Removing a relationship updates the reverse index.
@ -346,23 +391,17 @@ struct EntityChange
{
Entity Entity { get; }
ChangeKind Kind { get; }
Type ComponentType { get; } // null for entity-level changes
Type? ComponentType { get; } // null for entity-level changes
}
```
### 5.2 ChangeSet
### 5.2 ChangeBuffer
```
class ChangeSet
class ChangeBuffer
{
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();
void Mark<...>(...);
void Post(); // pushes to R3 subjects, then clears
}
```
@ -381,25 +420,15 @@ Rationale: structural changes are always detectable. Value mutations inside a
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.
**`ReadComponent<T>`** (returns a copy) and **`ReadSingleton<T>`** never
auto-mark. Use these when you only need to inspect state without signaling
changes.
### 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).
- During a system's `Run`, changes accumulate in a pending buffer.
- After each system's `Run`, `Post()` is called automatically by `SystemGroup`.
- After the full tick, `Post()` is called once more.
- When not in a system run, changes are **not** posted automatically — the
caller must call `world.PostChanges()`.
@ -408,9 +437,9 @@ class ChangeBuffer
`World` exposes observables:
```csharp
IObservable<EntityChange> ObserveEntityChanges();
IObservable<EntityChange> ObserveComponentChanges<T>() where T : struct;
IObservable<EntityChange> ObserveQuery(QueryDescriptor query);
Observable<EntityChange> ObserveEntityChanges();
Observable<EntityChange> ObserveComponentChanges<T>() where T : struct;
Observable<EntityChange> ObserveQuery(QueryDescriptor query);
```
These are backed by `Subject<EntityChange>` instances. Subscribers receive
@ -445,19 +474,21 @@ world.ObserveComponentChanges<Health>()
### 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.
and is excluded from normal queries (all iterators skip ID 1).
### 6.2 Singleton Accessors
```csharp
void SetSingleton<T>(T component) where T : struct;
ref T GetSingleton<T>() where T : struct;
T ReadSingleton<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.
singleton entity. `GetSingleton` returns a `ref` (auto-marks during iteration);
`ReadSingleton` returns a copy (never auto-marks).
### 6.3 Query Exclusion
@ -473,28 +504,62 @@ to include it, they can query it by its entity ID directly.
---
## Phase 7 — Polish & Documentation (Week 7)
## Phase 7 — Source Generator & Serialization (Week 7)
### 7.1 XML Docs
**Goal:** Compile-time component registry for serialization without reflection.
### 7.1 OECS.SourceGen
A Roslyn incremental source generator that scans for all types used as
generic arguments to `World` methods (`AddComponent<T>`, `GetComponent<T>`,
`SetSingleton<T>`, etc.) and generates a `ComponentRegistry` class with
per-type serialize/deserialize callbacks via MessagePack.
### 7.2 WorldSerializer
```csharp
static class WorldSerializer
{
static void Save(World world, Stream stream);
static void Load(World world, Stream stream);
}
```
Uses `ComponentRegistry` to serialize/deserialize all entities and their
components to/from a MessagePack stream. `IRelationship.Source` is fixed
up to the owning entity on load.
### 7.3 Snapshot Types
`WorldSnapshot`, `EntitySnapshot`, and `ComponentEntry` are the public
serialization DTOs.
---
## Phase 8 — Polish & Documentation (Week 8)
### 8.1 XML Docs
All public API surface gets `<summary>` XML documentation comments.
### 7.2 README
### 8.2 README
Quick-start guide with a minimal example: create world, register system, run
tick, observe changes.
### 7.3 NuGet Packaging
### 8.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`
- Bundles `OECS.SourceGen.dll` as an analyzer for consumers.
### 7.4 CI (optional)
### 8.4 Testing Games
GitHub Actions workflow: build, test, pack.
Test games (`Game.Blackjack`, `Game.TicTacToe`) serve as integration tests
and design validation. See `docs/testing-games.md` for the testing strategy.
---
@ -507,7 +572,8 @@ Phase 1 (Core)
└─→ Phase 4 (Relationships)
└─→ Phase 5 (Reactivity)
└─→ Phase 6 (Singletons)
└─→ Phase 7 (Polish)
└─→ Phase 7 (Source Gen & Serialization)
└─→ Phase 8 (Polish)
```
Phases 3 and 4 can be done in parallel; Phase 5 depends on both.
@ -520,8 +586,8 @@ Phases 3 and 4 can be done in parallel; Phase 5 depends on both.
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,
2. **Multiple worlds?** The design supports it naturally — `World` is a class,
you can instantiate multiple. No cross-world references are supported.
3. **Component import from CSV/MasterMemory?** The `design.md` mentions this as
a potential feature. Not yet implemented.

View File

@ -1,23 +1,53 @@
# Testing games
1. Games are dlls. Use a standalone test project to test a game.
1. Games are DLLs. Use a standalone test project to test a game.
2. Create baseline game snapshots in textual format. Save to files.
3. Create baseline logs via the r3 observable api. Save to files.
3. Create baseline logs via the R3 observable API. Save to files.
4. Read the snapshot/logs manually to identify issues.
5. Make sure serialization roundtrip works.
## Play tests
The goal of playtests is to execute a game with AI players and potentially random seeds. Then the log/snapshots are read by the LLM agent to spot issues.
The goal of playtests is to execute a game with AI players and potentially
random seeds. Then the log/snapshots are read by the LLM agent to spot issues.
There is no failure in play tests; all tests pass but the agent should read the log for reference.
There is no failure in play tests; all tests pass but the agent should read the
log for reference.
AI players are not LLMs here; they are typically common game AI implementations.
Create static functions that analyze the game state and return a command. Each such function is an agent. Each AI player routes its decisions to a random agent in a pool of weighted agents.
Create static functions that analyze the game state and return a command. Each
such function is an agent. Each AI player routes its decisions to a random
agent in a pool of weighted agents.
Agents to consider:
- Random: evenly chooses every move randomly.
- Greedy: uses a specific way to score actions. Always chooses the onewith highest score. There can be multiple Greedy agents wtih different scoring.
- **Random:** evenly chooses every move randomly.
- **Greedy:** uses a specific way to score actions. Always chooses the one with
highest score. There can be multiple Greedy agents with different scoring.
For each playtest attempt, generate a full game log, analyze and report what you find from the log.
For each playtest attempt, generate a full game log, analyze and report what
you find from the log.
### Round Runner
Playtests should run multiple rounds, not just one. The recommended pattern:
```
while chips >= minimum_bet AND chips < 2× starting_chips:
place bet → deal → agent decides hit/stand → resolve → new round
```
This exercises the full game lifecycle including deck reshuffling, round
transitions, and chip management. Safety cap at ~200 rounds to prevent
infinite loops.
### Play Log Format
Each play log should contain:
- **Header:** agent name, seed, result summary, win/loss/push counts.
- **Round Summaries:** per-round result, chip delta, hit count.
- **Decisions:** per-decision hand total and choice (Hit/Stand).
- **Reactivity:** raw R3 observable log of component/entity changes.
- **Final State:** snapshot of the world after all rounds.
Logs are saved as `.playlog` files alongside test output for manual review.