oecs-sharp/.agents/skills/writing-games/SKILL.md

8.9 KiB

name description
writing-games Create game logic using the OECS entity component system (C#). Use this when building a new game or game feature with OECS — defining components, systems, commands, relationships, and singletons.

Writing Games with OECS

OECS is a single-threaded, observable-first ECS for C#. It targets net8.0 (C# 12) and depends on MessagePack (serialization) and R3 (reactivity).

Before writing any code, read docs/api-surface.md for the full type reference and docs/architecture.md for the design rationale behind the key decisions.

Project Setup

A game is a class library referencing OECS:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Library</OutputType>
    <RootNamespace>Game.YourGameName</RootNamespace>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="..\OECS\OECS.csproj" />
  </ItemGroup>
</Project>

The game DLL must also reference OECS.SourceGen as an analyzer so the component registry is generated for serialization. See Blackjack.csproj for the exact MSBuild incantation.

Defining Components

Components are public record struct types annotated with [MessagePackObject] and [Key] attributes. Prefer record struct by default — it gives you value equality and a generated ToString() for free.

Use explicit properties or fields — not positional syntax. OECS mutates components in-place via ref T, which requires settable fields/properties. Positional record structs produce init-only properties that can't be mutated through a ref.

[MessagePackObject]
public record struct Card
{
    [Key(0)] public Suit Suit;
    [Key(1)] public Rank Rank;
}
  • Types must be public — MessagePack requires public accessibility.
  • Use sequential integer keys starting from 0.
  • Tag components (no data) are just empty structs:
[MessagePackObject]
public struct PlayerHand { }

Relationships

Relationships are components that implement IRelationship. They model a directed edge between a source entity and a target entity.

You can either use the generic Relationship<TSelf, TTarget> base struct or implement IRelationship directly:

// Using the base struct:
world.AddComponent(child, new Relationship<ChildOf, Parent>
{
    Source = child,
    Target = parent
});

// Direct implementation (preferred for domain-specific names):
[MessagePackObject]
public record struct Holds : IRelationship
{
    [Key(0)] public Entity Source { get; set; }
    [Key(1)] public Entity Target { get; set; }
}

Reverse lookup is automatic. The World maintains a reverse index so you can query all sources pointing to a target:

var cards = world.GetSources<Holds>(handEntity);

When an entity is destroyed, all relationships it participates in (as source or target) are cleaned up automatically.

Defining Systems

Systems implement ISystem (or ITickedSystem if they need delta time). Implement RunImpl with the system logic:

public class DealSystem : ISystem
{
    public void RunImpl(World world)
    {
        var state = world.ReadSingleton<GameState>();
        if (state.Phase != GamePhase.Dealing)
            return;

        // Do work...
    }
}

The ISystem interface has no Query property. Systems read singletons, build queries, and iterate on their own — this keeps the interface minimal and gives systems full flexibility.

Running Systems

Use the Run extension method to execute a system with automatic batching:

var system = new MySystem();
system.Run(world); // wraps RunImpl in BeginBatching/EndBatching

SystemGroup handles this automatically — you just register systems and call RunLogical() or RunTimed().

Iteration

Use world.Select<T1..T6>() with foreach for zero-allocation iteration with ref access to components. Use RefN for components you intend to mutate (tracked for auto-dirty-marking) and ValN for read-only access (untracked):

foreach (var it in world.Select<Position, Velocity>())
{
    it.Ref1.X += it.Val2.X * dt; // Position tracked, Velocity not
    it.Ref1.Y += it.Val2.Y * dt;
}

For queries with exclusion filters, create a Query<T> with .Without<W>():

var query = new Query<Cell>().Without<Mark>();
foreach (var it in world.Select(query))
{
    if (it.Val1.Row == row && it.Val1.Col == col) { ... }
}

To find a single entity, use FindEntity<T>():

var handEntity = world.FindEntity<PlayerHand>();

The Select ref struct enumerators expose:

  • it.Entity — the current entity handle.
  • it.Ref1..it.RefNref references to components (tracked for auto-dirty).
  • it.Val1..it.ValNref readonly references to components (untracked).

Singleton entities are automatically excluded from query results.

System Registration

Systems run in registration order via SystemGroup:

var world = new World();
var group = new SystemGroup(world);
group.Add(new DeckSetupSystem());
group.Add(new DealSystem());
group.Add(new PlayerBustCheckSystem());
group.Add(new DealerSystem());

SystemGroup automatically drains commands, flushes pending mutations, and posts changes after each system and after the full tick.

Defining Commands

Commands are public record struct types annotated with [MessagePackObject] and implementing ICommand:

[MessagePackObject]
public record struct PlaceBetCommand : ICommand
{
    [Key(0)] public int Amount;

    public void Execute(World world)
    {
        ref var state = ref world.GetSingleton<GameState>();
        if (state.Phase != GamePhase.Betting) return;
        state.CurrentBet = Amount;
        state.Chips -= Amount;
        state.Phase = GamePhase.Dealing;
    }
}

Enqueue commands via world.Commands.Enqueue(...). They execute deferred when the queue is drained (automatically by SystemGroup). Commands run inside a batching scope, so GetSingleton and GetComponent calls are auto-tracked for modification.

Singletons

Each singleton component type gets its own dedicated entity, allocated automatically by SetSingleton<T>. Use SetSingleton<T>, GetSingleton<T> (ref), and ReadSingleton<T> (copy):

// Setup:
world.SetSingleton(new GameState { Phase = GamePhase.Betting, Chips = 100 });

// Read-only inspection:
var state = world.ReadSingleton<GameState>();

// Mutation (auto-tracked during batching scopes):
ref var mutable = ref world.GetSingleton<GameState>();
mutable.Phase = GamePhase.RoundOver;

GetSingleton returns a ref. During system execution, command drains, and foreach iterations, mutations via GetSingleton are auto-marked for change tracking. Outside a batching scope, call MarkModified after mutating. ReadSingleton returns a copy and never auto-marks.

Change Tracking

  • Structural changes (entity create/destroy, component add/remove) are always auto-marked.
  • Value mutations via GetComponent<T>, GetSingleton<T>, and RefN iterator properties are auto-tracked during batching scopes (system runs, command drains, foreach iterations).
  • Outside batching scopes, call world.MarkModified<T>(entity) explicitly.
  • Changes are posted after each system runs (automatic via SystemGroup).

Serialization

WorldSerializer.Save/Load uses the source-generated ComponentRegistry. All component types used with World generic methods are automatically discovered. Serialization round-trips must be tested — see testing-games skill.

Interrupts

Interrupts pause system execution until an external response arrives. A system issues an interrupt, the next tick is skipped, and a handler command resolves it.

Issuing an Interrupt

[MessagePackObject]
public record struct ConfirmInterrupt : IInterrupt
{
    [Key(0)] public string Message;
}

// In a system:
world.Interrupt(new ConfirmInterrupt { Message = "Are you sure?" });

The current tick completes normally. The next tick is blocked — SystemGroup skips all systems but still drains commands and posts changes.

Handling an Interrupt

Handler commands implement IInterruptHandlerCommand<T>. The default Execute calls TryResolve with the pending interrupt. Return true to resolve it, false to leave it pending for another handler:

[MessagePackObject]
public struct ConfirmHandler : IInterruptHandlerCommand<ConfirmInterrupt>
{
    [Key(0)] public bool Confirmed;

    public bool TryResolve(ConfirmInterrupt interrupt)
    {
        // Do work here. The interrupt is just a signal.
        return true;
    }
}

// External code (e.g., UI button) enqueues the handler:
world.Commands.Enqueue(new ConfirmHandler { Confirmed = true });

Key Rules

  • Only one interrupt of a given type may be pending at a time. A second call to Interrupt<T> with the same type throws.
  • Interrupts are transient — they are not serialized and do not survive save/load.
  • If no SystemGroup manages the world, Interrupt<T>() is a no-op.
  • Check pending interrupts with world.HasInterrupt<T>().