Build Tic Tac Toe Game: UML + Code | Low-Level Design (LLD)
This comprehensive study note outlines the Low-Level Design (LLD) of a Tic Tac Toe game, covering functional requirements, top-down UML architecture, the observer pattern for notifications, strategy pattern for game rules, and a fully functional C++ implementation.
1. Problem Statement & Functional Requirements
Designing a game like Tic Tac Toe or Snake and Ladder requires identifying core functional and non-functional requirements to ensure the code remains scalable, maintainable, and extensible.
- Scalable Board Size: The traditional Tic Tac Toe game uses a grid. However, the system must support scalable dimensions (, such as , , etc.).
- Extensible Game Rules: While standard rules dictate that a player wins by completing a horizontal, vertical, or diagonal set of 3 matching symbols, the design should accommodate custom rules (e.g., disabling diagonal wins, requiring 4-in-a-row for larger grids).
- In-App Notification Service: When players make moves, or when a win/draw occurs, in-app notifications must be dispatched to inform users in real time.
2. Core Mental Model & Architectural Approach
When architecting game systems, the Top-Down Approach is preferred. Starting from the highest-level orchestrator (Game) down to the smallest components (Cell, Symbol) simplifies system decomposition.
Real-Life Analogy
Mental Hook: Think of the Game class as a Board Game Tournament Coordinator. The Coordinator doesn't personally keep track of how squares are colored; instead, they delegate board management to the Board, move-validation to a Rulebook, player turn order to a Queue, and announcements to a Public Address (PA) System / Notifier.
3. High-Level Class Architecture (UML Breakdown)
The system is broken down into distinct decoupled classes, ensuring adherence to the Single Responsibility Principle (SRP).
4. Entity Breakdown & Responsibilities
Each class handles a specific slice of domain logic:
5. Step-by-Step Flow Walkthrough
The game loop handles player turns sequentially using a Double-Ended Queue (std::deque).
6. Complete Production-Grade C++ Implementation
Below is the integrated, decoupled code representing the design discussed.
#include <iostream>
#include <vector>
#include <string>
#include <deque>
#include <memory>
#algorithm>
using namespace std;
// --- 1. Observer Pattern for In-App Notifications ---
class IObserver {
public:
virtual ~IObserver() = default;
virtual void update(const string& message) = 0;
};
class ConsoleNotifier : public IObserver {
public:
void update(const string& message) override {
cout << "[IN-APP NOTIFICATION]: " << message << endl;
}
};
// --- 2. Symbol Representation ---
class Symbol {
private:
char mark;
public:
Symbol(char m = '_') : mark(m) {}
char getMark() const { return mark; }
};
// --- 3. Board Management ---
class Board {
private:
int size;
vector<vector<Symbol>> grid;
unique_ptr<Symbol> emptySymbol;
public:
Board(int s) : size(s) {
emptySymbol = make_unique<Symbol>('_');
grid = vector<vector<Symbol>>(size, vector<Symbol>(size, *emptySymbol));
}
int getSize() const { return size; }
bool isCellEmpty(int r, int c) const {
if (r < 0 || r >= size || c < 0 || c >= size) return false;
return grid[r][c].getMark() == emptySymbol->getMark();
}
bool placeMark(int r, int c, const Symbol& symbol) {
if (!isCellEmpty(r, c)) return false;
grid[r][c] = symbol;
return true;
}
Symbol getCell(int r, int c) const {
return grid[r][c];
}
void display() const {
cout << "\nCurrent Board:\n";
for (int i = 0; i < size; ++i) {
for (int j = 0; j < size; ++j) {
cout << grid[i][j].getMark() << " ";
}
cout << endl;
}
cout << endl;
}
};
// --- 4. Player Definition ---
class Player {
private:
int id;
string name;
Symbol symbol;
int score;
public:
Player(int id, string name, char sym) : id(id), name(name), symbol(sym), score(0) {}
int getId() const { return id; }
string getName() const { return name; }
Symbol getSymbol() const { return symbol; }
int getScore() const { return score; }
void incrementScore() { score++; }
};
// --- 5. Rule Strategy Pattern ---
class Rule {
public:
virtual ~Rule() = default;
virtual bool isValidMove(const Board& board, int r, int c) = 0;
virtual bool checkWin(const Board& board, const Symbol& sym) = 0;
virtual bool checkDraw(const Board& board) = 0;
};
class StandardRule : public Rule {
public:
bool isValidMove(const Board& board, int r, int c) override {
return board.isCellEmpty(r, c);
}
bool checkWin(const Board& board, const Symbol& sym) override {
int n = board.getSize();
char target = sym.getMark();
// Check Rows & Columns
for (int i = 0; i < n; ++i) {
bool rowWin = true, colWin = true;
for (int j = 0; j < n; ++j) {
if (board.getCell(i, j).getMark() != target) rowWin = false;
if (board.getCell(j, i).getMark() != target) colWin = false;
}
if (rowWin || colWin) return true;
}
// Check Main Diagonal
bool mainDiagWin = true;
for (int i = 0; i < n; ++i) {
if (board.getCell(i, i).getMark() != target) mainDiagWin = false;
}
if (mainDiagWin) return true;
// Check Anti-Diagonal
bool antiDiagWin = true;
for (int i = 0; i < n; ++i) {
if (board.getCell(i, n - 1 - i).getMark() != target) antiDiagWin = false;
}
return antiDiagWin;
}
bool checkDraw(const Board& board) override {
int n = board.getSize();
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
if (board.isCellEmpty(i, j)) return false;
}
}
return true;
}
};
// --- 6. Game Orchestrator ---
class Game {
private:
unique_ptr<Board> board;
deque<shared_ptr<Player>> players;
unique_ptr<Rule> rules;
vector<shared_ptr<IObserver>> observers;
bool gameOver;
public:
Game(int boardSize) {
board = make_unique<Board>(boardSize);
rules = make_unique<StandardRule>();
gameOver = false;
}
void addPlayer(shared_ptr<Player> player) {
players.push_back(player);
}
void addObserver(shared_ptr<IObserver> observer) {
observers.push_back(observer);
}
void notifyObservers(const string& msg) {
for (auto& obs : observers) {
obs->update(msg);
}
}
void play() {
if (players.size() < 2) {
cout << "At least 2 players are required to play!" << endl;
return;
}
notifyObservers("Tic Tac Toe Game Started!");
board->display();
while (!gameOver) {
auto current_player = players.front();
players.pop_front();
int r, c;
cout << current_player->getName() << " (" << current_player->getSymbol().getMark() << "), enter row and column: ";
cin >> r >> c;
if (!rules->isValidMove(*board, r, c)) {
cout << "Invalid move! Try again.\n";
players.push_front(current_player); // Put back turn
continue;
}
board->placeMark(r, c, current_player->getSymbol());
board->display();
notifyObservers(current_player->getName() + " placed mark at (" + to_string(r) + ", " + to_string(c) + ")");
if (rules->checkWin(*board, current_player->getSymbol())) {
cout << current_player->getName() << " WINS the game!\n";
notifyObservers(current_player->getName() + " won the game!");
current_player->incrementScore();
gameOver = true;
break;
}
if (rules->checkDraw(*board)) {
cout << "The game is a DRAW!\n";
notifyObservers("Game ended in a draw!");
gameOver = true;
break;
}
players.push_back(current_player); // Rotate queue
}
}
};
// --- 7. Factory Pattern & Main Execution ---
enum class GameType { STANDARD };
class GameFactory {
public:
static unique_ptr<Game> createGame(GameType type, int boardSize) {
if (type == GameType::STANDARD) {
return make_unique<Game>(boardSize);
}
return nullptr;
}
};
int main() {
int size = 3;
auto game = GameFactory::createGame(GameType::STANDARD, size);
auto notifier = make_shared<ConsoleNotifier>();
game->addObserver(notifier);
auto p1 = make_shared<Player>(1, "Aditya", 'X');
auto p2 = make_shared<Player>(2, "Harshita", 'O');
game->addPlayer(p1);
game->addPlayer(p2);
// Uncomment to run interactive loop:
// game->play();
cout << "Tic Tac Toe LLD System Initialized Successfully." << endl;
return 0;
}
7. Real-World Debugging & Production Pitfalls
- Null Pointer Dereference on Empty Cells: Instead of storing standard
nullptrin grid vectors (which requires constant checks and risksNullPointerExceptionor segmentation faults), the design uses anemptySymbolsentinel object ('_'). - Turn Rotation Management: Using a standard array or list requires manual index tracking (
turn % 2). Utilizing astd::dequehandles rotation cleanly by popping the front player and pushing them to the back upon valid turn completion. - Extending Rule Sets: If a new rule variant is required (e.g., Ultimate Tic Tac Toe or No Diagonal Wins), developers can create a new class implementing the
Ruleinterface without modifying coreGameorBoardclasses, preserving the Open-Closed Principle (OCP).
Summary and Key Takeaways
- Top-Down Decomposition: Designing large interactive systems starts from the high-level coordinator (
Game) down to modular components (Board,Rules,Player). - Single Responsibility Principle (SRP): The
Boardonly manages cell states, theRuleengine evaluates wins/draws, and theGameclass orchestrates transitions. - Strategy Pattern for Rules: Encapsulating game logic behind a
Ruleinterface allows future rule modifications without touching core classes. - Observer Pattern for Notifications: Decoupling real-time messaging into an
IObserverinterface allows flexible notification delivery (Console, In-App UI, or Email). - Double-Ended Queue (
std::deque) for Turns: Facilitates effortless scaling from 2 players to players without complex modulo arithmetic. - Factory Pattern: Centralizes object creation and sets up scalability when introducing multiple game variants.