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

303 lines
8.9 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).
Implement `RunImpl` with the system logic:
```csharp
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:
```csharp
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):
```csharp
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>()`:
```csharp
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>()`:
```csharp
var handEntity = world.FindEntity<PlayerHand>();
```
The `Select` ref struct enumerators expose:
- `it.Entity` — the current entity handle.
- `it.Ref1`..`it.RefN` — `ref` references to components (tracked for auto-dirty).
- `it.Val1`..`it.ValN` — `ref readonly` references to components (untracked).
Singleton entities are automatically excluded from query results.
### 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, 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`:
```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;
}
}
```
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):
```csharp
// 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
```csharp
[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:
```csharp
[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>()`.