CASE STUDY

Snake & Ladder

Build it 7 times. Each level adds one constraint that breaks your code. By the end, you won't have memorized a design — you'll have discovered it.

7 Levels 15 Think First Challenges 64 SVG Diagrams 12 Interview Q&As C# / .NET
Section 1

A 30-Line Game That Hides 3 Design Patterns

Everyone's played Snake & Ladder. A 10×10 board, a die, some snakes that send you tumbling down, and ladders that zoom you up. It takes 5 minutes to play and about 30 lines to code. But what happens when you need 2–6 players taking turns? When the board needs to be configurableDifferent game modes like Classic (fixed snake/ladder positions), Random (generated each game), or Kids (more ladders, fewer snakes). The same game engine should support all of them without code changes. — Classic, Random, Kids mode? When you need to detect snake chains (land on a snake that drops you onto another snake)? When dice must be mockableIn testing, "mockable" means you can replace a real component (like a random die) with a fake one that returns predictable values. If the die always rolls 6, you can test exact board positions without randomness ruining your tests. for testing? That 30-line toy becomes a real design problem hiding 3 design patterns.

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. "Support 2-6 players" is a constraint. "Use the Factory 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: Roll & Move
L1: Snakes & Ladders
L2: Multiplayer
L3: Game States
L4: Dice Variants
L5: Edge Cases
L6: Testability
L7: Scale It

Difficulty Curve

L0 Roll & Move L1 Snakes L2 Multiplayer L3 States L4 Dice L5 Edge Cases L6 Testability L7 Scale Each level adds ONE constraint that breaks the previous code

The System Grows — Level by Level

Each level adds one constraint. Watch the class count climb from 1 to 22:

L0
1
type
L1
4
types
L2
8
types
L3
13
types
L4
16
types
L5
19
types
L6
22
types
L7
26
types
L0 Game L1 Game Snake Ladder BoardConfig L2 Player PlayerQueue GameFactory GameSettings L3 IGameState Roll Won L4 IDiceStrategy Std Dbl Rig L5-6 Result<T> ISnakeLadder IBoard IDice L7 GameManager Leaderboard IGameEvent 1 4 8 11 15 18 22

What You'll Build

SnakeLadderGame BoardConfig Snakes | Ladders | Chains IGameState Rolling | Moving | Finished GameFactory Classic | Random | Kids PlayerQueue Turn rotation, 2-6 players IDiceStrategy Standard | DoubleDice | Rigged IGameEvent Moved | SnakeBit | Won State Factory Strategy Observer

System

Production-grade Snake & Ladder with configurable boardsThe same game engine supports Classic (fixed positions), Random (generated each game), and Kids (more ladders, fewer snakes) boards — all without changing a line of game logic. The Factory pattern creates the right board configuration., 2–6 players, snake chainsWhen a snake drops you onto the head of ANOTHER snake, you slide down again. And again. A chain of snakes can send you from cell 99 all the way to cell 2. The board must handle these recursive transitions correctly., dice variants, and full DI.

Patterns

FactoryCreates fully configured game objects (board + players + rules) from a simple GameSettings record. One line to create a Classic 4-player game or a Random 6-player game., StrategyDefines interchangeable dice algorithms. StandardDice rolls 1-6. DoubleDice rolls two dice and sums them. RiggedDice returns preset values for testing. All implement IDiceStrategy., StateThe game behaves differently based on its current state. During RollingState, rolling is allowed but moving is not. During MovingState, the player is mid-move. During FinishedState, everything is rejected., ObserverWhen something interesting happens (player moved, hit a snake, climbed a ladder, won), the game fires events. Listeners (UI, logger, leaderboard) react without the game knowing about them.

Skills

Real-world walkthrough, records vs classesRecords are for data that never changes (a Snake's head and tail positions are fixed). Classes are for things with behavior and mutable state (a Player's position changes every turn). Choosing wrong leads to bugs., Factory for config, Strategy for dice, State for game flow, CREATESThe 7-step interview framework: Clarify, Requirements, Entities, API, Trade-offs, Edge cases, Scale. Works for every LLD problem.

Stats

15 Think Firsts • 64 SVGs • 12 Q&As • 100+ tooltips
~60 min (with challenges) • ~30 min (speed read)

Section 2

Before You Code — See the Real World

Before we write a single line of code, let's play an actual game of Snake & Ladder. Not on a computer — on a physical board. 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.

Things you probably found: Board, Cells (numbered 1–100), Snakes (red, connecting two cells), Ladders (green, connecting two cells), Tokens (colored player pieces), Die (six-sided). Actions: Roll the die, move token forward, check cell (snake? ladder? nothing?), pass turn to next player, reach cell 100 to win.

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, snakes, and players 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.

1. Set Up 10×10 board Board, tokens, die, pick order 2. Roll Dice Shake, throw, read number 3. Move 42 47 Count spaces, move token Can't pass 100! 4. Check Cell Snake? Slide! Climb! Snake? Ladder? Chain possible! 5. Next / Win 100! Exact 100 = win Otherwise: next player's turn Turn rotation, win condition

Stage 1: Set Up

What you SEE: A folded board with numbered cells 1–100 arranged in a boustrophedonA zig-zag pattern where even rows go left-to-right and odd rows go right-to-left. The board reads: row 1 (1-10 left to right), row 2 (20-11 right to left), row 3 (21-30 left to right), and so on. This creates the classic snake-and-ladder layout. (zig-zag) pattern. Red snakes connect higher cells to lower cells. Green ladders connect lower cells to higher cells. Colored tokens sit at the start. A single six-sided die.

What you DO: Unfold the board, pick a token color, decide who goes first (youngest player, highest roll, etc.).

Behind the scenes: Board configuration (which cells have snakes/ladders), player initialization, turn order determination. The board itself is data — it doesn't change during the game.

What you SEE: The current player picks up the die, shakes it, and throws it on the table. It lands showing a number between 1 and 6.

What you DO: Pick up the die, throw it, read the number.

Behind the scenes: Random number generationIn C#, Random.Shared.Next(1, 7) generates a number from 1 to 6 (the upper bound is exclusive). This simulates the physical randomness of rolling a die. (1–6). Turn validation — is it actually your turn? In some variants, rolling a 6 gives you a bonus turn. The die itself is an algorithm that could vary (one die, two dice, loaded dice for kids).

What you SEE: You count spaces one by one from your current cell, landing on a new numbered cell. If the move would take you past cell 100, you don't move (common house rule) or bounce back.

What you DO: Pick up token, count forward, place on new cell.

Behind the scenes: Position calculation: newPosition = currentPosition + diceRoll. Boundary checking: can't go past 100. The "bounce back" rule (if roll would exceed 100, stay put) is an edge caseA situation that occurs at the boundary of normal operation. "What happens when you're on cell 99 and roll a 5?" is an edge case. Handling it correctly is what separates toy code from production code. that many beginners miss.

What you SEE: You look at the cell you just landed on. Snake head? Slide allll the way down to the snake's tail. Ladder bottom? Climb up to the ladder's top. Nothing special? Stay right there.

What you DO: Check the cell, follow the snake or ladder if there is one.

Behind the scenes: Board lookup — does this position have a transitionA mapping from one cell to another. Snake: cell 97 → cell 3 (you go DOWN). Ladder: cell 4 → cell 56 (you go UP). Internally, both are just "if you land here, teleport there." The direction (up/down) is implicit in the numbers.? Position transformation. And here's the tricky part: chains. What if a snake drops you onto another snake's head? Or a ladder takes you to another ladder's bottom? You have to keep checking until you land on a cell with no transition. This is recursive or iterative resolution — a detail that catches many candidates off guard in interviews.

What you SEE: You pass the die to the next player. Unless you just hit exactly cell 100 — then you celebrate! You've won! The game is over and no more turns are taken.

What you DO: Hand over the die, or claim victory.

Behind the scenes: Turn rotation (circular — after the last player, back to the first). Win condition check (position == 100). Game state transition from "in progress" to "finished." Once someone wins, the game must reject any further rolls. The game has modes, and what's allowed depends on the current mode.

What We Discovered

REAL WORLD CODE 10×10 board with cells BoardConfig (class) Snake: head → tail Snake (record — immutable) Ladder: bottom → top Ladder (record — immutable) Player with colored token Player (class — mutable) Six-sided die IDiceStrategy (Strategy) Playing / Finished modes IGameState (State pattern)

What's Hiding Beneath the Surface

HIDDEN DESIGN CONCERNS Configuration Classic, Random, Kids boards need DIFFERENT snake/ladder layouts → Factory Snake Chains Land on snake → slide to ANOTHER snake? Recursive resolution → While-loop Dice Variants One die, two dice, rigged for tests. Same interface, differ. → Strategy Game Modes Can't roll after someone wins. Game has STATES. → State
Discovery Real World Code Type
Board10×10 grid with numbered cellsBoardConfigclass (holds configuration)
SnakeRed line from head to tail (go down)Snakerecord (immutable data)
LadderGreen line from bottom to top (go up)Ladderrecord (immutable data)
PlayerPerson with colored tokenPlayerclass (mutable position)
DieSix-sided cube, random numberIDiceStrategyinterface (Strategy pattern)
Turn OrderPass die to next playerPlayerQueueclass (circular rotation)
Game ModePlaying / Finished statesIGameStateinterface (State pattern)
ConfigurationClassic vs Random vs Kids boardGameFactorystatic class (Factory)

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. The real world is your first diagram. Senior engineers start here.

Section 3 🟢 EASY

Level 0 — Roll & Move

Constraint: "A player rolls a die and moves forward on a 100-cell board."
This is where it all begins. The simplest possible version — no snakes, no ladders, no other players. Just: roll a die, move forward. We'll feel the pain of missing features soon enough.

Every complex system starts with a laughably simple version. For Snake & Ladder, that means: a single player, a 100-cell board, and a die. Roll the die, add the result to your position, done. No snakes, no ladders, no winning condition. 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 for a 100-cell board? Do you even need an array, or is an integer enough? How does the player move? Take 60 seconds.

60 seconds — try it before peeking.

You don't need an array at all. The board has 100 cells, but at Level 0 there's nothing special about any cell — no snakes, no ladders. The player's position is just an int from 0 to 100. The "board" is the number space from 1 to 100. That's it. Don't over-engineer.

Your Internal Monologue

"100 cells... do I need Board[100]? Each cell is just a number. The player's position is just an int from 1 to 100. The board itself doesn't HOLD anything yet — no snakes, no ladders."

"So... position is an integer, roll is Random.Shared.Next(1, 7), new position = old + roll. That's it. Don't over-engineer."

"Wait — what about going past 100? If I'm on 98 and roll a 5... I'd be at 103. That's off the board. I'll just cap it or ignore the move. Actually, let me keep it dead simple for now — no boundary check. Let the next level force me to add it."

What Would You Do?

ArrayApproach.cs
// Over-engineered: an array of 100 cells
public class Board
{
    private readonly Cell[] _cells = new Cell[101]; // 1-indexed

    public Board()
    {
        for (int i = 1; i <= 100; i++)
            _cells[i] = new Cell(i);
    }
}

public class Cell
{
    public int Number { get; }
    public Cell(int number) => Number = number;
}

// Each cell is just... a number. The Cell class adds nothing.
// The array adds nothing. Position is still just an int.
The catch: You've created a Cell class that holds one integer and a Board class that holds an array of cells. All that machinery just to store... a number. At Level 0, every cell is identical. The data structure adds complexity without adding value. When a class has no behavior and one field, it probably shouldn't exist.
IntegerApproach.cs
// Clean: position is just an integer
public class Game
{
    private int _position = 0;  // 0 = not on board yet

    public int Roll()
    {
        int diceValue = Random.Shared.Next(1, 7);
        _position += diceValue;
        return diceValue;
    }

    public int Position => _position;
}

// Three lines of state. That's the entire game at L0.
Why this wins: The code matches the mental model. "What cell is the player on?" — it's an int. "Roll the die and move forward" — add the roll to the position. No abstractions needed yet. When the problem gets more complex, we'll add the right abstractions. Not before.
Decision compass: Do I NEED a data structure, or is a primitiveA primitive is a basic built-in type like int, bool, or string. When the concept you're modeling is just a number (position on board) with no special behavior, a primitive is the right choice. Wrapping it in a class adds ceremony without value. enough? If every instance would be identical (cells with no special properties), a wrapper class adds noise, not clarity.

Here's the complete Level 0 code. Read every line — there are only about 10 of them.

Game.cs — Level 0
public sealed class Game
{
    private int _position = 0;  // 0 means "not on the board yet"

    public int Position => _position;

    public int Roll()
    {
        int diceValue = Random.Shared.Next(1, 7); // 1-6 inclusive
        _position += diceValue;
        return diceValue;
    }
}

Let's walk through what each piece does:

10 lines. It works for a toy game. But can you already spot what's missing? There's no boundary checking (what if position exceeds 100?), no snakes or ladders, no other players, and no win condition. We'll feel each of these pains in the coming levels.

The Board — 100 Cells, Zig-Zag Numbering

100 99 98 97 96 95 94 93 92 91 81 82 83 84 85 86 87 88 89 90 . . . rows 3 – 8 . . . (same zig-zag pattern) Even rows: left → right Odd rows: right → left 20 19 18 17 16 15 14 13 12 11 1 2 3 4 5 P 6 7 8 9 10 Player on cell 5 (rolled a 5 on first turn) — position is just an int

How Roll() Works

Roll Die Random(1, 7) Add to Position pos += diceValue New Position e.g. 0 + 5 = 5 No boundary check, no snakes, no ladders — that's Level 1's problem

Growing Diagram — After Level 0

Class Diagram — Level 0
Game - _position : int = 0 + Position : int (get) + Roll() : int 1 class • 1 field • 2 members

Before This Level

"100 cells = I need an array of Cell objects!"

After This Level

"Do I NEED a data structure, or is a primitive enough? If every instance is identical, a wrapper adds noise, not value."

Section 4 🟢 EASY

Level 1 — Snakes & Ladders

Constraint: "The board has snakes (slide down) and ladders (climb up). Land on a snake's head — go to its tail. Land on a ladder's bottom — go to its top."
What breaks: L0's Roll() just adds the dice value to the position. No concept of special cells. Landing on cell 97 (snake head) should teleport you down to cell 3 (snake tail), but L0's code happily stays at 97. There's nowhere to store snake/ladder data and no logic to check after landing.

Now we get to add the thing that makes the game interesting — the snakes and ladders themselves. A snake has a head (where you land) and a tail (where you end up). A ladder has a bottom (where you land) and a top (where you climb to). Both are simple mappings: "if you land HERE, teleport to THERE." The question is: how do we represent them in code?

Think First #3

Snakes have a head and tail. Ladders have a bottom and top. Should these be classes with methods, or just data? What's the simplest representation? Take 60 seconds.

60 seconds — try it before peeking.

Does a snake do anything? No — it's just data. It has two numbers: head position and tail position. It never changes. Same for a ladder: bottom and top. These are perfect candidates for recordsIn C#, a readonly record struct is an immutable value type that auto-generates Equals(), GetHashCode(), and ToString(). It's ideal for "data bags" that never change — like a snake whose head and tail positions are fixed forever. — immutable data types that represent facts. The board then holds a collection of these records and provides a lookup: "given a position, what's the destination?"

Your Internal Monologue

"A snake has two positions: head (where you land) and tail (where you end up). Does it have behavior? Does a snake DO anything? No — it's just a mapping: position A → position B. Same for ladders. They're data, not behavior."

"Should I use a class Snake with methods? That feels heavy for something with zero behavior. A recordRecords in C# are perfect for data that will never change after creation. A snake at position 97→3 will ALWAYS be 97→3. It's a fact about the board, not something that evolves. is better — it says 'this is pure data, never changes.'"

"And the board... it needs to answer one question: 'I landed on cell X — where do I end up?' That's a DictionaryA Dictionary<int, int> maps keys to values. Here it maps landing positions to destination positions. Cell 97 (snake head) maps to cell 3 (snake tail). Cell 4 (ladder bottom) maps to cell 56 (ladder top). O(1) lookup time.. Build a Dictionary<int, int> from all the snakes and ladders. Lookup is O(1). Clean."

What Would You Do?

InheritanceApproach.cs
// Over-engineered: inheritance for data-only types
public abstract class BoardEntity
{
    public int Start { get; }
    public int End { get; }
    public abstract string Type { get; }
    public abstract void Apply(Player player);
}

public class Snake : BoardEntity
{
    public override string Type => "Snake";
    public override void Apply(Player p) => p.Position = End;
}

public class Ladder : BoardEntity
{
    public override string Type => "Ladder";
    public override void Apply(Player p) => p.Position = End;
}

// Both Apply() methods do the EXACT same thing.
// The "behavior" is identical. This is a hierarchy for nothing.
The catch: Both Snake.Apply() and Ladder.Apply() do the same thing — set position to the destination. When two subclasses have identical behavior, the hierarchy exists for naming, not for polymorphism. You've paid the cost of inheritance (coupling, rigidity) and gotten nothing in return.
DictionaryApproach.cs
// Minimal but loses distinction
var transitions = new Dictionary<int, int>
{
    [97] = 3,   // Is this a snake or a ladder?
    [4] = 56,   // Snake going up? Ladder going down?
    [62] = 19,  // No way to tell from the data alone
};

// Lookup: clean
int destination = transitions.GetValueOrDefault(position, position);

// Problem: you can't tell snakes from ladders.
// Logging "Player hit a snake at 97" is impossible.
// Debugging is harder. Type information is lost.
The catch: It's simple, but you lose the distinction between snakes and ladders. You can't log "Player hit a snake!" vs "Player climbed a ladder!" because all you see is [97] = 3. Is that a snake going down or a very weird ladder? The raw dictionary works for movement but kills observability.
RecordsApproach.cs
// Clean: typed records + config class
public readonly record struct Snake(int Head, int Tail);
public readonly record struct Ladder(int Bottom, int Top);

public class BoardConfig
{
    public IReadOnlyList<Snake> Snakes { get; }
    public IReadOnlyList<Ladder> Ladders { get; }
    private readonly Dictionary<int, int> _transitions;

    public BoardConfig(IEnumerable<Snake> snakes, IEnumerable<Ladder> ladders)
    {
        Snakes = snakes.ToList();
        Ladders = ladders.ToList();
        _transitions = new Dictionary<int, int>();
        foreach (var s in Snakes) _transitions[s.Head] = s.Tail;
        foreach (var l in Ladders) _transitions[l.Bottom] = l.Top;
    }

    public int GetDestination(int position)
        => _transitions.TryGetValue(position, out var dest) ? dest : position;
}
Why this wins: Best of both worlds. You keep the typed distinction (Snake vs Ladder — for logging, UI, debugging), AND you get O(1) lookup through an internal dictionary. The records are immutable (snakes don't move mid-game). BoardConfig holds everything and answers "where do I end up?" in one call.
Decision compass: Has behavior? → class with methods. Pure data that never changes? → recordRecords signal intent: "this is a data bag, not an actor." They get free Equals(), ToString(), and deconstruction. In this case, a Snake(97, 3) is always equal to another Snake(97, 3) — value equality, not reference equality.. Need both type info AND fast lookup? → typed records with a dictionary cache inside a config class.

Here's Level 1 — the Game class updated to use BoardConfig. Notice how Roll() now checks for snakes and ladders after moving.

Snake.cs
/// A snake connects a Head (higher cell) to a Tail (lower cell).
/// When a player lands on the Head, they slide down to the Tail.
/// Immutable — a snake's position never changes during a game.
public readonly record struct Snake(int Head, int Tail);
Ladder.cs
/// A ladder connects a Bottom (lower cell) to a Top (higher cell).
/// When a player lands on the Bottom, they climb up to the Top.
/// Immutable — a ladder's position never changes during a game.
public readonly record struct Ladder(int Bottom, int Top);
BoardConfig.cs
public sealed class BoardConfig
{
    public IReadOnlyList<Snake> Snakes { get; }
    public IReadOnlyList<Ladder> Ladders { get; }
    private readonly Dictionary<int, int> _transitions;

    public BoardConfig(IEnumerable<Snake> snakes, IEnumerable<Ladder> ladders)
    {
        Snakes = snakes.ToList();
        Ladders = ladders.ToList();

        // Build O(1) lookup: position → destination
        _transitions = new Dictionary<int, int>();
        foreach (var s in Snakes)  _transitions[s.Head]   = s.Tail;
        foreach (var l in Ladders) _transitions[l.Bottom] = l.Top;
    }

    /// Resolve ALL transitions (handles chains:
    /// snake drops you onto another snake's head).
    public int Resolve(int position)
    {
        // Keep following transitions until stable
        while (_transitions.TryGetValue(position, out var dest))
            position = dest;
        return position;
    }
}

Notice the Resolve() method uses a while loop, not a single lookup. This handles chain transitionsWhen a snake drops you onto the head of another snake (or a ladder lifts you to the bottom of another ladder), you need to keep resolving until you land on a "safe" cell. A while loop does this naturally. A single dictionary lookup would miss chains.: if a snake drops you onto another snake's head, you keep sliding. If a ladder lifts you to another ladder's bottom, you keep climbing. The loop continues until the position has no transition.

Game.cs — Level 1
public sealed class Game
{
    private readonly BoardConfig _board;     // NEW: board config
    private int _position = 0;

    public Game(BoardConfig board) => _board = board;  // NEW

    public int Position => _position;

    public int Roll()
    {
        int diceValue = Random.Shared.Next(1, 7);
        _position += diceValue;
        _position = _board.Resolve(_position);  // NEW: check snakes/ladders
        return diceValue;
    }
}

Only two lines changed from Level 0: the constructor now accepts a BoardConfig, and after moving, we call _board.Resolve() to handle any snakes or ladders at the landing position. The Game class doesn't know how transitions work — it just asks the board.

The Board Comes Alive — Snakes & Ladders

SAMPLE BOARD — 3 Snakes, 3 Ladders Snakes (go DOWN) 97 → 3 62 → 19 48 → 11 Ladders (go UP) 4 → 56 21 → 82 71 → 92 _transitions [97]=3 [4]=56 [62]=19 [21]=82 [48]=11 [71]=92 O(1) lookup Chain Example: Snake → Snake Imagine: Snake(48→11) exists AND Snake(11→2) exists 48 11 2 ✓ stable while loop resolves chains — single lookup would stop at 11!

How BoardConfig Holds Everything

BoardConfig List<Snake> (97,3) (62,19) (48,11) List<Ladder> (4,56) (21,82) (71,92) Dict<int,int> merged transitions Resolve(pos) → follows chain until stable

Record vs Class — How To Decide

Has behavior? (methods that DO things) NO record Snake, Ladder GameSettings YES class Game, Player BoardConfig Snake(97,3) is data. Game has Roll(), Move() — it DOES things. That's the line.

Growing Diagram — After Level 1

Class Diagram — Level 1
Game - _position : int + Roll() : int BoardConfig + Snakes, Ladders + Resolve(pos) : int record Snake Head : int, Tail : int record Ladder Bottom : int, Top : int 4 types • bright = new this level

Before This Level

"Snakes and ladders are things with behavior — I need classes with methods."

After This Level

"When something is pure data with no behavior, I use records. The board has behavior (resolving transitions). The snakes and ladders are just data it operates on."

Smell: "Data Without Behavior" — When entities are just mappings (A→B) with no methods, no state changes, no decisions — they're recordsRecords in C# signal "this is immutable data." They auto-generate Equals (value-based), GetHashCode, and ToString. Perfect for things like Snake(97, 3) that will never change., not classes. Save classes for things that DO stuff.
Section 5 🟡 MEDIUM

Level 2 — Multiplayer

Constraint: "2–6 players take turns. The game must support different configurations: 2-player Classic board, 4-player Random board, 6-player Kids board (easy, more ladders)."
What breaks: L1 is single-player. There's one _position integer — great for one player, useless for four. Adding a second player means... another _position2? A third means _position3? That doesn't scale. And the board is hardcoded — no way to switch between Classic, Random, and Kids layouts without rewriting the configuration every time.

This level introduces two challenges at once. First, multiple players — each with their own name and position, taking turns in order. Second, multiple board configurations — Classic boards have fixed snake/ladder positions, Random boards generate them each game, and Kids boards have more ladders and fewer snakes (to keep it fun for younger players). How do you create these different "flavors" of the same game without a constructor that takes 10 parameters?

Think First #4

You need to create a game with: N players, a specific board layout, and game rules. Different game modes (Classic, Random, Kids) need different board configurations. How do you create these variations without a massive constructor with 10 parameters? Take 60 seconds.

60 seconds — try it before peeking.

Wrap the configuration into a simple recordA GameSettings record captures all the choices: how many players, which board type, any special rules. It's just data — a snapshot of "what kind of game do you want?" One record replaces 10 constructor parameters. (GameSettings) and use a FactoryA Factory is a method that creates and configures complex objects. Instead of the caller knowing HOW to create a game (which snakes, which ladders, how many players), the Factory encapsulates all that logic. The caller just says "give me a Classic 4-player game" and gets one. to build fully-configured games from it. The factory reads the settings, creates the right board, creates the players, and returns a ready-to-play Game. The caller writes one line: GameFactory.Create(settings).

Your Internal Monologue

"I need Player objects now — each with a name and position. Turn management... a queue? Circular — after the last player, back to the first. Queue<Player>? Hmm, a queue dequeues. I want a circular list, not a FIFO drain."

"And the board varies: Classic has standard snake/ladder positions, Random generates them, Kids has more ladders. I could pass all this to the Game constructor, but that's ugly — new Game(players, snakes, ladders, rules, dice, ...). Ten parameters? No."

"Wait — I'm creating different CONFIGURATIONS of the same thing. Classic game, Random game, Kids game — same structure, different data. That's a FactoryThe Factory pattern is about creating objects. When you find yourself saying "I need to create the same kind of thing but with different configurations," a Factory method encapsulates all that creation logic in one place.. A GameFactory takes a GameSettings record and produces a fully-configured Game. One line to create any variant."

"Actually, is this Factory or Builder? Builder is for step-by-step construction with optional parts. Factory is for 'give me settings, get back a complete object.' Since all games need the same parts (board + players), just configured differently... Factory fits better here."

What Would You Do?

GiantConstructor.cs
// Unmaintainable: every option is a parameter
var game = new Game(
    players: new[] { "Alice", "Bob", "Charlie", "Diana" },
    snakes: new[] { new Snake(97, 3), new Snake(62, 19) },
    ladders: new[] { new Ladder(4, 56), new Ladder(21, 82) },
    diceMin: 1,
    diceMax: 6,
    doubleSixBonus: true,
    exactFinish: true,
    maxTurns: 1000
);

// 8 parameters. What if you add "bounceBack" mode?
// 9. "maxChainDepth"? 10. Every new option = every caller changes.
The catch: Every caller needs to know every option. Adding a new rule means updating every place that creates a Game. And most callers want "the standard Classic game" — why should they configure 10 parameters for that?
BuilderApproach.cs
// Valid but overkill for this problem
var game = new GameBuilder()
    .WithPlayers(4)
    .WithBoard("classic")
    .WithDoubleSixBonus(true)
    .WithExactFinish(true)
    .Build();

// Builder shines when:
// 1. Parts are optional (some games need X, others don't)
// 2. Construction order matters
// 3. You need immutability after build
//
// Here? Every game needs the SAME parts.
// The only thing that varies is the DATA, not the STEPS.
Not wrong, but overkill: Builder adds a fluent API and step-by-step construction. But every game needs a board, players, and rules — nothing is truly optional. Builder shines when parts are optional or construction order matters. Here, Factory is simpler.
FactoryApproach.cs
// Clean: one line to create any game variant
var settings = new GameSettings(
    PlayerCount: 4,
    BoardType: "classic",
    DoubleSixBonus: true
);

var game = GameFactory.Create(settings);
// Done. Factory knows HOW to build a Classic 4-player game.
// Caller just says WHAT they want.

// Adding "kids" mode? Change GameFactory.
// Every caller stays the same.
Why this wins: The caller describes WHAT they want (settings), and the Factory figures out HOW to build it. Adding a new board type means changing one place — GameFactory.Create() — not every caller. The complexity of construction is hidden behind a single method call.
Decision compass: Multiple configurations of the same product (Classic/Random/Kids game)? → FactoryFactory encapsulates object creation. The caller says "I want a Classic game" and the Factory returns a fully configured Game with the right board, players, and rules. One method, any variant.. Optional/incremental construction? → Builder. Both valid, pick the simpler one for YOUR problem.

Here's the complete Level 2 solution. Five new files, and the Game class evolves to support multiple players.

Player.cs
/// A player in the game. Has a name and a mutable position.
/// Position starts at 0 (off the board) and moves toward 100.
public sealed class Player
{
    public string Name { get; }
    public int Position { get; set; } = 0; // 0 = not on board yet

    public Player(string name) => Name = name;

    public override string ToString() => $"{Name} (pos {Position})";
}

Player is a class, not a record. Why? Because position changes every turn — it's mutable state. Records are for data that never changes (like Snake positions). Player has a lifecycle: starts at 0, moves forward, eventually reaches 100.

PlayerQueue.cs
/// Manages turn order. After the last player, wraps back to first.
/// Uses modular arithmetic for circular rotation.
public sealed class PlayerQueue
{
    private readonly List<Player> _players;
    private int _currentIndex = 0;

    public PlayerQueue(IEnumerable<Player> players)
    {
        _players = players.ToList();
        if (_players.Count < 2)
            throw new ArgumentException("Need at least 2 players");
    }

    /// The player whose turn it is right now.
    public Player Current => _players[_currentIndex];

    /// Advance to the next player (wraps around).
    public void NextTurn()
        => _currentIndex = (_currentIndex + 1) % _players.Count;

    public IReadOnlyList<Player> All => _players;
}

The magic is in (_currentIndex + 1) % _players.Count. With 4 players, the index cycles: 0 → 1 → 2 → 3 → 0 → 1 → ... The modulo operatorThe % (modulo) operator returns the remainder of division. 4 % 4 = 0, so after the last player (index 3), adding 1 gives 4 % 4 = 0, wrapping back to the first player. It's the classic trick for circular data structures. handles the wrap-around automatically.

GameSettings.cs
/// All the choices needed to create a game.
/// Immutable — once you decide the settings, they don't change.
public record GameSettings(
    int PlayerCount,         // 2-6 players
    string BoardType,        // "classic", "random", "kids"
    bool DoubleSixBonus = false  // roll 6 → extra turn
);

This is a recordGameSettings captures a decision: "I want a 4-player Classic game with double-six bonus." It's immutable because the game's configuration doesn't change mid-game. Using a record instead of individual parameters groups related data together. because settings never change mid-game. One record replaces what would be 3+ constructor parameters. Clean, typed, self-documenting.

GameFactory.cs
/// Creates fully configured games from settings.
/// All the "how to build it" logic lives HERE, not in callers.
public static class GameFactory
{
    public static Game Create(GameSettings settings)
    {
        // 1. Pick the right board based on settings
        var board = settings.BoardType switch
        {
            "classic" => BoardConfigs.Classic(),
            "random"  => BoardConfigs.Random(),
            "kids"    => BoardConfigs.Kids(),
            _         => BoardConfigs.Classic()
        };

        // 2. Create N players with default names
        var players = Enumerable
            .Range(1, settings.PlayerCount)
            .Select(i => new Player($"Player {i}"))
            .ToList();

        // 3. Assemble and return a ready-to-play game
        return new Game(board, new PlayerQueue(players), settings);
    }
}

This is the Factory patternThe Factory pattern encapsulates object creation. Instead of spreading creation logic across your codebase, you centralize it in one method. Adding a new board type ("tournament") means adding one case here — every caller stays unchanged. in action. The caller says what they want (GameSettings), and the Factory figures out how to build it. Adding "tournament" mode? One new case in the switch. Every existing caller stays untouched — that's the Open/Closed PrincipleOpen for extension (add new board types), closed for modification (don't change existing callers). The Factory method is the extension point — new types go here, not in every place that creates a game. at work.

BoardConfigs.cs
/// Predefined board layouts. Each returns a configured BoardConfig.
public static class BoardConfigs
{
    public static BoardConfig Classic() => new(
        snakes: new Snake[]
        {
            new(97, 3), new(62, 19), new(48, 11),
            new(36, 6), new(88, 24), new(95, 56)
        },
        ladders: new Ladder[]
        {
            new(4, 56), new(21, 82), new(28, 76),
            new(43, 77), new(71, 92), new(12, 51)
        }
    );

    public static BoardConfig Random()
    {
        var rng = Random.Shared;
        var snakes = Enumerable.Range(0, 6)
            .Select(_ =>
            {
                int head = rng.Next(20, 99);
                int tail = rng.Next(2, head);
                return new Snake(head, tail);
            }).ToList();

        var ladders = Enumerable.Range(0, 6)
            .Select(_ =>
            {
                int bottom = rng.Next(2, 80);
                int top = rng.Next(bottom + 1, 99);
                return new Ladder(bottom, top);
            }).ToList();

        return new BoardConfig(snakes, ladders);
    }

    public static BoardConfig Kids() => new(
        snakes: new Snake[] { new(48, 11), new(62, 19) },
        ladders: new Ladder[]
        {
            new(4, 56), new(12, 51), new(21, 82),
            new(28, 76), new(43, 77), new(71, 92),
            new(33, 68), new(55, 89), new(9, 45)
        }
    );
}

Classic has equal snakes and ladders with fixed positions. Random generates new positions each game (note the constraint: snake heads must be higher than tails, ladder tops higher than bottoms). Kids has only 2 snakes but 9 ladders — lots of climbing, minimal sliding. More fun for younger players.

Game.cs — Level 2
public sealed class Game
{
    private readonly BoardConfig _board;
    private readonly PlayerQueue _players;     // NEW
    private readonly GameSettings _settings;   // NEW

    public Game(BoardConfig board, PlayerQueue players, GameSettings settings)
    {
        _board = board;
        _players = players;
        _settings = settings;
    }

    public Player CurrentPlayer => _players.Current;  // NEW

    public int Roll()
    {
        var player = _players.Current;
        int diceValue = Random.Shared.Next(1, 7);
        int newPos = player.Position + diceValue;

        // Boundary: can't go past 100
        if (newPos <= 100)
        {
            player.Position = newPos;
            player.Position = _board.Resolve(player.Position);
        }
        // else: stay put (overshoot rule)

        _players.NextTurn();  // NEW: advance to next player
        return diceValue;
    }

    public IReadOnlyList<Player> Players => _players.All;  // NEW
}

The Game class now operates on the current player instead of a single _position integer. After each roll, _players.NextTurn() advances to the next player. Notice we also added the boundary check — if the roll would take a player past 100, they stay put. This is the "exact finish" rule most people play with.

Circular Turn Rotation

P1 CURRENT P2 P3 P4 (_currentIndex + 1) % Count — wraps 3 → 0

GameFactory — One Method, Any Variant

GameSettings PlayerCount: 4 BoardType: "classic" Bonus: true GameFactory 1. Pick board config 2. Create N players 3. Return Game Classic Board 6 snakes, 6 ladders (fixed) Random Board 6 snakes, 6 ladders (generated) Kids Board 2 snakes, 9 ladders (easy) Caller says WHAT (settings) → Factory decides HOW (board + players)

Giant Constructor vs Factory

Giant Constructor new Game(players, snakes, ladders, diceMin, diceMax, bonus, exact, maxTurns) 8 params • every caller knows all Add option = change ALL callers No reusable presets vs Factory var game = GameFactory.Create( new GameSettings(4, "classic") ); 1 call • caller says WHAT Add option = change Factory only Presets: Classic(), Random(), Kids()

Three Board Types Side by Side

Classic ×6 ×6 Fixed positions, balanced Random ? ? ? 6 snakes, 6 ladders NEW positions each game Constraints: head > tail, top > bottom Generated, replayable seed Kids ×2 only! ×9! Many ladders, few snakes Fun for younger players

Growing Diagram — After Level 2

Class Diagram — Level 2
Game board, players, settings Roll(), CurrentPlayer BoardConfig Resolve(), Snakes, Ladders Snake Ladder Player Name, Position PlayerQueue Current, NextTurn() GameFactory Create(settings) : Game GameSettings Players, BoardType, Bonus 8 types • bright purple = new this level • dimmed = from previous levels New (L2) L0-L1

Before This Level

"I'd create a Game with a huge constructor — pass in every option."

After This Level

"I smell 'multiple configurations of the same thing' and reach for Factory — one line to create any game variant."

Smell: "Multiple Configurations, Same Product" — When you have Classic, Random, Kids, Tournament versions of the same object — same structure, different data — that's a FactoryThe Factory pattern centralizes object creation. "Give me a Classic 4-player game" becomes one call. The Factory knows what snakes, ladders, and rules to use for each variant.. Wrap the choices in a settings record, and let the Factory do the wiring.
Transfer: This exact pattern appears in: Document editors (WordDocFactory, PdfDocFactory — same doc structure, different rendering), Game engines (LevelFactory creating Easy/Medium/Hard levels), E-commerce (OrderFactory for domestic vs international orders with different tax rules). Whenever you see "same product, different configuration" — Factory.

Section 6

Level 3 — Game States 🟡 MEDIUM

New Constraint: "The game has modes: Waiting (players joining), Playing (dice rolling), Finished (someone won), Paused (break time). You can't roll dice before the game starts. You can't move after someone wins."
What breaks: Level 2's Game.Roll() works anytime. A player can roll before the game starts — there's no concept of "waiting for players to join." A player can keep rolling after someone has already won — the game doesn't know it's over. Worse, there's no way to pause mid-game. The game is a single big "go" mode with no brakes and no traffic lights.

The game behaves differently in each mode. In Waiting, only AddPlayer() works. In Playing, only Roll() works. In Finished, nothing works except GetWinner(). And here's the kicker: adding a Paused mode later should require zero changes to existing code.

Four modes, five methods, and each combination does something different. How do you organize this without drowning in if/else branches?

Your inner voice:

"Okay, I need the game to behave differently depending on its current mode. In Waiting, rolling should fail. In Playing, adding new players should fail. In Finished, everything should fail except checking who won..."

"My first instinct: add if (state == GameState.Playing) checks to every method. But let me count... 4 states × 5 methods = 20 if/else branches. And when I add Paused? That's 25 branches. Every single method gets touched. That violates OCPThe Open/Closed Principle says a class should be open for extension but closed for modification. You should be able to add new behavior without changing existing code. Adding a new state should mean writing a new class, not editing every existing method.."

"Wait — the behavior depends on state. Each state defines what's allowed and what's blocked. What if each state was its own object that knew its own rules? The game just asks the current state, 'Hey, can I roll?' and the state says yes or no."

"That's the State patternThe State pattern lets an object change its behavior when its internal state changes. Instead of if/else checks, you create separate classes for each state — each defining what's allowed and what happens. The object delegates to its current state, and swapping states swaps behavior entirely.! Each mode becomes its own class. The game holds a reference to the current state. When someone wins, we swap the state object to FinishedState. Adding Paused? Write one new class. Done."

What Would You Do?

Three developers, three ways to handle game modes. Only one survives the "add Paused without touching existing code" test.

The idea: Add a GameState enum (Waiting, Playing, Finished) and check it in every method with a switch statement.

EnumSwitch.cs — every method checks the state
public enum GameMode { Waiting, Playing, Finished }

public void Roll()
{
    switch (_mode)
    {
        case GameMode.Waiting:
            throw new InvalidOperationException("Game hasn't started!");
        case GameMode.Playing:
            // actual dice roll logic...
            break;
        case GameMode.Finished:
            throw new InvalidOperationException("Game is over!");
    }
}

public void AddPlayer(string name)
{
    switch (_mode)
    {
        case GameMode.Waiting:
            _players.Add(new Player(name));
            break;
        case GameMode.Playing:
            throw new InvalidOperationException("Can't join mid-game!");
        case GameMode.Finished:
            throw new InvalidOperationException("Game is over!");
    }
}
// ... same pattern for Start(), Pause(), GetWinner()...

Verdict: It works, and it's honest about what it does. But every method has a switch on the state. Adding "Paused" means opening every method and adding a new case. Five methods, five edits. What about "Replay" mode? Five more edits. The mode logic is scattered across the entire class like confetti. And when two developers both add cases to the same switch at the same time? Merge conflicts galore.

When IS this actually better? When you have 2 states max and they'll never grow. A simple on/off toggle doesn't need the State pattern.

The idea: Use boolean flags — _isStarted, _isFinished, _isPaused — and check combinations in each method.

BooleanFlags.cs — flag spaghetti
private bool _isStarted = false;
private bool _isFinished = false;
private bool _isPaused = false;

public void Roll()
{
    if (!_isStarted)
        throw new InvalidOperationException("Not started!");
    if (_isFinished)
        throw new InvalidOperationException("Already finished!");
    if (_isPaused)
        throw new InvalidOperationException("Game is paused!");

    // actual logic...
}

public void Pause()
{
    if (!_isStarted || _isFinished)
        throw new InvalidOperationException("Can't pause now!");
    if (_isPaused)
        throw new InvalidOperationException("Already paused!");
    _isPaused = true;
}

Verdict: This is the enum approach's evil twin. Instead of clean switch cases, you get a tangled web of boolean checks. What state is _isStarted = true, _isFinished = false, _isPaused = true? You have to hold the entire truth table in your head. With 3 booleans, there are 8 possible combinations — but only 4 are valid states. The other 4 are impossible states that the compiler can't prevent. You've created bugs that can't be caught until runtime. And adding a fourth boolean? 16 combinations, mostly invalid.

When IS this actually better? Almost never. Booleans that represent exclusive states are a classic code smell. If two flags can't both be true, they're really an enum in disguise.

The idea: Each game mode becomes its own class that implements IGameState. The game holds a reference to the current state object and delegates every action to it. Changing modes = swapping the state object.

StatePattern.cs — each mode is its own class
// The game delegates everything to the current state
public void Roll()      => _currentState.Roll(this);
public void AddPlayer() => _currentState.AddPlayer(this);

// Each state knows its own rules:
// WaitingState.Roll()  → "Not started yet!"
// WaitingState.AddPlayer() → adds the player
// PlayingState.Roll()  → actually rolls dice
// PlayingState.AddPlayer() → "Can't join mid-game!"
// FinishedState.Roll() → "Game is over!"

// Adding Paused? One new class. ZERO changes to existing code.

Verdict: This is the winner. Each state is a self-contained class with clear rules. The game class never checks what state it's in — it just delegates. Adding PausedState means writing one new class with its own rules. No existing state, no existing method, no existing line of code needs to change. Each state can be tested in isolation. And the impossible-state problem from booleans? Gone — the game is always in exactly one state because it holds exactly one state object.

The Solution

The State patternA behavioral pattern where an object changes its behavior when its internal state changes. Each state is a separate class that defines what's allowed and what happens. The object delegates to whichever state is current. Swapping states swaps behavior completely — no if/else needed. turns each game mode into a self-contained class. The game doesn't ask "what mode am I in?" — it just says "do the thing" and the current state decides what "the thing" means right now.

IGameState.cs — the contract every state fulfills
public interface IGameState
{
    void AddPlayer(Game game, string playerName);
    void Start(Game game);
    void Roll(Game game);
    void Pause(Game game);
    string GetWinner(Game game);
    string Name { get; }
}

Six operations, one property. Every state must say what happens when any of these is called. Some states will do the work; others will politely refuse. The Name property is handy for logging and error messages — "Can't roll: game is in Waiting state."

WaitingState.cs — players are joining, game hasn't started
public sealed class WaitingState : IGameState
{
    public string Name => "Waiting";

    public void AddPlayer(Game game, string playerName)
    {
        game.Players.Enqueue(new Player(playerName));
        Console.WriteLine($"{playerName} joined! ({game.Players.Count} players)");
    }

    public void Start(Game game)
    {
        if (game.Players.Count < 2)
            throw new InvalidOperationException(
                "Need at least 2 players to start.");
        game.TransitionTo(new PlayingState());
    }

    // These don't make sense in Waiting mode
    public void Roll(Game game)
        => throw new InvalidOperationException(
            "Can't roll — game hasn't started. Call Start() first.");

    public void Pause(Game game)
        => throw new InvalidOperationException(
            "Can't pause — game hasn't started yet.");

    public string GetWinner(Game game)
        => throw new InvalidOperationException(
            "No winner — game hasn't started.");
}

In Waiting mode, only two things make sense: adding players and starting the game. Everything else throws a clear error. Notice Start() validates the player count and then transitions the game to PlayingState. The state itself triggers the state change — not the game.

PlayingState.cs — dice are rolling, game is active
public sealed class PlayingState : IGameState
{
    public string Name => "Playing";

    public void Roll(Game game)
    {
        var player = game.CurrentPlayer;
        int diceValue = Random.Shared.Next(1, 7);
        int newPos = player.Position + diceValue;

        // Board boundary check (Level 5 will refine this)
        if (newPos <= 100)
        {
            player.Position = game.Board.GetDestination(newPos);
            Console.WriteLine(
                $"{player.Name} rolled {diceValue} → position {player.Position}");

            if (player.Position == 100)
            {
                game.Winner = player;
                game.TransitionTo(new FinishedState());
                return;
            }
        }
        game.AdvanceTurn();
    }

    public void Pause(Game game)
        => game.TransitionTo(new PausedState());

    public void AddPlayer(Game game, string playerName)
        => throw new InvalidOperationException(
            "Can't add players mid-game.");

    public void Start(Game game)
        => throw new InvalidOperationException(
            "Game is already running.");

    public string GetWinner(Game game)
        => throw new InvalidOperationException(
            "No winner yet — game is still in progress.");
}

This is where the action happens. Roll() handles the actual game logic: roll the die, move the player, check for snakes/ladders via the board, and detect a winner. When someone hits 100, the state transitions to FinishedState automatically. The Pause() method transitions to PausedState — notice how clean that is. One line.

FinishedState.cs — someone won, game is locked
public sealed class FinishedState : IGameState
{
    public string Name => "Finished";

    public string GetWinner(Game game)
        => game.Winner?.Name
            ?? throw new InvalidOperationException("No winner recorded.");

    // Everything else is blocked — the game is over
    public void Roll(Game game)
        => throw new InvalidOperationException(
            $"Game over! {game.Winner?.Name} already won.");

    public void AddPlayer(Game game, string playerName)
        => throw new InvalidOperationException(
            "Game is finished — can't add players.");

    public void Start(Game game)
        => throw new InvalidOperationException(
            "Game already finished. Create a new game to play again.");

    public void Pause(Game game)
        => throw new InvalidOperationException(
            "Can't pause a finished game.");
}

The simplest state — almost everything throws. The game is over. The only useful thing you can do is ask who won. This is the "brick wall" state that prevents any further game actions. Notice how clear the error messages are — each one tells you why the action is blocked and what to do instead.

PausedState.cs — added with ZERO changes to existing states
// This entire file was added without modifying a single existing state class.
// That's the OCP in action.

public sealed class PausedState : IGameState
{
    public string Name => "Paused";

    public void Start(Game game)
    {
        // Resume the game — go back to Playing
        Console.WriteLine("Game resumed!");
        game.TransitionTo(new PlayingState());
    }

    public void Roll(Game game)
        => throw new InvalidOperationException(
            "Game is paused. Call Start() to resume.");

    public void AddPlayer(Game game, string playerName)
        => throw new InvalidOperationException(
            "Can't add players while paused.");

    public void Pause(Game game)
        => throw new InvalidOperationException(
            "Game is already paused.");

    public string GetWinner(Game game)
        => throw new InvalidOperationException(
            "No winner — game is paused.");
}

This is the proof that the State pattern works. We added an entirely new game mode — Paused — by creating one new file. Zero edits to WaitingState, PlayingState, or FinishedState. Zero edits to the Game class. The Open/Closed PrincipleA class should be open for extension (you can add new behavior) but closed for modification (you don't change existing code). The State pattern embodies this perfectly — new states are new classes, not edits to existing switch statements. in its purest form.

Game.cs — delegates everything to the current state
public sealed class Game
{
    private IGameState _currentState;

    public PlayerQueue Players { get; }
    public BoardConfig Board { get; }
    public Player? Winner { get; internal set; }
    public Player CurrentPlayer => Players.Current;

    public Game(BoardConfig board)
    {
        Board = board;
        Players = new PlayerQueue();
        _currentState = new WaitingState(); // Always starts in Waiting
    }

    // Every public method is a one-liner — just ask the current state
    public void AddPlayer(string name) => _currentState.AddPlayer(this, name);
    public void Start()                => _currentState.Start(this);
    public void Roll()                 => _currentState.Roll(this);
    public void Pause()                => _currentState.Pause(this);
    public string GetWinner()          => _currentState.GetWinner(this);

    // State objects call this to change the game's mode
    internal void TransitionTo(IGameState newState)
    {
        Console.WriteLine($"[{_currentState.Name}] → [{newState.Name}]");
        _currentState = newState;
    }

    internal void AdvanceTurn() => Players.Advance();
}

Look at how clean the Game class is. Every method is a single line that delegates to the current state. The Game doesn't know which state it's in — it just trusts the state to do the right thing. The TransitionTo() method is internal so only state classes (within the same assembly) can trigger transitions. The game starts in WaitingState and the states handle all transitions from there.

Diagrams

State Machine — How the Game Flows Between Modes

Think of this like a traffic system. Each circle is a mode the game can be in. The arrows show what action causes a transition. You can only follow the arrows — there's no shortcut from Waiting to Finished, for example.

Waiting AddPlayer() ✅ start Playing Roll() ✅ Start() Paused nothing ✅ Pause() Resume() Finished GetWinner() ✅ Player hits 100 Double-circle = terminal state (no exits) | Each state = one class

Full Game Flow Through States

Here's a complete game from start to finish. Watch how the state transitions happen automatically — the game class never checks what mode it's in.

Timeline: A Complete Snake & Ladder Game WAITING AddPlayer("Alice") AddPlayer("Bob") AddPlayer("Carol") Start() → PLAYING Alice rolls 4 → pos 4 Bob rolls 6 → snake! → pos 2 Carol rolls 3 → ladder! → pos 22 Pause() ↓ PAUSED Roll() → error! Resume() → PLAYING Alice rolls 6, then 5... ...Bob hits 100! → FINISHED GetWinner() → "Bob" 🏆 Blocked Operations (handled by each state) Waiting: Roll() ❌ Playing: AddPlayer() ❌ Paused: Roll() ❌ Finished: Roll() ❌ Each state class decides what's allowed — no if/else in the Game class

Permission Matrix — What Each State Allows

Here's the full truth table. Green means the action works in that state, red means it throws an error. Notice how each column (state) is implemented as a single class — the green cells are the methods with real logic, the red cells are one-line throws.

Action Waiting Playing Paused Finished AddPlayer() Start() Roll() Pause() GetWinner() Each column = one class. Green cells = real logic. Red cells = one-line throw. Adding a new state = adding a new column (new class) without changing existing columns.

Growing Diagram — Level 3

Four new pieces join the system: the IGameState interface and its four concrete implementations. The Game class now delegates all behavior to whichever state is current.

Game Roll() | AddPlayer() | Start() | Pause() BoardConfig PlayerQueue «interface» IGameState Roll() | AddPlayer() | Start() | Pause() | GetWinner() NEW WaitingState NEW PlayingState NEW FinishedState NEW PausedState NEW Player Snake Ladder GameFactory GameSettings System after Level 3 — 12 types | cyan boxes = new | gray = from previous levels

Before / After Your Brain

Before This Level

You see "the game has different modes" and think "I'll add if/else checks for the current mode in every method."

After This Level

You smell "mode-dependent behavior" and instinctively reach for the State pattern. Each mode becomes a class, new modes mean new classes, and existing code stays untouched.

Smell → Pattern: Mode-Dependent Behavior — When an object acts differently based on its current mode, and you see if (state == X) scattered across multiple methods → State pattern. Each mode becomes its own class. The object delegates to whichever mode is current. Adding new modes means writing new classes, not editing existing ones.
Transfer: Same technique in a Vending Machine: Idle, HasMoney, Dispensing, OutOfStock — each state decides what buttons do. Same in an Elevator: Moving, Stopped, DoorsOpen, Maintenance — each state decides whether pressing a floor button queues it or ignores it. Same in a TCP Connection: Listen, Established, Closing, Closed — the textbook State pattern example.
Section 7

Level 4 — Dice Variants 🟡 MEDIUM

New Constraint: "Standard game uses 1d6. Variant mode uses 2d6 (doubles = bonus turn). Testing needs rigged dice that return a predetermined sequence."
What breaks: Level 3's PlayingState uses Random.Shared.Next(1, 7) directly inside the Roll() method. That's fine for one dice type, but now we need three: a single die, a double die with bonus-turn detection, and a rigged die for testing. We can't swap dice types at runtime — the random call is baked in. And we can't write deterministic tests because every test run gives different results. "Test that player at position 94 rolls a 6 and wins" is impossible when the dice are truly random.

Three dice types, one operation: roll and get a number. A single die returns 1-6. A double die returns 2-12 and also tells you whether the two dice matched (doubles). A rigged die returns whatever number you feed it — perfect for testing.

All three do the same job — "roll and produce a result" — but the algorithm is completely different each time. Adding a "weighted dice" for difficulty mode should require zero changes to existing code. Sound familiar?

Your inner voice:

"Three dice, same interface. Roll() returns a number. But HOW it calculates that number is different every time. The single die uses one random call. The double die uses two random calls and checks if they match. The rigged die just pops the next number from a queue."

"I could use a switch statement on a DiceType enum... but that's the same mistake we avoided in Level 3 with states. And for testing, I need to inject a dice that returns exactly what I want. If the dice is hardcoded inside PlayingState, I can't swap it."

"Multiple algorithms, same interface. The game doesn't care how the number is generated — it just needs a number. That's the Strategy patternThe Strategy pattern defines a family of algorithms, puts each 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 between different route algorithms on a GPS — fastest, shortest, scenic — the app interface stays the same.. Each dice type becomes its own class implementing IDiceStrategy. The game holds one, uses it, and doesn't care which one it is."

What Would You Do?

Three ideas for adding different dice. Two survive initially, but only one survives the "add a new dice type with zero changes" test.

The idea: Add a DiceType enum and switch on it inside the Roll logic.

SwitchDice.cs — another switch statement
public int RollDice(DiceType type)
{
    return type switch
    {
        DiceType.Single => Random.Shared.Next(1, 7),
        DiceType.Double => Random.Shared.Next(1, 7) + Random.Shared.Next(1, 7),
        DiceType.Rigged => _riggedQueue.Dequeue(), // how does this get here?!
        _ => throw new ArgumentException("Unknown dice type")
    };
}
// Problem: DiceResult needs IsDouble for bonus turn.
// Now you need ANOTHER switch to check if doubles occurred.
// And where does the rigged queue live? In the game class? Messy.

Verdict: We just escaped the switch trap in Level 3, and here we are falling right back in. Adding a weighted dice? Edit this method. The rigged dice queue has nowhere clean to live — it ends up as a weird field on the game class that's only used in tests. And the double-dice bonus-turn logic leaks into the calling code instead of being encapsulated.

The idea: Create a base Dice class and inherit from it: DoubleDice : SingleDice.

InheritanceDice.cs — wrong "is-a" relationship
public class SingleDice
{
    public virtual int Roll() => Random.Shared.Next(1, 7);
}

public class DoubleDice : SingleDice
{
    public override int Roll()
    {
        // A double die IS NOT a single die that rolls twice!
        // It has completely different behavior: two independent rolls,
        // checking for doubles, and returning a combined result.
        int d1 = Random.Shared.Next(1, 7);
        int d2 = Random.Shared.Next(1, 7);
        IsDouble = d1 == d2;
        return d1 + d2;
    }
    public bool IsDouble { get; private set; }
}

// Where does RiggedDice fit?
// RiggedDice : SingleDice? It's not a single die at all!
// The inheritance hierarchy is a lie.

Verdict: A double die is not a single die. They share a method name, but they don't share a relationship. This violates the Liskov Substitution PrincipleThe LSP says if class B extends class A, you should be able to use B anywhere A is expected without breaking anything. DoubleDice violates this because it returns 2-12 instead of 1-6 and has an extra IsDouble property that SingleDice doesn't know about. It surprises the caller. — DoubleDice returns values outside SingleDice's range (2-12 vs 1-6), which surprises any code expecting 1-6. And RiggedDice has no logical parent. Inheritance models "is-a" — these dice share an interface, not an identity.

When IS inheritance actually better? When there's genuine shared behavior. A StreamReader : TextReader makes sense because a stream reader IS a text reader with a specific backing store.

The idea: Define an IDiceStrategy interface with one method: Roll(). Each dice type becomes its own class. The game holds a strategy and calls it.

StrategyDice.cs — plug in any dice
// The game just asks for a result. It doesn't know how.
DiceResult result = _diceStrategy.Roll();
int value = result.Value;
bool isDouble = result.IsDouble;

// Standard game? new SingleDice()
// Variant mode? new DoubleDice()
// Unit test? new RiggedDice(4, 6, 3, 1)

// Adding WeightedDice? One new class. Zero changes anywhere.

Verdict: This is the winner. Each dice type is a self-contained class that owns its own algorithm and its own state. The game doesn't know which dice it's using. Swapping from single to double is one line. Testing with rigged dice is one line. Adding new dice types means writing one new class — zero changes to the game, zero changes to existing dice.

The Solution

The Strategy patternA behavioral design pattern that defines a family of algorithms, puts each in its own class, and makes them interchangeable at runtime. The object using the algorithm doesn't know or care which strategy it's running. Think of it like interchangeable batteries — AA, rechargeable, or lithium — the device just needs power. turns each dice type into a pluggable component. The game asks "give me a roll result" and doesn't care whether that result came from random numbers, two dice added together, or a predetermined test sequence.

IDiceStrategy.cs — the one method every dice type must implement
public interface IDiceStrategy
{
    DiceResult Roll();
}

// The result carries BOTH the total value AND whether doubles occurred.
// This is much richer than returning a plain int.
public readonly record struct DiceResult(int Value, bool IsDouble);

Two pieces working together. The interfaceAn interface in C# is a contract that says "any class that implements me must have these methods." It doesn't provide any logic — it just declares what the class must be able to do. Think of it like a job description: it says "must be able to Roll()" without specifying how. says "you must be able to roll." The record structA readonly record struct in C# is a lightweight, immutable value type with built-in equality. It's perfect for small data carriers like DiceResult — it holds a Value and an IsDouble flag, can't be modified after creation, and lives on the stack for better performance. carries the result — not just the number, but also the "was it a double?" flag. By putting both in the result, the game can check for doubles without knowing which dice type produced it.

SingleDice.cs — the classic one-die roll
public sealed class SingleDice : IDiceStrategy
{
    public DiceResult Roll()
    {
        int value = Random.Shared.Next(1, 7);
        return new DiceResult(value, IsDouble: false);
        // Single die can never produce doubles
    }
}

The simplest implementation. One random call, 1 through 6, never doubles. Notice it's sealed — nobody should inherit from this. If you need different behavior, create a new strategy class.

DoubleDice.cs — two dice, doubles detection, bell curve
public sealed class DoubleDice : IDiceStrategy
{
    public DiceResult Roll()
    {
        int die1 = Random.Shared.Next(1, 7);
        int die2 = Random.Shared.Next(1, 7);

        bool isDouble = die1 == die2;
        int total = die1 + die2;

        Console.WriteLine(
            $"Rolled {die1} + {die2} = {total}"
            + (isDouble ? " DOUBLES!" : ""));

        return new DiceResult(total, isDouble);
        // Range: 2-12, bell curve distribution
        // Doubles probability: 1/6 (six matching pairs out of 36)
    }
}

Two independent random calls. The total ranges from 2 to 12 with a bell curve distributionWith two dice, the values cluster around 7 (most likely) and the extremes (2 and 12) are rare. That's because there's only one way to roll a 2 (1+1) but six ways to roll a 7 (1+6, 2+5, 3+4, 4+3, 5+2, 6+1). This creates a bell-shaped probability curve. — 7 is most common, 2 and 12 are rarest. Doubles happen 1-in-6 rolls. The game can check result.IsDouble to grant a bonus turn, and it doesn't need to know these were two dice — the strategy handles all of that internally.

RiggedDice.cs — deterministic rolls for testing
public sealed class RiggedDice : IDiceStrategy
{
    private readonly Queue<int> _values;

    public RiggedDice(params int[] values)
        => _values = new Queue<int>(values);

    public DiceResult Roll()
    {
        if (_values.Count == 0)
            throw new InvalidOperationException(
                "RiggedDice ran out of values! "
                + "Add more values to the constructor.");

        int value = _values.Dequeue();
        return new DiceResult(value, IsDouble: false);
    }
}

// Usage in tests:
// var dice = new RiggedDice(6, 4, 3, 6, 1);
// First roll = 6, second = 4, third = 3, etc.
// Fully deterministic. Every test run is identical.

This is the testing superpower. Feed it a sequence of numbers, and it returns them in order. Need to test "player rolls 6 from position 94 and wins"? new RiggedDice(6). Need to test a full 20-turn game? Feed it 20 values. The game has no idea the dice are rigged — it gets a DiceResult just like any other strategy. This is the magic of programming to an interfaceWhen code depends on an interface rather than a concrete class, you can swap implementations freely. The game depends on IDiceStrategy, so it works with SingleDice, DoubleDice, or RiggedDice without knowing the difference. This is what makes mocking and testing possible. — the game depends on the contract, not the implementation.

Game.cs — updated to accept a dice strategy
public sealed class Game
{
    private IGameState _currentState;
    private readonly IDiceStrategy _dice; // NEW — injected via constructor

    public Game(BoardConfig board, IDiceStrategy dice)
    {
        Board = board;
        _dice = dice;           // Store the strategy
        Players = new PlayerQueue();
        _currentState = new WaitingState();
    }

    // PlayingState now uses this instead of Random.Shared directly:
    internal DiceResult RollDice() => _dice.Roll();

    // Everything else stays exactly the same...
    public void Roll()       => _currentState.Roll(this);
    public void AddPlayer(string name) => _currentState.AddPlayer(this, name);
    // ... etc
}

// Creating games with different dice:
var standard = new Game(board, new SingleDice());
var variant  = new Game(board, new DoubleDice());
var testGame = new Game(board, new RiggedDice(4, 6, 3, 2, 5));

The change to the Game class is tiny: one new field, one new constructor parameter. PlayingState now calls game.RollDice() instead of Random.Shared.Next(). The dice strategy is injectedDependency Injection means passing a dependency (like the dice strategy) into a class from the outside, instead of the class creating it internally. This lets the caller choose which implementation to use. The game receives its dice, it doesn't create them. through the constructor — whoever creates the game decides which dice to use. Production code passes SingleDice or DoubleDice. Test code passes RiggedDice. The game doesn't know the difference.

Diagrams

Strategy Fan-Out — One Interface, Three Implementations

The game depends on the interface (the dashed box at the top). Below it, three implementations offer completely different algorithms. The game never sees or cares about which one is active.

Game «interface» IDiceStrategy Roll() : DiceResult SingleDice 1d6 | range 1-6 flat distribution DoubleDice 2d6 | range 2-12 | doubles bell curve distribution RiggedDice predetermined | any range for unit tests

Probability Distribution — 1d6 Flat vs. 2d6 Bell Curve

Why does the dice type matter for gameplay? With a single die, every number (1-6) has an equal 16.7% chance. With two dice, 7 is the most likely result and the extremes (2 and 12) are rare. This fundamentally changes the game's speed and strategy.

Single Die (1d6) Each value equally likely: 16.7% 1 16.7% 2 3 4 5 6 Average move: 3.5 squares ~29 rolls to finish (100 / 3.5) Double Dice (2d6) 7 most likely, extremes rare 2 3 4 5 6 7 16.7% 8 9 10 11 12 Average move: 7 squares ~14 rolls to finish + bonus turns from doubles! Same game, different dice = completely different gameplay experience

Plug & Play — Same Game, Different Dice

This is the Strategy pattern's superpower: the Game class is identical in all three cases. Only the dice strategy changes. Production, variant mode, and testing all use the same game code.

Game (standard) SingleDice Production: classic mode Game (variant) DoubleDice Variant: faster, bonus turns Game (test) RiggedDice Testing: deterministic Game class is IDENTICAL in all three. Only the injected dice strategy differs.

Growing Diagram — Level 4

Four new pieces: the IDiceStrategy interface, DiceResult record, and three concrete dice classes. The game now accepts its dice as a constructor parameter.

Game RollDice() | Board | Players | _dice BoardConfig PlayerQueue IGameState + 4 states «interface» IDiceStrategy Roll() : DiceResult NEW DiceResult Value : int | IsDouble : bool NEW SingleDice NEW DoubleDice NEW RiggedDice NEW Player Snake Ladder GameFactory GameSettings System after Level 4 — 17 types | cyan/colored = new | gray = previous levels

Before / After Your Brain

Before This Level

You see "different dice types" and think "switch statement on a dice type enum" or "inherit from a base dice class."

After This Level

You smell "multiple algorithms, same interface" and instinctively reach for Strategy. You also realize that testability is a first-class reason to use Strategy — rigged implementations are a testing superpower.

Smell → Pattern: Multiple Algorithms, Same Interface — When you see 3+ ways to do the same thing and the choice happens at runtime → Strategy pattern. Each algorithm becomes its own class behind an interface. Bonus: it instantly makes the code testable via rigged/mock implementations.
Transfer: Same technique in Parking Lot: IPricingStrategy with HourlyPricing, FlatRatePricing, and TestPricing. Same in GPS Navigation: IRouteStrategy with FastestRoute, ShortestRoute, and ScenicRoute. Same in any system where an algorithm can vary independently from the code that uses it.
Section 8

Level 5 — Edge Cases 🔴 HARD

New Constraint: "Must land EXACTLY on 100 (overshoot = bounce back). Double-six gets bonus turn. Snake chains (land on snake, drop, land on ANOTHER snake). Ladder-snake loops must be detected and rejected during board creation."
What breaks: Level 4 doesn't check for exact 100 — a player at position 97 who rolls 5 lands at 102, which doesn't exist on the board. There's no bonus turn for doubles. Snake chains aren't resolved — if you land on a snake that drops you onto another snake, only the first one fires. And the worst bug: if someone creates a board where a ladder goes from 50 to 85 and a snake goes from 85 to 50, any player who lands on 50 loops forever. The game freezes.

Apply the What If? framework to Snake & Ladder. For each category, think of at least one scenario that would break the current code:

  • Boundary: Player at 97, rolls 5 — what happens?
  • Recursive: Snake at 97→25, but cell 25 has snake 25→3 — how many hops?
  • Cycle: Ladder 50→85 + Snake 85→50 = infinite loop. How to prevent?
  • Concurrency: In online mode, two players submit rolls at the same time.

Take 60 seconds. What's the scariest edge case — the one that could silently corrupt a game?

Your inner voice:

"Let me walk through each edge case category..."

"Boundary — exact 100 rule. Player at 97, rolls 5 = 102. That's past the finish line. The rule says you have to land exactly on 100. So 102 is invalid... bounce back? 100 - (102 - 100) = 98. The player stays at 97, moves forward to 100, overshoots by 2, bounces back 2 spots to 98. That feels right."

"Recursive — chain resolution. Right now, GetDestination() is called once. But what if the destination is ALSO a snake or ladder? I need to keep resolving until the player lands on a cell with no transition. That's a while loop: while (board.HasTransition(pos)) pos = board.GetDestination(pos);"

"Cycle — this is the scary one. If the chain resolution enters a loop, the while loop runs forever. I need to detect cycles during board creation — reject any board that has a cycle. And as a safety net, add a max-hop guard to the resolution loop."

"Wait — this isn't a pattern. This is the What If? frameworkA systematic approach to finding edge cases: walk through boundary conditions, recursive structures, cycles, and concurrency scenarios. The framework forces you to ask 'what if THIS breaks?' for each category, instead of only testing the happy path.. I'm not reaching for a design pattern. I'm reaching for defensive coding — validation, guards, and result types. The 'happy path only' code smell."

Diagrams

The What If? Framework — Four Quadrants of Edge Cases

Every system has the same four categories of things that can go wrong. By systematically walking through each quadrant, you find bugs that happy-path testing never catches.

What If? Framework — Snake & Ladder Boundary What happens at the edges? • Position 97 + roll 5 = 102 (past 100) • Position 100 exactly = WIN • Position 0 (before first roll) • Roll 1 from position 99 = WIN Recursive What if the result triggers another result? • Snake 97→25, then snake 25→3 • Ladder 5→40, then ladder 40→78 • How many hops max? • Need while loop + visited tracking Cycle What if it loops forever? • Ladder 50→85 + Snake 85→50 = ∞ • 3-step: 20→60→80→20 = loop • Detect during board creation! • Safety: max-hop guard at runtime Concurrency What if two things happen at once? • Two players submit rolls simultaneously • Player disconnects mid-roll • Spectator joins during state transition • (Addressed in Level 7 — Scale It) What If?

What Would You Do?

Three ways to handle these edge cases. One quietly ignores them. One crashes the program. One handles them gracefully with a rich return type.

The idea: Just... don't handle them. Let the game do whatever happens naturally.

IgnoreEdgeCases.cs — the "it works on my machine" approach
// Player at 97 rolls 5...
player.Position = 97 + 5; // Position = 102
// 102 doesn't exist on the board. Now what?
// board.GetDestination(102) → IndexOutOfRangeException? null? 0?
// Nobody knows until it happens in production.

// Snake chain at 97→25→3?
player.Position = board.GetDestination(97); // = 25
// Player lands on 25. There's a snake at 25→3.
// But we only checked once. Player stays at 25.
// The second snake is silently ignored.

// Ladder 50→85 + Snake 85→50?
// If we DO resolve chains... infinite loop. Game hangs.
// If we DON'T... second transition is silently ignored.
// Both are bugs. Silent ones — the worst kind.

Verdict: This is the "Happy-Path Hero" anti-pattern. The code looks clean. It passes code review. And then the first production game with a player near position 100 crashes or hangs. Silent bugs are worse than loud crashes — at least a crash tells you something's wrong.

The idea: Throw exceptions for every invalid scenario.

ThrowExceptions.cs — loud but brittle
public void Roll()
{
    int newPos = player.Position + diceValue;

    if (newPos > 100)
        throw new InvalidOperationException(
            $"Overshoot: {newPos} exceeds 100!");
    // But wait — overshooting isn't an ERROR.
    // It's a normal game rule: bounce back.
    // Throwing here forces the caller to wrap in try-catch.
    // And the caller doesn't know if this is a real bug
    // or just a player who rolled too high.
}

Verdict: Better than ignoring — at least we detect the problem. But overshooting isn't an error, it's a normal game scenario. Using exceptions for expected game rules is like pulling the fire alarm because someone asked a question. The game loop needs to handle "overshoot" differently from "position 102 array index out of bounds." Exceptions blur that distinction.

The idea: Return a rich MoveResult that describes exactly what happened — did the player move normally, overshoot, hit a snake, climb a ladder, or win?

MoveResult.cs — every outcome explicitly described
public sealed record MoveResult(
    string PlayerName,
    int DiceValue,
    int StartPosition,
    int FinalPosition,
    MoveOutcome Outcome,
    IReadOnlyList<string> Events);

public enum MoveOutcome
{
    Normal,       // Moved to new position, no special events
    Overshoot,    // Bounced back — didn't reach 100
    SnakeHit,     // Landed on snake(s), slid down
    LadderClimb,  // Landed on ladder(s), climbed up
    Won,          // Landed exactly on 100!
    BonusTurn     // Rolled doubles, gets another turn
}

// Usage: caller always gets a clear answer
var result = game.Roll();
// result.Outcome == MoveOutcome.Overshoot
// result.Events == ["Rolled 5 from 97 → 102 → bounce to 98"]
// No exceptions. No guessing. No crashes.

Verdict: This is the winner. Every possible outcome is explicitly modeled. The caller never has to guess what happened. The Events list provides a human-readable log of every step (snake hit, ladder climbed, bounce back). No exceptions for expected scenarios. No silent failures. And it's perfectly testable — just check result.Outcome and result.FinalPosition.

The Solution

Three pieces working together: the exact-100 bounce logic, the chain resolution loop with cycle protection, and the board validator that catches loops before the game even starts.

Exact-100 bounce — overshoot = bounce back
public static int ApplyBounce(int currentPos, int diceValue)
{
    int target = currentPos + diceValue;

    if (target == 100)
        return 100; // Exact hit — WIN!

    if (target > 100)
    {
        // Overshoot: bounce back from 100
        // At 97, roll 5: target = 102, overshoot = 2, bounce to 98
        int overshoot = target - 100;
        return 100 - overshoot;
    }

    return target; // Normal move
}

// Examples:
// ApplyBounce(97, 3) → 100 (WIN!)
// ApplyBounce(97, 5) → 98  (102 → bounce → 98)
// ApplyBounce(97, 1) → 98  (normal)
// ApplyBounce(99, 1) → 100 (WIN!)
// ApplyBounce(99, 6) → 95  (105 → bounce → 95)

Simple arithmetic. If the player would go past 100, calculate how far they overshoot and bounce them back that many squares from 100. A player at 97 who rolls 5 would land at 102 — that's 2 past 100, so they bounce to 98. Clean, predictable, easy to test.

ResolvePosition — chase the chain until it ends
public int ResolvePosition(int pos, BoardConfig board,
    List<string> events)
{
    const int maxHops = 10; // Safety net — no real board needs 10 hops
    var visited = new HashSet<int>();
    int hops = 0;

    while (board.HasTransition(pos))
    {
        // Cycle detection: have we been here before?
        if (!visited.Add(pos))
            throw new InvalidBoardException(
                $"Cycle detected at position {pos}! "
                + "Board validation should have caught this.");

        // Safety: guard against absurdly long chains
        if (++hops > maxHops)
            throw new InvalidBoardException(
                $"Chain exceeded {maxHops} hops from {pos}.");

        int oldPos = pos;
        pos = board.GetDestination(pos);

        // Log the event for MoveResult
        string type = pos < oldPos ? "Snake" : "Ladder";
        events.Add($"{type}: {oldPos} → {pos}");
    }

    return pos;
}

// Example: Snake chain 97→25→3
// Hop 1: pos=97 → visited={97} → dest=25 → "Snake: 97→25"
// Hop 2: pos=25 → visited={97,25} → dest=3 → "Snake: 25→3"
// Hop 3: pos=3 → no transition → return 3
// Events: ["Snake: 97→25", "Snake: 25→3"]

The visited set tracks every position we've passed through. If we visit the same position twice, we've found a cycle — throw immediately. The maxHops guard is a belt-and-suspenders safety net. In practice, board validation (next tab) catches cycles at creation time, but this guard protects against bugs in validation itself. Defense in depth.

BoardValidator.cs — catch bad boards before the game starts
public static class BoardValidator
{
    public static void Validate(BoardConfig board)
    {
        ValidateNoOverlaps(board);
        ValidateNoCycles(board);
        ValidatePositionRange(board);
    }

    private static void ValidateNoCycles(BoardConfig board)
    {
        // For every cell that has a transition, follow the chain
        for (int pos = 1; pos <= 100; pos++)
        {
            if (!board.HasTransition(pos)) continue;

            var visited = new HashSet<int>();
            int current = pos;

            while (board.HasTransition(current))
            {
                if (!visited.Add(current))
                    throw new InvalidBoardException(
                        $"Cycle detected: position {current} "
                        + $"appears in chain starting at {pos}. "
                        + $"Path: {string.Join(" → ", visited)} → {current}");
                current = board.GetDestination(current);
            }
        }
    }

    private static void ValidateNoOverlaps(BoardConfig board)
    {
        // A cell can't be both a snake head AND a ladder bottom
        foreach (var snake in board.Snakes)
            if (board.Ladders.Any(l => l.Bottom == snake.Head))
                throw new InvalidBoardException(
                    $"Position {snake.Head} is both a snake head "
                    + "and a ladder bottom!");
    }

    private static void ValidatePositionRange(BoardConfig board)
    {
        // No snake/ladder can start or end at 1 or 100
        // 1 = start, 100 = win condition
        foreach (var s in board.Snakes)
        {
            if (s.Head <= 1 || s.Head >= 100 || s.Tail < 1)
                throw new InvalidBoardException(
                    $"Snake {s.Head}→{s.Tail} has invalid positions.");
        }
    }
}

This runs once, when the board is created. If it passes, the board is guaranteed cycle-free and well-formed. Three checks: no cell has both a snake and a ladder (overlaps), no chain forms a loop (cycles), and no snake/ladder starts or ends at position 1 or 100 (range). Catching problems at creation time is always better than catching them mid-game.

PlayingState.Roll() — all edge cases handled
public void Roll(Game game)
{
    var player = game.CurrentPlayer;
    var diceResult = game.RollDice();
    var events = new List<string>();
    int startPos = player.Position;

    // Step 1: Apply bounce rule
    int afterBounce = MoveHelper.ApplyBounce(
        player.Position, diceResult.Value);

    bool isOvershoot = afterBounce != player.Position + diceResult.Value
                    && afterBounce != 100;
    if (isOvershoot)
        events.Add($"Overshoot! {player.Position + diceResult.Value}"
            + $" → bounced to {afterBounce}");

    // Step 2: Resolve chains (snakes/ladders)
    int finalPos = MoveHelper.ResolvePosition(
        afterBounce, game.Board, events);

    // Step 3: Update player
    player.Position = finalPos;

    // Step 4: Determine outcome
    MoveOutcome outcome;
    if (finalPos == 100)
        outcome = MoveOutcome.Won;
    else if (isOvershoot)
        outcome = MoveOutcome.Overshoot;
    else if (events.Any(e => e.StartsWith("Snake")))
        outcome = MoveOutcome.SnakeHit;
    else if (events.Any(e => e.StartsWith("Ladder")))
        outcome = MoveOutcome.LadderClimb;
    else if (diceResult.IsDouble)
        outcome = MoveOutcome.BonusTurn;
    else
        outcome = MoveOutcome.Normal;

    // Step 5: Handle game state transitions
    if (outcome == MoveOutcome.Won)
    {
        game.Winner = player;
        game.TransitionTo(new FinishedState());
    }
    else if (outcome != MoveOutcome.BonusTurn)
    {
        game.AdvanceTurn(); // Doubles = same player goes again
    }

    game.LastMove = new MoveResult(
        player.Name, diceResult.Value,
        startPos, finalPos, outcome, events);
}

Every edge case is handled in a clear sequence: bounce, resolve chains, determine outcome, transition state. Notice the doubles bonus turn — if the outcome is BonusTurn, we don't advance the turn, so the same player rolls again. The MoveResult captures everything that happened, making it perfect for logging, UI display, and testing.

Exact-100 Bounce — Visual Walkthrough

The player at position 97 rolls a 5. They need exactly 3 more to hit 100, but they rolled 5 — that's 2 too many. The ball bounces off 100 and comes back 2 squares to 98.

94 95 96 97 P 98 99 100 WIN 102 doesn't exist! +5 = 102 (overshoot!) bounce! 100 - 2 = 98 5 dice roll

Snake Chain Resolution — Multi-Hop Cascade

This is the trickiest edge case. The player lands on a snake, slides down, and lands on another snake. Without chain resolution, the second snake is silently ignored. With it, the player cascades through every transition until they land on a clean cell.

Snake Chain: 97 → 25 → 3 97 Snake head 🐍 slides to 25 P lands! HOP 1: Slide down! 25 Another snake! 🐍 slides to 3 HOP 2: Slide again! 3 Clean cell ✅ No transition P stops Chain Resolution with Cycle Detection Hop 1: pos=97 visited = {97} dest = 25 Hop 2: pos=25 visited = {97, 25} dest = 3 Hop 3: pos=3 HasTransition(3)? No → STOP, return 3 Events: ["Snake: 97→25", "Snake: 25→3"]

Growing Diagram — Level 5

New pieces: MoveResult record, MoveOutcome enum, BoardValidator, and MoveHelper (bounce + chain resolution). The system now has built-in safety nets.

Game BoardConfig PlayerQueue IGameState + 4 states IDiceStrategy + 3 dice MoveResult PlayerName | Dice | Start | Final | Outcome | Events NEW MoveOutcome Normal | Overshoot | Won | SnakeHit | ... NEW MoveHelper ApplyBounce() | ResolvePosition() NEW BoardValidator ValidateNoCycles() | ValidateNoOverlaps() NEW InvalidBoardException thrown when board has cycles/overlaps NEW System after Level 5 — 22 types | colored = new | gray = previous levels

Before / After Your Brain

Before This Level

You write the happy path, feel good about it, and think "edge cases are rare, I'll handle them later." (You never do.)

After This Level

You systematically apply the What If? framework (Boundary, Recursive, Cycle, Concurrency) to every system. You validate data at creation time, resolve chains with cycle guards, and model every outcome as a rich return type.

Smell → Pattern: Happy Path Only — When your code handles success beautifully but ignores what happens at boundaries, in chains, or in loops → What If? framework. Walk through all four quadrants. Validate input at creation time. Resolve chains with cycle guards. Return rich result types instead of throwing for expected scenarios.
Transfer: Same framework for Elevator: Boundary = floor 0 and max floor. Recursive = multiple stops queued. Cycle = elevator oscillating between two floors. Same for Shopping Cart: Boundary = quantity 0 or max int. Recursive = discount that triggers another discount. Cycle = coupon A requires coupon B which requires coupon A. The four quadrants work everywhere.
Section 9

Level 6 — Testability 🔴 HARD

New Constraint: "Game must be fully testable: set up any board, use rigged dice for deterministic outcomes, verify exact move sequences."
What breaks: Level 5's Game still creates its own BoardConfig internally through GameFactory. You can't test "player at 97 with a snake at 97→25 rolls 5, bounces to 98" without controlling both the dice AND the board layout. We already have RiggedDice from Level 4 — but the board creation is still locked inside the factory. Tests are half-deterministic at best: you control the dice but not where the snakes and ladders are.

Write this test in your head: "Player rolls a 6 from position 94, reaches 100, wins." What do you need to control?

  • The dice must roll exactly 6 — we have RiggedDice for this.
  • The board must have no snake at position 100 — we need to create a custom board.
  • The outcome must be exactly MoveOutcome.Won with final position 100.

What needs to be injectable — passed in from outside — so tests can control every variable?

Your inner voice:

"We already injected the dice in Level 4. But the board is still created inside the factory. If I want a specific board layout for testing — say, only one snake at position 97 — I need to create the board externally and pass it in."

"Actually, we already pass BoardConfig to the Game constructor. The issue is that GameFactory calls BoardValidator internally, creates the config, and passes it to Game. For tests, I can skip the factory entirely and create a BoardConfig directly."

"Wait, this is the bigger picture: Dependency InjectionDependency Injection (DI) means passing dependencies into a class from the outside rather than the class creating them itself. Instead of 'new SomeDependency()' inside the class, you accept it through the constructor. This lets tests swap in fakes, mocks, or rigged versions.. The Game class should receive ALL its dependencies from outside — the board, the dice, even the validator. Tests create the exact configuration they need. Production uses the factory. The Game doesn't know or care who built its pieces."

"This is where everything clicks. Every pattern we used — State, Strategy, records — they all made the code testable as a side effect. Interfaces for dice? Test with RiggedDice. State pattern? Test each state in isolation. MoveResult? Assert on exact outcomes. DI is the glue that connects all the patterns into a testable whole."

The Solution

No fork for this level — there's really only one right answer for testability: inject everything. The Game accepts its board and dice from outside. Tests create the exact scenario they need. Production uses the factory for convenience.

Game.cs — fully injectable, fully testable
public sealed class Game
{
    private IGameState _currentState;
    private readonly IDiceStrategy _dice;

    // ALL dependencies injected through constructor
    public Game(BoardConfig board, IDiceStrategy dice)
    {
        Board = board;
        _dice = dice;
        Players = new PlayerQueue();
        _currentState = new WaitingState();
    }

    public BoardConfig Board { get; }
    public PlayerQueue Players { get; }
    public Player CurrentPlayer => Players.Current;
    public Player? Winner { get; internal set; }
    public MoveResult? LastMove { get; internal set; }

    // Public API — delegates to current state
    public void AddPlayer(string name)
        => _currentState.AddPlayer(this, name);
    public void Start()
        => _currentState.Start(this);
    public void Roll()
        => _currentState.Roll(this);
    public void Pause()
        => _currentState.Pause(this);
    public string GetWinner()
        => _currentState.GetWinner(this);

    // Internal — used by states
    internal DiceResult RollDice() => _dice.Roll();
    internal void TransitionTo(IGameState state)
        => _currentState = state;
    internal void AdvanceTurn() => Players.Advance();
}

// Factory for production convenience
public static class GameFactory
{
    public static Game CreateClassic()
    {
        var board = BoardConfigs.Classic();
        BoardValidator.Validate(board);
        return new Game(board, new SingleDice());
    }

    public static Game CreateVariant()
    {
        var board = BoardConfigs.Random();
        BoardValidator.Validate(board);
        return new Game(board, new DoubleDice());
    }
}

The Game constructor is pure DI — it takes a board and a dice, nothing else. No new calls inside. No static dependencies. No hidden state. The GameFactory is separate — it's a convenience for production code that wires up the "standard" configuration. Tests bypass the factory entirely and create their own configurations.

WinScenarioTest.cs — player rolls 6 from 94, wins
[Fact]
public void Player_Rolls6_From94_Wins()
{
    // ARRANGE: empty board (no snakes/ladders), rigged dice
    var board = new BoardConfig(
        snakes: Array.Empty<Snake>(),
        ladders: Array.Empty<Ladder>());

    var dice = new RiggedDice(6);
    var game = new Game(board, dice);

    // Add players, manually set position
    game.AddPlayer("Alice");
    game.AddPlayer("Bob");
    game.Start();
    game.CurrentPlayer.Position = 94; // Cheat for testing

    // ACT
    game.Roll(); // Alice rolls 6: 94 + 6 = 100 = WIN!

    // ASSERT
    Assert.Equal(MoveOutcome.Won, game.LastMove!.Outcome);
    Assert.Equal(100, game.LastMove.FinalPosition);
    Assert.Equal("Alice", game.GetWinner());
}
// This test is 100% deterministic.
// Run it 1000 times, same result every time.
// RiggedDice(6) always returns 6.
// Empty board means no snake/ladder surprises.

Every variable is controlled. The dice always rolls 6. The board has no snakes or ladders. The player starts at 94. The result is perfectly predictable. This test will produce the same result on every developer's machine, on CI, at 3 AM, forever. That's the power of DI + RiggedDice + injectable boards.

SnakeChainTest.cs — cascading snakes, exact positions
[Fact]
public void SnakeChain_ResolvesAllHops()
{
    // ARRANGE: two chained snakes
    var board = new BoardConfig(
        snakes: new[]
        {
            new Snake(Head: 97, Tail: 25),
            new Snake(Head: 25, Tail: 3)
        },
        ladders: Array.Empty<Ladder>());

    var dice = new RiggedDice(2); // 95 + 2 = 97 → snake!
    var game = new Game(board, dice);

    game.AddPlayer("Alice");
    game.AddPlayer("Bob");
    game.Start();
    game.CurrentPlayer.Position = 95;

    // ACT
    game.Roll();

    // ASSERT: landed on 97 → snake to 25 → snake to 3
    Assert.Equal(3, game.LastMove!.FinalPosition);
    Assert.Equal(MoveOutcome.SnakeHit, game.LastMove.Outcome);
    Assert.Contains("Snake: 97", game.LastMove.Events[0]);
    Assert.Contains("Snake: 25", game.LastMove.Events[1]);
}

We create a board with exactly two chained snakes and nothing else. The rigged dice ensures the player lands on 97. The test verifies the entire chain: landed on 97, slid to 25, slid again to 3. Both events are logged. The outcome is SnakeHit. Without DI + custom boards, this scenario would be impossible to test reliably.

BounceAndSnakeTest.cs — overshoot + snake combo
[Fact]
public void Overshoot_ThenSnake_HandledCorrectly()
{
    // ARRANGE: snake at position 98
    var board = new BoardConfig(
        snakes: new[] { new Snake(Head: 98, Tail: 42) },
        ladders: Array.Empty<Ladder>());

    var dice = new RiggedDice(5); // 97 + 5 = 102 → bounce to 98 → snake!
    var game = new Game(board, dice);

    game.AddPlayer("Alice");
    game.AddPlayer("Bob");
    game.Start();
    game.CurrentPlayer.Position = 97;

    // ACT
    game.Roll();

    // ASSERT: bounced to 98, then hit snake to 42
    Assert.Equal(42, game.LastMove!.FinalPosition);
    // The outcome is SnakeHit (snake is the more "impactful" event)
    Assert.Equal(MoveOutcome.SnakeHit, game.LastMove.Outcome);
}

// The scariest combo: overshoot + chain resolution.
// Without both, this scenario either crashes or gives wrong results.

This test combines two edge cases: overshoot bounce and snake hit. Player at 97 rolls 5, would land at 102, bounces to 98, which has a snake that drops them to 42. Without the bounce logic, they'd go to 102 and crash. Without chain resolution, they'd stay at 98. Both must work together. This is exactly the kind of scenario that breaks in production but is easy to test with DI.

Diagrams

Dependency Injection Graph — Who Creates What

In production, the factory wires up the Game with a real board and real dice. In tests, the test method creates a custom board and rigged dice. The Game class is identical in both cases — it receives its dependencies and does its job.

Production GameFactory.CreateClassic() BoardConfigs.Classic() new SingleDice() Game Random dice, classic board Non-deterministic gameplay Testing SnakeChainTest() custom BoardConfig RiggedDice(2) Game Rigged dice, custom board 100% deterministic

Test Anatomy — Create, Control, Verify

Every test follows the same three-step pattern: create a controlled environment, trigger the action, and verify the exact outcome. DI makes step 1 possible. Rich return types (MoveResult) make step 3 precise.

ARRANGE Create controlled environment board = custom snakes/ladders dice = RiggedDice(6) player.Position = 94 game = new Game(board, dice) ACT Trigger the action game.Roll(); ASSERT Verify exact outcome Outcome == MoveOutcome.Won FinalPosition == 100 Events == ["Normal: 94 → 100"] GetWinner() == "Alice" DI makes Arrange possible. MoveResult makes Assert precise. Together = 100% deterministic tests.

Growing Diagram — Level 6

No new classes in Level 6 — only a new arrangement. The Game constructor is now the sole entry point. Tests and production use the same Game class with different configurations.

Game constructor(BoardConfig, IDiceStrategy) BoardConfig IDiceStrategy GameFactory CreateClassic() CreateVariant() Test Methods custom board + RiggedDice 100% deterministic Level 6 adds no new types — just a new arrangement Same 22 types from Level 5. DI makes them testable without modification.

Before / After Your Brain

Before This Level

You write code first, then struggle to test it. Tests are flaky because they depend on random values and hidden state.

After This Level

You design for testability from the start. Every dependency is injectable. Tests control every variable. "Works on my machine" is replaced by "passes on CI, every time, deterministically."

Smell → Pattern: Untestable Code — When you can't write a deterministic test for a scenario because the code creates its own dependencies internally → Dependency Injection. Pass dependencies through the constructor. Tests inject fakes. Production injects real implementations. The class doesn't know the difference.
Transfer: Same technique everywhere. Parking Lot: inject IPricingStrategy + IDisplayBoard for deterministic fee tests. Elevator: inject ISchedulingStrategy for deterministic floor-order tests. Any system with randomness or external dependencies: wrap the dependency in an interface, inject it, test with a controlled fake.
Section 10

Level 7 — Scale It 🔴 HARD

New Constraint: "Online multiplayer: game rooms (lobby → playing → finished), spectators watching live, leaderboard tracking wins, full game replay."
What breaks: Level 6 is a single in-process game. There's no concept of multiple games running at once, game rooms where players can join and spectate, or any mechanism for spectators to see moves in real time. The game doesn't broadcast events — it just updates internal state. There's no leaderboard, no replay capability, and no way for external systems to react to game events without tightly coupling to the Game class.

How does a spectator see moves in real-time without the game knowing about spectators? The game should just announce "something happened" — whoever cares can listen. The game shouldn't have a list of UIs to update, a leaderboard to write to, or a replay log to append to.

What pattern lets an object broadcast events to an unknown number of listeners without depending on any of them?

Your inner voice:

"The game needs to announce what happens — 'player moved', 'snake hit', 'player won' — without knowing who's listening. A spectator UI needs to hear those events. A leaderboard service needs to hear them. A replay recorder needs to hear them. But the game shouldn't import or reference any of these."

"If I add _spectatorUI.Update() and _leaderboard.RecordWin() inside the Game class, I'm tightly coupling the game to every listener. Adding a new listener (like an analytics service) means editing the Game class. That's the opposite of what we want."

"The game needs to say 'something happened' and walk away. Anyone who cares subscribes. Anyone who doesn't, doesn't. The game has zero knowledge of its audience. That's the Observer patternThe Observer pattern lets an object (the subject) notify a list of dependents (observers) when something changes, without the subject knowing who the observers are. Think of it like a newspaper subscription: the newspaper publishes, subscribers receive. The newspaper doesn't know or care who reads it.."

"And for the game room concept — lobby, playing, finished — that's the State pattern again! We already know how to do that from Level 3. A GameRoom has its own lifecycle, independent of the Game inside it."

The Solution

Two patterns working together: the Observer patternA behavioral pattern where a subject maintains a list of observers and notifies them automatically when state changes. The subject doesn't know what the observers do with the information — it just broadcasts. Observers subscribe and unsubscribe freely. for event broadcasting and a GameRoom wrapper that manages the lifecycle of online games.

IGameObserver.cs — the contract for anyone who wants to listen
public interface IGameObserver
{
    void OnPlayerMoved(MoveResult result);
    void OnSnakeHit(string playerName, int from, int to);
    void OnLadderClimbed(string playerName, int from, int to);
    void OnPlayerWon(string playerName, int totalTurns);
    void OnGameStarted(IReadOnlyList<string> playerNames);
    void OnGamePaused();
    void OnGameResumed();
}

Seven events that cover the entire game lifecycle. Each method receives just the data it needs — no reference to the Game object itself. Observers don't know how the game works internally. They just receive announcements. The interface is the contract: "If you want to watch a game, implement these methods."

Observers — three listeners, zero game knowledge
// Spectator UI: shows moves in real-time
public sealed class SpectatorDisplay : IGameObserver
{
    public void OnPlayerMoved(MoveResult r)
        => Console.WriteLine(
            $"[LIVE] {r.PlayerName} rolled {r.DiceValue}: "
            + $"{r.StartPosition} → {r.FinalPosition}");

    public void OnPlayerWon(string name, int turns)
        => Console.WriteLine($"[LIVE] {name} wins in {turns} turns!");

    // ... other methods: log or ignore
}

// Leaderboard: tracks statistics
public sealed class LeaderboardTracker : IGameObserver
{
    private readonly Dictionary<string, int> _wins = new();

    public void OnPlayerWon(string name, int turns)
    {
        _wins[name] = _wins.GetValueOrDefault(name) + 1;
        Console.WriteLine($"[STATS] {name}: {_wins[name]} total wins");
    }

    public void OnPlayerMoved(MoveResult r) { } // Don't care
    // ... other methods: no-op
}

// Replay Recorder: stores every move for playback
public sealed class ReplayRecorder : IGameObserver
{
    private readonly List<MoveResult> _moves = new();

    public void OnPlayerMoved(MoveResult r)
        => _moves.Add(r);

    public IReadOnlyList<MoveResult> GetReplay() => _moves;

    public void OnPlayerWon(string name, int turns) { }
    // ... other methods: no-op
}

Three completely independent listeners. The SpectatorDisplay prints live updates. The LeaderboardTracker counts wins. The ReplayRecorder stores every move for later playback. Each one implements only the events it cares about — the rest are empty (no-op). None of them know anything about the Game's internal state. They just react to announcements. Adding a fourth observer (analytics, Discord bot, push notifications) is one new class — zero changes to the game or existing observers.

GameRoom.cs — online multiplayer lobby
public sealed class GameRoom
{
    public string RoomId { get; }
    public Game? CurrentGame { get; private set; }
    public RoomStatus Status { get; private set; }
    private readonly List<string> _waitingPlayers = new();
    private readonly int _maxPlayers;

    public GameRoom(string roomId, int maxPlayers = 4)
    {
        RoomId = roomId;
        _maxPlayers = maxPlayers;
        Status = RoomStatus.Lobby;
    }

    public void Join(string playerName)
    {
        if (Status != RoomStatus.Lobby)
            throw new InvalidOperationException("Room is not in lobby.");
        if (_waitingPlayers.Count >= _maxPlayers)
            throw new InvalidOperationException("Room is full.");

        _waitingPlayers.Add(playerName);
    }

    public void StartGame(BoardConfig board, IDiceStrategy dice)
    {
        if (_waitingPlayers.Count < 2)
            throw new InvalidOperationException("Need 2+ players.");

        CurrentGame = new Game(board, dice);
        foreach (var name in _waitingPlayers)
            CurrentGame.AddPlayer(name);
        CurrentGame.Start();
        Status = RoomStatus.Playing;
    }
}

public enum RoomStatus { Lobby, Playing, Finished }

The GameRoom wraps a Game with an online multiplayer lifecycle. Players join the lobby, the host starts the game, and the room transitions through Lobby → Playing → Finished. Notice it uses the State pattern's idea (status-dependent behavior) in a simpler form since the room lifecycle is straightforward enough for an enum. The Game inside the room has its own state management (WaitingState, PlayingState, etc.).

Game.cs — observer list + event firing
public sealed class Game
{
    private readonly List<IGameObserver> _observers = new();

    // Subscribe/unsubscribe
    public void AddObserver(IGameObserver observer)
        => _observers.Add(observer);
    public void RemoveObserver(IGameObserver observer)
        => _observers.Remove(observer);

    // Notify all observers (called by states after actions)
    internal void NotifyMoved(MoveResult result)
    {
        foreach (var obs in _observers)
            obs.OnPlayerMoved(result);

        // Fire specific events based on outcome
        foreach (var evt in result.Events)
        {
            if (evt.StartsWith("Snake"))
                foreach (var obs in _observers)
                    obs.OnSnakeHit(result.PlayerName,
                        result.StartPosition, result.FinalPosition);
            else if (evt.StartsWith("Ladder"))
                foreach (var obs in _observers)
                    obs.OnLadderClimbed(result.PlayerName,
                        result.StartPosition, result.FinalPosition);
        }
    }

    internal void NotifyWon(string playerName, int totalTurns)
    {
        foreach (var obs in _observers)
            obs.OnPlayerWon(playerName, totalTurns);
    }

    // ... rest of Game unchanged
}

// Wiring it all together:
var game = new Game(board, dice);
game.AddObserver(new SpectatorDisplay());
game.AddObserver(new LeaderboardTracker());
game.AddObserver(new ReplayRecorder());
// Game doesn't know what's listening. It just broadcasts.

The Game maintains a list of observers but doesn't know what they are. It just iterates through the list and calls the appropriate method. Adding or removing observers at runtime is trivial — AddObserver() and RemoveObserver(). A spectator who disconnects? RemoveObserver(). An analytics service that joins mid-game? AddObserver(). The game's behavior is unchanged either way.

Diagrams

Game Room Lifecycle

An online game room goes through three phases: players join the lobby, the game runs, and then it's finished. This is the State pattern applied at the room level, wrapping the game-level states we built in Level 3.

Lobby Players join Host configures board Spectators can watch Start() Playing Dice rolling, turns advancing Observers see live events Replay being recorded Winner! Finished Leaderboard updated Replay available Room can be recycled HLD bridge: each room = a microservice instance. WebSockets for real-time. Redis for leaderboard.

Observer Broadcasting — Game Emits, Everyone Listens

The game is like a radio station. It broadcasts events. Spectators, the leaderboard, and the replay recorder are all tuned in. The game doesn't know who's listening — it just sends the signal.

Game NotifyMoved() | NotifyWon() broadcasts to all observers «interface» IGameObserver SpectatorDisplay Shows live moves to viewers Real-time UI updates WebSocket push to browsers LeaderboardTracker Counts wins per player Updates global rankings Redis sorted set in HLD ReplayRecorder Stores every MoveResult Full game replay later Event sourcing in HLD Add AnalyticsTracker, DiscordBot, PushNotifier... ZERO changes to Game or existing observers

Growing Diagram — Level 7 (Final)

The final system diagram. Five new pieces: IGameObserver interface, three concrete observers, and GameRoom. The complete system has 27 types — each with a clear purpose, each introduced by a specific constraint.

GameRoom Lobby | Playing | Finished NEW Game Roll | AddPlayer | Start | _observers BoardConfig PlayerQueue IGameState+4 IDiceStrategy+3 MoveResult MoveHelper BoardValidator GameFactory «interface» IGameObserver OnPlayerMoved | OnSnakeHit | OnLadderClimbed | OnPlayerWon NEW SpectatorDisplay live UI updates NEW LeaderboardTracker win counts + rankings NEW ReplayRecorder stores every move NEW Player Snake Ladder GameSettings DiceResult MoveOutcome InvalidBoardException Complete System — 27 Types 3 patterns (State, Strategy, Observer) + DI + Result types + defensive coding = production-ready Snake & Ladder

Before / After Your Brain

Before This Level

You see "spectators need to see moves" and think "add a spectator list to the Game class and call spectator.Update() after every move."

After This Level

You smell "notify when something happens" and reach for the Observer pattern. The game broadcasts events through an interface. Anyone can subscribe. Adding new listeners never touches the Game. And you see how LLD concepts bridge naturally to HLD: observers become event consumers, game rooms become microservices, replay becomes event sourcing.

Smell → Pattern: Notify When Something Happens — When other parts of the system need to react to events without the source knowing about them → Observer pattern. The source maintains a list of observers and broadcasts. Observers subscribe and unsubscribe freely. Adding new listeners requires zero changes to the source.
Transfer & HLD Bridge: The Observer pattern at LLD scale becomes event-driven architecture at HLD scale. In-process IGameObserver becomes a message queue (RabbitMQ, Kafka). The spectator display becomes a WebSocket server pushing to browsers. The leaderboard tracker becomes a Redis sorted set. The replay recorder becomes an event-sourced log. Same pattern, different scale. Every LLD Observer is a future HLD event consumer.
Section 11

The Full Code — Everything Assembled

Seven levels. One constraint at a time. And now the whole system is ready to see in one place. Every file below is the final, production-ready version — incorporating every pattern, every edge case, and every refinement we discovered along the way.

Before diving into the code, here's a bird's-eye view of every type in the system. Green types appeared in the foundation levels (L0–L1), yellow ones arrived with the patterns (L2–L4), and red ones came in during the advanced levels (L5–L7). Notice how the system grew organically — each type was forced into existence by a real constraint, not by upfront planning.

COMPLETE TYPE MAP — COLOR = LEVEL INTRODUCED L0–L1 (Foundation) L2–L4 (Patterns) L5–L7 (Advanced) Interfaces MODELS Game class · L0 Snake record · L1 Ladder record · L1 BoardConfig class · L1 Player class · L2 PlayerQueue class · L2 GameSettings record · L2 GameFactory class · L2 BoardConfigs static · L2 DiceResult record · L4 MoveResult record · L5 INTERFACES IGameState L3 IDiceStrategy L4 IBoardFactory L6 IDiceProvider L6 IGameEventListener L6 GAME STATES WaitingState L3 PlayingState L3 FinishedState L3 PausedState L3 DICE STRATEGIES SingleDice L4 DoubleDice L4 RiggedDice L4 ADVANCED (L5–L7) BoardValidator L5 GameRoom L7 IGameObserver interface · L7 Leaderboard class · L7 ReplayService class · L7 GameEvent (records) 5 event types · L7 ENGINE Game orchestrates everything · L0–L7 26 types total 8 models · 5 interfaces · 4 states · 3 dice · 6 events/services

Now let's see the actual code. Each file is organized by responsibility — models in one place, states in another, dice strategies in a third. Click through the tabs to read each file.

Models.cs — All data types the system carries around
namespace SnakeAndLadder.Models;

// ─── Snake ────────────────────────────────────────────
// A snake lives on a specific cell (its head) and sends you
// sliding down to a lower cell (its tail). Records give us
// value equality for free — two Snakes at the same positions
// are considered equal without writing custom Equals().
public record Snake(int Head, int Tail);                     // Level 1

// ─── Ladder ───────────────────────────────────────────
// A ladder sits at its bottom cell and lifts you up to its
// top cell. Same immutable record approach as Snake.
public record Ladder(int Bottom, int Top);                   // Level 1

// ─── BoardConfig ──────────────────────────────────────
// Holds the complete layout of one board: which cells have
// snakes, which have ladders, and the board size. The
// GetDestination() method is the single place that answers
// "if I land on cell X, where do I actually end up?"
public sealed class BoardConfig                              // Level 1
{
    public int Size { get; }
    public IReadOnlyList<Snake> Snakes { get; }
    public IReadOnlyList<Ladder> Ladders { get; }

    private readonly Dictionary<int, int> _redirects;

    public BoardConfig(int size,
        IEnumerable<Snake> snakes,
        IEnumerable<Ladder> ladders)
    {
        Size = size;
        Snakes = snakes.ToList().AsReadOnly();
        Ladders = ladders.ToList().AsReadOnly();
        _redirects = new Dictionary<int, int>();

        foreach (var s in Snakes) _redirects[s.Head] = s.Tail;
        foreach (var l in Ladders) _redirects[l.Bottom] = l.Top;
    }

    // One lookup handles both snakes AND ladders.
    // If the cell has no redirect, you stay where you are.
    public int GetDestination(int position) =>               // Level 1
        _redirects.TryGetValue(position, out var dest)
            ? dest : position;
}

// ─── Player ───────────────────────────────────────────
// Each player has a name and a current position on the board.
// Position starts at 0 (off-board) and the goal is to reach
// exactly boardSize (e.g., 100).
public sealed class Player                                   // Level 2
{
    public string Name { get; }
    public int Position { get; set; }

    public Player(string name) => (Name, Position) = (name, 0);

    public override string ToString() => $"{Name} @ {Position}";
}

// ─── PlayerQueue ──────────────────────────────────────
// A circular turn manager. After the last player rolls,
// it wraps back to the first player. This keeps turn logic
// out of the Game class (SRP).
public sealed class PlayerQueue                              // Level 2
{
    private readonly List<Player> _players;
    private int _currentIndex;

    public PlayerQueue(IEnumerable<Player> players)
    {
        _players = players.ToList();
        _currentIndex = 0;
    }

    public Player Current => _players[_currentIndex];
    public int Count => _players.Count;

    public Player Advance()                                  // Level 2
    {
        _currentIndex = (_currentIndex + 1) % _players.Count;
        return Current;
    }

    public void Reset() => _currentIndex = 0;
}

// ─── GameSettings ─────────────────────────────────────
// Configuration record: how many players, which board preset,
// and which dice strategy. Immutable — once a game starts,
// the rules don't change mid-game.
public record GameSettings(                                  // Level 2
    int PlayerCount,
    string BoardPreset,
    string DiceType
);

// ─── DiceResult ───────────────────────────────────────
// What the dice produced: the individual values (for display)
// and the total. Immutable record — a roll can never change.
public record DiceResult(                                    // Level 4
    IReadOnlyList<int> Values,
    int Total
);

// ─── MoveResult ───────────────────────────────────────
// The outcome of a single move: where the player started,
// where they landed after the dice, where they ended up
// after snake/ladder resolution, and whether it was a
// winning move. This replaces scattered Console.WriteLines
// with structured data that any UI can consume.
public record MoveResult(                                    // Level 5
    string PlayerName,
    int StartPosition,
    int DiceTotal,
    int LandedOn,
    int FinalPosition,
    bool WasSnakeBite,
    bool WasLadderClimb,
    bool WasBounce,
    bool IsWin
);

// ─── GameEvent records ────────────────────────────────
// Events that the game fires when interesting things happen.
// Observers subscribe to these without the game knowing
// who's listening. Each event is an immutable record.
public record GameStartedEvent(                              // Level 7
    IReadOnlyList<string> PlayerNames,
    int BoardSize
);
public record TurnStartedEvent(string PlayerName, int Position);
public record DiceRolledEvent(string PlayerName, DiceResult Result);
public record PlayerMovedEvent(MoveResult Result);
public record GameOverEvent(string WinnerName, int TotalTurns);
GameStates.cs — State pattern: behavior changes when the phase changes
namespace SnakeAndLadder.States;

// ─── IGameState ───────────────────────────────────────
// The contract every game state must follow. Instead of
// asking "are we in the playing phase?" with boolean flags,
// we let each state object DECIDE what happens when you
// try to roll, start, pause, or resume.
public interface IGameState                                  // Level 3
{
    MoveResult? Roll(Game game);
    void Start(Game game);
    void Pause(Game game);
    void Resume(Game game);
    string Name { get; }
}

// ─── WaitingState ─────────────────────────────────────
// The game has been created but nobody pressed "Start" yet.
// Rolling the dice makes no sense here — we reject it with
// a clear message. Starting transitions us to PlayingState.
public sealed class WaitingState : IGameState                // Level 3
{
    public string Name => "Waiting";

    public MoveResult? Roll(Game game) => null;
    // Can't roll before the game starts — return null

    public void Start(Game game)
    {
        game.TransitionTo(new PlayingState());
        game.NotifyStart();                                  // Level 7
    }

    public void Pause(Game game) { }   // Can't pause if not started
    public void Resume(Game game) { }  // Can't resume if not started
}

// ─── PlayingState ─────────────────────────────────────
// The game is live. This is where all the action happens.
// Rolling the dice moves the current player, checks for
// snakes/ladders, handles the exact-100 bounce rule, and
// advances the turn to the next player.
public sealed class PlayingState : IGameState                // Level 3
{
    public string Name => "Playing";

    public MoveResult? Roll(Game game)
    {
        var player = game.CurrentPlayer;
        var diceResult = game.RollDice();                    // Level 4
        var startPos = player.Position;
        var rawLanding = startPos + diceResult.Total;

        // ── Exact-100 bounce rule ─────────────────────
        // You must land EXACTLY on 100. If the roll takes
        // you past 100, you bounce back by the excess.
        bool wasBounce = false;                              // Level 5
        if (rawLanding > game.BoardSize)
        {
            rawLanding = game.BoardSize - (rawLanding - game.BoardSize);
            wasBounce = true;
        }

        // ── Snake chain resolution ────────────────────
        // If you land on a snake's head, you slide down.
        // But what if the tail lands on ANOTHER snake?
        // We loop until there's no more redirect.
        int finalPos = rawLanding;                           // Level 5
        bool wasSnake = false, wasLadder = false;
        int safetyCounter = 0;
        while (safetyCounter++ < 50)
        {
            int dest = game.Board.GetDestination(finalPos);
            if (dest == finalPos) break;
            if (dest < finalPos) wasSnake = true;
            if (dest > finalPos) wasLadder = true;
            finalPos = dest;
        }

        player.Position = finalPos;
        bool isWin = finalPos == game.BoardSize;

        var result = new MoveResult(
            player.Name, startPos, diceResult.Total,
            rawLanding, finalPos, wasSnake, wasLadder,
            wasBounce, isWin
        );

        game.NotifyMove(result);                             // Level 7

        if (isWin)
        {
            game.TransitionTo(new FinishedState(player.Name));
            return result;
        }

        game.AdvanceTurn();
        return result;
    }

    public void Start(Game game) { }   // Already started
    public void Pause(Game game) =>
        game.TransitionTo(new PausedState());
    public void Resume(Game game) { }  // Already playing
}

// ─── FinishedState ────────────────────────────────────
// Someone reached exactly 100. The game is over. Any further
// rolls are rejected. This state is a dead end — once you're
// here, you can't go back.
public sealed class FinishedState : IGameState               // Level 3
{
    public string WinnerName { get; }
    public string Name => "Finished";

    public FinishedState(string winnerName) =>
        WinnerName = winnerName;

    public MoveResult? Roll(Game game) => null;
    public void Start(Game game) { }
    public void Pause(Game game) { }
    public void Resume(Game game) { }
}

// ─── PausedState ──────────────────────────────────────
// The game is temporarily suspended. Rolling is blocked
// but Resume() brings it back to PlayingState. This is
// essentially a Null Object — it absorbs actions silently
// instead of throwing exceptions.
public sealed class PausedState : IGameState                 // Level 3
{
    public string Name => "Paused";

    public MoveResult? Roll(Game game) => null;
    public void Start(Game game) { }
    public void Pause(Game game) { }   // Already paused
    public void Resume(Game game) =>
        game.TransitionTo(new PlayingState());
}
DiceStrategies.cs — Strategy pattern: swap the dice without touching the game
namespace SnakeAndLadder.Dice;

// ─── IDiceStrategy ────────────────────────────────────
// The contract: "give me a dice result." The game doesn't
// care HOW the result is produced — single die, double dice,
// or a rigged test die. All it needs is a DiceResult.
public interface IDiceStrategy                               // Level 4
{
    DiceResult Roll();
}

// ─── SingleDice ───────────────────────────────────────
// Classic Snake & Ladder: one six-sided die.
// Produces values from 1 to 6.
public sealed class SingleDice : IDiceStrategy               // Level 4
{
    private readonly Random _rng;

    public SingleDice(Random? rng = null)
        => _rng = rng ?? Random.Shared;                     // Level 6

    public DiceResult Roll()
    {
        var value = _rng.Next(1, 7);
        return new DiceResult(new[] { value }, value);
    }
}

// ─── DoubleDice ───────────────────────────────────────
// Party variant: two dice, values added together.
// Produces values from 2 to 12. Some house rules give
// an extra turn on doubles.
public sealed class DoubleDice : IDiceStrategy               // Level 4
{
    private readonly Random _rng;

    public DoubleDice(Random? rng = null)
        => _rng = rng ?? Random.Shared;

    public DiceResult Roll()
    {
        var d1 = _rng.Next(1, 7);
        var d2 = _rng.Next(1, 7);
        return new DiceResult(new[] { d1, d2 }, d1 + d2);
    }
}

// ─── RiggedDice ───────────────────────────────────────
// For testing: you feed it a sequence of predetermined
// results. The game doesn't know the difference — it just
// calls Roll() and gets a DiceResult. This is the beauty
// of the Strategy pattern: testability comes for free.
public sealed class RiggedDice : IDiceStrategy               // Level 4
{
    private readonly Queue<int> _values;

    public RiggedDice(IEnumerable<int> values)
        => _values = new Queue<int>(values);

    public DiceResult Roll()
    {
        if (_values.Count == 0)
            throw new InvalidOperationException(
                "RiggedDice ran out of values");

        var value = _values.Dequeue();
        return new DiceResult(new[] { value }, value);
    }
}
Game.cs — The orchestrator that ties everything together
namespace SnakeAndLadder;

// ─── IGameObserver ────────────────────────────────────
// Any external system that wants to know what's happening
// in the game implements this interface. The game fires
// events; observers react. The game never knows WHO is
// listening — it just broadcasts.
public interface IGameObserver                               // Level 7
{
    void OnGameStarted(GameStartedEvent e);
    void OnTurnStarted(TurnStartedEvent e);
    void OnDiceRolled(DiceRolledEvent e);
    void OnPlayerMoved(PlayerMovedEvent e);
    void OnGameOver(GameOverEvent e);
}

// ─── Game ─────────────────────────────────────────────
// The central orchestrator. It doesn't know how dice work
// (Strategy), doesn't know what behavior its current phase
// allows (State), doesn't know who's watching (Observer),
// and doesn't know how the board was built (Factory).
// It just coordinates.
public sealed class Game                                     // Level 0
{
    private IGameState _state;
    private readonly IDiceStrategy _dice;                    // Level 4
    private readonly PlayerQueue _turnQueue;                 // Level 2
    private readonly List<IGameObserver> _observers = new(); // Level 7
    private int _turnCount;

    public BoardConfig Board { get; }                        // Level 1
    public int BoardSize => Board.Size;
    public Player CurrentPlayer => _turnQueue.Current;
    public string StateName => _state.Name;

    public Game(
        BoardConfig board,
        IEnumerable<Player> players,
        IDiceStrategy dice)                                  // Level 4
    {
        Board = board ?? throw new ArgumentNullException(nameof(board));
        _dice = dice ?? throw new ArgumentNullException(nameof(dice));
        _turnQueue = new PlayerQueue(players);
        _state = new WaitingState();                         // Level 3
    }

    // ── State transitions ────────────────────────────
    public void TransitionTo(IGameState newState)            // Level 3
        => _state = newState;

    public void Start() => _state.Start(this);
    public MoveResult? Roll() => _state.Roll(this);
    public void Pause() => _state.Pause(this);
    public void Resume() => _state.Resume(this);

    // ── Dice (delegated to Strategy) ─────────────────
    internal DiceResult RollDice() => _dice.Roll();          // Level 4

    // ── Turn management ──────────────────────────────
    internal void AdvanceTurn()                               // Level 2
    {
        _turnQueue.Advance();
        _turnCount++;
    }

    // ── Observer notifications ───────────────────────
    public void AddObserver(IGameObserver observer)           // Level 7
        => _observers.Add(observer);

    public void RemoveObserver(IGameObserver observer)
        => _observers.Remove(observer);

    internal void NotifyStart()
    {
        var names = Enumerable.Range(0, _turnQueue.Count)
            .Select(_ => { var p = _turnQueue.Current;
                _turnQueue.Advance(); return p.Name; })
            .ToList().AsReadOnly();
        _turnQueue.Reset();
        var e = new GameStartedEvent(names, BoardSize);
        foreach (var obs in _observers) obs.OnGameStarted(e);
    }

    internal void NotifyMove(MoveResult result)
    {
        foreach (var obs in _observers) obs.OnPlayerMoved(
            new PlayerMovedEvent(result));
        if (result.IsWin)
            foreach (var obs in _observers) obs.OnGameOver(
                new GameOverEvent(result.PlayerName, _turnCount));
    }
}

// ─── BoardValidator ───────────────────────────────────
// Catches impossible boards BEFORE the game starts.
// Checks for: snake head == ladder bottom on same cell,
// cycles (snake → ladder → snake loop), out-of-range
// positions, and head/bottom at cell 1 or 100.
public static class BoardValidator                           // Level 5
{
    public static IReadOnlyList<string> Validate(BoardConfig board)
    {
        var errors = new List<string>();
        var redirects = new Dictionary<int, int>();

        foreach (var s in board.Snakes)
        {
            if (s.Head <= 0 || s.Head >= board.Size)
                errors.Add($"Snake head {s.Head} out of range");
            if (s.Tail <= 0 || s.Tail >= s.Head)
                errors.Add($"Snake tail {s.Tail} must be < head {s.Head}");
            if (redirects.ContainsKey(s.Head))
                errors.Add($"Duplicate at cell {s.Head}");
            redirects[s.Head] = s.Tail;
        }

        foreach (var l in board.Ladders)
        {
            if (l.Bottom <= 0 || l.Bottom >= board.Size)
                errors.Add($"Ladder bottom {l.Bottom} out of range");
            if (l.Top <= l.Bottom || l.Top > board.Size)
                errors.Add($"Ladder top {l.Top} must be > bottom {l.Bottom}");
            if (redirects.ContainsKey(l.Bottom))
                errors.Add($"Duplicate at cell {l.Bottom}");
            redirects[l.Bottom] = l.Top;
        }

        // Cycle detection: follow redirects, max 50 hops
        foreach (var start in redirects.Keys)
        {
            var visited = new HashSet<int>();
            var pos = start;
            while (redirects.TryGetValue(pos, out var next))
            {
                if (!visited.Add(next))
                {
                    errors.Add($"Cycle detected starting at {start}");
                    break;
                }
                pos = next;
            }
        }

        return errors.AsReadOnly();
    }
}

// ─── GameFactory ──────────────────────────────────────
// Builds a fully-configured Game from a GameSettings record.
// The caller doesn't need to know how BoardConfig is built
// or which IDiceStrategy to instantiate.
public static class GameFactory                              // Level 2
{
    public static Game Create(GameSettings settings)
    {
        var board = BoardConfigs.Get(settings.BoardPreset);  // Level 2
        var errors = BoardValidator.Validate(board);         // Level 5
        if (errors.Count > 0)
            throw new InvalidOperationException(
                $"Invalid board: {string.Join("; ", errors)}");

        var players = Enumerable.Range(1, settings.PlayerCount)
            .Select(i => new Player($"Player {i}"))
            .ToList();

        IDiceStrategy dice = settings.DiceType switch        // Level 4
        {
            "single" => new SingleDice(),
            "double" => new DoubleDice(),
            _ => new SingleDice()
        };

        return new Game(board, players, dice);
    }
}

// ─── BoardConfigs ─────────────────────────────────────
// Preset board layouts. Classic is the traditional 100-cell
// board. Kids is a smaller 25-cell version. Random generates
// a new layout each time.
public static class BoardConfigs                             // Level 2
{
    public static BoardConfig Get(string preset) => preset switch
    {
        "classic" => Classic(),
        "kids" => Kids(),
        _ => Classic()
    };

    public static BoardConfig Classic() => new(100,
        snakes: new[]
        {
            new Snake(16, 6), new Snake(47, 26),
            new Snake(49, 11), new Snake(56, 53),
            new Snake(62, 19), new Snake(64, 60),
            new Snake(87, 24), new Snake(93, 73),
            new Snake(95, 75), new Snake(98, 78)
        },
        ladders: new[]
        {
            new Ladder(1, 38), new Ladder(4, 14),
            new Ladder(9, 31), new Ladder(21, 42),
            new Ladder(28, 84), new Ladder(36, 44),
            new Ladder(51, 67), new Ladder(71, 91),
            new Ladder(80, 100)
        }
    );

    public static BoardConfig Kids() => new(25,
        snakes: new[]
        {
            new Snake(14, 7), new Snake(19, 3),
            new Snake(22, 12)
        },
        ladders: new[]
        {
            new Ladder(2, 10), new Ladder(8, 16),
            new Ladder(15, 23)
        }
    );
}
Program.cs — Wiring everything together and running a game
using SnakeAndLadder;
using SnakeAndLadder.Models;
using SnakeAndLadder.States;
using SnakeAndLadder.Dice;

// ─── Console Observer ─────────────────────────────────
// A simple observer that prints game events to the console.
// The game doesn't know this class exists — it just fires
// events to whoever subscribed.
public sealed class ConsoleObserver : IGameObserver          // Level 7
{
    public void OnGameStarted(GameStartedEvent e)
        => Console.WriteLine(
            $"Game started! {e.PlayerNames.Count} players on " +
            $"a {e.BoardSize}-cell board.");

    public void OnTurnStarted(TurnStartedEvent e)
        => Console.WriteLine($"\n{e.PlayerName}'s turn (at {e.Position})");

    public void OnDiceRolled(DiceRolledEvent e)
        => Console.WriteLine($"  Rolled: {e.Result.Total}");

    public void OnPlayerMoved(PlayerMovedEvent e)
    {
        var r = e.Result;
        Console.Write($"  {r.PlayerName}: {r.StartPosition}");

        if (r.WasBounce)
            Console.Write($" -> bounced at 100 -> {r.LandedOn}");
        else
            Console.Write($" -> {r.LandedOn}");

        if (r.WasSnakeBite)
            Console.Write($" -> SNAKE! down to {r.FinalPosition}");
        if (r.WasLadderClimb)
            Console.Write($" -> LADDER! up to {r.FinalPosition}");

        Console.WriteLine();
    }

    public void OnGameOver(GameOverEvent e)
        => Console.WriteLine(
            $"\n*** {e.WinnerName} wins in {e.TotalTurns} turns! ***");
}

// ─── Entry point ──────────────────────────────────────
var settings = new GameSettings(
    PlayerCount: 2,
    BoardPreset: "classic",
    DiceType: "single"
);

var game = GameFactory.Create(settings);                     // Level 2
game.AddObserver(new ConsoleObserver());                     // Level 7
game.Start();                                                // Level 3

// Play until someone wins
while (game.StateName == "Playing")                          // Level 3
{
    var result = game.Roll();
    if (result is null) break;
}

Console.WriteLine("\nThanks for playing!");
Every // Level N comment tells you which constraint introduced that line. If you strip away everything above Level 0, you'd get the simplest possible game. Add Level 1 and you get snakes and ladders. Each level adds exactly one capability.
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 State." 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 four patterns during the build: Factory, State, Strategy, and Observer. But there are at least two MORE patterns hiding in our code that we never mentioned by name. Hint: look at how the game loop runs each turn, and think about what PausedState really is.

Take your time.

Template Method — The game loop follows a fixed sequence: roll dice → calculate landing → resolve redirects → check win → advance turn. Each turn runs through the same skeleton of steps. The PlayingState.Roll() method IS a template method — the steps are fixed, but the dice strategy and board configuration plug in different behavior at each step.

Null ObjectPausedState is a textbook Null ObjectA pattern where instead of checking "is this null?" everywhere, you provide an object that does nothing. PausedState absorbs every action silently (Roll returns null, Start/Pause do nothing) instead of throwing exceptions. The game code never needs to check "are we paused?" — it just calls Roll() and the PausedState handles it by doing nothing.. It doesn't throw exceptions, it doesn't crash — it simply absorbs every action and does nothing. The game never needs to check if (state != Paused) because the PausedState handles it gracefully.

The Four 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.

Factory Pattern — "Build complex objects without exposing how"

WhereGameFactory.Create() + BoardConfigs.Get()
EnablesCreating a fully wired game from a simple GameSettings record. The caller says "I want a classic board with 2 players and single dice" and gets back a ready-to-play Game object. No need to manually wire BoardConfigThe class that holds all snake and ladder positions for one board layout. Instead of hardcoding these in the Game class, we put them in a separate configuration object that can be swapped (classic, kids, random)., players, and dice together.
Without itEvery place that creates a game would need 15 lines of setup: build the board, validate it, create players, pick a dice strategy, wire them together. One missed step and the game crashes. Duplication everywhere.

State Pattern — "Behavior changes when the phase changes"

WhereIGameState + WaitingState, PlayingState, FinishedState, PausedState
EnablesThe game behaves differently depending on its current phase without a single if-else or switch. Rolling during "Playing" processes the move; rolling during "Waiting" is silently ignored; rolling during "Finished" is rejected. Adding a new phase like "Disconnected" is a one-file change.
Without itEvery method would start with if (state == Playing) ... else if (state == Finished) .... Four states × four actions = sixteen if-branches scattered across the Game class. A single missing check and you'd have players rolling dice after the game is over.

Strategy Pattern — "Swap the algorithm without touching the engine"

WhereIDiceStrategy + SingleDice, DoubleDice, RiggedDice
EnablesSwitching between single die, double dice, or rigged test dice at runtime without changing a single line in the Game class. The game calls _dice.Roll() and gets back a result — it doesn't care how the number was generated. This also gives us free testabilityBy injecting a RiggedDice that returns predetermined values, you can write unit tests that are 100% deterministic. "Roll a 6, land on snake at 16, slide to 6" can be tested reliably every time instead of hoping Random gives you the right number..
Without itDice logic would be hardcoded in the Game class. Want double dice? Edit Game. Want rigged dice for tests? Somehow override Random. Every dice variant would mean modifying core game code.

Observer Pattern — "Broadcast events without knowing who's listening"

WhereIGameObserver + 5 GameEvent recordsGameStartedEvent, TurnStartedEvent, DiceRolledEvent, PlayerMovedEvent, and GameOverEvent. Each one is an immutable record that carries data about what happened. The game creates these and hands them to observers. Since they're records, observers can't accidentally modify them. + ConsoleObserver
EnablesThe game fires events (OnPlayerMoved, OnGameOver) and any number of listeners can react: a console renderer, a leaderboardThe Leaderboard class subscribes to game events and tracks wins per player across multiple games. It doesn't need to know how the game works internally — it just listens for GameOverEvent and updates its stats., a replay recorder, a web socket broadcaster. Zero coupling between the game engine and its consumers.
Without itConsole.WriteLine calls scattered throughout the Game class. Want to add a leaderboard? Edit Game. Want a web UI? Edit Game again. The game engine becomes married to its display layer.

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. Purple is State, yellow is Strategy, cyan is Observer, and green is Factory. Some types serve multiple patterns — that's normal and healthy.

PATTERN X-RAY OVERLAY State Strategy Observer Factory STATE IGameState WaitingState PlayingState FinishedState PausedState (Null Object) STRATEGY IDiceStrategy SingleDice DoubleDice RiggedDice (testability for free) OBSERVER IGameObserver 5 Event Records ConsoleObserver Leaderboard ReplayService FACTORY GameFactory BoardConfigs Validator

How the Patterns Talk to Each Other

These four patterns don't work in isolation. They interact in a specific choreography during every turn. Here's the flow: the State decides if a roll is allowed, the Strategy produces a dice result, the game engine processes the move, and the Observer broadcasts what happened. The Factory set all of this up before the first roll.

ONE TURN — PATTERN CHOREOGRAPHY STATE "Is rolling allowed right now?" PlayingState: Yes! STRATEGY "What did the dice produce?" SingleDice: 4 ENGINE Move + resolve snakes/ladders bounce + chain OBSERVER "Broadcast result to all listeners" Console, Leaderboard, ... FACTORY (before the game starts) Wires board + players + dice + initial state together
Hidden Patterns: Beyond the four named patterns, Template Method hides in the turn sequence (fixed steps, pluggable behaviors), and Null Object hides in PausedState (absorbs every action silently instead of throwing exceptions). Good design often creates patterns you never planned.
Section 13

The Growing Diagram — Complete Evolution

You've just spent seven levels building a Snake & Ladder game. At Level 0 it was a single class with an int for position. By Level 7, it's a clean architecture with interfacesContracts that define WHAT something does without saying HOW. IGameState, IDiceStrategy, IBoardFactory, and IGameObserver are all interfaces. They let us swap implementations (SingleDice vs DoubleDice) without changing the code that uses them., records, state machines, and event systems. 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.

Think First #11

Look at the evolution below. At which level does the system experience its biggest jump in complexity? Why does that happen, and could we have avoided it?

60 seconds.

Level 3 (State Pattern) is the biggest jump — it adds 5 types at once (1 interface + 4 state classes). This happens because the State pattern requires a family of classes to work: you need the interface AND every concrete state. You can't add half a State pattern. But this is good complexity — it replaces tangled boolean logic with clear, separated state objects. The alternative (boolean flags) looks simpler at first but becomes unmaintainable as states multiply.

Each stage below shows what was added at that level. Glowing boxes are new arrivals; dimmed boxes are types that already existed from earlier levels. Pay attention to the growth curve — it stays gentle because we never added more than one concept at a time.

Design Evolution — L0 through L7
L0 Roll + Move Game 1 type L1 Snakes + Ladders Snake Ladder BoardConfig +3 types L2 Players + Turns Player PlayerQueue GameFactory GameSettings +4 types L3 State Pattern IGameState 4 state classes +5 types L4 Strategy Pattern IDiceStrategy 3 dice classes DiceResult +5 types L5 Edge Cases MoveResult BoardValidator +2 types L6 DI + Testability IBoardFactory IDiceProvider +2 types L7 Observer IGameObserver 5 event records GameRoom + more +8 types CUMULATIVE TYPE COUNT 1 L0 4 L1 8 L2 13 L3 18 L4 20 L5 22 L6 30 L7 TOTAL: 30 types across 8 levels

Here's the complete picture — every entity, its type, and the level that introduced it.

EntityKindLevelWhy This Kind?
Gamesealed classL0Has mutable state (position) and behavior (roll/move)
SnakerecordL1Immutable — a snake's position never changes mid-game
LadderrecordL1Immutable — same reasoning as Snake
BoardConfigsealed classL1Holds the redirect dictionaryA Dictionary<int, int> that maps cell numbers to their destinations. If cell 16 has a snake going to cell 6, the dictionary contains {16: 6}. One lookup handles both snakes and ladders. + behavior (GetDestination)
Playersealed classL2Mutable state — position changes every turn
PlayerQueuesealed classL2Manages circular turn order — has mutable index
GameFactorystatic classL2No instance needed — just a builder function
GameSettingsrecordL2Immutable config — rules don't change mid-game
BoardConfigsstatic classL2Preset factory — returns pre-built BoardConfig instances
IGameStateinterfaceL3Contract for phase-dependent behaviorEach game phase (Waiting, Playing, Finished, Paused) needs different behavior for the same action. Rolling dice during "Playing" processes a move; during "Finished" it's rejected. The interface lets each phase define its own behavior.
WaitingStatesealed classL3Rejects rolls before game starts
PlayingStatesealed classL3Processes rolls, moves players, checks wins
FinishedStatesealed classL3Rejects all actions after someone wins
PausedStatesealed classL3Absorbs actions silently (Null Object)
IDiceStrategyinterfaceL4Contract for interchangeable dice algorithms
SingleDicesealed classL4One die, 1-6 range
DoubleDicesealed classL4Two dice, 2-12 range
RiggedDicesealed classL4Predetermined results for testing
DiceResultrecordL4Immutable — a roll can't change after it happens
MoveResultrecordL5Immutable result carrier — structured data, not strings
BoardValidatorstatic classL5No instance — pure validation function
IBoardFactoryinterfaceL6Abstraction for board creation (mockable in tests)
IDiceProviderinterfaceL6Abstraction for dice creation (injectable)
IGameEventListenerinterfaceL6DI-friendly observer contract
IGameObserverinterfaceL7Rich event contract for external systems
GameRoomsealed classL7Manages multiple concurrent games
Leaderboardsealed classL7Tracks wins across games via events
ReplayServicesealed classL7Records MoveResults for replay
GameEvent5 recordsL7Immutable event data — observers can't mutate them
Growth pattern: 1 → 4 → 8 → 13 → 18 → 20 → 22 → 30. The biggest jumps are at L3 (State pattern — 5 types) and L7 (Observer + infrastructure — 8 types). Everything else grows by 2–5 types per level. That's healthy, organic growth — each type earned its place by solving a real problem.
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 a diagram of the problem, and demonstrate the fix.

Think First #12

Of the five bad solutions below, which one is the most dangerous in a real interview? Not the messiest — the most dangerous. Think about which one could actually fool a reviewer.

60 seconds.

Bad Solution #3: "The Happy-Path Hero" is the most dangerous. It actually uses proper patterns, clean naming, and good structure. It would pass most code reviews. But it ignores exact-100 bounce, snake chain resolution, and board cycle detection. The first week in production, a player lands on 98, rolls a 5, and the game breaks. Solutions #1 and #2 are obviously wrong — someone catches them immediately. #3 is a wolf in sheep's clothing.

BAD SOLUTION SEVERITY SPECTRUM #1 God Class Obviously bad #2 Over-Engineer Suspiciously complex #3 Happy Path Looks great, breaks fast #4 Hardcoded Board Works until it doesn't #5 No Turns Race condition bomb #3 is the most dangerous — it looks professional but collapses in production

Bad Solution 1: "The God Class"

Imagine a restaurant where one person takes orders, cooks the food, serves the tables, washes the dishes, AND manages the staff schedule. On a quiet Monday, it sort of works. On a busy Friday, everything falls apart because one person can't juggle six jobs at once.

That's what happens when you put everything in a single SnakeLadderSystem class: the board layout, player management, dice rolling, state tracking, snake/ladder resolution, and console output all mixed together. At first it feels efficient — one file, no navigation needed. But the moment you try to add double dice or a different board layout, you're wading through 1500 lines of tangled code where changing one thing risks breaking something completely unrelated.

SnakeLadderSystem (1500 lines) Board Layout Player Management Dice Rolling State Tracking Snake/Ladder Logic Console.WriteLine 6 reasons to change Change dice? Risk breaking state. Clean Architecture (26 types) BoardConfig IGameState IDiceStrategy IGameObserver 1 reason to change each Change dice? Only IDiceStrategy.
GodClass.cs — Everything in One Place
public class SnakeLadderSystem
{
    private int[] positions;
    private string[] names;
    private int currentPlayerIndex;
    private bool gameOver;
    private Dictionary<int, int> snakes = new()
        { {16,6}, {47,26}, {49,11}, {62,19}, {87,24}, {98,78} };
    private Dictionary<int, int> ladders = new()
        { {1,38}, {4,14}, {9,31}, {21,42}, {28,84}, {80,100} };

    public SnakeLadderSystem(string[] playerNames)
    {
        names = playerNames;
        positions = new int[playerNames.Length];
    }

    public void PlayTurn()
    {
        if (gameOver) { Console.WriteLine("Game over!"); return; }

        var roll = new Random().Next(1, 7);
        var player = names[currentPlayerIndex];
        var pos = positions[currentPlayerIndex] + roll;

        Console.WriteLine($"{player} rolled {roll}");

        if (pos > 100) { Console.WriteLine("Bounce!"); pos = 100 - (pos - 100); }
        if (snakes.ContainsKey(pos)) { Console.WriteLine("Snake!"); pos = snakes[pos]; }
        if (ladders.ContainsKey(pos)) { Console.WriteLine("Ladder!"); pos = ladders[pos]; }

        positions[currentPlayerIndex] = pos;
        Console.WriteLine($"{player} is at {pos}");

        if (pos == 100) { gameOver = true; Console.WriteLine($"{player} wins!"); }
        currentPlayerIndex = (currentPlayerIndex + 1) % names.Length;
    }
}

What's wrong: Board layout, player array, dice, snake/ladder resolution, state flag, display, and turn management — all in one class. The PlayTurn() method alone handles seven different responsibilities. Want double dice? Edit this method. Want a kids board? Edit the hardcoded dictionaries. Want to test snake chaining? You'd need to construct the entire system.

CleanSeparation.cs — Each Class Has One Job
// Board: ONLY layout and redirect lookups
public sealed class BoardConfig
{
    public int GetDestination(int position) => ...;
}

// State: ONLY "is this action allowed right now?"
public interface IGameState
{
    MoveResult? Roll(Game game);
}

// Dice: ONLY producing random values
public interface IDiceStrategy
{
    DiceResult Roll();
}

// Player: ONLY name and position
public sealed class Player
{
    public string Name { get; }
    public int Position { get; set; }
}

// Observer: ONLY notifying external systems
public interface IGameObserver
{
    void OnPlayerMoved(PlayerMovedEvent e);
}

Why the fix works: Each type has exactly one reason to change. New dice type? Add a new IDiceStrategy — zero changes to Game. New board? Create a new BoardConfig. Test snake resolution? Build a BoardConfig with specific snakes, 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 rolls dice AND tracks turns AND displays output"), it's a God Class. Split it.

The opposite extreme. Instead of one massive class, this developer created an abstraction layerAn extra level of indirection between your code and the actual work. One layer of abstraction (like IDiceStrategy) is useful — it lets you swap dice types. But five layers of abstraction between "roll the dice" and "generate a random number" is a nightmare to debug. for everything. The dice doesn't just roll — it goes through a factory, a provider, a validator, and a strategy before producing a single number.

OverEngineered.cs — Abstraction Upon Abstraction
// To roll a single die, you navigate through:
public interface IAbstractBoardCellEntityTransitionFactory { ... }
public interface IBoardCellTransitionStrategyProvider { ... }
public interface IDiceRollValidatorStrategyFactory { ... }
public interface IPlayerMovementTransitionMediator { ... }
public interface ISnakeLadderResolutionChainHandler { ... }
public interface IGameStateTransitionObserverMediator { ... }

public class DefaultBoardCellEntityTransitionFactory
    : IAbstractBoardCellEntityTransitionFactory
{
    private readonly IBoardCellTransitionStrategyProvider _provider;
    private readonly IDiceRollValidatorStrategyFactory _validator;
    private readonly IPlayerMovementTransitionMediator _mediator;
    // ... 200 lines of delegation ...
}

// New developer: "Where does the dice actually roll?"
// Answer: Follow the chain through 6 interfaces and 4 classes.
// Time to find it: 45 minutes.

What's wrong: Every simple operation goes through 4–6 layers of indirection. A new developer asks "where does the player move?" and the answer requires tracing through an IPlayerMovementTransitionMediator that delegates to a IBoardCellTransitionStrategyProvider that calls a ISnakeLadderResolutionChainHandler. Simple changes take days because nobody can follow the code flow.

JustEnough.cs — Abstractions Where They Earn Their Keep
// Only 5 interfaces — each solves a REAL problem:
public interface IGameState { ... }       // Needed: 4 phases
public interface IDiceStrategy { ... }    // Needed: 3 dice types
public interface IBoardFactory { ... }    // Needed: testable boards
public interface IDiceProvider { ... }    // Needed: injectable dice
public interface IGameObserver { ... }    // Needed: decoupled output

// "Where does the player move?"
// Answer: PlayingState.Roll() — one method, one file.
// Time to find it: 10 seconds.

The rule: Every interface must solve a real problem. IGameState solves "behavior varies by phase." IDiceStrategy solves "dice types vary at runtime." If you can't name the specific problem an abstraction solves, delete it. YAGNI — You Aren't Gonna Need It.

How to Spot This: Count the class names that use more than three nouns mashed together. If you see AbstractBoardCellEntityTransitionStrategyFactoryValidator, you've over-engineered.

This one is dangerous because it looks great. Clean patterns, good naming, proper separation of concerns. The code would pass a code review from most developers. But it ignores three critical edge cases that will destroy the game in production: no exact-100 bounceIn Snake & Ladder, you must land EXACTLY on cell 100 to win. If you're at 98 and roll a 5, you don't go to 103 — you bounce back to 97 (100 minus the excess of 3). Without this rule, players overshoot and the game breaks., no snake chain resolution, and no board validation.

HappyPath.cs — Looks Clean, Breaks Fast
// Beautiful code — patterns, naming, separation. But...
public sealed class PlayingState : IGameState
{
    public MoveResult? Roll(Game game)
    {
        var dice = game.RollDice();
        var player = game.CurrentPlayer;
        var newPos = player.Position + dice.Total;

        // BUG #1: No bounce! Player goes to 103 on a 100-cell board
        // and is now in an impossible state.
        player.Position = newPos;

        // BUG #2: Only checks ONE redirect. If snake tail lands
        // on another snake, the second one is ignored.
        var dest = game.Board.GetDestination(newPos);
        if (dest != newPos) player.Position = dest;

        // BUG #3: No validation at startup — board could have a
        // snake head AND ladder bottom at the same cell. Or worse,
        // a cycle: snake at 50→30, ladder at 30→50. Infinite loop.

        if (player.Position == game.BoardSize)
            game.TransitionTo(new FinishedState(player.Name));

        game.AdvanceTurn();
        return new MoveResult(player.Name, ...);
    }
}

What's wrong: The code structure is excellent. But real games will crash within minutes: a player at position 98 rolling 5 goes to 103 (out of bounds), a snake-to-snake chain silently drops the second redirect, and a malicious board config could create an infinite loop.

ProductionReady.cs — Every Edge Case Handled
// Fix #1: Exact-100 bounce
if (rawLanding > game.BoardSize)
{
    rawLanding = game.BoardSize - (rawLanding - game.BoardSize);
    wasBounce = true;
}

// Fix #2: Snake chain resolution with safety counter
int finalPos = rawLanding;
int safetyCounter = 0;
while (safetyCounter++ < 50)
{
    int dest = game.Board.GetDestination(finalPos);
    if (dest == finalPos) break;
    finalPos = dest;
}

// Fix #3: Board validation at creation time
var errors = BoardValidator.Validate(board);
if (errors.Count > 0)
    throw new InvalidOperationException(
        $"Invalid board: {string.Join("; ", errors)}");

The lesson: Clean code is NOT the same as robust code. Edge cases aren't optional — they're what separates a demo from production. Always ask: "What if the player overshoots? What if redirects chain? What if the board config is invalid?"

This is the most dangerous bad solution. It looks professional. It passes code review. It works in dev. But the first real game session exposes all three bugs. Solutions #1 and #2 are obviously bad — someone catches them immediately. #3 sails through.

Instead of putting snake and ladder positions into a data structure, this developer hardcoded them directly into the Move() method as magic numbersLiteral values embedded directly in code without explanation. When you see "if (pos == 16) pos = 6;" in the middle of a method, the reader has to guess what 16 and 6 mean. Are those snake positions? Ladder positions? Something else entirely? Magic numbers make code unreadable and unmaintainable.. Every snake and ladder is an if-statement buried in a 200-line method.

HardcodedBoard.cs — Magic Numbers Everywhere
public void Move(int diceRoll)
{
    position += diceRoll;

    // Snakes — good luck figuring out which is which
    if (position == 16) position = 6;
    if (position == 47) position = 26;
    if (position == 49) position = 11;
    if (position == 56) position = 53;
    if (position == 62) position = 19;
    if (position == 87) position = 24;
    if (position == 93) position = 73;
    if (position == 95) position = 75;
    if (position == 98) position = 78;

    // Ladders — more magic numbers
    if (position == 1) position = 38;
    if (position == 4) position = 14;
    if (position == 9) position = 31;
    if (position == 21) position = 42;
    if (position == 28) position = 84;
    if (position == 80) position = 100;

    // Want a kids board? Copy-paste this entire method
    // and change all the numbers. OCP? Never heard of it.
}

What's wrong: Every board variant requires a completely new version of Move(). Snake/ladder positions are buried in code instead of data. Testing a specific scenario means setting up a game and hoping the right if-branch fires. Adding a new board means copy-pasting 40 if-statements and changing every number.

DataDriven.cs — Board as Data, Not Code
// Board positions are DATA, not code
public sealed class BoardConfig
{
    private readonly Dictionary<int, int> _redirects;

    public BoardConfig(IEnumerable<Snake> snakes,
                       IEnumerable<Ladder> ladders)
    {
        _redirects = new();
        foreach (var s in snakes) _redirects[s.Head] = s.Tail;
        foreach (var l in ladders) _redirects[l.Bottom] = l.Top;
    }

    // ONE line handles every snake AND every ladder
    public int GetDestination(int pos) =>
        _redirects.TryGetValue(pos, out var dest) ? dest : pos;
}

// Want a kids board? Just different data:
BoardConfigs.Kids()  // 25 cells, 3 snakes, 3 ladders
BoardConfigs.Classic() // 100 cells, 10 snakes, 9 ladders

The rule: When the same structure repeats with different values, those values are data, not code. Move them into a data structure. One lookup replaces forty if-statements.

How to Spot This: If you see the same pattern (if (x == A) x = B;) repeated more than three times, those values should be in a dictionary or configuration, not in code.

This implementation lets any player roll at any time. There's no concept of "whose turn is it?" — whoever calls Roll() first gets to move. In a console app this might work because humans take turns naturally. But the moment you add a web API, a multiplayer lobby, or any form of concurrent access, you have a race conditionA race condition happens when two operations happen at nearly the same time and the result depends on which one finishes first. If Player A and Player B both call Roll() at the same millisecond, both might read the same "current position" before either write completes, causing one move to be lost or both players to occupy the same logical turn. waiting to happen.

NoTurns.cs — Anyone Can Roll Anytime
public class Game
{
    private List<Player> players = new();
    // No turn tracking. No current player index. No queue.

    public void Roll(string playerName)
    {
        // ANY player can call this at ANY time
        var player = players.First(p => p.Name == playerName);
        var dice = new Random().Next(1, 7);
        player.Position += dice;

        // No check: is it this player's turn?
        // No check: has someone already won?
        // No check: is this player even in the game?

        // Two players call Roll() at the same millisecond:
        // Both read position = 94
        // Both roll 6
        // Both write position = 100
        // Both think they won. Who actually won? Nobody knows.
    }
}

What's wrong: No turn queue means no order enforcement. In a concurrent environment (web API, multiplayer), two players can roll simultaneously. Both read the same state, both make conflicting changes, and the game state becomes corrupted. Even in single-player mode, nothing prevents a player from rolling twice in a row.

TurnManaged.cs — PlayerQueue Enforces Order
public sealed class PlayerQueue
{
    private readonly List<Player> _players;
    private int _currentIndex;

    public Player Current => _players[_currentIndex];

    public Player Advance()
    {
        _currentIndex = (_currentIndex + 1) % _players.Count;
        return Current;
    }
}

// Now the Game enforces turn order:
public MoveResult? Roll()
{
    // Only the CURRENT player moves
    var player = _turnQueue.Current;
    // ... process the move ...
    _turnQueue.Advance();  // Next player's turn
    return result;
}

// Caller never picks who rolls — the queue decides.

The lesson: Turn-based games need an enforced turn order, not a suggested one. The PlayerQueue is the single source of truth for "whose turn is it?" The caller says "roll" and the queue decides who moves. No race conditions, no double-rolls, no confusion.

How to Spot This: If the Roll() or PlayTurn() method takes a player name as a parameter, the caller is choosing who plays. The game should decide that internally using a queue.
Section 15

Code Review Challenge — Find 5 Bugs

A candidate submitted this Snake & Ladder implementation as a pull request. It compiles. It runs. It plays 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?

PR Submitted "LGTM!" Under Review Reading code... 5 Issues Found Request changes Fixes Applied Push new commit Approved + Merged Ship it!

Read the code below carefully. Try to find all 5 issues before revealing the answers.

CandidateSolution.cs — Find 5 Bugs
public class SnakeLadderGame                                    // Line 1
{
    private readonly Dictionary<int, int> snakes;               // Line 3
    private readonly Dictionary<int, int> ladders;
    private List<Player> players;
    private int currentPlayer = 0;
    private bool gameOver = false;

    public SnakeLadderGame(Dictionary<int, int> snakes,         // Line 9
        Dictionary<int, int> ladders, List<string> names)
    {
        this.snakes = snakes;
        this.ladders = ladders;
        players = names.Select(n => new Player(n)).ToList();
    }

    public string PlayTurn()                                    // Line 16
    {
        if (gameOver) return "Game over";
        var player = players[currentPlayer];
        var roll = new Random().Next(1, 7);                     // Line 20
        var newPos = player.Position + roll;

        if (newPos > 100) return "Bounce";                      // Line 23
        // Doesn't actually bounce — just skips the turn!

        if (snakes.ContainsKey(newPos))                         // Line 26
            newPos = snakes[newPos];
        if (ladders.ContainsKey(newPos))
            newPos = ladders[newPos];

        player.Position = newPos;

        if (newPos == 100)                                      // Line 32
        {
            gameOver = true;
            return $"{player.Name} wins!";
        }

        currentPlayer = (currentPlayer + 1) % players.Count;   // Line 37
        return $"{player.Name} moved to {newPos}";
    }

    public void Pause() { gameOver = true; }                    // Line 41
    public void Resume() { gameOver = false; }                  // Line 42
    // Pause sets gameOver = true. Resume sets it back.
    // But what about an ACTUAL game-over? Resume would
    // reopen a finished game!
}
BUG SEVERITY #1 new Random() THREAD #2 Silent Bounce LOGIC #3 No Validation MISSING #4 State Corruption STATE #5 Magic Number 6 FRAGILE Thread safety and state corruption are hardest to catch in review

Found them? Reveal one at a time:

Problem: Every call to PlayTurn() creates new Random(). In .NET, Random seeds from the system clock. If two calls happen within the same millisecond (very common in loops or concurrent access), they produce identical sequences. In a multiplayer web scenario, two players rolling at the same time could get the exact same numbers every time.

Fix: Use Random.Shared (thread-safe singleton introduced in .NET 6) or inject the Random instance via the constructorBy taking Random as a constructor parameter, you can: (1) share one instance across the app lifetime, (2) inject a seeded Random for deterministic tests, or (3) use Random.Shared for production. The dice class never creates its own Random.. Our solution uses IDiceStrategy with Random.Shared inside the dice implementations.

Taught in: Level 4 — Strategy Pattern (dice injection)

Problem: When the roll takes a player past 100, the code returns "Bounce" immediately without moving the player OR advancing the turn. The player's position stays the same, but so does currentPlayer — meaning the SAME player gets to try again. Even worse, the "bounce" doesn't actually bounce: proper Snake & Ladder rules say you bounce back by the excess (at 98, roll 5 = bounce to 97). Here the player just loses a turn.

Fix: Implement the actual bounce formula: newPos = boardSize - (newPos - boardSize). Then continue processing normally (check snakes/ladders on the bounced position, advance turn). Never silently skip a turn.

Taught in: Level 5 — Edge Cases (exact-100 bounce rule)

Problem: The constructor accepts arbitrary Dictionary<int, int> for snakes and ladders with zero validation. A snake head at cell 200 on a 100-cell board? Accepted. A ladder bottom at cell 0? Accepted. A snake head AND a ladder bottom on the same cell? Accepted — and whichever dictionary gets checked first wins, silently overriding the other. Worse: a snake at 50→30 plus a ladder at 30→50 creates an infinite loopPlayer lands on 50, gets redirected to 30 (snake). But 30 has a ladder going to 50. Gets redirected back to 50. Snake again to 30. Ladder again to 50. Forever. The game hangs and never returns. — the player bounces between 30 and 50 forever.

Fix: Validate the board at construction time using BoardValidator: range checks, duplicate detection, and cycle detection (follow the redirect chain with a safety counter).

Taught in: Level 5 — Edge Cases (BoardValidator with cycle detection)

Problem: Pause() sets gameOver = true and Resume() sets it back to false. But what if the game is actually over (someone won)? Calling Resume() reopens a finished game! The single boolean gameOver is being used to represent two completely different states: "game ended because someone won" and "game is temporarily paused." There's no way to distinguish them.

Fix: Replace the boolean flag with the State patternInstead of one boolean trying to represent 4 states (Waiting, Playing, Finished, Paused), you use 4 separate classes. FinishedState ignores Resume() calls. PausedState's Resume() goes back to PlayingState. Impossible combinations become literally impossible.: WaitingState, PlayingState, FinishedState, PausedState. FinishedState ignores Resume() entirely. PausedState transitions back to PlayingState. The wrong transition can't happen because the state objects control their own transitions.

Taught in: Level 3 — State Pattern

Problem: new Random().Next(1, 7) hardcodes a six-sided die directly in the game logic. Want to play with two dice (values 2–12)? You'd need to edit the PlayTurn() method. Want to write a deterministic test? You can't control what Random produces. The dice behavior is coupled to the game engine.

Fix: Extract dice behavior into IDiceStrategy. The game calls _dice.Roll() and gets back a DiceResult. Single die, double dice, or rigged test dice — the game doesn't care. The magic number 6 is now hidden inside SingleDice where it belongs.

Taught in: Level 4 — Strategy Pattern (IDiceStrategy)

How did you score?
  • All 5 found: Senior-level thinking. You caught the thread safety issue AND the state corruption — most reviewers miss those.
  • 3–4 found: Solid mid-level. You probably caught the obvious ones (bounce, magic number) and maybe one subtle one.
  • 1–2 found: Review Levels 3–5 again. The State pattern (bug #4) and edge case handling (bugs #2–3) are the most commonly missed topics in interviews.
Section 16

The Interview — Both Sides of the Table

Snake & Ladder sounds like a children’s game — and that’s exactly the trap. Candidates hear “board game” and think it’s trivial, then get blindsided by chain resolution, loop detection, and snake chains. The interviewers below follow the CREATES structure: Clarify → Requirements → Entities → API → Trade-offs → Edge cases → Scale. Two runs: the polished one and the messy-but-honest one. Both get hired.

What Interviewers Score (Snake & Ladder Edition) 1. SCOPE FIRST Board size? # players? Online? Shows system thinking 2. ENTITY MODELING Snake/Ladder as records, not classes Data vs behavior decisions 3. PATTERN USAGE Factory, State, Strategy, Observer Motivated by problem, not memorized 4. EDGE CASES Exact-100, snake chains, loops Proactive = Strong Hire signal 5. ARTICULATION Explain WHY records, not classes Say trade-offs out loud Strong Hire = all 5 visible. No Hire = jumped to code, skipped 1-2-4-5. Snake chains are the #1 missed edge case. Mention them first.
TimeCandidate SaysInterviewer Thinks
0:00 “Before I code, let me scope this. What’s the board size — classic 10×10 (100 cells) or configurable? How many players? Is this a console game or does it need online multiplayer? Single dice or configurable?” Excellent — scoping a “simple” game shows the habit is automatic.
2:00 “Functional: roll dice, move player, resolve snakes/ladders, detect winner. Non-functional: configurable board, testable with rigged dice, extensible to new dice types.” F/NF split on a board game. Testable dice is a senior-level thought.
4:00 “Entities: Board owns the grid of snakes and ladders. Snake and Ladder are records — they’re pure data, no behavior. Player tracks name and position. Game orchestrates flow. PlayerQueue manages turn order.” Clean entity extraction. Records for Snake/Ladder shows data-vs-behavior thinking.
8:00 “Three patterns fit naturally. Factory for game setup — creating a configured board with snakes, ladders, and players is complex enough to warrant a dedicated creator. State for game modes — WaitingForPlayers, InProgress, Finished each have different rules. Strategy for dice — standard random, weighted, or rigged for testing.” Three patterns, each justified by a specific problem. Not name-dropping.
12:00 Starts coding: records for Snake/Ladder, Board class, IGameState interface, IDiceStrategy, GameFactory... Watching for: sealed classes, MoveResult type, clean separation
20:00 “Edge cases: player must land exactly on 100 to win — if the roll overshoots, the player stays put. Snake chains — a snake might drop you onto another snake’s head. I need to resolve recursively until the player lands on a safe cell. And I need to validate the board at creation time — reject configurations with ladder-snake loops.” Proactive! Snake chains and loop detection are the two things most candidates miss entirely.
25:00 “For scale: wrap each game in a GameRoom. Add Observer for spectators — anyone watching gets notified on every move. For replay, store the dice sequence and reconstruct. This bridges naturally to HLD with a game server managing multiple rooms.” LLD → HLD bridge. Game rooms + Observer for spectators. Strong Hire.
TimeCandidate SaysInterviewer Thinks
0:00 “Snake and Ladder — OK, so a board with snakes that go down and ladders that go up. Let me start with a Board class...” Jumped to implementation. Let’s see if they recover.
1:30 “Actually wait — let me ask first: is it always a 10×10 board? How many players? Do we need to support different dice?” Good recovery. Self-corrected to scope first.
4:00 “I’ll make Snake and Ladder as classes with a Resolve() method... hmm, actually they don’t have behavior. They’re just ‘head at 16, tail at 6.’ That’s a record, not a class.” Self-corrected class → record! Shows real-time data-vs-behavior thinking.
8:00 “For game flow, I could use a boolean isGameOver... but there are three states: waiting for players, in progress, and finished. Each has different allowed actions. That’s a State pattern.” Same evolution as the clean run, just visible. Even better — shows engineering judgment.
14:00 Coding... implements Board, Player, dice rolling, basic move logic... Good progress. Watching for how they handle landing on snakes.
18:00 Interviewer: “What if a snake drops the player onto another snake’s head?” Testing chain awareness. Most candidates freeze here.
18:30 “Oh — I didn’t think about chains. Let me think... after resolving a snake, I should check the destination cell again. It’s a while-loop: keep resolving until the cell has no snake or ladder. I need to be careful about infinite loops though — if a snake drops you to a ladder that takes you back to the snake, that’s a cycle. I should validate the board at creation time to prevent that.” Needed a nudge but recovered brilliantly. Chain resolution AND loop detection in one answer. That’s a strong recovery.
24:00 “For the exact-100 rule: if a player is on cell 96 and rolls a 5, they can’t move to 101. I return a MoveResult with StayedInPlace reason. The player’s turn ends but position doesn’t change.” MoveResult type instead of boolean — that’s clean modeling.
27:00 “For scaling — each game instance is independent, so wrapping it in a GameRoom makes sense. Multiple rooms on a server. Observer pattern for spectators who want to watch live. The game logic stays the same, just the transport layer changes.” Honest recovery throughout. Strong Hire — stumbles don’t matter, thinking does.
CREATES Timeline — 30 Minutes, 7 Steps C Clarify 0-2 min R Requirements 2-4 min E Entities 4-8 min A API + Code 8-20 min T Trade-offs woven in E Edge Cases 20-25 min S Scale 25-30 min Snake chains + loop detection = the two edge cases that separate Hire from Strong Hire

Scoring Summary

The Clean Run

Strong Hire

  • Scoped before coding — board size, players, dice
  • F/NF split with “testable dice” as NF requirement
  • Three patterns, each motivated by a specific problem
  • Proactive snake chains + loop detection
  • GameRoom + Observer for HLD bridge
The Realistic Run

Strong Hire

  • Slow start — recovered with scope questions
  • Self-corrected class → record for Snake/Ladder
  • Needed nudge on chains — nailed resolution + loop detection
  • MoveResult type for exact-100 (not boolean)
  • Honest, structured recovery throughout

Common Follow-up Questions Interviewers Ask

  • “What’s the minimum number of dice rolls to reach 100?” — Tests if the candidate knows this is a BFS/shortest-path problem
  • “How would you add power-ups?” — Tests extensibility (new entity type + Strategy or Decorator)
  • “Can two games run simultaneously?” — Tests if state is instance-level or global
  • “How would you balance a randomly generated board?” — Tests algorithmic thinking (ensure reachability, limit snake density near 100)
  • “How would you add spectators?” — Tests Observer pattern knowledge
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 ambiguity in production.
Section 17

Articulation Guide — What to SAY

Knowing the design and communicating the design are separate skills. You can have a perfect Snake & Ladder architecture in your head and still tank the interview because you mumbled through the pattern justification. The 8 cards below cover the exact moments where phrasing matters most — each with a “Say” and “Don’t Say” so you can hear the difference.

Pattern Decision Tree — What to Say and Why “What design problem am I solving?” Complex object creation? FACTORY “Board setup is complex — encapsulate creation.” Behavior changes by mode? STATE “Can’t roll after game ends — each mode has its rules.” Swap algorithm at runtime? STRATEGY “Dice type varies — random, weighted, or rigged for tests.” Notify on events? OBSERVER “Spectators get notified on every move.” Key: name the PROBLEM before the PATTERN. Four problems, four patterns — each earns its place.
1. Opening the Problem

Situation: The interviewer says “Design Snake & Ladder.” You feel the pull to start a Board class.

Say: “Before I start, let me scope this. Is it a classic 10×10 board or configurable size? How many players — 2 or up to N? Is the dice standard or could there be variants like two-dice or weighted? Is this console-only or does it need an online spectator mode?”

Don’t say: “OK so I’ll make a 100-cell array...” (jumping to data structures before knowing scope)

Why it works: A “toy” problem has hidden complexity. Scoping it shows the habit is automatic, not performative.

2. Entity Decisions — Snake as a Record

Situation: You’re deciding how to model snakes and ladders. The interviewer might ask “why not classes?”

Say: “A snake is a record with a Head and Tail position. It has no behavior — it doesn’t ‘do’ anything, it just is data. A class would imply mutable state and methods. Records give me immutability, value equality, and clean ToString() for free.”

Don’t say: “I’ll use records because they’re modern C#.” (naming the feature without the reasoning)

Why it works: Shows you choose types deliberately based on data-vs-behavior analysis, not trends.

3. Factory Pattern Choice

Situation: You need to justify why game creation uses a Factory. The interviewer pushes: “why not just new Game()?”

Say: “Creating a game involves building a board with validated snake/ladder positions, initializing players in a queue, setting up the dice strategy, and configuring the initial state. That’s 5+ setup steps that shouldn’t live in the Game constructor. A Factory encapsulates that complexity — the caller just says GameFactory.CreateClassic(players) and gets a ready-to-play game.”

Don’t say: “I’ll use Factory because object creation is complex.” (vague — which objects? why complex?)

Why it works: You listed the SPECIFIC steps that make creation complex. That’s evidence, not assertion.

4. Defending a Trade-off

Situation: The interviewer asks: “Isn’t State pattern overkill for just 3 game modes?”

Say: “The cost is more files — 3 state classes plus the interface, versus a single enum + switch. The gain is that each mode’s rules live in one place. Right now we have 3 modes, but if we add Paused or WaitingForReady, it’s one new class with zero edits to existing states. For 3 modes I could go either way — but since the interviewer might ask about new modes, State future-proofs me.”

Don’t say: “State pattern is always better than enums.” (dogmatic — no trade-off awareness)

Why it works: You acknowledged the cost, explained the gain, and justified the choice for this specific context. That’s engineering judgment, not pattern worship.

5. Strategy for Dice

Situation: You’re explaining why dice is an interface. The interviewer says “why not just Random.Next(1,7)?”

Say: “A hardcoded Random.Next() means every test run is nondeterministic — I can never reproduce a specific game sequence. With IDiceStrategy, I get three things: StandardDice for production, RiggedDice for deterministic tests (I control every roll), and WeightedDice if we ever want a ‘lucky’ game mode. The Game class doesn’t know or care which dice it’s using.”

Don’t say: “Strategy is the standard approach for swappable algorithms.” (textbook phrasing with no connection to the actual problem)

Why it works: You connected Strategy to a concrete testing pain point. Testability is the strongest argument — interviewers love it.

6. Edge Cases — Exact-100 & Chains

Situation: You’ve finished the happy path. Most candidates stop here.

Say: “Let me think about what could go wrong. First, exact-100: if a player overshoots, they stay put, and I return a MoveResult explaining why. Second, snake chains: a snake could drop you onto another snake. I resolve in a while-loop until the cell is safe. Third, I validate the board at creation time to reject configurations that create infinite loops.”

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. Snake chains are the #1 thing candidates miss.

7. Scaling Bridge

Situation: The interviewer asks “How would this handle 1,000 concurrent games?”

Say: “Each game is already an isolated instance — no shared state between games. I’d wrap each in a GameRoom. A game server manages rooms, routes players, and could shard across machines. For spectators, Observer pattern — each room publishes moves, spectator clients subscribe. For replays, store the dice roll sequence and reconstruct on demand.”

Don’t say: “I’d use microservices.” (buzzword without specifics)

Why it works: You showed the LLD → HLD bridge with specific mechanisms (rooms, Observer, replay). That’s architecture, not hand-waving.

8. “I Don’t Know”

Situation: The interviewer asks about real-time board synchronization and you’ve never built a multiplayer game server.

Say: “I haven’t built real-time game sync before, but the approach is clear: each move already produces a MoveResult with the new position and any snake/ladder resolution. I’d serialize that result and broadcast it via WebSocket to all connected clients. The game logic stays identical — the transport layer wraps around it. I’d need to handle reconnection and state sync for players who drop, which I’d research.”

Don’t say: “I don’t know multiplayer.” (full stop, no reasoning)

Why it works: Honesty + structured reasoning about the approach = respect. Bluffing or shutting down = red flag.

The Articulation Order (get this wrong and you sound like a textbook) 1. THE PROBLEM “Board setup is complex...” 2. PATTERN NAME “That’s a Factory.” 3. TRADE-OFF “More files, but clean setup.” 4. CODE “Here’s the Factory...” BAD order: “I’ll use Factory pattern” (step 2 first) — sounds memorized, not motivated GOOD: Problem → Pattern → Trade-off → Code. Every single time.
Pro Tip — Practice OUT LOUD, not just in your head

Reading these cards silently builds recognition. Saying them aloud builds production. Set a 5-minute timer, explain this Snake & Ladder design to an imaginary interviewer. Target three phrases for fluency: the record justification (“no behavior, just data — that’s a record”), the chain resolution (“resolve in a while-loop until the cell is safe”), and the Factory motivation (“5+ setup steps that shouldn’t live in the constructor”).

Section 18

Interview Questions & Answers

12 questions ranked by difficulty. Each has a “Think” prompt, a solid answer, and the great answer that gets “Strong Hire.” Pay special attention to Q7 — the minimum-moves question is a BFS problem in disguise, and it’s the most impressive answer you can give.

Think: Does a snake DO anything, or does it just EXIST as data? Does it ever change after being placed on the board?

Think about a snake on a physical board game. Once you place the snake sticker between cell 16 and cell 6, it never moves. It doesn’t “do” anything — it’s just there. The game logic checks whether a player landed on cell 16 and moves them to cell 6. The snake itself is pure data: a start position and an end position. Nothing more.

Answer: Snake and Ladder have no behavior — they’re immutable data with a head and tail. Records give immutability, value equality, and clean ToString() for free.

Great Answer: “A snake is data that never changes after creation — record Snake(int Head, int Tail). If I used a class, I’d imply mutable state and behavior. Records give me three things for free: immutability (can’t accidentally move the snake mid-game), value equality (two snakes with the same head/tail are equal), and deconstruction for pattern matching. The Board owns the collection of snakes — the resolution logic lives there, not on the snake itself.”

What to SAY: “No behavior, never changes after creation — that’s the definition of a record. Classes imply mutability and methods.”
Think: How many steps does it take to create a ready-to-play game? Should the caller know all those steps?

Imagine buying a board game from a store. You don’t individually source the board, dice, snakes, ladders, and player tokens from 5 different factories. You buy a box, open it, and it’s ready to play. The Factory is that box — it hides the assembly complexity.

Answer: GameFactory.CreateClassic(playerNames) handles board construction, snake/ladder placement, player initialization, dice setup, and board validation in one call. The caller doesn’t know or care about those 5+ steps.

Great Answer: “Without Factory, the caller writes: create a BoardConfig, validate it, build the Board, create Players, put them in a PlayerQueue, pick a dice strategy, wire up the initial state, and attach observers. That’s 8 steps the caller doesn’t need to know about. With Factory: GameFactory.CreateClassic(players). One line. And I can add CreateTournament() or CreateKidsMode() later without the caller changing at all.”

What to SAY: “Factory hides 8 construction steps behind one method call. Adding new game modes means one new factory method, zero caller changes.”
Think: Where is the board configuration defined? Does adding a new configuration require changing existing code?

The beauty of the Factory + BoardConfig design is that the board configuration is just data. A “classic” board has 8 snakes and 8 ladders. A “tournament” board has 15 snakes and 5 ladders (harder). The Game class doesn’t know or care which configuration it was built with.

Answer: Add a new BoardConfig with the tournament snake/ladder layout and a new factory method CreateTournament(). Zero changes to Game, Board, Player, or any existing code.

Great Answer: “I’d create a new BoardConfig — maybe from a JSON file so non-developers can design boards. Then add GameFactory.CreateTournament(players) that passes that config to the same Board constructor. The entire game engine is config-driven — Game, State, Strategy all stay untouched. That’s OCP in action.”

What to SAY: “New config + new factory method. Zero changes to existing code. That’s OCP.”
Think: What happens if cell 48’s snake drops you to cell 22, and cell 22 has another snake dropping to cell 5? How many times should you check?

Imagine a water slide park where one slide ends at the top of another slide. You don’t stop between slides — you keep sliding until you land on solid ground. That’s exactly how snake chains work: keep resolving until the destination cell has neither a snake head nor a ladder bottom.

Answer: After moving to a cell, check if it has a snake or ladder. If yes, move to the destination. Repeat until the cell is “safe.” Use a while loop with a visited-cell set to detect infinite loops.

Great Answer: “The algorithm is: while (cell has snake or ladder) { move to destination; add to visited set; if (visited contains cell) throw — that’s a cycle the validator should have caught }. The key insight is that chain resolution is the Board’s responsibility, not the Game’s. The Game says ‘move player to cell X,’ and the Board says ‘after all resolutions, the final position is cell Y.’ This is a transparent resolution — the Game never sees the intermediate steps.”

Snake Chain Resolution — Step by Step Step 1: Land Cell 48 Snake head here! snake Step 2: Slide Cell 22 Another snake! snake Step 3: Slide Cell 5 No snake/ladder safe! Final Position Cell 5 2 chains resolved while (board.HasSnakeOrLadder(cell)) cell = board.Resolve(cell);
What to SAY: “While-loop with visited set. Board resolves chains transparently — Game only sees the final position.”
Think: What happens if the Game is in “Finished” state and someone calls RollDice()? Where does the rejection logic live?

Think about a TV remote. When the TV is off, pressing “volume up” does nothing — the TV’s “off” state simply ignores that input. You don’t need an if (isOn) check before every button — the current state decides what inputs are valid. Same with game modes.

Answer: Each state class implements RollDice() differently. InProgressState rolls and moves. FinishedState returns a MoveResult.GameAlreadyOver error. No if/else needed in Game.

Great Answer: “The Game delegates every action to its current IGameState. InProgressState.RollDice() does the actual roll-and-move logic. FinishedState.RollDice() returns a MoveResult with Reason = GameOver. WaitingForPlayersState.RollDice() returns Reason = NotEnoughPlayers. The beauty is zero branching in Game — each state knows its own rules. Adding a PausedState means one new class that returns Reason = GamePaused for everything. Zero edits to Game or the other states.”

What to SAY: “Each state decides what’s allowed. FinishedState rejects everything. No if/else anywhere in Game.”
Think: What if a ladder takes you to cell 45, which has a snake that drops you to cell 12, which has a ladder back to cell 45? How do you catch this at board creation time?

Imagine a circular hallway where every door leads to another door that leads back to the first. You’d walk in circles forever. That’s what happens when a ladder’s top coincides with a snake’s head that drops to a cell with another ladder going back up. The player would bounce infinitely. This must be caught before the game starts, not during play.

Answer: At board creation time, BoardValidator simulates chain resolution for every cell. If any cell leads to a cycle (visited set detects a repeat), the configuration is rejected.

Great Answer: “I build a directed graph from all snakes and ladders — each entity is an edge from head to tail (snake) or bottom to top (ladder). Then I run cycle detection on this graph. If any cycle exists, the board is invalid. This runs once at creation time in O(V+E) where V = 100 cells and E = number of snakes + ladders. It’s cheap and guarantees every game terminates. The BoardValidator sits in the Factory — invalid configs are rejected before a Game instance is ever created.”

Cycle Detection — Reject Invalid Boards INVALID — Infinite Loop 12 Ladder bottom 45 Snake head ladder snake VALID — No Cycle 12 45 30 Safe cell BoardValidator detects cycles at creation time. Invalid boards never become games.
What to SAY: “Build a directed graph from snakes/ladders, run cycle detection in O(V+E). Invalid configs are rejected at creation time, not discovered during play.”
Think: From any cell, you can reach cells +1 through +6 (dice outcomes). Snakes and ladders are like “teleport edges.” What algorithm finds the shortest path in an unweighted graph?

This is the question that separates good candidates from exceptional ones. Most people don’t see it coming because it sounds like a game question, not an algorithm question. But here’s the key insight: the Snake & Ladder board is a graph. Each cell is a node. From each cell, you have 6 outgoing edges (dice outcomes 1-6). Snakes and ladders are extra edges that teleport you. Finding the minimum number of rolls is finding the shortest path from cell 1 to cell 100 in an unweighted graph. And the textbook algorithm for shortest path in an unweighted graph is BFS.

Answer: Model the board as a graph. Run BFS from cell 1. Each cell has 6 neighbors (+1 through +6, with snake/ladder resolution). BFS guarantees the shortest path because every edge (one dice roll) has equal weight.

Great Answer: “I model the board as an adjacency list. From cell c, I can reach cells c+1 through c+6 (each through one dice roll). If any destination has a snake or ladder, I resolve it to the final position. Then I run BFS from cell 1 — the first time I dequeue cell 100, the BFS depth is the answer. Time complexity: O(100 × 6) = O(600) = O(1) for a standard board. Why BFS and not DFS? Because BFS explores all 1-roll positions before any 2-roll positions, guaranteeing minimum. DFS might find a path but not the shortest.”

BFS on the Board Graph — Finding Minimum Rolls Each level = one dice roll. BFS guarantees we find the shortest path first. 1 Roll 0 (start) Roll 1: 2 3 25 4→25 (ladder!) 5 6 7 Roll 2: 26 27 76 28→76 (ladder!) 29 30 31 Roll 3: From 76: cells 77-82 99 80→99 (ladder!) Roll 4: 100 ← WINNER! (roll 1) BFS Algorithm Queue: [1] Roll 1: [2,3,25,5,6,7] Roll 2: [26,27,76,...] Roll 3: [77,..,99,..] Roll 4: [100] FOUND! Answer: 4 rolls minimum Path: 1→4(25)→28(76)→80(99)→100
MinimumMoves.cs — BFS Solution
public static int MinimumRolls(Board board)
{
    var visited = new bool[board.Size + 1];
    var queue = new Queue<(int cell, int rolls)>();
    queue.Enqueue((1, 0));
    visited[1] = true;

    while (queue.Count > 0)
    {
        var (cell, rolls) = queue.Dequeue();

        for (int dice = 1; dice <= 6; dice++)
        {
            int next = cell + dice;
            if (next > board.Size) continue;

            // Resolve snakes and ladders
            next = board.ResolveCell(next);

            if (next == board.Size) return rolls + 1;

            if (!visited[next])
            {
                visited[next] = true;
                queue.Enqueue((next, rolls + 1));
            }
        }
    }
    return -1; // unreachable (shouldn't happen with valid board)
}
What to SAY: “The board is an unweighted graph. Each cell has 6 edges (dice outcomes). BFS from cell 1 finds the shortest path in O(N×6). Snakes and ladders are just edge redirections resolved before enqueuing. This also validates that every cell is reachable.”
Think: How do you test that a player wins after landing on exactly cell 100 when the dice is random?

Random dice makes tests flaky — sometimes a player lands on 100, sometimes they don’t. The fix is to inject a dice that returns predetermined values. This is the Strategy pattern applied to testing.

Answer: RiggedDice implements IDiceStrategy and accepts a queue of predetermined values. Each call to Roll() dequeues the next value. Tests control every outcome.

Great Answer:RiggedDice takes params int[] rolls and returns them in order. I can write tests like: ‘Player starts at 94, RiggedDice returns [6] — verify player wins.’ Or: ‘Player starts at 94, RiggedDice returns [5] — verify exact-100 rule keeps them at 94.’ Or: ‘RiggedDice returns [3] for cell 16 (snake head) — verify player slides to cell 6.’ Every edge case becomes a deterministic, repeatable test. That’s the real value of Strategy for dice — it’s not about multiple dice types, it’s about testability.”

What to SAY: “Strategy for dice isn’t just about game variants — it’s about making every test deterministic. RiggedDice controls every outcome.”
Think: Power-ups modify how a move is resolved. Does the player skip a snake? Move twice? Teleport to a random cell? Where does that logic live?

Power-ups are move modifiers — they change what happens after a dice roll. An immunity shield means the player ignores the next snake. A double move means the roll is applied twice. A teleport sends the player to a random cell. Each power-up is a different strategy for modifying a move result.

Answer: Create an IPowerUp interface with ModifyMove(MoveResult). Each power-up implements it. Players hold a List<IPowerUp> that gets applied after each move. Power-ups placed on board cells get collected when a player lands on them.

Great Answer: “I’d model power-ups as a Decorator on the move resolution pipeline. The base pipeline is: roll → move → resolve snakes/ladders. An immunity shield wraps the snake resolution step and skips it once. A double-move wraps the roll step and doubles it. The Game doesn’t know which power-ups are active — it just calls player.ResolveTurn() and the decorators handle the rest. New power-up = new decorator class, zero changes to Game or Board.”

What to SAY: “Power-ups are Decorators on the move pipeline. The Game doesn’t know they exist. New power-up = new class, zero edits.”
Think: What makes a board “unfair”? Too many snakes near cell 100? All ladders in the first 10 cells? How do you measure “balance” mathematically?

A randomly generated board could accidentally have 5 snakes near cell 95-99, making it nearly impossible to win. Or all ladders in cells 1-10, making the opening trivially easy. “Balance” means the game is challenging but winnable, with snakes and ladders distributed reasonably across the board.

Answer: Define balance constraints: max N snakes in the last 20 cells, at least one ladder in each quarter of the board, no snake starting above cell 98 (otherwise exact-100 becomes nearly impossible). BoardValidator enforces these rules.

Great Answer: “I’d use the BFS minimum-moves algorithm from Q7 as a balance metric. Generate a random board, compute minimum rolls. If the minimum is below 3 (too many lucky ladders) or above 20 (too many snakes), regenerate. Also: divide the board into quartiles (1-25, 26-50, 51-75, 76-100) and require each to have at least 1 snake and 1 ladder. This ensures the difficulty curve is gradual. The BoardValidator gets a ValidateBalance() method alongside the existing ValidateCycles().”

What to SAY: “Use BFS minimum-rolls as a balance metric. Divide the board into quartiles with min snake/ladder distribution. Reject boards outside the target difficulty range.”
Think: Spectators need to see every move in real time but shouldn’t be able to affect the game. What pattern lets you “broadcast” events to many listeners?

Think about watching a sports match on TV. You see every play in real time, but you can’t run onto the field. Spectators in our game are Observers — they subscribe to game events and receive notifications without being able to influence the game.

Answer: The Game implements IObservable<GameEvent> or has a List<IGameObserver>. After each move, the Game calls observer.OnPlayerMoved(event). Spectator clients implement IGameObserver and render the events.

Great Answer: “I’d define an IGameObserver interface with methods like OnPlayerMoved, OnPlayerSlid, OnPlayerClimbed, OnGameWon. The GameRoom maintains a list of observers. When a move happens, the room calls each observer. For local spectators, the observer renders to the console. For online spectators, the observer serializes the event to JSON and pushes it via WebSocket. The Game logic doesn’t know whether 0 or 10,000 people are watching — it just publishes events. Adding a ‘record to file’ observer for replay is one new class, zero changes to Game.”

Observer Pattern for Spectators GameRoom (Subject) Publishes: OnPlayerMoved, OnGameWon ConsoleRenderer Prints moves to terminal WebSocketClient Broadcasts to online viewers FileRecorder Saves for replay Game doesn’t know who’s watching. 0 or 10,000 spectators — same code.
What to SAY: “GameRoom publishes events via IGameObserver. Console, WebSocket, and file recorder all implement it. Game logic never changes regardless of spectator count.”
Think: What’s the minimum information needed to reconstruct an entire game? Do you save every board state, or just the dice rolls?

You don’t need to save every board state — that’s redundant. The game is fully deterministic given the board configuration and the sequence of dice rolls. Save those two things, and you can replay the entire game from scratch.

Answer: Save the BoardConfig (snake/ladder positions), player names, and the sequence of dice rolls. To replay: recreate the game from the config with a RiggedDice pre-loaded with the saved roll sequence. Run each turn automatically.

Great Answer: “The replay record is tiny: record GameReplay(BoardConfig Config, List<string> Players, List<int> DiceRolls). To reconstruct: GameFactory.CreateFromConfig(Config, Players, new RiggedDice(DiceRolls)). Then call game.PlayTurn() in a loop with a delay for visualization. The key insight is that the RiggedDice we built for testing is the exact same tool we need for replay. Strategy pattern paid for itself twice: once for testability, once for replay. That’s the beauty of good abstractions — they solve problems you didn’t even plan for.”

What to SAY: “Board config + dice sequence = complete replay. RiggedDice for testing IS the replay mechanism. Strategy pattern pays for itself twice.”
Section 19

10 Deadliest Snake & Ladder Interview Mistakes

Every one of these has ended real interviews. Snake & Ladder is particularly dangerous because candidates think it’s trivial — they lower their guard and skip the fundamentals that interviewers actually grade on. The mistakes below are ordered by severity: fatal first, then serious, then minor.

Mistake Severity Map FATAL — Interview Enders #1 Jump to code, no scope #2 God class with all logic #3 enum + switch for states #4 No edge case handling Any one = likely No Hire Signals: no system thinking, no separation of concerns SERIOUS — Red Flags #5 Over-engineering everything #6 No testability thinking #7 Hardcoded board config #8 Ignoring chain resolution Drops you from Hire to Lean No Signals: junior-level habits, not production-ready MINOR — Missed Opportunities #9 Happy path only #10 No HLD bridge Won’t fail you, but won’t shine Signals: solid but not senior-level depth

Fatal Mistakes — Interview Enders

Why this happens: “It’s just Snake & Ladder — everyone knows the rules.” So you skip clarifying and start typing int[] board = new int[101]. Five minutes in, the interviewer asks about configurable board sizes or different dice types, and your rigid array can’t handle it.

What the interviewer thinks: “Doesn’t scope. Will build the wrong thing in production for two weeks before asking what the customer needed.”

Fix: Spend 2 minutes asking: board size? number of players? dice variants? online or local? Then summarize requirements. On a “simple” problem, scoping is even MORE impressive.

Why this happens: “It’s a small game — why multiple classes?” You put board setup, dice rolling, snake resolution, player management, turn tracking, and win detection all in SnakeLadderGame.cs. It works for the demo, but adding spectators means modifying the same 300-line class.

Bad — God Class
public class SnakeLadderGame  // does EVERYTHING
{
    private int[] _snakes, _ladders, _players;
    public void RollDice() { ... }       // dice logic here
    public void MovePlayer() { ... }     // movement logic here
    public void CheckSnake() { ... }     // snake resolution here
    public void CheckLadder() { ... }    // ladder resolution here
    public bool IsWinner() { ... }       // win detection here
    public void PrintBoard() { ... }     // display logic here
    // 250+ lines growing every feature...
}
Good — Separated Responsibilities
Board          // owns snake/ladder map + ResolveCell()
Game           // orchestrates turns, delegates to state
IGameState     // WaitingForPlayers, InProgress, Finished
IDiceStrategy  // StandardDice, RiggedDice, WeightedDice
PlayerQueue    // manages turn order
GameFactory    // builds configured games
// Each class: one job. Change dice without touching Board.

Fix: Ask “what changes independently?” Board layout, dice algorithm, game flow, and display all change for different reasons. Each gets its own class.

Why this happens: An enum GameStatus { WaitingForPlayers, InProgress, Finished } feels natural. But every method in Game now needs a switch (status) block. Adding a Paused state means editing every switch in every method. The logic for “what’s allowed in Paused mode” is scattered across the entire class.

What the interviewer thinks: “Can’t recognize when State pattern fits. Will create brittle switch blocks in production code.”

Fix: Each mode becomes a class implementing IGameState. The Game delegates to the current state. Adding Paused = one new class, zero edits to Game or existing states.

Why this happens: The happy path works: roll dice, move player, check snake/ladder, check winner. But what if the player overshoots 100? What if one snake drops you onto another? What if a ladder and snake form a cycle? These are the cases that break real implementations.

What the interviewer thinks: “Only handles the happy path. First edge case in production and this code breaks.”

Fix: Proactively mention exact-100 rule (stay put on overshoot), chain resolution (while-loop until safe cell), and board validation (cycle detection at creation time). These three edge cases are the #1 differentiator.

Serious Mistakes — Red Flags

Why this happens: You’ve learned patterns and want to show them all. So you create an ICellVisitor, a BoardBuilderFactory, a PlayerMoveMediator, and a DiceRollChainOfResponsibility. It’s a board game. It doesn’t need 15 abstractions.

Fix: Every pattern must solve a SPECIFIC problem. If you can’t name the problem in one sentence, remove the pattern. Factory for complex setup? Yes. Strategy for dice? Yes. Mediator between 2 classes? No — direct calls are fine.

Why this happens: Testing feels like a separate concern. But an interviewer asking about Snake & Ladder is watching for: “How would you TEST that the exact-100 rule works?” If your dice is Random.Next(1,7) hardcoded inside Game, the answer is “I can’t.”

Fix: Mention RiggedDice as the Strategy variant that enables deterministic testing. Show that you think about testability as part of the design, not an afterthought.

Why this happens: You write snakes[16] = 6; snakes[48] = 26; directly in the Board constructor. It works for the demo. But adding a tournament mode or random board generation means rewriting the Board class.

Fix: Board receives a BoardConfig (list of snakes + ladders) through its constructor. The Factory creates the config. Different game modes = different configs, same Board class. This is OCP at its simplest.

Why this happens: Your code checks: “Did the player land on a snake? Move them.” Done. But what if the snake’s tail is on another snake’s head? Your player stops at an intermediate cell that should trigger another slide. The game state becomes incorrect.

Fix: Use a while-loop: while (HasSnakeOrLadder(cell)) cell = Resolve(cell). Add a visited set to detect cycles. Board.ResolveCell() returns the final resting position after ALL chain steps.

Minor Mistakes — Missed Opportunities

Your game works when everything goes right. But what about: player disconnects mid-game? Invalid player count? Board config with overlapping snake heads and ladder bottoms on the same cell? Mentioning even 2-3 failure scenarios shows production thinking.

The interview ends and you never mentioned: game rooms for concurrent games, Observer for spectators, replay via dice sequence, or a game server. These bridges show the interviewer you can think beyond the whiteboard. Spend 2 minutes at the end on scaling — it’s the difference between “Hire” and “Strong Hire.”

Interviewer’s actual rubric: No scope + God class + no edge cases = “No Hire” in the first 10 minutes Scope + records + State + chains + scale bridge = “Strong Hire” at any pace

Interviewer Scoring Rubric

LevelRequirementsDesignCodeEdge CasesCommunication
Strong Hire Structured F+NF 3-4 patterns, each motivated Clean modern C#, records 3+ proactive (chains, exact-100, loops) Explains WHY, names trade-offs
Hire Key ones covered 1-2 patterns used Mostly correct When asked Clear explanations
Lean No Partial / missed NF Forced or wrong patterns Works but messy Misses obvious ones Quiet or overly verbose
No Hire None — jumped to code No abstractions Can’t code it None Can’t explain decisions
Section 20

Memory Anchors — Never Forget This

You just built a Snake & Ladder system from scratch and discovered four 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.

CREATES — The Universal LLD Approach

Clarify  •  Requirements  •  Entities  •  API  •  Trade-offs  •  Edge cases  •  Scale

“Every system design CREATES a solution.” — Seven steps, same order, every interview. You used all seven in this case study.

CREATES on the Board — Each Step Is a Stretch of Cells C Clarify Cell 1 R Requirements Cells 2-20 E Entities Cells 21-50 A API Cells 51-70 T Trade-offs Cells 71-85 E Edge Cases Cells 86-99 S Scale Cell 100! Reach cell 100 (Scale) and you’ve won the interview. Skip any cell and you fall behind.

Memory Palace — Walk Through the Board

Imagine the Snake & Ladder board itself as your memory palace. Each region of the board maps to a design concept. As you walk from cell 1 to cell 100, you pass through every concept in order — just like the CREATES structure.

Memory Palace — The Board IS Your Map
Memory palace mapping board regions to CREATES design steps THE SNAKE & LADDER BOARD Cell 1: START “What’s the scope?” CLARIFY Cells 21-50 Board, Snake, Ladder, Player ENTITIES Cells 51-70 Roll, Move, ResolveCell API Cells 71-85 Factory vs Builder? TRADE-OFFS Cells 86-99: DANGER! Snakes everywhere near the end exact-100, chains, loops EDGE CASES Cell 100: YOU WON! GameRooms, Spectators, Replay Now scale it! SCALE “Walk from Cell 1 to 100 — that’s the interview. Skip cells and you fall behind.”

Smell → Pattern Quick Reference

SmellSignalResponse
Complex object creation with 5+ stepsBoard setup involves validation, config, wiringFactory pattern
Behavior changes by game phaseCan’t roll when game is finishedState pattern
Multiple algorithms for the same operationStandard dice, weighted dice, rigged diceStrategy pattern
Others need to know when something happensSpectators watching the game liveObserver pattern

Pattern Detection Tree

“What varies independently?” Object creation is complex? FACTORY GameFactory Behavior depends on mode? STATE IGameState Algorithm swappable at runtime? STRATEGY IDiceStrategy Others need to be notified? OBSERVER IGameObserver Ask “What varies?” once, and the right pattern reveals itself. No memorization needed.

5 Things to ALWAYS Mention in a Snake & Ladder Interview

Records for Snake/Ladder (data, not behavior)
Factory for complex game setup
State pattern for game modes
Three edge cases: exact-100, chains, loops
RiggedDice for deterministic testing

Flashcards — Quiz Yourself

Click each card to reveal the answer. If you can answer without peeking, the concept is sticking.

No behavior, immutable data. A snake is just a Head and Tail position. It doesn’t “do” anything. Records give immutability and value equality for free.

State pattern. FinishedState.RollDice() returns MoveResult.GameAlreadyOver. No if/else in Game — the current state decides what’s allowed.

RiggedDice. Inject a RiggedDice([7]) when the player is on cell 94. The player can’t move to 101, so they stay at 94. Test is deterministic and repeatable.

BFS from cell 1. The board is a graph. Each cell has 6 edges (dice outcomes). Snakes/ladders are edge redirections. BFS finds the shortest path in O(N×6).

Directed graph + cycle detection. Build a graph from snake/ladder edges. Run DFS-based cycle detection at board creation time. O(V+E). Invalid boards are rejected before a Game instance exists.

Smell → Pattern (This Case Study’s 4 Instincts) 5+ setup steps → Factory GameFactory Mode-dependent behavior → State IGameState Swappable algorithm → Strategy IDiceStrategy Broadcast to listeners → Observer IGameObserver
Section 21

Transfer — These Patterns Work Everywhere

You didn’t just learn how to build a board game. You learned a set of thinking moves — Factory for complex setup, State for mode-dependent behavior, Strategy for swappable algorithms, and Observer for broadcasting events. Those four ideas appear in virtually every interactive system. Below is the proof: the same four patterns applied to three completely different domains.

TechniqueSnake & LadderChessLudoCard Game
Real-world walkthrough Unbox → place snakes → roll → move → win Setup board → pick piece → validate move → capture Roll → choose token → move → safe zone → home Shuffle → deal → draw/play → discard → win
Key entities Board, Snake, Ladder, Player, Dice Board, Piece, Square, Player, MoveValidator Board, Token, Player, SafeZone, Dice Deck, Card, Hand, Player, DiscardPile
Factory use GameFactory builds configured board BoardFactory sets up initial piece positions GameFactory builds board + 4 player sets DeckFactory shuffles and deals cards
State use WaitingForPlayers → InProgress → Finished WhiteTurn → BlackTurn → Check → Checkmate WaitingForRoll → ChoosingToken → Moving → Won Dealing → PlayerTurn → DrawPhase → GameOver
Strategy use IDiceStrategy: Standard, Weighted, Rigged IAiEngine: Novice, Intermediate, Grandmaster IDiceStrategy: single, double, custom IDrawStrategy: draw-one, draw-two, pick-from-discard
Key edge case Snake chains, exact-100, board cycles En passant, castling, stalemate vs checkmate Token blocking, safe zones, exact-home Empty deck reshuffle, wild cards, hand limit
Scale path GameRooms → Observer spectators → server GameRooms → ELO matchmaking → time controls GameRooms → 4-player online → tournaments GameRooms → hand history → anti-cheat

Transfer Web — One Game, Many Domains

Transfer Web — Patterns Radiate to Other Domains
Transfer web showing Snake and Ladder patterns connecting to Chess, Ludo, Card Game, Vending Machine, and E-Commerce Snake & Ladder Factory + State + Strategy + Observer Chess State + Strategy Ludo Factory + State + Strategy Card Game State + Strategy Vending Machine State + Strategy E-Commerce Factory + State + Observer Game Server All four patterns

Pattern Heat Map — Which Patterns Repeat Most?

Heat Map — Pattern Frequency Across Systems
Heat map showing how frequently each pattern appears across different systems Pattern Frequency Across Systems Factory State Strategy Observer S&L Chess Ludo Card Vending E-Com Yes Yes Yes Yes Maybe Yes Core Core Core Core Core Core Yes Yes Yes Yes Yes Yes Scale Scale Scale Scale IoT Core State appears in EVERY system. Strategy is universal. Factory handles any complex setup. Observer scales everything.
The insight: Patterns aren’t domain-specific. They target structural problems that recur everywhere: “setup is complex” (Factory), “behavior varies by phase” (State), “algorithms are swappable” (Strategy), “others need to react” (Observer). Learn the structure once, apply it forever.
Section 22

The Reusable Toolkit

Six thinking tools you picked up in this case study. Each one is a portable mental move — not a Snake & Ladder trick, but something you can use in any LLD interview or real-world design.

Your Toolkit — 6 Portable Thinking Tools
Toolbox with six labeled design thinking tools TOOLKIT SCOPE Size, Complexity, Ops, Perf, Ext Clarify phase What Varies? Detect patterns Strategy / State Trade-offs phase What If? Concurrency, Fail, Boundary, Weird Edge cases phase Record vs Class Data → record Behavior → class Entity phase CREATES 7-step LLD approach Full interview Can I Test It? DI + interfaces RiggedDice Testability phase
SCOPE

Ask 5 questions before coding: Size (how big?), Complexity (what types?), Operations (what actions?), Performance (concurrent?), Extensions (what grows?).

What Varies?

Ask: “Is there more than one way to do this? Could it change at runtime?” If yes → Strategy. If behavior changes by mode → State.

What If?

After happy path works, run 4 categories: Concurrency, Failure, Boundary, Weird Input. Each surfaces edge cases the happy path ignores.

Record vs Class

Data that never changes after creation? → Record. Has behavior, mutable state, or lifecycle? → Class. Snake = record. Board = class.

CREATES

The 7-step universal LLD approach: Clarify → Requirements → Entities → API → Trade-offs → Edge cases → Scale. Works for EVERY interview.

Can I Test It?

If your class needs the whole app to run, your dependencies are too tight. Inject interfaces. RiggedDice exists because IDiceStrategy exists.

Self-Check — Can You Answer These?

  • What are the 7 steps of CREATES?
  • Why are Snake/Ladder records and not classes?
  • What’s the Factory doing that a constructor can’t?
  • How does State prevent rolling after the game ends?
  • Why does the BFS algorithm find minimum dice rolls?
  • How does RiggedDice enable deterministic testing?

If you can answer all 6 without scrolling up, you’re interview-ready for this case study.

Section 23

Practice Exercises

Three 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. Use the thinking process: What entity changes? What varies? What pattern fits?

Exercise Difficulty Progression
Exercise difficulty progression from medium to hard 1 Power-Ups MEDIUM 2 Board Validator MEDIUM 3 Minimum Moves HARD
Exercise 1: Add Power-Ups Medium

New constraint: Certain cells now contain power-ups. When a player lands on one, they collect it. Three power-ups exist: Immunity Shield (skip the next snake), Double Move (next roll is doubled), and Teleport (jump to any cell from 50-90). A player can hold at most one power-up at a time.

Think: What entity represents a power-up? What pattern lets you modify move resolution without changing the Game class? How does the Board know which cells have power-ups?

Power-ups are move modifiers. Model them as IPowerUp with a ModifyMove(MoveContext) method. ImmunityShield intercepts snake resolution and skips it. DoubleMove intercepts the dice roll and doubles it. Teleport overrides the destination. The Board’s config includes power-up placements alongside snakes and ladders. The Decorator pattern is ideal here because each power-up wraps a different stage of the move pipeline.

PowerUp.cs
public interface IPowerUp
{
    string Name { get; }
    MoveResult ModifyMove(MoveContext context, MoveResult result);
}

public class ImmunityShield : IPowerUp
{
    public string Name => "Immunity Shield";

    public MoveResult ModifyMove(MoveContext ctx, MoveResult result)
    {
        // If the player would land on a snake, skip the slide
        if (result.HitSnake)
            return result with { FinalPosition = result.PreSnakePosition,
                                 HitSnake = false,
                                 PowerUpUsed = true };
        return result; // no snake? shield wasn't needed
    }
}

public class DoubleMove : IPowerUp
{
    public string Name => "Double Move";

    public MoveResult ModifyMove(MoveContext ctx, MoveResult result)
    {
        // Double the dice roll BEFORE resolving snakes/ladders
        int doubled = ctx.DiceRoll * 2;
        int newPos = ctx.CurrentPosition + doubled;
        if (newPos > ctx.Board.Size) return result; // overshoot
        return ctx.Board.ResolveMove(newPos);
    }
}
Exercise 2: Board Validator Medium

New constraint: Write a BoardValidator that rejects board configurations with: (1) cycles between snakes and ladders, (2) cells that are unreachable from cell 1, (3) more than 3 snakes in the last 10 cells (too hard). The validator runs at board creation time inside the Factory.

Think: For cycle detection, what kind of graph do snakes and ladders form? For reachability, which algorithm explores all reachable nodes? For snake density, is it just a count?

Three separate checks: (1) Cycle detection: Build a directed graph from snake/ladder edges. Run DFS with a “currently visiting” stack to detect back edges = cycles. (2) Reachability: Run BFS from cell 1 (same algorithm as minimum moves). Any cell not visited in BFS is unreachable. (3) Density: Count snakes with head in [91, 100]. Reject if count > 3. All three run in O(N) time and live in a BoardValidator class that the Factory calls before constructing the Board.

BoardValidator.cs
public class BoardValidator
{
    public ValidationResult Validate(BoardConfig config)
    {
        var errors = new List<string>();

        if (HasCycles(config))
            errors.Add("Board has snake-ladder cycles");

        if (HasUnreachableCells(config))
            errors.Add("Some cells are unreachable from cell 1");

        if (CountSnakesNear100(config) > 3)
            errors.Add("Too many snakes near cell 100 (max 3)");

        return errors.Count == 0
            ? ValidationResult.Valid
            : ValidationResult.Invalid(errors);
    }

    private bool HasCycles(BoardConfig config)
    {
        // Build directed graph: each snake/ladder is an edge
        var graph = new Dictionary<int, int>();
        foreach (var s in config.Snakes)
            graph[s.Head] = s.Tail;
        foreach (var l in config.Ladders)
            graph[l.Bottom] = l.Top;

        // DFS cycle detection
        var visited = new HashSet<int>();
        foreach (var start in graph.Keys)
        {
            var current = start;
            var path = new HashSet<int>();
            while (graph.ContainsKey(current))
            {
                if (path.Contains(current)) return true; // cycle!
                path.Add(current);
                current = graph[current];
            }
        }
        return false;
    }
}
Exercise 3: Minimum Moves Calculator Hard

New constraint: Implement a MinimumMovesCalculator that uses BFS to find the fewest dice rolls needed to go from cell 1 to cell 100. The calculator should also return the actual path (which cells were visited) and handle boards where cell 100 is unreachable (return -1).

Think: Each cell has 6 outgoing edges (dice outcomes 1-6). After computing the new position, resolve snakes/ladders. How do you reconstruct the path after BFS finds the destination? What data structure tracks “I reached this cell from that cell”?

Use a Dictionary<int, int> parent to track “cell X was reached from cell Y.” After BFS reaches cell 100, walk backwards from 100 through the parent chain to reconstruct the path. Time complexity: O(N × 6) where N = board size (100). Space: O(N) for visited array and parent map. The key insight: snakes and ladders are “free teleports” — they don’t cost an extra roll, so resolve them BEFORE enqueuing.

MinimumMovesCalculator.cs
public record MinMovesResult(int Rolls, List<int> Path);

public class MinimumMovesCalculator
{
    private readonly Board _board;

    public MinimumMovesCalculator(Board board) => _board = board;

    public MinMovesResult? Calculate()
    {
        int size = _board.Size;
        var visited = new bool[size + 1];
        var parent = new Dictionary<int, int>();
        var dist = new int[size + 1];
        var queue = new Queue<int>();

        queue.Enqueue(1);
        visited[1] = true;
        dist[1] = 0;

        while (queue.Count > 0)
        {
            int cell = queue.Dequeue();

            for (int dice = 1; dice <= 6; dice++)
            {
                int next = cell + dice;
                if (next > size) continue;

                // Resolve snakes and ladders (free teleport)
                next = _board.ResolveCell(next);

                if (next == size) // reached 100!
                {
                    parent[next] = cell;
                    dist[next] = dist[cell] + 1;
                    return new MinMovesResult(
                        dist[next],
                        ReconstructPath(parent, next));
                }

                if (!visited[next])
                {
                    visited[next] = true;
                    parent[next] = cell;
                    dist[next] = dist[cell] + 1;
                    queue.Enqueue(next);
                }
            }
        }

        return null; // cell 100 unreachable
    }

    private List<int> ReconstructPath(
        Dictionary<int, int> parent, int target)
    {
        var path = new List<int>();
        int current = target;
        while (current != 1)
        {
            path.Add(current);
            current = parent[current];
        }
        path.Add(1);
        path.Reverse();
        return path;
    }
}

// Usage:
// var calc = new MinimumMovesCalculator(board);
// var result = calc.Calculate();
// Console.WriteLine($"Minimum: {result.Rolls} rolls");
// Console.WriteLine($"Path: {string.Join(" → ", result.Path)}");
BFS Levels = Dice Rolls — First Time We See 100 = Answer Roll 0: [1] Roll 1: [2,3,25,5,6,7] Roll 2: [26,27,76,...] Roll 3: [77,..,99,..] Roll 4: [100!] Why BFS and not DFS? BFS explores ALL roll-1 positions before any roll-2. First time BFS finds cell 100 = guaranteed minimum rolls. DFS might find a longer path first.