DEV Community

Cover image for Tic-Tac-Toe with Pygame
1

Tic-Tac-Toe with Pygame

Hi folks, this will be the first post of the year. I hope you had very merry holidays, are relaxed, and are full of energy to continue your IT journey!

At least once in your career as a developer, you’ve probably created something banal, like a calculator, a to-do list, or a Tic-Tac-Toe game. Today, that’s exactly what I’m going to do—but with a bit of a twist. I’ll create a Tic-Tac-Toe game using Python with Pygame. Yes, I’m not that proficient in Python—at least not yet—so I’d be glad to hear any suggestions or improvements in the comments!

So let's dive in Python with Pygame, in this article I will cover:


Game Logic

First things first, the main logic behind Tic-Tac-Toe is obvious. There is a board with a size of 3x3 fields. Players take turns filling the fields with X or O. The first player to get three symbols in a row, column, or diagonal wins. For my implementation, in addition to common logic, I want to track player scores and provide the ability to reset the game or a round. Here is a sketch representation of what I’m going to build:

sketch

Here is the class responsible for the game behavior. As you can see, I haven’t used a matrix to track the filled fields on the board. Of course, you can create your implementation using a matrix, but for me, it doesn’t make much sense to use one when the board size is only 3x3. This is why a list is more than enough. The win-checking logic is designed as an iteration over winning combinations, represented as tuple of tuples with indexes. Also, there’s no point in checking for a winner before the fifth move because, before that, there aren’t enough symbols to form a win.

from enum import Enum
from config import WINNING_COMBINATIONS

DEFAULT_BOARD = [None] * 9


class Players(Enum):
    PLAYER_X = "X"
    PLAYER_O = "O"


class Game:
    def __init__(
        self,
        board=DEFAULT_BOARD.copy(),
        turn=Players.PLAYER_X.value,
        score_x=0,
        score_o=0,
        player_won=None,
        move_count=0,
        win_line=None,
    ):

        self.turn = turn
        self.board = board
        self.is_full = False
        self.score_x = score_x
        self.score_o = score_o
        self.win_line = win_line
        self.move_count = move_count
        self.player_won = player_won

    def __check_win(self):
        for index, item in enumerate(WINNING_COMBINATIONS):
            board_item_1 = self.board[item[0]]
            board_item_2 = self.board[item[1]]
            board_item_3 = self.board[item[2]]

            if board_item_1 == board_item_2 == board_item_3 and board_item_1 != None:
                self.win_line = index
                return (True, board_item_1)

        return (False, None)

    def move(self, index):
        if self.player_won:
            return

        if self.board[index] != None:
            return

        self.board[index] = self.turn
        self.move_count += 1

        self.turn = (
            Players.PLAYER_X.value
            if self.turn == Players.PLAYER_O.value
            else Players.PLAYER_O.value
        )

        if self.move_count < 5:
            return

        self.is_full = None not in self.board

        (isWin, player) = self.__check_win()

        if isWin:
            if player == Players.PLAYER_X.value:
                self.score_x += 1
                self.player_won = Players.PLAYER_X.value

            if player == Players.PLAYER_O.value:
                self.score_o += 1
                self.player_won = Players.PLAYER_O.value

    def new_round(self):
        self.move_count = 0
        self.board = DEFAULT_BOARD.copy()

        if self.player_won == Players.PLAYER_X.value:
            self.turn = Players.PLAYER_O.value

        if self.player_won == Players.PLAYER_O.value:
            self.turn = Players.PLAYER_X.value

        self.win_line = None
        self.player_won = None

    def rest_game(self):
        self.score_x = 0
        self.score_o = 0
        self.move_count = 0
        self.win_line = None
        self.player_won = None
        self.board = DEFAULT_BOARD.copy()
Enter fullscreen mode Exit fullscreen mode

Buttons

The game needs to have two types of buttons, according to the sketch: board button to display an empty field, X, or O, and text button for controls.
But firstly I would like to implement the button primitive that will accept callback and handle click events:

from pygame import Rect, draw, mouse
from config import PALE_GOLDENROD_COLOR


class Button:
    def __init__(
        self,
        top=0,
        left=0,
        width=100,
        height=100,
        callback=None,
        background_color=PALE_GOLDENROD_COLOR,
    ):
        self.top = top
        self.left = left
        self.clicked = False
        self.callback = callback
        self.background_color = background_color
        self.rect = Rect(left, top, width, height)

    def __handle_click(self):
        if not self.callback:
            return

        mouse_pos = mouse.get_pos()

        if self.rect.collidepoint(mouse_pos):
            if mouse.get_pressed()[0] == 1 and not self.clicked:
                self.clicked = True
                self.callback()

        if mouse.get_pressed()[0] == 0:
            self.clicked = False

    def draw(self, screen):
        self.__handle_click()
        draw.rect(screen, self.background_color, self.rect)
Enter fullscreen mode Exit fullscreen mode

The __handle_click method calls the callback function passed to the Button whenever the user clicks the mouse over the button's rectangle. It also prevents multiple clicks by tracking whether the button has already been clicked.

Now, with the button primitive in place, the keypad text button and board button can be created. The keypad button will have a border and be able to display text inside:

from .button import Button
from pygame import font, draw
from config import CHESTNUT_BROWN_COLOR


class KeypadButton(Button):
    def __init__(
        self,
        text="button",
        text_color=CHESTNUT_BROWN_COLOR,
        border_color=CHESTNUT_BROWN_COLOR,
        **kwargs
    ):
        super().__init__(**kwargs)
        self.text = text
        self.text_color = text_color
        self.border_color = border_color
        self.font = font.Font(None, 30)

    def __draw_text(self, screen):
        text_surface = self.font.render(self.text, True, self.text_color)
        text_rect = text_surface.get_rect(center=self.rect.center)
        screen.blit(text_surface, text_rect)

    def draw(self, screen, top=None, left=None):
        self.rect.top = top or self.top
        self.rect.left = left or self.left
        super().draw(screen)
        draw.rect(screen, self.border_color, self.rect, 4)
        self.__draw_text(screen)
Enter fullscreen mode Exit fullscreen mode

The board button can be blank, or it can contain an X or O:

from enum import Enum
from pygame import draw
from .button import Button
from config import COLDEN_OCHRE_COLOR


class BoardButtonState(Enum):
    EMPTY = None
    CROSS = "X"
    CIRCLE = "O"


class BoardButton(Button):
    def __init__(
        self, state=BoardButtonState.EMPTY, text_color=COLDEN_OCHRE_COLOR, **kwargs
    ):
        super().__init__(**kwargs)
        self.state = state
        self.text_color = text_color

    def __draw_circle(self, screen):
        center = self.rect.center
        radius = self.rect.width // 2.5
        draw.circle(screen, self.text_color, center, radius, width=6)

    def __draw_cross(self, screen):
        padding = 10
        x1, y1 = self.rect.topleft
        x2, y2 = self.rect.bottomright
        draw.line(
            screen,
            self.text_color,
            (x1 + padding, y1 + padding),
            (x2 - padding, y2 - padding),
            width=8,
        )
        draw.line(
            screen,
            self.text_color,
            (x2 - padding, y1 + padding),
            (x1 + padding, y2 - padding),
            width=8,
        )

    def draw(self, screen, top=None, left=None):
        self.rect.top = top or self.top
        self.rect.left = left or self.left
        super().draw(screen)
        if self.state == BoardButtonState.CIRCLE.value:
            self.__draw_circle(screen)
        elif self.state == BoardButtonState.CROSS.value:
            self.__draw_cross(screen)
Enter fullscreen mode Exit fullscreen mode

That’s essentially all about the buttons. You’ll also notice that the winning combinations and default button colors are stored in a config file, which can be found via the link to the game repository at the end of the article.


Keypad

The keypad component is quite simple; it just needs to display two buttons. In general, I could probably just draw control buttons like "Reset Game" or "New Round" directly in the main file. However, for better organization and flexibility, it would be ideal to define these components separately and integrate them into the main file as needed.

from pygame import Rect, draw
from .keypad_button import KeypadButton
from config import PALE_GOLDENROD_COLOR

class Keypad:
    def __init__(
        self,
        left=0,
        top=0,
        width=312,
        height=60,
        background_color=PALE_GOLDENROD_COLOR,
        left_text="left_button",
        left_callback=None,
        right_text="right_button",
        right_callback=None,
    ):
        self.left_text = left_text
        self.right_text = right_text
        self.left_callback = left_callback
        self.right_callback = right_callback
        self.background_color = background_color
        self.rect = Rect(left, top, width, height)

    def draw(self, screen):
        draw.rect(screen, self.background_color, self.rect)

        width = 151
        height = 60

        left = self.rect.left
        right = self.rect.width - width + 10
        top = self.rect.top + (self.rect.height - height) // 2

        left_button = KeypadButton(
            text=self.left_text,
            callback=self.left_callback,
            top=top,
            left=left,
            width=width,
            height=height,
        )
        right_button = KeypadButton(
            text=self.right_text,
            callback=self.right_callback,
            top=top,
            left=right,
            width=width,
            height=height,
        )

        left_button.draw(screen)
        right_button.draw(screen)
Enter fullscreen mode Exit fullscreen mode

Scorebar

Remembering how simple the keypad is, I expected that the scorebar wouldn’t be harder to build.... Yeah, I was wrong because I forgot that text placement will requires a hell lot of math. The players' score placement wasn’t that tough; just two text containers for the label and the value, placed one after another on opposite sides of the container.

If you’ve worked with a canvas, you might know that text doesn’t wrap automatically, so you need to handle it manually. The Pygame wiki provides a simple example for text wrapping. However, it may not always suit your specific use cases. Probably the best approach would be to create a Text component to handle wrapping and styling, but I was too lazy at that point)

from game import Players
from pygame import Rect, draw, font
from config import CHESTNUT_BROWN_COLOR, PALE_GOLDENROD_COLOR, COLDEN_OCHRE_COLOR


class Scorebar:
    def __init__(
        self,
        score_x=0,
        score_o=0,
        turn=None,
        move_count=0,
        is_full=False,
        player_won=None,
        left=0,
        top=0,
        width=312,
        height=100,
        backgroundColor=PALE_GOLDENROD_COLOR,
        textPrimaryColor=CHESTNUT_BROWN_COLOR,
        textSecondaryColor=COLDEN_OCHRE_COLOR,
    ):
        self.turn = turn
        self.width = width
        self.height = height
        self.is_full = is_full
        self.score_x = score_x
        self.score_o = score_o
        self.move_count = move_count
        self.player_won = player_won
        self.backgroundColor = backgroundColor
        self.textPrimaryColor = textPrimaryColor
        self.textSecondaryColor = textSecondaryColor
        self.rect = Rect(left, top, width, height)

    def __draw_score_x(self, screen):
        label_font = font.Font(None, 24)
        value_font = font.Font(None, 30)
        label_surface = label_font.render("Score-X:", True, self.textPrimaryColor)
        value_surface = value_font.render(
            f"{self.score_x}", True, self.textSecondaryColor
        )

        label_left = self.rect.left + 10
        label_top = self.rect.top + 10
        value_left = label_left + label_surface.get_width() + 5
        value_top = label_top - 2

        screen.blit(label_surface, (label_left, label_top))
        screen.blit(value_surface, (value_left, value_top))

    def __draw_score_o(self, screen):
        label_font = font.Font(None, 24)
        value_font = font.Font(None, 30)
        label_surface = label_font.render("Score-O:", True, self.textPrimaryColor)
        value_surface = value_font.render(
            f"{self.score_o}", True, self.textSecondaryColor
        )

        value_left = self.rect.right - 10 - value_surface.get_width()
        label_left = value_left - label_surface.get_width() - 5
        label_top = self.rect.top + 10
        value_top = label_top - 2

        screen.blit(label_surface, (label_left, label_top))
        screen.blit(value_surface, (value_left, value_top))

    def __draw_message(self, screen):
        text_container = Rect(self.rect.left, self.rect.top + 30, self.rect.width, 70)
        text_font = font.Font(None, 30)
        line_spacing = -2
        text = ""

        if self.turn:
            text = f"Player {self.turn}'s turn"

        if self.player_won != None:
            text = f"Player {self.player_won} won"

        if self.is_full and self.player_won == None:
            text = "It seems tie"

        if self.move_count == 0:
            second= None
            if self.turn == Players.PLAYER_X.value:
                second = Players.PLAYER_O.value
            if self.turn == Players.PLAYER_O.value:
                second = Players.PLAYER_X.value
            text = f"Player {self.turn} starts first, followed by Player {second}"

        lines = []
        remaining_text = text
        font_height = text_font.size("Tg")[1]

        while remaining_text:
            i = 1

            while text_font.size(remaining_text[:i])[
                0
            ] < text_container.width and i < len(remaining_text):
                i += 1

            if i < len(remaining_text):
                i = remaining_text.rfind(" ", 0, i) + 1

            lines.append(remaining_text[:i])
            remaining_text = remaining_text[i:]

        total_text_height = len(lines) * font_height + (len(lines) - 1) * line_spacing

        y = text_container.top + (text_container.height - total_text_height) // 2

        for line in lines:
            line_surface = text_font.render(line, True, self.textPrimaryColor)
            line_rect = line_surface.get_rect(
                center=(text_container.centerx, y + font_height // 2)
            )
            screen.blit(line_surface, line_rect.topleft)
            y += font_height + line_spacing

    def draw(self, screen):
        draw.rect(screen, self.backgroundColor, self.rect)
        self.__draw_score_x(screen)
        self.__draw_score_o(screen)
        self.__draw_message(screen)
Enter fullscreen mode Exit fullscreen mode

Playboard

The main goal for playboard is to place all the buttons correctly and display win line in case some of the players won the round. Somehow I decided that bord should accept the list of items instead of creating the buttons inside itself, looking back that probably wasnt the great desition, my only excuse is that I was foused for creating universal grid layout at the beginning.

The game itself can have only the 8 win combinations, the win line will be drawn based on the index of the win combination passed to the board and put on place relative to board size.

from pygame import Rect, draw
from config import CHESTNUT_BROWN_COLOR, PALE_GOLDENROD_COLOR


class Board:
    def __init__(
        self,
        top=0,
        left=0,
        item_width=100,
        item_height=100,
        gap_x=6,
        gap_y=6,
        items=None,
        win_line=0,
        background_color=CHESTNUT_BROWN_COLOR,
        line_border_color=CHESTNUT_BROWN_COLOR,
        line_background_color=PALE_GOLDENROD_COLOR,
    ):
        self.items = items
        self.gap_y = gap_y
        self.gap_x = gap_x
        self.win_line = win_line
        self.item_width = item_width
        self.item_height = item_height
        self.background_color = background_color
        self.items = items or [None] * 9
        height = 3 * item_height + 2 * gap_y
        width = 3 * item_width + 2 * gap_x
        self.line_border_color = line_border_color
        self.line_background_color = line_background_color
        self.rect = Rect(left, top, width, height)

    def __draw_items(self, screen):
        for column in range(3):
            for row in range(3):
                index = row * 3 + column

                if index >= len(self.items):
                    break

                top = self.rect.top + row * (self.item_height + self.gap_y)
                left = self.rect.left + column * (self.item_width + self.gap_x)
                item = self.items[index]

                if item:
                    item.draw(screen, top, left)

    def __draw_win_line(self, screen):
        if not self.win_line:
            return

        padding = 10
        start_pos, end_pos = None, None

        if self.win_line == 0:
            y = self.rect.top + self.item_height // 2
            start_pos = (self.rect.left + padding, y)
            end_pos = (self.rect.right - padding, y)

        if self.win_line == 1:
            y = self.rect.top + self.item_height + self.gap_y + self.item_height // 2
            start_pos = (self.rect.left + padding, y)
            end_pos = (self.rect.right - padding, y)

        if self.win_line == 2:
            y = self.rect.bottom - self.item_height // 2
            start_pos = (self.rect.left + padding, y)
            end_pos = (self.rect.right - padding, y)

        if self.win_line == 3:
            x = self.rect.left + self.item_width // 2
            start_pos = (x, self.rect.top + padding)
            end_pos = (x, self.rect.bottom - padding)

        if self.win_line == 4:
            x = self.rect.left + self.item_width + self.gap_x + self.item_width // 2
            start_pos = (x, self.rect.top + padding)
            end_pos = (x, self.rect.bottom - padding)

        if self.win_line == 5:
            x = self.rect.right - self.item_width // 2
            start_pos = (x, self.rect.top + padding)
            end_pos = (x, self.rect.bottom - padding)

        if self.win_line == 6:
            start_pos = (self.rect.left + padding, self.rect.top + padding)
            end_pos = (self.rect.right - padding, self.rect.bottom - padding)

        if self.win_line == 7:
            start_pos = (self.rect.right - padding, self.rect.top + padding)
            end_pos = (self.rect.left + padding, self.rect.bottom - padding)

        draw.line(
            screen,
            self.line_border_color,
            start_pos,
            end_pos,
            width=8,
        )

        draw.line(
            screen,
            self.line_background_color,
            start_pos,
            end_pos,
            width=6,
        )

    def draw(self, screen):
        draw.rect(screen, self.background_color, self.rect)
        self.__draw_items(screen)
        self.__draw_win_line(screen)
Enter fullscreen mode Exit fullscreen mode

Here we are done with all necessary ui components for building the game, so let's put it all together in the nest section.


Constructing and Building game

Before putting everything together, I will use the very first template from the Pygame documentation. Now the game itself can be initialized. After that, I created a few functions to retrieve the necessary parameters from the Game class and format them for the UI components. The update function is responsible for creating all the necessary components and is also called within the button callback functions to trigger UI re-rendering. And that’s basically it—the game is ready to play!

import pygame
from game import Game
from ui.board import Board
from ui.keypad import Keypad
from pygame.locals import QUIT
from ui.scorebar import Scorebar
from ui.board_button import BoardButton
from config import PALE_GOLDENROD_COLOR

pygame.init()
screen = pygame.display.set_mode((332, 522))
clock = pygame.time.Clock()

padding = 10
running = True
game = Game()


def create_board_button_callback(index):
    def callback():
        game.move(index)
        update()

    return callback


def create_keypad_button_callback(fn):
    def callback():
        fn()
        update()

    return callback


def create_scorebar_params():
    return {
        "turn": game.turn,
        "is_full": game.is_full,
        "score_x": game.score_x,
        "score_o": game.score_o,
        "move_count": game.move_count,
        "player_won": game.player_won,
    }


def create_board_params():
    buttons = []
    for index, state in enumerate(game.board):
        callback = create_board_button_callback(index)
        button = BoardButton(state=state, callback=callback)
        buttons.append(button)
    return {"items": buttons, "win_line": game.win_line}


def create_keypad_params():
    left_text = "Reset Game"
    left_callback = create_keypad_button_callback(game.rest_game)
    right_text = "New Round" if game.player_won else "Reset Round"
    right_callback = create_keypad_button_callback(game.new_round)

    return {
        "left_text": left_text,
        "right_text": right_text,
        "left_callback": left_callback,
        "right_callback": right_callback,
    }


def update():
    global board
    global keypad
    global scorebar
    scorebar = Scorebar(top=padding, left=padding, **create_scorebar_params())
    board = Board(top=(2 * padding) + 100, left=padding, **create_board_params())
    keypad = Keypad(top=(4 * padding) + 412, left=padding, **create_keypad_params())


update()

while running:
    for event in pygame.event.get():
        if event.type == QUIT:
            running = False

    screen.fill(PALE_GOLDENROD_COLOR)

    scorebar.draw(screen)
    board.draw(screen)
    keypad.draw(screen)

    pygame.display.flip()
    clock.tick(30)

pygame.quit()
Enter fullscreen mode Exit fullscreen mode

Here is a small preview on the game:

game-show

Now the only thing left to do is to bundle the game so it can be played without requiring Python to be installed on your system. For this, I will use PyInstaller. It’s straightforward and will package the game into a single executable file.


Conclusion

Oh, now that I’m looking at this article, there’s quite a lot of code for a simple game... But nevertheless, it was an interesting experience to build Tic-Tac-Toe for the first time in my life. I’m not entirely sure I used Python in the best way throughout, but I hope you’ll let me know in the comments if I made any mistakes. For now, that’s it. I hope you enjoyed reading this! And here’s the link to the repository with the game


Image of Datadog

How to Diagram Your Cloud Architecture

Cloud architecture diagrams provide critical visibility into the resources in your environment and how they’re connected. In our latest eBook, AWS Solution Architects Jason Mimick and James Wenzel walk through best practices on how to build effective and professional diagrams.

Download the Free eBook

Top comments (0)

Postmark Image

Speedy emails, satisfied customers

Are delayed transactional emails costing you user satisfaction? Postmark delivers your emails almost instantly, keeping your customers happy and connected.

Sign up