Last Updated: January 22, 2026
Tic Tac Toe is a classic two-player game played on a 3x3 grid. Players take turns marking empty cells with their respective symbols: X or O.
Loading simulation...
The goal is to be the first to place three of your symbols in a row, either horizontally, vertically, or diagonally. At the same time, you must try to prevent your opponent from achieving the same. If all cells are filled and no player wins, the game ends in a draw.
In this chapter, we will explore the low-level design of a tic tac toe game in detail.
Lets start by clarifying the requirements:
Before starting any design, it's important to ask thoughtful questions to uncover hidden assumptions, clarify ambiguities, and define the system's scope.
Here is an example of how a discussion between the candidate and the interviewer might unfold:
Candidate: Should the game support variable board sizes, such as 4x4 or 5x5?
Interviewer: For the purpose of this interview, let’s stick with the standard 3x3 board.
Candidate: Should the game support both player-vs-player and player-vs-computer modes?
Interviewer: Let’s keep it simple and focus only on the player-vs-player mode for now.
Candidate: What should happen if a player tries to make an invalid move, like selecting an already filled cell?
Interviewer: The game should reject the move and inform the player to make another selection.
Candidate: Should the system maintain a scoreboard across multiple games to track player wins?
Interviewer: Yes, tracking the scoreboard across games would be a good addition.
Candidate: How should the user input be handled? Should we take input from the console, or just hardcode a sample game sequence?
Interviewer: To keep things focused on the design, you can hardcode a sample sequence in a demo or main method.
Candidate: Should we track the history of moves to allow features like undo or move replay?
Interviewer: That's an interesting feature to consider, but let’s leave it out for now and focus on the core gameplay logic.
After gathering the details, we can summarize the key system requirements.
After the requirements are clear, the next step is to identify the core entities that we will form the foundation of our design.
How do you go from a list of requirements to actual entities/classes? The key is to look for nouns in the requirements that have distinct attributes or behaviors. Not every noun becomes a class, but this approach gives you a starting point.
Let's walk through our requirements and identify what needs to exist in our system.
The grid is central to everything. We need something to represent it. This gives us our first entity: Board.
But what is a grid made of? Individual squares. Each square can be empty or contain a symbol. This suggests a second entity: Cell. The Board will contain 9 Cells arranged in a 3x3 structure.
Because they have different responsibilities. The Board manages the overall grid structure and operations like "is the board full?" or "place a symbol at position (1,2)". The Cell just holds a single value.
This separation also makes the code cleaner. If we later want to add features like highlighting the winning cells, the Cell class is the natural place for that logic.
We need to represent players. Each player has a name and an assigned symbol. This gives us the Player entity.
What about the symbols themselves? We could use strings ("X", "O") or characters, but that's error-prone. What stops someone from creating a player with symbol "Z"?
Using an enum Symbol with values X, O, and EMPTY gives us type safety. The compiler will catch invalid symbols at compile time rather than runtime.
Something needs to coordinate the gameplay: accept moves, validate them, check for wins, switch turns. This orchestrator is our Game entity.
The game also needs to track its current state. Is it still in progress? Did someone win? Is it a draw?
We could use a boolean isGameOver and a winner field, but that gets messy. What if we need to distinguish between "X won" and "O won"?
An enum GameStatus with values IN_PROGRESS, WINNER_X, WINNER_O, and DRAW captures all possibilities cleanly.
A single Game object handles one game. But our requirements say we need to track scores across multiple games. This suggests two more entities:
Scoreboard: Tracks how many times each player has wonTicTacToeSystem: A central controller that creates games and maintains the shared scoreboardThe TicTacToeSystem acts as a facade. External code doesn't need to know about Game, Board, or Scoreboard directly. It just calls system.createGame() and system.makeMove().
Here's how these entities relate to each other:
We've identified three types of entities:
Enums define fixed sets of values. They provide type safety and make code self-documenting.
Data Classes primarily hold data with minimal behavior. Player and Cell are simple containers.
Core Classes contain the main logic. Board manages the grid, Game orchestrates gameplay, Scoreboard tracks history, and TicTacToeSystem ties everything together.
With our entities identified, let's define their attributes, behaviors, and relationships.
Now that we know what entities we need, let's flesh out their details. For each class, we'll define what data it holds (attributes) and what it can do (methods). Then we'll look at how these classes connect to each other.
While listing class methods, we will skip trivial getters and setters to keep the walkthrough focused on core behaviors
We'll work bottom-up: simple types first, then data containers, then the classes with real logic. This order makes sense because complex classes depend on simpler ones.
Enums define fixed sets of values that provide type safety and make code self-documenting. Using enums prevents invalid states at compile time rather than runtime.
SymbolRepresents the values a cell can contain.
Each enum value maps to a display character for printing the board.
Why not just use characters directly?
Because Symbol.X is self-documenting and type-safe. You can't accidentally pass 'Z' where a symbol is expected. The compiler catches invalid symbols at compile time, not runtime.
GameStatusDefines the possible states of the game. Tracks where we are in the game lifecycle.
Four distinct states cover all possible game outcomes. A game starts as IN_PROGRESS and transitions to exactly one terminal state when it ends.
We use WINNER_X and WINNER_O instead of a generic WINNER with a separate winner field. This makes status checks simpler: if (status == GameStatus.WINNER_X) instead of if (status == GameStatus.WINNER && winner.getSymbol() == Symbol.X).
It also makes the enum self-contained. You can determine the winner from the status alone without needing additional context.
Data classes are simple containers that hold data with minimal behavior. They represent the "nouns" in our system that have attributes but little logic.
PlayerHolds player information.
The Player class is immutable. Once created, a player's name and symbol don't change. This prevents bugs where someone accidentally reassigns a player's symbol mid-game.
CellHolds the current value of a board position.
Unlike Player, Cell is mutable. It starts as EMPTY and gets set to X or O when a player makes a move. The isEmpty() helper method makes calling code more readable: if (cell.isEmpty()) is clearer than if (cell.getSymbol() == Symbol.EMPTY).
Core classes contain the actual game logic. They coordinate between data classes and implement the rules of the game.
BoardEncapsulates the 3x3 grid and handles all board-related operations including its state and the rules for checking win/draw conditions.
The Board doesn't know about players or game rules. It just manages a grid of cells. This separation means we could reuse Board for other grid-based games.
getCell(), which validates bounds first.GameThe orchestrator that brings all components together and manages gameplay.
Game ties everything together. It owns the Board, knows the Players, tracks whose turn it is, and uses WinningStrategies to detect wins. When the game ends, it notifies observers (like the Scoreboard).
ScoreboardTracks wins across multiple games.
The Scoreboard implements GameObserver so it can automatically update when games end.
TicTacToeSystem
This is the public-facing facade.
External code only interacts with TicTacToeSystem. It doesn't need to know about Board, Cell, or WinningStrategy.
How do these classes connect? There are three types of relationships we use.
Composition means one object owns another. When the owner is destroyed, the owned object is destroyed too.
Association means one object uses another, but doesn't own it. Both objects have independent lifecycles.
Implementation means a class fulfills an interface contract.
You might notice some structural patterns emerging in our design. Let's make them explicit and justify why each pattern is appropriate here.
The Problem: A player can win in three distinct ways: completing a row, a column, or a diagonal. If we hardcode all win conditions in a single method, we end up with a long, complex function that's hard to test and modify. Adding a new win condition (like "four corners" in a variant) would require changing existing code.
The Solution: The Strategy pattern encapsulates each win-checking algorithm in its own class. The Game holds a list of WinningStrategy implementations and iterates through them to check for a winner.
Why Strategy Pattern?
We could use a simple switch statement or a series of if-else blocks. However, the Strategy pattern gives us:
We check all strategies on every move rather than optimizing for the last move position. This is simpler and more maintainable.
For a 3x3 board, the performance difference is negligible. If we were building for larger boards, we might optimize by only checking strategies relevant to the last move's position.
The Problem: When a game ends, the Scoreboard needs to update. The naive approach is to have the Game directly call scoreboard.recordWin(). But this couples the Game to the Scoreboard. What if we want to add analytics tracking? Or a replay recorder? Each new listener would require modifying the Game class.
The Solution: The Observer pattern decouples the Game (subject) from its listeners (observers). The Game maintains a list of observers and notifies them when the game ends. Observers implement a common interface.
Why Observer Pattern?
For a single Scoreboard, direct method calls would work fine. We use Observer because:
The Game only notifies observers when it transitions to a terminal state, not on every move. This keeps the observer interface simple and avoids unnecessary updates.
If we needed move-by-move notifications, we could add a separate onMove() method to the observer interface.
The Problem: We need a single, globally accessible entry point to the system that maintains a consistent scoreboard across multiple games.
The Solution: The Singleton pattern ensures only one instance of TicTacToeSystem exists. It provides a global access point via getInstance().
Why Singleton Pattern?
Singleton is often overused, but it's appropriate here because:
Now that we've designed our classes and relationships, let's bring this to life with code.
Before looking at the complete implementation, try building the Tic-Tac-Toe system yourself. Below you'll find template stubs for all the classes we designed. Each stub includes method signatures and // TODO comments where you need to add the implementation logic.
Your task: Implement all the // TODO sections based on the design we discussed. Once complete, run the demo to verify your implementation produces the expected output.
Now let's translate our design into working code. We'll build bottom-up: foundational types first, then data classes, then the classes with real logic. This order matters because each layer depends on the ones below it.
We start with the two enums that other classes depend on.
Each Symbol maps to a display character. This keeps display logic centralized. If we later want to use 'x' instead of 'X', we change it in one place.
Four possible states. The game starts IN_PROGRESS and ends in one of the three terminal states.
Before we write classes that can fail, let's define how they fail. A custom exception makes error handling cleaner than catching generic RuntimeException.
We'll throw this when someone tries to play on an occupied cell, make a move after the game ends, or specify an out-of-bounds position.
These are simple containers. They hold data with minimal logic.
Notice the constructor validation. A player with Symbol.EMPTY makes no sense, so we reject it immediately. This is "fail fast" design. If you create an invalid Player, you find out right away, not three hours later when debugging a weird game state.
Both fields are final. Once you create a Player, their name and symbol never change. Immutability prevents bugs.
Unlike Player, Cell is mutable. It starts empty and gets filled during gameplay. The isEmpty() helper makes calling code more readable: if (cell.isEmpty()) is clearer than if (cell.getSymbol() == Symbol.EMPTY).
Now we define the contracts that our strategy and observer classes will implement.
The interface takes the board, the position of the last move, and the symbol to check. Each implementation decides how to use these parameters. Row strategy only cares about row. Column strategy only cares about col. Diagonal strategy ignores both and checks the whole diagonal.
Simple notification interface. When a game ends, observers get the Game object and can extract whatever information they need (winner, final board state, etc.).
Each strategy checks one way to win. Let's implement all three.
RowWinningStrategy checks if all cells in the row of the last move contain the same symbol.
We iterate through every column in the given row. If any cell doesn't match, return false immediately. No need to check further.
ColumnWinningStrategy works the same way, but iterates through rows instead of columns.
DiagonalWinningStrategy is more complex because there are two diagonals: main (top-left to bottom-right) and anti-diagonal (top-right to bottom-left).
The main diagonal has cells at positions (0,0), (1,1), (2,2). The anti-diagonal has cells at (0,2), (1,1), (2,0). Notice how size - 1 - i gives us the anti-diagonal column index.
Each strategy is independently testable. You can unit test RowWinningStrategy without creating a full Game. Just create a Board, set up a winning row, and verify the strategy returns true.
The Board encapsulates all grid operations. It doesn't know about players, turns, or game rules. It just manages a 2D array of cells.
A few things to note about the Board:
initializeBoard() method runs in the constructor, so you never have a Board with null cells.validatePosition() method is private and called by every public method that takes coordinates. This prevents code duplication.isFull() short-circuits: As soon as we find an empty cell, we return false. No need to scan the entire board.printBoard() is for debugging: In a real application, you'd probably have a separate view layer. But for interviews and testing, a simple print method is useful.This is where everything comes together. The Game coordinates players, board, strategies, and observers. It's the most complex class, but each method has a single responsibility.
Let's break down the key design decisions in the Game class:
Thread Safety: The makeMove method is synchronized. This prevents two threads from making moves simultaneously, which could corrupt the game state. The observer list uses CopyOnWriteArrayList, which allows safe iteration even if observers are added during notification.
The makeMove flow:
Strategy iteration: The checkWin method iterates through all strategies. As soon as one returns true, we have a winner. This is where the Strategy pattern pays off. Adding a new win condition just means adding another strategy to the list.
Observer notification: We only notify observers when the game ends (win or draw). This keeps the observer interface simple. If we needed move-by-move notifications, we could add a separate onMove() method to GameObserver.
The Scoreboard demonstrates the Observer pattern in action. It listens for game end events and automatically updates scores.
The Scoreboard is decoupled from the Game. It doesn't know when games start or how moves work. It just receives a notification when a game ends, extracts the winner, and updates its internal map.
Note the use of ConcurrentHashMap and the merge() method. The merge() call atomically gets the current value (or 0 if absent), adds 1, and stores the result. This is thread-safe without explicit synchronization.
The system class is the public entry point. External code only needs to know about this class. It hides the complexity of Game, Board, and Scoreboard behind a simple interface.
instance == null twice. The first check avoids the cost of synchronization when the instance already exists. The second check (inside the synchronized block) handles the race condition where two threads both pass the first check.volatile keyword: This ensures that when one thread creates the instance, other threads immediately see the fully constructed object. Without volatile, threads might see a partially constructed instance due to instruction reordering.resetInstance() for testing: Singletons are notoriously hard to test because the instance persists across tests. This method lets us reset the singleton between tests. In production, you'd probably remove this or make it package-private.The TicTacToeSystem class simplifies the API. Compare these two approaches:
Without facade:
With facade:
The facade handles object creation, wiring, and lifecycle. Callers don't need to know that games have observers or that scoreboards exist.
Let's see the system in action with a demo that plays three games.
The following diagram illustrates what happens when a player makes a move:
Which entity is primarily responsible for managing the state of the Tic Tac Toe grid?