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.
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#
| Entity | Responsibility |
|---|---|
| Symbol | Enum of X, O, EMPTY — the only values a cell may hold |
| GameStatus | Enum of IN_PROGRESS, WIN, DRAW — tracks overall game state |
| Cell | Wraps a Symbol; knows whether it is empty |
| Player | Value object holding a player's name and assigned symbol |
| Board | Interface: placeMark, isFull, checkWinner, reset, showBoard |
| Game | Interface: playMove, resetGame, getStatus, printBoard, isGameOver |
| WinningStrategy | Strategy interface: checkWinner(Board, Symbol) → boolean |
| HorizontalWinningStrategy | Checks all rows for a three-in-a-row match |
| VerticalWinningStrategy | Checks all columns for a three-in-a-column match |
| DiagonalWinningStrategy | Checks both diagonals for a match |
| GameController | Singleton — validates moves, runs strategies, marks cells |
| TicTacToeBoard | Implements Board; owns the Cell[][] grid; delegates to GameController |
| TicTacToe | Implements Game; manages player list, turn index, and GameStatus |
| TicTacToeGame | Entry 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.
public enum Symbol {
X,
O,
EMPTY;
}public enum GameStatus {
IN_PROGRESS,
DRAW,
WIN
}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.
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;
}
}public class Player {
String name;
Symbol symbol;
public Player(String name, Symbol symbol) {
this.name = name;
this.symbol = symbol;
}
public String getName() {
return name;
}
public Symbol getSymbol() {
return symbol;
}
}Contracts — Board, Game, WinningStrategy#
Three interfaces define what components must provide. High-level code depends only on these — concrete classes are implementation details.
public interface Board {
void placeMark(int x, int y, Symbol symbol);
boolean isFull();
Player checkWinner(Player player1, Player player2);
void reset();
void showBoard();
}public interface Game {
void playMove(Player player, int x, int y);
void resetGame();
GameStatus getStatus();
void printBoard();
boolean isGameOver();
}public interface WinningStrategy {
boolean checkWinner(Board board, Symbol symbol);
}Winning strategies#
Each strategy receives the board and the symbol to check. All three cast to TicTacToeBoard to access getSymbol(row, col) and getSize().
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;
}
}public class VerticalWinningStrategy 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(0, i) == symbol
&& ticTacToeBoard.getSymbol(1, i) == symbol
&& ticTacToeBoard.getSymbol(2, i) == symbol) {
return true;
}
}
return false;
}
}public class DiagonalWinningStrategy implements WinningStrategy {
@Override
public boolean checkWinner(Board board, Symbol symbol) {
if (!(board instanceof TicTacToeBoard ticTacToeBoard)) {
throw new IllegalArgumentException("Invalid board type");
}
// Main diagonal: top-left → bottom-right
if (ticTacToeBoard.getSymbol(0, 0) == symbol
&& ticTacToeBoard.getSymbol(1, 1) == symbol
&& ticTacToeBoard.getSymbol(2, 2) == symbol) {
return true;
}
// Anti-diagonal: top-right → bottom-left
if (ticTacToeBoard.getSymbol(0, 2) == symbol
&& ticTacToeBoard.getSymbol(1, 1) == symbol
&& ticTacToeBoard.getSymbol(2, 0) == 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.
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);
}
}import java.util.List;
public class TicTacToeBoard implements Board {
private static TicTacToeBoard board;
private final Cell[][] grid;
private int movesCount;
private static final int SIZE = 3;
GameController controller;
private TicTacToeBoard(List<WinningStrategy> strategies) {
this.grid = new Cell[3][3];
movesCount = 0;
initializeBoard();
controller = GameController.getInstance(strategies);
}
private void initializeBoard() {
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
grid[i][j] = new Cell();
}
}
}
public static synchronized Board getInstance(List<WinningStrategy> strategies) {
if (board == null) {
board = new TicTacToeBoard(strategies);
}
return board;
}
@Override
public void placeMark(int x, int y, Symbol symbol) {
controller.markCell(x, y, symbol, grid);
showBoard();
}
@Override
public boolean isFull() {
return controller.isBoardFull(this, grid);
}
@Override
public Player checkWinner(Player player1, Player player2) {
if (controller.checkWin(board, player1.getSymbol())) return player1;
if (controller.checkWin(board, player2.getSymbol())) return player2;
return null;
}
@Override
public void reset() {
movesCount = 0;
initializeBoard();
}
@Override
public void showBoard() {
for (int i = 0; i < SIZE; i++) {
for (int j = 0; j < SIZE; j++) {
System.out.print(grid[i][j].getSymbol() == Symbol.EMPTY ? "- " : grid[i][j].getSymbol() + " ");
}
System.out.println();
}
}
public Symbol getSymbol(int x, int y) {
if (x < 0 || x >= SIZE || y < 0 || y >= SIZE) {
throw new IllegalArgumentException("Coordinates out of bounds");
}
return grid[x][y].getSymbol();
}
public int getSize() {
return SIZE;
}
}import java.util.List;
public class TicTacToe implements Game {
Board board;
List<Player> players;
GameStatus gameStatus;
int currentPlayerIdx;
public TicTacToe(Player player1, Player player2) {
List<WinningStrategy> strategies = List.of(
new HorizontalWinningStrategy(),
new VerticalWinningStrategy(),
new DiagonalWinningStrategy()
);
this.board = TicTacToeBoard.getInstance(strategies);
this.players = List.of(player1, player2);
this.gameStatus = GameStatus.IN_PROGRESS;
this.currentPlayerIdx = 0;
}
@Override
public void playMove(Player player, int x, int y) {
if (gameStatus != GameStatus.IN_PROGRESS) {
throw new IllegalStateException("Game is not in progress");
}
if (!players.contains(player)) {
throw new IllegalArgumentException("Player not part of this game");
}
TicTacToeBoard ticTacToeBoard = (TicTacToeBoard) board;
ticTacToeBoard.placeMark(x, y, player.getSymbol());
Player winner = board.checkWinner(players.get(0), players.get(1));
if (winner != null) {
gameStatus = GameStatus.WIN;
System.out.println("Winner: " + winner.getName());
} else if (ticTacToeBoard.isFull()) {
gameStatus = GameStatus.DRAW;
System.out.println("Game is a draw");
}
moveToNextPlayer();
}
@Override
public void resetGame() {
board.reset();
gameStatus = GameStatus.IN_PROGRESS;
currentPlayerIdx = 0;
}
@Override
public GameStatus getStatus() {
return gameStatus;
}
@Override
public void printBoard() {
board.showBoard();
}
@Override
public boolean isGameOver() {
return gameStatus != GameStatus.IN_PROGRESS;
}
private void moveToNextPlayer() {
currentPlayerIdx = (currentPlayerIdx + 1) % players.size();
}
public Player getCurrentPlayer() {
return players.get(currentPlayerIdx);
}
}Entry point — interactive game loop#
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.