Most people think Tic-Tac-Toe is trivial. A 3×3 grid, two symbols, done in 25 lines. But what happens when you need to detect a winner across 8 possible lines? When the game needs distinct states — you can't place a mark after someone wins? When players want to undo moves? When you add an AI opponent with three difficulty levels? That 25-line toy becomes a real design problem with real patterns hiding inside it.
We're going to build this game 7 times — each time adding ONE constraintA real-world requirement that forces your code to evolve. Each constraint is something the GAME needs, not a technical exercise. "Players need undo" is a constraint. "Use the Command pattern" is not — that's a solution you DISCOVER. that breaks your previous code. You'll feel the pain, discover the fix, and understand WHY the design exists. By Level 7, you'll have a complete, production-grade game engine — and a set of reusable thinking tools that work for any system.
The Constraint Game — 7 Levels
L0: Place a Mark
L1: Detect a Winner
L2: Game States
L3: Undo / Redo
L4: AI Opponent
L5: Edge Cases
L6: Testability
L7: Tournament
The System Grows — Level by Level
Each level adds one constraint. Here's a thumbnail of how the class diagram expands from 1 class to a full game engine:
What You'll Build
System
Production-grade Tic-Tac-Toe with game statesThe game behaves differently depending on its current mode: during InProgress, moves are accepted. During Won, they're rejected. The State pattern encapsulates each mode's behavior into its own class., undo/redoEach move is stored as a Command object that knows how to execute itself AND reverse itself. A stack of commands enables undo (pop and reverse) and redo (re-execute)., AI opponents, error handling, and DI.
Patterns
StateLets an object change its behavior when its internal state changes. The game delegates actions (PlaceMark, Undo) to a state object, and each state class defines what's allowed in that mode., CommandEncapsulates a request as an object. Each move becomes a MoveCommand with Execute() and Undo() methods, enabling move history, replay, and reversal., StrategyDefines a family of interchangeable algorithms. The AI uses IPlayerStrategy — Random picks any empty cell, Smart blocks winning moves, Minimax guarantees optimal play., Result<T>A functional error handling pattern that returns either a success value or an error message, instead of throwing exceptions for business logic. Makes error paths explicit and compiler-visible.
Skills
Real-world walkthrough, state machine thinkingModeling a system as a set of states (InProgress, Won, Draw) with explicit transitions between them. This prevents impossible operations (like placing a mark after the game is over) at the type level., "What Varies?" for AI, Command for undo, CREATESThe 7-step interview framework: Clarify → Requirements → Entities → API → Trade-offs → Edge cases → Scale. Works for every LLD problem.
Before we touch a single line of code, let's play an actual game of Tic-Tac-Toe. Not on a computer — on a piece of paper. Watch what happens step by step, and pay attention to every thing (noun) and every action (verb) you encounter. These become your classes, methods, and data types.
Think First #1 — Close your eyes and mentally play a full game of Tic-Tac-Toe from empty board to "X Wins!" List every THING (noun) you encounter and every ACTION (verb) that happens. Don't peek below for 60 seconds.
Nouns you probably found: Board, Cell, Mark (X or O), Player, Move, Winner, Draw, Game. Verbs: Place a mark, Check for winner, Switch turns, Declare result, Start new game.
Every noun is a candidate entityIn LLD, an entity is a real-world concept that becomes a class, record, or enum in your code. Boards, players, and moves are entities because they hold data and have identity.. Every verb is a candidate method. This is noun extractionA systematic technique for finding entities: read the problem description, highlight every noun, and evaluate which ones need to exist in your code. Not every noun becomes a class — some become fields, some become enums, some are irrelevant. — and it works for ANY system, not just games.
Stage 1: Set Up the Board
What you SEE: An empty 3×3 grid — nine cells, all blank. One player is assigned X, the other gets O. Someone decides who goes first.
What happens behind the scenes: The board is initialized. Two players are created with their marks. A turn order is established (X always goes first by convention).
Design insight: We already found 4 nouns — Board (a 2D structure with positions), Cell (a single position that can be empty or hold a mark), Player (someone who places marks), and Mark (X or O — a category with no behavior, perfect for an enumAn enum (enumeration) is a type that represents a fixed set of named constants. Mark.X and Mark.O have no behavior — they're just labels. Enums are perfect when you have a small, fixed set of categories that don't need methods or state.).
Stage 2: Take Turns
What you SEE: The current player picks an empty cell and draws their mark in it. Then it's the other player's turn.
What happens behind the scenes: A move is made — someone places a mark at a specific position. The system needs to validate the cell is actually empty. Then it switches turns.
Design insight: New noun: Move. A move has data — who (which player), where (row, column), when (turn number). It never changes after it happens. That makes it an immutable recordIn C#, a record is an immutable reference type perfect for data that never changes after creation. Records auto-generate Equals(), GetHashCode(), and ToString(). A move that happened at (1,2) on turn 3 will always be that — it's a fact, not a mutable thing..
Stage 3: Check for Winner
What you SEE: After each move, you scan the board. Three X's in a row? Three O's in a column? A diagonal? If yes — that player wins. If the board is full with no winner — it's a drawIn Tic-Tac-Toe, a draw (also called a "cat's game") happens when all 9 cells are filled but neither player has three in a row. In a game between two perfect players, every game ends in a draw..
What happens behind the scenes: There are exactly 8 possible winning lines on a 3×3 board: 3 rows + 3 columns + 2 diagonals. After every move, the system checks all 8. The checking algorithm is the same every time — only the data (which line to check) varies.
Design insight: New concept: WinCondition. The 8 winning lines are data, not code. You could hardcode 8 if-else blocks, or you could store the 8 line definitions in an array and loop through them. Treating rules as data is a powerful technique — adding a new win condition means adding data, not writing new code.
Stage 4: Game Over — What Happens Next?
What you SEE: "X Wins!" or "It's a Draw!" is declared. The game is over. No more marks can be placed. Players decide whether to play again, review the game, or quit.
What happens behind the scenes: The game transitions from "in progress" to "finished." Any attempt to place another mark should be rejected. The game has modes — and what you can do depends entirely on which mode you're in.
Design insight: The game has states — Not Started, In Progress, Won, Draw, Resigned. Each state defines which operations are valid. During "In Progress," placing a mark is fine. During "Won," it's not. When one object behaves completely differently based on its current mode, and you see yourself writing if (state == X) in every method, there's a pattern for that. You'll discover it in Level 2.
What We Discovered
Discovery
Real World
Code
Type
Board
3×3 grid of cells
Board
class (mutable state)
Mark
X or O symbol
Mark
enum (fixed categories)
Move
Placing a mark at (row, col)
Move
record (immutable fact)
Player
Person choosing where to play
Player
class (has strategy)
Win Condition
Three in a row/col/diagonal
WinLine
record (data configuration)
Game Result
X wins, O wins, or draw
GameResult
enum (outcome category)
Game State
Playing, Won, Draw modes
IGameState
interface (State pattern)
AI Difficulty
Easy, Medium, Unbeatable
IPlayerStrategy
interface (Strategy pattern)
Skill Unlocked: Real-World Walkthrough
Walk through the physical system before coding. List every noun (= entity) and verb (= method). This gives you a requirements checklist AND a starter class diagram — for free. Works for parking lots, elevators, chess, vending machines, anything.
Section 3 🟢 EASY
Level 0 — Place a Mark
Constraint: "Two players take turns placing X and O on a 3×3 board."
This is where it all begins. The simplest possible version — no win detection, no validation, no AI. Just: put an X or O on the board, take turns, done. We'll feel the pain of missing features soon enough.
Every complex system starts with a laughably simple version. For Tic-Tac-Toe, that means: a board that holds marks, and a way to place them. That's it. No winner checking, no error handling, no nothing. The goal of Level 0 is to get something working — then let the next constraint break it.
Think First #2
What's the simplest data structure to represent a 3×3 board? What single method places a mark? How do you alternate turns? Take 60 seconds.
60 seconds — try it before peeking.
Reveal Answer
A 2D arrayA grid stored in memory as rows and columns. In C#, Mark[3,3] gives you a 3-row, 3-column grid where you access cells with board[row, col] — exactly how you'd point at a Tic-Tac-Toe cell.Mark[3,3] for the board — row and column map directly to physical positions. A PlaceMark(row, col) method to put a mark on the board. And a bool _isXTurn that flips after each move so players alternate. ~15 lines total.
Your Internal Monologue
"OK, simplest thing possible... A 2D array? char[3,3]? Or maybe a flat array of 9... No, 2D feels natural — row and column map directly to positions on the board."
"For the marks — should I use char with 'X' and 'O'? That works, but an enumA type with a fixed set of named values. enum Mark { None, X, O } means a cell can only ever be empty, X, or O — the compiler won't let you accidentally assign 'Z' or any other invalid value. is safer. Mark.X, Mark.O, Mark.None. The compiler catches mistakes instead of me."
"For turns... a boolean? _isXTurn — flip it after each move. No validation, no win checking — just the absolute minimum. I can feel this is incomplete, but that's the point."
What Would You Do?
FlatArray.cs
// Flat array approach: 9 cells in a row
private readonly Mark[] _board = new Mark[9];
public void PlaceMark(int row, int col)
{
int index = row * 3 + col; // math to convert 2D → 1D
_board[index] = CurrentMark;
_isXTurn = !_isXTurn;
}
// Reading cell (1,2) means: _board[1 * 3 + 2] = _board[5]
// Not terrible, but you always need the row*3+col formula.
The catch: It works, but every access requires row * 3 + col index math. You're fighting the data structure instead of letting it help you. When you think "row 1, column 2," you want to write board[1, 2], not board[5].
TwoDimensionalArray.cs
// 2D array approach: rows and columns are explicit
private readonly Mark[,] _board = new Mark[3, 3];
public void PlaceMark(int row, int col)
{
_board[row, col] = CurrentMark; // direct mapping!
_isXTurn = !_isXTurn;
}
// Reading cell (1,2) means: _board[1, 2]
// Row and column are right there in the code.
Why this wins: The code reads like the domain. "Place mark at row 1, column 2" becomes _board[1, 2] — no translation needed. The data structure mirrors the real-world grid. When your code naturally matches how you think about the problem, bugs have fewer places to hide.
Here's the complete Level 0 code. Read every line — there are only 15 of them.
TicTacToeGame.cs — Level 0
public enum Mark { None, X, O }
public sealed class TicTacToeGame
{
private readonly Mark[,] _board = new Mark[3, 3];
private bool _isXTurn = true;
public Mark CurrentMark => _isXTurn ? Mark.X : Mark.O;
public void PlaceMark(int row, int col)
{
_board[row, col] = CurrentMark;
_isXTurn = !_isXTurn;
}
}
Let's walk through what each piece does:
Mark enum — three possible values for any cell: empty (None), X, or O. Using an enum instead of raw characters means the compiler catches typos for us.
_board — a 3×3 grid of Mark values. Every cell starts as Mark.None (the default for enums in C#).
_isXTurn — a simple boolean toggle. true means it's X's turn, false means O's turn. Starts with X (convention: X always goes first).
CurrentMark — a computed propertyA property that doesn't store a value — it calculates one on the fly. CurrentMark checks _isXTurn and returns Mark.X or Mark.O. No stored state to get out of sync. that reads _isXTurn and returns the right mark. No separate stored state to get out of sync.
PlaceMark() — puts the current player's mark on the board, then flips the turn. Two lines. That's the entire game loop at this level.
15 lines. It works for a toy game. But can you spot what's missing? There's no win detection, no draw detection, no validation of moves, and players can overwrite each other's marks. We'll feel each of these pains in the coming levels.
Board Coordinate System
Turn Alternation — How _isXTurn Works
Growing Diagram — After Level 0
Class Diagram — Level 0
Before This Level
You see "Tic-Tac-Toe" and think "this is too simple to design."
After This Level
You know to start with the stupidest possible version — then feel the pain of each missing constraint.
Transfer: This "start with the dumbest thing that works" approach isn't unique to games. In an Elevator System, Level 0 would be: one elevator, one button, go to a floor. In a Parking Lot: one lot, park a car, return a fee. The pattern is universal — build the skeleton first, then let constraints shape the design.
Section 4 🟢 EASY
Level 1 — Detect a Winner
New Constraint: "The game must detect when someone wins or when it's a draw."
What Breaks?
Our Level 0 has no concept of winning. Players can keep placing marks forever — even after three in a row. The game never ends.
Try it: place X at (0,0), (0,1), (0,2) — X has three in a row, but O can still move. Place marks on top of existing marks. Fill the entire board and keep going. The game is fundamentally broken because it doesn't know what "winning" means.
A game without an ending isn't really a game. Right now our code is just a grid with a toggle — there's no finish line, no winner announcement, no "game over." We need to teach the code how to see three in a row, and how to recognize when the board is full with no winner.
Think First #3
There are 8 ways to win on a 3×3 board: 3 rows, 3 columns, 2 diagonals. Design an algorithm that checks all 8 after every move. Should you hardcode the 8 lines or generate them dynamically?
60 seconds — think about maintainability.
Reveal Answer
Store the 8 winning lines as data — an array of three-cell coordinate triples. Then iterate through them with a single loop. The checking algorithm is the SAME every time; only the DATA (which three cells to check) changes. Adding a new winning condition = adding one entry to the array, not writing a new if-else branch.
Your Internal Monologue
"8 win lines... I could write 8 separate if-statements. if (board[0,0] == board[0,1] && board[0,1] == board[0,2]) for the first row, then repeat for the other 7. That's explicit, but that's 30+ lines of nearly identical code."
"Or I could store the 8 line definitions as data — an array of three-cell tuplesA tuple groups multiple values together without creating a full class. In C#, (int Row, int Col) is a named tuple — lightweight and perfect for coordinate pairs. Three of these describe one winning line. — and loop through them. The loop is the SAME every time; only the DATA changes. That feels cleaner."
"And if somehow the board grew to 4×4 or 5×5, I'd just update the data, not rewrite the algorithm. ...Wait, YAGNI"You Aren't Gonna Need It" — a principle that says don't build features for hypothetical future requirements. But here, treating rules as data isn't extra work — it's actually LESS code than 8 if-statements. So it's good design, not premature engineering.. It's 3×3 right now. But treating rules as data is still the right instinct — it's actually less code anyway."
"For the result, I need more than just 'winner found.' The game could be in progress, X could win, O could win, or it could be a draw. That's four states. An enum GameResult with those four values."
The 8 Winning Lines
Every possible way to win — three of the same mark in a straight line. 3 rows + 3 columns + 2 diagonals = 8 total.
What Would You Do?
HardcodedChecks.cs
public Mark? CheckWinner()
{
// Row 0
if (_board[0,0] != Mark.None && _board[0,0] == _board[0,1] && _board[0,1] == _board[0,2])
return _board[0,0];
// Row 1
if (_board[1,0] != Mark.None && _board[1,0] == _board[1,1] && _board[1,1] == _board[1,2])
return _board[1,0];
// Row 2
if (_board[2,0] != Mark.None && _board[2,0] == _board[2,1] && _board[2,1] == _board[2,2])
return _board[2,0];
// ... 3 more for columns, 2 more for diagonals
// Total: 8 nearly identical if-blocks. 30+ lines.
return null;
}
Consequence: Works perfectly for 3×3. But every check is a copy-paste with different indices. Spot a bug in one? You have to fix all 8. This is the kind of code that works today and hurts tomorrow.
ManualLoops.cs
public Mark? CheckWinner()
{
// Check rows
for (int r = 0; r < 3; r++)
if (_board[r,0] != Mark.None && _board[r,0] == _board[r,1] && _board[r,1] == _board[r,2])
return _board[r,0];
// Check columns
for (int c = 0; c < 3; c++)
if (_board[0,c] != Mark.None && _board[0,c] == _board[1,c] && _board[1,c] == _board[2,c])
return _board[0,c];
// Diagonals — still special cases
if (_board[0,0] != Mark.None && _board[0,0] == _board[1,1] && _board[1,1] == _board[2,2])
return _board[0,0];
if (_board[0,2] != Mark.None && _board[0,2] == _board[1,1] && _board[1,1] == _board[2,0])
return _board[0,2];
return null;
}
Better, but: Rows and columns are now loop-driven (good!), but the two diagonals are still hardcoded as special cases. Three different code shapes for the same logical operation: "check if three cells match." Can we unify them?
WinLineData.cs
// Define the 8 winning lines as DATA
private static readonly (int R, int C)[][] WinLines =
[
[(0,0),(0,1),(0,2)], // row 0
[(1,0),(1,1),(1,2)], // row 1
[(2,0),(2,1),(2,2)], // row 2
[(0,0),(1,0),(2,0)], // col 0
[(0,1),(1,1),(2,1)], // col 1
[(0,2),(1,2),(2,2)], // col 2
[(0,0),(1,1),(2,2)], // diagonal ↘
[(0,2),(1,1),(2,0)], // diagonal ↙
];
// ONE loop checks them ALL
public Mark? CheckWinner()
{
foreach (var line in WinLines)
{
var a = _board[line[0].R, line[0].C];
if (a != Mark.None && a == _board[line[1].R, line[1].C]
&& a == _board[line[2].R, line[2].C])
return a;
}
return null;
}
Why this wins: The algorithm (loop + compare three cells) is written once. The 8 winning patterns are pure data — an array you can read, test, and extend. Rows, columns, and diagonals are all treated identically. No special cases. The same approach works if you later needed to support a 4×4 or 5×5 board — you'd only change the data array.
Here's the complete Level 1 code. We add a WinLinerecordA record in C# is an immutable data container — once created, its values can't change. Perfect for things like coordinate triples that represent fixed winning lines. Records also get free equality comparison, so two WinLines with the same coordinates are automatically equal., a GameResult enum, and a CheckResult() method that examines all 8 lines.
TicTacToeGame.cs — Level 1
public enum Mark { None, X, O }
public enum GameResult { InProgress, XWins, OWins, Draw }
public record WinLine((int Row, int Col) A, (int Row, int Col) B, (int Row, int Col) C);
public sealed class TicTacToeGame
{
private readonly Mark[,] _board = new Mark[3, 3];
private bool _isXTurn = true;
// All 8 winning lines — stored as data, not code
private static readonly WinLine[] WinLines =
[
new((0,0),(0,1),(0,2)), new((1,0),(1,1),(1,2)), new((2,0),(2,1),(2,2)), // rows
new((0,0),(1,0),(2,0)), new((0,1),(1,1),(2,1)), new((0,2),(1,2),(2,2)), // cols
new((0,0),(1,1),(2,2)), new((0,2),(1,1),(2,0)) // diags
];
public Mark CurrentMark => _isXTurn ? Mark.X : Mark.O;
public void PlaceMark(int row, int col)
{
_board[row, col] = CurrentMark;
_isXTurn = !_isXTurn;
}
public GameResult CheckResult()
{
// Check each winning line
foreach (var line in WinLines)
{
var a = _board[line.A.Row, line.A.Col];
if (a != Mark.None
&& a == _board[line.B.Row, line.B.Col]
&& a == _board[line.C.Row, line.C.Col])
{
return a == Mark.X ? GameResult.XWins : GameResult.OWins;
}
}
// No winner — check for draw (all cells filled)
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++)
if (_board[r, c] == Mark.None)
return GameResult.InProgress;
return GameResult.Draw;
}
}
Let's walk through the new pieces:
GameResult enum — four possible outcomes. The game is either still going (InProgress), someone won (XWins / OWins), or every cell is filled with no winner (Draw). These are the only possible states a Tic-Tac-Toe game can be in.
WinLine record — holds three cell coordinates. Each instance represents one of the 8 ways to win. The record is immutableOnce a WinLine is created, you can't change its coordinates. This is intentional — winning lines are facts about the game's rules. They never change at runtime. Immutability makes your code safer because nothing can accidentally modify game rules mid-game. — winning rules don't change mid-game.
WinLines array — all 8 winning patterns stored as data. The first three entries are rows, the next three are columns, the last two are diagonals. But the checking code doesn't care which is which — it treats them all the same.
CheckResult() — iterates through all 8 lines. For each line, it reads the first cell (a). If a isn't empty AND all three cells match, we have a winner. If no line wins and no empty cells remain, it's a draw. Otherwise, the game continues.
Win Detection Algorithm Flow
Code vs. Data — Same Result, Different Maintainability
Both approaches produce identical results. The difference is in what happens when you need to change, debug, or extend them.
Growing Diagram — After Level 1
Class Diagram — Level 1
Before This Level
You'd manually check if board[0,0] == board[0,1] && board[0,1] == board[0,2] for each of the 8 lines.
After This Level
You model win conditions as data (an array of line definitions) and iterate. Adding a new win pattern = adding one entry, not a new if-else.
Smell → Pattern: Data as Configuration — When the same checking logic repeats for different inputs, extract the inputs as data. The algorithm stays the same; only the data varies. You'll see this pattern everywhere: validation rules, routing tables, permission matrices, tax brackets. The what lives in data; the how lives in code.
Transfer: This "rules as data" approach shows up in surprising places. A Chess engine stores piece movement rules as coordinate offsets. A routing framework stores URL patterns as data and matches them with one generic algorithm. A tax calculator stores brackets as data and applies one formula. Wherever you find repeated if-else blocks checking different values, ask: "Can I extract the values into a data array and write one loop?"
Section 5
Level 2 — Game States 🟡 MEDIUM
New Constraint: The game has distinct modes — NotStarted, InProgress, Won, Draw, Resigned. You can't place a mark after someone wins.
What breaks: Our Level 1 code checks for a winner but doesn't prevent further moves. After X wins by getting three in a row, O can still call PlaceMark(). The game happily continues past its own ending — accepting marks on a board that should be frozen. In a real application, this means corrupted game data and confused users staring at a board that says "X wins!" while O keeps playing.
Think First #4 — pause and design before you see the answer
The game acts differently depending on its current state. During "InProgress," placing a mark is valid. During "Won," it's invalid. During "NotStarted," it should tell you to start first.
How would you implement this so that adding a new state (like "Paused" for an online game) requires zero changes to existing state logic? Spend 30 seconds thinking before scrolling.
Your inner voice:
"I could add if (_state == GameState.InProgress) at the top of every method... but that means every method has state-checking boilerplate. And if I add a new state like 'Paused' for online play, I'd have to modify 5 methods. That violates OCPThe Open/Closed Principle says code should be open for extension but closed for modification. Adding new behavior should mean adding new code, not changing existing code.."
"Wait — the game's behavior depends on its state. Different state, different behavior for the same operation. That sounds like... the State patternThe State pattern lets an object change its behavior when its internal state changes. Instead of if-else chains checking state, each state becomes its own class with its own behavior. The object delegates to the current state object.. Each state becomes its own class. PlaceMark() in InProgressState does the real work. PlaceMark() in WonState returns an error. Adding 'Paused' means one new class, zero changes to existing ones."
What Would You Do?
Three developers, three ideas. Read all three, then see which one survives.
The idea: Create a GameState enum with values like InProgress, Won, Draw, Resigned. Then add a switch statement at the top of every method.
EnumSwitch.cs — switch in every method
public GameResult PlaceMark(int row, int col)
{
switch (_state)
{
case GameState.InProgress:
_board[row, col] = _currentMark;
ToggleTurn();
return CheckResult();
case GameState.Won:
return GameResult.AlreadyOver;
case GameState.Draw:
return GameResult.AlreadyOver;
case GameState.NotStarted:
return GameResult.NotStarted;
default:
throw new InvalidOperationException();
}
}
// Same switch block in Undo(), Reset(), Resign()...
Verdict: Works for 3 states. But every method now has a 15-line switch block. And when you add "Paused" for online play? You touch every method. At 6 states and 5 methods, you're maintaining 30 case branches. That's a maintenance nightmare.
The idea: Use boolean flags — _isOver, _isDraw, _isResigned. Check them at the start of each method.
BooleanFlags.cs — combinatorial explosion
private bool _isStarted = false;
private bool _isOver = false;
private bool _isDraw = false;
private bool _isResigned = false;
public GameResult PlaceMark(int row, int col)
{
if (!_isStarted) return GameResult.NotStarted;
if (_isOver) return GameResult.AlreadyOver;
if (_isDraw) return GameResult.AlreadyOver;
if (_isResigned) return GameResult.AlreadyOver;
// ... actual logic buried under flag checks
}
Verdict: What does _isOver = true, _isDraw = true, _isResigned = true mean? Nonsense — you can't be both a draw and resigned. Booleans create impossible combinations that your code has to handle. With 4 booleans, there are 16 possible combinations but only 5 are valid states. The other 11 are bugs waiting to happen.
The idea: Each state becomes its own class. The game holds a reference to its current state object. Every method delegates to that state. Switching states means swapping the object.
StatePattern.cs — one class per state
// The game delegates to its current state
public GameResult PlaceMark(int row, int col)
=> _currentState.PlaceMark(this, row, col);
// InProgressState handles the real work
// WonState returns "game over"
// Adding PausedState = one new class, zero existing changes
Verdict:This is the winner. Zero switch statements. Zero boolean flags. Each state knows exactly what's valid during that phase. Adding "Paused" for online play? Write one class, plug it in, done. No existing code changes.
The Solution
The State patternA behavioral design pattern where an object delegates its behavior to a state object. When the state changes, the behavior changes automatically. Each state is its own class implementing a common interface. gives every game mode its own class. The game itself doesn't contain any if-else logic — it just asks its current state "what should I do?"
IGameState.cs — the contract every state must follow
public interface IGameState
{
GameResult PlaceMark(TicTacToeGame game, int row, int col);
bool CanPlaceMark { get; }
string Status { get; }
}
This interface is tiny on purpose. Every state must answer three questions: can the player place a mark right now? What happens if they try? And what's the game's status message?
InProgressState.cs — the only state where real play happens
public sealed class InProgressState : IGameState
{
public bool CanPlaceMark => true;
public string Status => "Game in progress";
public GameResult PlaceMark(TicTacToeGame game, int row, int col)
{
// Place the mark and switch turns
game.Board[row, col] = game.CurrentMark;
game.ToggleTurn();
// Check if this move ended the game
var result = game.CheckResult();
if (result == GameResult.XWins || result == GameResult.OWins)
game.TransitionTo(new WonState(result));
else if (result == GameResult.Draw)
game.TransitionTo(new DrawState());
return result;
}
}
Notice that InProgressState is the only state that actually modifies the board. It places the mark, toggles the turn, then checks whether the game just ended. If it did, it transitions the game to the appropriate terminal state. The state decides the next state — this is the core of the State pattern.
WonState.cs + DrawState.cs — terminal states that reject all moves
public sealed class WonState(GameResult result) : IGameState
{
public bool CanPlaceMark => false;
public string Status => $"{result} — game over";
public GameResult PlaceMark(TicTacToeGame game, int row, int col)
=> result; // Silently reject — game is over
}
public sealed class DrawState : IGameState
{
public bool CanPlaceMark => false;
public string Status => "Draw — game over";
public GameResult PlaceMark(TicTacToeGame game, int row, int col)
=> GameResult.Draw; // Board is full, nothing to do
}
public sealed class NotStartedState : IGameState
{
public bool CanPlaceMark => false;
public string Status => "Waiting to start";
public GameResult PlaceMark(TicTacToeGame game, int row, int col)
=> GameResult.NotStarted; // Can't play before starting
}
Each terminal state is beautifully simple — they all say "no, you can't do that" in their own way. WonState remembers who won (it keeps the result). DrawState and NotStartedState just return fixed responses. No if-else anywhere.
TicTacToeGame.cs — the game delegates everything to its state
public class TicTacToeGame
{
private IGameState _currentState = new NotStartedState();
public GameResult PlaceMark(int row, int col)
=> _currentState.PlaceMark(this, row, col);
public bool CanPlaceMark => _currentState.CanPlaceMark;
public string Status => _currentState.Status;
public void TransitionTo(IGameState newState)
=> _currentState = newState;
public void Start()
=> _currentState = new InProgressState();
}
Look how clean the game class is. PlaceMark() is one line. CanPlaceMark is one line. The game doesn't know or care which state it's in — it just delegates. This is the magic of the State pattern: the complexity lives in the state classes, and the main class stays simple forever.
Diagrams
State Machine — all the modes your game lives in
Every game starts in NotStarted, moves to InProgress when someone calls Start(), and then either ends in Won, Draw, or Resigned. Once it reaches a terminal state, it stays there — no going back.
Enum + Switch vs. State Pattern
On the left: every method contains a switch over all states. On the right: each state is its own class with a single clean implementation. Adding a new state (dashed box) is the real test — left requires modifying every method, right requires adding one file.
How States Block Invalid Operations
When PlaceMark() is called, the behavior depends entirely on which state object is active. InProgressState does the work, everything else rejects it.
Growing Diagram — Level 2
Our class diagram keeps growing. The game now holds a reference to IGameState (dashed = interface), and four concrete states implement it.
Before / After Your Brain
Before This Level
You see "different behavior by state" and think "add if-else checks everywhere."
After This Level
You smell "mode-dependent behavior" and instinctively reach for the State pattern — one class per state, zero if-else chains.
Smell → Pattern:Mode-Dependent Behavior — When an object acts completely differently depending on what "mode" or "phase" it's in, and you find yourself writing if (state == X) in every method → State pattern. Each state becomes a class that defines behavior for that mode. No switch statements, no boolean flags, no impossible combinations.
Transfer: Same technique in a Vending Machine: Idle → HasMoney → Dispensing → OutOfStock. Each state defines what the buttons do. Press "Dispense" during Idle? Error. During HasMoney? Check stock and dispense. Same technique in a Document Workflow: Draft → Review → Published → Archived. Each state controls which operations are allowed.
Section 6
Level 3 — Undo / Redo 🟡 MEDIUM
New Constraint: Players can undo their last move and redo it. A game can be replayed move-by-move from its history.
What breaks: Our Level 2 has no concept of history. When you call PlaceMark(), it writes directly to the board. There's no way to go back. Want to undo? You'd have to remember what was in that cell before — but you didn't save it. The board has amnesia. Every move is permanent and irreversible.
Think First #5 — pause and design before you see the answer
How would you design an undo system? The naive approach — save a full copy of the board before every move — wastes memory (9 cells × N moves). The smart approach treats each move as a reversible object.
What data does each move need to undo itself? Think about it: if you know "Player X placed a mark at row 1, column 2," then undoing means "clear row 1, column 2." The move already contains enough information to reverse itself.
Your inner voice:
"I need to remember every move... A stack? Push on each move, pop on undo. But what IS a move? It's data: 'Player X placed mark at (1,2) on turn 3.' To undo: clear cell (1,2) and switch turns back."
"The move knows how to undo itself. That's the Command patternThe Command pattern turns a request (like "place a mark") into an object. That object stores all the data needed to execute the request AND reverse it. This is why undo/redo systems almost always use Commands — each action is a self-contained, reversible object.. Each move is a command object with Execute() and Undo(). The game history is just two stacks — an undo stack and a redo stack."
"When a new move happens: execute it and push to the undo stack. When the player hits undo: pop from the undo stack, reverse it, push to the redo stack. When they hit redo: pop from the redo stack, re-execute, push back to undo. Clean."
What Would You Do?
Two approaches to "remembering the past." One saves everything, the other saves just enough.
The idea (MementoThe Memento pattern saves a snapshot of an object's entire internal state so you can restore it later. Think of it like saving a game — you dump the whole world to a file. Simple but can be memory-heavy for large or frequent saves. approach): Before every move, save a complete copy of the board. Undo means "restore the previous snapshot." Simple, right?
SnapshotApproach.cs — save the whole board every time
// Before every move, clone the entire board
var snapshot = (Mark[,])_board.Clone();
_history.Push(snapshot);
// Undo = restore the previous snapshot
_board = _history.Pop();
// Memory: 9 cells copied per move
// After 5 moves: 5 × 9 = 45 cell values stored
Verdict: It works — and for a 3×3 board, the memory cost is trivial. But the technique doesn't scale. Chess has 64 cells. Go has 361. A complex app might have megabytes of state. Saving the entire world before every tiny change is wasteful when the change itself is tiny. There's a smarter way.
The idea (Command approach): Don't save the whole board. Instead, treat each move as a tiny object that knows exactly two things: how to do itself and how to undo itself.
CommandApproach.cs — store just the change, not the whole world
// Each move: 3 fields (who, row, col)
var move = new MoveCommand(Mark.X, 1, 2);
move.Execute(board); // Place X at (1,2)
move.Undo(board); // Clear (1,2)
// Memory: 3 fields per move
// After 5 moves: 5 × 3 = 15 values stored
// Replay: re-execute all commands from empty board
Verdict: This is the winner. Each command is just 3 fields — the mark, the row, and the column. Undo is the inverse operation: clear the cell. Two stacks (undo + redo) give you full time-travel. And replay is free: start from an empty board and re-execute every command in order. This scales to chess, to drawing apps, to text editors — anywhere you need undo.
The Solution
The Command patternA behavioral design pattern that turns a request into a standalone object containing all information about the request. This lets you parameterize methods with different requests, delay or queue requests, and support undoable operations. turns every move into a self-contained object. The object knows what happened (which mark, where) and can reverse it. A GameHistory class manages two stacks to coordinate undo and redo.
MoveCommand.cs — a move that knows how to undo itself
public sealed record MoveCommand(Mark Mark, int Row, int Col)
{
public void Execute(Board board)
=> board[Row, Col] = Mark; // Place the mark
public void Undo(Board board)
=> board[Row, Col] = Mark.None; // Clear the cell
}
That's the entire command. A recordIn C#, a record is an immutable reference type. It auto-generates equality checks, hashing, and a nice ToString(). Perfect for value-like objects that shouldn't change after creation — like a move that happened at a specific position. with three fields and two methods. Execute() places the mark. Undo() clears it. Because Tic-Tac-Toe cells are always empty before a move, undoing simply means setting the cell back to Mark.None. No need to store "what was there before" — we know it was empty.
GameHistory.cs — two stacks for time-travel
public sealed class GameHistory
{
private readonly Stack<MoveCommand> _undoStack = new();
private readonly Stack<MoveCommand> _redoStack = new();
public void Execute(MoveCommand command, Board board)
{
command.Execute(board);
_undoStack.Push(command);
_redoStack.Clear(); // New move invalidates redo history
}
public MoveCommand? Undo(Board board)
{
if (_undoStack.Count == 0) return null;
var command = _undoStack.Pop();
command.Undo(board);
_redoStack.Push(command);
return command;
}
public MoveCommand? Redo(Board board)
{
if (_redoStack.Count == 0) return null;
var command = _redoStack.Pop();
command.Execute(board);
_undoStack.Push(command);
return command;
}
public IReadOnlyList<MoveCommand> GetHistory()
=> _undoStack.Reverse().ToList();
}
Let's walk through what happens step by step:
Execute: Run the command's Execute() on the board, then push it onto the undo stack. Crucially, clear the redo stack — because making a new move after undoing means the old "future" is no longer valid.
Undo: Pop the most recent command from the undo stack, call its Undo() to reverse the effect, then push it onto the redo stack (so the player can redo it if they change their mind).
Redo: Pop from the redo stack, re-execute it, push it back to the undo stack. It's like the move never left.
GetHistory: Returns all executed moves in order — perfect for replaying the game from scratch or saving to a file.
Diagrams
How the Two Stacks Work Together
Think of two stacks sitting side by side. Every action moves a command between them. New moves go to the undo stack. Undo pops from undo and pushes to redo. Redo pops from redo and pushes back to undo.
Replay from History — a filmstrip of moves
Because each move is a command, you can replay an entire game by starting with an empty board and re-executing each command in order. This is how game replays work in chess apps and drawing programs.
Memory: Snapshots vs. Commands
Here's why commands win as systems grow. Snapshots save the entire state every time. Commands save only the delta — what changed.
Growing Diagram — Level 3
Two new pieces join our system: MoveCommand (an immutable record that knows how to execute and undo itself) and GameHistory (the stack manager that coordinates undo and redo).
Before / After Your Brain
Before This Level
You see "undo" and think "save a copy of the whole thing before every change."
After This Level
You model each change as a Command that knows how to reverse itself. Undo = pop + reverse. Replay = re-execute from empty.
Smell → Pattern:Undo/Redo Required — When actions need to be reversible and you need a history of what happened → Command pattern. Each action becomes an object that knows how to Execute() AND Undo() itself. Two stacks give you full time-travel.
Transfer: Same technique in a Text Editor: each keystroke or delete is a Command object. Ctrl+Z pops the undo stack. Same in a Drawing App: each shape drawn is a Command. Undo removes it, redo re-draws it. Same in Database Migrations: each migration has an Up() and Down() method — that's the Command pattern too.
Section 7
Level 4 — AI Opponent 🟡 MEDIUM
New Constraint: "Add a computer player that picks moves. Players should be able to choose between Easy, Medium, and Hard difficulty."
What breaks: Our game assumes both players are human. Every call to PlaceMark() waits for user input — row and column from the keyboard. There's no concept of "the computer decides where to go." Worse, if we hardcode AI logic into the game loop, we can never swap between difficulty levels without rewriting the game. And what if tomorrow we want an online player, or a replay bot? The game is glued to "human types a coordinate" and nothing else.
Think First #6 — pause and design before you see the answer
The game needs to ask "where do you want to play?" — but the answer comes from completely different sources. A human reads the board and types a coordinate. A random AI picks any empty cell. A smart AI analyzes every possible future move.
All three do the same job — "pick a cell" — but the algorithm is different each time. How would you design this so swapping from Easy to Hard AI means changing one line of code, not rewriting the game loop?
Your inner voice:
"I need different ways to pick a move. A human picks by typing. A dumb AI picks randomly. A smart AI uses some algorithm. But the game doesn't care how the move is chosen — it just needs a row and a column."
"That's the key insight — the game wants a move, and different strategies produce that move in different ways. If I hide the 'how' behind an interface, the game just calls GetMove() and doesn't care whether a human typed it or an AI computed it."
"That's the Strategy patternThe Strategy pattern defines a family of algorithms, puts each one in its own class, and makes them interchangeable. The caller picks which strategy to use, but the code that uses the strategy doesn't change. Think of it like choosing a route on a GPS — fastest, shortest, or scenic — the GPS app doesn't care which algorithm you picked.. Each way of picking a move becomes its own class. The game holds a reference to the current strategy. Swapping from random AI to minimax AI means changing which strategy object is assigned — one line."
What Would You Do?
Three developers, three ideas for adding AI. One survives the "add a new difficulty" test.
The idea: Write the AI logic directly inside the game loop. When it's the computer's turn, pick a random empty cell.
HardcodedAI.cs — AI baked into the game loop
// Inside the game loop
if (isComputerTurn)
{
// AI logic lives right here in the game loop
var emptyCells = board.GetEmptyCells();
var random = new Random();
var pick = emptyCells[random.Next(emptyCells.Count)];
PlaceMark(pick.Row, pick.Col);
}
else
{
// Human input
var (row, col) = ReadFromConsole();
PlaceMark(row, col);
}
Verdict: Works for one difficulty. But the AI logic is inside the game loop. Want to add "Hard" difficulty? You'd stuff a minimax algorithm right into the same if-block. Want "Medium"? Another branch. The game loop becomes an AI textbook. And you can never reuse these AI algorithms in a different game — they're trapped inside this one file.
The idea: Extract the AI into a method, but use if-else to pick the right algorithm based on a difficulty enum.
IfElseDifficulty.cs — one method, many branches
public (int Row, int Col) GetAiMove(Difficulty level)
{
if (level == Difficulty.Easy)
{
// Random pick
return RandomEmptyCell();
}
else if (level == Difficulty.Medium)
{
// Win if possible, else random
return TryWinOrRandom();
}
else if (level == Difficulty.Hard)
{
// Full minimax search
return Minimax(board, depth: 0, isMaximizing: true);
}
throw new ArgumentException("Unknown difficulty");
}
Verdict: Better — at least the AI is separated from the game loop. But every difficulty lives in one method. Adding "Expert" or "Tutorial" means modifying this method. And the easy/medium/hard logic can't be unit tested independently — it's all tangled together. You've just moved the if-else from the game loop into a helper method. The shape of the problem hasn't changed.
The idea: Define an interface — IPlayerStrategy — with one method: GetMove(). Each way of picking a move (human input, random AI, minimax AI) becomes its own class. The game holds a strategy for each player and just calls GetMove().
StrategyPattern.cs — plug in any algorithm
// The game doesn't know WHO is playing — just asks for a move
var move = _currentPlayer.Strategy.GetMove(board);
PlaceMark(move.Row, move.Col);
// Human? Reads from console.
// Easy AI? Random empty cell.
// Hard AI? Minimax algorithm.
// All implement IPlayerStrategy.GetMove()
Verdict: This is the winner. The game loop is one line: ask the current player's strategy for a move. Adding "Expert AI" means writing one new class — zero changes to the game, zero changes to existing strategies. Each strategy can be tested in isolation. And the same strategies work in Chess, Checkers, Connect Four — anywhere a player needs to pick a move.
The Solution
The Strategy patternA behavioral design pattern that lets you define a family of algorithms, put each in its own class, and make them interchangeable at runtime. The object using the algorithm doesn't know or care which strategy it's running. separates "how a move is chosen" from "what happens when a move is made." The game asks for a move. The strategy delivers one. That's the entire contract.
IPlayerStrategy.cs — the one method every player type must implement
public interface IPlayerStrategy
{
(int Row, int Col) GetMove(Board board);
}
That's it — one method. Given the current board, return a position. Whether that position comes from a human typing at a keyboard, a random number generator, or a tree-search algorithm that evaluates thousands of possible futures — the game doesn't know, and it doesn't need to.
HumanStrategy.cs — asks the player for input
public sealed class HumanStrategy : IPlayerStrategy
{
public (int Row, int Col) GetMove(Board board)
{
Console.Write("Enter row and column (e.g. 1 2): ");
var parts = Console.ReadLine()!.Split(' ');
return (int.Parse(parts[0]), int.Parse(parts[1]));
}
}
The human strategy is straightforward — read from the console and parse two numbers. Nothing fancy. The important thing is that it implements the same interface as every AI strategy, so the game treats humans and computers identically.
RandomStrategy.cs — Easy AI, picks any empty cell
public sealed class RandomStrategy : IPlayerStrategy
{
private readonly Random _rng = new();
public (int Row, int Col) GetMove(Board board)
{
var empty = board.GetEmptyCells();
return empty[_rng.Next(empty.Count)];
}
}
The easy AI just grabs a list of all empty cells and picks one at random. It's not smart, but it's a perfectly valid opponent for casual play. And because it's its own class, you can unit test it: give it a board with one empty cell and verify it picks that cell. Clean and isolated.
MinimaxStrategy.cs — Hard AI, unbeatable
public sealed class MinimaxStrategy(Mark aiMark) : IPlayerStrategy
{
public (int Row, int Col) GetMove(Board board)
{
var (_, move) = Minimax(board, isMaximizing: true);
return move;
}
private (int Score, (int Row, int Col) Move) Minimax(
Board board, bool isMaximizing)
{
// Base cases: someone won or board is full
if (board.HasWinner(aiMark)) return (+10, (-1, -1));
if (board.HasWinner(Opponent())) return (-10, (-1, -1));
if (board.IsFull()) return (0, (-1, -1));
var bestScore = isMaximizing ? int.MinValue : int.MaxValue;
var bestMove = (-1, -1);
foreach (var cell in board.GetEmptyCells())
{
// Try this move
board[cell.Row, cell.Col] = isMaximizing ? aiMark : Opponent();
var (score, _) = Minimax(board, !isMaximizing);
board[cell.Row, cell.Col] = Mark.None; // Undo
if (isMaximizing ? score > bestScore : score < bestScore)
{
bestScore = score;
bestMove = (cell.Row, cell.Col);
}
}
return (bestScore, bestMove);
}
private Mark Opponent() => aiMark == Mark.X ? Mark.O : Mark.X;
}
Here's where things get interesting. MinimaxA decision-making algorithm used in two-player games. It simulates every possible future move, assumes both players play perfectly, and picks the move that maximizes the AI's chances while minimizing the opponent's. For Tic-Tac-Toe (only ~9! possible games), it's fast enough to search the entire tree. is a classic AI technique. The idea is simple: imagine every possible future. If the AI moves here, what's the best the opponent can do? And then what's the best the AI can do after that? It plays out every scenario and picks the move that leads to the best guaranteed outcome.
For Tic-Tac-Toe, this makes the AI literally unbeatable. The game tree is small enough (at most 9! = 362,880 positions) that minimax can search every single possibility. The AI will always win or draw — never lose.
Player.cs + Game setup — plugging strategies in
public class Player(string name, Mark mark, IPlayerStrategy strategy)
{
public string Name => name;
public Mark Mark => mark;
public IPlayerStrategy Strategy => strategy;
}
// Human vs Easy AI
var playerX = new Player("Alice", Mark.X, new HumanStrategy());
var playerO = new Player("Bot", Mark.O, new RandomStrategy());
// Human vs Unbeatable AI
var playerO = new Player("Bot", Mark.O, new MinimaxStrategy(Mark.O));
// AI vs AI (for testing / simulation)
var playerX = new Player("Easy", Mark.X, new RandomStrategy());
var playerO = new Player("Hard", Mark.O, new MinimaxStrategy(Mark.O));
Look at how easy it is to configure different matchups. Human vs human, human vs AI, AI vs AI — it's all the same game loop. The only thing that changes is which strategy each player holds. This is the power of the Strategy pattern: the game is completely decoupled from the decision-making algorithm.
Diagrams
How Strategy Selection Works at Runtime
When it's a player's turn, the game asks the player for a move. The player delegates to its strategy. The strategy does the work and returns a position. The game never knows (or cares) what algorithm was used.
How Minimax Thinks — the Decision Tree
Minimax explores every possible future by building a tree. At each level, it alternates between maximizing (AI picks its best move) and minimizing (opponent picks their best response). Scores bubble up from the leaves — +10 for an AI win, -10 for a loss, 0 for a draw.
Strategy Pattern UML
The Player holds a reference to IPlayerStrategy. At game setup, you assign the right strategy — human, random, or minimax. The game loop never changes.
Growing Diagram — Level 4
Our design keeps expanding. The Player class is new — it holds a name, a mark, and a strategy. The game now manages two Player objects instead of raw marks.
Before / After Your Brain
Before This Level
You see "multiple algorithms for the same job" and think "add if-else or switch on a difficulty enum."
After This Level
You smell "swappable algorithm" and instinctively reach for the Strategy pattern — one interface, multiple implementations, swap at runtime with one line.
Smell → Pattern:Swappable Algorithm — When the same operation can be performed in multiple ways, and you want to choose which way at runtime without modifying the code that uses it → Strategy pattern. Define an interface for the algorithm. Each variation becomes its own class. Swap strategies by changing which object you inject.
Transfer: Same technique in Payment Processing: IPaymentStrategy with CreditCardPayment, PayPalPayment, CryptoPayment. The checkout page calls strategy.ProcessPayment(amount) without knowing which provider is handling it. Same in Sorting: ISortStrategy with QuickSort, MergeSort, BubbleSort. Same in Compression: Zip, Gzip, Brotli — all implement one interface.
Section 8
Level 5 — Edge Cases 🔴 HARD
New Constraint: "Handle every invalid input gracefully. No crashes, no cryptic exceptions, no silent failures. Tell the user exactly what went wrong."
What breaks: Our code is a minefield of silent assumptions. Pass row = 5 to a 3×3 board? IndexOutOfRangeException. Place a mark on an occupied cell? The old mark gets silently overwritten — no error, just corruption. Call PlaceMark() after the game is over? Depends on which state we're in, but the caller doesn't get a clean error message. The HumanStrategy parses console input with int.Parse() — type "hello" and watch the whole app crash with a FormatException. Every method is a happy path. The unhappy paths are landmines.
Think First #7 — pause and design before you see the answer
There are two broad approaches to error handling. One is exceptions — throw when something goes wrong, catch somewhere up the call stack. The other is return values — the method tells the caller whether it succeeded or failed, and why.
Exceptions are great for unexpected failures (disk is full, network is down). But "user typed 5 on a 3×3 board" isn't unexpected — it's a normal part of using the program. Should we really throw an exception because someone made a typo?
Think about: how would you design a return type that says "here's the result if it worked, or here's what went wrong if it didn't" — without using exceptions for expected failures?
Your inner voice:
"Every public method needs to handle bad input. But I don't want try-catch blocks in every method — that's noisy and exceptions are expensive for things we expect to happen (like typing a wrong number)."
"I could return error codes — like int where 0 = success, 1 = out of bounds, 2 = cell taken. But then the caller has to remember what each number means. And the actual result (like 'X wins') has to come through a separate channel. Ugly."
"What if the method returns a single object that says either 'here's your result' OR 'here's what went wrong'? Something like Result<T> — it holds a success value or an error message, never both. The caller checks IsSuccess and either uses the value or reads the error. No exceptions for expected failures. No magic numbers. No split channels."
What Would You Do?
Three ways to handle errors. One is heavy, one is cryptic, and one is just right.
The idea: Validate nothing upfront. Let errors happen naturally, catch them, and convert them into user-friendly messages.
TryCatchEverywhere.cs — exception as control flow
public string PlaceMark(int row, int col)
{
try
{
_board[row, col] = _currentMark; // May throw IndexOutOfRange
var result = CheckWinner(); // May throw anything
ToggleTurn();
return $"Placed at ({row},{col}). {result}";
}
catch (IndexOutOfRangeException)
{
return "Row and column must be 0-2.";
}
catch (InvalidOperationException ex)
{
return ex.Message;
}
catch (Exception ex)
{
return $"Something went wrong: {ex.Message}";
}
}
Verdict: This "works" but it's backwards. You're letting the error happen, then catching it after the damage. Exceptions are expensiveIn .NET, throwing an exception captures a full stack trace, which is significantly slower than a normal return. Throwing exceptions for expected situations (like bad user input) can be 100-1000x slower than validating upfront and returning a result. in .NET — each one captures a stack trace. Using exceptions for expected situations (bad input is not exceptional — it's normal!) is like setting off the fire alarm every time you burn toast. It's the wrong tool for the job.
The idea: Return an integer error code. 0 = success, 1 = out of bounds, 2 = cell occupied, 3 = game over. The actual result comes through an out parameter.
ErrorCodes.cs — C-style error handling
public int PlaceMark(int row, int col, out GameResult result)
{
result = default;
if (row < 0 || row > 2 || col < 0 || col > 2)
return 1; // Out of bounds... but what's 1 again?
if (_board[row, col] != Mark.None)
return 2; // Cell taken... or was that 3?
_board[row, col] = _currentMark;
result = CheckWinner();
ToggleTurn();
return 0; // Success
}
// Caller has to remember the codes
var code = game.PlaceMark(r, c, out var result);
if (code == 1) Console.WriteLine("Out of bounds");
else if (code == 2) Console.WriteLine("Cell taken");
else Console.WriteLine(result);
Verdict: This is how C programs handled errors in the 1970s. The caller has to remember what each magic number means. The actual result (GameResult) travels through a separate out parameter, split from the error code. And nothing forces the caller to check the error code — they can happily ignore it and use the uninitialized result. It works, but it's fragile and hard to read.
The idea: Create a Result<T> type that holds either a success value or an error message. The method validates upfront, and returns a clean result that the caller must handle.
ResultPattern.cs — success or failure, never ambiguous
public Result<GameResult> PlaceMark(int row, int col)
{
if (row < 0 || row > 2 || col < 0 || col > 2)
return Result<GameResult>.Fail("Position must be 0-2.");
if (_board[row, col] != Mark.None)
return Result<GameResult>.Fail("That cell is already taken.");
_board[row, col] = _currentMark;
ToggleTurn();
return Result<GameResult>.Ok(CheckWinner());
}
// Caller: clean and obvious
var result = game.PlaceMark(r, c);
if (result.IsSuccess)
Console.WriteLine(result.Value);
else
Console.WriteLine($"Error: {result.Error}");
Verdict: This is the winner. No exceptions for expected failures. No magic numbers. One return type that clearly says "it worked, here's the value" or "it failed, here's why." The caller can't accidentally ignore the error because Value and Error are right there on the same object. This is a widely used pattern in modern C#, Rust (Result<T, E>), and functional programming.
The Solution
The Result patternA pattern where methods return a Result object instead of throwing exceptions for expected failures. The Result holds either a success value or an error message. This forces the caller to handle both cases explicitly, making error handling visible and predictable. replaces "throw and pray someone catches it" with "return a clear answer that says yes or no." It's especially good for situations where failure is a normal part of the workflow — like user input validation.
Result.cs — a tiny type with big impact
public sealed record Result<T>
{
public bool IsSuccess { get; }
public T? Value { get; }
public string? Error { get; }
private Result(T value)
{
IsSuccess = true;
Value = value;
Error = null;
}
private Result(string error)
{
IsSuccess = false;
Value = default;
Error = error;
}
public static Result<T> Ok(T value) => new(value);
public static Result<T> Fail(string error) => new(error);
}
The constructor is private — you can only create a Result through Ok() or Fail(). This guarantees every Result is in a valid state: either it has a value and no error, or it has an error and no value. You can't accidentally create a Result that's both successful and has an error message. The recordIn C#, a record is an immutable reference type with value-based equality. Using a record here means two Results with the same value are considered equal, and the Result can't be modified after creation — perfect for a "sealed envelope" that contains a verdict. keyword makes it immutable — once created, a Result never changes.
MakeMove() with validation — every edge case handled upfront
public Result<GameResult> MakeMove(int row, int col)
{
// 1. Is the game still going?
if (!_currentState.CanPlaceMark)
return Result<GameResult>.Fail(
$"Can't play — {_currentState.Status}");
// 2. Is the position on the board?
if (row < 0 || row > 2 || col < 0 || col > 2)
return Result<GameResult>.Fail(
$"Position ({row},{col}) is out of bounds. Use 0-2.");
// 3. Is the cell empty?
if (_board[row, col] != Mark.None)
return Result<GameResult>.Fail(
$"Cell ({row},{col}) is already taken by {_board[row, col]}.");
// All checks passed — execute the move
var command = new MoveCommand(_currentMark, row, col);
_history.Execute(command, _board);
ToggleTurn();
var result = CheckResult();
if (result == GameResult.XWins || result == GameResult.OWins)
TransitionTo(new WonState(result));
else if (result == GameResult.Draw)
TransitionTo(new DrawState());
return Result<GameResult>.Ok(result);
}
Every possible failure is caught before the move happens. The validations read like a checklist: Is the game still in progress? Is the position valid? Is the cell empty? Only when all three pass do we actually modify the board. If any check fails, we return a friendly error message — no exceptions thrown, no state corrupted, no partial updates. The board is untouched until we're certain the move is legal.
Game loop — clean error handling without try-catch
while (game.IsInProgress)
{
var (row, col) = currentPlayer.Strategy.GetMove(game.Board);
var result = game.MakeMove(row, col);
if (result.IsSuccess)
{
game.PrintBoard();
Console.WriteLine($"Result: {result.Value}");
game.SwitchPlayer();
}
else
{
// Error message is right there — no guessing
Console.WriteLine(result.Error);
// Don't switch player — let them try again
}
}
The game loop is beautifully clear. Ask for a move. Try it. If it worked, show the board and switch players. If it failed, show the error and let the same player try again. No try-catch. No error codes. No null checks. The Result type makes the two paths (success and failure) explicit and impossible to miss.
Diagrams
Error Flow — Before vs. After Result<T>
On the left, errors happen unpredictably — exceptions fly, callers crash, users see stack traces. On the right, every error is anticipated and communicated cleanly through the return value.
Anatomy of Result<T>
A Result is like a sealed envelope. When you open it, you find either a present (the value) or a note explaining what went wrong (the error). It can never be both, and it can never be neither.
Growing Diagram — Level 5
One new piece joins the system: Result<T>. It sits at the boundary between the game and the outside world — every public method now returns a Result instead of a raw value. The internal classes stay the same.
Before / After Your Brain
Before This Level
You see "handle errors" and think "wrap everything in try-catch" or "return null when something fails."
After This Level
You distinguish expected failures (bad input) from unexpected failures (disk crash). Expected failures get a Result<T> return. Exceptions are reserved for truly exceptional situations.
Smell → Pattern:Returning null or throwing for expected failures — When a method can fail for reasons that are normal (user typos, validation failures, business rule violations) and you find yourself throwing exceptions or returning null → Result<T>. Return a rich object that clearly says "success with value" or "failure with reason." The compiler won't let the caller forget to check.
Transfer: Same technique in API Validation: a REST endpoint returns Result<User> — either the created user or a list of validation errors. No 500 errors for missing fields. Same in Form Processing: Result<Order> tells the UI exactly which field failed and why. Same in File Parsing: Result<Config> returns either a valid config or "line 42: invalid value for 'timeout'." Rust's entire standard library is built on this pattern with Result<T, E>.
Section 9 🔴 HARD
Level 6 — Make It Testable
New Constraint: "The game engine must be fully unit-testable. A test must be able to set up any board state, control 'randomness' in the AI, control time for timeouts, and verify exact outcomes — with zero flakiness."
What breaks: Our Level 5 code works great in production but falls apart the moment you try to write a test. The RandomStrategy calls Random.Shared — a static globalA static global is a value or object shared across the entire application through a static field. You can't swap it out, you can't control it from outside, and you can't replace it in tests. It's like a light switch wired directly into the wall — works for the house, but try testing the switch in a lab without the wall. — meaning every test run produces different results. If we add move timeouts, DateTime.Now can't be frozen or fast-forwarded. The board is private, so there's no way to set up a mid-game scenario without playing through all the preceding moves. Want to test "Minimax never loses"? You'd need to run thousands of games, but Random is... well, random.
Think First #8
You need to write a test: "Minimax never loses against Random over 1,000 games." You also need a test for "AI move completes in under 100ms." And one for "placing on an occupied cell returns an error." What concrete things in our code would you need to swap out to make these tests deterministic?
60 seconds — think about which "global" things need to become injectable.
Reveal Answer
Three things are hardcoded that need to become injectable:
Hardcoded Thing
Why It's a Problem
What to Inject Instead
Random.Shared
Can't predict or control outputs
IRandomProvider interface
DateTime.Now
Can't freeze or fast-forward time
TimeProvider (built into .NET 8)
Private board state
Can't set up mid-game scenarios
Constructor that accepts initial board, or IBoard interface
The whole game should also live behind an interface — ITicTacToeGame — so controllers and UI don't depend on the concrete class. That way you can swap in a fake game when testing the controller, too.
Your inner voice:
Random.Shared is the root of the problem. It's a static global — I can't mock it, I can't control it, I can't predict it. I need to wrap it behind an interface. Something like IRandomProvider with a single method: Next(max). In production, I inject the real Random. In tests, I inject a FakeRandom that returns predetermined values.
Same story for time — luckily .NET 8 ships TimeProvider built in. I just use TimeProvider.System in production and a FakeTimeProvider in tests. For the board, I need a way to start from any mid-game position — either an IBoard interface or a constructor overload that accepts an initial board.
The pattern here is dead simple: if you can't control it from outside, you can't test it. Every "global" thing becomes an injected dependencyA dependency is any object your class needs to do its work. When the class creates dependencies internally (new Random()), they're hidden and uncontrollable. When they're passed in through the constructor, they're visible and swappable. That's the difference between untestable and testable..
What Would You Do?
Three developers, three approaches to making randomness testable. Read all three, then see which survives.
The idea: Leave Random.Shared in place. Run the test enough times and "it'll probably pass."
OptionA.cs — hardcoded randomness
public sealed class RandomStrategy : IPlayerStrategy
{
public Position ChooseMove(IBoard board)
{
var empty = board.GetEmptyCells();
// 🚫 Random.Shared — can't control from outside
return empty[Random.Shared.Next(empty.Count)];
}
}
Verdict: Every test run gives different results. A test checking "Minimax never loses against Random" might pass 999 times and fail once because Random happened to pick brilliant moves. That's a flaky testA test that sometimes passes and sometimes fails without any code changes. Flaky tests are worse than no tests — they erode trust. Developers stop believing test results and start ignoring real failures hiding among the noise. — worse than no test at all because it destroys trust in your entire test suite.
The idea: Wrap Random in a static helper with a Func<int, int> you can replace in tests.
OptionB.cs — static wrapper with test seam
public static class RandomHelper
{
// "Just set this in tests!" — famous last words
public static Func<int, int> NextFunc = max => Random.Shared.Next(max);
}
public sealed class RandomStrategy : IPlayerStrategy
{
public Position ChooseMove(IBoard board)
{
var empty = board.GetEmptyCells();
return empty[RandomHelper.NextFunc(empty.Count)];
}
}
Verdict: Tests share a global mutable variable. If two tests run in parallel (which modern test runners do by default), they overwrite each other's NextFunc. You've traded randomness-flakiness for parallelism-flakiness. Static mutable state is the enemy of parallel tests.
The idea: Create an IRandomProvider interface. Each strategy receives it through its constructor. In production, pass the real one. In tests, pass a fake with known outputs.
OptionC.cs — dependency injection
public interface IRandomProvider
{
int Next(int maxValue);
}
public sealed class RandomStrategy(IRandomProvider random) : IPlayerStrategy
{
public Position ChooseMove(IBoard board)
{
var empty = board.GetEmptyCells();
return empty[random.Next(empty.Count)]; // ✅ Injected — fully controllable
}
}
Verdict: This is the winner. Each test creates its own IRandomProvider with predetermined outputs. No shared state. No flakiness. Tests run in parallel without interfering. The strategy doesn't know or care whether it's using real randomness or a test double — it just asks for a number.
The Solution — Injectable Everything
The core idea behind Dependency InjectionDependency Injection (DI) means giving an object the things it needs from the outside instead of letting it create them internally. Think of a restaurant: the chef doesn't grow the vegetables — they're delivered (injected). This makes the chef (your class) testable: swap in plastic vegetables (fakes) and verify the chef follows the recipe. is simple: every "global thing" your code touches — randomness, time, the board itself — becomes something you pass in through the constructor. In production you pass real implementations. In tests you pass fakes with predetermined behavior.
// The abstraction — one method, zero knowledge of implementation
public interface IRandomProvider
{
int Next(int maxValue);
}
// Production implementation — delegates to the real Random
public sealed class SystemRandomProvider : IRandomProvider
{
public int Next(int maxValue) => Random.Shared.Next(maxValue);
}
// Test implementation — returns predetermined values in order
public sealed class FakeRandomProvider(Queue<int> values) : IRandomProvider
{
public int Next(int maxValue) => values.Dequeue();
}
// The game interface — controllers depend on this, not the concrete class
public interface ITicTacToeGame
{
GameResult? Result { get; }
IReadOnlyList<MoveCommand> History { get; }
MoveResult MakeMove(int row, int col);
void PlayToCompletion();
}
Program.cs — DI registration (production wiring)
// Production wiring — real randomness, real time
builder.Services.AddSingleton<IRandomProvider, SystemRandomProvider>();
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);
builder.Services.AddTransient<ITicTacToeGame, TicTacToeGame>();
Singleton for randomness and time (one shared instance is fine). Transient for the game (every request gets a fresh game instance). The container handles all the wiring automatically — you never call new TicTacToeGame() yourself.
TicTacToeTests.cs — deterministic tests
[Fact]
public void Minimax_Never_Loses_Against_Random()
{
// Arrange — predetermined "random" moves
var fakeRandom = new FakeRandomProvider(
new Queue<int>([0, 1, 2, 0, 1]));
var game = new TicTacToeGame(
xStrategy: new MinimaxStrategy(),
oStrategy: new RandomStrategy(fakeRandom));
// Act
game.PlayToCompletion();
// Assert — Minimax (X) should never lose
Assert.NotEqual(GameResult.OWins, game.Result);
}
[Fact]
public void Placing_On_Occupied_Cell_Returns_Error()
{
// Arrange — set up a board with X already at (0,0)
var board = new Board();
board.Place(0, 0, Mark.X);
var game = new TicTacToeGame(board, currentTurn: Mark.O);
// Act
var result = game.MakeMove(0, 0); // O tries the same cell
// Assert
Assert.False(result.Success);
Assert.Equal("Cell is already occupied.", result.Error);
}
Notice: no real randomness in either test. The first test controls exactly which cells Random picks via a queue of predetermined values. The second test starts from a specific board state. Both produce the same result every single time — that's the whole point of DI.
Before & After: Dependency Injection
On the left, randomness is baked into the class — sealed shut, untestable. On the right, randomness is passed in — the class is open to any implementation you choose.
How a Deterministic Test Works
The test creates fake versions of every dependency, wires them into the game, runs it, and checks the exact result. No surprises, no randomness, no flakiness — ever.
Growing Diagram — Level 6
Our system gains a testability layer. New abstractions (purple, dashed border) wrap every external dependency so tests can swap them out freely.
Before / After Your Brain
Before L6: "I test by running the game and eyeballing the output. Sometimes the test passes, sometimes it fails. That's just how testing works, right?"
After L6: "Every external dependency is injectable. I control randomness, time, and board state from the test. My tests are deterministic — same input, same output, every single time."
Smell → Pattern: "Need One Instance But Testable"
When you need global-ish state (like a game engine) but also need to mock it in tests — don't use static. Register it as a SingletonA Singleton means only one instance exists in the application. In DI, you register it as AddSingleton<T>() and the container ensures everyone gets the same instance. Unlike a static class, a DI singleton implements an interface — which means tests can replace it with a fake. in the DI container and inject the interface everywhere. You get one instance in production, a fresh fake in every test.
Transfer: Same technique in an E-Commerce system — the payment gateway uses IPaymentProvider. In production, it talks to Stripe. In tests, a FakePaymentProvider always returns "approved." You test the entire order flow without charging real credit cards or hitting real APIs.
Section 10 🔴 HARD
Level 7 — Tournament & Scale
New Constraint: "Multiple games run simultaneously. A tournament system manages brackets, seedings, and leaderboards. Completed games can be replayed move-by-move. Spectators can watch live."
What breaks: Our Level 6 engine is a single game instance. There's no concept of multiple games existing at the same time, no way to track tournament standings, and no mechanism for spectators to get notified when moves happen. We built a perfect single-player engine — but a tournament is a fundamentally different problem: managing many engines at once and keeping everyone in sync.
Think First #9
How would you run 100 simultaneous games without them interfering with each other? When a player makes a move, how would you notify a spectator dashboard instantly? And how would you replay a completed game move-by-move? Sketch out the new classes and responsibilities you'd need beyond the game engine.
90 seconds — sketch the new classes before looking.
Reveal Answer
Three new responsibilities emerge, each needing its own class:
Need
New Class
Responsibility
Manage many games
GameManager
Creates, stores, and retrieves game instances by unique ID
Notify spectators
IGameEventListener
Receives move and game-end events (Observer patternThe Observer pattern lets an object announce "something happened" without knowing who's listening. Any number of listeners can subscribe. Adding a new listener never requires changing the source. Think of a radio station: it broadcasts, and anyone with a receiver can tune in.)
Track brackets
Tournament
Generates bracket pairings, records results, advances winners to next round
And replay? That's already solved. The MoveCommand history from Level 3 is literally a replay log. Create an empty board, apply each command in order, done. The Command pattern pays off again.
Your inner voice:
Multiple games at once... I need a GameManager that creates and tracks game instances by ID. Each game already has its own state and history from previous levels, so isolation is natural — no shared mutable state between games.
But notifications are the tricky part. When a move happens, the spectator UI needs to know. But the game engine shouldn't know about the UI — that would be tight couplingTight coupling means two components know too much about each other. If the game directly calls spectatorUI.Update(), then adding a leaderboard means editing the game class. Loose coupling means the game announces "something happened" and anyone who cares can listen — without the game knowing who they are.. I want the game to just say "hey, something happened" and let anyone who cares react. That's the Observer pattern — emit events, and any number of listeners can subscribe.
For tournaments, I need a bracket — basically a tree of matchups. Round 1 has 4 games. Winners advance to Round 2. Then a final. I'll model it as a list of rounds, each containing matchup records.
And replay? Wait... we already built that! The MoveCommand history from Level 3 is literally a replay log. Fresh board, apply each command in order. The Command pattern just paid for itself again.
What Would You Do?
The key design question: how do spectators learn about moves? Three approaches, only one scales.
The idea: The game directly calls each consumer after every move — dashboard, leaderboard, replay recorder.
OptionA.cs — game calls spectators directly
public sealed class TicTacToeGame
{
private readonly SpectatorDashboard _dashboard;
private readonly Leaderboard _leaderboard;
public void MakeMove(int row, int col)
{
// ... place mark ...
_dashboard.Update(this); // Game knows about UI
_leaderboard.Refresh(this); // Game knows about leaderboard
}
}
Verdict: Every new consumer means editing the game class. Want analytics? Edit the game. Want a replay recorder? Edit the game again. The game becomes a God classA class that knows too much and does too much. It has references to unrelated systems (UI, leaderboard, analytics) that have nothing to do with its core responsibility of managing a game. This violates the Single Responsibility Principle and makes the class fragile. that knows about everything. Adding a feature requires modifying core game logic — the exact opposite of good design.
The idea: Spectators poll the game every 500ms to check for changes.
OptionB.cs — spectators poll for changes
// Spectator dashboard checks every 500ms
while (true)
{
await Task.Delay(500);
var state = game.GetCurrentState();
if (state != lastKnownState)
{
Render(state);
lastKnownState = state;
}
}
Verdict: Wasteful. Tic-Tac-Toe moves happen every few seconds, but the dashboard checks twice per second — hundreds of wasted checks. There's always up to 500ms delay before the spectator sees the move. With 100 tournament games, that's 200 polls per second doing nothing useful most of the time.
The idea: The game emits events. Any number of listeners subscribe. The game never knows WHO is listening or what they do.
OptionC.cs — Observer pattern (event-driven)
public interface IGameEventListener
{
void OnMoveMade(Guid gameId, MoveCommand move);
void OnGameEnded(Guid gameId, GameResult result);
}
// Game doesn't know WHO listens — just announces events
public void Subscribe(IGameEventListener listener)
=> _listeners.Add(listener);
// Anyone can listen — zero coupling to the game
manager.Subscribe(new SpectatorDashboard());
manager.Subscribe(new ReplayRecorder());
manager.Subscribe(new LeaderboardService());
Verdict: This is the winner. The game announces events without knowing who listens. Adding a new consumer is one line: Subscribe(new AnalyticsLogger()). No game code changes. Zero wasted checks. Instant notification. The Observer patternA pattern where an object (the subject) maintains a list of dependents (observers) and notifies them automatically when its state changes. The subject never knows the concrete types of its observers — it only knows the interface. This means new observers can be added without touching the subject's code. decouples the producer from all consumers.
The Solution — GameManager + Tournament + Observer
We need three new pieces: a GameManager to track multiple simultaneous games, an Observer system so spectators get instant updates, and a Tournament class to manage brackets. Let's build them one at a time.
GameManager.cs — managing multiple games
public sealed class GameManager
{
private readonly ConcurrentDictionary<Guid, ITicTacToeGame> _games = new();
private readonly List<IGameEventListener> _listeners = [];
// Create a new game and get back its unique ID
public Guid CreateGame(
IPlayerStrategy? xStrategy = null,
IPlayerStrategy? oStrategy = null)
{
var id = Guid.NewGuid();
var game = new TicTacToeGame(xStrategy, oStrategy);
_games[id] = game;
return id;
}
// Anyone can subscribe to ALL game events
public void Subscribe(IGameEventListener listener)
=> _listeners.Add(listener);
// When a move happens, tell everyone who cares
private void NotifyMoveMade(Guid gameId, MoveCommand move)
{
foreach (var listener in _listeners)
listener.OnMoveMade(gameId, move);
}
private void NotifyGameEnded(Guid gameId, GameResult result)
{
foreach (var listener in _listeners)
listener.OnGameEnded(gameId, result);
}
// Retrieve any game by ID (for spectator viewing)
public ITicTacToeGame? GetGame(Guid id)
=> _games.GetValueOrDefault(id);
}
ConcurrentDictionaryA thread-safe dictionary built into .NET. Multiple threads can read and write simultaneously without locks. It handles all the synchronization internally — perfect for a tournament where multiple games are being created and accessed from different threads at the same time. handles thread safety for game storage. The Observer list lets any number of consumers get notified without the GameManager knowing what they do with the events.
Tournament.cs — bracket management
public sealed class Tournament
{
// A single matchup: two players, optional game, optional winner
public record Matchup(
string PlayerA,
string PlayerB,
Guid? GameId = null,
string? Winner = null);
// Rounds[0] = first round, Rounds[^1] = final
private readonly List<List<Matchup>> _rounds = [];
// Pair players into a single-elimination bracket
public void GenerateBracket(string[] players)
{
var shuffled = players.OrderBy(_ => Random.Shared.Next()).ToArray();
var round = new List<Matchup>();
for (int i = 0; i < shuffled.Length; i += 2)
round.Add(new Matchup(shuffled[i], shuffled[i + 1]));
_rounds.Add(round);
}
// Record a game result and advance the winner
public void RecordResult(Guid gameId, string winner)
{
// Find the matchup, set the winner
// Generate next round when all games in current round are done
}
public IReadOnlyList<IReadOnlyList<Matchup>> GetBracket()
=> _rounds;
}
A tournament is just a list of rounds, where each round is a list of matchups. When a game ends, the winner advances. Simple data structure, powerful result. The bracket is just data — no complex tree traversal needed.
GameReplayService.cs — command history in action
// Replay is FREE — we already store MoveCommands from Level 3!
public static class GameReplayService
{
public static void ReplayGame(IReadOnlyList<MoveCommand> history)
{
var board = new Board();
foreach (var move in history)
{
board.Place(move.Row, move.Col, move.Mark);
Console.WriteLine($"Turn {move.TurnNumber}: {move.Mark} → ({move.Row},{move.Col})");
board.Print();
Thread.Sleep(500); // Pause for dramatic effect
}
}
}
// Usage: replay any completed game
var game = manager.GetGame(gameId);
GameReplayService.ReplayGame(game!.History);
This is the payoff for the Command patternThe Command pattern turns actions into objects. Instead of just executing a move, we create a MoveCommand record that captures WHO placed WHAT mark WHERE on WHICH turn. These command objects can be stored, reversed (undo), replayed (this!), and even sent over a network. from Level 3. Every move was stored as a MoveCommand. Replay is just: create a fresh board, apply each command in sequence. Zero extra infrastructure needed.
Bridge to HLD — How This Scales Beyond One Machine
Everything we've built works on a single server. But what happens when thousands of players join?
LLD (what we built)
HLD (how it scales)
ConcurrentDictionary for games
Redis for distributed game state — any server can serve any game
IGameEventListener (in-memory)
WebSocket connections for real-time spectator updates across clients
MoveCommand history (in-memory list)
Event Sourcing — store commands in a durable log, rebuild any game from history
Tournament (single class)
Matchmaking service — ELO ratings, queue-based pairing, separate microservice
The key insight: good LLD makes HLD easier. Because we used interfaces and events at the LLD level, swapping in distributed implementations is just writing new IGameEventListener adapters — the game engine never changes.
Tournament Bracket — 8-Player Single Elimination
Each round halves the remaining players. Four games in Round 1, two semi-finals, one grand final, one champion.
Observer Notification Flow
The game engine announces events. Any number of listeners react independently. The game has zero knowledge of who's listening or what they do with the information.
Growing Diagram — Level 7 (Complete Architecture)
This is the final picture. From a 20-line script in Level 0 to a full tournament system with observers, strategies, states, commands, and DI. Every single box was discovered by a constraint — not memorized from a textbook.
Before / After Your Brain
Before L7: "I design for one game at a time. Scaling is a different problem for a different day."
After L7: "I bridge from LLD to HLD naturally — multiple game instances via GameManager, Observer for real-time events, and Command history for replay. Good LLD makes HLD easy."
Smell → Pattern: "Notify When Something Happens"
When multiple unrelated systems need to react to the same event, and the event source shouldn't know about its consumers — that's the Observer pattern. Decouple the producer from the consumers. Add new listeners without touching the source.
Transfer: Same technique in every event-driven system. Chat app: message sent → notify recipient, update read receipts, log analytics. E-commerce: order placed → update inventory, charge payment, send confirmation email. The source emits the event and never knows who's listening.
Section 11
The Full Code — Everything Assembled
You've built this system piece by piece across seven levels. Now it's time to see the whole thing in one place. Every file below is the final, production-ready version — incorporating every pattern and refinement we discovered along the way.
Before we dive into the code, here's a bird's-eye view of every type in the system, color-coded by the level that introduced it. Green types appeared early (Levels 0–1), yellow ones came in the middle (Levels 2–4), and red ones were added in the advanced levels (5–7). Notice how the system grew organically — each type was forced into existence by a real constraint, not by upfront planning.
Now let's see the actual code. Each file is organized by responsibility — models in one place, states in another, strategies in a third. Click through the tabs to read each file.
Models.cs — All data types that the system carries around
namespace TicTacToe.Models;
// ─── Mark ───────────────────────────────────────────
// The two symbols that can occupy a cell.
// "None" means the cell is empty.
public enum Mark { None, X, O }
// ─── Position ───────────────────────────────────────
// A (row, col) coordinate on the board. Records give us
// value equality for free: two Positions with the same
// row and col are considered equal without writing Equals().
public readonly record struct Position(int Row, int Col);
// ─── Cell ───────────────────────────────────────────
// A single square on the board: its coordinate + what's in it.
public readonly record struct Cell(Position Pos, Mark Mark);
// ─── WinLine ────────────────────────────────────────
// When someone wins, this captures WHO won and WHICH three
// cells formed the winning line (useful for highlighting).
public record WinLine(Mark Winner, IReadOnlyList<Position> Cells);
// ─── MoveCommand ────────────────────────────────────
// Every move is stored as an immutable record — this is
// the Command pattern in action. We can replay, undo, or
// transmit these over the network because they're just data.
public record MoveCommand(
int TurnNumber, // Which turn this was (1, 2, 3...)
Mark Mark, // Who placed this mark (X or O)
int Row, // Board row (0-2)
int Col // Board column (0-2)
);
// ─── MoveResult ─────────────────────────────────────
// The outcome of attempting a move. Either it succeeded
// (and possibly ended the game), or it failed with a reason.
public record MoveResult(
bool Success,
string? Error = null,
WinLine? WinLine = null,
bool IsDraw = false
);
// ─── GameResult<T> ──────────────────────────────────
// A generic result wrapper — either a value or an error.
// Used for operations that might fail (like starting a
// game that's already started).
public record GameResult<T>(T? Value, string? Error = null)
{
public bool IsSuccess => Error is null;
public static GameResult<T> Ok(T value) => new(value);
public static GameResult<T> Fail(string error) => new(default, error);
}
// ─── Player ─────────────────────────────────────────
// Pairs a mark (X or O) with a strategy for choosing moves.
// This lets us mix human and AI players freely.
public record Player(Mark Mark, IPlayerStrategy Strategy);
// ─── Board ──────────────────────────────────────────
// The 3x3 grid. Encapsulates all board logic: placing marks,
// checking for wins, listing empty cells.
public sealed class Board
{
private readonly Mark[,] _grid = new Mark[3, 3];
// Place a mark at the given position
public bool Place(int row, int col, Mark mark)
{
if (row < 0 || row > 2 || col < 0 || col > 2)
return false; // Out of bounds
if (_grid[row, col] != Mark.None)
return false; // Already occupied
_grid[row, col] = mark;
return true;
}
// Remove a mark (used by Undo)
public void Clear(int row, int col) => _grid[row, col] = Mark.None;
// Read a cell's value
public Mark this[int row, int col] => _grid[row, col];
// All empty cells — used by AI strategies to find valid moves
public List<Position> GetEmptyCells()
{
var cells = new List<Position>();
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++)
if (_grid[r, c] == Mark.None)
cells.Add(new Position(r, c));
return cells;
}
// Check all 8 possible winning lines (3 rows, 3 cols, 2 diagonals)
public WinLine? CheckWinner()
{
// All 8 lines that can form a win
Position[][] lines =
[
[new(0,0), new(0,1), new(0,2)], // Row 0
[new(1,0), new(1,1), new(1,2)], // Row 1
[new(2,0), new(2,1), new(2,2)], // Row 2
[new(0,0), new(1,0), new(2,0)], // Col 0
[new(0,1), new(1,1), new(2,1)], // Col 1
[new(0,2), new(1,2), new(2,2)], // Col 2
[new(0,0), new(1,1), new(2,2)], // Main diagonal
[new(0,2), new(1,1), new(2,0)], // Anti-diagonal
];
foreach (var line in lines)
{
var mark = _grid[line[0].Row, line[0].Col];
if (mark != Mark.None
&& mark == _grid[line[1].Row, line[1].Col]
&& mark == _grid[line[2].Row, line[2].Col])
{
return new WinLine(mark, line);
}
}
return null; // No winner yet
}
// Is the board completely full? (Draw check)
public bool IsFull => GetEmptyCells().Count == 0;
// Pretty-print for console output
public void Print()
{
for (int r = 0; r < 3; r++)
{
for (int c = 0; c < 3; c++)
{
var symbol = _grid[r, c] switch
{
Mark.X => "X",
Mark.O => "O",
_ => "."
};
Console.Write($" {symbol} ");
if (c < 2) Console.Write("|");
}
Console.WriteLine();
if (r < 2) Console.WriteLine("---+---+---");
}
}
}
Everything in this file is a data typeData types describe the SHAPE of information — what fields it has and what values are valid. They don't contain business logic (no decisions, no side effects). Think of them as forms: they define the blanks, not what you write in them. — it describes what the system carries around without making any decisions. The Board is the one exception: it owns the grid and knows how to check for winners, because that logic is inseparable from the data structure itself.
GameStates.cs — State pattern: one class per game phase
namespace TicTacToe.States;
// ─── The contract every game state must follow ──────
// Each state answers: "What happens when the player
// tries to place a mark right now?"
public interface IGameState
{
MoveResult PlaceMark(TicTacToeGame game, int row, int col);
bool CanPlaceMark { get; }
string StatusMessage { get; }
}
// ─── NotStartedState ────────────────────────────────
// The game exists but nobody has called Start() yet.
// Every action is rejected with a helpful message.
public sealed class NotStartedState : IGameState
{
public bool CanPlaceMark => false;
public string StatusMessage => "Waiting to start. Call Start() first.";
public MoveResult PlaceMark(TicTacToeGame game, int row, int col)
=> new(Success: false, Error: "Game hasn't started yet.");
}
// ─── InProgressState ────────────────────────────────
// The ONLY state where real gameplay happens. Validates
// the move, places the mark, checks for a winner or
// draw, and transitions to the appropriate next state.
public sealed class InProgressState : IGameState
{
public bool CanPlaceMark => true;
public string StatusMessage => "Game in progress";
public MoveResult PlaceMark(TicTacToeGame game, int row, int col)
{
// 1. Validate the move
if (!game.Board.Place(row, col, game.CurrentMark))
return new MoveResult(false, Error: "Invalid move: cell occupied or out of bounds.");
// 2. Record it as a command (for undo/replay)
game.RecordMove(new MoveCommand(
TurnNumber: game.MoveHistory.Count + 1,
Mark: game.CurrentMark,
Row: row, Col: col));
// 3. Check if this move wins the game
var winLine = game.Board.CheckWinner();
if (winLine is not null)
{
game.TransitionTo(new WonState(winLine));
return new MoveResult(true, WinLine: winLine);
}
// 4. Check if the board is full (draw)
if (game.Board.IsFull)
{
game.TransitionTo(new DrawState());
return new MoveResult(true, IsDraw: true);
}
// 5. No winner, no draw — toggle turn and continue
game.ToggleTurn();
return new MoveResult(true);
}
}
// ─── WonState ───────────────────────────────────────
// Someone won. The game is frozen — no more moves allowed.
// Stores the winning line so UI can highlight it.
public sealed class WonState(WinLine winLine) : IGameState
{
public bool CanPlaceMark => false;
public string StatusMessage => $"{winLine.Winner} wins!";
public WinLine WinLine { get; } = winLine;
public MoveResult PlaceMark(TicTacToeGame game, int row, int col)
=> new(false, Error: $"Game is over. {winLine.Winner} already won.");
}
// ─── DrawState ──────────────────────────────────────
// Board is full with no winner. Game over, but nobody lost.
public sealed class DrawState : IGameState
{
public bool CanPlaceMark => false;
public string StatusMessage => "It's a draw!";
public MoveResult PlaceMark(TicTacToeGame game, int row, int col)
=> new(false, Error: "Game is over. It's a draw.");
}
This is the State patternInstead of a big if-else or switch that checks "what state am I in?", each state becomes its own class. The game holds a reference to the current state object and delegates every action to it. Changing state = swapping the object. in its purest form. Each state class is tiny and focused. InProgressState does the heavy lifting — the other three just politely say "no." Adding a new state (like "Paused" for online play) means adding one file. Zero changes to existing states.
Strategies.cs — Strategy pattern: swappable AI brains
namespace TicTacToe.Strategies;
// ─── The contract for choosing a move ───────────────
// Any strategy — human, random, genius AI — implements
// this one method. The game engine doesn't care HOW
// the move is chosen, only that it gets a Position back.
public interface IPlayerStrategy
{
Position ChooseMove(Board board, Mark currentMark);
}
// ─── RandomStrategy ─────────────────────────────────
// Picks a random empty cell. Used for "Easy" AI.
// Note: randomness is INJECTED, not hardcoded — this
// makes the strategy fully testable.
public sealed class RandomStrategy(IRandomProvider random) : IPlayerStrategy
{
public Position ChooseMove(Board board, Mark currentMark)
{
var emptyCells = board.GetEmptyCells();
return emptyCells[random.Next(emptyCells.Count)];
}
}
// ─── SmartStrategy ──────────────────────────────────
// A middle-ground AI: wins if it can, blocks if it must,
// otherwise picks a random cell. "Medium" difficulty.
public sealed class SmartStrategy(IRandomProvider random) : IPlayerStrategy
{
public Position ChooseMove(Board board, Mark currentMark)
{
var opponent = currentMark == Mark.X ? Mark.O : Mark.X;
// 1. Can I win right now? Take it!
var winMove = FindWinningMove(board, currentMark);
if (winMove.HasValue) return winMove.Value;
// 2. Can opponent win next turn? Block it!
var blockMove = FindWinningMove(board, opponent);
if (blockMove.HasValue) return blockMove.Value;
// 3. Take center if available (strongest position)
if (board[1, 1] == Mark.None) return new Position(1, 1);
// 4. Otherwise pick randomly
var emptyCells = board.GetEmptyCells();
return emptyCells[random.Next(emptyCells.Count)];
}
// Try every empty cell — if placing the mark there wins, return it
private static Position? FindWinningMove(Board board, Mark mark)
{
foreach (var cell in board.GetEmptyCells())
{
board.Place(cell.Row, cell.Col, mark);
var win = board.CheckWinner();
board.Clear(cell.Row, cell.Col); // undo the probe
if (win is not null) return cell;
}
return null;
}
}
// ─── MinimaxStrategy ────────────────────────────────
// The unbeatable AI. Explores EVERY possible future game
// state and picks the move that guarantees the best outcome.
// Against perfect play, the result is always a draw.
public sealed class MinimaxStrategy : IPlayerStrategy
{
public Position ChooseMove(Board board, Mark currentMark)
{
var bestScore = int.MinValue;
Position bestMove = default;
foreach (var cell in board.GetEmptyCells())
{
board.Place(cell.Row, cell.Col, currentMark);
int score = Minimax(board, depth: 0,
isMaximizing: false, aiMark: currentMark);
board.Clear(cell.Row, cell.Col);
if (score > bestScore)
{
bestScore = score;
bestMove = cell;
}
}
return bestMove;
}
// Recursively evaluate all possible game continuations
private static int Minimax(Board board, int depth,
bool isMaximizing, Mark aiMark)
{
var opponent = aiMark == Mark.X ? Mark.O : Mark.X;
var winner = board.CheckWinner();
// Base cases: someone won, or board is full
if (winner?.Winner == aiMark) return 10 - depth; // AI wins (prefer faster wins)
if (winner?.Winner == opponent) return depth - 10; // Opponent wins (prefer slower losses)
if (board.IsFull) return 0; // Draw
var currentMark = isMaximizing ? aiMark : opponent;
var bestScore = isMaximizing ? int.MinValue : int.MaxValue;
foreach (var cell in board.GetEmptyCells())
{
board.Place(cell.Row, cell.Col, currentMark);
int score = Minimax(board, depth + 1, !isMaximizing, aiMark);
board.Clear(cell.Row, cell.Col);
bestScore = isMaximizing
? Math.Max(bestScore, score)
: Math.Min(bestScore, score);
}
return bestScore;
}
}
This is the Strategy patternThe Strategy pattern lets you define a family of algorithms, put each one in its own class, and make them interchangeable. The game engine calls ChooseMove() without caring which strategy is behind it — Random, Smart, or Minimax all look the same from the outside.. Three wildly different algorithms, one interface. The game engine calls ChooseMove() and gets back a Position — it has no idea whether the move came from a coin flip, a heuristic, or an exhaustive search of every possible future. That's the power of polymorphismPolymorphism means "many forms." It lets you treat different objects through the same interface. When the engine calls strategy.ChooseMove(), the ACTUAL method that runs depends on which strategy object is plugged in. Same call, different behavior — that's polymorphism..
TicTacToeGame.cs — The game engine that ties everything together
namespace TicTacToe;
// ─── ITicTacToeGame ─────────────────────────────────
// The public contract for the game engine. Controllers,
// UIs, and tests depend on THIS, not the concrete class.
public interface ITicTacToeGame
{
Board Board { get; }
Mark CurrentMark { get; }
IReadOnlyList<MoveCommand> MoveHistory { get; }
string Status { get; }
bool IsGameOver { get; }
MoveResult MakeMove(int row, int col);
MoveResult Undo();
void Start();
void PlayToCompletion();
}
// ─── TicTacToeGame ──────────────────────────────────
// The orchestrator. Doesn't contain game logic itself —
// delegates to the current STATE for move handling, and
// to STRATEGIES for AI move selection.
public sealed class TicTacToeGame : ITicTacToeGame
{
private IGameState _currentState = new NotStartedState();
private readonly List<MoveCommand> _history = [];
private readonly Stack<MoveCommand> _redoStack = new();
// ─ Dependencies (all injected) ─
private readonly Player _playerX;
private readonly Player _playerO;
// ─ Constructors ─
public TicTacToeGame(
IPlayerStrategy? xStrategy = null,
IPlayerStrategy? oStrategy = null)
{
_playerX = new Player(Mark.X, xStrategy!);
_playerO = new Player(Mark.O, oStrategy!);
Board = new Board();
}
// Test constructor: start from a specific board state
public TicTacToeGame(Board board, Mark currentTurn = Mark.X)
{
Board = board;
CurrentMark = currentTurn;
_playerX = new Player(Mark.X, null!);
_playerO = new Player(Mark.O, null!);
_currentState = new InProgressState();
}
// ─ Properties ─
public Board Board { get; }
public Mark CurrentMark { get; private set; } = Mark.X;
public IReadOnlyList<MoveCommand> MoveHistory => _history;
public string Status => _currentState.StatusMessage;
public bool IsGameOver => !_currentState.CanPlaceMark
&& _currentState is not NotStartedState;
// ─ State management ─
public void TransitionTo(IGameState newState)
=> _currentState = newState;
public void ToggleTurn()
=> CurrentMark = CurrentMark == Mark.X ? Mark.O : Mark.X;
public void RecordMove(MoveCommand cmd)
{
_history.Add(cmd);
_redoStack.Clear(); // New move invalidates redo stack
}
// ─ Core operations ─
public void Start() => _currentState = new InProgressState();
public MoveResult MakeMove(int row, int col)
=> _currentState.PlaceMark(this, row, col);
// ─ Undo: pop the last move and rewind ─
public MoveResult Undo()
{
if (_history.Count == 0)
return new MoveResult(false, Error: "Nothing to undo.");
// If game is over, revert to InProgress first
if (IsGameOver)
_currentState = new InProgressState();
var lastMove = _history[^1];
_history.RemoveAt(_history.Count - 1);
_redoStack.Push(lastMove);
Board.Clear(lastMove.Row, lastMove.Col);
ToggleTurn();
return new MoveResult(true);
}
// ─ Auto-play: let strategies play until game ends ─
public void PlayToCompletion()
{
if (_currentState is NotStartedState) Start();
while (_currentState.CanPlaceMark)
{
var player = CurrentMark == Mark.X ? _playerX : _playerO;
var pos = player.Strategy.ChooseMove(Board, CurrentMark);
MakeMove(pos.Row, pos.Col);
}
}
}
Notice how short the game engine is despite doing so much. That's because it delegates everything. Move handling? Delegated to the current IGameState. AI decisions? Delegated to IPlayerStrategy. The engine itself is just a mediatorA mediator is a central hub that coordinates interactions between other objects without them knowing about each other. The game engine mediates between states, strategies, and the board — none of them reference each other directly. that holds references, routes calls, and manages the move history.
Program.cs — DI wiring + console game loop
using Microsoft.Extensions.DependencyInjection;
using TicTacToe;
using TicTacToe.Models;
using TicTacToe.Strategies;
// ─── Wire up the dependency injection container ─────
var services = new ServiceCollection();
// Randomness: inject the real provider for production
services.AddSingleton<IRandomProvider, SystemRandomProvider>();
// Strategies: registered by name for easy swapping
services.AddTransient<RandomStrategy>();
services.AddTransient<SmartStrategy>();
services.AddTransient<MinimaxStrategy>();
var provider = services.BuildServiceProvider();
// ─── Game setup ─────────────────────────────────────
Console.WriteLine("=== Tic-Tac-Toe ===");
Console.WriteLine("Choose difficulty: (1) Easy (2) Medium (3) Impossible");
var choice = Console.ReadLine();
// Pick the AI strategy based on user choice
IPlayerStrategy aiStrategy = choice switch
{
"1" => provider.GetRequiredService<RandomStrategy>(),
"2" => provider.GetRequiredService<SmartStrategy>(),
"3" => provider.GetRequiredService<MinimaxStrategy>(),
_ => provider.GetRequiredService<SmartStrategy>()
};
// Human player is X, AI is O
var game = new TicTacToeGame(
xStrategy: null!, // Human — moves come from console input
oStrategy: aiStrategy);
game.Start();
// ─── The game loop ──────────────────────────────────
while (!game.IsGameOver)
{
Console.WriteLine();
game.Board.Print();
Console.WriteLine($"\n{game.CurrentMark}'s turn.");
if (game.CurrentMark == Mark.X) // Human's turn
{
Console.Write("Enter row,col (e.g. 1,2) or 'u' to undo: ");
var input = Console.ReadLine()?.Trim();
if (input == "u")
{
// Undo twice: the AI's last move + the human's last move
game.Undo();
game.Undo();
continue;
}
var parts = input?.Split(',');
if (parts?.Length == 2
&& int.TryParse(parts[0], out var row)
&& int.TryParse(parts[1], out var col))
{
var result = game.MakeMove(row, col);
if (!result.Success)
{
Console.WriteLine($"Invalid move: {result.Error}");
continue;
}
}
}
else // AI's turn
{
var pos = aiStrategy.ChooseMove(game.Board, Mark.O);
var result = game.MakeMove(pos.Row, pos.Col);
Console.WriteLine($"AI plays ({pos.Row},{pos.Col})");
}
}
// ─── Game over ──────────────────────────────────────
Console.WriteLine();
game.Board.Print();
Console.WriteLine($"\n{game.Status}");
Console.WriteLine("\nMove history:");
foreach (var move in game.MoveHistory)
Console.WriteLine($" Turn {move.TurnNumber}: {move.Mark} → ({move.Row},{move.Col})");
This is the composition rootThe composition root is the ONE place in your application where all the dependencies get wired together. Everything else just declares what it NEEDS (through constructor parameters). Only Program.cs decides which concrete implementations to use. This separation means swapping implementations (for testing, or for different game modes) requires changing just this one file. — the single place where concrete implementations are chosen and plugged together. Notice that TicTacToeGame never mentions RandomStrategy or MinimaxStrategy by name. It only knows about IPlayerStrategy. The wiring happens here, at the very top of the application.
Key Insight: Five files. 19 types. Three design patterns (State, Strategy, Command). Every single type was discovered by hitting a real constraint, not by reading a textbook and deciding to "apply" a pattern. That's the difference between knowing patterns and understanding them.
Section 12
Pattern Spotting — X-Ray Vision
You've been using design patterns for the last seven levels. But here's the interesting part: you might not have noticed all of them. Some patterns are obvious — we named them as we built them. Others are hiding in the code, doing their job quietly without anyone putting a label on them.
This section is about developing pattern recognitionThe ability to look at code and see the underlying design patterns at work. Senior engineers do this unconsciously — they glance at code and immediately see "that's a Strategy" or "that's a Command." This skill comes from practice: once you've built patterns yourself, you spot them everywhere. — the skill of looking at code and seeing the structural bones underneath. Let's start with a challenge.
Think First #10
We explicitly named three patterns during the build: State, Command, and Strategy. But there are at least two MORE patterns hiding in our code that we never mentioned by name. Can you find them? Hint: look at how WinLine objects get created, and think about what the move history gives us beyond undo.
Take your time.
Reveal Answer
Factory Method — Board.CheckWinner() doesn't just check for a winner, it creates a WinLine object when one is found. The board "manufactures" result objects based on its internal state. The caller doesn't decide how to build them.
Memento — The MoveHistory (list of MoveCommand records) is a sequence of snapshots. You can rebuild the entire game state from an empty board by replaying commands in order. That's the Memento pattern — capturing state so you can restore it later.
The Three Explicit Patterns
These are the patterns we named during the build. For each one, we'll look at where it lives in the code, what it enables, and what would happen without it.
State Pattern — "Behavior changes when the mode changes"
The game behaves differently depending on its current phase without a single if-else or switch. Placing a mark during "InProgress" works normally; during "Won" it's rejected; during "NotStarted" it reminds you to start first.
Without it
Every method would start with if (state == InProgress) ... else if (state == Won) .... Adding a new state like "Paused" would mean editing every method in the game class. The State pattern makes adding a new phase a one-file change.
Command Pattern — "Turn actions into data you can store and replay"
Where
MoveCommand record + _history list + _redoStack
Enables
Undo (pop the last command and reverse it), redo (re-apply from the redo stack), replay (iterate the history on a fresh board), network sync (send commands over the wire instead of board snapshots).
Without it
You'd have no history. Undo would require cloning the entire board before every move (expensive and fragile). Replay would be impossible. Network play would need to serialize the whole board on every turn instead of a tiny command object.
Strategy Pattern — "Swap the algorithm without touching the engine"
Difficulty levels (Easy/Medium/Impossible) chosen at runtime. Mix and match: Human vs AI, AI vs AI, Human vs Human. Tournaments where different AI strategies compete against each other.
Without it
AI logic would live inside the game engine, tangled with move validation and state management. Changing the AI would mean editing the core game class. Adding a new difficulty level would risk breaking existing ones.
Pattern X-Ray — See Through the Code
Here's the class diagram again, but this time with colored overlays showing which pattern each type belongs to. Blue is State, cyan is Command, and orange is Strategy. Some types serve multiple patterns — that's normal and healthy.
How the Patterns Interact
Patterns don't live in isolation. Here's how they collaborateIn well-designed systems, patterns work together like gears in a machine. State delegates to Strategy (the InProgressState asks the player's strategy for a move). Strategy produces a move that becomes a Command. The Command gets stored and can be replayed. Each pattern handles one concern and passes the result to the next. in a single move cycle: the State pattern decides IF a move is allowed, the Strategy pattern decides WHERE to move, and the Command pattern records WHAT happened.
Hidden Patterns — 2 more patterns you didn't name
Factory Method
Where:Board.CheckWinner() creates and returns a WinLine object when three marks align. The caller doesn't decide how to build the WinLine — the board does.
Why it matters: If you later want to add rich win information (like the line direction, or animation data), you change CheckWinner() in one place. Every consumer automatically gets the richer data without code changes.
Memento
Where: The MoveHistory list. Each MoveCommand is an immutable snapshotAn immutable snapshot is a piece of data that, once created, can never be changed. C# records are immutable by default. Because MoveCommand is a record, nobody can accidentally alter a historical move after it happened. The history is a trustworthy, unchangeable log. of a single action. Together, they form a complete event logAn event log is a chronological list of everything that happened in a system. Instead of just storing the current state, you store every change that led to it. This lets you reconstruct any past state by replaying events from the beginning. This idea scales all the way up to Event Sourcing in distributed systems. that can reconstruct any past state.
Why it matters: Undo, replay, and even event sourcingEvent Sourcing is an architectural pattern where you store every change as an event instead of just the current state. Want the state at 3:45 PM? Replay all events up to that time. This is how databases store transaction logs, and how systems like Git track file history. (the HLD scaling technique) all build on the same idea: don't just store the current state, store HOW you got there.
The Takeaway: Five patterns in a Tic-Tac-Toe game. None of them were "applied" from a textbook. Each one was discovered by hitting a constraint that made the simpler approach painful. That's how patterns work in real projects: you feel the pain first, and the pattern is the relief.
Section 13
The Growing Diagram — Complete Evolution
You've just spent seven levels building a Tic-Tac-Toe game. At Level 0 it was a single class with a 2D array. By Level 7, it's a clean architecture with interfaces, records, strategies, and events. But it didn't happen all at once — it grew one constraint at a time. Let's zoom out and watch the whole journey unfold.
Each level below shows what was added at that stage. Glowing boxes are new arrivals; dimmed boxes are types that already existed from earlier levels. Pay attention to the growth curveThe growth curve shows how many types (classes, interfaces, records, enums) exist at each level. A healthy design grows gradually — 1 or 2 new types per level. An unhealthy design dumps 15 types in one go because someone tried to "design everything up front." — it stays gentle because we never added more than one concept at a time.
Design Evolution — L0 through L7
Here's the complete picture — every entity, its type, and the level that introduced it.
Entity
Kind
Level
Why This Kind?
Board
sealed class
L0
Has mutable state (the grid) and behavior (place/check)
Game
sealed class
L0
Orchestrates turns, tracks players
CellMark
enum
L1
Category without behavior (X, O, Empty)
WinLine
record
L1
Immutable — once detected, a win never changes
IGameState
interface
L2
Contract for state-dependent behavior
NotStartedState
sealed class
L2
Rejects moves before game starts
InProgressState
sealed class
L2
Allows moves, checks for winner
WonState
sealed class
L2
Rejects moves after a win
DrawState
sealed class
L2
Rejects moves after a draw
MoveCommand
record
L3
Immutable snapshot of one action — enables undo/redo
IPlayerStrategy
interface
L4
Contract for interchangeable AI algorithms
RandomStrategy
sealed class
L4
Easy AI — picks randomly
SmartStrategy
sealed class
L4
Medium AI — blocks wins, takes center
MinimaxStrategy
sealed class
L4
Impossible AI — never loses
MoveResult
record
L5
Immutable result carrier — no exceptions for flow control
IGameEngine
interface
L6
DI abstractionBy extracting an interface for the game engine, you can inject a mock in unit tests while the real engine runs in production. This is the Dependency Inversion Principle: depend on abstractions, not concrete types. for testability
IGameObserver
interface
L7
Decouples event production from consumption
MoveMadeEvent
record
L7
Immutable event — captures what happened
GameOverEvent
record
L7
Immutable event — carries outcome data
"What if we designed everything up front?" You'd sit down, list every pattern you know (State, Command, Strategy, Observer, Factory), sketch 19 types, wire them all together, and start coding. Sounds efficient, right? It's a trap. Here's why:
You'd add IPlayerStrategy before you understand WHY the AI needs to be swappable
You'd add MoveCommand before you've felt the pain of "I can't undo"
You'd add IGameState before your first bug where a move lands on a finished game
You'd wire Observer before realizing the UI needs to update when a move happens
The result? Patterns that don't quite fit, abstractions in the wrong places, and code nobody understands — because nobody felt the pain that each pattern was designed to solve.
Final Architecture — All Patterns Working Together
Here's the complete class diagram after Level 7. Each color represents the pattern that introduced that type. Notice how the patterns compose cleanlyGood patterns don't fight each other. State decides IF a move is allowed. Strategy decides WHERE to move. Command records WHAT happened. Observer notifies WHO cares. Each handles one concern and passes the baton to the next. This composability is what makes the design feel natural rather than forced. — State delegates to Strategy, Strategy produces moves that become Commands, and Commands fire Events through the Observer.
The lesson: Each level added exactly one concept. State was introduced when we needed controlled transitions. Command arrived when "undo" became a requirement. Strategy appeared when the AI needed to be swappable. Observer came last, when external systems needed to react to game events. 19 types sounds like a lot — but each one has a clear reason to exist because you felt the constraint that demanded it.
Section 14
Five Bad Solutions — Learn What NOT to Do
You've seen the good solution — built incrementally over 7 levels. Now let's study five bad approaches that people commonly reach for. Each one is tempting for a different reason, and each one breaks in a different way. We'll walk through the exact code, show you an SVG of the problem, and demonstrate the fix.
Bad Solution 1: "The God Class"
Imagine a restaurant where one person takes orders, cooks the food, serves the tables, and does the dishes. On a quiet night, it works. On a busy night, everything falls apart because one person can't juggle six jobs at once.
That's what happens when you put everything in a single TicTacToeGame class: the board array, win checking, state tracking, undo history, AI logic, and Console.WriteLine calls all mixed together. At first it feels productive — everything in one file, no need to navigate between classes. But the moment you try to add a new AI difficulty or change how the board is displayed, you're wading through 2000 lines of tangled code where changing one thing risks breaking something completely unrelated.
GodClass.cs — Everything in One Place
public class TicTacToeGame
{
private char[,] board = new char[3, 3];
private bool isStarted, isOver, isWon;
private char currentPlayer = 'X';
private List<(int r, int c, char mark)> history = new();
public void MakeMove(int row, int col)
{
if (!isStarted) { Console.WriteLine("Game not started!"); return; }
if (isOver) { Console.WriteLine("Game already over!"); return; }
if (board[row, col] != '\0') { Console.WriteLine("Spot taken!"); return; }
board[row, col] = currentPlayer;
history.Add((row, col, currentPlayer));
Console.WriteLine($"{currentPlayer} placed at ({row},{col})");
// Win check — inline, 20+ lines of nested loops
for (int i = 0; i < 3; i++)
{
if (board[i,0] == currentPlayer && board[i,1] == currentPlayer
&& board[i,2] == currentPlayer)
{ isWon = true; isOver = true; }
}
// ... 40 more lines of win/draw checking ...
if (isWon) Console.WriteLine($"{currentPlayer} wins!");
currentPlayer = currentPlayer == 'X' ? 'O' : 'X';
// AI move — also inline
if (!isOver && currentPlayer == 'O')
{
var rng = new Random();
// ... 30 lines of AI logic mixed into the game class ...
}
}
// ... 200 more lines: undo, display, reset, scoring ...
}
What's wrong: Board logic, win detection, state flags, AI, display, and history are all in one class. The MakeMove method alone handles validation, placement, win checking, display, and AI — five different responsibilities. Adding a new AI difficulty means editing this method. Changing the display means editing this method. Testing win detection means constructing the entire game.
CleanSeparation.cs — Each Class Has One Job
// Board: ONLY grid management and win detection
public sealed class Board
{
public bool TryPlace(int row, int col, CellMark mark) { ... }
public WinLine? CheckWinner() { ... }
}
// State: ONLY "is this move allowed right now?"
public interface IGameState
{
MoveResult HandleMove(Game game, int row, int col);
}
// Command: ONLY recording and reversing moves
public record MoveCommand(int Row, int Col, CellMark Mark);
// Strategy: ONLY choosing WHERE to move
public interface IPlayerStrategy
{
(int Row, int Col) PickMove(Board board);
}
// Observer: ONLY notifying external systems
public interface IGameObserver
{
void OnMoveMade(MoveMadeEvent e);
void OnGameOver(GameOverEvent e);
}
Why the fix works: Each class has exactly one reason to change. New AI difficulty? Add a new IPlayerStrategy implementation — zero changes to Board or Game. Change the display? Implement a new IGameObserver. Test win detection? Create a Board, place marks, call CheckWinner() — no game object needed.
How to Spot This: If you can describe your class using the word "and" more than once ("it manages the board AND checks for wins AND handles undo AND runs the AI AND prints to the console"), it's a God Class. Split it.
Bad Solution 2: "Boolean State Soup"
Instead of using the State pattern, some developers track the game's phase with boolean flags: isStarted, isOver, isWon, isDraw. It seems simpler at first — just a few booleans, no need for interfaces or classes. But here's the problem: booleans can combine in impossible ways. Can isWon = true and isDraw = true happen at the same time? What about isStarted = false and isOver = true? The code can't prevent illegal combinations, so you end up with defensive if-checks scattered everywhere.
BooleanSoup.cs — Flags Instead of States
public class TicTacToeGame
{
private bool isStarted = false;
private bool isOver = false;
private bool isWon = false;
private bool isDraw = false; // can isWon AND isDraw both be true?
public void MakeMove(int row, int col)
{
if (!isStarted) throw new InvalidOperationException("Not started");
if (isOver) throw new InvalidOperationException("Game over");
if (isWon) throw new InvalidOperationException("Already won"); // redundant?
if (isDraw) throw new InvalidOperationException("Already drawn"); // also redundant?
// Place the mark...
board[row, col] = currentPlayer;
if (CheckWin())
{
isWon = true;
isOver = true;
// Bug: forgot to set isDraw = false explicitly
// What if someone set isDraw = true earlier by accident?
}
else if (IsBoardFull())
{
isDraw = true;
isOver = true;
}
}
public void Reset()
{
isStarted = true;
isOver = false;
isWon = false;
// Bug: forgot to reset isDraw!
// Now isDraw = true from the previous game
}
}
What's wrong: Four booleans create 16 possible combinations, but only 4 are valid (NotStarted, InProgress, Won, Draw). The other 12 combinations are impossible statesAn impossible state is a combination of values that makes no sense in the real world. "The game is both won AND drawn" is impossible. "The game is over but hasn't started" is impossible. If your data model can represent impossible states, bugs will find those states. that the code can't prevent. Notice the Reset() method — it forgets to clear isDraw. After a draw followed by a reset, the new game starts with isDraw = true. Every method needs defensive checks against every flag, and you'll inevitably miss one.
StatePattern.cs — Impossible States Are Impossible
// The game is ALWAYS in exactly ONE state
public interface IGameState
{
MoveResult HandleMove(Game game, int row, int col);
}
public sealed class InProgressState : IGameState
{
public MoveResult HandleMove(Game game, int row, int col)
{
// This is the ONLY state where moves are allowed
// No boolean checks needed — the type system enforces it
game.Board.TryPlace(row, col, game.CurrentMark);
var winner = game.Board.CheckWinner();
if (winner is not null)
game.TransitionTo(new WonState(winner));
else if (game.Board.IsFull)
game.TransitionTo(new DrawState());
return MoveResult.Success;
}
}
// Can't be Won AND Draw — there's only ONE state object at a time
// Can't forget to reset — new game = new NotStartedState()
Why the fix works: With the State pattern, the game holds exactly one IGameState reference at a time. It's either NotStartedState, InProgressState, WonState, or DrawState — never two at once. Impossible combinations are literally impossible because there's no way to construct them. Reset creates a fresh NotStartedState, so there's nothing to "forget."
How to Spot This: Count your boolean flags. If you have N booleans, you have 2^N possible states — but how many are valid? If the valid states are fewer than the possible ones, you need a State pattern or an enum.
Bad Solution 3: "Clone the Board for Undo"
When people first think about undo, the instinct is: "Before each move, save a copy of the entire board. To undo, restore the old copy." That's called the snapshot approachThe snapshot approach saves the complete system state before every change. For small systems it works, but it scales terribly — each snapshot costs O(n) memory where n is the board size. For Tic-Tac-Toe that's tiny, but the same habit applied to a chess game, a document editor, or a database would be disastrous.. For a 3x3 board it works, but it teaches a terrible habit.
ArrayClone.cs — Copy Everything for Undo
public class TicTacToeGame
{
private char[,] board = new char[3, 3];
private Stack<char[,]> snapshots = new(); // entire board copies!
public void MakeMove(int row, int col)
{
// Save a FULL COPY of the board before every move
snapshots.Push((char[,])board.Clone()); // O(n) per move
board[row, col] = currentPlayer;
}
public void Undo()
{
if (snapshots.Count == 0) return;
board = snapshots.Pop(); // restore the entire board
// Bug: what about currentPlayer? State? Turn count?
// None of those were saved in the snapshot!
}
}
What's wrong: The snapshot only saves the board array. It doesn't save whose turn it was, what the game state was, or the undo/redo stack itself. After undoing, the current player is still the wrong person, and the game state might still be "Won" even though the winning move was reversed. Also: cloning the entire board for every move is wasteful. In a real system with a larger board (chess, Go), this approach would eat memory fast.
CommandPattern.cs — Record Actions, Not State
// Each command records WHAT happened — not the entire board
public record MoveCommand(int Row, int Col, CellMark Mark);
public sealed class Game
{
private readonly Stack<MoveCommand> _history = new();
private readonly Stack<MoveCommand> _redoStack = new();
public MoveResult MakeMove(int row, int col)
{
var command = new MoveCommand(row, col, CurrentMark);
_board.TryPlace(row, col, CurrentMark);
_history.Push(command);
_redoStack.Clear();
return MoveResult.Success;
}
public void Undo()
{
if (_history.Count == 0) return;
var cmd = _history.Pop();
_board.Clear(cmd.Row, cmd.Col); // reverse just ONE cell
_redoStack.Push(cmd);
// State, turn, everything stays consistent
}
}
Why the fix works: Instead of cloning the entire board, we record the action that happened: "X was placed at (1,2)." To undo, we reverse just that one action: "clear cell (1,2)." Each MoveCommand is a tiny record (12 bytes) instead of a full board clone. Redo is free — pop from the redo stack and replay. Replay the whole game? Iterate the history on a fresh board.
How to Spot This: If your undo involves .Clone(), DeepCopy(), or serializing the entire object, you're using snapshots. Ask yourself: "Can I describe the change as a small command instead of a full copy?" If yes, use the Command pattern.
Bad Solution 4: "Magic Number 3 Everywhere"
It's a 3x3 board, right? So you write 3 everywhere: new char[3, 3], for (int i = 0; i < 3; i++), if (count == 3). The code works perfectly for Tic-Tac-Toe. Then your interviewer says: "What if I want a 4x4 board with 4-in-a-row to win?" Suddenly you realize the number 3 appears in 47 places across your codebase, and half of them mean "board size" while the other half mean "win length." You can't change one without untangling the other.
MagicNumbers.cs — 3 Hardcoded Everywhere
public class Board
{
private char[,] grid = new char[3, 3]; // magic 3
public bool CheckWin(char player)
{
// Rows
for (int r = 0; r < 3; r++) // magic 3
if (grid[r,0] == player && grid[r,1] == player
&& grid[r,2] == player) // magic 3
return true;
// Columns
for (int c = 0; c < 3; c++) // magic 3
if (grid[0,c] == player && grid[1,c] == player
&& grid[2,c] == player) // magic 3
return true;
// Diagonals — hardcoded indices
if (grid[0,0] == player && grid[1,1] == player
&& grid[2,2] == player) // magic 3
return true;
return false;
}
public bool IsFull()
{
int count = 0;
for (int r = 0; r < 3; r++) // magic 3
for (int c = 0; c < 3; c++) // magic 3
if (grid[r, c] != '\0') count++;
return count == 9; // magic 9 = 3 * 3
}
}
What's wrong: The number 3 appears 12 times. Some mean "board width," some mean "board height," some mean "marks needed to win." If you want a 5x5 board with 4-in-a-row, you'd need to change each 3 individually — and hope you don't confuse board size with win length. The 9 in IsFull() is even worse: it's a derived value (3 * 3) disguised as a literal. Change the board to 4x4 and forget to update 9 to 16? The game never recognizes a draw.
Configurable.cs — Named Constants and Constructor Parameters
public sealed class Board
{
private readonly CellMark[,] _grid;
public int Size { get; }
public int WinLength { get; }
public Board(int size = 3, int winLength = 3)
{
Size = size;
WinLength = winLength;
_grid = new CellMark[size, size];
}
public WinLine? CheckWinner()
{
// Uses Size and WinLength — works for any board
for (int r = 0; r < Size; r++)
for (int c = 0; c < Size; c++)
foreach (var dir in Directions)
if (CheckLine(r, c, dir, WinLength) is WinLine w)
return w;
return null;
}
public bool IsFull => _moveCount == Size * Size; // derived!
}
// 3x3 classic: new Board(3, 3)
// 5x5 gomoku: new Board(5, 4)
// 15x15 gomoku: new Board(15, 5)
Why the fix works: The board size and win length are constructor parameters with sensible defaults. Every loop uses Size instead of a hardcoded number. IsFull is computed from Size * Size so it's always correct. Want a 4x4 board? Change one constructor call. The win-checking algorithm works for any size because it uses WinLength to count consecutive marks in each direction.
How to Spot This: Search your code for literal numbers. If the same number appears more than twice, it should be a named constant or a constructor parameter. If two different concepts share the same number by coincidence (board size and win length both being 3), they MUST be separate variables.
Bad Solution 5: "Console.WriteLine Everywhere"
This is the most common mistake in beginner code: Console.WriteLine calls sprinkled throughout game logic. The board class prints itself. The game class announces moves. The AI class explains its reasoning. It works fine when you run it in a terminal. But the moment you want to use this game engine in a web app, a mobile app, or a unit test — the console output becomes garbage. Your unit test log is full of "X placed at (1,2)" noise, and your web app can't use the engine because it's printing to a console that doesn't exist.
ConsoleEverywhere.cs — UI Mixed Into Logic
public class Board
{
public void Place(int row, int col, char mark)
{
grid[row, col] = mark;
Console.WriteLine($"{mark} placed at ({row},{col})"); // UI in Board!
PrintBoard(); // Board knows how to render itself to console
}
private void PrintBoard()
{
for (int r = 0; r < 3; r++)
{
Console.WriteLine($" {grid[r,0]} | {grid[r,1]} | {grid[r,2]} ");
if (r < 2) Console.WriteLine("---+---+---");
}
}
}
public class Game
{
public void Start()
{
Console.Clear(); // assumes a console exists
Console.ForegroundColor = ConsoleColor.Cyan; // crashes in web
Console.WriteLine("=== TIC-TAC-TOE ===");
}
}
What's wrong: The Board class knows about Console. The Game class sets console colors and clears the screen. This tightly couplesTight coupling means two things depend directly on each other. Here, the game engine depends directly on Console. If you remove Console (because you're running in a web server), the game engine breaks. The fix is to depend on an abstraction (an interface) instead of a concrete output mechanism. the game engine to a specific output mechanism. In a unit test, those Console.WriteLine calls produce noise. In a web API, Console.Clear() throws. In a mobile app, none of this works at all.
// Game engine has ZERO Console references
public sealed class Game
{
private readonly List<IGameObserver> _observers = new();
public void MakeMove(int row, int col)
{
// ... game logic ...
foreach (var obs in _observers)
obs.OnMoveMade(new MoveMadeEvent(row, col, CurrentMark));
}
}
// Console UI — one possible observer
public sealed class ConsoleRenderer : IGameObserver
{
public void OnMoveMade(MoveMadeEvent e)
=> Console.WriteLine($"{e.Mark} placed at ({e.Row},{e.Col})");
}
// Web API — another observer, zero engine changes
public sealed class WebSocketNotifier : IGameObserver
{
public void OnMoveMade(MoveMadeEvent e)
=> _hub.Clients.All.SendAsync("moveMade", e);
}
// Unit test — silent observer
public sealed class TestObserver : IGameObserver { /* collect events */ }
Why the fix works: The game engine fires events through IGameObserver. The console renderer is just one possible subscriber. A web socket notifier is another. A test observer collects events silently. The engine doesn't know or care who's listening. To switch from console to web: register a different observer. Zero changes to game logic.
How to Spot This: Search your game logic files for Console.. If you find any, that's UI mixed into logic. The rule: game logic classes should have zero references to Console, HttpContext, or any specific output technology. They fire events; someone else decides how to display them.
Coupling Comparison — Bad vs Good
The pattern across all 5: Every bad solution was tempting because it was simpler in the short term. One class is simpler than five. Booleans are simpler than a State interface. Cloning is simpler than commands. Magic numbers are simpler than parameters. Console.WriteLine is simpler than observers. But "simpler now" always means "harder later." Good design isn't about complexity — it's about putting the complexity in the right places.
Section 15
Code Review Challenge — Find 5 Bugs
A candidate submitted this Tic-Tac-Toe implementation as a pull request. It compiles. It runs. It handles a basic game from start to finish. But there are exactly 5 bugs hiding in it — issues that would cause real problems in production. Can you find them all before scrolling down?
Read the code below carefully. Try to find all 5 issues before revealing the answers.
CandidateSolution.cs — Find 5 Bugs
public class TicTacToeGame // Line 1
{
private char[,] board = new char[3, 3]; // Line 3
private bool isStarted, isOver;
private char currentPlayer = 'X';
private List<(int r, int c, char mark)> history = new();
public string MakeMove(int row, int col) // Line 8
{
if (!isStarted) return "Game not started";
if (isOver) return "Game is over";
if (board[row, col] != '\0') return "Spot taken"; // Line 12
board[row, col] = currentPlayer;
history.Add((row, col, currentPlayer));
Console.WriteLine($"Board updated: {currentPlayer} at ({row},{col})");
if (CheckWin(currentPlayer)) // Line 17
{
isOver = true;
return $"{currentPlayer} wins!";
// Note: currentPlayer never switches after this
}
if (history.Count == 9) // Line 23
{
isOver = true;
return "Draw!";
}
currentPlayer = currentPlayer == 'X' ? 'O' : 'X';
return "OK";
}
public void Undo() // Line 31
{
if (history.Count == 0) return;
var last = history[^1];
history.RemoveAt(history.Count - 1);
board[last.r, last.c] = '\0';
// That's it — no state restoration
}
private bool CheckWin(char p) // Line 40
{
for (int i = 0; i < 3; i++)
{
if (board[i,0]==p && board[i,1]==p && board[i,2]==p) return true;
if (board[0,i]==p && board[1,i]==p && board[2,i]==p) return true;
}
if (board[0,0]==p && board[1,1]==p && board[2,2]==p) return true;
// Missing: anti-diagonal check! // Line 47
return false;
}
}
Found them? Reveal one at a time:
Bug #1 — Boolean State Flags (Lines 4)
Problem:isStarted and isOver are boolean flags. Two booleans = 4 possible states, but only 3 are valid (NotStarted, InProgress, Over). The combination isStarted = false, isOver = true makes no sense but nothing prevents it. Worse: there's no distinction between "game over because someone won" and "game over because of a draw."
Fix: Replace both booleans with the State patternInstead of boolean flags, represent each game phase as a class that implements IGameState. The game holds exactly one state object at a time. Each state handles moves differently: NotStartedState rejects them, InProgressState processes them, WonState announces the winner. Impossible combinations become literally impossible.: IGameState with NotStartedState, InProgressState, WonState, DrawState.
Taught in: Level 2 — State Pattern
Bug #2 — Console.WriteLine in Game Logic (Line 15)
Problem:Console.WriteLine is hardcoded inside the game logic. This means the game engine can't be used in a web app, a mobile app, or a unit test without polluting the output. The game class should produce data, not output.
Fix: Remove all Console references from game logic. Use the Observer patternThe Observer pattern lets the game engine fire events (OnMoveMade, OnGameOver) without knowing who's listening. A console renderer, a web socket notifier, or a test spy can all subscribe. The engine stays reusable across any platform. — fire events, let subscribers handle display.
Taught in: Level 7 — Observer Pattern
Bug #3 — Undo Doesn't Restore State (Lines 31-37)
Problem: The Undo() method removes the last move from history and clears the cell, but it never switches currentPlayer back, never clears isOver, and never restores the game state. If you win and then undo the winning move, isOver stays true and the game is stuck.
Fix: Use proper MoveCommand records that capture the full context of each move. The undo operation should reverse the command completely: clear the cell, switch the player back, and re-evaluate the game state.
Taught in: Level 3 — Command Pattern
Bug #4 — Magic Number 9 for Draw (Line 23)
Problem:history.Count == 9 is a magic numberA magic number is a literal value that appears in code without explanation. The number 9 here means "3 * 3 = total cells on the board." But if you change the board size to 4x4, you'd need to change this to 16 — and it's easy to miss. Named constants make the intent clear and the code maintainable. derived from 3 x 3. If the board size ever changes, this silent assumption breaks. The draw detection should be based on the board's actual state (is it full?), not a hardcoded count.
Fix: Replace with board.IsFull — a computed property that checks the actual grid, not a magic number.
Problem: The CheckWin method checks rows, columns, and the main diagonal (top-left to bottom-right). But it completely forgets the anti-diagonal (top-right to bottom-left: positions [0,2], [1,1], [2,0]). A player who wins along that line will never be detected as the winner. The game continues even though someone has already won.
Fix: Add the missing check: if (board[0,2]==p && board[1,1]==p && board[2,0]==p) return true;. Better yet, use a direction-based algorithm that checks all 4 directions systematically, so you can't accidentally miss one.
Taught in: Level 1 — Win Detection
Score Yourself: All 5: Senior-level code review skills. You see design flaws AND logic bugs. 3-4: Solid mid-level. You caught the obvious ones — review the levels for the ones you missed. 1-2: Go back to Levels 1-3. The patterns will click once you've seen the problems they prevent. 0: No shame — code review is a skill you build with practice. Re-read this section after finishing the case study.
The real lesson: Notice that bugs #1, #2, and #3 are design problems, not logic mistakes. The missing diagonal (bug #5) is a simple logic error that's easy to spot and fix. But the boolean flags, console coupling, and broken undo are structural issues that require rethinking the architecture. In code reviews, design-level feedback is more valuable than bug-level feedback — because design flaws multiply into dozens of bugs over time.
Section 16
The Interview — Both Sides of the Table
Tic-Tac-Toe looks like a toy problem. That's exactly why interviewers love it — most candidates underestimate it, rush to code, and miss the design decisions hiding inside a "simple" game. Below you'll see two interview runs: the polished one and the realistic one (with stumbles). Both get hired. The difference is how they recover.
Time
Candidate Says
Interviewer Thinks
0:00
"Before I code, let me clarify scope. Is it 3x3 only or NxN? Do we need an AI opponent? Undo/redo? Is this console or does it need a GUI abstraction?"
Great — scoping a "toy" problem shows they treat every design seriously.
2:00
"Functional: place a mark, detect winner, alternate turns, optional AI. Non-functional: extensible to NxN, undoable moves, testable without UI."
F/NF split on a game? That's senior-level thinking applied to any problem.
4:00
"Entities: Board (owns the grid), Cell (position + mark), Mark enum (X, O, Empty). Game orchestrates flow. Player is an interface — human and AI implement it."
Clean entity extraction. Player as interface — already thinking about extensibility.
6:00
"Game flow has distinct modes: not started, in progress, won, draw. That's a State pattern — each mode has different rules for what's allowed."
Pattern motivated by the problem. Didn't just name-drop — explained WHY.
8:00
"For undo, I'll use Command — each move is an object I can reverse. For AI difficulty, Strategy — swap algorithms without touching game logic."
Three patterns, each justified by a specific need. Not over-engineering.
10:00
Starts coding: enums, Board, IGameState, Game class...
Watching for: sealed classes, Result types, clean separation
22:00
"Edge cases: placing on an occupied cell returns a Result error, placing after game over is blocked by the state, undo on empty history returns failure."
Proactive edge cases. Most candidates only handle the happy path.
26:00
"For NxN: the board becomes parameterized, win detection uses a general line-checker instead of hardcoded diagonals, and AI strategy needs alpha-beta pruning for larger boards."
Scaling a game design. Shows the LLD isn't locked to 3x3. Strong Hire.
Time
Candidate Says
Interviewer Thinks
0:00
"Tic-Tac-Toe... OK, so a 3x3 grid. Let me start with a 2D array..."
Slow start. Jumped to data structure. Let's see if they recover.
1:30
"Actually wait — let me ask: do you want just the basic game, or should I consider AI and undo too?"
Good recovery. Self-corrected to scope first.
4:00
"I'll make a Game class with a PlayingState boolean... hmm, actually there are four states: not started, playing, won, draw. That's more than a boolean."
Self-corrected from boolean to states! This is BETTER than getting it right instantly — shows real-time thinking.
7:00
"For the states, I could use an enum with a switch... but each state has different rules. Let me use the State pattern — each state is its own class."
Explored the simple path, found the limit, upgraded. That's engineering judgment.
12:00
Coding... pauses... "I need to think about how undo works here..."
Pausing is fine. Silence beats rushing into wrong code.
13:00
"Each move should be an object — that's Command. I store a stack of commands, and undo pops the last one and reverses it."
Needed a moment but the solution is solid and well-articulated.
20:00
Interviewer: "What if someone tries to place a mark on an occupied cell?"
Testing if candidate handles invalid input gracefully.
20:30
"Oh — I should return a Result type instead of throwing. Let me refactor PlaceMark to return Result<T> with success or an error message."
Needed a nudge but responded immediately with a solid approach.
26:00
"For extensibility, the Board size could be a constructor parameter, and the win checker would scan rows, columns, and diagonals generically. I'd also add Observer for UI updates."
Good scaling vision. Honest about what was missed. Strong Hire.
Scoring Summary
The Clean Run
Strong Hire
Scoped before coding — clarified NxN, AI, undo
F/NF split stated upfront
Three patterns, each motivated by a specific problem
Proactive edge cases — not prompted
Scaling vision (NxN + alpha-beta)
The Realistic Run
Strong Hire
Slow start — recovered with scope questions
Self-corrected boolean → State pattern
Paused to think about undo — then nailed Command
Needed nudge on edge cases — responded with Result<T>
Honest, structured recovery throughout
Common Follow-up Questions Interviewers Ask
"How would you add a third player?" — Tests if Mark is hardcoded as X/O or extensible
"What if the board is 100x100?" — Tests if win detection is O(n) or O(n²) and whether you'd change the AI
"Can two games run at the same time?" — Tests if state is instance-level or global
"How would you add a replay feature?" — Tests if moves are stored as objects (Command) or just side effects
"What would you test first?" — Tests if you think about testability as part of the design
Key Takeaway: Two very different styles. Same outcome.
Interviewers don't grade on polish — they grade on THINKING.
A stumble you recover from is often more impressive than a flawless run — because it shows how you handle real-world ambiguity.
Section 17
Articulation Guide — What to SAY
Here’s something most candidates get wrong: they treat articulation as a side-effect of knowing the design. It isn’t. Design skill and communication skill are separate muscles — you can have a brilliant Tic-Tac-Toe design locked in your head and still tank the interview because you can’t narrate it under pressure. The 8 cards below cover the exact situations where phrasing matters most.
1. Opening the Problem
Situation: The interviewer says “Design Tic-Tac-Toe.” You feel the pull to start typing.
Say: “Before I start, let me scope this. Is it 3x3 or NxN? Do we need AI? Undo/redo? Is the UI in scope or just the game engine?”
Don’t say: “OK so I’ll make a 2D array...” (jumping to implementation without scoping)
Why it works: Even a "toy" problem has hidden scope. Asking questions on a simple problem is MORE impressive — it shows the habit is automatic, not performative.
2. Entity Decisions
Situation: You’re deciding how to model the board, cells, and marks.
Say: “Mark is an enum — X, O, Empty are categories with no behavior. Board is a class because it owns the grid and has behavior like PlaceMark and CheckWinner. Player is an interface because human and AI have different move strategies.”
Don’t say: “I’ll use a 2D char array.” (no reasoning, no type modeling)
Why it works: Shows you choose types deliberately. Enum vs interface vs class is a real design decision.
3. State Pattern Choice
Situation: You’re about to introduce the State pattern. The interviewer might ask “why not just a boolean?”
Say: “The game has four distinct modes: not started, in progress, won, and draw. Each mode has different rules — you can only place marks in ‘in progress,’ and the game transitions itself to the next state. A boolean can’t capture four modes, and an enum plus switch would scatter the logic. State pattern keeps each mode’s rules in one place.”
Don’t say: “I’ll use State pattern because the game has states.” (circular reasoning)
Why it works: The pattern emerged from counting modes and noticing each has different rules. That's problem-driven, not memorized.
4. Command for Undo
Situation: The interviewer asks about undo. You need to explain why moves are objects.
Say: “To undo a move, I need to know what happened. If a move is just a method call, it’s gone the moment it runs. But if each move is an object — ‘Player X placed at row 1, col 2’ — I can store it, reverse it, even replay the whole game. That’s the Command pattern: actions become data.”
Don’t say: “I’ll use Command because I need undo.” (names the pattern without explaining the insight)
Why it works: The "actions become data" phrase is memorable and shows you understand the core idea, not just the class diagram.
5. Strategy for AI
Situation: You’re explaining AI difficulty levels. The interviewer pushes: “Why not just pass a difficulty enum?”
Say: “Each difficulty level uses a genuinely different algorithm — random for easy, one-move-ahead for medium, minimax for hard. An enum would need a switch statement that grows with every new difficulty. Strategy lets each algorithm be its own class. Swap at runtime, test in isolation, add new difficulties with zero changes to the Game class.”
Don’t say: “Strategy is the standard pattern for swappable algorithms.” (textbook answer, no connection to the problem)
Why it works: You connected Strategy to a concrete problem — genuinely different algorithms, not just different config values.
6. Edge Cases
Situation: You’ve finished the happy path. Most candidates stop here.
Say: “Let me think about what could go wrong. Placing on an occupied cell — Result error, not an exception. Placing after game over — the State rejects it. Undoing when there’s nothing to undo — empty stack check. AI asked to move on a full board — no valid moves returned. Each of these needs an explicit decision, not a crash.”
Don’t say:(nothing — most candidates never mention edge cases unprompted)
Why it works: Proactive edge-case thinking is the single strongest "Strong Hire" signal. It shows production thinking, not whiteboard thinking.
7. Scaling to NxN
Situation: The interviewer asks “What if the board is 10x10?”
Say: “Board size becomes a constructor parameter. Win detection generalizes — instead of checking hardcoded diagonals, I scan rows, columns, and diagonals of length N. The State and Command patterns don’t change at all. AI strategy matters more — minimax on 10x10 is too slow without alpha-beta pruning or depth limits. I’d swap in a depth-limited strategy.”
Don’t say: “I’d just change the array size.” (misses algorithm and performance implications)
Why it works: Shows you know what changes (win detection, AI performance) and what doesn't (patterns, game flow).
8. “I Don’t Know”
Situation: The interviewer asks about multiplayer networking and you’ve never built a real-time game server.
Say: “I haven’t built real-time multiplayer, but the approach is clear: each move is already a Command object, so I’d serialize it and send it over a WebSocket. The server validates and broadcasts. The game engine stays the same — the transport layer wraps around it. I’d need to handle latency and conflict resolution, which I’d research.”
Don’t say: “I don’t know multiplayer.” (full stop, no reasoning)
Why it works: Honesty about a gap + clear reasoning about the approach = respect. Bluffing or shutting down = red flag.
Pro Tip — Practice OUT LOUD, not just in your head
Reading these cards silently builds recognition. Saying them aloud builds production. There’s a real difference: recognition fails under pressure because retrieval competes with anxiety. Muscle memory is automatic. Try this: set a 5-minute timer, explain this Tic-Tac-Toe design to an imaginary interviewer out loud. You’ll hear where you hesitate — that’s where you need more practice.
Target three phrases for fluency: the State pattern justification ("four modes, each with different rules"), the Command insight ("actions become data"), and the Strategy distinction ("genuinely different algorithms, not config values"). These are the exact spots where candidates go vague under pressure.
Section 18
Interview Questions & Answers
10 questions ranked by difficulty. Each has a "Think" prompt, a solid answer, and the great answer that gets "Strong Hire." These aren't hypothetical — they're the exact questions interviewers ask when they see a Tic-Tac-Toe design.
Q1: Why did you use a State pattern instead of a simple boolean or enum for game status?
Easy
Think: How many game modes exist? Does each mode have different rules for what's allowed?
Think about a traffic light. You could track its color with a string variable — "red", "green", "yellow". But each color has different rules: green means cars go, red means stop, yellow means slow down. If you put all that logic in switch statements scattered across your code, adding a "flashing red" mode means hunting through every switch. A State pattern puts each mode's rules in one place.
Answer: The game has four modes (not started, playing, won, draw), each with different allowed actions. State pattern keeps each mode's rules isolated in its own class.
Great Answer: "A boolean only gives me two states — but the game has four. An enum gets me four labels but still needs switch statements everywhere. The State pattern gives me four labels AND four behavior sets, each in its own class. Adding a 'paused' state later means one new class — zero changes to Game."
What to SAY: "Boolean = 2 states. Enum = labels without behavior. State pattern = labels + behavior + extensibility. Four modes with different rules = State."
Q2: How does the Command pattern enable undo/redo in your design?
Easy
Think: What information does "undo" need? Can you get it from a method call that already ran?
Think about the undo button in a text editor. Every keystroke is remembered — not just "a key was pressed" but "the letter 'A' was inserted at position 42." That's enough information to reverse it: delete the character at position 42. If the editor only tracked "something happened," undo would be impossible. The same logic applies to game moves.
Answer: Each move is stored as a Command object with Execute and Undo methods. A stack tracks history — undo pops the last command and calls Undo().
Great Answer: "Without Command, a move is a side effect — the board changes and the information is lost. With Command, a move is data: 'Player X placed at (1,2)'. That data lets me: undo by calling Undo(), redo by re-executing from a redo stack, replay the entire game by iterating the history, and even serialize moves for a save/load feature. The pattern doesn't just enable undo — it makes every move a first-class citizen."
Command Undo/Redo in 15 Lines
private readonly Stack<IMoveCommand> _history = new();
private readonly Stack<IMoveCommand> _redoStack = new();
public Result<GameResult> PlaceMark(int row, int col)
{
var cmd = new PlaceMarkCommand(_board, row, col, CurrentPlayer);
var result = cmd.Execute(); // place the mark
if (result.IsSuccess)
{
_history.Push(cmd); // remember it
_redoStack.Clear(); // new move = new timeline
}
return result;
}
public bool Undo()
{
if (_history.Count == 0) return false;
var cmd = _history.Pop();
cmd.Undo(); // reverse the mark
_redoStack.Push(cmd); // save for redo
return true;
}
What to SAY: "Without Command, a move is a lost side effect. With Command, it's data — storable, reversible, replayable."
Q3: How does your AI strategy work, and how would you add a new difficulty level?
Easy
Think: Is adding a difficulty a config change or a code change? How many files would you touch?
Think about a chess engine with difficulty settings. Easy mode plays random legal moves. Medium looks one move ahead. Hard uses deep search. These aren't just parameter tweaks — they're fundamentally different algorithms. You wouldn't put all three in one method with if/else — you'd give each its own class.
Answer: Each AI difficulty implements IPlayerStrategy. Adding a new difficulty means one new class — zero changes to existing code.
Great Answer: "Each strategy is a genuinely different algorithm: RandomStrategy picks any empty cell, BlockingStrategy checks if the opponent is about to win and blocks, MinimaxStrategy explores all possible game trees. Adding 'Medium-Hard' (minimax with depth limit 3) is one new class implementing IPlayerStrategy. The Game class doesn't know or care which algorithm runs — it just calls ChooseMove(board)."
Adding a New AI Difficulty — 1 File, 0 Edits
// Existing strategies -- untouched
public sealed class RandomStrategy : IPlayerStrategy { ... }
public sealed class MinimaxStrategy : IPlayerStrategy { ... }
// NEW: depth-limited minimax -- just add this file
public sealed class DepthLimitedStrategy(int maxDepth = 3) : IPlayerStrategy
{
public (int Row, int Col) ChooseMove(Board board, Mark mark)
{
// Minimax but stops at maxDepth instead of exploring everything
return Minimax(board, mark, depth: 0, maxDepth);
}
}
// Game class: zero changes. Just inject the new strategy.
var game = new Game(new HumanPlayer(), new AiPlayer(new DepthLimitedStrategy(3)));
What to SAY: "Different algorithms, not different config values. One new class, zero edits to Game. That's the Strategy payoff."
Q4: Why use Result<T> instead of throwing exceptions for invalid moves?
Medium
Think: Is an invalid move an "exceptional" event or an expected one? How often does a user click an occupied cell?
Think about a form validation. When a user types an invalid email, you don't crash the app — you show a red border and a message. Invalid emails are expected, not exceptional. Same with placing a mark on an occupied cell: it's a user mistake that will happen regularly, not a system failure.
Answer: Exceptions are for unexpected failures. Invalid moves are expected user errors — Result<T> communicates the failure without the performance cost and control flow confusion of exceptions.
Great Answer: "Three reasons. (1) Semantics: an occupied cell isn't a system failure, it's a user mistake. Exceptions imply something broke. (2) Performance: throwing and catching exceptions is 100-1000x slower than returning a Result. In a game loop, that matters. (3) Forced handling: the caller MUST check the Result. With exceptions, the caller can just... not catch them, and the app crashes. Result makes the error visible in the type signature."
What to SAY: "Expected failure = Result. Unexpected failure = Exception. Don't use exceptions for control flow."
Q5: How would you make this design testable without a real UI?
Medium
Think: Can you test win detection without drawing a board? Can you test AI without a human typing input?
The key insight is dependency inversionInstead of a class creating its own dependencies (new ConsoleRenderer()), dependencies are provided from outside via constructor parameters. Tests inject fakes; production injects real implementations. — the game engine should never know about Console.ReadLine or a GUI framework. The game talks to interfaces; tests provide fakes.
Answer: All dependencies are interfaces. Tests inject fakes: a fake player that returns pre-programmed moves, a fake renderer that captures output.
Great Answer: "Three layers of testability. (1) Board logic: pure functions — give it a grid, check for winner, no dependencies at all. (2) Game flow: inject IPlayer fakes that return scripted moves — test full games deterministically. (3) AI strategy: each strategy is a pure function of the board — give it a board state, assert the move. No UI, no randomness (inject a seeded Random), no timing issues."
Testing Without a UI
// Fake player that returns scripted moves -- no console input needed
public sealed class ScriptedPlayer(Queue<(int, int)> moves) : IPlayer
{
public Mark Mark { get; init; }
public (int Row, int Col) GetMove(Board board) => moves.Dequeue();
}
[Fact]
public void X_wins_with_top_row()
{
var x = new ScriptedPlayer(new([(0,0), (0,1), (0,2)])) { Mark = Mark.X };
var o = new ScriptedPlayer(new([(1,0), (1,1)])) { Mark = Mark.O };
var game = new Game(x, o);
game.PlayToEnd();
Assert.Equal(GameStatus.Won, game.Status);
Assert.Equal(Mark.X, game.Winner);
}
What to SAY: "Interfaces at every seam. Tests inject fakes. Board logic is pure. AI is a pure function of the board."
Q6: How would you extend this to an NxN board?
Medium
Think: What's hardcoded to 3? What algorithm assumptions break at larger sizes?
In our design, board size should be a constructor parameter, not a magic number. The tricky parts are win detection (can't hardcode diagonal positions) and AI (minimax is too slow for large boards).
Answer: Board takes size as a parameter. Win detection scans rows, columns, and diagonals generically. AI needs depth-limiting for larger boards.
Great Answer: "Three things change: (1) Board:new Board(size: 10) — already parameterized. (2) Win detection: instead of checking 8 hardcoded lines, I scan all rows, all columns, and both diagonals dynamically. O(n) per line, O(n) lines = O(n²) total. (3) AI: minimax on a 10x10 board has ~10! nodes — impossible. I'd swap to a depth-limited strategy or Monte Carlo Tree SearchAn AI algorithm that plays random games from the current position and picks the move that wins most often statistically. Works on large boards where exhaustive search (minimax) is too slow. Used by AlphaGo.. The State and Command patterns? Completely unchanged."
What to SAY: "Board and win-check parameterize on N. AI needs smarter algorithms for big boards. Patterns are untouched — that's the payoff of good design."
Q7: How would you add Observer to notify a UI when the board changes?
Medium
Think: Should the Game class know about the UI? What if there are multiple UIs (console + web)?
The game engine should be UI-agnostic. It should announce "something happened" without knowing who's listening. A console renderer, a web socket, and a logging system might all want to know about each move — but the game shouldn't have references to any of them.
Answer: Game raises events like MovePlaced and GameOver. UI components subscribe. The game never references the UI.
Great Answer: "I'd add C# events to the Game class: event Action<MoveEvent> OnMovePlaced and event Action<GameResult> OnGameOver. The UI subscribes at startup. The game fires events after each state transition. This means: console renderer subscribes for text output, a web UI subscribes via SignalR, a replay logger subscribes to write moves to a file — all without the Game knowing any of them exist. Adding a new subscriber is zero changes to Game."
Observer via C# Events
// Game publishes events -- doesn't know who listens
public sealed class Game
{
public event Action<Mark, int, int>? OnMovePlaced;
public event Action<GameResult>? OnGameOver;
public Result<GameResult> PlaceMark(int row, int col)
{
// ... place mark, check winner ...
OnMovePlaced?.Invoke(CurrentPlayer, row, col);
if (gameResult is not null)
OnGameOver?.Invoke(gameResult);
}
}
// Console subscribes -- Game doesn't know this class exists
game.OnMovePlaced += (mark, r, c) => Console.WriteLine($"{mark} at ({r},{c})");
game.OnGameOver += result => Console.WriteLine($"Game over: {result}");
What to SAY: "Game fires events. UIs subscribe. Adding a new display is zero changes to Game. That's Observer."
Q8: How does dependency injection fit into this design?
Medium
Think: Who decides which AI strategy to use? Who creates the Game object? Where does that wiring live?
Think about how a restaurant works. The chef doesn't go to the market to buy ingredients — someone else delivers them. The chef just uses whatever arrives. That's DI: the Game class doesn't create its own players or strategies — they're delivered from outside.
Answer: Game takes IPlayer instances via constructor. Strategy is injected into AiPlayer. The composition root (Program.cs or DI container) wires everything.
Great Answer: "Every dependency is an interface: IPlayer, IPlayerStrategy, IWinChecker. The Game class never uses new for its dependencies — they arrive via constructor. This means: in production, I wire new AiPlayer(new MinimaxStrategy()). In tests, I wire new ScriptedPlayer([...]). The Game class is identical in both cases — it's the composition rootThe single place in your app where all the objects are created and wired together. In .NET: Program.cs or the DI container. No other class should use 'new' for its dependencies. This keeps all wiring decisions in one place. that differs."
What to SAY: "Game takes interfaces. Tests inject fakes. Production injects real implementations. One class, two contexts, zero changes."
Q9: How would you implement a tournament mode with multiple games?
Hard
Think: Is a tournament a different entity from a game? What new responsibilities appear?
A tournament is a higher-level concept that manages multiple games. It tracks scores, decides matchups, and determines an overall winner. The Game class doesn't change at all — the tournament just creates and runs Game instances.
Answer: A Tournament class orchestrates multiple Game instances, tracking scores across rounds.
Great Answer: "The Tournament is a new entity that sits ABOVE Game. It owns: a list of players, a scoring table, and a matchup algorithm (round-robin, bracket, etc.). Each round creates a fresh Game with two players, runs it, and records the result. The matchup algorithm is itself a Strategy — swap between round-robin and single-elimination without changing Tournament. Key insight: Game stays untouched. Tournament is purely additive."
What to SAY: "Tournament sits above Game. Creates fresh Game per round, records results. Game is untouched — purely additive feature."
Q10: What real-world applications use the same patterns as your Tic-Tac-Toe?
Hard
Think: Where else do you see state machines, undoable actions, and swappable algorithms in production software?
This question tests whether you see patterns as transferable tools or game-specific tricks. The patterns in Tic-Tac-Toe appear everywhere in production software.
Answer: State machines in order processing, Command in text editors, Strategy in payment gateways.
Great Answer: "State: e-commerce order flow (pending → paid → shipped → delivered), each state has different allowed actions. Command: text editors (Ctrl+Z), database migrations (up/down), git commits (revert). Strategy: payment processing (Stripe vs PayPal vs crypto), compression (gzip vs brotli), authentication (JWT vs OAuth). Observer: real-time dashboards, notification systems, event-driven microservices. These aren't 'game patterns' — they're production architecture patterns that Tic-Tac-Toe happens to teach perfectly because it's small enough to see them all at once."
What to SAY: "These aren't game patterns — they're production patterns. Tic-Tac-Toe just lets you see them all at once in a small, learnable system."
Section 19
8 Deadliest Tic-Tac-Toe Interview Mistakes
Every one of these has ended real interviews. They're especially dangerous on a "simple" problem like Tic-Tac-Toe because candidates lower their guard — they think the problem is easy and skip the fundamentals that interviewers actually grade on.
●
Critical Mistakes — Interview Enders
#1 Skipping scope entirely — jumping straight to a 2D array
Why this happens: "It's just Tic-Tac-Toe — everyone knows the rules." So you skip clarifying questions and start typing char[3,3] board. But the interviewer is watching to see if you scope problems habitually. Skipping scope on a simple problem is worse than skipping it on a hard one — it reveals the habit is performative, not automatic.
Five minutes in, the interviewer asks: "What about undo?" You didn't plan for it. Your board is a raw array with no move history. Now you're retrofitting undo into a design that never considered it, while the clock ticks.
Bad — Immediately Coding
// Interviewer: "Design Tic-Tac-Toe"
// Candidate immediately types...
char[,] board = new char[3, 3]; // no scoping, no clarification
// 5 min later: "Wait, do you need undo?"
// Candidate: *panic rewrite*
Good — Scope First
// "Before I code: 3x3 or NxN? AI? Undo? Console or GUI-agnostic?"
// Interviewer: "3x3 with undo and AI"
// NOW the candidate knows the full scope before line 1.
// Board is a class (not an array), moves are Commands, AI is a Strategy.
What the interviewer thinks: "Doesn't scope. Will build the wrong thing in production for two weeks before asking what the customer actually needed."
Fix: Spend 2-3 minutes asking: board size? AI? undo? UI scope? Then summarize requirements before touching code. On a "simple" problem, this is even MORE impressive.
#2 God class — everything inside one TicTacToe class
Why this happens: "It's a small game — why would I need more than one class?" So you put board logic, win detection, turn management, AI, display, and undo all in TicTacToe.cs. The class starts at 50 lines and seems fine. But every feature the interviewer asks about — AI, undo, NxN — gets bolted onto the same class. By the end, it's 300 lines of tangled code where a change to AI might break win detection.
Bad — God Class
public class TicTacToe // does EVERYTHING
{
private char[,] _board = new char[3, 3];
public void PlaceMark(int r, int c) { ... }
public bool CheckWinner() { ... } // win logic here
public void AiMove() { ... } // AI logic here
public void PrintBoard() { ... } // display logic here
public void Undo() { ... } // undo logic here
// 250 more lines...
}
Good — Separated Responsibilities
Board // owns grid + PlaceMark + CheckWinner
Game // orchestrates turns, delegates to state
IGameState // each mode's rules (State pattern)
IPlayer // human vs AI (Strategy via IPlayerStrategy)
IMoveCommand // undo/redo (Command pattern)
// Each class: one job. Change AI without touching Board.
What the interviewer thinks: "No separation of concerns. This person creates classes that grow until nobody can maintain them."
Fix: Ask "what changes independently?" Board rules, AI strategy, game flow, and display all change for different reasons. Each gets its own class.
#3 Hardcoding 3x3 — magic numbers everywhere
Why this happens: "The problem says 3x3, so I'll code for 3x3." You write new char[3,3], check board[0,0] == board[1,1] == board[2,2] for diagonals, and loop for (int i = 0; i < 3; i++). Everything works — until the interviewer asks "What about 5x5?" and you realize 3 is baked into 20 places.
What the interviewer thinks: "Can't generalize. Will write code that breaks when requirements change even slightly."
Fix: Board takes size as a constructor parameter. Every loop uses board.Size. Win detection scans dynamically. Even if the scope is 3x3, parameterizing shows engineering maturity.
●
Serious Mistakes — Significant Red Flags
#4 Throwing exceptions for invalid moves
Why this happens: You learned that errors = exceptions. So when someone places on an occupied cell, you throw InvalidOperationException. But clicking a taken cell isn't a system failure — it's a user mistake that will happen every game. Exceptions are for surprises; validation failures are expected.
Bad — Exception for Validation
public void PlaceMark(int row, int col)
{
if (_board[row, col] != Mark.Empty)
throw new InvalidOperationException("Cell occupied!");
// caller MIGHT forget to catch -- app crashes
}
Good — Result Type
public Result<GameResult> PlaceMark(int row, int col)
{
if (_board[row, col] != Mark.Empty)
return Result.Fail("Cell is already occupied");
// caller MUST check .IsSuccess -- can't ignore
}
What the interviewer thinks: "Uses exceptions for control flow. Will write fragile code that crashes on expected user input."
Fix: Return Result<T> for expected failures. Reserve exceptions for genuine surprises (null references, disk failures). The Result type makes errors visible in the type signature — the caller can't ignore them.
#5 Never mentioning testability
Why this happens: You focus on making the game work and never stop to ask: "Could I write a unit test for this?" Your Game class calls Console.ReadLine() directly, so testing requires an actual human typing input. Your AI uses Random without a seed, so tests are non-deterministic.
Bad — Untestable
public (int, int) GetHumanMove()
{
Console.Write("Enter row,col: "); // hardcoded to console
var input = Console.ReadLine()!; // can't test without human
return Parse(input);
}
public (int, int) GetAiMove()
{
var random = new Random(); // non-deterministic
return (random.Next(3), random.Next(3));
}
Good — Testable via Interfaces
// IPlayer interface -- tests inject ScriptedPlayer
public interface IPlayer
{
(int Row, int Col) GetMove(Board board);
}
// AI takes injected Random for deterministic tests
public sealed class RandomStrategy(Random random) : IPlayerStrategy
{
public (int, int) ChooseMove(Board b, Mark m)
{
var empty = b.GetEmptyCells();
return empty[random.Next(empty.Count)];
}
}
// Test: new RandomStrategy(new Random(seed: 42)) -- deterministic!
What the interviewer thinks: "Doesn't think about quality. Will write code that's impossible to test and unsafe to refactor."
Fix: Every external dependency is an interface. Tests inject fakes. Random gets a seed. One sentence on testability in the interview signals senior thinking.
#6 Pattern name-dropping without motivation
Why this happens: You studied all 23 patterns and want to show it. So you add State, Command, Strategy, Observer, Factory, AND Decorator to a Tic-Tac-Toe game — without explaining which problem each one solves. When the interviewer asks "why Decorator here?", you hesitate because there's no real answer.
The test: Can you complete this sentence for every pattern you use? "I'm using [Pattern] because [specific problem]. Without it, [this bad thing happens]." If you can't finish the sentence, remove the pattern.
What the interviewer thinks: "Pattern collector, not problem solver. Knows the names but not when to use them. Cargo-cult design."
Fix: Three patterns are natural for Tic-Tac-Toe: State (game modes), Command (undo), Strategy (AI). Each solves a real problem. Don't add more unless asked. Quality over quantity.
●
Minor Mistakes — Missed Opportunities
#7 No scaling or extension discussion
You build a working 3x3 game and stop. The interviewer waits for you to mention NxN, tournament mode, or multiplayer networking. You don't. This won't fail you, but it's a missed chance to show breadth. Senior engineers naturally think about "what if the requirements change?"
What the interviewer thinks: "Solid implementation but doesn't think ahead. Good for mid-level, not yet senior."
Fix: After finishing the core design, say: "For NxN, the board parameterizes, win detection generalizes, and AI needs depth-limiting. For multiplayer, moves serialize as Commands over WebSockets." Two sentences. Massive signal.
#8 Not mentioning edge cases unprompted
You implement the happy path — X and O take turns, someone wins or it's a draw. But you never mention: what if someone places on an occupied cell? What about placing after the game is over? What about undo on an empty history? The interviewer has to prompt each one. You answer correctly, but the fact that you needed prompting drops you from "proactive" to "reactive."
What the interviewer thinks: "Handles edge cases when asked but doesn't think of them independently. Solid but not outstanding."
Fix: After finishing your core design, pause and say: "Let me think about edge cases." Then list 3-4: occupied cell, game-over move, empty undo stack, full-board AI. Proactive edge-case thinking is the single biggest separator between mid-level and senior candidates.
Key Takeaway: The deadliest mistakes on a "simple" problem aren't about the solution — they're about the process. Scoping, separating concerns, choosing patterns deliberately, mentioning testability and edge cases. These habits matter more than whether your minimax is perfect.
Section 20
Memory Anchors — Never Forget This
You just built a Tic-Tac-Toe game from scratch and discovered three design patterns along the way. Now let's lock those patterns into long-term memory. The trick isn't rote repetition — it's anchoring each concept to something vivid. A story, a place, a picture. When you can "see" it, you can recall it.
The SCS Trio — State, Command, Strategy
State tracks where you are •
Command records what you did •
Strategy decides how to act
"Where am I? What did I do? How do I play?" — Three questions, three patterns. Ask them in order and you've covered every Tic-Tac-Toe concern.
Memory Palace — Walk Through a Game Room
Imagine walking into a game room with three stations. Each station represents one of the three core patterns. As you walk through, the pattern clicks into place.
Memory Palace — Game Room → Design Patterns
The Story Mnemonic — A Game Night
Picture this: You sit down for game night. First, you check the scoreboard on the wall to see who's X and who's O, and whose turn it is — that's State. Then you grab a notebook and write down every move, so if someone bumps the table, you can rewind — that's Command. Finally, you open a playbook and pick your strategy: random moves for beginners, or Minimax for the competitive players — that's Strategy.
Next time you're in an interview and someone says "design a game," ask yourself: Do I need a scoreboard? A notebook? A playbook? The answer to those three questions gives you 90% of the design.
Flashcards — Quiz Yourself
Click each card to reveal the answer. If you can answer without peeking, the pattern is sticking.
Which pattern tracks whose turn it is?
State pattern. The game transitions between XTurn, OTurn, XWins, OWins, and Draw. Each state knows what moves are legal and what happens next.
How do you add undo/redo?
Command pattern. Each move is an object (PlaceMark) stored in a history stack. Undo pops the last command and reverses it. Redo pushes it back.
How do you swap AI difficulty?
Strategy pattern. Define IAiStrategy with a PickMove() method. RandomAi, MinimaxAi, and HeuristicAi each implement it differently. Swap at runtime.
Smell → Pattern Quick Reference
Smell
Signal
Response
If/else chain for game phases
Behavior changes with game state
State pattern
Can't undo a move
Actions aren't recorded as objects
Command pattern
AI difficulty is hardcoded
Algorithm varies at runtime
Strategy pattern
Board checks scattered everywhere
Validation logic is duplicated
Extract into Board class
Win detection is a giant if block
Complex condition needs encapsulation
WinChecker service
5 Things to ALWAYS Mention in a Tic-Tac-Toe Interview
Game state machine (not boolean flags)
Command pattern for undo/redo
Strategy for swappable AI
Board as a separate entity (not mixed into game logic)
Extensibility: NxN boards, custom win rules
Section 21
Transfer — These Patterns Work Everywhere
You didn't just learn how to build a game. You learned a set of thinking moves — state management, action recording, and algorithm swapping. Those three ideas appear in virtually every interactive system, from chat apps to banking software. Below is the proof: the exact same three patterns, applied to four different domains. Same skeleton, different skin.
Pattern
Tic-Tac-Toe Use
Chess
Vending Machine
Workflow Engine
State
XTurn → OTurn → XWins / Draw
WhiteTurn → BlackTurn → Check → Checkmate
Idle → HasMoney → Dispensing → OutOfStock
Draft → Pending → Approved → Rejected
Wherever behavior changes based on "what phase are we in?" — that's a State pattern waiting to happen. The states differ by domain, but the transition logic is identical.
Command
PlaceMark(row, col) with undo
MovePiece(from, to) with undo
InsertCoin(amount) with refund
ApproveStep(id) with rollback
Whenever you need to undo, replay, or log actions — wrap each action in a Command object. The "undo" method just reverses whatever "execute" did.
Strategy
RandomAi vs MinimaxAi
NoviceEngine vs GrandmasterEngine
CashPayment vs CardPayment
AutoApprove vs ManagerApprove
When there's more than one way to do the same job and the choice can change at runtime — extract it behind an interface. New algorithm = new class, zero changes to existing code.
Transfer Web — One Game, Many Domains
Think of Tic-Tac-Toe as the center of a web. Every pattern you learned radiates outward into entirely different systems. The domain changes, but the structural move stays the same.
Transfer Web — Patterns Radiate to Other Domains
Same Skeleton, Different Skin
Here's the key insight: the structure of your code doesn't change when the domain changes. An IState interface, a History<ICommand> stack, and an IStrategy injection point look the same whether you're building a board game or a banking app. Only the concrete classes change.
Same Skeleton — Interface stays, implementation swaps
Quick Transfer — Three Concrete Examples
Each card shows a technique from Tic-Tac-Toe dropped into a completely different domain. Same move, different arena.
Command → Text Editor
In Tic-Tac-Toe, every move is a PlaceMark command with an undo. A text editor does the same: every keystroke is a TypeChar command, every delete is a DeleteChar command. Ctrl+Z pops the history stack. The pattern is identical — only the action changes.
State → E-Commerce Order
Tic-Tac-Toe game phases (XTurn → OTurn → Win/Draw) map directly to order phases (Placed → Paid → Shipped → Delivered). Each state has different legal actions: you can cancel a "Placed" order but not a "Shipped" one. Same State pattern, retail domain.
Strategy → Ride-Hailing
Tic-Tac-Toe swaps AI difficulty via IAiStrategy. A ride-hailing app swaps pricing algorithms via IPricingStrategy — surge pricing, flat rate, subscription. Same injection point, same interface contract, completely different domain.
The insight: Patterns aren't domain-specific. They target structural problems that recur everywhere: "behavior varies by phase" (State), "actions need undoing" (Command), "algorithms are swappable" (Strategy). Learn the structure once, apply it forever.
Section 22
The Reusable Toolkit
Five thinking tools you picked up in this case study. Each one is a portable mental move — not a Tic-Tac-Toe trick, but something you can use in any LLD interview or real-world design. Here's what each tool is, when to reach for it, and where you used it in this case study.
Your Toolkit — 5 Portable Thinking Tools
What Phase?
Ask: "Does the system behave differently depending on what happened before?" If yes, you have a State pattern candidate. List every phase the system can be in, and what's legal in each phase.
Tic-Tac-Toe use: Levels 2-3 — game phases (XTurn, OTurn, XWins, OWins, Draw) control what moves are legal and when the game ends.
Need Undo?
Ask: "Should users be able to reverse what they just did?" If yes, wrap each action in a Command object with Execute() and Undo(). Push to a history stack. Pop to undo.
Tic-Tac-Toe use: Level 3 — PlaceMark command stores row, col, and player. Undo clears the cell and switches the turn back.
What Varies?
Ask: "Is there more than one way to do this? Could it change at runtime?" If yes, extract the algorithm behind an interface. New algorithms = new classes, zero changes to existing code.
Tic-Tac-Toe use: Level 4 — AI difficulty varies (Random, Minimax, Heuristic). IAiStrategy lets you swap algorithms without touching game logic.
What If?
After your happy path works, run through four categories: Concurrency, Failure, Boundary, and Weird Input. Each category surfaces edge cases that the happy path ignores.
Tic-Tac-Toe use: Level 5 — What if the same cell is clicked twice? What if the board is full with no winner? What if the AI takes too long?
Can I Test It?
Ask: "Can I write a unit test for this class without spinning up the entire app?" If not, your dependencies are too tight. Inject interfaces, not concrete classes. Use DI containers so tests can swap in fakes.
Tic-Tac-Toe use: Level 6 — Game takes IBoard, IWinChecker, and IAiStrategy via constructor injection. Tests can inject a fake board that always returns a specific state.
Smell → Pattern → When to Use
Smell
Pattern
When to Use
Giant if/else chain checking current phase
State
Behavior depends on which "mode" the system is in
No way to reverse an action
Command
Users expect undo, or you need an audit trail
Hardcoded algorithm that should be swappable
Strategy
Multiple algorithms for the same job, chosen at runtime
Test requires the whole app to run
DI + Interfaces
You need to test a class in isolation
Only the happy path is handled
What If? framework
After the feature works, before you call it "done"
These 5 tools are your permanent inventory. They work for every game, every workflow, every interactive system. Domains change. The structural questions don't. If you remember nothing else from this page, remember THESE.
Section 23
Practice Exercises
Five exercises that test whether you truly learned the thinking, not just memorized the code. Each one adds a new constraint that forces you to extend the design — exactly like a real interview follow-up question.
Exercise Difficulty Progression
Exercise 1: 4x4 Board Variant Easy
New constraint: The game now supports a 4x4 grid where you need 4 in a row to win (not 3). The board size should be configurable at game creation time, not hardcoded.
Think: What changes in the Board class? What changes in the WinChecker? Does the State pattern need any modification? Does the AI strategy need to adapt?
Hint
The Board class should take a size parameter in its constructor instead of hardcoding 3. The WinChecker needs to check for N consecutive marks (where N = board size) instead of always 3. The State pattern doesn't change at all — XTurn is still XTurn regardless of board size. The Strategy pattern is fine too — but Minimax gets much slower on a 4x4 board (16 cells instead of 9), so you may need alpha-beta pruning or a depth limit.
Key Changes
Board.cs — Configurable Size
public class Board
{
public int Size { get; }
private readonly Mark?[,] _cells;
public Board(int size = 3)
{
Size = size;
_cells = new Mark?[size, size];
}
public bool IsValidMove(int row, int col)
=> row >= 0 && row < Size
&& col >= 0 && col < Size
&& _cells[row, col] is null;
}
// WinChecker now uses Size instead of hardcoded 3
public bool CheckLine(Board board, int startR, int startC,
int dR, int dC, Mark mark)
{
for (int i = 0; i < board.Size; i++)
if (board[startR + i * dR, startC + i * dC] != mark)
return false;
return true;
}
Exercise 2: Game Replay Easy
New constraint: After a game ends, the player can hit "Replay" to watch the entire game unfold move by move, with a 1-second delay between each step.
Think: Which pattern already stores the data you need? Do you need a new class, or can you reuse something?
Hint
You already have a Command history stack from Level 3! Replay is just: start with an empty board, then re-execute each command in order with a delay. No new patterns needed — just a ReplayGame() method that iterates through the command list.
Key Changes
GameReplay.cs
public class GameReplay
{
private readonly IReadOnlyList<ICommand> _moves;
public GameReplay(IReadOnlyList<ICommand> moves)
=> _moves = moves;
public async IAsyncEnumerable<Board> ReplayAsync(
Board freshBoard,
TimeSpan delay)
{
yield return freshBoard; // show empty board first
foreach (var move in _moves)
{
await Task.Delay(delay);
move.Execute(freshBoard);
yield return freshBoard;
}
}
}
// Usage:
var replay = new GameReplay(game.MoveHistory);
await foreach (var snapshot in replay.ReplayAsync(
new Board(), TimeSpan.FromSeconds(1)))
{
renderer.Draw(snapshot);
}
Exercise 3: Save & Load Game Medium
New constraint: Players can save a game in progress to a file and load it later to resume. The saved file must include the board state, move history (for undo), whose turn it is, and the AI difficulty setting.
Think: What serialization format works best? How do you reconstruct Command objects from saved data? Should save/load know about game internals, or go through an interface?
Hint
Create a GameSnapshot record that captures all the data needed to restore the game. Use JSON serialization. The tricky part: Command objects in the history contain behavior (Execute/Undo methods), but you only need to save the data (row, col, player). On load, reconstruct the Command objects from the saved data. Use an IGamePersistence interface so you can swap between file, database, or cloud storage.
Key Changes
GamePersistence.cs
// Snapshot = pure data, easy to serialize
public record GameSnapshot(
int BoardSize,
Mark?[,] Cells,
List<MoveData> MoveHistory,
Mark CurrentTurn,
string AiDifficulty);
public record MoveData(int Row, int Col, Mark Player);
// Interface for persistence (Strategy pattern!)
public interface IGamePersistence
{
Task SaveAsync(GameSnapshot snapshot, string path);
Task<GameSnapshot> LoadAsync(string path);
}
// JSON implementation
public class JsonGamePersistence : IGamePersistence
{
public async Task SaveAsync(GameSnapshot snap, string path)
=> await File.WriteAllTextAsync(path,
JsonSerializer.Serialize(snap));
public async Task<GameSnapshot> LoadAsync(string path)
=> JsonSerializer.Deserialize<GameSnapshot>(
await File.ReadAllTextAsync(path))!;
}
Exercise 4: Spectator Mode Medium
New constraint: Other users can watch a game in progress as spectators. Spectators see every move in real time but cannot interact with the board. When a move is made, all spectators are notified instantly.
Think: Which design pattern is all about "when something happens, tell everyone who cares"? How do spectators subscribe? How do they unsubscribe when they leave?
Hint
This is the Observer pattern. The Game is the subject (publisher). Each spectator is an observer (subscriber) that implements IGameObserver with a OnMoveMade(MoveData move) method. When a player makes a move, the game loops through all registered observers and notifies them. Spectators subscribe when they join and unsubscribe when they leave. This is the same pattern used in .NET events and IObservable<T>.
Key Changes
SpectatorMode.cs
public interface IGameObserver
{
void OnMoveMade(MoveData move, Board boardSnapshot);
void OnGameOver(GameResult result);
}
public class Game
{
private readonly List<IGameObserver> _observers = [];
public void AddSpectator(IGameObserver spectator)
=> _observers.Add(spectator);
public void RemoveSpectator(IGameObserver spectator)
=> _observers.Remove(spectator);
private void NotifyAll(MoveData move)
{
var snapshot = _board.Clone(); // read-only copy
foreach (var obs in _observers)
obs.OnMoveMade(move, snapshot);
}
public void MakeMove(int row, int col)
{
// ... existing move logic ...
var move = new MoveData(row, col, currentPlayer);
NotifyAll(move);
}
}
// A spectator that logs to console
public class ConsoleSpectator(string name) : IGameObserver
{
public void OnMoveMade(MoveData m, Board b)
=> Console.WriteLine(
$"[{name}] {m.Player} placed at ({m.Row},{m.Col})");
public void OnGameOver(GameResult r)
=> Console.WriteLine($"[{name}] Game over: {r}");
}
Exercise 5: Tournament Rankings Hard
New constraint: Build a tournament system where 8 players compete in a bracket. Each match is a best-of-3 Tic-Tac-Toe game. The system tracks wins, losses, and a rating score. Players are seeded by rating, and matchups are generated automatically.
Think: What new entities do you need (Tournament, Match, Player)? How does a single Game relate to a Match (best-of-3)? How do you generate brackets? Where does the rating calculation go — is it a Strategy?
Hint
New entities: Tournament (manages brackets), Match (best-of-3 wrapper around Game), PlayerProfile (name + rating). A Match contains up to 3 Games and tracks who wins each. The bracket is a binary tree of Matches. Rating calculation is a Strategy — you might use Elo, Glicko, or a simple win/loss ratio. IRatingStrategy with UpdateRatings(PlayerProfile winner, PlayerProfile loser). The Tournament uses Observer to notify a leaderboard when ratings change.
Skeleton Design
Tournament.cs — Skeleton
public record PlayerProfile(string Name, double Rating);
public interface IRatingStrategy
{
(double NewWinner, double NewLoser) Calculate(
double winnerRating, double loserRating);
}
public class EloRating(int kFactor = 32) : IRatingStrategy
{
public (double, double) Calculate(double w, double l)
{
double expected = 1.0 / (1 + Math.Pow(10, (l - w) / 400));
double newW = w + kFactor * (1 - expected);
double newL = l + kFactor * (0 - (1 - expected));
return (newW, newL);
}
}
public class Match(PlayerProfile p1, PlayerProfile p2)
{
private readonly List<Game> _games = [];
public int P1Wins => _games.Count(g => g.Winner == p1);
public int P2Wins => _games.Count(g => g.Winner == p2);
public bool IsComplete => P1Wins == 2 || P2Wins == 2;
public PlayerProfile? Winner =>
P1Wins == 2 ? p1 : P2Wins == 2 ? p2 : null;
}
public class Tournament(
List<PlayerProfile> players,
IRatingStrategy rating)
{
// Seed by rating, generate bracket, run matches
// After each match, update ratings via IRatingStrategy
// Notify observers (leaderboard) via IGameObserver
}
Scoring guide: If you can sketch the entity relationships and identify which patterns go where (State for game flow, Command for undo within games, Strategy for rating + AI, Observer for live leaderboard), you've nailed it. The exact implementation details matter less than the structural thinking.
Spaced Repetition: Try Exercise 1 today. Try Exercise 3 in three days (without re-reading). Try Exercise 5 in a week. If you can sketch the design from memory after a week, it's permanent.
Section 24
Related Topics
You've discovered patterns, principles, and thinking tools in this case study. Here's where to go next — each page below deepens a specific skill you've already started building.
Same difficulty tier. The State pattern becomes the primary pattern (machine modes: idle → has-money → dispensing). Strategy for payment methods.
Skills that transfer: State + Strategy combo, edge cases, What If? frameworkComing Soon
Snake & Ladder
Another board game with turn-based mechanics. Introduces randomness (dice rolls) and board configuration (snake/ladder placement) as new design concerns.
Skills that transfer: State tracking, player management, game loop designComing Soon
Recommended path: If this was your first case study, try Vending Machine next — it's at the same difficulty level but makes State the primary pattern instead of a supporting one. Then move to Snake & Ladder for more board game practice, or Shopping Cart for a step into non-game domains. The thinking tools you built here (What Phase? What Varies? What If? Can I Test It?) carry forward to every single one.