Robert Johns | 13 Feb, 2025
Fact checked by Jim Markus

Build a Python Tic-Tac-Toe Game with PyQt (Step-by-Step)

In this tutorial, we will build a simple Tic-Tac-Toe game using Python and PyQt5. This project will help you understand Object-Oriented Programming (OOP), GUI design, and event handling in PyQt5.

By the end, you’ll have a fully functional game where two players can compete in a classic Tic-Tac-Toe match using a sleek GUI.

This beginner-friendly project covers essential programming concepts, including:

  • Using PyQt5 to create an interactive GUI

  • Handling user interactions through button clicks

  • Implementing game logic using Python classes

  • Updating UI elements dynamically based on game state

  • Utilizing OOP to organize your code

Let’s dive in!

Step 1: Setting Up the Project

Before we start coding, let’s set up our Python project:

1. Make sure Python is installed on your computer. If not, download it from the official Python website.
2. Open your favorite code editor or IDE.
3. Create a new Python file, for example, tic_tac_toe.py.

Before we start coding, Install PyQt5 if you haven't already. You can install it using:

pip install PyQt5

Great, now, let's dive head first into our Python editor to get this build started.

Step 2: Understanding How the Game Works

Tic-Tac-Toe is played on a 3x3 grid. Two players take turns placing their marker (X or O) on an empty square. The game ends when:

  • A player gets three markers in a row, column, or diagonal (winning condition).

  • The board is completely filled without a winner (draw condition).

We’ll use PyQt5 to build a user-friendly interface with buttons representing the grid squares. Each button click will place an X or O on the board, and the game logic will determine the winner.

When we're done, we should have something that looks like this:

GUI for Tic Tac Toe game built with PyQt in Python.

Step 3: Importing Required Modules

Now, let’s start coding in Python! First, we need three Python modules for this project:

import sys  # For handling system operations
import random  # For selecting the starting player randomly
from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QGridLayout, QLabel, QVBoxLayout

Why Do We Use These Modules?

  • sys: Required for running the PyQt5 application.

  • random: We use Python random to select which player goes first.

  • PyQt5.QtWidgets: Provides the necessary GUI components.

Step 4: Creating the Class Skeleton

To follow good OOP design practices, we will first create the class skeleton and then gradually implement each method.

import sys
import random
from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QGridLayout, QLabel, QVBoxLayout

class TicTacToe(QWidget):
    def __init__(self):
        super().__init__()
        self.init_ui()
    
    def init_ui(self):
        pass
    
    def play_turn(self, row, col):
        pass
    
    def check_winner(self):
        pass
    
    def is_board_full(self):
        pass
    
    def update_status(self):
        pass
    
    def reset_game(self):
        pass

if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = TicTacToe()
    window.show()
    sys.exit(app.exec_())

Explanation:

  • We define the TicTacToe class as a subclass of QWidget.
  • The __init__ method initializes the game and calls init_ui().
  • Each function is defined as a placeholder (pass) to be implemented step by step.
  • The main block (if __name__ == '__main__':) ensures that the script runs as a standalone application.

Step 5: Designing the Game Layout

We will now implement init_ui() to define the GUI layout using QGridLayout and QVBoxLayout.

def init_ui(self):
    self.setWindowTitle('Tic-Tac-Toe')
    self.setGeometry(100, 100, 300, 350)
    
    self.grid_layout = QGridLayout()
    self.buttons = [[QPushButton('') for _ in range(3)] for _ in range(3)]
    
    for i in range(3):
        for j in range(3):
            self.buttons[i][j].setFixedSize(80, 80)
            self.buttons[i][j].clicked.connect(lambda _, row=i, col=j: self.play_turn(row, col))
            self.grid_layout.addWidget(self.buttons[i][j], i, j)
    
    self.status_label = QLabel("Player X's Turn")
    self.reset_button = QPushButton('Reset Game')
    self.reset_button.clicked.connect(self.reset_game)
    
    self.v_layout = QVBoxLayout()
    self.v_layout.addWidget(self.status_label)
    self.v_layout.addLayout(self.grid_layout)
    self.v_layout.addWidget(self.reset_button)
    
    self.setLayout(self.v_layout)
    
    self.board = [['' for _ in range(3)] for _ in range(3)]
    self.current_player = 'X' if random.randint(0, 1) == 1 else 'O'
    self.update_status()

Breakdown:

  • We set up the main window title and size.
  • A 3x3 grid of QPushButton objects represents the Tic-Tac-Toe board.
  • A label (QLabel) displays the current player's turn.
  • A reset button (QPushButton) allows the game to be restarted.
  • update_status() sets the initial player turn randomly.

Step 6: Handling Player Moves

Now, let's implement play_turn() to handle player moves when they click a button.

def play_turn(self, row, col):
    if self.board[row][col] == '' and not self.check_winner():
        self.board[row][col] = self.current_player
        self.buttons[row][col].setText(self.current_player)
        
        if self.check_winner():
            self.status_label.setText(f'Player {self.current_player} Wins!')
        elif self.is_board_full():
            self.status_label.setText('Match Draw!')
        else:
            self.current_player = 'X' if self.current_player == 'O' else 'O'
            self.update_status()

Explanation:

  • The method first checks if the clicked cell is empty and if the game is ongoing.
  • It updates the board and sets the button text to the current player’s symbol (X or O) with an f-string.
  • It then checks for a winner or a draw.
  • If the game is still in progress, the turn switches to the other player.

Step 7: Checking for a Winner

The check_winner() function verifies if a player has won.

def check_winner(self):
    for row in self.board:
        if row.count(row[0]) == 3 and row[0] != '':
            return True
    
    for col in range(3):
        if self.board[0][col] == self.board[1][col] == self.board[2][col] and self.board[0][col] != '':
            return True
    
    if self.board[0][0] == self.board[1][1] == self.board[2][2] and self.board[0][0] != '':
        return True
    
    if self.board[0][2] == self.board[1][1] == self.board[2][0] and self.board[0][2] != '':
        return True
    
    return False

Breakdown:

  • The function checks for three consecutive symbols horizontally, vertically, and diagonally.

Step 8: Checking for a Draw

The is_board_full() function determines if the board is completely filled.

def is_board_full(self):
    return all(self.board[i][j] != '' for i in range(3) for j in range(3))

Explanation:

  • It loops through the board via the Python range function, and returns True if no empty spaces are left.

Step 9: Resetting the Game

The reset_game() function resets the board and buttons.

def reset_game(self):
    self.board = [['' for _ in range(3)] for _ in range(3)]
    for i in range(3):
        for j in range(3):
            self.buttons[i][j].setText('')
    self.current_player = 'X' if random.randint(0, 1) == 1 else 'O'
    self.update_status()

Explanation:

  • Clears the board and resets all buttons.
  • Chooses a new starting player randomly.

Final Code: Tic-Tac-Toe Game

import sys
import random
from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QGridLayout, QLabel, QVBoxLayout

class TicTacToe(QWidget):
    def __init__(self):
        super().__init__()
        self.init_ui()
    
    def init_ui(self):
        self.setWindowTitle('Tic-Tac-Toe')
        self.setGeometry(100, 100, 300, 350)
        
        self.grid_layout = QGridLayout()
        self.buttons = [[QPushButton('') for _ in range(3)] for _ in range(3)]
        
        for i in range(3):
            for j in range(3):
                self.buttons[i][j].setFixedSize(80, 80)
                self.buttons[i][j].clicked.connect(lambda _, row=i, col=j: self.play_turn(row, col))
                self.grid_layout.addWidget(self.buttons[i][j], i, j)
                
        self.status_label = QLabel('Player X\'s Turn')
        self.reset_button = QPushButton('Reset Game')
        self.reset_button.clicked.connect(self.reset_game)
        
        self.v_layout = QVBoxLayout()
        self.v_layout.addWidget(self.status_label)
        self.v_layout.addLayout(self.grid_layout)
        self.v_layout.addWidget(self.reset_button)
        
        self.setLayout(self.v_layout)
        
        self.board = [['' for _ in range(3)] for _ in range(3)]
        self.current_player = 'X' if random.randint(0, 1) == 1 else 'O'
        self.update_status()
    
    def play_turn(self, row, col):
        if self.board[row][col] == '' and not self.check_winner():
            self.board[row][col] = self.current_player
            self.buttons[row][col].setText(self.current_player)
            
            if self.check_winner():
                self.status_label.setText(f'Player {self.current_player} Wins!')
            elif self.is_board_full():
                self.status_label.setText('Match Draw!')
            else:
                self.current_player = 'X' if self.current_player == 'O' else 'O'
                self.update_status()
    
    def check_winner(self):
        for row in self.board:
            if row.count(row[0]) == 3 and row[0] != '':
                return True
        
        for col in range(3):
            if self.board[0][col] == self.board[1][col] == self.board[2][col] and self.board[0][col] != '':
                return True
        
        if self.board[0][0] == self.board[1][1] == self.board[2][2] and self.board[0][0] != '':
            return True
        
        if self.board[0][2] == self.board[1][1] == self.board[2][0] and self.board[0][2] != '':
            return True
        
        return False
    
    def is_board_full(self):
        return all(self.board[i][j] != '' for i in range(3) for j in range(3))
    
    def update_status(self):
        self.status_label.setText(f'Player {self.current_player}\'s Turn')
    
    def reset_game(self):
        self.board = [['' for _ in range(3)] for _ in range(3)]
        for i in range(3):
            for j in range(3):
                self.buttons[i][j].setText('')
        self.current_player = 'X' if random.randint(0, 1) == 1 else 'O'
        self.update_status()
        
if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = TicTacToe()
    window.show()
    sys.exit(app.exec_())

Wrapping Up

Congratulations! You’ve built a Tic-Tac-Toe game using Python and PyQt5. This project demonstrated how to use PyQt5 layouts, event handling, and object-oriented programming.

Next steps:

  • Add a simple AI opponent.
  • Improve the UI with colors and styles.
  • Track player scores across multiple rounds.

Happy coding!

By Robert Johns

Technical Editor for Hackr.io | 15+ Years in Python, Java, SQL, C++, C#, JavaScript, Ruby, PHP, .NET, MATLAB, HTML & CSS, and more... 10+ Years in Networking, Cloud, APIs, Linux | 5+ Years in Data Science | 2x PhDs in Structural & Blast Engineering

View all post by the author

Subscribe to our Newsletter for Articles, News, & Jobs.

I accept the Terms and Conditions.

Disclosure: Hackr.io is supported by its audience. When you purchase through links on our site, we may earn an affiliate commission.

In this article

Learn More

Please login to leave comments