diff --git a/examples/TicTacToe/Commands/PlaceMarkCommand.cs b/examples/TicTacToe/Commands/PlaceMarkCommand.cs
new file mode 100644
index 0000000..520ccb7
--- /dev/null
+++ b/examples/TicTacToe/Commands/PlaceMarkCommand.cs
@@ -0,0 +1,50 @@
+using MessagePack;
+using OECS;
+
+namespace TicTacToe;
+
+///
+/// Places the current player's mark on the cell at (Row, Col).
+/// Validates that the cell is empty and the game is still playing.
+///
+[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();
+
+ 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().Without().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(target.Value);
+
+ // Advance turn.
+ state.MoveCount++;
+ state.CurrentPlayer = state.CurrentPlayer == Player.X ? Player.O : Player.X;
+ world.MarkModified(World.SingletonEntity);
+ }
+}
diff --git a/examples/TicTacToe/Components/Cell.cs b/examples/TicTacToe/Components/Cell.cs
new file mode 100644
index 0000000..18e30b6
--- /dev/null
+++ b/examples/TicTacToe/Components/Cell.cs
@@ -0,0 +1,13 @@
+using MessagePack;
+
+namespace TicTacToe;
+
+///
+/// Identifies a board position. One entity per cell.
+///
+[MessagePackObject]
+public struct Cell
+{
+ [Key(0)] public int Row;
+ [Key(1)] public int Col;
+}
diff --git a/examples/TicTacToe/Components/Mark.cs b/examples/TicTacToe/Components/Mark.cs
new file mode 100644
index 0000000..567cd6c
--- /dev/null
+++ b/examples/TicTacToe/Components/Mark.cs
@@ -0,0 +1,13 @@
+using MessagePack;
+
+namespace TicTacToe;
+
+///
+/// A mark placed on a cell by a player.
+/// Only present on cells that have been claimed.
+///
+[MessagePackObject]
+public struct Mark
+{
+ [Key(0)] public Player Player;
+}
diff --git a/examples/TicTacToe/Components/Player.cs b/examples/TicTacToe/Components/Player.cs
new file mode 100644
index 0000000..103885d
--- /dev/null
+++ b/examples/TicTacToe/Components/Player.cs
@@ -0,0 +1,8 @@
+namespace TicTacToe;
+
+public enum Player : byte
+{
+ None = 0,
+ X = 1,
+ O = 2
+}
diff --git a/examples/TicTacToe/CsvLoader.cs b/examples/TicTacToe/CsvLoader.cs
new file mode 100644
index 0000000..43bd7c0
--- /dev/null
+++ b/examples/TicTacToe/CsvLoader.cs
@@ -0,0 +1,45 @@
+using OECS;
+
+namespace TicTacToe;
+
+///
+/// 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.
+///
+public static class CsvLoader
+{
+ public static List LoadCells(World world, string filePath)
+ {
+ var entities = new List();
+ 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;
+ }
+}
diff --git a/examples/TicTacToe/Data/board.csv b/examples/TicTacToe/Data/board.csv
new file mode 100644
index 0000000..41024db
--- /dev/null
+++ b/examples/TicTacToe/Data/board.csv
@@ -0,0 +1,10 @@
+Row,Col
+0,0
+0,1
+0,2
+1,0
+1,1
+1,2
+2,0
+2,1
+2,2
diff --git a/examples/TicTacToe/Program.cs b/examples/TicTacToe/Program.cs
new file mode 100644
index 0000000..77f34ea
--- /dev/null
+++ b/examples/TicTacToe/Program.cs
@@ -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();
+
+ // 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().Subscribe(change =>
+ {
+ reactivityLog.Add($"[react] {change}");
+ });
+
+ // 3. Log every GameState singleton change.
+ world.ObserveComponentChanges().Subscribe(change =>
+ {
+ reactivityLog.Add($"[react] {change}");
+ });
+
+ // 4. Log changes matching a query: cells that have a Mark.
+ var markedQuery = world.Query().With().With().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();
+ 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 0–2.");
+ 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 log)
+ {
+ if (log.Count == 0) return;
+ Console.WriteLine("── reactivity log ──");
+ foreach (var entry in log)
+ Console.WriteLine(entry);
+ Console.WriteLine();
+ log.Clear();
+ }
+}
diff --git a/examples/TicTacToe/Singletons/GameState.cs b/examples/TicTacToe/Singletons/GameState.cs
new file mode 100644
index 0000000..0888da1
--- /dev/null
+++ b/examples/TicTacToe/Singletons/GameState.cs
@@ -0,0 +1,14 @@
+using MessagePack;
+
+namespace TicTacToe;
+
+///
+/// Global game state stored on the singleton entity.
+///
+[MessagePackObject]
+public struct GameState
+{
+ [Key(0)] public Player CurrentPlayer;
+ [Key(1)] public GameStatus Status;
+ [Key(2)] public int MoveCount;
+}
diff --git a/examples/TicTacToe/Singletons/GameStatus.cs b/examples/TicTacToe/Singletons/GameStatus.cs
new file mode 100644
index 0000000..c1d3946
--- /dev/null
+++ b/examples/TicTacToe/Singletons/GameStatus.cs
@@ -0,0 +1,9 @@
+namespace TicTacToe;
+
+public enum GameStatus : byte
+{
+ Playing = 0,
+ XWon = 1,
+ OWon = 2,
+ Draw = 3
+}
diff --git a/examples/TicTacToe/Systems/RenderSystem.cs b/examples/TicTacToe/Systems/RenderSystem.cs
new file mode 100644
index 0000000..df1f30e
--- /dev/null
+++ b/examples/TicTacToe/Systems/RenderSystem.cs
@@ -0,0 +1,67 @@
+using OECS;
+
+namespace TicTacToe;
+
+///
+/// Prints the board to the console.
+///
+public class RenderSystem : ISystem
+{
+ public QueryDescriptor Query { get; }
+
+ public RenderSystem(World world)
+ {
+ Query = world.Query().With().Build();
+ }
+
+ public void Run(World world)
+ {
+ ref var state = ref world.GetSingleton();
+
+ // 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().With().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\"): ");
+ }
+}
diff --git a/examples/TicTacToe/Systems/WinCheckSystem.cs b/examples/TicTacToe/Systems/WinCheckSystem.cs
new file mode 100644
index 0000000..0d1ce53
--- /dev/null
+++ b/examples/TicTacToe/Systems/WinCheckSystem.cs
@@ -0,0 +1,89 @@
+using OECS;
+
+namespace TicTacToe;
+
+///
+/// After each move, checks whether the game has been won or drawn.
+/// Updates the GameState singleton accordingly.
+///
+public class WinCheckSystem : ISystem
+{
+ public QueryDescriptor Query { get; }
+
+ public WinCheckSystem(World world)
+ {
+ Query = world.Query().With| ().With().Build();
+ }
+
+ public void Run(World world)
+ {
+ ref var state = ref world.GetSingleton();
+
+ 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(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(World.SingletonEntity);
+ }
+}
diff --git a/examples/TicTacToe/TicTacToe.csproj b/examples/TicTacToe/TicTacToe.csproj
new file mode 100644
index 0000000..8852db0
--- /dev/null
+++ b/examples/TicTacToe/TicTacToe.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ TicTacToe
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
diff --git a/examples/TicTacToe/test_input.txt b/examples/TicTacToe/test_input.txt
new file mode 100644
index 0000000..75b2559
--- /dev/null
+++ b/examples/TicTacToe/test_input.txt
@@ -0,0 +1,5 @@
+0 0
+1 1
+0 1
+1 0
+0 2
diff --git a/src/OECS/SystemGroup.cs b/src/OECS/SystemGroup.cs
index 4cd638d..bc4dbc1 100644
--- a/src/OECS/SystemGroup.cs
+++ b/src/OECS/SystemGroup.cs
@@ -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)
| | | | |