oecs-sharp/docs/api-surface.md

13 KiB
Raw Blame History

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();

    // --- 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;

    // --- 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 26 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>(...) where T1 : struct where T2 : struct;
    public static Entity FindEntity<T1, T2, T3>(...) 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() — returns this for foreach compatibility.
  • 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 26 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.


IRelationship

namespace OECS;

public interface IRelationship
{
    Entity Source { get; }
    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 Source { get; set; }
    [Key(1)] 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
}

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.