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:
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()
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)
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)
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)
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)
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)
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)
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()
Here is a small preview on the game:
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
Top comments (0)