03.2 - Game Engine Design
Recap
03.1 - Collections, Generics, LINQ gave us the board as EGamePiece[][], the move history as a List<T> and LINQ for scanning lines; 02.2 - OOP 2 gave us records for immutable data. Now we put them together into the engine for A2 — designed so that persistence (A3/A4) and the AI (A5) plug in later without a rewrite.
By the end of this lecture you should be able to:
- Separate configuration, state, rules and UI into the right types and projects.
- Write an immutable
GameConfigurationand a serialisableGameState. - Implement
GameBrainwith legal-move generation, a validatedMakeMove, N-in-a-row win detection and draw detection. - Run a hot-seat game loop in the console through an
IMoveProvider. - Explain what changes in state and rules for your assigned game.
Lecture demos: csharp-2026-fall
Four things that must not be mixed
| Configuration | State | Rules | UI | |
|---|---|---|---|---|
| What | how a game is set up | where a game is right now | what may happen next | how it is shown, how input is read |
| Example | board size, win length, cylinder on/off | cells, whose turn, move history | is this move legal, did somebody win | draw the board, read "row,col", menus |
| Changes | before the game, never during | every move | never at runtime | swapped for a web UI in A6 |
| Type | GameConfiguration record | GameState class | GameBrain class | ConsoleUI project |
| Persisted | yes — config CRUD in A3 | yes — game CRUD in A3 | no, it is code | no |
Mixing them looks like: Console.WriteLine inside the engine, the board size hardcoded in the win check, or the UI deciding whose turn it is. Every one of those makes A3, A5 and A6 harder.
Projects and dependency direction
GameEnginereferences nothing. NoConsole, noSystem.IO, no menu.MenuSystemreferences nothing and knows nothing about games (A1 rule).- Arrows point one way.
GameEngine → ConsoleUIwould be a cycle, and the web app could never reuse the engine.
dotnet new classlib -n GameEngine
dotnet new console -n ConsoleUI
dotnet new xunit -n Tests
dotnet sln add GameEngine ConsoleUI Tests
dotnet add ConsoleUI reference GameEngine MenuSystem
dotnet add Tests reference GameEngine
Configuration: an immutable record
namespace GameEngine;
public record GameConfiguration(
string Name,
int BoardWidth,
int BoardHeight,
int WinLength)
{
// game-specific settings get defaults, so presets stay short
public bool IsCylinder { get; init; } = false;
public List<string> Validate()
{
List<string> errors = [];
if (string.IsNullOrWhiteSpace(Name)) errors.Add("Name is required.");
if (BoardWidth is < 3 or > 20) errors.Add("Width must be 3..20.");
if (BoardHeight is < 3 or > 20) errors.Add("Height must be 3..20.");
if (WinLength < 3 || WinLength > Math.Max(BoardWidth, BoardHeight))
errors.Add("Win length must be between 3 and the longer board side.");
return errors;
}
}
Why a record: value equality (handy in tests and for "did the user change anything"), with for copy-and-change, and no setters — nobody can change the board size in the middle of a game. Validate() returns a list instead of throwing, because the configuration UI wants to show all problems at once.
public static class Presets
{
public static readonly GameConfiguration Classic = new("Classic", 7, 6, 4);
public static readonly GameConfiguration Cylinder = Classic with { Name = "Cylinder", IsCylinder = true };
public static IReadOnlyList<GameConfiguration> All =>
[Classic, new("Connect3", 5, 4, 3), new("Connect5", 9, 7, 5), Cylinder];
}
State: everything needed to continue the game
namespace GameEngine;
public enum EGamePiece { Empty, X, O }
public enum EGameStatus { InProgress, XWon, OWon, Draw }
public class GameState
{
public Guid Id { get; set; } = Guid.NewGuid();
public required GameConfiguration Config { get; init; }
public EGamePiece[][] Board { get; set; } = [];
public EGamePiece NextMoveBy { get; set; } = EGamePiece.X;
public List<(int Row, int Col)> Moves { get; set; } = [];
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
public static GameState New(GameConfiguration config) => new()
{
Config = config,
Board = CreateBoard(config.BoardHeight, config.BoardWidth),
};
public static EGamePiece[][] CreateBoard(int height, int width)
{
var board = new EGamePiece[height][];
for (var row = 0; row < height; row++)
{
board[row] = new EGamePiece[width];
}
return board;
}
}
Design decisions, each of which pays off later:
- Plain public properties with setters — 04.1 - JSON serialises this without any tricks.
- The configuration travels inside the state. A saved game must be self-contained: you can load it next year even if the preset was deleted or edited.
Movesis the full history — replay, undo and "last move" highlighting all come from it.Idis aGuidcreated here, not by a database. The same id works for a JSON file (A3), an SQLite row (A4) and a URL (A6).- Timestamps are UTC. Always.
Nothing in GameState is derived. Whose turn it is is stored because the AI and the UI both need it cheaply; the game status is not stored because it can be computed from the board and the last move — one source of truth, no way for the two to disagree.
Rules: GameBrain
The brain owns the rules and manipulates a state it was given. It does not create states, load them or print them.
namespace GameEngine;
public class InvalidMoveException(string message) : Exception(message);
public class GameBrain(GameState state)
{
public GameState State => state;
public GameConfiguration Config => state.Config;
public EGamePiece GetPiece(int row, int col) => state.Board[row][col];
public bool IsInside(int row, int col)
=> row >= 0 && row < Config.BoardHeight && col >= 0 && col < Config.BoardWidth;
public bool IsLegalMove(int row, int col)
=> Status == EGameStatus.InProgress && IsInside(row, col) && state.Board[row][col] == EGamePiece.Empty;
public List<(int Row, int Col)> GetLegalMoves()
{
List<(int Row, int Col)> moves = [];
for (var row = 0; row < Config.BoardHeight; row++)
{
for (var col = 0; col < Config.BoardWidth; col++)
{
if (state.Board[row][col] == EGamePiece.Empty) moves.Add((row, col));
}
}
return moves;
}
public EGameStatus MakeMove(int row, int col)
{
if (!IsLegalMove(row, col))
throw new InvalidMoveException($"({row}, {col}) is not a legal move for {state.NextMoveBy}.");
state.Board[row][col] = state.NextMoveBy;
state.Moves.Add((row, col));
state.NextMoveBy = state.NextMoveBy == EGamePiece.X ? EGamePiece.O : EGamePiece.X;
return Status;
}
public bool TryMakeMove(int row, int col, out string? error)
{
error = Status != EGameStatus.InProgress ? "The game is over."
: !IsInside(row, col) ? "That cell is outside the board."
: state.Board[row][col] != EGamePiece.Empty ? "That cell is taken."
: null;
if (error is not null) return false;
MakeMove(row, col);
return true;
}
public EGameStatus Status
{
get
{
if (HasWinner())
{
var (row, col) = state.Moves[^1];
return state.Board[row][col] == EGamePiece.X ? EGameStatus.XWon : EGameStatus.OWon;
}
return IsDraw() ? EGameStatus.Draw : EGameStatus.InProgress;
}
}
public bool HasWinner()
{
if (state.Moves.Count == 0) return false;
var (row, col) = state.Moves[^1];
return CheckWin(row, col);
}
public bool IsDraw() => !HasWinner() && GetLegalMoves().Count == 0;
// CheckWin and helpers below
}
GetLegalMoves is the method the AI will call thousands of times per second in A5, and MakeMove is the only way the board changes. IMoveProvider (below) returns a coordinate; the brain decides whether it is legal.
MakeMove throws InvalidMoveException — an illegal move reaching it is a programming error, because the UI (or the AI) should ask IsLegalMove first. TryMakeMove is the friendly variant for user input: it returns false and a message the UI can print. Pick one style for your engine and use it consistently; Week 5 returns to exceptions vs result objects.
Win detection: N in a row, four directions
Only the piece just placed can have completed a line, so check from (row, col) outward. Four directions, each counted both ways:
private static readonly (int DRow, int DCol)[] Directions =
[
(0, 1), // horizontal
(1, 0), // vertical
(1, 1), // diagonal down-right
(1, -1), // diagonal down-left
];
public bool CheckWin(int row, int col)
{
var player = state.Board[row][col];
if (player == EGamePiece.Empty) return false;
foreach (var (dRow, dCol) in Directions)
{
var count = 1
+ CountDirection(row, col, dRow, dCol, player)
+ CountDirection(row, col, -dRow, -dCol, player);
if (count >= Config.WinLength) return true;
}
return false;
}
private int CountDirection(int row, int col, int dRow, int dCol, EGamePiece player)
{
var count = 0;
var r = row + dRow;
var c = WrapCol(col + dCol);
while (count < Config.WinLength && IsInside(r, c) && state.Board[r][c] == player)
{
count++;
r += dRow;
c = WrapCol(c + dCol);
}
return count;
}
// cylinder: columns wrap around the side edge; rectangle: no change
private int WrapCol(int col)
=> Config.IsCylinder ? ((col % Config.BoardWidth) + Config.BoardWidth) % Config.BoardWidth : col;
Three details worth a test each:
- The count starts at 1 (the piece itself) and the two half-lines are added.
count < Config.WinLengthstops the walk. On a cylinder a full ring of the same piece would otherwise loop forever.WrapColuses the double modulo because-1 % 7is-1in C#, not6.
Gomoku's "exactly N" rule and Reversi's flanking need different checks — see the per-game notes.
The hot-seat loop in ConsoleUI
The loop asks a move provider for the next move. Today both providers are humans typing at the same keyboard; in A5 one of them becomes minimax, and the loop does not change.
namespace GameEngine;
public interface IMoveProvider
{
(int Row, int Col) GetMove(GameState state);
}
using GameEngine;
namespace ConsoleUI;
public class ConsoleMoveProvider : IMoveProvider
{
public (int Row, int Col) GetMove(GameState state)
{
while (true)
{
Console.Write($"{state.NextMoveBy} - enter move as row,col: ");
var parts = (Console.ReadLine() ?? "").Split(',', StringSplitOptions.TrimEntries);
if (parts.Length == 2 && int.TryParse(parts[0], out var row) && int.TryParse(parts[1], out var col))
{
return (row, col);
}
Console.WriteLine("Two numbers please, for example 2,3");
}
}
}
using GameEngine;
namespace ConsoleUI;
public static class GameRunner
{
public static void Run(GameState state, IMoveProvider xPlayer, IMoveProvider oPlayer)
{
var brain = new GameBrain(state);
while (brain.Status == EGameStatus.InProgress)
{
BoardView.Draw(brain);
var provider = state.NextMoveBy == EGamePiece.X ? xPlayer : oPlayer;
var (row, col) = provider.GetMove(state);
if (!brain.TryMakeMove(row, col, out var error))
{
Console.WriteLine($"Illegal move: {error}");
Console.ReadKey(intercept: true);
}
}
BoardView.Draw(brain);
Console.WriteLine(brain.Status switch
{
EGameStatus.XWon => "X wins!",
EGameStatus.OWon => "O wins!",
_ => "Draw."
});
}
}
public static class BoardView
{
public static void Draw(GameBrain brain)
{
Console.Clear();
Console.Write(" ");
for (var col = 0; col < brain.Config.BoardWidth; col++) Console.Write($"{col,2} ");
Console.WriteLine();
for (var row = 0; row < brain.Config.BoardHeight; row++)
{
Console.Write($"{row,2} ");
for (var col = 0; col < brain.Config.BoardWidth; col++)
{
Console.Write(brain.GetPiece(row, col) switch
{
EGamePiece.X => " X ",
EGamePiece.O => " O ",
_ => " . "
});
}
Console.WriteLine();
}
}
}
A menu item from your A1 library starts it: choose a configuration, GameState.New(config), GameRunner.Run(state, human, human). Where the later assignments hook in:
- A3 adds one line after each successful move:
gameRepository.Save(state). Loading a game isGameRunner.Run(gameRepository.Get(id), ...). - A5 adds
new MinimaxMoveProvider(depth)as one of the two providers. Human vs AI and AI vs AI are the same loop with different providers. - A6 replaces the loop with one move per HTTP request, same
GameBrain.
Per-game notes
The skeleton above is Connect Four without gravity. What changes for each course game — in configuration, state and rules.
Connect Four
- Config: width, height, win length,
IsCylinder. - State: unchanged.
- Rules: the player picks a column, gravity picks the row.
GetLegalMovesreturns the lowest empty cell per column. KeepIMoveProviderreturning(Row, Col)and let the engine compute the row, or read only a column in the console and call aDropPiece(col)helper. The cylinder is handled inWrapCol— write a test where the winning line crosses the side edge.
public int? FindDropRow(int col)
{
if (col < 0 || col >= Config.BoardWidth) return null;
for (var row = Config.BoardHeight - 1; row >= 0; row--)
{
if (state.Board[row][col] == EGamePiece.Empty) return row;
}
return null; // column is full
}
Tic-Tac-Two
- Config: board size N, grid size M, win length W, pieces per player P, unlock threshold.
- State: grid position (
GridRow,GridColof the top-left corner) and how many pieces each player has placed — the unlock rule depends on it. - Rules: three move kinds — place a piece (inside the grid), move an own piece (inside the grid), move the grid one step in any of eight directions. A row/col tuple cannot express all three, so define your own move type and make
IMoveProviderreturn it. Win = W in a row inside the grid only, soCheckWinscans grid cells, not the whole board. After a grid move both players can have a line at once; decide what that means (draw, or the mover wins) and document it in the README.
public abstract record GameMove;
public record PlacePiece(int Row, int Col) : GameMove;
public record MovePiece(int FromRow, int FromCol, int ToRow, int ToCol) : GameMove;
public record MoveGrid(int DRow, int DCol) : GameMove;
Reversi / Othello
- Config: even board size, opening variant (Othello fixed diagonal vs Reversi free placement of the first four), blocked cells. The simplest wall is a fourth enum value
EGamePiece.Wallon the board — it serialises with the board and stops a ray automatically. - State: number of consecutive passes.
- Rules: a move is legal when it flanks at least one line of opponent pieces in one of eight directions; all flanked pieces flip. No legal move → the player passes (a
Passmove or a sentinel coordinate). Two passes in a row → game over; more pieces wins, equal is a draw. N-in-a-rowCheckWinis irrelevant here — replace it with counting.
private static readonly (int DRow, int DCol)[] AllDirections =
[(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)];
public List<(int Row, int Col)> GetFlips(int row, int col, EGamePiece player)
{
List<(int Row, int Col)> flips = [];
var opponent = player == EGamePiece.X ? EGamePiece.O : EGamePiece.X;
foreach (var (dRow, dCol) in AllDirections)
{
List<(int Row, int Col)> line = [];
var r = row + dRow;
var c = col + dCol;
while (IsInside(r, c) && state.Board[r][c] == opponent) // a Wall is neither opponent nor player
{
line.Add((r, c));
r += dRow;
c += dCol;
}
if (line.Count > 0 && IsInside(r, c) && state.Board[r][c] == player) flips.AddRange(line);
}
return flips;
}
Gomoku
- Config: board 9–19 (rectangles allowed), win length 4–6,
ExactWinLengthtoggle (overline rule), optional pro opening. - State: unchanged.
- Rules: the same
CheckWin, but with the overline rule a line of N+1 is not a win. LetCountDirectionwalk up toWinLength + 1and requirecount == Config.WinLengthinstead of>=. The pro opening makesIsLegalMovedepend onMoves.Count: first move in the centre, the first player's second move at least three cells away.
Nine Men's Morris
The board is not a grid. It is a graph of 24 points with an adjacency list and a list of mills — everything the 2D skeleton assumed about rows and columns is gone.
- Config: variant (Three / Six / Nine / Twelve — Twelve adds diagonals), pieces per player, flying threshold, "may remove from a mill when no free piece exists" toggle. Each variant is a different topology table.
- State:
EGamePiece[] Points(24 entries for Nine), pieces still in hand per player, pieces on the board per player, the phase (placing / moving / flying) and a capture pending flag. - Rules: moves are multi-step — place a piece, or move a piece to an adjacent point, or fly anywhere at the flying threshold; when the move closes a mill the same player must capture an opponent piece before the turn ends. Model this as the brain staying in a "capture pending" state where
GetLegalMovesreturns capture targets. A player loses with fewer than three pieces (after placing) or with no legal move.
public static class NineMensMorrisBoard
{
// 24 points numbered 0..23: outer ring, middle ring, inner ring, clockwise from the top-left corner
public static readonly int[][] Adjacent =
[
[1, 9], [0, 2, 4], [1, 14], [4, 10], [1, 3, 5, 7], [4, 13], [7, 11], [4, 6, 8], [7, 12],
[0, 10, 21], [3, 9, 11, 18], [6, 10, 15], [8, 13, 17], [5, 12, 14, 20], [2, 13, 23],
[11, 16], [15, 17, 19], [12, 16], [10, 19], [16, 18, 20, 22], [13, 19], [9, 22], [19, 21, 23], [14, 22],
];
public static readonly int[][] Mills =
[
[0, 1, 2], [3, 4, 5], [6, 7, 8], [9, 10, 11], [12, 13, 14], [15, 16, 17], [18, 19, 20], [21, 22, 23],
[0, 9, 21], [3, 10, 18], [6, 11, 15], [1, 4, 7], [16, 19, 22], [8, 12, 17], [5, 13, 20], [2, 14, 23],
];
}
public bool IsInMill(int point, EGamePiece player)
=> NineMensMorrisBoard.Mills.Any(mill => mill.Contains(point) && mill.All(p => state.Points[p] == player));
The console UI draws the graph as ASCII art with point numbers; the web UI (A6) draws it with CSS. The engine never knows.
Testing hooks
Unit tests on the rules are mandatory from A2 (15 of 100 points) and the engine above is built to make them cheap: no console, no files, a brain constructed from any state you like. What the A2 tests should cover:
- A new game: empty board, X to move, status
InProgress. - A legal move is placed, recorded in
Moves, and the turn switches. - Illegal moves are rejected: outside the board, occupied cell, after the game is over.
- A win in each of the four directions; a line one short of
WinLengthis not a win. - A full board without a winner is a draw.
- Each mandatory extension of your game: a win across the cylinder edge, a wall stopping a flank, exactly-N vs N-or-more, flying at the threshold.
A helper that builds a board from text keeps the tests readable:
public static class TestBoards
{
public static GameState FromString(GameConfiguration config, string rows)
{
var state = GameState.New(config);
var lines = rows.Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
for (var row = 0; row < lines.Length; row++)
{
for (var col = 0; col < lines[row].Length; col++)
{
state.Board[row][col] = lines[row][col] switch
{
'X' => EGamePiece.X,
'O' => EGamePiece.O,
_ => EGamePiece.Empty
};
}
}
return state;
}
}
[Fact]
public void HorizontalFour_IsWin()
{
var state = TestBoards.FromString(Presets.Classic, """
.......
.......
.......
.......
.......
XXX.OOO
""");
var brain = new GameBrain(state);
var status = brain.MakeMove(5, 3);
Assert.Equal(EGameStatus.XWon, status);
}
Set the board up one move before the win and make the last move through MakeMove — that is the behaviour you are testing, and it is why Status looks at the last recorded move. Test project setup, [Theory] with [InlineData] for the four directions, and naming conventions are in 05.1 - Unit Testing.
Self preparation QA
- What is the difference between configuration and state, and why does the state carry a copy of the configuration? — Configuration is fixed before the game (sizes, rules), state changes every move (board, turn, history); the copy makes a saved game self-contained even if the preset is later edited or deleted.
- Why is
GameConfigurationa record andGameStatea class? — The configuration is immutable and compared by value (with, equality in tests); the state is mutated every move and needs settable properties for serialisation. - Why does
GameEnginereference no other project? — So that the console app, the tests, the JSON and EF data layers and the web app can all reference it without cycles; anything the engine needed from the UI would break that. - How does
CheckWinavoid scanning the whole board? — Only the piece just placed can complete a line, so it counts outward from that cell in four directions, both ways, and compares the total toWinLength. - What is the purpose of
IMoveProvider? — The game loop asks it for the next move without knowing whether a human or an AI answers; A5 adds a minimax provider and the loop stays the same. - Where does the cylinder variant change the code? — Only in the column arithmetic of the win check (
WrapCol), plus a walk limit so a full ring cannot loop forever. - Why is Nine Men's Morris different from the other four games? — Its board is a graph of 24 points with adjacency and mill tables, not a grid, and a move can be multi-step (place or move, then capture), so the state needs a phase and a pending-capture flag.
- Which behaviours must the A2 tests cover? — Legal and illegal moves, turn switching, a win in every direction, a non-win one short of the length, the draw, and every mandatory custom-rule extension of your game.