6.5 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):
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
Use world.Select<T1..T6>() with foreach for zero-allocation iteration with
ref access to components:
foreach (var it in world.Select<Position, Velocity>())
{
it.Item1.X += it.Item2.X * dt; // ref Position, ref Velocity
}
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.Item1.Row == row && it.Item1.Col == col) { ... }
}
To find entities, use FindEntity<T>() or FindEntities<T>():
var handEntity = world.FindEntity<PlayerHand>();
var allCards = world.FindEntities<Card>();
The Select ref struct enumerators expose:
it.Entity— the current entity handle.it.Item1..it.ItemN—refreferences to the matched components.
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 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).
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:
ref var mutable = ref world.GetSingleton<GameState>();
mutable.Phase = GamePhase.RoundOver;
GetSingleton returns a ref. During system execution (inside SystemGroup),
mutations via GetSingleton are auto-marked. Outside of iteration, 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 Tcomponent) must be manually marked viaworld.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.