Connect Four LLD deep dive

Design Connect Four

Full interview walkthrough for Connect Four: clarifying dialogue, Game/Board/Player design, makeMove and direction-vector win checks (no WinChecker Strategy), verification trace, and extensibility.

What this prompt is really asking

Connect Four is a two-player connection game on a 7×6 grid. Players alternate dropping coloured discs into columns; gravity seats each disc in the lowest empty row. First to get four of their colour in a line — horizontal, vertical, or diagonal — wins. A full board with no winner is a draw.
In the interview you’ll hear something like: “Design the OO layout for two-player Connect Four.” That sentence is a door, not a spec. Your job is to turn it into requirements you can code against, then show clean ownership between Game, Board, and Player.
01Clarify

Actions, errors, scope

02Model

Game · Board · Player

03Code

makeMove + vectors

04Extend

Size · undo · bot

Connect Four game loop
Drop → board updates → win check from last disc → next turn or end.

Clarifying questions → locked requirements

Structure clarifying questions around four buckets: core actions, error handling, system boundaries, and whether to plan for extensions. A strong dialogue sounds like this (paraphrased):
  • You: Do players just pick a column 0–6 and the disc falls? They: Yes — lowest empty row.
  • You: Win only, or draws too? They: Four in a row wins; full board = draw.
  • You: Full column / out of turn / move after game over? They: Reject clearly; don’t corrupt state.
  • You: One game or many concurrent? UI or backend only? They: One game, backend logic only.

Entities: Game, Board, Player

Connect Four entities
Game orchestrates; Board owns the grid and win math; Player is data.
Pull nouns that own rules. The board owns placement physics and “four in a row.” The game owns turns and terminal state. A player is just identity + disc colour — no AI, no move validation.
  • Game — holds Board + two Players, currentPlayer, state (IN_PROGRESS / WON / DRAW), optional winner. Entry point for makeMove.
  • Board — 6×7 grid of colours / empty. placeDisc, checkWin, isFull, canPlace. No idea whose turn it is.
  • Player — name + colour (RED / YELLOW). Pure data.

Class design from the requirements

Derive state top-down from the locked requirements. Game needs the two players, whose turn, the board, and terminal outcome. Board needs rows/cols and the grid. Player needs name and colour.
from enum import Enum, auto
from typing import Optional

class DiscColor(Enum):
    RED = auto()
    YELLOW = auto()

class GameState(Enum):
    IN_PROGRESS = auto()
    WON = auto()
    DRAW = auto()

class Player:
    def __init__(self, name: str, color: DiscColor):
        self.name, self.color = name, color

class Board:
    ROWS, COLS = 6, 7

    def __init__(self):
        self.grid: list[list[Optional[DiscColor]]] = [
            [None] * self.COLS for _ in range(self.ROWS)
        ]

    # can_place / place_disc / is_full / check_win — see implementation

class Game:
    def __init__(self, player1: Player, player2: Player):
        self.board = Board()
        self.player1, self.player2 = player1, player2
        self.current_player = player1
        self.state = GameState.IN_PROGRESS
        self.winner: Optional[Player] = None

    def make_move(self, player: Player, column: int) -> bool: ...
    def get_current_player(self) -> Player: ...
    def get_game_state(self) -> GameState: ...
    def get_winner(self) -> Optional[Player]: ...
import java.util.*;

enum DiscColor { RED, YELLOW }

enum GameState { IN_PROGRESS, WON, DRAW }

class Player {
    String name;
    DiscColor color;
    Player(String name, DiscColor color) { this.name = name; this.color = color; }
}

class Board {
    static final int ROWS = 6, COLS = 7;
    DiscColor[][] grid = new DiscColor[ROWS][COLS]; // null = empty

    // canPlace / placeDisc / isFull / checkWin — see implementation
}

class Game {
    Board board = new Board();
    Player player1, player2, currentPlayer;
    GameState state = GameState.IN_PROGRESS;
    Player winner; // nullable

    Game(Player player1, Player player2) {
        this.player1 = player1; this.player2 = player2;
        this.currentPlayer = player1;
    }

    boolean makeMove(Player player, int column) { /* ... */ return false; }
    Player getCurrentPlayer() { return currentPlayer; }
    GameState getGameState() { return state; }
    Player getWinner() { return winner; }
}

Implementation highlights

Interviewers usually want the interesting methods in real code: make_move, place_disc, and check_win / count-in-direction.
def make_move(self, player: Player, column: int) -> bool:
    if self.state is not GameState.IN_PROGRESS:
        return False
    if player is not self.current_player:
        return False

    row = self.board.place_disc(column, player.color)
    if row < 0:
        return False

    if self.board.check_win(row, column, player.color):
        self.state = GameState.WON
        self.winner = player
    elif self.board.is_full():
        self.state = GameState.DRAW
    else:
        self.current_player = (
            self.player2 if player is self.player1 else self.player1
        )
    return True


def place_disc(self, column: int, color: DiscColor) -> int:
    if column < 0 or column >= self.COLS or not self.can_place(column):
        return -1
    for row in range(self.ROWS - 1, -1, -1):
        if self.grid[row][column] is None:
            self.grid[row][column] = color
            return row
    return -1


def check_win(self, row: int, col: int, color: DiscColor) -> bool:
    if not (0 <= row < self.ROWS and 0 <= col < self.COLS):
        return False
    if self.grid[row][col] is not color:
        return False
    for dr, dc in ((0, 1), (1, 0), (1, 1), (1, -1)):
        total = 1
        total += self._count_in_direction(row, col, dr, dc, color)
        total += self._count_in_direction(row, col, -dr, -dc, color)
        if total >= 4:
            return True
    return False


def _count_in_direction(self, row, col, dr, dc, color) -> int:
    n, r, c = 0, row + dr, col + dc
    while 0 <= r < self.ROWS and 0 <= c < self.COLS and self.grid[r][c] is color:
        n += 1
        r += dr
        c += dc
    return n
boolean makeMove(Player player, int column) {
    if (state != GameState.IN_PROGRESS) return false;
    if (player != currentPlayer) return false;

    int row = board.placeDisc(column, player.color);
    if (row < 0) return false;

    if (board.checkWin(row, column, player.color)) {
        state = GameState.WON;
        winner = player;
    } else if (board.isFull()) {
        state = GameState.DRAW;
    } else {
        currentPlayer = (player == player1) ? player2 : player1;
    }
    return true;
}

int placeDisc(int column, DiscColor color) {
    if (column < 0 || column >= COLS || !canPlace(column)) return -1;
    for (int row = ROWS - 1; row >= 0; row--) {
        if (grid[row][column] == null) {
            grid[row][column] = color;
            return row;
        }
    }
    return -1;
}

boolean checkWin(int row, int col, DiscColor color) {
    if (!(0 <= row && row < ROWS && 0 <= col && col < COLS)) return false;
    if (grid[row][col] != color) return false;
    int[][] dirs = {{0, 1}, {1, 0}, {1, 1}, {1, -1}};
    for (int[] d : dirs) {
        int total = 1;
        total += countInDirection(row, col, d[0], d[1], color);
        total += countInDirection(row, col, -d[0], -d[1], color);
        if (total >= 4) return true;
    }
    return false;
}

int countInDirection(int row, int col, int dr, int dc, DiscColor color) {
    int n = 0, r = row + dr, c = col + dc;
    while (0 <= r && r < ROWS && 0 <= c && c < COLS && grid[r][c] == color) {
        n++; r += dr; c += dc;
    }
    return n;
}

Verification trace

Trace a short vertical win out loud (partial board to save time):
  1. Bottom two cells of col 0 already RED; current = player1.
  2. P1 → col 0 lands row 3; vertical count = 3 — no win yet.
  3. P2 → col 1; no win. P1 → elsewhere; P2 → elsewhere.
  4. P1 → col 0 again → four RED stacked → state=WON, winner=P1.
  5. P2 tries any column → rejected because state ≠ IN_PROGRESS.
You don’t need a full whiteboard transcript. Narrate placement, the direction scan from the last disc, turn flip, and post-game rejection — enough to catch logic bugs before the interviewer does.

Extensibility follow-ups

  • Configurable board size — Board(rows, cols). Placement and win logic already use bounds helpers; Game just constructs the size you need.
  • Undo / history — choke point is Game.make_move. Push (player, row, col) onto a stack; undo clears the cell, restores turn, recomputes state.
  • Bot opponent — do not shove AI into the Player interface. Keep Player as data; add a BotEngine.choose_move(game) -> int that reads the board and returns a column for the game loop to pass into make_move.

Common interview pitfalls

These mistakes show up constantly on this prompt. Name the trap, then show the fix in your design — don’t wait for the interviewer to catch you.

  • Forcing Strategy pattern for win checks when direction vectors on Board are clearer.
  • Forgetting gravity — placing in a row/col like Tic Tac Toe.
  • Win check only after move in one direction, missing diagonals.
  • No draw detection when board is full.
  • BotEngine that mutates board without going through make_move.
  • Unlimited undo without command/history seam.

Interview script (say this)

Read this once out loud before a mock. It’s the spine of a strong answer — not a script to recite robotically.

  1. Connect Four: 7×6, gravity drops, alternate colors, four-in-a-row including diagonals.
  2. Entities: Game, Board, Player; Bot optional behind Player port.
  3. make_move(column) finds lowest empty row; then win/draw check.
  4. Win: from last disc, scan 4 directions (and opposites) counting runs.
  5. Trace: vertical win; full board draw; invalid column full.
  6. Keep win logic on Board — no premature Strategy.

Extra verification traces

Walk these three traces on the board. If you can narrate them cleanly, your implementation section usually follows.

def make_move(self, column: int) -> Outcome:
    row = self.board.drop(column, self.current.color)  # raises if full
    if self.board.wins_from(row, column, self.current.color):
        self.status = "WON"
        return Outcome.WIN
    if self.board.is_full():
        self.status = "DRAW"
        return Outcome.DRAW
    self.swap_turn()
    return Outcome.CONTINUE
enum Outcome { WIN, DRAW, CONTINUE }

Outcome makeMove(int column) {
    int row = board.drop(column, current.color); // throws if full
    if (board.winsFrom(row, column, current.color)) {
        status = "WON";
        return Outcome.WIN;
    }
    if (board.isFull()) {
        status = "DRAW";
        return Outcome.DRAW;
    }
    swapTurn();
    return Outcome.CONTINUE;
}

Staff-level follow-ups

At staff+, they twist the prompt. Answer in one sentence that names the seam — don’t redesign the whole board.

  • Popout / power-ups? — MoveCommand hierarchy; Board applies commands.
  • NxM / connect-K? — Board parameterized; win length K.
  • Online rematch? — GameFactory + immutable MatchConfig.
  • AI difficulty? — BotEngine strategies MiniMax depth — Player interface stable.

Complete solution notes (Connect Four)

Connect Four is usually a single-threaded rules engine in LLD. If the prompt adds a multiplayer HTTP API, wrap each game id with a lock so two moves can’t interleave on the same board.
from threading import Lock

class GameService:
    def __init__(self):
        self.games: dict[str, object] = {}
        self._locks: dict[str, Lock] = {}
        self._map_lock = Lock()

    def _lock_for(self, game_id: str) -> Lock:
        with self._map_lock:
            return self._locks.setdefault(game_id, Lock())

    def make_move(self, game_id: str, *args):
        with self._lock_for(game_id):
            return self.games[game_id].make_move(*args)

# Different game ids → different locks → parallel matches.
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;

class GameService {
    Map<String, Object> games = new HashMap<>();
    Map<String, ReentrantLock> locks = new HashMap<>();
    private final ReentrantLock mapLock = new ReentrantLock();

    ReentrantLock lockFor(String gameId) {
        mapLock.lock();
        try {
            return locks.computeIfAbsent(gameId, k -> new ReentrantLock());
        } finally {
            mapLock.unlock();
        }
    }

    Object makeMove(String gameId, Object... args) {
        ReentrantLock lock = lockFor(gameId);
        lock.lock();
        try {
            // return games.get(gameId).makeMove(...);
            return null;
        } finally {
            lock.unlock();
        }
    }
}

// Different game ids → different locks → parallel matches.

Concurrency cases

  • Two HTTP moves on one game — without per-game lock, turn checks race.
  • Spectators reading board — copy board under lock or accept briefly stale reads.
  • Matchmaking — separate lock from game rules lock.

← Lattice