Design Tic Tac Toe Game

Ashish

Ashish Pratap Singh

easy
10 min read

In this chapter, we will explore the low-level design of a tic tac toe game in detail.

Lets start by clarifying the requirements:

1. Clarifying Requirements

Before starting the design, it's important to ask thoughtful questions to uncover hidden assumptions, clarify ambiguities, and define the system's scope more precisely.

Here is an example of how a discussion between the candidate and the interviewer might unfold:

After gathering the details, we can summarize the key system requirements.

1.1 Functional Requirements

  • The game is played on a 3x3 grid.
  • Two players take alternate turns, identified by markers ‘X’ and ‘O’.
  • The game should detect and announce the winner.
  • The game should declare a draw if all cells are filled and no player has won.
  • The game should reject invalid moves and inform the player.
  • The system should maintain a scoreboard across multiple games.
  • Moves can be hardcoded in a driver/demo class to simulate gameplay.

1.2 Non-Functional Requirements

  • The design should follow object-oriented principles with clear responsibilities and separation of concerns.
  • The system should be modular and extensible to support future features like larger boards, AI opponent, move history, etc.
  • The game logic should be testable and easy to maintain.
  • The system should provide clear console output that reflects the current state of the game board.

After the requirements are clear, the next step is to identify the core entities that we will form the foundation of our design.

2. Identifying Core Entities

Core entities are the fundamental building blocks of our system. We identify them by analyzing the functional requirements and highlighting the key nouns and responsibilities that naturally map to object-oriented abstractions such as classes, enums, or interfaces.

Let’s walk through the functional requirements and extract the relevant entities:

1. The game is played on a 3x3 grid.

This suggests the need for a Board entity that manages the overall state of the grid. The grid itself consists of individual cells, so we also need a Cell entity.

2. Two players take alternate turns, identified by markers ‘X’ and ‘O’.

We need a Player entity to represent each participant in the game. Each player is associated with a unique symbol and may optionally have an identifier such as a name or number.

The player symbols are constant and limited to X, O, or EMPTY, which can be modeled using an enum Symbol.

3. The game processes moves and determines game outcomes.

This behavior suggests a Game entity that encapsulates the game loop. This class will coordinate the board and players, process moves, switch turns, and determine whether the game is still in progress, has been won, or ended in a draw.

To represent the current state of the game, such as whether it is still ongoing, has ended in a draw, or has a winner, we can use an enum GameStatus.

4. The system should maintain a scoreboard across multiple games.

To support multiple games and a persistent scoreboard, we introduce a central controller class TicTacToeSystem . It manages game sessions and delegates gameplay to the appropriate Game instance.

For tracking player performance across games, we need a Scoreboard entity that maintains win/draw statistics.

These core entities define the key abstractions of the game and will guide the structure of our low-level design and class diagrams.

3. Designing Classes and Relationships

Once the core entities have been identified, the next step is to design the system's class structure. This involves defining the attributes and behaviors of each class, establishing relationships among them, applying relevant object-oriented design patterns, and visualizing the overall architecture using a class diagram.

3.1 Class Definitions

We begin by defining the classes and enums, starting with simple data-holding components and progressing toward classes that encapsulate the game’s core logic.

Enums

Enums (short for enumerations) are a special data type that represents a fixed set of named constants. They are ideal when a variable should only take one of a predefined set of values. Enums enhance type safety, improve code readability, and reduce the risk of invalid values.

Here are the enums we can have in our system:

Tic Tac Toe Enums

Symbol

Represents the values that a cell on the board can hold. Using an enum provides type safety and improves readability.

  • Values: XOEMPTY

GameStatus

Defines the possible states of the game. This helps in managing game flow and determining the outcome.

  • Values: IN_PROGRESSWINNER_XWINNER_ODRAW

Data Classes

Data classes are classes primarily used to hold data rather than encapsulate complex behavior or logic. Their main purpose is to store and transfer data in a clean and structured way. They represent entities like players and board cells in our design.

Player

Represents a player participating in the game.

Player
Attributes
  • name: String – The player’s name (e.g., "Player 1")
  • symbol: Symbol – The marker assigned to the player (X or O)
Methods
  • Player(String name, Symbol symbol) – Constructor to initialize player
  • getName() – Returns the player’s name
  • getSymbol() – Returns the player’s symbol

Cell

Represents a single square on the board.

Cell
Attributes
  • symbol: Symbol – Current value of the cell
Methods
  • Cell() – Initializes the cell with Symbol.EMPTY
  • getSymbol() – Returns the current symbol in the cell
  • setSymbol(Symbol symbol) – Updates the cell’s symbol

Core Classes

Core classes are central to the system and contain the primary logic that drives the gameplay. They manage state, enforce rules, and coordinate interactions between components.

Board

Encapsulates the 3x3 grid and handles all board-related operations including its state and the rules for checking win/draw conditions.

Board

Attributes

  • grid: Cell[][] – A 2D array representing the board
  • size: int – The board dimension (default is 3)

Methods

  • Board(int size) – Constructor to initialize the board with empty cells.
  • placeSymbol(int row, int col, Symbol symbol) – Places a symbol on the specified cell.
  • isCellEmpty(int row, int col) – Checks whether a cell is available
  • isFull() – Checks if all cells are filled, a condition for a draw.
  • checkWinner(int row, int col, Symbol symbol) – Checks if a winning condition is met based on the last move
  • printBoard() – Displays the current state of the board

Game

The orchestrator that brings all components together and manages gameplay.

Game

Attributes

  • board: Board – The game board instance.
  • players: Player[] – Array containing the two players
  • currentPlayer: Player – The player whose turn it is
  • status: GameStatus – Current status of the game

Methods

  • Game(Player p1, Player p2, int boardSize) – Constructor to initialize the game state.
  • makeMove(int row, int col) – Validates and applies a move, updates status, and switches turn
  • getGameStatus() – Returns the current game status
  • getWinner() – Returns the winning player, if any
  • printBoard() – Delegates to Board.printBoard() for display

GameDriver

Serves as the entry point to simulate gameplay using a predefined sequence of moves.

Responsibility:

  • Instantiates the Game and other components
  • Calls makeMove() to simulate a full game session
  • Displays the final result

3.2 Class Relationships

The relationships define how our classes interact. We use standard object-oriented relationships to create a well-structured system.

Composition ("has-a")

  • Board --* Cell: A Board is composed of multiple Cell objects. The Cells' lifecycle is managed by the Board; they are created when the Board is initialized and do not exist independently.
  • Game --* Board: Each Game instance has one Board. The Board is created and owned by the Game.

Association ("uses-a")

This represents a weaker relationship where one class uses another.

  • Game --> WinningStrategy: A Game uses a list of WinningStrategy objects to check for a win condition.
  • Game --> GameState: A Game holds a reference to a GameState object and delegates move handling to it. The GameState object can be changed during the game's lifecycle.
  • WinningStrategy --> Board: The checkWinner method in a WinningStrategy implementation takes a Board object as an argument to perform its check.
  • Scoreboard --> Game: The update method in Scoreboard takes a Game object as an argument to inspect its final state and identify the winner.

Implementation / Inheritance ("is-a"):

  • Game --|> GameSubject: Game is a subject that can be observed.
  • Scoreboard --|> GameObserver: Scoreboard is an observer that listens to Game events.
  • RowWinningStrategy, ColumnWinningStrategy, DiagonalWinningStrategy --|> WinningStrategy: These are concrete implementations of the winning strategy contract.
  • InProgressState, WinnerState, DrawState --|> GameState: These are concrete implementations of the game state contract.

3.3 Key Design Patterns

Strategy Pattern

Winning Strategy (for rule modularity)

The WinningStrategy interface and its concrete implementations (RowWinningStrategy, etc.) encapsulate different win-checking algorithms.

WinningStrategy

A player can win in three ways: completing a row, column, or diagonal. Rather than hardcoding all win conditions in one place, we can create a WinningStrategy interface and implement the conditions separately.

  • RowWinningStrategy: Checks if all cells in the current row are filled with the same symbol
  • ColumnWinningStrategy: Checks for column wins
  • DiagonalWinningStrategy: Checks both diagonals for a winning condition

State Pattern

The GameState interface and its implementations (InProgressState, WinnerState, DrawState) allow the Game object to alter its behavior when its internal state changes.

GameStat

This pattern cleanly separates the logic for handling a move when the game is in progress versus when it is already over, avoiding complex conditional statements within the Game class.

Observer Pattern

The Game (Subject) and Scoreboard (Observer) use this pattern to decouple score-keeping from the core game logic.

GameObserver

The Game notifies the Scoreboard only when it enters a finished state, allowing the Scoreboard to update scores without the Game needing to know about the scoreboard's existence.

Facade Pattern (Implicit)

The TicTacToeSystem acts as a facade, providing a simplified, high-level interface (createGame, makeMove) to the client. It hides the underlying complexity of instantiating Game objects, managing the Board, wiring up observers like the Scoreboard, and handling game state transitions.

Singleton Pattern

The TicTacToeSystem class is implemented as a singleton to ensure a single, globally accessible entry point to the system. This is particularly useful for managing a central Scoreboard that persists across multiple games.

3.4 Full Class Diagram

Tic Tac Toe Class Diagram

4. Code Implementation

4.1 Game Status and Symbols

GameStatus Enum

1class GameStatus(Enum):
2    IN_PROGRESS = "IN_PROGRESS"
3    WINNER_X = "WINNER_X"
4    WINNER_O = "WINNER_O"
5    DRAW = "DRAW"

Represents the state of the game at any point. Helps in determining if further moves are allowed or if the game is already concluded.

Symbol Enum

1class Symbol(Enum):
2    X = 'X'
3    O = 'O'
4    EMPTY = '_'
5    
6    def get_char(self):
7        return self.value

Represents each player's marker as well as empty cells. This abstraction allows consistent symbol management across the game board.

4.2 Custom Exception

1class InvalidMoveException(Exception):
2    def __init__(self, message):
3        super().__init__(message)

Custom exception used to indicate illegal moves (e.g., out-of-bounds or occupied cells), helping to keep the game logic clean and robust.

4.3 Core Entities

Player

1class Player:
2    def __init__(self, name: str, symbol: Symbol):
3        self.name = name
4        self.symbol = symbol
5    
6    def get_name(self):
7        return self.name
8    
9    def get_symbol(self):
10        return self.symbol

Encapsulates a player's identity and their assigned symbol (X or O).

Cell

1class Cell:
2    def __init__(self):
3        self.symbol = Symbol.EMPTY
4    
5    def get_symbol(self):
6        return self.symbol
7    
8    def set_symbol(self, symbol: Symbol):
9        self.symbol = symbol

Each cell on the board holds a symbol. Initially empty, it gets updated when a player makes a valid move.

Board

1class Board:
2    def __init__(self, size: int):
3        self.size = size
4        self.moves_count = 0
5        self.board = []
6        self.initialize_board()
7    
8    def initialize_board(self):
9        for row in range(self.size):
10            board_row = []
11            for col in range(self.size):
12                board_row.append(Cell())
13            self.board.append(board_row)
14    
15    def place_symbol(self, row: int, col: int, symbol: Symbol) -> bool:
16        if row < 0 or row >= self.size or col < 0 or col >= self.size:
17            raise InvalidMoveException("Invalid position: out of bounds.")
18        if self.board[row][col].get_symbol() != Symbol.EMPTY:
19            raise InvalidMoveException("Invalid position: cell is already occupied.")
20        
21        self.board[row][col].set_symbol(symbol)
22        self.moves_count += 1
23        return True
24    
25    def get_cell(self, row: int, col: int) -> Optional[Cell]:
26        if row < 0 or row >= self.size or col < 0 or col >= self.size:
27            return None
28        return self.board[row][col]
29    
30    def is_full(self) -> bool:
31        return self.moves_count == self.size * self.size
32    
33    def print_board(self):
34        print("-------------")
35        for i in range(self.size):
36            print("| ", end="")
37            for j in range(self.size):
38                symbol = self.board[i][j].get_symbol()
39                print(f"{symbol.get_char()} | ", end="")
40            print("\n-------------")
41    
42    def get_size(self):
43        return self.size

Manages the 2D grid of the game. Handles move placement, board initialization, checking if it's full, and rendering the board.

4.4 Winning Strategy Pattern

Interface and Implementations

1class WinningStrategy(ABC):
2    @abstractmethod
3    def check_winner(self, board: Board, player: Player) -> bool:
4        pass
5
6class RowWinningStrategy(WinningStrategy):
7    def check_winner(self, board: Board, player: Player) -> bool:
8        for row in range(board.get_size()):
9            row_win = True
10            for col in range(board.get_size()):
11                if board.get_cell(row, col).get_symbol() != player.get_symbol():
12                    row_win = False
13                    break
14            if row_win:
15                return True
16        return False
17
18class ColumnWinningStrategy(WinningStrategy):
19    def check_winner(self, board: Board, player: Player) -> bool:
20        for col in range(board.get_size()):
21            col_win = True
22            for row in range(board.get_size()):
23                if board.get_cell(row, col).get_symbol() != player.get_symbol():
24                    col_win = False
25                    break
26            if col_win:
27                return True
28        return False
29
30class DiagonalWinningStrategy(WinningStrategy):
31    def check_winner(self, board: Board, player: Player) -> bool:
32        # Main diagonal
33        main_diag_win = True
34        for i in range(board.get_size()):
35            if board.get_cell(i, i).get_symbol() != player.get_symbol():
36                main_diag_win = False
37                break
38        if main_diag_win:
39            return True
40        
41        # Anti-diagonal
42        anti_diag_win = True
43        for i in range(board.get_size()):
44            if board.get_cell(i, board.get_size() - 1 - i).get_symbol() != player.get_symbol():
45                anti_diag_win = False
46                break
47        return anti_diag_win

Uses the Strategy design pattern to encapsulate different ways of checking win conditions. Allows the game to remain extensible and clean.

  • WinningStrategy Interface: This defines a common contract for any algorithm that checks for a win.
  • Concrete Strategies: RowWinningStrategy, ColumnWinningStrategy, and DiagonalWinningStrategy are concrete implementations. The Game class will hold a list of these strategies and iterate through them after each move.

4.5 Observer Pattern for Score Tracking

To allow for features like a scoreboard without tightly coupling it to the game logic, we use the Observer Pattern. The Game acts as the "Subject" and notifies its "Observers" (like a Scoreboard) when its state changes.

GameObserver and GameSubject

1class GameObserver(ABC):
2    @abstractmethod
3    def update(self, game):
4        pass
1class GameSubject(ABC):
2    def __init__(self):
3        self.observers = []
4    
5    def add_observer(self, observer: GameObserver):
6        self.observers.append(observer)
7    
8    def remove_observer(self, observer: GameObserver):
9        self.observers.remove(observer)
10    
11    def notify_observers(self):
12        for observer in self.observers:
13            observer.update(self)

Decouples the core game logic from side-effects like updating scores. Scoreboard subscribes to the game lifecycle through these interfaces.

Scoreboard

1class Scoreboard(GameObserver):
2    def __init__(self):
3        self.scores = {}
4    
5    def update(self, game):
6        # The scoreboard only cares about finished games with a winner
7        if game.get_winner() is not None:
8            winner_name = game.get_winner().get_name()
9            self.scores[winner_name] = self.scores.get(winner_name, 0) + 1
10            print(f"[Scoreboard] {winner_name} wins! Their new score is {self.scores[winner_name]}.")
11    
12    def print_scores(self):
13        print("\n--- Overall Scoreboard ---")
14        if not self.scores:
15            print("No games with a winner have been played yet.")
16            return
17        
18        for player_name, score in self.scores.items():
19            print(f"Player: {player_name:<10} | Wins: {score}")
20        print("--------------------------\n")

Keeps a running tally of player wins. Gets notified only when a game concludes with a winner. Its update method is called whenever the Game (subject) decides to notify its observers. It inspects the Game's final state to update the scores.

4.6 Game State Pattern

GameState and Concrete States

1class GameState(ABC):
2    @abstractmethod
3    def handle_move(self, game, player: Player, row: int, col: int):
4        pass
5
6class InProgressState(GameState):
7    def handle_move(self, game, player: Player, row: int, col: int):
8        if game.get_current_player() != player:
9            raise InvalidMoveException("Not your turn!")
10        
11        # Place the piece on the board
12        game.get_board().place_symbol(row, col, player.get_symbol())
13        
14        # Check for a winner or a draw
15        if game.check_winner(player):
16            game.set_winner(player)
17            game.set_status(GameStatus.WINNER_X if player.get_symbol() == Symbol.X else GameStatus.WINNER_O)
18            game.set_state(WinnerState())
19        elif game.get_board().is_full():
20            game.set_status(GameStatus.DRAW)
21            game.set_state(DrawState())
22        else:
23            # If the game is still in progress, switch players
24            game.switch_player()
25
26
27class DrawState(GameState):
28    def handle_move(self, game, player: Player, row: int, col: int):
29        raise InvalidMoveException("Game is already over. It was a draw.")
30
31
32class WinnerState(GameState):
33    def handle_move(self, game, player: Player, row: int, col: int):
34        raise InvalidMoveException(f"Game is already over. {game.get_winner().get_name()} has won.")

Applies the State pattern to delegate move handling logic based on the current state of the game (in-progress, won, or draw).

GameState Interface: Defines the common action that can be performed in any state, in this case, handleMove.

Concrete States: InProgressState, WinnerState, and DrawState each implement the handleMove method differently.

  • InProgressState contains all the logic for a standard move.
  • WinnerState and DrawState prevent any further moves by throwing an exception.

4.7 Core Game Engine

1class Game(GameSubject):
2    def __init__(self, player1: Player, player2: Player):
3        super().__init__()
4        self.board = Board(3)
5        self.player1 = player1
6        self.player2 = player2
7        self.current_player = player1  # Player 1 starts
8        self.winner = None
9        self.status = GameStatus.IN_PROGRESS
10        self.state = InProgressState()
11        self.winning_strategies = [
12            RowWinningStrategy(),
13            ColumnWinningStrategy(),
14            DiagonalWinningStrategy()
15        ]
16    
17    def make_move(self, player: Player, row: int, col: int):
18        self.state.handle_move(self, player, row, col)
19    
20    def check_winner(self, player: Player) -> bool:
21        for strategy in self.winning_strategies:
22            if strategy.check_winner(self.board, player):
23                return True
24        return False
25    
26    def switch_player(self):
27        self.current_player = self.player2 if self.current_player == self.player1 else self.player1
28    
29    def get_board(self):
30        return self.board
31    
32    def get_current_player(self):
33        return self.current_player
34    
35    def get_winner(self):
36        return self.winner
37    
38    def set_winner(self, winner: Player):
39        self.winner = winner
40    
41    def get_status(self):
42        return self.status
43    
44    def set_state(self, state: GameState):
45        self.state = state
46    
47    def set_status(self, status: GameStatus):
48        self.status = status
49        # Notify observers when the status changes to a finished state
50        if status != GameStatus.IN_PROGRESS:
51            self.notify_observers()

This is the core engine of a single match. It orchestrates the interactions between the Board, Players, WinningStrategy list, and the current GameState. It delegates the complex logic to the appropriate components and notifies observers when the game ends.

4.8 System Layer and Demo

TicTacToeSystem

1class TicTacToeSystem:
2    _instance = None
3    _lock = threading.Lock()
4    
5    def __new__(cls):
6        if cls._instance is None:
7            with cls._lock:
8                if cls._instance is None:
9                    cls._instance = super().__new__(cls)
10        return cls._instance
11    
12    def __init__(self):
13        if not hasattr(self, 'initialized'):
14            self.game = None
15            self.scoreboard = Scoreboard()  # The system now manages a scoreboard
16            self.initialized = True
17    
18    @classmethod
19    def get_instance(cls):
20        return cls()
21    
22    def create_game(self, player1: Player, player2: Player):
23        self.game = Game(player1, player2)
24        # Register the scoreboard as an observer for this new game
25        self.game.add_observer(self.scoreboard)
26        
27        print(f"Game started between {player1.get_name()} (X) and {player2.get_name()} (O).")
28    
29    def make_move(self, player: Player, row: int, col: int):
30        if self.game is None:
31            print("No game in progress. Please create a game first.")
32            return
33        
34        try:
35            print(f"{player.get_name()} plays at ({row}, {col})")
36            self.game.make_move(player, row, col)
37            self.print_board()
38            print(f"Game Status: {self.game.get_status().value}")
39            if self.game.get_winner() is not None:
40                print(f"Winner: {self.game.get_winner().get_name()}")
41        except InvalidMoveException as e:
42            print(f"Error: {e}")
43    
44    def print_board(self):
45        self.game.get_board().print_board()
46    
47    def print_score_board(self):
48        self.scoreboard.print_scores()

Implements Singleton pattern to centralize system operations like creating games and processing moves. Handles I/O and delegates logic to the Game class. Ensures there is only one instance of the system, which is useful for managing a single, system-wide Scoreboard.

It also acts as facade providing a simplified, high-level interface (createGame, makeMove) to the client, hiding the complexity of creating and managing Game objects and their observers.

TicTacToeDemo

Finally, the TicTacToeDemo class serves as the entry point and demonstrates how a client would interact with our system.

1class TicTacToeDemo:
2    @staticmethod
3    def main():
4        system = TicTacToeSystem.get_instance()
5        
6        alice = Player("Alice", Symbol.X)
7        bob = Player("Bob", Symbol.O)
8        
9        # --- GAME 1: Alice wins ---
10        print("--- GAME 1: Alice (X) vs. Bob (O) ---")
11        system.create_game(alice, bob)
12        system.print_board()
13        
14        system.make_move(alice, 0, 0)
15        system.make_move(bob, 1, 0)
16        system.make_move(alice, 0, 1)
17        system.make_move(bob, 1, 1)
18        system.make_move(alice, 0, 2)  # Alice wins, scoreboard is notified
19        print("----------------------------------------\n")
20        
21        # --- GAME 2: Bob wins ---
22        print("--- GAME 2: Alice (X) vs. Bob (O) ---")
23        system.create_game(alice, bob)  # A new game instance
24        system.print_board()
25        
26        system.make_move(alice, 0, 0)
27        system.make_move(bob, 1, 0)
28        system.make_move(alice, 0, 1)
29        system.make_move(bob, 1, 1)
30        system.make_move(alice, 2, 2)
31        system.make_move(bob, 1, 2)  # Bob wins, scoreboard is notified
32        print("----------------------------------------\n")
33        
34        # --- GAME 3: A Draw ---
35        print("--- GAME 3: Alice (X) vs. Bob (O) - Draw ---")
36        system.create_game(alice, bob)
37        system.print_board()
38        
39        system.make_move(alice, 0, 0)
40        system.make_move(bob, 0, 1)
41        system.make_move(alice, 0, 2)
42        system.make_move(bob, 1, 1)
43        system.make_move(alice, 1, 0)
44        system.make_move(bob, 1, 2)
45        system.make_move(alice, 2, 1)
46        system.make_move(bob, 2, 0)
47        system.make_move(alice, 2, 2)  # Draw, scoreboard is not notified of a winner
48        print("----------------------------------------\n")
49        
50        # --- Final Scoreboard ---
51        # We get the scoreboard from the system and print its final state
52        system.print_score_board()
53
54
55if __name__ == "__main__":
56    TicTacToeDemo.main()

This class simulates a user interacting with the TicTacToeSystem. It creates players, starts new games, and makes moves. It showcases the system's ability to handle multiple consecutive games and correctly track scores via the decoupled Scoreboard.

5. Run and Test

Languages
Java
C#
Python
C++
Files20
core
entities
enums
exceptions
observer
states
strategy
tic_tac_toe_demo.py
main
tic_tac_toe_system.py
tic_tac_toe_demo.py
Output

6. Quiz

Design Tic Tac Toe Quiz

1 / 21
Multiple Choice

Which entity is primarily responsible for managing the state of the Tic Tac Toe grid?

How helpful was this article?

Comments (1)


0/2000
Sort by
The Cap20 days ago

A lot of design patterns are added without any intention i believe, do we really need to maintain game state if we are maintaing gameStatus in enum

Copilot extension content script