feat: add agent skill documentation
Add new skill documentation files for developing OECS, testing games, and writing games. Also update the API surface documentation to include guidance on component and command definitions.
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
---
|
||||
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.
|
||||
Reference in New Issue
Block a user