220 lines
6.1 KiB
Markdown
220 lines
6.1 KiB
Markdown
|
|
---
|
|||
|
|
name: writing-games
|
|||
|
|
description: 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`:
|
|||
|
|
|
|||
|
|
```xml
|
|||
|
|
<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`.
|
|||
|
|
|
|||
|
|
```csharp
|
|||
|
|
[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:
|
|||
|
|
|
|||
|
|
```csharp
|
|||
|
|
[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:
|
|||
|
|
|
|||
|
|
```csharp
|
|||
|
|
// 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:
|
|||
|
|
|
|||
|
|
```csharp
|
|||
|
|
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):
|
|||
|
|
|
|||
|
|
```csharp
|
|||
|
|
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** (1–6 components):
|
|||
|
|
```csharp
|
|||
|
|
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>()` (1–3 components):
|
|||
|
|
```csharp
|
|||
|
|
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`:
|
|||
|
|
|
|||
|
|
```csharp
|
|||
|
|
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`:
|
|||
|
|
|
|||
|
|
```csharp
|
|||
|
|
[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):
|
|||
|
|
|
|||
|
|
```csharp
|
|||
|
|
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.
|