7.4 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.RefN—refreferences to components (tracked for auto-dirty).it.Val1..it.ValN—ref readonlyreferences 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>, andRefNiterator properties are auto-tracked during batching scopes (system runs, command drains,foreachiterations). - 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.