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

6.1 KiB
Raw Blame History

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):

public class DealSystem : ISystem
{
    public void Run(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.

Iteration Styles

Two options:

ForEach callbacks (16 components):

var query = world.Query().With<Position>().With<Velocity>().Build();
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) =>
{
    pos.X += vel.X * dt;
    world.MarkModified<Position>(e);
});

Ref struct iterators via EntityIterator.Select<T>() (13 components):

using var iter = world.Select<PlayerHand>();
while (iter.MoveNext())
{
    // iter.CurrentEntity, iter.Current1 (ref)
}

The singleton entity (ID 1) is automatically skipped by all iterators.

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 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;
        world.MarkModified<GameState>(World.SingletonEntity);
    }
}

Enqueue commands via world.Commands.Enqueue(...). They execute deferred when the queue is drained (automatically by SystemGroup).

Singletons

Global state lives on the singleton entity (ID 1). Use SetSingleton<T>, GetSingleton<T> (ref), and ReadSingleton<T> (copy):

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

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

// Mutation:
ref var mutable = ref world.GetSingleton<GameState>();
mutable.Phase = GamePhase.RoundOver;
world.MarkModified<GameState>(World.SingletonEntity);

GetSingleton returns a ref — always call MarkModified after mutating so reactivity subscribers see the change. ReadSingleton returns a copy and never auto-marks.

Change Tracking

  • Structural changes (entity create/destroy, component add/remove) are auto-marked.
  • Value mutations (modifying a ref T component) must be manually marked via world.MarkModified<T>(entity).
  • 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.