Skip to main content

08.2 - Classical Code Patterns

Recap

08.1 - Repository & DI introduced the first two patterns you will use in every .NET project: Repository and Dependency Injection. This lecture is the catalogue of the others that a game solution actually needs — each one as a problem, a short C# example in our game context, and a note on when not to reach for it.

By the end of this lecture you should be able to:

  • State the five SOLID principles with one game example each.
  • Recognise Strategy, Factory, Command, State, Observer, Template Method, Facade and Builder in code, and name the problem each one solves.
  • Use IMoveProvider as a Strategy to plug human, random and minimax players into the same game loop.
  • Explain why a static Singleton is a problem and what to use instead.
  • Spot over-engineering: a pattern applied where a method would do.
Demo code

Lecture demos: csharp-2026-fall

SOLID on one screen

PrincipleIn the game
SSingle Responsibility — one reason to changeGameBrain knows the rules, GameRepositoryJson knows files, ConsoleUI draws. A brain that also writes JSON changes when either the rules or the file format change.
OOpen/Closed — open for extension, closed for modificationA new AI level is a new IMoveProvider class, not another if inside the game loop.
LLiskov Substitution — a subtype must work wherever the base type is expectedEvery IGameRepository follows the same behaviour table; the contract tests from lecture 08.1 are LSP made executable.
IInterface Segregation — small, focused interfacesIConfigRepository and IGameRepository are separate. A page that only lists configurations does not depend on game persistence.
DDependency Inversion — depend on abstractionsLecture 40. ConsoleUI depends on interfaces from GameEngine, never on DAL.EF.

Patterns are the reusable shapes that these principles produce. The Gang of Four catalogued 23 of them in 1994; you will meet about nine in this project.

Strategy

Problem. The game loop needs a move from "whoever is on turn". Sometimes that is a person typing coordinates, sometimes a random AI, sometimes a minimax search. Without a pattern the loop grows a switch on player type that has to be edited every time a new kind of player is added.

Solution. One interface, one implementation per behaviour, and the loop only ever sees the interface.

public interface IMoveProvider
{
(int Row, int Col) GetMove(GameState state);
}

public class HumanConsoleMoveProvider : IMoveProvider
{
public (int Row, int Col) GetMove(GameState state)
{
var legal = new GameBrain(state).GetLegalMoves();
while (true)
{
Console.Write($"{state.NextMoveBy} - row,col: ");
var parts = (Console.ReadLine() ?? "").Split(',');
if (parts.Length == 2
&& int.TryParse(parts[0], out var row)
&& int.TryParse(parts[1], out var col)
&& legal.Contains((row, col)))
{
return (row, col);
}
Console.WriteLine("Illegal move, try again.");
}
}
}

public class RandomAiMoveProvider(Random random) : IMoveProvider
{
public (int Row, int Col) GetMove(GameState state)
{
var legal = new GameBrain(state).GetLegalMoves();
return legal[random.Next(legal.Count)];
}
}

public class MinimaxMoveProvider(int depth, IEvaluator evaluator) : IMoveProvider
{
public (int Row, int Col) GetMove(GameState state) =>
new MinimaxSearch(evaluator, depth).BestMove(state);
}

The loop does not care who is playing:

var providers = new Dictionary<EGamePiece, IMoveProvider>
{
[EGamePiece.X] = new HumanConsoleMoveProvider(),
[EGamePiece.O] = new MinimaxMoveProvider(depth: 4, new ConnectFourEvaluator()),
};

var brain = new GameBrain(state);
while (brain.CheckWin() == EGamePiece.Empty && !brain.IsDraw())
{
var (row, col) = providers[state.NextMoveBy].GetMove(state);
brain.MakeMove(row, col);
}

Human vs human, human vs AI and AI vs AI are three dictionaries, not three loops. The web app gets the same interface with a WebFormMoveProvider that reads the move from the posted form.

Difficulty levels are strategies too. Easy is RandomAiMoveProvider; Medium is minimax at depth 2 that plays a random move one time in five; Hard is depth 6 with a time budget. Three registrations, zero ifs in the search code.

When not to use it. When there is exactly one behaviour and no test needs a substitute. An interface with one implementation forever is a file you scroll past.

Factory

Problem. Somebody has to turn "player 2 is an AI at level 3" into a MinimaxMoveProvider(depth: 6, ...). If that decision sits in the menu code, it also sits in the web page, and the two drift.

Solution. One method that owns the mapping from description to object.

public enum EPlayerType { Human, Ai }

public static class MoveProviderFactory
{
public static IMoveProvider Create(EPlayerType type, int difficulty, Random? random = null) =>
(type, difficulty) switch
{
(EPlayerType.Human, _) => new HumanConsoleMoveProvider(),
(EPlayerType.Ai, <= 1) => new RandomAiMoveProvider(random ?? Random.Shared),
(EPlayerType.Ai, 2) => new MinimaxMoveProvider(depth: 2, new SimpleEvaluator()),
(EPlayerType.Ai, _) => new MinimaxMoveProvider(depth: 6, new SimpleEvaluator()),
};
}

public static class GameFactory
{
public static GameState Create(GameConfiguration config) => new()
{
Id = Guid.NewGuid(),
Board = Enumerable.Range(0, config.BoardHeight)
.Select(_ => new EGamePiece[config.BoardWidth])
.ToArray(),
NextMoveBy = EGamePiece.X,
Config = config,
Moves = [],
CreatedAtUtc = DateTime.UtcNow,
};
}

GameFactory.Create is the only place that knows how an empty board looks. Tests, console and web all start games through it, so nobody forgets to set CreatedAtUtc.

When not to use it. When the choice is made in one place already — the composition root from lecture 08.1 is a factory. And the DI container is a factory for everything it registers; do not wrap it in another one.

Command

Problem. Minimax needs to try a move, search below it, and take it back — thousands of times per second. The UI wants an "undo" menu item. Both need the same thing: a move that knows how to reverse itself.

Solution. Wrap the move in an object with Execute and Undo.

public interface ICommand
{
void Execute();
void Undo();
}

public sealed class MoveCommand(GameState state, int row, int col) : ICommand
{
private EGamePiece _mover = EGamePiece.Empty;

public void Execute()
{
_mover = state.NextMoveBy;
state.Board[row][col] = _mover;
state.Moves.Add((row, col));
state.NextMoveBy = _mover == EGamePiece.X ? EGamePiece.O : EGamePiece.X;
}

public void Undo()
{
state.Board[row][col] = EGamePiece.Empty;
state.Moves.RemoveAt(state.Moves.Count - 1);
state.NextMoveBy = _mover;
}
}

In the search, Execute / Undo replaces cloning the board:

foreach (var (row, col) in brain.GetLegalMoves())
{
var cmd = new MoveCommand(state, row, col);
cmd.Execute();
var score = -Negamax(state, depth - 1, -beta, -alpha);
cmd.Undo();
// ... alpha-beta bookkeeping
}

In the UI, a Stack<ICommand> of executed commands gives you "undo last move" in four lines.

When not to use it. When undo is not cheap. In Reversi a move flips a variable number of pieces; the command has to remember all of them, and a board clone may be simpler and just as fast. Measure before deciding — the performance video in Week 10 shows how.

State

Problem. Nine Men's Morris has phases: placing pieces, moving them along lines, flying anywhere when a player is down to three, and finished. Tic-Tac-Two has "place a piece" and "move the grid" phases. The naive version is an EGamePhase enum and a switch on it in GetLegalMoves, in MakeMove, in CheckWin and in the renderer. Add one phase and you edit four switches.

Solution. Each phase is a class that knows its own legal moves and which phase comes next.

public enum EGamePhase { Placing, Moving, Flying, Finished }

public sealed record MorrisMove(int? From, int To);

public interface IGamePhase
{
EGamePhase Kind { get; }
List<MorrisMove> GetLegalMoves(MorrisState state);
IGamePhase Apply(MorrisState state, MorrisMove move);
}

public sealed class PlacingPhase : IGamePhase
{
public EGamePhase Kind => EGamePhase.Placing;

public List<MorrisMove> GetLegalMoves(MorrisState state) =>
state.EmptyPoints().Select(p => new MorrisMove(null, p)).ToList();

public IGamePhase Apply(MorrisState state, MorrisMove move)
{
state.Place(state.NextMoveBy, move.To);
return state.PiecesInHand(EGamePiece.X) + state.PiecesInHand(EGamePiece.O) == 0
? new MovingPhase()
: this;
}
}

The brain holds IGamePhase Phase and delegates: Phase = Phase.Apply(state, move). Adding "flying" is a new class, not four new case labels. Persist only the Kind enum in GameState; rebuild the phase object on load.

When not to use it. Games with one phase — Tic-Tac-Toe, Connect Four, Gomoku — need a bool for "finished" and nothing else. Two phases with two switches are still readable; the pattern earns its keep from three phases up.

Observer, the C# way: events

Problem. After every move the console must redraw, a log line should be written, and later the web page should be notified. The brain must not know about any of them.

Solution. C# has the Observer pattern built into the language: event.

public sealed record MoveMadeEventArgs(int Row, int Col, EGamePiece By);

public class GameBrain
{
public event EventHandler<MoveMadeEventArgs>? MoveMade;

public void MakeMove(int row, int col)
{
var mover = _state.NextMoveBy;
// ... validate and apply
MoveMade?.Invoke(this, new MoveMadeEventArgs(row, col, mover));
}
}

// ConsoleUI subscribes; the brain does not know who is listening
brain.MoveMade += (_, e) => BoardRenderer.Draw(state);
brain.MoveMade += (_, e) => Console.Title = $"Last move: {e.By} at {e.Row},{e.Col}";

?.Invoke handles the "nobody subscribed" case. Subscribers are called in registration order, on the thread that raised the event.

When not to use it. When there is exactly one listener that you control — return a value instead. And unsubscribe (-=) when the subscriber's life is shorter than the publisher's, or the publisher keeps it alive forever.

Template Method

Problem. Every grid game has the same move skeleton — validate, place, record, switch player — and differs only in what counts as legal and what counts as a win.

Solution. The skeleton lives in an abstract base; the varying steps are virtual or abstract.

public abstract class GameBrainBase(GameState state)
{
protected GameState State { get; } = state;

public bool TryMakeMove(int row, int col)
{
if (!IsLegal(row, col)) return false;
Apply(row, col);
State.Moves.Add((row, col));
State.NextMoveBy = Opponent(State.NextMoveBy);
return true;
}

protected virtual bool IsLegal(int row, int col) => State.Board[row][col] == EGamePiece.Empty;
protected virtual void Apply(int row, int col) => State.Board[row][col] = State.NextMoveBy;
public abstract EGamePiece CheckWin();
public virtual bool IsDraw() =>
CheckWin() == EGamePiece.Empty && State.Board.All(r => r.All(c => c != EGamePiece.Empty));

protected static EGamePiece Opponent(EGamePiece p) => p == EGamePiece.X ? EGamePiece.O : EGamePiece.X;
}

public sealed class ConnectFourBrain(GameState state) : GameBrainBase(state)
{
// gravity: only the lowest empty cell of a column is legal
protected override bool IsLegal(int row, int col) =>
base.IsLegal(row, col)
&& (row == State.Board.Length - 1 || State.Board[row + 1][col] != EGamePiece.Empty);

public override EGamePiece CheckWin() => LineChecker.Winner(State.Board, State.Config.WinLength);
}

When not to use it. You implement one game. A base class with a single subclass is inheritance for its own sake. Template Method also fixes the skeleton at compile time; when the variation is a pluggable piece (an evaluator, a move provider), Strategy is more flexible and easier to test.

Facade and Adapter

Facade puts one simple door in front of several rooms. Console and web both need "start a game from a configuration", "make a move and persist it", "resume a saved game". Without a facade each front-end repeats the repository-brain-repository choreography.

public class GameService(IGameRepository games, IConfigRepository configs)
{
public GameState StartNew(string configName)
{
var state = GameFactory.Create(configs.Get(configName));
games.Save(state);
return state;
}

public GameState MakeMove(Guid id, int row, int col)
{
var state = games.Get(id);
new GameBrain(state).MakeMove(row, col);
games.Save(state);
return state;
}
}

Register it in the container and both ConsoleUI and WebApp call the same three methods. Your tests test the facade once.

Adapter makes an existing class fit an interface it was not written for. You will write one in lecture 10.2, when the synchronous IMoveProvider has to be presented as its asynchronous variant without rewriting the human provider.

When not to use them. A facade with one method that forwards to one class is a rename. Keep the facade where three or more collaborators meet.

Singleton — and why you should not

The textbook Singleton guarantees one instance through a static property:

public sealed class GameRepositoryJson
{
public static GameRepositoryJson Instance { get; } = new("data/games");
private GameRepositoryJson(string directory) { /* ... */ }
}

Every class that writes GameRepositoryJson.Instance has a hidden dependency that no constructor reveals, no test can replace, and no configuration can redirect to another folder. It is global state with a design-pattern name.

What you want is one instance, not a static one. The container gives you exactly that:

services.AddSingleton<IGameRepository>(new GameRepositoryJson(Path.Combine(dataDir, "games")));

Still a single instance, but it arrives through the interface and the constructor, so tests substitute it and lecture 08.1's config switch can replace it.

Repository and Unit of Work

Covered in 08.1 - Repository & DI; the one-paragraph recap is: a Repository hides where data lives behind a domain-shaped interface, and a Unit of Work groups the writes of one operation into one commit. In our solution IGameRepository is the repository and DbContext.SaveChanges() at the end of each repository method is the unit of work. When several repositories must commit together, SaveChanges() moves into a separate class that owns the context — the web applications course goes there.

Builder, briefly

Problem. A GameConfiguration has validation rules (the win length cannot exceed the board) and presets (Tic-Tac-Toe, Connect Four, Gomoku). Constructor calls with four positional integers are easy to get wrong.

public sealed class GameConfigurationBuilder
{
private string _name = "Custom";
private int _width = 3, _height = 3, _winLength = 3;

public GameConfigurationBuilder Named(string name) { _name = name; return this; }
public GameConfigurationBuilder Board(int width, int height) { _width = width; _height = height; return this; }
public GameConfigurationBuilder WinLength(int n) { _winLength = n; return this; }

public GameConfiguration Build()
{
if (_winLength > Math.Max(_width, _height))
throw new InvalidOperationException("WinLength cannot exceed the board size");
return new GameConfiguration(_name, _width, _height, _winLength);
}
}

public static class Presets
{
public static GameConfiguration TicTacToe => new("Tic-Tac-Toe", 3, 3, 3);
public static GameConfiguration ConnectFour =>
new GameConfigurationBuilder().Named("Connect Four").Board(7, 6).WinLength(4).Build();
public static GameConfiguration Gomoku =>
TicTacToe with { Name = "Gomoku", BoardWidth = 15, BoardHeight = 15, WinLength = 5 };
}

When not to use it. Look at Gomoku above: a record with a with expression already gives you readable, named construction. A builder pays off when there are many optional parts or validation that spans several fields — a four-property record rarely qualifies.

Pattern smells

Over-engineering

Patterns are vocabulary, not a scoring system. Warning signs that you are applying them for their own sake:

  • An interface with one implementation and no test that fakes it.
  • A factory that is called from exactly one place.
  • An abstract base class with one subclass.
  • A GameManagerServiceFactoryProvider. If the name lists three patterns, the class does none of them well.
  • A 200-line project with eight projects and twelve folders.

The rule of three: write it once, write it twice, and on the third copy extract the pattern. Every pattern in this lecture was discovered in code that had the problem, not planned in advance. Your A5 needs Strategy, Factory and probably Command; add the rest when the code asks for them.

Self preparation QA

  1. Give the five SOLID principles with one example each from the game. — S: brain vs repository vs UI. O: new AI level is a new class. L: every repository passes the same contract tests. I: separate config and game repositories. D: UI depends on interfaces in GameEngine.
  2. How does Strategy remove the switch from the game loop? — The loop holds an IMoveProvider per player; human, random and minimax are implementations. Adding a level adds a class, not a branch.
  3. What is the difference between Factory and the DI container? — Both create objects from a description. The container does it for registered types by constructor matching; a factory encodes a domain decision (difficulty → provider) that the container cannot know. Do not wrap one in the other.
  4. Why does minimax like the Command pattern?Execute / Undo lets the search try and revert moves on one board without cloning, which is cheaper when undo is O(1).
  5. When does the State pattern beat an enum plus switch? — From about three phases up, or when each phase has its own legal-move logic. For one- or two-phase games the enum is clearer.
  6. How is Observer implemented in C#? — With event and EventHandler<T>; the publisher raises via ?.Invoke, subscribers attach with += and must detach with -= if they live shorter than the publisher.
  7. Why is a static Singleton a problem, and what replaces it? — Hidden global dependency, no substitution in tests, no configuration. A container singleton (AddSingleton) gives one instance through the interface and the constructor.
  8. Name three smells of over-engineering. — One-implementation interfaces without a fake, factories with one caller, base classes with one subclass.