Overview
Tic-Tac-Toe is a great first OOP project in C++ because the entire game fits naturally into two small, focused classes: a `Board` that owns the 3x3 grid and knows how to display itself, validate a move, and detect a win or draw, and a `Game` that owns a `Board` and drives the turn-by-turn flow between two players. Splitting the project this way — data and rules in `Board`, turn sequencing in `Game` — is a small taste of the separation of concerns you will rely on constantly as programs grow larger.
This tutorial builds a two-player console version: player X and player O take turns typing a row and column, and the board is redrawn after every move. All of the game logic — is this move legal, has anyone won, is the board full — is expressed as conditional checks over a `std::vector<std::vector<char>>`, C++'s standard way to represent a 2D grid without relying on a fixed-size C-style array.
- A `Board` class wrapping a `std::vector<std::vector<char>>` 3x3 grid.
- A `display()` method that prints the grid with row/column separators.
- Move validation that rejects out-of-range coordinates and already-occupied cells.
- Win detection that checks every row, column, and both diagonals.
- Draw detection for when the board fills up with no winner.
- A `Game` class that alternates turns between Player X and Player O until the game ends.
Prerequisites
- Classes — defining a `class` with `private` fields and `public` methods.
- Arrays — indexing a 2D structure with `grid[row][col]`, here using `std::vector<std::vector<char>>`.
- Loops — nested `for` loops for iterating over every cell of a grid.
- Conditional logic — `if`/`else if`/`else` chains and logical operators (`&&`, `||`).
- Basic console I/O — reading two integers per turn with `std::cin >> row >> col`.
Project Structure
The program lives in a single file, `tic_tac_toe.cpp`. `Board` is built around one private member, `grid`, a `std::vector<std::vector<char>>` sized to 3x3 in its constructor, where each cell holds `'X'`, `'O'`, or `' '` for empty. Every method on `Board` — `display`, `isValidMove`, `placeMark`, `checkWin`, `isFull` — either reads or writes that single grid, keeping the class small and focused on exactly one job: being an accurate, queryable model of the board.
`Game` owns a `Board` by value (not by pointer), plus one field, `currentPlayer`, tracking whose turn it is. `Game::play()` is the only method that needs to know about turn order, prompting the current player, checking their move against the board, and asking the board whether that move just won or drew the game — `Game` never reaches directly into `grid`, it only ever talks to `Board` through its public methods.
Step 1: Define the Board Class
The constructor builds a 3x3 grid of `std::vector<char>`, each initialized to `' '` (space) so every cell starts empty. The rest of `Board`'s methods are declared here and defined over the next two steps.
#include <iostream> // std::cin/std::cout for all console interaction#include <vector> // std::vector<std::vector<char>> models the 3x3 grid
class Board {private: std::vector<std::vector<char>> grid; // 3x3 grid; each cell holds 'X', 'O', or ' ' for empty
public: Board(); // Defined in this step
void display() const; // Defined in Step 2 bool isValidMove(int row, int col) const; // Defined in Step 3 void placeMark(int row, int col, char mark); // Defined in Step 3 bool checkWin(char mark) const; // Defined in Step 4 bool isFull() const; // Defined in Step 4};
Board::Board() { grid = std::vector<std::vector<char>>(3, std::vector<char>(3, ' ')); // 3 rows, each a vector of 3 spaces}Step 2: Display the Board
`display()` walks every row and column, printing each cell's mark with a padding space, plus a `|` column separator and a `---` row separator between cells, which together produce the familiar tic-tac-toe grid look.
void Board::display() const { std::cout << "\n"; for (int r = 0; r < 3; r++) { for (int c = 0; c < 3; c++) { std::cout << " " << grid[r][c]; // One space of padding before each cell's mark if (c < 2) { std::cout << " |"; // Column separator, skipped after the last column } } std::cout << "\n"; if (r < 2) { std::cout << "-----------\n"; // Row separator, skipped after the last row } } std::cout << "\n";}Click Run to see what this code prints.
Step 3: Validate and Place a Move
`isValidMove` first checks that `row` and `col` fall inside the 0-2 range before touching `grid` at all — checking bounds before indexing is what keeps this code safe, since indexing `grid[row][col]` with an out-of-range value would be undefined behavior. Only once the coordinates are known to be in range does it check whether that cell is still empty.
bool Board::isValidMove(int row, int col) const { if (row < 0 || row > 2 || col < 0 || col > 2) { // Reject anything outside the 3x3 grid before indexing it return false; } return grid[row][col] == ' '; // In range; valid only if the target cell is still empty}
void Board::placeMark(int row, int col, char mark) { grid[row][col] = mark; // Caller is expected to have already checked isValidMove}Step 4: Detect Wins and Draws
`checkWin` tests all eight possible winning lines: three rows, three columns, and two diagonals, all in one pass with a loop handling the rows and columns together and two explicit checks for the diagonals. `isFull` simply looks for any remaining empty cell — if it finds none, the board is full and the game is a draw as long as `checkWin` has already returned false for both players.
bool Board::checkWin(char mark) const { for (int i = 0; i < 3; i++) { if (grid[i][0] == mark && grid[i][1] == mark && grid[i][2] == mark) { // Row i is all one mark return true; } if (grid[0][i] == mark && grid[1][i] == mark && grid[2][i] == mark) { // Column i is all one mark return true; } } if (grid[0][0] == mark && grid[1][1] == mark && grid[2][2] == mark) { // Top-left to bottom-right diagonal return true; } if (grid[0][2] == mark && grid[1][1] == mark && grid[2][0] == mark) { // Top-right to bottom-left diagonal return true; } return false; // No row, column, or diagonal matched}
bool Board::isFull() const { for (int r = 0; r < 3; r++) { for (int c = 0; c < 3; c++) { if (grid[r][c] == ' ') { // Any empty cell means the board isn't full yet return false; } } } return true; // Every cell was occupied}Step 5: Build the Game Class
`Game` composes a `Board` with turn-tracking state. Keeping `switchPlayer()` `private` while `play()` stays `public` reflects that only `Game` itself ever needs to flip whose turn it is — nothing outside the class has a legitimate reason to call it directly.
class Game {private: Board board; // The 3x3 board this game is played on char currentPlayer; // 'X' or 'O', tracks whose turn it is
public: Game() : currentPlayer('X') {} // X always moves first
void play(); // Defined in Step 6
private: void switchPlayer(); // Defined in Step 6};Step 6: Run the Turn Loop in main()
`play()` is an infinite loop that only ever exits through one of two explicit `break` statements: a win or a draw. Every iteration displays the board, reads a move, validates it (checking both for non-numeric input and for `Board::isValidMove`), places it, and then checks for a win or a full board before handing the turn to the other player.
void Game::switchPlayer() { currentPlayer = (currentPlayer == 'X') ? 'O' : 'X'; // Toggle between the two mark characters}
void Game::play() { std::cout << "Tic-Tac-Toe! Player X vs Player O.\n"; std::cout << "Enter moves as row and column (0-2), e.g. \"1 2\".\n";
while (true) { // Runs until an explicit break on a win or a draw board.display(); int row, col; std::cout << "Player " << currentPlayer << ", enter row and column: "; std::cin >> row >> col;
if (std::cin.fail()) { // User typed something that isn't two numbers std::cin.clear(); // Reset the stream's error flags so future reads still work std::cin.ignore(10000, '\n'); // Discard whatever bad input is still sitting in the buffer std::cout << "Invalid input, please enter two numbers.\n"; continue; }
if (!board.isValidMove(row, col)) { std::cout << "Invalid move, try again.\n"; continue; }
board.placeMark(row, col, currentPlayer);
if (board.checkWin(currentPlayer)) { board.display(); std::cout << "Player " << currentPlayer << " wins!\n"; break; // Game over: a player just won }
if (board.isFull()) { board.display(); std::cout << "It's a draw!\n"; break; // Game over: no cells left and nobody won }
switchPlayer(); // Neither win nor draw: hand the turn to the other player }}
int main() { Game game; game.play(); return 0;}Complete Code
Here is the full program, ready to compile with `g++ tic_tac_toe.cpp -o tic_tac_toe`.
#include <iostream> // std::cin/std::cout for all console interaction#include <vector> // std::vector<std::vector<char>> models the 3x3 grid
class Board {private: std::vector<std::vector<char>> grid;
public: Board();
void display() const; bool isValidMove(int row, int col) const; void placeMark(int row, int col, char mark); bool checkWin(char mark) const; bool isFull() const;};
Board::Board() { grid = std::vector<std::vector<char>>(3, std::vector<char>(3, ' '));}
void Board::display() const { std::cout << "\n"; for (int r = 0; r < 3; r++) { for (int c = 0; c < 3; c++) { std::cout << " " << grid[r][c]; if (c < 2) { std::cout << " |"; } } std::cout << "\n"; if (r < 2) { std::cout << "-----------\n"; } } std::cout << "\n";}
bool Board::isValidMove(int row, int col) const { if (row < 0 || row > 2 || col < 0 || col > 2) { return false; } return grid[row][col] == ' ';}
void Board::placeMark(int row, int col, char mark) { grid[row][col] = mark;}
bool Board::checkWin(char mark) const { for (int i = 0; i < 3; i++) { if (grid[i][0] == mark && grid[i][1] == mark && grid[i][2] == mark) { return true; } if (grid[0][i] == mark && grid[1][i] == mark && grid[2][i] == mark) { return true; } } if (grid[0][0] == mark && grid[1][1] == mark && grid[2][2] == mark) { return true; } if (grid[0][2] == mark && grid[1][1] == mark && grid[2][0] == mark) { return true; } return false;}
bool Board::isFull() const { for (int r = 0; r < 3; r++) { for (int c = 0; c < 3; c++) { if (grid[r][c] == ' ') { return false; } } } return true;}
class Game {private: Board board; char currentPlayer;
public: Game() : currentPlayer('X') {}
void play();
private: void switchPlayer();};
void Game::switchPlayer() { currentPlayer = (currentPlayer == 'X') ? 'O' : 'X';}
void Game::play() { std::cout << "Tic-Tac-Toe! Player X vs Player O.\n"; std::cout << "Enter moves as row and column (0-2), e.g. \"1 2\".\n";
while (true) { board.display(); int row, col; std::cout << "Player " << currentPlayer << ", enter row and column: "; std::cin >> row >> col;
if (std::cin.fail()) { std::cin.clear(); std::cin.ignore(10000, '\n'); std::cout << "Invalid input, please enter two numbers.\n"; continue; }
if (!board.isValidMove(row, col)) { std::cout << "Invalid move, try again.\n"; continue; }
board.placeMark(row, col, currentPlayer);
if (board.checkWin(currentPlayer)) { board.display(); std::cout << "Player " << currentPlayer << " wins!\n"; break; }
if (board.isFull()) { board.display(); std::cout << "It's a draw!\n"; break; }
switchPlayer(); }}
int main() { Game game; game.play(); return 0;}Sample Run
Click Run to see what this code prints.
Extend This Project
- Add a `reset()` method to `Board` and a "Play Again?" prompt in `Game` so a new round can start without restarting the program.
- Support a variable board size (e.g. 4x4) by turning the hard-coded `3`s into a `size` member, and generalize `checkWin` to loop over that size.
- Add a simple computer opponent for single-player mode that picks the first available cell, then improve it to block an opponent about to win.
- Track and display a running win count for X and O across multiple rounds.
- Replace the row/column text prompt with a numbered 1-9 keypad-style input (like a phone dial pad) for a friendlier interface.
Summary
You built a fully playable, two-player Tic-Tac-Toe game with the game state and rules cleanly separated into a `Board` class and the turn flow handled by a `Game` class. This split — one class that models data and enforces its own invariants, another that orchestrates behavior on top of it — is a pattern you will reuse in almost every larger C++ project you build from here on.