14 KiB
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
namespace OECS;
public readonly struct Entity : IEquatable<Entity>
{
public static Entity Null { get; } // ID=0, Version=0
public uint Id { get; } // lower 24-bit identifier
public uint Version { get; } // upper 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
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 T ReadComponent<T>(Entity entity) where T : struct;
public bool TryGetComponent<T>(Entity entity, out T value) 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 T ReadSingleton<T>() where T : struct;
public bool HasSingleton<T>() where T : struct;
public void RemoveSingleton<T>() where T : struct;
// --- Queries ---
// See WorldQueryExtensions for Select / FindEntity.
// --- Commands ---
public CommandQueue Commands { get; }
public void ExecuteCommands();
// --- Interrupts ---
public void Interrupt<T>(T interrupt) where T : struct, IInterrupt;
public bool HasInterrupt<T>() where T : struct, IInterrupt;
// --- Reactivity ---
public void MarkModified<T>(Entity entity) where T : struct;
public void PostChanges();
public Observable<EntityChange> ObserveEntityChanges();
public Observable<EntityChange> ObserveComponentChanges<T>()
where T : struct;
public Observable<EntityChange> ObserveQuery<T1>(Query<T1> query = default)
where T1 : struct;
public Observable<EntityChange> ObserveQuery<T1, T2>(Query<T1, T2> query = default)
where T1 : struct where T2 : struct;
// --- Relationships ---
public IReadOnlyCollection<Entity> GetSources<T>(Entity target)
where T : struct, IRelationship;
public void ReorderSources<T>(Entity target, IReadOnlyList<Entity> ordered)
where T : struct, IRelationship;
// --- Cleanup ---
public void Dispose();
}
WorldQueryExtensions
Extension methods on World providing foreach-compatible iteration and
entity lookup. Select returns ref struct enumerators for zero-allocation
iteration with ref access to components.
namespace OECS;
public static class WorldQueryExtensions
{
// Select — returns a ref struct enumerator for foreach.
public static Select1<T1> Select<T1>(
this World world, Query<T1> query = default) where T1 : struct;
// Overloads for 2–6 component types:
public static Select2<T1, T2> Select<T1, T2>(...) where T1 : struct where T2 : struct;
// ... up to Select6<T1..T6>
// FindEntity — returns the first matching entity or Entity.Null.
public static Entity FindEntity<T1>(
this World world, Query<T1> query = default) where T1 : struct;
public static Entity FindEntity<T1, T2>(
this World world, Query<T1, T2> query = default)
where T1 : struct where T2 : struct;
public static Entity FindEntity<T1, T2, T3>(
this World world, Query<T1, T2, T3> query = default)
where T1 : struct where T2 : struct where T3 : struct;
}
Each ref struct (Select1 through Select6) exposes:
Entity Entity— the current entity handle.ref T1 Ref1,ref T2 Ref2, ... — mutable references to components. Access is tracked for auto-dirty-marking during a batching scope.ref readonly T1 Val1,ref readonly T2 Val2, ... — read-only references. Access is NOT tracked — use these when you only read the component.bool MoveNext()— advances and returns true while items remain.GetEnumerator()— returnsthisforforeachcompatibility.Dispose()— ends the batching scope (flushes deferred mutations).
Usage:
// Mutating: use RefN to auto-mark as modified.
foreach (var it in world.Select<Position, Velocity>())
{
it.Ref1.X += it.Val2.X * dt; // Position tracked, Velocity not
}
// Read-only: use ValN everywhere.
foreach (var it in world.Select<Cell, Mark>())
{
grid[it.Val1.Row, it.Val1.Col] = it.Val2.Player;
}
// With Without filter:
var query = new Query<Position>().Without<Frozen>();
foreach (var it in world.Select(query))
{
// it.Ref1, it.Val1
}
// Find first entity:
var hand = world.FindEntity<PlayerHand>();
Query
Describes a query over the ECS world. The With types are encoded as generic
parameters. Optional Without filters are added via the fluent API.
namespace OECS;
public struct Query<T1> where T1 : struct
{
public Query<T1> Without<W>() where W : struct;
}
// Overloads for 2–6 With types:
public struct Query<T1, T2> where T1 : struct where T2 : struct { ... }
// ... up to Query<T1, T2, T3, T4, T5, T6>
Usage:
// Two required types, one excluded:
var query = new Query<Position, Velocity>().Without<Frozen>();
foreach (var it in world.Select(query)) { ... }
// Simple scan — no Without filter needed:
foreach (var it in world.Select<Card>()) { ... }
ISystem
A system is a unit of logic that runs during a tick. Implement RunImpl with
the system logic. Use the Run extension method to execute with automatic
batching — or register with SystemGroup which handles this automatically.
namespace OECS;
public interface ISystem
{
void RunImpl(World world);
}
Systems do not declare a query on the interface. Instead, they either
read singletons directly (world.ReadSingleton<T>()) or use the
WorldQueryExtensions (Select, FindEntity) with a
Query<T1..T6> they build inline. This keeps the interface minimal and
gives systems full flexibility over what they inspect at runtime.
The Run extension method wraps RunImpl in a batching scope so component
accesses via GetComponent<T> and RefN are auto-tracked for modification:
public static class SystemExtensions
{
public static void Run(this ISystem system, World world);
public static void Run(this ITickedSystem system, World world, Tick tick);
}
ITickedSystem
An optional extension for systems that need tick metadata (delta time or logical tick marker).
namespace OECS;
public interface ITickedSystem : ISystem
{
void RunImpl(World world, Tick tick);
}
ITickedSystem must also implement ISystem.RunImpl(World) (typically
delegating to the ticked overload with a default tick). SystemGroup checks
each system at runtime and calls the appropriate Run extension.
Tick
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
namespace OECS;
public class SystemGroup
{
public SystemGroup(World world);
public void Add(ISystem system);
public void Remove(ISystem system);
public void RunTimed(float deltaTime);
public void RunLogical();
public int Count { get; }
}
Systems execute in registration order. SystemGroup wraps the entire tick in
a batching scope, and each system's Run extension adds a nested scope.
Commands are drained, pending mutations are flushed, and changes are posted
after each system and after the full tick.
ICommand
namespace OECS;
public interface ICommand
{
void Execute(World world);
}
Implementations should be [MessagePackObject] structs so they can be
serialized and replayed.
CommandQueue
namespace OECS;
public class CommandQueue
{
public void Enqueue<T>(T command) where T : struct, ICommand;
public void ExecuteAll(World world);
public void Clear();
public void ClearErrors();
public int Count { get; }
public IReadOnlyList<Exception> Errors { get; }
}
Enqueue<T> uses a constrained generic to avoid boxing at the call site.
ExecuteAll wraps the drain in a batching scope — mutations are deferred and
component accesses are auto-tracked. Pending mutations are flushed after each
command so chained commands see each other's changes within the same drain cycle.
IInterrupt
namespace OECS;
public interface IInterrupt { }
Marker interface for interrupt types. Interrupts are issued by systems to block the next tick until a matching handler command resolves them.
IInterruptHandlerCommand<TInterrupt>
namespace OECS;
public interface IInterruptHandlerCommand<TInterrupt> : ICommand
where TInterrupt : struct, IInterrupt
{
bool TryResolve(TInterrupt interrupt);
}
A command that resolves a pending interrupt. The default ICommand.Execute
looks up the pending interrupt, calls TryResolve, and resolves the
interrupt if TryResolve returns true.
Usage:
[MessagePackObject]
public struct ConfirmHandler : IInterruptHandlerCommand<ConfirmInterrupt>
{
[Key(0)] public bool Confirmed;
public bool TryResolve(ConfirmInterrupt interrupt)
{
// Do work here. Return false to reject.
return true;
}
}
IRelationship
namespace OECS;
public interface IRelationship
{
Entity Target { get; }
}
Relationship<TSelf, TTarget>
A convenience base struct for relationship components. The type parameters are phantom types that differentiate relationship kinds at the type level, enabling type-safe queries and reverse lookups.
namespace OECS;
[MessagePackObject]
public struct Relationship<TSelf, TTarget> : IRelationship
where TSelf : struct where TTarget : struct
{
[Key(0)] public Entity Target { get; set; }
}
EntityChange
namespace OECS;
public readonly struct EntityChange : IEquatable<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,
RelationshipReordered
}
WorldSerializer
Saves and loads a World to/from a MessagePack stream. Components are
serialized by their runtime type using the source-generated ComponentRegistry.
namespace OECS;
public static class WorldSerializer
{
public static void Save(World world, Stream stream);
public static void Load(World world, Stream stream);
}
WorldSnapshot / EntitySnapshot / ComponentEntry
Serialization types used by WorldSerializer. These are public but intended
primarily for serialization infrastructure.
namespace OECS;
[MessagePackObject]
public class WorldSnapshot
{
[Key(0)] public EntitySnapshot[] Entities { get; set; }
}
[MessagePackObject]
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; }
}
[MessagePackObject]
public class ComponentEntry
{
[Key(0)] public string TypeName { get; set; }
[Key(1)] public byte[] Data { get; set; }
}
Usage Example
Components and commands should be public record struct types with explicit
fields/properties (not positional syntax). See the writing-games skill for
the rationale.
using OECS;
// Define components
[MessagePackObject]
public record struct Position
{
[Key(0)] public float X;
[Key(1)] public float Y;
}
[MessagePackObject]
public record struct Velocity
{
[Key(0)] public float X;
[Key(1)] public float Y;
}
// Define a system
public class MovementSystem : ITickedSystem
{
public void RunImpl(World world) => RunImpl(world, Tick.Logical());
public void RunImpl(World world, Tick tick)
{
float dt = tick.DeltaTime;
foreach (var it in world.Select<Position, Velocity>())
{
it.Ref1.X += it.Val2.X * dt;
it.Ref1.Y += it.Val2.Y * dt;
}
}
}
// Wire it up
var world = new World();
var group = new SystemGroup(world);
group.Add(new MovementSystem());
// 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
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. |
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. |
WorldQueryExtensions |
Extension methods with ref struct iterators and entity lookup. |
ComponentRegistry |
Source-generated registry of all component types for serialization. |
ComponentDescriptor |
Source-generated per-type descriptor with serialize/deserialize callbacks. |