Low Level Design

Design a Tic-Tac-Toe Game

A clean OOP design for the classic two-player grid game — pluggable winning strategies, a Singleton game controller, interface-driven board and game contracts, and a turn-based loop in Java.

August 16, 2026·14 min read

Problem Description#

Design a two-player Tic-Tac-Toe game played on a 3×3 grid where players alternate marking cells until one wins or the board fills.

Tic-Tac-Toe is deceptively rich as an LLD problem. The naive approach — one God class with hard-coded win checks — violates Open/Closed the moment you want to support a different board size or add a diagonal-only variant. A clean design separates the board state, the win-check algorithms, the game loop, and the rule enforcement into distinct, independently changeable components.

This problem exercises the Strategy pattern, the Singleton pattern, interface-driven design (Board, Game, WinningStrategy), and the Single Responsibility principle — all common interview expectations at the medium level.


Clarify Requirements#

Before designing, ask these questions in an interview:

Functional

  • How many players? Fixed at two, or should N-player be supported?
  • Is the board always 3×3, or should the size be configurable?
  • What defines a win — row, column, diagonal, or all three?
  • What happens when a player tries to mark an already-occupied cell?
  • Should the game support reset and replay without recreating objects?
  • Does the game loop live inside the engine, or is it driven externally (CLI, UI)?

Non-functional

  • Should the same GameController instance be reused across games?
  • Do we need thread safety for concurrent move submissions?
  • Should win-check logic be swappable at runtime or just at construction?
  • Is an undo/redo feature in scope?

Final Requirements#

After clarification, here's what we'll build:

  • A 3×3 board managed by TicTacToeBoard, initialized with empty Cell objects
  • Three win conditions checked via pluggable WinningStrategy implementations: horizontal, vertical, and diagonal
  • GameController Singleton validates moves (bounds, occupancy) and delegates win checks to whichever strategies are registered
  • TicTacToe is the game engine: manages turn order, calls playMove(), and updates GameStatus (IN_PROGRESS → WIN or DRAW)
  • Players are identified by a Symbol enum (X or O); each Player holds a name and symbol
  • Move validation rejects out-of-bounds coordinates and warns on occupied cells
  • The game can be reset via resetGame() without recreating players or the board instance

Core Entities#

EntityResponsibility
SymbolEnum of X, O, EMPTY — the only values a cell may hold
GameStatusEnum of IN_PROGRESS, WIN, DRAW — tracks overall game state
CellWraps a Symbol; knows whether it is empty
PlayerValue object holding a player's name and assigned symbol
BoardInterface: placeMark, isFull, checkWinner, reset, showBoard
GameInterface: playMove, resetGame, getStatus, printBoard, isGameOver
WinningStrategyStrategy interface: checkWinner(Board, Symbol) → boolean
HorizontalWinningStrategyChecks all rows for a three-in-a-row match
VerticalWinningStrategyChecks all columns for a three-in-a-column match
DiagonalWinningStrategyChecks both diagonals for a match
GameControllerSingleton — validates moves, runs strategies, marks cells
TicTacToeBoardImplements Board; owns the Cell[][] grid; delegates to GameController
TicTacToeImplements Game; manages player list, turn index, and GameStatus
TicTacToeGameEntry point: creates players, drives the interactive game loop

Patterns Used#

1. Strategy — pluggable win-check algorithms#

WinningStrategy is a single-method interface. GameController holds a List<WinningStrategy> and iterates it on every checkWin() call — returning true as soon as any strategy matches. Adding an anti-diagonal-only mode or a 5-in-a-row variant means writing a new class, not touching GameController.

GameController
 └─ List<WinningStrategy>
      ├─ HorizontalWinningStrategy
      ├─ VerticalWinningStrategy
      └─ DiagonalWinningStrategy

This satisfies Open/Closed: the controller is closed for modification but open for extension through new strategy implementations.

2. Singleton — GameController#

One GameController instance is shared by all board operations in a game session. It is lazily created on first call to getInstance(strategies) and thereafter returns the same object. The constructor is private so no code outside the class can create a second instance.

3. Factory method — TicTacToeBoard.getInstance()#

TicTacToeBoard.getInstance(strategies) controls instantiation — the constructor is private. This mirrors the Factory pattern: callers ask for a board without knowing or caring how it is built, and the same instance is reused across calls within a session.

4. Interface-driven design (Dependency Inversion)#

TicTacToe depends on the Board interface, not TicTacToeBoard directly. Concrete classes (TicTacToeBoard, TicTacToe) implement Board and Game respectively. High-level orchestration code — the game loop — only calls interface methods, leaving room to swap in a 5×5 board or a remote board without changing TicTacToe.


Code#

Enums — game vocabulary#

Symbol and GameStatus are the shared vocabulary every class uses. Keeping them as enums prevents magic strings and makes exhaustive switch expressions possible.

java
public enum Symbol {
    X,
    O,
    EMPTY;
}

Data layer — Cell and Player#

Cell wraps a Symbol and defaults to EMPTY. Player is a simple value object pairing a name with the symbol that player owns for the entire game.

java
public class Cell {
    Symbol symbol;

    Cell() {
        this.symbol = Symbol.EMPTY;
    }

    public void setSymbol(Symbol symbol) {
        this.symbol = symbol;
    }

    public Symbol getSymbol() {
        return symbol;
    }

    public Boolean isEmpty() {
        return this.symbol == Symbol.EMPTY;
    }
}

Contracts — Board, Game, WinningStrategy#

Three interfaces define what components must provide. High-level code depends only on these — concrete classes are implementation details.

java
public interface Board {
    void placeMark(int x, int y, Symbol symbol);
    boolean isFull();
    Player checkWinner(Player player1, Player player2);
    void reset();
    void showBoard();
}

Winning strategies#

Each strategy receives the board and the symbol to check. All three cast to TicTacToeBoard to access getSymbol(row, col) and getSize().

java
public class HorizontalWinningStrategy implements WinningStrategy {

    @Override
    public boolean checkWinner(Board board, Symbol symbol) {
        if (!(board instanceof TicTacToeBoard ticTacToeBoard)) {
            throw new IllegalArgumentException("Invalid board type");
        }
        for (int i = 0; i < ticTacToeBoard.getSize(); i++) {
            if (ticTacToeBoard.getSymbol(i, 0) == symbol
                    && ticTacToeBoard.getSymbol(i, 1) == symbol
                    && ticTacToeBoard.getSymbol(i, 2) == symbol) {
                return true;
            }
        }
        return false;
    }
}

Game engine — Controller, Board, Game#

GameController is the Singleton rule enforcer. TicTacToeBoard owns the grid and delegates everything to the controller. TicTacToe drives turn rotation and status transitions.

java
import java.util.List;

public class GameController {
    private List<WinningStrategy> winnningStrategies;
    private static GameController controller;

    private GameController(List<WinningStrategy> strategies) {
        this.winnningStrategies = strategies;
    }

    public static synchronized GameController getInstance(List<WinningStrategy> strategies) {
        if (strategies == null || strategies.isEmpty()) {
            throw new IllegalArgumentException("Strategies cannot be null or empty");
        }
        if (controller == null) {
            controller = new GameController(strategies);
        }
        return controller;
    }

    boolean isBoardFull(Board board, Cell[][] grid) {
        if (!isValidBoard(board)) {
            throw new IllegalArgumentException("Invalid board type");
        }
        TicTacToeBoard ticTacToeBoard = (TicTacToeBoard) board;
        for (int i = 0; i < ticTacToeBoard.getSize(); i++) {
            for (int j = 0; j < ticTacToeBoard.getSize(); j++) {
                if (grid[i][j].getSymbol() == Symbol.EMPTY) {
                    return false;
                }
            }
        }
        return true;
    }

    boolean checkWin(Board board, Symbol symbol) {
        if (!isValidBoard(board)) {
            throw new IllegalArgumentException("Invalid board type");
        }
        for (WinningStrategy strategy : winnningStrategies) {
            if (strategy.checkWinner(board, symbol)) {
                return true;
            }
        }
        return false;
    }

    void markCell(int x, int y, Symbol symbol, Cell[][] grid) {
        if (!isValidMove(x, y, grid.length)) {
            throw new IllegalArgumentException("Coordinates out of bounds");
        }
        if (grid[x][y].getSymbol() != Symbol.EMPTY) {
            System.out.print("Cell is already used. Please choose another cell.");
        }
        grid[x][y].setSymbol(symbol);
    }

    private boolean isValidMove(int x, int y, int size) {
        return x >= 0 && x < size && y >= 0 && y < size;
    }

    private boolean isValidBoard(Board board) {
        return (board instanceof TicTacToeBoard);
    }
}

Entry point — interactive game loop#

java
import java.util.Scanner;

public class TicTacToeGame {
    public static void main(String[] args) {
        Player player1 = new Player("Player 1", Symbol.X);
        Player player2 = new Player("Player 2", Symbol.O);

        TicTacToe ticTacToeGame = new TicTacToe(player1, player2);

        Scanner scanner = new Scanner(System.in);
        while (true) {
            Player current = ticTacToeGame.getCurrentPlayer();
            System.out.println("Enter coordinates for " + current.getName()
                    + " [" + current.getSymbol() + "] (row col): ");
            ticTacToeGame.playMove(current, scanner.nextInt(), scanner.nextInt());
            if (ticTacToeGame.isGameOver()) {
                break;
            }
        }
        scanner.close();
    }
}

Class Diagram#


Extendible — Follow Ups#

1. Configurable board size (N×N)#

Replace the hard-coded SIZE = 3 constant with a constructor parameter on TicTacToeBoard. Each WinningStrategy should derive win length from ticTacToeBoard.getSize() instead of hard-coding column/row indices. A 5×5 board with 5-in-a-row win condition becomes a one-line config change.

2. AI bot player using Minimax#

Add a BotPlayer extends Player that overrides move selection with the Minimax algorithm. TicTacToe.playMove() already accepts any Player — swap in a BotPlayer for one of the two players and the game loop works unchanged. The Strategy pattern on WinningStrategy can be reused for the bot to evaluate board states.

3. Undo / Redo with move history#

Maintain a Deque<int[]> of past moves inside TicTacToe. An undoMove() method pops the last entry, resets the corresponding cell to EMPTY, rolls back currentPlayerIdx, and resets gameStatus to IN_PROGRESS. This is the Command pattern applied to game moves.

4. Stateless GameController#

Remove the Singleton and pass the strategy list into GameController's constructor directly. TicTacToeBoard would hold a GameController instance created at construction time. This makes GameController easier to unit-test (no static state to reset between tests) and supports multiple simultaneous game sessions without shared mutable state.

5. Observer for game events#

Introduce a GameEventListener interface with callbacks onMovePlayed, onWin, onDraw. TicTacToe holds a list of listeners and notifies them after every state change. A CLI renderer, a GUI board, and a statistics tracker can all listen independently — none need to poll getStatus().

6. Support more than two players#

Replace the fixed player1 / player2 pair in TicTacToeBoard.checkWinner() with a loop over a List<Player>. Add additional Symbol values (TRIANGLE, STAR, …) for extra players. TicTacToe already uses an index-based round-robin, so turn management scales to N players with no changes.