Skip to main content

Tic-tac-toe game using Python and tkinter

Tic-tac-toe is a popular two player game where each player tries to occupy an empty slot in a 3x3 board until one of them wins or the entire board is filled without any winner. The winner has to occupy three continuous cells in any direction, including the two diagonals. In this article, a version of the tic-tac-toe game is coded using Python and tkinter library.

Environment

The source code is written and executed in the following environment,

  • Operating System - Windows 11
  • Interpreter - Python 3.10.1
  • Editor - Visual Studio code

The game board can be seen as a square which contains a total of 9 cells. The code in the article is designed with the following specifications, also shown visually in the image below,

  • Window size - 650x650 pixels
  • Cell Size - 150x150 pixels
  • Board Size - 450x450 pixels
  • Padding around board - 100 pixels on each direction

The code uses tkinter and tkinter.ttk libraries. Apart from these libraries, Enum is used for defining a set of constants and choice is used in AI decision to occupy an empty cell in the board. A global variable is used to hold the current game state.

from enum import Enum
from random import choice
from tkinter import *
from tkinter import ttk
from tkinter import messagebox 

gameState = None

Instead of hard coded values, the code defines a few useful constants for using Enum. These constants include,

  • various sizes related to the cells and window
  • font and font sizes
  • markers and turn indicators for player and AI

class Const(Enum):
    OFFSET = 100
    SIDE = 150
    MID = 75
    FONT = "Comic Sans MS"
    CELL_FONT_SIZE = 56
    LABEL_FONT_SIZE = 16
    WINSIZE = 650
    ROWCOL = 3
    PLAYER_CHAR = "X"
    AI_CHAR = "O"
    EMPTY_CHAR = "0"
    TURN_PLAYER = 0
    TURN_CPU = 1

The first class defined is the Point class, which is used to store a (x, y) value which lies within the game window. It also defines a method which creates a new object with offset applied to both x and y values. The add() method is used later to calculate the mid point of a cell.

class Point:
    def __init__(self, x, y, offset=0):
        self.x = x + offset
        self.y = y + offset


    def add(self, offset):
        return Point(self.x + offset, self.y + offset)

The next class is the Cell class, which holds data related to one cell in the game board. An array of objects are created later to represent the entire game board. The Cell class exports quite a few methods for operation, each method is discussed briefly,

Constructor

Cells are arranged in a 2D format with indices available to both row and column. Event though there are 9 cells, all these cells are represented visually within a single canvas. So the constructor expects a canvas argument along with the row and column values. While the row and column values are not stored in the object, it is used to calculate the start and mid-point of the cell. The cell object also holds an id value to the text that will be created during the game, the id is required later for deleting the character from the canvas. Each cell is initially empty, so a couple of fields to indicate that the cell is empty.

mark() method

The mark() method creates the visual mark on the game board, the method can mark the cell only if it is empty, it can mark the cell for either the player or AI. There are two wrapper methods markPlayer() and markAI() coded for better clarity. The current marker can be obtained using the getMarker() method, the marker needs to be retrieved at various stages to game logic.

unmark() method

The unmark() method removes the current marker and marks the cell as empty again.

class Cell:
    def __init__(self, canvas, i, j):
        self.canvas = canvas
        self.start = Point(i * Const.SIDE.value,
                           j * Const.SIDE.value,
                           offset=Const.OFFSET.value)
        self.mid = self.start.add(Const.MID.value)
        self.textId = 0
        self.marked = False
        self.marker = Const.EMPTY_CHAR.value


    def mark(self, char):
        if self.marked:
            return False

        self.marker = char
        self.textId = self.canvas.create_text(
                           self.mid.x,
                           self.mid.y,
                           text=self.marker,
                           font=(Const.FONT.value,
                                 Const.CELL_FONT_SIZE.value))
        self.marked = True
        return True


    def markPlayer(self):
        return self.mark(Const.PLAYER_CHAR.value)


    def markAI(self):
        return self.mark(Const.AI_CHAR.value)


    def unmark(self):
        if not self.marked:
            return False

        self.canvas.delete(self.textId)
        self.marked = False
        self.marker = Const.EMPTY_CHAR.value
        return True


    def getMarker(self):
        return self.marker

Then comes the Score class, which keeps track of the number of games won by both the player and the AI. The object has two labels for scores, one aligned to the left edge and the other aligned to the right edge of the board. The alignment is done using the place() geometry manager of tkinter library. The scores are initially set to 0, and every time someone wins, the corresponding score gets incremented by 1, and the labels' text are updated.

class Score:
    def __init__(self, root):
        self.playerScore = 0
        self.cpuScore = 0
        
        self.playerLabel = ttk.Label(root,
                                     font=(Const.FONT.value,
                                           Const.LABEL_FONT_SIZE.value))
        self.playerLabel.place(x=100, y=35, width=Const.SIDE.value)        
        self.cpuLabel = ttk.Label(root,
                                  font=(Const.FONT.value,
                                        Const.LABEL_FONT_SIZE.value),
                                  anchor='e')
        self.cpuLabel.place(x=400, y=35, width=Const.SIDE.value)
        self.updateScore()


    def updateScore(self):
        self.playerLabel.config(text=f"You - {self.playerScore}")
        self.cpuLabel.config(text=f"Cpu - {self.cpuScore}")


    def playerWon(self):
        self.playerScore += 1
        self.updateScore()


    def cpuWon(self):
        self.cpuScore += 1
        self.updateScore()

The final and the largest class is the GameState class, which tracks the whole board, controls the AI, updates scores, reset games etc. All the methods part of this class are explained in detail below.

Constructor

The constructor creates the 3x3 two-dimensional array for the Cell objects, creates a Score object, draws the tic-tac-toe board and initially set the turn to the player. It takes a tkinter root element as argument, which is used to create the canvas.

setupCanvas() method

The canvas is created with the input tkinter root, and the tic-tac-toe board is drawn. For taking user input through the mouse, the left mouse button is bound to a callback.

playerSelected() method

The method is called when the player clicks on the canvas. If the click happened on a valid and empty cell, the cell is marked as occupied by the player.

clear() method

After the game ended, the clear() method is called with an optional winner argument. The entire board is marked as empty and scores are updated based on the winner. The scores are not incremented if the game is drawn. Also, the first turn is handed over to either the player or AI depending on the last round.

checkWinner() method

The end game state is calculated here, the code goes through all the rows, columns and diagonals to see if there is a winner. The winner is decided if three continuous cells are occupied in a row or column or one of the diagonals. The cells to check are input to a helper method check() to decide the winner. If no winner is found, the method returns False. There are wrapper methods to check win status for player and AI.

aiTurn() method

The AI is coded using a simple logic, it will occupy one of the un-occupied slots. There is a helper method getEmptySlots() which returns a list of row and column tuples, and a random tuple is chosen. The checkDrawn() method also makes use of getEmptySlots() method to decide whether the game is drawn. The game will be drawn when the board is full and there is no winner yet.

class GameState:
    def __init__(self, root):
        self.canvas = self.setupCanvas(root)
        self.cells = [[Cell(self.canvas, i, j) for i in range(Const.ROWCOL.value)] for j in range(Const.ROWCOL.value)]        
        self.score = Score(root)
        self.turn = Const.TURN_PLAYER.value

    
    def setupCanvas(self, root):
        c = Canvas(root)
        c.place(x=0, y=0, height=Const.WINSIZE.value, width=Const.WINSIZE.value)
        c.create_line(250, 100, 250, 550, width=5, fill="#aaa")
        c.create_line(400, 100, 400, 550, width=5, fill="#aaa")
        c.create_line(100, 250, 550, 250, width=5, fill="#aaa")
        c.create_line(100, 400, 550, 400, width=5, fill="#aaa")
        c.bind("<Button-1>", mouseCb)
        return c


    def playerSelected(self, i, j):
        valid = lambda x : 0 <= x < Const.ROWCOL.value
        if not valid(i) or not valid(j):
            return False
        return self.cells[i][j].markPlayer()


    def clear(self, winner="none"):
        for i in range(Const.ROWCOL.value):
            for j in range(Const.ROWCOL.value):
                self.cells[i][j].unmark()

        match winner:
            case "player": self.score.playerWon()
            case "cpu": self.score.cpuWon()
            case "_": pass
        
        if self.turn == Const.TURN_PLAYER.value:
            self.turn = Const.TURN_CPU.value
            self.aiTurn()
        elif self.turn == Const.TURN_CPU.value:
            self.turn = Const.TURN_PLAYER.value


    def check(self, rowColPairs, marker):
        for (row, col) in rowColPairs:
            if (marker != self.cells[row][col].getMarker()):
                return False
        return True


    def getColPair(self, row):
        return [(row, i) for i in range(Const.ROWCOL.value)]
        

    def getRowPair(self, col):
        return [(i, col) for i in range(Const.ROWCOL.value)]


    def checkWinner(self, char):
        for row in range(Const.ROWCOL.value):
            if self.check(self.getColPair(row), char):
                return True

        for col in range(Const.ROWCOL.value):
            if self.check(self.getRowPair(col), char):
                return True

        # check diagonals
        if self.check([(0, 0), (1, 1), (2, 2)], char):
            return True
        if self.check([(0, 2), (1, 1), (2, 0)], char):
            return True
        
        return False


    def checkPlayerWin(self):
        return self.checkWinner(Const.PLAYER_CHAR.value)


    def checkCpuWin(self):
        return self.checkWinner(Const.AI_CHAR.value)


    def getEmptySlots(self):
        slots = []
        for i in range(Const.ROWCOL.value):
            for j in range(Const.ROWCOL.value):
                if self.cells[i][j].getMarker() == Const.EMPTY_CHAR.value:
                    slots.append((i, j))
        return slots


    def checkDrawn(self):
        return len(self.getEmptySlots()) == 0


    def aiTurn(self):
        slots = self.getEmptySlots()
        if len(slots) == 0:
            return
        (row, col) = choice(slots)
        self.cells[row][col].markAI()

The callback event handles the logic for occupying a cell for the player. If a cell is successfully occupied by the player then a win condition is checked for the player. If the player did not win, the AI makes its move and a win condition for the AI is checked. After every move, the game is also checked for a Draw state. The method also displays a message box for the winner or the drawn state.

def mouseCb(event):
    index = lambda x: (x - Const.OFFSET.value) // Const.SIDE.value
    j = index(event.x)
    i = index(event.y)

    if not gameState.playerSelected(i, j):
        return

    if gameState.checkPlayerWin():
        messagebox.showinfo("You won", "You won the round.")
        gameState.clear(winner="player")
        return

    gameState.aiTurn()

    if gameState.checkCpuWin():
        messagebox.showinfo("Cpu won", "Cpu won the round.")
        gameState.clear(winner="cpu")
        return

    if gameState.checkDrawn():
        messagebox.showinfo("Game drawn", "The round has been drawn.")
        gameState.clear()

Finally, the main() function creates the tkinter root, the GameState object and runs the main loop.

def main():
    global gameState
    global ai
    root = Tk()
    root.title("Tic-tac-toe")
    root.geometry(f"{Const.WINSIZE.value}x{Const.WINSIZE.value}+500+500")
    root.resizable(False, False)
    gameState = GameState(root)
    root.mainloop()


if __name__ == "__main__":
    main()

The below image captures various screens from a sample run of the tic-tac-toe game.


Thanks for reading, please leave comments/suggestions if any.

Comments

Popular posts from this blog

BMI Calculator using Python and Tkinter

Body Mass Index can be calculated using a simple formula kg/m 2 , where the weight is represented in kilograms (kg) and the height is represented in metres (m). The article presents code to create a simple GUI to calculate BMI based on user input values. The GUI is implemented using tkinter and tkinter.ttk libraries in Python language.

Using hilite.me API in Python

hilite.me is a light weight website made by Alexander Kojevnikov , used to format source code from various programming languages into formatted HTML snippets which can be added to blogs, articles, and other web pages as needed. I personally use the API to format the source code snippets in this blog, and it has greatly reduced the time taken to complete an article, much thanks to the creator. In this article, an automated way of using the hilite.me API through Python and a simple HTML document is created with formatted code snippets.