12. Tic-Tac-Toe / Board Game Engine Beginner → Intermediate (Pattern Showcase)

A. Realistic Interview Prompt

"Design a Tic-Tac-Toe game for two players. Then generalize it: support an arbitrary board size and an arbitrary number of symbols in a row to win, and support both human and computer-controlled players."

B. Difficulty

Beginner → Intermediate. Trivial if hardcoded to a 3×3 board with 8 fixed win-lines; the entire value of this problem, as an interview signal, is in the generalization the interviewer almost always asks for next.

C. What This Problem Tests

D. Requirements

Functional

  1. Support an N×N board (default 3×3)
  2. Support K-in-a-row win condition (default 3), checked across rows, columns, and both diagonal directions
  3. Support 2+ players, each assigned a distinct symbol
  4. Support both human players (external move input) and computer players (internally decided moves)
  5. Detect a win, a draw (board full, no winner), and reject illegal moves (occupied cell, out-of-turn, out-of-bounds)

Non-Functional

E. Clarifying Questions

#QuestionWhy It MattersDesign Decision Affected
1Fixed 3×3, or should the board size and win-length be generalized?Determines whether a generic line-scan win checker is required from the start or can be added laterDesign of WinningStrategy
2Should the engine support other turn-based grid games (Connect Four, Gomoku) later?Determines whether the game loop should be a reusable Template Method nowScope of TurnBasedGame base class
3Does the computer player need real strategy (minimax) or just legal random moves?Affects how much of the AI Strategy hierarchy is worth building outDepth of MoveStrategy implementations
4More than 2 players?Some generalized variants support 3+ symbols on a larger boardWhether Player collection is fixed-size or a list

F. Assumptions

G. Actors

ActorResponsibility
Player (human or AI)Produces a move each turn
GameController (facade)Runs the game loop, enforces turn order and legality

H. Core Domain Entities

ClassResponsibilityKey AttributesKey MethodsDoes NOT Handle
BoardOwns the grid and cell occupancyN×N grid of SymbolplaceMark(row, col, symbol), isFull(), isValidMove()Deciding whether a placement wins — that's WinningStrategy's job
Symbol (value)A player's mark (X, O, or generalized)———
Player (abstract)Common identity for anything that produces a moveid, symbolgetMove(board)How the move is decided — delegated to subclass/strategy
HumanPlayerReturns externally-supplied input as the move—getMove(board)Any move computation
AIPlayerDelegates move selection to a MoveStrategyMoveStrategygetMove(board)The algorithm itself
MoveStrategy (interface)One algorithm for choosing the AI's next move—decideMove(board, symbol)—
WinningStrategy (interface)Generic K-in-a-row check over the current board—checkWinner(board, lastMove)Anything about turn order
TurnBasedGame (abstract, Template Method)The shared skeleton of any turn-based grid gameboard, players, winningStrategyplay() (template), hooks: setupBoard(), declareResult()Game-specific win rules — a hook, not hardcoded
GameController (facade)Public API to start/query a gameTurnBasedGame instancestartGame(), makeMove()—

I. Class Relationships

J. Class Diagram

*

TurnBasedGame

board

players

winningStrategy

+play()

+setupBoard()

Board

grid

+placeMark()

+isValidMove()

+isFull()

«interface»

WinningStrategy

+checkWinner(board, lastMove)

«abstract»

Player

id

symbol

+getMove(board)

AIPlayer

moveStrategy

+getMove(board)

«interface»

MoveStrategy

+decideMove(board, symbol)

HumanPlayer

View Mermaid source
classDiagram
  class TurnBasedGame {
    board
    players
    winningStrategy
    +play()
    +setupBoard()
  }
  class Board {
    grid
    +placeMark()
    +isValidMove()
    +isFull()
  }
  class WinningStrategy {
    <<interface>>
    +checkWinner(board, lastMove)
  }
  class Player {
    <<abstract>>
    id
    symbol
    +getMove(board)
  }
  class AIPlayer {
    moveStrategy
    +getMove(board)
  }
  class MoveStrategy {
    <<interface>>
    +decideMove(board, symbol)
  }
  TurnBasedGame --> Board
  TurnBasedGame --> WinningStrategy
  Player <|-- HumanPlayer
  Player <|-- AIPlayer
  AIPlayer --> MoveStrategy
  TurnBasedGame --> "*" Player

K. Responsibility Mapping

ClassResponsibilityDoes NOT Handle
BoardGrid state, cell validityWin detection, turn order
WinningStrategy implementationsK-in-a-row detection for any board sizePlacing marks, turn order
Player implementationsProducing one move given the current boardValidating or applying that move
TurnBasedGameLoop skeleton: turn order, validation, win/draw checksBoard-specific win rules (hook, not hardcoded)

L. SOLID Analysis

PrincipleHow It's AppliedNaive Violation
SRPBoard owns grid state; WinningStrategy owns win logic; Player owns move productionOne Game class owning the grid, the win-check, and the turn loop inline
OCPA new AI difficulty is a new MoveStrategy; a new board game reusing the loop is a new TurnBasedGame subclassBranching on a difficulty enum inside one giant getAIMove() method
LSPHumanPlayer and AIPlayer both honestly return a legal move from getMove(); callers never care whichAn AIPlayer.getMove() that occasionally blocks waiting for console input it doesn't actually have
ISPMoveStrategy and WinningStrategy stay single-method interfacesA combined "GameRules" interface mixing win-checking with move validation and scoring
DIPTurnBasedGame depends on the WinningStrategy abstraction, not a concrete 3×3 checkerTurnBasedGame.checkWin() hardcoding 8 coordinate triples inline

M. Design Patterns

PatternProblem It SolvesWhere It AppearsWhen NOT to Use
StrategyAI move selection and win-checking are both algorithms expected to vary/generalizeMoveStrategy, WinningStrategySkip MoveStrategy variety if the round only ever needs "legal random move"
Template MethodThe turn-based loop skeleton is identical across a family of grid games; only specific steps differTurnBasedGame.play()Skip entirely if only one single game will ever exist — then a plain loop in GameController is simpler and this is a real case where Template Method is not needed (contrast with the reasoning in I)

Explicitly avoided: hardcoded win-line enumeration — the most common shortcut for exactly this problem, and precisely what a generic line-scan WinningStrategy replaces.

N. What Is Likely to Change?

BehaviorCurrent DesignAnticipated ChangeProtecting Abstraction
Board size / win length3×3, 3-in-a-rowAny N×N, any K-in-a-row (Gomoku-style)WinningStrategy parameterized by K, scans generically
AI difficultyRandom legal moveMinimax/alpha-beta lookaheadNew MoveStrategy implementation, zero change to AIPlayer
Game familyTic-Tac-Toe onlyConnect Four (gravity-constrained drops), GomokuNew TurnBasedGame subclass reusing the same loop

O. Design Evolution

What stays stable: Board's cell-level API. Generalizing size/win-length/game-family are all additive changes behind existing interfaces.

P. API / Interface Design

interface WinningStrategy {
  boolean checkWinner(Board board, Move lastMove);
}

interface MoveStrategy {
  Move decideMove(Board board, Symbol symbol);
}

abstract class TurnBasedGame {
  void play();                       // template method
  protected abstract void setupBoard();
}

class GameController {
  void startGame();
  MoveResult makeMove(String playerId, int row, int col);
}

Ownership/validation notes: Board.isValidMove() checks bounds and occupancy before placeMark() ever mutates state; WinningStrategy.checkWinner() is only ever invoked against the cell that was just played, not the whole board, keeping each check O(K) instead of rescanning everything.

Q. Pseudocode — Critical Logic

LineScanWinningStrategy.checkWinner(board, lastMove):
  symbol = board.get(lastMove.row, lastMove.col)
  for each direction in [horizontal, vertical, diagonal, anti-diagonal]:
    count = 1
    count += countMatching(board, lastMove, direction, symbol)
    count += countMatching(board, lastMove, oppositeOf(direction), symbol)
    if count >= K: return true
  return false

TurnBasedGame.play():
  setupBoard()
  while true:
    player = nextPlayer()
    move = player.getMove(board)
    if not board.isValidMove(move): continue   // human retries; AI never produces one
    board.placeMark(move, player.symbol)
    if winningStrategy.checkWinner(board, move): declareResult(WINNER, player); return
    if board.isFull(): declareResult(DRAW); return

R. End-to-End Use Case

startGame() — 3×3 board, K=3, Player X (human), Player O (AI, RandomMoveStrategy)
  │
  ▼
makeMove(X, 0, 0) → valid → board updated → checkWinner (count=1) → no win → switch turn
  │
  ▼
AIPlayer.getMove() → RandomMoveStrategy picks (1,1) → board updated → checkWinner → no win
  │
  ▼
... turns continue until X completes a diagonal ...
  ▼
checkWinner(board, lastMove) scans diagonal through last move → count=3 → declareResult(WINNER, X)

S. Data Structures

StructureUsed ForWhyAlternative & Trade-off
2D array/grid of SymbolBoard storageO(1) cell access by (row, col)A sparse map works too but adds overhead with no benefit on a dense, small grid
Direction-vector scan around lastMoveWin-checkingOnly checks the K-neighborhood of the just-played cell instead of rescanning the whole board every turnRescanning the entire board each move is simpler to write but wastefully re-derives information the previous checks already had

T. Complexity Analysis

OperationTimeSpace
placeMark() / isValidMove()O(1)O(1)
checkWinner() (incremental, around last move)O(K) per direction, O(1) directionsO(1)
Random AI moveO(N²) worst case scanning for an empty cellO(1)

U. Testing Strategy

V. Interviewer Follow-Ups

Follow-UpTestingDesign ChangeClasses ChangedPattern/Trade-off
"Generalize to any board size and win length."Whether win-checking was ever hardcodedNone if WinningStrategy was already generic — just new constructor parametersNone (validates M)The single most common follow-up for this exact problem
"Add a smarter AI."Whether AI logic was isolated behind a StrategyNew MinimaxMoveStrategyNew class onlyValidates the MoveStrategy boundary
"Now build Connect Four, reusing what you can."Whether the game loop was genuinely reusableNew ConnectFourGame extends TurnBasedGame with a gravity-constrained Board variantNew subclass + board variantValidates the Template Method boundary drawn in M — or reveals it was premature if a lot has to change anyway

W. Concurrency

This problem is turn-based and single-threaded by nature — only one player acts at a time, and there is no shared-resource race analogous to the ones in Parking Lot or Elevator. The one legitimate concern is a networked/remote human player submitting a move for a turn that is no longer theirs (a stale-turn check), which is a validation concern in TurnBasedGame.play(), not a locking concern.

X. Persistence

Not required for a live single-session game. If game history/replays are requested, each move is naturally an append-only log entry (player, cell, timestamp) — a simple repository behind the existing API, no change to Board or TurnBasedGame.

Y. Scaling Boundary

LayerWhat Belongs Here
LLDBoard/win-check/player/game-loop class design and generalization
HLDMatchmaking, real-time multiplayer sync across clients, leaderboards
Explicitly out of scopeNetworked multiplayer infrastructure unless the round pivots there

Z. Failure Scenarios

AA. Common Bad Designs

AB. Design Trade-Offs

DecisionOption AOption BRecommendation
Win-check scopeFull-board rescan every moveIncremental scan around only the last-played cellIncremental — same correctness, strictly less work, and it's the natural implementation once you're scanning directions instead of enumerating lines
Template Method timingBuild TurnBasedGame upfront "in case" of future gamesBuild a plain loop now, extract the Template Method only when a second game is actually requestedOption B — see AA; this is a rare case in this document where premature abstraction is the more likely mistake, not premature concreteness

AC. Interviewer Evaluation

Strong signal: writing the win-check as a direction-scan from the start rather than enumerating lines; cleanly polymorphic Player subclasses; correctly judging not to build Template Method until a second game is actually on the table.

Red flag: hardcoded win-lines that silently break the moment board size is generalized; an isAI flag with branching logic; reflexively building a multi-game framework for a problem that only asked for Tic-Tac-Toe.

AD. Interview Simulation

Interviewer: Design Tic-Tac-Toe.
Candidate: A 3×3 grid, two players with distinct symbols, and a win-check. I'd write the win-check as a generic scan — for the cell just played, count consecutive matches in each of the four line directions — rather than listing the eight specific winning lines, since that generalizes for free.
Interviewer: Good. Now generalize it to any board size and any number-in-a-row to win.
Candidate: That's exactly why I avoided hardcoding the lines — the same scan works unchanged, I'd just parameterize K and the board dimension.
Interviewer: Add a computer player.
Candidate: I'd make Player an abstract type with HumanPlayer and AIPlayer subclasses rather than a flag, and give AIPlayer a swappable MoveStrategy so a smarter AI later is a new class, not a rewrite.
Interviewer: Would you build out a whole reusable game-engine framework here?
Candidate: Not unless a second game is actually requested — right now that would be speculative abstraction. I'd keep the loop simple and only extract a Template Method base class if Connect Four or something similar actually comes up.

AE. Online Solution Comparison

SourceApproachStrengthWeaknessWorth Learning
GeeksforGeeks — Low-Level Design of Tic-Tac-ToeClass-based design with separate Player and Board classes, functional/non-functional requirements laid out explicitly before the class diagramGood requirements-first structure; clean SRP split between Board and game-flow controlWin-check in the public version is board-size-specific rather than presented as a generalized direction-scanThe requirements-first framing is worth adopting broadly, even outside this one problem
AlgoMaster.io — Design Tic-Tac-ToeObject-oriented breakdown emphasizing Player abstraction and board state management as a stepping-stone LLD problemConfirms Player-as-abstraction (not a boolean) as the expected shapePublic outline doesn't emphasize the generalized K-in-a-row scan as the central lessonValidates the Player/Board split; this document's addition is treating win-check generality as the headline signal

AF. Interview Cheat Sheet

ProblemTic-Tac-Toe / Board Game Engine
DifficultyBeginner → Intermediate (Pattern Showcase)
Core requirementsN×N board, K-in-a-row win, human + AI players, draw detection
Key clarificationsFixed 3×3 or generalized? Reusable engine for other games? AI sophistication? Player count?
Core entitiesBoard, Symbol, Player, HumanPlayer, AIPlayer, MoveStrategy, WinningStrategy, TurnBasedGame
Key relationshipsStrategy for AI moves and win-checking; Template Method for the loop, only once a second game is real
Key patternsStrategy (headline), Template Method (conditional, situational)
Most important decisionGeneric direction-scan win-check, never hardcoded win-lines
Likely-changing behaviorBoard size/win length, AI difficulty, game family
Concurrency concernNone beyond stale-turn validation for remote players
Top follow-upsGeneralize board/win-length, smarter AI, build a second game reusing the engine
Common mistakesHardcoded win-lines; isAI boolean instead of Player subclasses; premature Template Method framework for one game
What NOT to over-engineerDon't build a multi-game engine or networked multiplayer unless actually asked

AG. Full Implementation

A complete, compilable reference implementation of every class in the diagram above (composition, inheritance, interface realization, and dependency-injected strategies all preserved per section I), with a runnable demo exercising the primary flow — in Java, C++, JavaScript, and TypeScript.

import java.util.*;

/**
 * Tic-Tac-Toe / Board Game Engine — full class-diagram implementation.
 *
 * TurnBasedGame is a Template Method: play() defines the fixed skeleton (setup -> loop
 * of get move / validate / place / check win-or-draw / switch turn) while setupBoard()
 * and the WinningStrategy are the pluggable hooks. WinningStrategy generically scans
 * K-in-a-row through the last move in every direction, so board size/win-length are
 * just parameters, never hardcoded coordinate lists.
 */
public class p12 {

    // ---------- Symbol (value) ----------
    static final class Symbol {
        final char value;
        Symbol(char value) { this.value = value; }
        @Override public String toString() { return String.valueOf(value); }
        @Override public boolean equals(Object o) { return o instanceof Symbol && ((Symbol) o).value == value; }
        @Override public int hashCode() { return value; }
    }

    // ---------- Move (value) ----------
    static final class Move {
        final int row, col;
        final Symbol symbol;
        Move(int row, int col, Symbol symbol) { this.row = row; this.col = col; this.symbol = symbol; }
    }

    enum MoveResultType { OK, INVALID, WIN, DRAW }
    static final class MoveResult {
        final MoveResultType type;
        final String message;
        MoveResult(MoveResultType type, String message) { this.type = type; this.message = message; }
    }

    // ---------- Board ----------
    /** Owns the grid and cell occupancy. Does NOT decide whether a placement wins. */
    static final class Board {
        final int size;
        private final Symbol[][] grid;

        Board(int size) {
            this.size = size;
            this.grid = new Symbol[size][size];
        }

        boolean isValidMove(int row, int col) {
            return row >= 0 && row < size && col >= 0 && col < size && grid[row][col] == null;
        }

        void placeMark(int row, int col, Symbol symbol) {
            if (!isValidMove(row, col)) throw new IllegalArgumentException("Illegal move at " + row + "," + col);
            grid[row][col] = symbol;
        }

        Symbol get(int row, int col) {
            if (row < 0 || row >= size || col < 0 || col >= size) return null;
            return grid[row][col];
        }

        boolean isFull() {
            for (Symbol[] r : grid) for (Symbol s : r) if (s == null) return false;
            return true;
        }

        void print() {
            for (Symbol[] r : grid) {
                StringBuilder sb = new StringBuilder();
                for (Symbol s : r) sb.append(s == null ? '.' : s.value).append(' ');
                System.out.println(sb.toString().trim());
            }
        }
    }

    // ---------- WinningStrategy (interface, Strategy) ----------
    interface WinningStrategy {
        boolean checkWinner(Board board, Move lastMove);
    }

    /** Generic K-in-a-row check: scans every row/col/diagonal direction through the last move. */
    static final class LineScanWinningStrategy implements WinningStrategy {
        private final int k;
        LineScanWinningStrategy(int k) { this.k = k; }

        @Override
        public boolean checkWinner(Board board, Move lastMove) {
            Symbol symbol = board.get(lastMove.row, lastMove.col);
            if (symbol == null) return false;
            int[][] directions = { {0, 1}, {1, 0}, {1, 1}, {1, -1} }; // horizontal, vertical, diag, anti-diag
            for (int[] dir : directions) {
                int count = 1;
                count += countMatching(board, lastMove, dir[0], dir[1], symbol);
                count += countMatching(board, lastMove, -dir[0], -dir[1], symbol);
                if (count >= k) return true;
            }
            return false;
        }

        private int countMatching(Board board, Move move, int dRow, int dCol, Symbol symbol) {
            int count = 0;
            int r = move.row + dRow, c = move.col + dCol;
            while (symbol.equals(board.get(r, c))) {
                count++;
                r += dRow;
                c += dCol;
            }
            return count;
        }
    }

    // ---------- MoveStrategy (interface, Strategy) ----------
    interface MoveStrategy {
        Move decideMove(Board board, Symbol symbol);
    }

    /** Picks the first available empty cell -- trivially valid, deliberately simple default AI. */
    static final class FirstAvailableMoveStrategy implements MoveStrategy {
        @Override
        public Move decideMove(Board board, Symbol symbol) {
            for (int r = 0; r < board.size; r++) {
                for (int c = 0; c < board.size; c++) {
                    if (board.isValidMove(r, c)) return new Move(r, c, symbol);
                }
            }
            throw new IllegalStateException("No available moves");
        }
    }

    // ---------- Player (abstract) ----------
    /** Common identity for anything that produces a move. How the move is decided is delegated. */
    abstract static class Player {
        final String id;
        final Symbol symbol;
        Player(String id, Symbol symbol) { this.id = id; this.symbol = symbol; }
        abstract Move getMove(Board board);
    }

    /** Returns externally-supplied input as the move. */
    static final class HumanPlayer extends Player {
        private final Deque<int[]> pendingInputs = new ArrayDeque<>();

        HumanPlayer(String id, Symbol symbol) { super(id, symbol); }

        /** Feed the next external move (e.g. from UI/CLI input) before calling getMove(). */
        void supplyMove(int row, int col) { pendingInputs.addLast(new int[]{row, col}); }

        @Override
        Move getMove(Board board) {
            if (pendingInputs.isEmpty()) throw new IllegalStateException("No input supplied for human player " + id);
            int[] rc = pendingInputs.pollFirst();
            return new Move(rc[0], rc[1], symbol);
        }
    }

    /** Delegates move selection to a MoveStrategy -- the algorithm itself is not hardcoded here. */
    static final class AIPlayer extends Player {
        private final MoveStrategy moveStrategy;
        AIPlayer(String id, Symbol symbol, MoveStrategy moveStrategy) {
            super(id, symbol);
            this.moveStrategy = moveStrategy;
        }
        @Override
        Move getMove(Board board) { return moveStrategy.decideMove(board, symbol); }
    }

    // ---------- TurnBasedGame (abstract, Template Method) ----------
    abstract static class TurnBasedGame {
        Board board;
        final List<Player> players;
        final WinningStrategy winningStrategy;
        private int turnIndex = 0;
        private boolean finished = false;

        TurnBasedGame(List<Player> players, WinningStrategy winningStrategy) {
            this.players = players;
            this.winningStrategy = winningStrategy;
        }

        /** Hook: board dimensions/shape -- subclasses plug in their own structural piece. */
        protected abstract void setupBoard();

        protected abstract void declareResult(MoveResultType type, Player player);

        private Player nextPlayer() {
            Player p = players.get(turnIndex % players.size());
            turnIndex++;
            return p;
        }

        /** Template method: the fixed skeleton shared by any turn-based grid game. */
        MoveResult play() {
            setupBoard();
            while (!finished) {
                Player player = players.get(turnIndex % players.size());
                Move move = player.getMove(board);
                if (!board.isValidMove(move.row, move.col)) continue; // human retries; AI never produces one
                board.placeMark(move.row, move.col, player.symbol);
                nextPlayer();
                if (winningStrategy.checkWinner(board, move)) {
                    finished = true;
                    declareResult(MoveResultType.WIN, player);
                    return new MoveResult(MoveResultType.WIN, player.id + " wins");
                }
                if (board.isFull()) {
                    finished = true;
                    declareResult(MoveResultType.DRAW, null);
                    return new MoveResult(MoveResultType.DRAW, "draw");
                }
            }
            return new MoveResult(MoveResultType.OK, "game already finished");
        }

        boolean isFinished() { return finished; }
    }

    /** Concrete N x N, K-in-a-row game (default Tic-Tac-Toe: 3x3, 3-in-a-row). */
    static final class TicTacToeGame extends TurnBasedGame {
        private final int size;

        TicTacToeGame(int size, List<Player> players, WinningStrategy winningStrategy) {
            super(players, winningStrategy);
            this.size = size;
        }

        @Override
        protected void setupBoard() { this.board = new Board(size); }

        @Override
        protected void declareResult(MoveResultType type, Player player) {
            if (type == MoveResultType.WIN) System.out.println("Result: " + player.id + " (" + player.symbol + ") wins!");
            else System.out.println("Result: draw");
            board.print();
        }
    }

    // ---------- GameController (facade) ----------
    static final class GameController {
        private final TurnBasedGame game;
        GameController(TurnBasedGame game) { this.game = game; }

        MoveResult startGame() { return game.play(); }

        MoveResult makeMove(String playerId, int row, int col) {
            // For human players, external input is supplied before play()/loop consumes it.
            for (Player p : game.players) {
                if (p.id.equals(playerId) && p instanceof HumanPlayer) {
                    ((HumanPlayer) p).supplyMove(row, col);
                    return new MoveResult(MoveResultType.OK, "queued");
                }
            }
            return new MoveResult(MoveResultType.INVALID, "no such human player: " + playerId);
        }
    }

    // ---------- Demo ----------
    public static void main(String[] args) {
        Symbol X = new Symbol('X');
        Symbol O = new Symbol('O');

        HumanPlayer human = new HumanPlayer("P1", X);
        AIPlayer ai = new AIPlayer("P2", O, new FirstAvailableMoveStrategy());

        // Pre-supply a scripted sequence of human moves for this non-interactive demo.
        // Column 0 is targeted (rather than row 0) so it never collides with the AI's
        // deterministic "first empty cell" picks, which fill row 0 left-to-right first.
        human.supplyMove(0, 0);
        human.supplyMove(1, 0);
        human.supplyMove(2, 0); // completes left column -> human should win

        TicTacToeGame game = new TicTacToeGame(3, List.of(human, ai), new LineScanWinningStrategy(3));
        GameController controller = new GameController(game);

        MoveResult result = controller.startGame();
        System.out.println("Final: " + result.type + " -- " + result.message);
    }
}