Skip to content
intermediatePhase 50 · LLD Practice

Tic Tac Toe

Design a tic-tac-toe game with multiplayer support.

1h 30m
0 problems
Topic Progress0%

Requirements and Scope

Functional Requirements

1. Board:
   - 3x3 grid (generalizable to NxN)
   - Each cell is empty, or holds X, or holds O

2. Players:
   - Two players: X and O
   - X always goes first
   - Players alternate turns

3. Moves:
   - Player selects a cell (row, col)
   - Cell must be empty and game must be in progress
   - After placement, check for win or draw

4. Win Condition:
   - Three of same mark in a row (horizontal, vertical, or diagonal)

5. Draw Condition:
   - Board is full and no player has won

6. Game End:
   - Game stops immediately on win or draw
   - No further moves accepted

Non-Functional Requirements

  • Modularity: Win detection, board, and game logic are separate concerns
  • Testability: Each component independently testable
  • Extensibility: Easy to change board size, add AI, or support online play
  • Performance: Move validation and win check must be O(N) for NxN board

Core Entities

Entity Responsibility
Cell Holds a player mark (X, O, or empty)
Board NxN grid, place pieces, query cell state
Player Symbol (X or O), name
Move Row + column of a placement
MoveValidator Checks if a move is legal
WinChecker Checks rows, cols, diagonals for a winner
Game Orchestrates turns, delegates validation and win checks

Game State Diagram

┌──────────────────────────────────────────────────────┐
│                   Game States                         │
├──────────────────────────────────────────────────────┤
│                                                       │
│   NOT_STARTED ──→ PLAYING ──→ WON ──→ GAME_OVER      │
│                        │                              │
│                        └──→ DRAW ──→ GAME_OVER        │
│                                                       │
└──────────────────────────────────────────────────────┘

Transitions:
  start()        : NOT_STARTED → PLAYING
  makeMove()     : PLAYING → PLAYING (valid move, no winner yet)
  makeMove()     : PLAYING → WON (move creates a winning line)
  makeMove()     : PLAYING → DRAW (move fills last cell, no winner)
  any after END  : throw IllegalStateException

Game Flow

Player makes move
       │
       ▼
┌─────────────┐     invalid     ┌──────────┐
│ Validate    │───────────────→ │ Reject   │
│ Move        │                 │ Move     │
└──────┬──────┘                 └──────────┘
       │ valid
       ▼
┌─────────────┐
│ Place piece │
│ on board    │
└──────┬──────┘
       │
       ▼
┌─────────────┐     yes        ┌──────────┐
│ Check Win?  │──────────────→ │ Set WON  │
└──────┬──────┘                └──────────┘
       │ no
       ▼
┌─────────────┐     yes        ┌──────────┐
│ Check Draw? │──────────────→ │ Set DRAW │
└──────┬──────┘                └──────────┘
       │ no
       ▼
┌─────────────┐
│ Switch      │
│ Player      │
└─────────────┘

Board and Cell Design

Cell

A cell holds one of three states: empty, X, or O. An enum is the cleanest representation.

public enum CellState {
    EMPTY, X, O;
}

Board Class

The board owns the 2D grid and provides methods to place pieces, query cells, and check fullness.

public class Board {
    private final int size;
    private final CellState[][] grid;
    private int emptyCells;

    public Board(int size) {
        this.size = size;
        this.grid = new CellState[size][size];
        this.emptyCells = size * size;
        for (int r = 0; r < size; r++) {
            for (int c = 0; c < size; c++) {
                grid[r][c] = CellState.EMPTY;
            }
        }
    }

    public int getSize() { return size; }

    public CellState getCell(int row, int col) {
        checkBounds(row, col);
        return grid[row][col];
    }

    public void placePiece(int row, int col, CellState state) {
        checkBounds(row, col);
        if (grid[row][col] != CellState.EMPTY) {
            throw new IllegalStateException("Cell already occupied");
        }
        grid[row][col] = state;
        emptyCells--;
    }

    public boolean isFull() {
        return emptyCells == 0;
    }

    public boolean isEmpty(int row, int col) {
        checkBounds(row, col);
        return grid[row][col] == CellState.EMPTY;
    }

    private void checkBounds(int row, int col) {
        if (row < 0 || row >= size || col < 0 || col >= size) {
            throw new IndexOutOfBoundsException(
                "Position (" + row + "," + col + ") out of bounds"
            );
        }
    }
}

Coordinate Record

public record Position(int row, int col) {
    public Position {
        if (row < 0 || col < 0) {
            throw new IllegalArgumentException("Negative coordinates");
        }
    }
}

Board Visual (3x3)

     Col 0   Col 1   Col 2
    ┌───────┬───────┬───────┐
Row │       │       │       │
 0  │(0,0)  │(0,1)  │(0,2)  │
    ├───────┼───────┼───────┤
Row │       │       │       │
 1  │(1,0)  │(1,1)  │(1,2)  │
    ├───────┼───────┼───────┤
Row │       │       │       │
 2  │(2,0)  │(2,1)  │(2,2)  │
    └───────┴───────┴───────┘

Example mid-game:
    ┌───────┬───────┬───────┐
    │   X   │   O   │   X   │
    ├───────┼───────┼───────┤
    │       │   O   │       │
    ├───────┼───────┼───────┤
    │   O   │       │       │
    └───────┴───────┴───────┘

Time and Space Complexity

Operation Time Space
getCell(row, col) O(1) O(1)
placePiece(row, col) O(1) O(1)
isFull() O(1) O(1)
isEmpty(row, col) O(1) O(1)
Board construction O(N²) O(N²)

Win and Draw Detection

Strategy Pattern for Win Checking

Using Strategy pattern allows swapping win-checking algorithms (e.g., 3x3 vs NxN, or custom variants).

public interface WinChecker {
    boolean checkWinner(Board board, CellState lastMoveCell, int row, int col);
}

Standard 3x3 Win Checker

Only the row, column, and two diagonals passing through the last move need checking.

public class StandardWinChecker implements WinChecker {

    @Override
    public boolean checkWinner(Board board, CellState piece, int row, int col) {
        int size = board.getSize();

        // Check row
        boolean winRow = true;
        for (int c = 0; c < size; c++) {
            if (board.getCell(row, c) != piece) {
                winRow = false;
                break;
            }
        }
        if (winRow) return true;

        // Check column
        boolean winCol = true;
        for (int r = 0; r < size; r++) {
            if (board.getCell(r, col) != piece) {
                winCol = false;
                break;
            }
        }
        if (winCol) return true;

        // Check main diagonal (top-left to bottom-right)
        if (row == col) {
            boolean winDiag = true;
            for (int i = 0; i < size; i++) {
                if (board.getCell(i, i) != piece) {
                    winDiag = false;
                    break;
                }
            }
            if (winDiag) return true;
        }

        // Check anti-diagonal (top-right to bottom-left)
        if (row + col == size - 1) {
            boolean winAnti = true;
            for (int i = 0; i < size; i++) {
                if (board.getCell(i, size - 1 - i) != piece) {
                    winAnti = false;
                    break;
                }
            }
            if (winAnti) return true;
        }

        return false;
    }
}

NxN Generalized Win Checker (Connect K)

For an NxN board with a configurable "connect K" requirement:

public class GeneralizedWinChecker implements WinChecker {
    private final int connectK;

    public GeneralizedWinChecker(int connectK) {
        this.connectK = connectK;
    }

    @Override
    public boolean checkWinner(Board board, CellState piece, int row, int col) {
        int[][] directions = {{0,1}, {1,0}, {1,1}, {1,-1}};

        for (int[] dir : directions) {
            int count = 1;
            count += countInDirection(board, piece, row, col, dir[0], dir[1]);
            count += countInDirection(board, piece, row, col, -dir[0], -dir[1]);
            if (count >= connectK) return true;
        }
        return false;
    }

    private int countInDirection(Board board, CellState piece,
                                 int row, int col, int dr, int dc) {
        int count = 0;
        int r = row + dr;
        int c = col + dc;
        while (r >= 0 && r < board.getSize() &&
               c >= 0 && c < board.getSize() &&
               board.getCell(r, c) == piece) {
            count++;
            r += dr;
            c += dc;
        }
        return count;
    }
}

Draw Detection

public class DrawChecker {
    public static boolean isDraw(Board board) {
        return board.isFull();
    }
}

Win Detection Walkthrough

After X plays (0,0):
    ┌───────┬───────┬───────┐
    │   X   │       │       │
    ├───────┼───────┼───────┤
    │       │       │       │  Check: row 0, col 0,
    ├───────┼───────┼───────┤  diag (0,0), anti-diag N/A
    │       │       │       │  Result: NO WIN
    └───────┴───────┴───────┘

After X plays (0,1):
    ┌───────┬───────┬───────┐
    │   X   │   X   │       │
    ├───────┼───────┼───────┤
    │       │       │       │  Check: row 0 has 2 X's
    ├───────┼───────┼───────┤  Result: NO WIN
    │       │       │       │
    └───────┴───────┴───────┘

After X plays (0,2) — WIN:
    ┌───────┬───────┬───────┐
    │   X   │   X   │   X   │  ← Row 0 all X
    ├───────┼───────┼───────┤
    │       │       │       │  Check row 0: X X X ✓
    ├───────┼───────┼───────┤  Result: X WINS!
    │       │       │       │
    └───────┴───────┴───────┘

Complexity

Checker Time Notes
Standard 3x3 O(N) N = board size, check 4 lines max
Generalized (connect K) O(K) Walk at most K cells in each direction
Draw O(1) Single counter check

Game Class and State Management

Game Status Enum

public enum GameStatus {
    NOT_STARTED, PLAYING, WON, DRAW, GAME_OVER
}

Player Class

public class Player {
    private final String name;
    private final CellState symbol;

    public Player(String name, CellState symbol) {
        this.name = name;
        this.symbol = symbol;
    }

    public String getName() { return name; }
    public CellState getSymbol() { return symbol; }
}

Move Class

public record Move(Player player, int row, int col) {
    public Move {
        if (player == null) throw new NullPointerException("Player required");
    }
}

MoveValidator

public class MoveValidator {

    public void validate(Board board, GameStatus status, int row, int col) {
        if (status != GameStatus.PLAYING) {
            throw new IllegalStateException(
                "Game is not in progress. Status: " + status
            );
        }
        if (row < 0 || row >= board.getSize() ||
            col < 0 || col >= board.getSize()) {
            throw new IllegalArgumentException(
                "Position out of bounds: (" + row + "," + col + ")"
            );
        }
        if (!board.isEmpty(row, col)) {
            throw new IllegalStateException(
                "Cell (" + row + "," + col + ") is already occupied"
            );
        }
    }
}

Game Class — The Orchestrator

public class Game {
    private final Board board;
    private final Player playerX;
    private final Player playerO;
    private final MoveValidator validator;
    private final WinChecker winChecker;
    private GameStatus status;
    private Player currentPlayer;
    private final List<Move> moveHistory;
    private Player winner;

    public Game(Player playerX, Player playerO, int boardSize) {
        this(playerX, playerO, boardSize, new StandardWinChecker());
    }

    public Game(Player playerX, Player playerO, int boardSize,
                WinChecker winChecker) {
        this.playerX = playerX;
        this.playerO = playerO;
        this.board = new Board(boardSize);
        this.validator = new MoveValidator();
        this.winChecker = winChecker;
        this.status = GameStatus.NOT_STARTED;
        this.currentPlayer = playerX; // X always goes first
        this.moveHistory = new ArrayList<>();
        this.winner = null;
    }

    public void start() {
        if (status != GameStatus.NOT_STARTED) {
            throw new IllegalStateException("Game already started");
        }
        status = GameStatus.PLAYING;
    }

    public void makeMove(int row, int col) {
        validator.validate(board, status, row, col);

        CellState piece = currentPlayer.getSymbol();
        board.placePiece(row, col, piece);

        Move move = new Move(currentPlayer, row, col);
        moveHistory.add(move);

        if (winChecker.checkWinner(board, piece, row, col)) {
            status = GameStatus.WON;
            winner = currentPlayer;
        } else if (board.isFull()) {
            status = GameStatus.DRAW;
        } else {
            switchPlayer();
        }
    }

    private void switchPlayer() {
        currentPlayer = (currentPlayer == playerX) ? playerO : playerX;
    }

    public void reset() {
        for (int r = 0; r < board.getSize(); r++) {
            for (int c = 0; c < board.getSize(); c++) {
                // Recreate board to clear
            }
        }
        status = GameStatus.NOT_STARTED;
        currentPlayer = playerX;
        moveHistory.clear();
        winner = null;
    }

    // Getters
    public Board getBoard() { return board; }
    public GameStatus getStatus() { return status; }
    public Player getCurrentPlayer() { return currentPlayer; }
    public Player getWinner() { return winner; }
    public List<Move> getMoveHistory() {
        return Collections.unmodifiableList(moveHistory);
    }
    public int getMoveCount() { return moveHistory.size(); }
}

State Transition Table

Current State Action Condition New State
NOT_STARTED start() PLAYING
PLAYING makeMove() Valid + Win WON
PLAYING makeMove() Valid + Full DRAW
PLAYING makeMove() Valid + No win PLAYING
PLAYING makeMove() Invalid move throw
WON makeMove() throw
DRAW makeMove() throw

Observer Pattern for Game Events

public interface GameListener {
    void onMoveMade(Move move, Board board);
    void onWin(Player winner, List<Move> moveHistory);
    void onDraw(List<Move> moveHistory);
}

// Usage in Game class:
private final List<GameListener> listeners = new ArrayList<>();

public void addListener(GameListener listener) {
    listeners.add(listener);
}

private void notifyMove(Move move) {
    listeners.forEach(l -> l.onMoveMade(move, board));
}

private void notifyWin(Player winner) {
    listeners.forEach(l -> l.onWin(winner, moveHistory));
}

private void notifyDraw() {
    listeners.forEach(l -> l.onDraw(moveHistory));
}

Follow-ups and Extensions

Follow-up 1: NxN Board with Connect-K

Swap the WinChecker strategy:

Game game = new Game(playerX, playerO, 5, new GeneralizedWinChecker(4));
// 5x5 board, connect 4 to win

Follow-up 2: Undo Move

Maintain a move stack and revert board state:

public class UndoableGame extends Game {
    private final Deque<Move> undoStack = new ArrayDeque<>();

    @Override
    public void makeMove(int row, int col) {
        super.makeMove(row, col);
        undoStack.push(new Move(getCurrentPlayer(), row, col));
    }

    public Move undo() {
        if (undoStack.isEmpty()) return null;
        Move last = undoStack.pop();
        // Revert board cell to EMPTY
        getBoard().getCell(last.row(), last.col()); // placeholder
        return last;
    }
}

Follow-up 3: AI Opponent (Random / Minimax)

public interface AIStrategy {
    Position getMove(Board board, CellState aiSymbol);
}

public class RandomAI implements AIStrategy {
    private final Random rand = new Random();

    @Override
    public Position getMove(Board board, CellState aiSymbol) {
        List<Position> empty = new ArrayList<>();
        for (int r = 0; r < board.getSize(); r++)
            for (int c = 0; c < board.getSize(); c++)
                if (board.isEmpty(r, c))
                    empty.add(new Position(r, c));
        return empty.get(rand.nextInt(empty.size()));
    }
}

public class MinimaxAI implements AIStrategy {
    @Override
    public Position getMove(Board board, CellState aiSymbol) {
        // Implement minimax with alpha-beta pruning
        // Evaluate board: +10 for AI win, -10 for opponent win, 0 for draw
        return null; // placeholder for full implementation
    }
}

Follow-up 4: Online Multiplayer

┌─────────┐     WebSocket      ┌─────────────┐
│ Player 1 │◄─────────────────►│  Game Server │
└─────────┘                    │  (Game Room) │
                               │              │
┌─────────┐     WebSocket      │  - Board     │
│ Player 2 │◄─────────────────►│  - Players   │
└─────────┘                    │  - State     │
                               └──────────────┘
  • Each player sends moves via WebSocket
  • Server validates and broadcasts state to both players
  • Sync conflicts: server is source of truth

Follow-up 5: Tournament Mode

public class Tournament {
    private final List<Game> games;
    private final Map<Player, Integer> scores;

    public void recordWin(Player winner) {
        scores.merge(winner, 3, Integer::sum); // 3 pts for win
    }

    public void recordDraw(Player p1, Player p2) {
        scores.merge(p1, 1, Integer::sum); // 1 pt each
        scores.merge(p2, 1, Integer::sum);
    }

    public Player getLeader() {
        return scores.entrySet().stream()
            .max(Map.Entry.comparingByValue())
            .map(Map.Entry::getKey)
            .orElse(null);
    }
}

Design Pattern Summary

Pattern Where Used Why
Strategy WinChecker interface Swap 3x3 vs NxN vs custom rules
State GameStatus enum + transitions Enforce legal state changes
Observer GameListener interface Decouple UI from game logic
Factory AI strategy selection Create AI based on difficulty

Time & Space Complexity

Operation Time Space
makeMove() O(N) O(1)
Win check (3x3) O(N) O(1)
Win check (generalized) O(K) O(1)
Move history O(M) total O(M)
Undo O(1) O(M)

N = board size, K = connect length, M = total moves made

Practice Problems

0/3solved
Design Tic Tac Toe System

Design a scalable Tic Tac Toe system. Cover high-level architecture, data model, and API design.

Solution
// Complete system design:
// - Functional + Non-functional requirements
// - Capacity estimation
// - Data model (SQL/NoSQL choice)
// - API endpoints
// - Component architecture
// - Scaling strategy
// - Monitoring & reliability
Tic Tac Toe Scaling

How would you scale Tic Tac Toe to handle 10x the current load? Identify bottlenecks and solutions.

Solution
// Scaling approach:
// 1. Load balancing
// 2. Database sharding/replication
// 3. Cache layer (Redis)
// 4. CDN for static assets
// 5. Async processing (queues)
// 6. Microservices decomposition
Tic Tac Toe Failure Modes

Analyze potential failure modes for Tic Tac Toe and design mitigation strategies.

Solution
// Failure mitigation:
// 1. Redundancy (multi-AZ)
// 2. Circuit breakers
// 3. Retry with backoff
// 4. Dead letter queues
// 5. Health checks
// 6. Graceful degradation

Quiz

1. Which design pattern is best for swapping win-checking algorithms (3x3 vs NxN)?

Question 1 options

2. Why check only the row, column, and diagonals through the last move instead of scanning the entire board?

Question 2 options

3. What is the correct sequence of operations after a valid move is made?

Question 3 options

4. How do you detect a draw in Tic Tac Toe?

Question 4 options

5. What should happen when a player tries to make a move after the game has ended?

Question 5 options

6. In a generalized NxN board with connect-K, how many directions need checking from the last move?

Question 6 options

7. What is the time complexity of making a single move and checking for a win on a 3x3 board?

Question 7 options

Flashcards

Question

What are the 5 game states in Tic Tac Toe?

Answer

NOT_STARTED → PLAYING → WON / DRAW → GAME_OVER. NOT_STARTED transitions to PLAYING on start(). PLAYING transitions to WON if win detected, DRAW if board full, or stays PLAYING otherwise. WON and DRAW are terminal states.

Question

Which lines do you need to check for a win after placing a piece at (row, col)?

Answer

Exactly 4 lines: the row, the column, the main diagonal (if row==col), and the anti-diagonal (if row+col==size-1). No other lines can be completed by a single new piece.

Question

How do you generalize win detection from 3x3 to NxN with connect-K?

Answer

Use 4 direction vectors: (0,1), (1,0), (1,1), (1,-1). From the placed piece, count consecutive matching cells in both directions along each vector. If any count reaches K, it is a win.

Question

What design pattern allows swapping win-checking algorithms?

Answer

Strategy Pattern. Define a WinChecker interface with checkWinner(). Implement StandardWinChecker for 3x3, GeneralizedWinChecker for NxN. Inject the desired strategy into Game at construction time.

Question

How does the Observer pattern help in a Tic Tac Toe game?

Answer

GameListener interface decouples game logic from UI. The Game class notifies listeners on move made, win, and draw events. UI components subscribe without tight coupling to Game internals.

Question

What validation must happen before placing a piece?

Answer

Three checks: (1) Game status must be PLAYING, (2) Row and col must be within bounds, (3) The target cell must be EMPTY. Violations throw IllegalStateException or IllegalArgumentException.

Question

Why is X always the first player?

Answer

By convention, X always goes first in standard Tic Tac Toe. This is enforced in the Game constructor by setting currentPlayer = playerX. It ensures consistency and simplifies state management.

Question

What is the time complexity of win detection for a general NxN board?

Answer

O(N). We check at most 4 lines (row, column, 2 diagonals), each requiring O(N) comparisons. This is optimal since we must inspect N cells in the worst case for each line.

Revision Notes

Key Takeaways

  • 1.Always validate move legality before modifying board state
  • 2.Only check lines passing through the last move — this cuts win-check from O(N²) to O(N)
  • 3.Strategy pattern makes win-checking algorithms swappable without changing Game class
  • 4.GameStatus enum enforces state machine — terminal states reject all moves
  • 5.Observer pattern decouples game logic from presentation layer
  • 6.Design for NxN from the start — it costs almost nothing extra
  • 7.MoveValidator as a separate class keeps Game class focused on orchestration

Interview Tips

  • Start by clarifying board size (3x3 vs NxN) and win condition (connect-3 vs connect-K)
  • Draw the state diagram early — it shows structured thinking
  • Mention Strategy pattern for win detection — interviewers love swappable algorithms
  • Walk through the move flow step by step with a concrete board example
  • Discuss follow-ups proactively: AI opponent, undo, online multiplayer, tournament
  • Always mention validation: bounds check, empty cell check, game-in-progress check
  • Explain why checking only the last move's row/col/diagonals is correct and optimal

Cheat Sheet

Tic Tac Toe - Cheat Sheet

Core Entities

  • Board: NxN grid of CellState, placePiece(), isEmpty(), isFull()
  • CellState: enum EMPTY, X, O
  • Player: name + symbol (X or O)
  • Move: record of (player, row, col)
  • Game: orchestrator holding board, players, status, history

Game States

NOT_STARTED → start() → PLAYING
PLAYING → makeMove() → WON (win detected)
PLAYING → makeMove() → DRAW (board full, no win)
WON/DRAW → GAME_OVER (terminal)

Win Detection (3x3)

Check only 4 lines from last move:

  1. Row: board[row][0..2]
  2. Column: board[0..2][col]
  3. Main diagonal: board[i][i] if row==col
  4. Anti-diagonal: board[i][size-1-i] if row+col==size-1

Win Detection (NxN, connect-K)

4 direction vectors: (0,1), (1,0), (1,1), (1,-1)
Count matching cells in both directions. Win if count ≥ K.

Move Flow

validate(status == PLAYING && bounds && empty)
  → placePiece(row, col, piece)
  → checkWin(piece, row, col)
    → if true: status = WON
  → else checkDraw()
    → if true: status = DRAW
  → else: switchPlayer()

Complexity

Operation Time
placePiece O(1)
winCheck (3x3) O(N)
winCheck (connect-K) O(K)
drawCheck O(1)
makeMove total O(N)

Design Patterns

Pattern Usage
Strategy WinChecker (swap algorithms)
State GameStatus transitions
Observer GameListener for UI events
Factory AI strategy creation