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: "For now, let's keep the core design focused on a single game. Tracking a scoreboard across multiple games is a good extension to discuss afterward."
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 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.
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, and Game orchestrates gameplay and win detection.
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.
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.
Notice that all terminal states are one-way. There's no transition from DRAW back to IN_PROGRESS, and no transition from WINNER_X to WINNER_O. Once a game ends, it stays ended.
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.
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 checks for a winner after each move.
Now that we've designed our classes and relationships, let's bring this to life with code.
Before reading the full implementation, try building this yourself. Below are template stubs for the classes from the design. The enums, data classes, and the demo are already written for you. The job is to fill in the core logic where the TODO comments appear.
Your task: Implement every TODO using the design from the previous sections. When you are done, run the demo. A correct 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.
SymbolEach 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.
GameStatusFour 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.
InvalidMoveExceptionWe'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.
PlayerNotice 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.
CellUnlike 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).
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 the players and the board. 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 makeMove flow:
Win check: The checkWin method checks the row and column of the last move, then the two diagonals when the move lies on them. The moment one of those lines is completely filled with the current player's symbol, we have a winner.
The following diagram illustrates what happens when a player makes a move:
A console game where Alice and Bob take turns typing runs on a single thread, so thread safety does not matter. A web version is different: two players in separate browsers send requests to the same server, each request runs on its own thread, and both threads share one Game object. Now concurrent calls to makeMove() can corrupt the game.
Suppose it is Alice's turn (currentPlayerIndex = 0). Note that makeMove(row, col) takes only a cell, not a player: it stamps the move with players[currentPlayerIndex] and trusts that whoever calls is the current player. The web server simply turns each click into a makeMove call, it does not pass along who clicked.
Now Alice clicks (0, 0) and Bob clicks (1, 1) at nearly the same moment, so two threads enter makeMove together. Both read currentPlayerIndex = 0 before either advances it, so both moves are stamped with Alice's symbol. Bob's cell (1, 1) gets an X instead of an O, and the two threads then race to advance the turn counter. The board is corrupt because the read-place-advance steps of the two moves interleaved.
The two threads interleave like this:
Locking makeMove() makes each move atomic. The first thread acquires the lock and runs the whole sequence, place the symbol, check for a win, switch turns, before releasing it. The second thread waits, then reads the updated state and plays correctly. Since validation, placement, and the status update all happen under one lock, a move either completes fully or does not run at all.
One of the best ways to validate a design is to see how it handles change. If adding a feature requires modifying multiple classes, the design has problems. If you can add features by creating new classes without touching existing code, you've achieved the Open/Closed Principle.
Let's walk through three common extension requests and see how our design handles them.
Scenario: "Support 4x4 and 5x5 boards."
Our design already handles this. The Board takes a size parameter, and the win check uses board.getSize() instead of hardcoding 3.
What stays unchanged: Everything. The same Game class works for any board size.
Scenario: "Add a computer player that makes moves automatically."
We introduce a MoveStrategy interface for selecting moves. It controls how a player picks a move, separate from how the Game checks for a win.
A simple random strategy:
A smarter minimax strategy (simplified):
The Game class can check if the current player has a MoveStrategy and auto-play:
What stays unchanged: Board, Cell, the win check.
Scenario: "Track how many games each player has won across a session."
A single Game plays one match and is then finished. To keep a running tally across many games without coupling the Game to any scoring logic, we use the Observer pattern. The Game lets components register as listeners and notifies them when a match ends. A Scoreboard listens and records the winner.
First, define the observer contract and a Scoreboard that implements it:
Then give the Game a way to register observers and notify them when a match reaches a terminal state:
Because the Game only knows about the GameObserver contract, you can add other listeners later, such as a logger or an analytics tracker, the same way and without touching the Game again.
What stays unchanged: Board, Cell, and the core Game logic. The scoreboard plugs in through the observer hook.
21 quizzes