feat: add TicTacToe example project

Implement a complete Tic-Tac-Toe game using OECS to demonstrate
ECS patterns, including:
- Command pattern for placing marks
- System groups for logical updates and rendering
- CSV loading for initial board state
- Reactivity logging for observing entity and component changes
This commit is contained in:
hypercross 2026-07-18 20:43:13 +08:00
parent dddddbdbd6
commit a19861c080
14 changed files with 467 additions and 0 deletions

View File

@ -0,0 +1,50 @@
using MessagePack;
using OECS;
namespace TicTacToe;
/// <summary>
/// Places the current player's mark on the cell at (Row, Col).
/// Validates that the cell is empty and the game is still playing.
/// </summary>
[MessagePackObject]
public struct PlaceMarkCommand : ICommand
{
[Key(0)] public int Row;
[Key(1)] public int Col;
public void Execute(World world)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Status != GameStatus.Playing)
return;
// Copy to locals so the lambda in ForEach can capture them
// (this is a struct, so `this` cannot be captured directly).
int row = Row;
int col = Col;
// Find the cell entity at (Row, Col) that has no Mark.
var query = world.Query().With<Cell>().Without<Mark>().Build();
Entity? target = null;
world.ForEach(query, (Entity entity, ref Cell cell) =>
{
if (cell.Row == row && cell.Col == col)
target = entity;
});
if (target == null)
return; // Cell already occupied or invalid position.
// Place the mark.
world.AddComponent(target.Value, new Mark { Player = state.CurrentPlayer });
world.MarkModified<Mark>(target.Value);
// Advance turn.
state.MoveCount++;
state.CurrentPlayer = state.CurrentPlayer == Player.X ? Player.O : Player.X;
world.MarkModified<GameState>(World.SingletonEntity);
}
}

View File

@ -0,0 +1,13 @@
using MessagePack;
namespace TicTacToe;
/// <summary>
/// Identifies a board position. One entity per cell.
/// </summary>
[MessagePackObject]
public struct Cell
{
[Key(0)] public int Row;
[Key(1)] public int Col;
}

View File

@ -0,0 +1,13 @@
using MessagePack;
namespace TicTacToe;
/// <summary>
/// A mark placed on a cell by a player.
/// Only present on cells that have been claimed.
/// </summary>
[MessagePackObject]
public struct Mark
{
[Key(0)] public Player Player;
}

View File

@ -0,0 +1,8 @@
namespace TicTacToe;
public enum Player : byte
{
None = 0,
X = 1,
O = 2
}

View File

@ -0,0 +1,45 @@
using OECS;
namespace TicTacToe;
/// <summary>
/// Minimal CSV loader that creates entities from a CSV file.
///
/// The first row is a header. Each subsequent row creates one entity.
/// Column names are mapped to component fields by convention:
/// a column named "Row" sets Cell.Row, "Col" sets Cell.Col, etc.
///
/// Currently hardcoded for the Cell component. Extend as needed.
/// </summary>
public static class CsvLoader
{
public static List<Entity> LoadCells(World world, string filePath)
{
var entities = new List<Entity>();
var lines = File.ReadAllLines(filePath);
if (lines.Length < 2)
return entities;
// Parse header to get column indices.
var headers = lines[0].Split(',');
int rowIdx = Array.IndexOf(headers, "Row");
int colIdx = Array.IndexOf(headers, "Col");
for (int i = 1; i < lines.Length; i++)
{
var values = lines[i].Split(',');
if (values.Length < 2) continue;
var entity = world.CreateEntity();
world.AddComponent(entity, new Cell
{
Row = int.Parse(values[rowIdx]),
Col = int.Parse(values[colIdx])
});
entities.Add(entity);
}
return entities;
}
}

View File

@ -0,0 +1,10 @@
Row,Col
0,0
0,1
0,2
1,0
1,1
1,2
2,0
2,1
2,2
1 Row Col
2 0 0
3 0 1
4 0 2
5 1 0
6 1 1
7 1 2
8 2 0
9 2 1
10 2 2

View File

@ -0,0 +1,119 @@
using OECS;
using R3;
namespace TicTacToe;
public static class Program
{
public static void Main()
{
var world = new World();
// ── Setup reactivity logging ──────────────────────────────────
//
// Each subscription appends to a shared log buffer. The log is
// printed after the board render so Console.Clear() doesn't wipe it.
var reactivityLog = new List<string>();
// 1. Log every entity-level change (creates, destroys).
world.ObserveEntityChanges().Subscribe(change =>
{
if (change.ComponentType != null) return;
reactivityLog.Add($"[react] {change}");
});
// 2. Log every Mark component change (placed on a cell).
world.ObserveComponentChanges<Mark>().Subscribe(change =>
{
reactivityLog.Add($"[react] {change}");
});
// 3. Log every GameState singleton change.
world.ObserveComponentChanges<GameState>().Subscribe(change =>
{
reactivityLog.Add($"[react] {change}");
});
// 4. Log changes matching a query: cells that have a Mark.
var markedQuery = world.Query().With<Cell>().With<Mark>().Build();
world.ObserveQuery(markedQuery).Subscribe(change =>
{
reactivityLog.Add($"[react:query] {change}");
});
// ── Load initial state from CSV ───────────────────────────────
var csvPath = Path.Combine(AppContext.BaseDirectory, "Data", "board.csv");
CsvLoader.LoadCells(world, csvPath);
world.PostChanges(); // flush entity-created notifications
// ── Initialize singleton ──────────────────────────────────────
world.SetSingleton(new GameState
{
CurrentPlayer = Player.X,
Status = GameStatus.Playing,
MoveCount = 0
});
world.PostChanges();
// ── Wire up systems ───────────────────────────────────────────
var group = new SystemGroup(world);
group.Add(new WinCheckSystem(world));
group.Add(new RenderSystem(world));
// ── Game loop ─────────────────────────────────────────────────
// Initial render.
group.RunLogical();
PrintReactivityLog(reactivityLog);
while (true)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Status != GameStatus.Playing)
break;
var input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input))
continue;
var parts = input.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2 ||
!int.TryParse(parts[0], out var row) ||
!int.TryParse(parts[1], out var col))
{
Console.WriteLine(" Invalid input. Try \"1 2\".");
continue;
}
if (row < 0 || row > 2 || col < 0 || col > 2)
{
Console.WriteLine(" Row and col must be 02.");
continue;
}
world.Commands.Enqueue(new PlaceMarkCommand { Row = row, Col = col });
group.RunLogical();
PrintReactivityLog(reactivityLog);
}
// ── Final state ───────────────────────────────────────────────
Console.WriteLine();
Console.WriteLine("=== Game over ===");
PrintReactivityLog(reactivityLog);
}
private static void PrintReactivityLog(List<string> log)
{
if (log.Count == 0) return;
Console.WriteLine("── reactivity log ──");
foreach (var entry in log)
Console.WriteLine(entry);
Console.WriteLine();
log.Clear();
}
}

View File

@ -0,0 +1,14 @@
using MessagePack;
namespace TicTacToe;
/// <summary>
/// Global game state stored on the singleton entity.
/// </summary>
[MessagePackObject]
public struct GameState
{
[Key(0)] public Player CurrentPlayer;
[Key(1)] public GameStatus Status;
[Key(2)] public int MoveCount;
}

View File

@ -0,0 +1,9 @@
namespace TicTacToe;
public enum GameStatus : byte
{
Playing = 0,
XWon = 1,
OWon = 2,
Draw = 3
}

View File

@ -0,0 +1,67 @@
using OECS;
namespace TicTacToe;
/// <summary>
/// Prints the board to the console.
/// </summary>
public class RenderSystem : ISystem
{
public QueryDescriptor Query { get; }
public RenderSystem(World world)
{
Query = world.Query().With<Cell>().Build();
}
public void Run(World world)
{
ref var state = ref world.GetSingleton<GameState>();
// Build a 3×3 grid.
var grid = new char[3, 3];
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++)
grid[r, c] = '.';
// Fill in placed marks.
var markQuery = world.Query().With<Cell>().With<Mark>().Build();
world.ForEach(markQuery, (Entity entity, ref Cell cell, ref Mark mark) =>
{
grid[cell.Row, cell.Col] = mark.Player == Player.X ? 'X' : 'O';
});
Console.WriteLine();
Console.WriteLine(" Tic-Tac-Toe");
Console.WriteLine(" ═══════════");
Console.WriteLine();
Console.WriteLine(" 0 1 2");
Console.WriteLine(" ┌───┬───┬───┐");
for (int r = 0; r < 3; r++)
{
Console.Write($"{r} │");
for (int c = 0; c < 3; c++)
{
Console.Write($" {grid[r, c]} ");
if (c < 2) Console.Write("│");
}
Console.WriteLine("│");
if (r < 2) Console.WriteLine(" ├───┼───┼───┤");
}
Console.WriteLine(" └───┴───┴───┘");
Console.WriteLine();
// Status line.
var statusText = state.Status switch
{
GameStatus.Playing => $"{(state.CurrentPlayer == Player.X ? 'X' : 'O')}'s turn (move {state.MoveCount + 1})",
GameStatus.XWon => "X wins!",
GameStatus.OWon => "O wins!",
GameStatus.Draw => "It's a draw!",
_ => ""
};
Console.WriteLine($" {statusText}");
Console.WriteLine();
Console.Write(" Enter row col (e.g. \"1 2\"): ");
}
}

View File

@ -0,0 +1,89 @@
using OECS;
namespace TicTacToe;
/// <summary>
/// After each move, checks whether the game has been won or drawn.
/// Updates the GameState singleton accordingly.
/// </summary>
public class WinCheckSystem : ISystem
{
public QueryDescriptor Query { get; }
public WinCheckSystem(World world)
{
Query = world.Query().With<Cell>().With<Mark>().Build();
}
public void Run(World world)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Status != GameStatus.Playing)
return;
// Build a 3×3 grid of marks.
var grid = new Player[3, 3];
world.ForEach(Query, (Entity entity, ref Cell cell, ref Mark mark) =>
{
grid[cell.Row, cell.Col] = mark.Player;
});
// Check rows.
for (int r = 0; r < 3; r++)
{
if (TryGetWinner(grid[r, 0], grid[r, 1], grid[r, 2], out var winner))
{
SetWinner(world, ref state, winner);
return;
}
}
// Check columns.
for (int c = 0; c < 3; c++)
{
if (TryGetWinner(grid[0, c], grid[1, c], grid[2, c], out var winner))
{
SetWinner(world, ref state, winner);
return;
}
}
// Check diagonals.
if (TryGetWinner(grid[0, 0], grid[1, 1], grid[2, 2], out var diag1))
{
SetWinner(world, ref state, diag1);
return;
}
if (TryGetWinner(grid[0, 2], grid[1, 1], grid[2, 0], out var diag2))
{
SetWinner(world, ref state, diag2);
return;
}
// Check draw.
if (state.MoveCount >= 9)
{
state.Status = GameStatus.Draw;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
private static bool TryGetWinner(Player a, Player b, Player c, out Player winner)
{
if (a != Player.None && a == b && b == c)
{
winner = a;
return true;
}
winner = Player.None;
return false;
}
private static void SetWinner(World world, ref GameState state, Player winner)
{
state.Status = winner == Player.X ? GameStatus.XWon : GameStatus.OWon;
world.MarkModified<GameState>(World.SingletonEntity);
}
}

View File

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>TicTacToe</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\OECS\OECS.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Data\board.csv">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@ -0,0 +1,5 @@
0 0
1 1
0 1
1 0
0 2

View File

@ -58,6 +58,10 @@ public class SystemGroup
private void RunAll(Tick tick)
{
// Drain commands enqueued before the tick so the first system
// sees their effects.
_world.ExecuteCommands();
foreach (var system in _systems)
{
if (system is ITickedSystem ticked)