Create a Tic Tac Toe Game Using Python Pygame

Tic tac toe is the first game I build with anyone who’s new to Pygame, because it needs every core idea and still fits in about 150 lines.

Creating a game with Python and Pygame comes down to four jobs: open a window, run a loop that reads mouse and keyboard events, redraw the board every frame and check the rules after each move. Here’s the finished game after three real rounds:

Tic tac toe game window built with Python Pygame showing X winning on the diagonal with a red line and the score X 2, O 0, Draw 1
The finished game: X takes the diagonal in round three, and the bar keeps the running score.

In this tutorial, I’ll build that pygame tic tac toe game one step at a time. Each step adds a few lines to the same file, and you’ll run it after every step.

  • A 600 x 680 window with a 3 x 3 grid and a status bar
  • Mouse clicks that place X and O in empty squares only
  • Win and draw detection with a red winning line
  • A score, an R key to restart, and an optional computer player

Before You Start: install Pygame for the tic tac toe game

You need Python 3.9 or newer and one package. I used pygame-ce (the community edition), installed with pip install pygame-ce; it’s imported as pygame, exactly like the original package.

Important: install either pygame or pygame-ce, not both. They share the same import name, so a second install overwrites the first one’s files. A Python virtual environment keeps a game project clean.

python -m venv venv
venv\Scripts\activate
pip install pygame-ce

Then check that Python can import it:

import platform
import pygame

print("Python:", platform.python_version())
print("Pygame:", pygame.version.ver)
print("SDL:", ".".join(str(n) for n in pygame.get_sdl_version()))

Output:

pygame-ce 2.5.8 (SDL 2.32.10, Python 3.12.5)
Python: 3.12.5
Pygame: 2.5.8
SDL: 2.32.10
Command Prompt running a script that prints the Python, pygame-ce and SDL versions
Importing pygame-ce prints its banner first, then the script shows the Python, Pygame and SDL versions.

Mine reported Python 3.12.5, Pygame 2.5.8 and SDL 2.32.10. These are the settings the whole game uses, so you can change one number later and everything follows:

SettingValueWhy
CELL200 pxOne square of the board
BOARD600 px3 squares across and down
BAR80 pxStatus bar for turn, result and score
Window600 x 680 pxBOARD wide, BOARD + BAR tall
PlayersX then OX always opens a round
Restart keyRClears the board and keeps the score

Step 1: Open the Pygame window and run the game loop

Every Pygame game is a loop: read events, update the game, draw a frame, repeat. Create tictactoe.py with the settings and a loop that only paints the background (it’s a plain Python while loop that stops when you close the window):

import sys
import pygame

CELL = 200                    # each square is 200 x 200 pixels
BOARD = CELL * 3              # the 3 x 3 board is 600 pixels wide
BAR = 80                      # status bar under the board
WIDTH, HEIGHT = BOARD, BOARD + BAR

BG = (28, 170, 156)           # teal background
GRID = (21, 128, 118)         # darker teal grid lines
X_COLOR = (52, 58, 64)        # charcoal X
O_COLOR = (242, 235, 211)     # cream O
WIN_COLOR = (230, 57, 70)     # red winning line
BAR_COLOR = (16, 42, 67)      # navy status bar
TEXT = (255, 255, 255)

pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Tic Tac Toe - Pygame")
clock = pygame.time.Clock()

running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:       # the window's X button
            running = False

    screen.fill(BG)
    pygame.display.flip()                   # show the finished frame
    clock.tick(60)                          # at most 60 frames per second

pygame.quit()
sys.exit()

pygame.display.flip() pushes the frame you drew to the screen, and clock.tick(60) caps the loop at 60 frames per second, so the game doesn’t burn a whole CPU core.

After the loop, pygame.quit() closes the window and sys.exit() ends the Python program.

Empty teal Pygame window titled Tic Tac Toe - Pygame opened by the game loop
The real window: 600 x 680 pixels of teal, with the caption from set_caption().

Checkpoint: run python tictactoe.py. A teal window titled “Tic Tac Toe – Pygame” opens, and the X button closes it cleanly.

Step 2: Draw the tic tac toe grid with pygame.draw.line()

The grid is four lines: two horizontal at y = 200 and 400, and two vertical at x = 200 and 400. Put the drawing in a function so the loop can call it every frame:

def draw_grid():
    screen.fill(BG)
    for i in (1, 2):
        # horizontal line at y = 200 and y = 400
        pygame.draw.line(screen, GRID, (0, i * CELL), (BOARD, i * CELL), 10)
        # vertical line at x = 200 and x = 400
        pygame.draw.line(screen, GRID, (i * CELL, 0), (i * CELL, BOARD), 10)

In the loop, replace screen.fill(BG) with draw_grid(). Pygame doesn’t remember what’s on screen, so we repaint the background first and the lines on top, every frame.

Pygame window with a 3 x 3 tic tac toe grid drawn with pygame.draw.line on a teal background
Two horizontal and two vertical lines split the top 600 pixels into nine squares.

If you’ve drawn shapes in Tkinter before, this is the same idea as a canvas; my Tkinter canvas guide shows the event-driven version of it.

Step 3: Place X and O with mouse clicks in Pygame

Now the game needs memory. The board is a list of three lists, one per row, holding None, "X" or "O"; that’s the usual way to initialize a 2D array in Python without NumPy.

board = [[None] * 3 for _ in range(3)]      # None, "X" or "O"
player = "X"                                # X always moves first


def cell_from_pos(pos):
    """Turn a mouse position into (row, col), or None for the status bar."""
    x, y = pos
    if y >= BOARD:
        return None
    return y // CELL, x // CELL


def draw_marks():
    for row in range(3):
        for col in range(3):
            cx = col * CELL + CELL // 2     # center of this square
            cy = row * CELL + CELL // 2
            if board[row][col] == "X":
                d = 55
                pygame.draw.line(screen, X_COLOR, (cx - d, cy - d), (cx + d, cy + d), 20)
                pygame.draw.line(screen, X_COLOR, (cx - d, cy + d), (cx + d, cy - d), 20)
            elif board[row][col] == "O":
                pygame.draw.circle(screen, O_COLOR, (cx, cy), 60, 14)

Build the board with a list comprehension, not [[None] * 3] * 3. That shortcut makes three references to one row, so an X in the top row would also appear in the other two rows.

cell_from_pos() uses floor division: a click at x = 330, y = 120 gives row 0, column 1. The status bar check matters, and you’ll see why in the troubleshooting section.

draw_marks() walks every square with two nested loops (the same pattern as when you iterate through a 2D array) and draws an X as two thick lines or an O as a ring. Here’s the loop that handles left clicks:

running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
            cell = cell_from_pos(event.pos)
            if cell and board[cell[0]][cell[1]] is None:    # empty square only
                row, col = cell
                board[row][col] = player
                player = "O" if player == "X" else "X"

    draw_grid()
    draw_marks()
    pygame.display.flip()
    clock.tick(60)

pygame.quit()
sys.exit()
Pygame tic tac toe board with X in the center and top-right corner and O in the top-left and bottom-left squares
Six scripted clicks: four landed, a repeat click on the center and a click on the bar were ignored.

Checkpoint: my demo clicked the center, top-left, top-right, bottom-left, the center again and the status bar. The board held exactly four marks, [['O', None, 'X'], [None, 'X', None], ['O', None, None]], and it was X’s turn.

Step 4: Check for a tic tac toe winner or a draw

There are only eight ways to win: three rows, three columns and two diagonals. List them once as cell coordinates, then test each line after every move:

LINES = [[(r, 0), (r, 1), (r, 2)] for r in range(3)]            # 3 rows
LINES += [[(0, c), (1, c), (2, c)] for c in range(3)]           # 3 columns
LINES += [[(0, 0), (1, 1), (2, 2)], [(0, 2), (1, 1), (2, 0)]]   # 2 diagonals

winner, win_line = None, None


def check_winner(board):
    """Return ("X" or "O", winning cells), ("Draw", None) or (None, None)."""
    for line in LINES:
        a, b, c = (board[r][col] for r, col in line)
        if a is not None and a == b == c:
            return a, line
    if all(cell is not None for row in board for cell in row):
        return "Draw", None
    return None, None


def draw_win_line(line):
    (r1, c1), (r2, c2) = line[0], line[-1]
    start = (c1 * CELL + CELL // 2, r1 * CELL + CELL // 2)
    end = (c2 * CELL + CELL // 2, r2 * CELL + CELL // 2)
    pygame.draw.line(screen, WIN_COLOR, start, end, 12)

check_winner() returns two things at once, the winning mark and its line (see returning multiple values from a function). It gives "Draw" for a full board and None while the game is on.

The draw test runs Python all() over a generator that walks the rows and then the cells, the same trick used to flatten a list of lists.

Before wiring it into the window, test the logic by itself. That’s much faster than clicking through games:

LINES = [[(r, 0), (r, 1), (r, 2)] for r in range(3)]            # 3 rows
LINES += [[(0, c), (1, c), (2, c)] for c in range(3)]           # 3 columns
LINES += [[(0, 0), (1, 1), (2, 2)], [(0, 2), (1, 1), (2, 0)]]   # 2 diagonals



def check_winner(board):
    """Return ("X" or "O", winning cells), ("Draw", None) or (None, None)."""
    for line in LINES:
        a, b, c = (board[r][col] for r, col in line)
        if a is not None and a == b == c:
            return a, line
    if all(cell is not None for row in board for cell in row):
        return "Draw", None
    return None, None

games = {
    "Top row":    [["X", "X", "X"], ["O", "O", None], [None, None, None]],
    "Diagonal":   [["O", "X", "X"], ["X", "O", None], [None, None, "O"]],
    "Full board": [["X", "O", "X"], ["X", "O", "O"], ["O", "X", "X"]],
    "In play":    [["X", None, None], [None, "O", None], [None, None, None]],
}
for name, b in games.items():
    who, line = check_winner(b)
    print(f"{name:<11} -> {who}  {line}")

Output:

Top row     -> X  [(0, 0), (0, 1), (0, 2)]
Diagonal    -> O  [(0, 0), (1, 1), (2, 2)]
Full board  -> Draw  None
In play     -> None  None
Command Prompt output of check_winner tested on a top-row win, a diagonal win, a full board draw and a game in play
A top-row win, a diagonal win for O, a full board that’s a draw, and a game still in play.

All four answers match the boards. Now change the click handler: it unpacks the returned tuple, only switches players while nobody has won, and ignores clicks once there’s a winner. The win line is drawn after the marks:

running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1 and winner is None:
            cell = cell_from_pos(event.pos)
            if cell and board[cell[0]][cell[1]] is None:
                row, col = cell
                board[row][col] = player
                winner, win_line = check_winner(board)
                if winner is None:                          # game still on
                    player = "O" if player == "X" else "X"

    draw_grid()
    draw_marks()
    if win_line:
        draw_win_line(win_line)
    pygame.display.flip()
Pygame tic tac toe window with X winning on the diagonal and a red line drawn by draw_win_line
X completes the diagonal, the red line appears, and a later click on the bottom-left square changes nothing.

Checkpoint: the demo’s last click on the bottom-left square came after X won, and the board stayed at five marks with winner set to X.

Step 5: Show whose turn it is in a Pygame status bar

Players need to know whose move it is and how the round ended. Pygame draws text in two steps: font.render() turns a string into an image, and screen.blit() pastes that image onto the window.

font = pygame.font.Font(None, 44)           # None = Pygame's built-in font
small = pygame.font.Font(None, 30)

def draw_status():
    pygame.draw.rect(screen, BAR_COLOR, (0, BOARD, WIDTH, BAR))
    if winner == "Draw":
        msg = "It's a draw!"
    elif winner:
        msg = f"{winner} wins!"
    else:
        msg = f"{player}'s turn"
    text = font.render(msg, True, TEXT)
    screen.blit(text, text.get_rect(midleft=(24, BOARD + BAR // 2)))

The message itself is a normal f-string. If you ever want literal braces in the bar, you’ll need the rules for escaping curly braces in Python format strings.

pygame.font.Font(None, 44) loads Pygame’s built-in font, so you don’t ship a font file. Call draw_status() right after the marks:

    draw_grid()
    draw_marks()
    if win_line:
        draw_win_line(win_line)
    draw_status()                   # new: paint the bar last
    pygame.display.flip()
Pygame tic tac toe window with a navy status bar showing O's turn under the board
After three moves, the bar shows it’s O’s turn.

Checkpoint: after X, O, X, the bar reads “O’s turn” in white on navy, and the board above it is unchanged.

Step 6: Restart the Pygame tic tac toe game and keep score

A round ends quickly, so let players press R for another without losing the tally. The score is a dictionary keyed by result, which makes the update one line (more on that pattern in incrementing a dictionary value in Python).

score = {"X": 0, "O": 0, "Draw": 0}


def reset():
    """Clear the board for a new round; the score stays."""
    global board, player, winner, win_line
    board = [[None] * 3 for _ in range(3)]
    player, winner, win_line = "X", None, None


def draw_status():
    pygame.draw.rect(screen, BAR_COLOR, (0, BOARD, WIDTH, BAR))
    if winner == "Draw":
        msg = "It's a draw! Press R"
    elif winner:
        msg = f"{winner} wins! Press R"
    else:
        msg = f"{player}'s turn"
    text = font.render(msg, True, TEXT)
    screen.blit(text, text.get_rect(midleft=(24, BOARD + BAR // 2)))
    tally = small.render(f"X {score['X']}   O {score['O']}   Draw {score['Draw']}", True, TEXT)
    screen.blit(tally, tally.get_rect(midright=(WIDTH - 24, BOARD + BAR // 2)))

reset() needs global because it rebinds names that live at module level; setting global variables in Python functions explains the rule. In the event loop, add the R key and count each finished round:

        elif event.type == pygame.KEYDOWN and event.key == pygame.K_r:
            reset()
        ...
                winner, win_line = check_winner(board)
                if winner:
                    score[winner] += 1                      # "X", "O" or "Draw"
                else:
                    player = "O" if player == "X" else "X"
Pygame tic tac toe window after pressing R showing a new round in progress and the score X 1, O 0, Draw 0
X won round one, R cleared the board, and the tally on the right kept the win.

Checkpoint: after X won the top row and the demo pressed R, round two opened with two fresh moves, the bar showed “X’s turn”, and the score read X 1, O 0, Draw 0.

Test the Pygame Tic Tac Toe Game: full code

Here’s the complete tictactoe.py with every step in place. Copy it into one file and run it:

import sys
import pygame

CELL = 200                    # each square is 200 x 200 pixels
BOARD = CELL * 3              # the 3 x 3 board is 600 pixels wide
BAR = 80                      # status bar under the board
WIDTH, HEIGHT = BOARD, BOARD + BAR

BG = (28, 170, 156)           # teal background
GRID = (21, 128, 118)         # darker teal grid lines
X_COLOR = (52, 58, 64)        # charcoal X
O_COLOR = (242, 235, 211)     # cream O
WIN_COLOR = (230, 57, 70)     # red winning line
BAR_COLOR = (16, 42, 67)      # navy status bar
TEXT = (255, 255, 255)

pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Tic Tac Toe - Pygame")
clock = pygame.time.Clock()

def draw_grid():
    screen.fill(BG)
    for i in (1, 2):
        # horizontal line at y = 200 and y = 400
        pygame.draw.line(screen, GRID, (0, i * CELL), (BOARD, i * CELL), 10)
        # vertical line at x = 200 and x = 400
        pygame.draw.line(screen, GRID, (i * CELL, 0), (i * CELL, BOARD), 10)

board = [[None] * 3 for _ in range(3)]      # None, "X" or "O"
player = "X"                                # X always moves first


def cell_from_pos(pos):
    """Turn a mouse position into (row, col), or None for the status bar."""
    x, y = pos
    if y >= BOARD:
        return None
    return y // CELL, x // CELL


def draw_marks():
    for row in range(3):
        for col in range(3):
            cx = col * CELL + CELL // 2     # center of this square
            cy = row * CELL + CELL // 2
            if board[row][col] == "X":
                d = 55
                pygame.draw.line(screen, X_COLOR, (cx - d, cy - d), (cx + d, cy + d), 20)
                pygame.draw.line(screen, X_COLOR, (cx - d, cy + d), (cx + d, cy - d), 20)
            elif board[row][col] == "O":
                pygame.draw.circle(screen, O_COLOR, (cx, cy), 60, 14)

LINES = [[(r, 0), (r, 1), (r, 2)] for r in range(3)]            # 3 rows
LINES += [[(0, c), (1, c), (2, c)] for c in range(3)]           # 3 columns
LINES += [[(0, 0), (1, 1), (2, 2)], [(0, 2), (1, 1), (2, 0)]]   # 2 diagonals

winner, win_line = None, None


def check_winner(board):
    """Return ("X" or "O", winning cells), ("Draw", None) or (None, None)."""
    for line in LINES:
        a, b, c = (board[r][col] for r, col in line)
        if a is not None and a == b == c:
            return a, line
    if all(cell is not None for row in board for cell in row):
        return "Draw", None
    return None, None


def draw_win_line(line):
    (r1, c1), (r2, c2) = line[0], line[-1]
    start = (c1 * CELL + CELL // 2, r1 * CELL + CELL // 2)
    end = (c2 * CELL + CELL // 2, r2 * CELL + CELL // 2)
    pygame.draw.line(screen, WIN_COLOR, start, end, 12)

font = pygame.font.Font(None, 44)           # None = Pygame's built-in font
small = pygame.font.Font(None, 30)

score = {"X": 0, "O": 0, "Draw": 0}


def reset():
    """Clear the board for a new round; the score stays."""
    global board, player, winner, win_line
    board = [[None] * 3 for _ in range(3)]
    player, winner, win_line = "X", None, None


def draw_status():
    pygame.draw.rect(screen, BAR_COLOR, (0, BOARD, WIDTH, BAR))
    if winner == "Draw":
        msg = "It's a draw! Press R"
    elif winner:
        msg = f"{winner} wins! Press R"
    else:
        msg = f"{player}'s turn"
    text = font.render(msg, True, TEXT)
    screen.blit(text, text.get_rect(midleft=(24, BOARD + BAR // 2)))
    tally = small.render(f"X {score['X']}   O {score['O']}   Draw {score['Draw']}", True, TEXT)
    screen.blit(tally, tally.get_rect(midright=(WIDTH - 24, BOARD + BAR // 2)))

running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN and event.key == pygame.K_r:
            reset()
        elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1 and winner is None:
            cell = cell_from_pos(event.pos)
            if cell and board[cell[0]][cell[1]] is None:
                row, col = cell
                board[row][col] = player
                winner, win_line = check_winner(board)
                if winner:
                    score[winner] += 1                      # "X", "O" or "Draw"
                else:
                    player = "O" if player == "X" else "X"

    draw_grid()
    draw_marks()
    if win_line:
        draw_win_line(win_line)
    draw_status()
    pygame.display.flip()
    clock.tick(60)

pygame.quit()
sys.exit()

For the test run, I played a full game that nobody can win: X center, O top-left, X top-right, O bottom-left, X middle-left, O middle-right, X top-middle, O bottom-middle and X bottom-right.

Pygame tic tac toe window with a full board, no winning line and the status It's a draw! Press R with score Draw 1
Nine moves, no line of three: the bar says it’s a draw and adds it to the score.

All nine squares filled, no red line appeared, and the tally moved to Draw 1. Clicks after that do nothing until you press R.

How do I make the computer play O in Pygame tic tac toe?

Same game, one new function. The computer checks every empty square in order: can O win there, does X need blocking there, and otherwise it takes the center, a corner, then a side.

def computer_move(board):
    """O's move: win if it can, block X, then center, corners, sides."""
    empty = [(r, c) for r in range(3) for c in range(3) if board[r][c] is None]
    for mark in ("O", "X"):                 # 1) winning move  2) block X
        for r, c in empty:
            board[r][c] = mark
            done = check_winner(board)[0] == mark
            board[r][c] = None
            if done:
                return r, c
    for r, c in [(1, 1), (0, 0), (0, 2), (2, 0), (2, 2)]:
        if (r, c) in empty:
            return r, c
    return empty[0]

Then let O reply straight after each X move inside the click handler:

                if winner:
                    score[winner] += 1                      # "X", "O" or "Draw"
                else:
                    player = "O" if player == "X" else "X"
                if winner is None and player == "O":        # computer replies at once
                    row, col = computer_move(board)
                    board[row][col] = "O"
                    winner, win_line = check_winner(board)
                    if winner:
                        score[winner] += 1
                    else:
                        player = "X"
Pygame tic tac toe window where the computer playing O blocks X and then wins the middle row
I opened with corners. O took the center, blocked my column, then completed the middle row.

In my run, X played top-left, bottom-right, bottom-left and top-middle, and O won the middle row on its fourth move. This rule-based player is beatable; for a perfect opponent, see the minimax version in my tic tac toe in Python guide.

Troubleshooting Pygame tic tac toe errors

My first version of cell_from_pos() had no status bar check. A click on the bar at y = 650 gives row 3, and the board only has rows 0-2:

CELL = 200
board = [[None] * 3 for _ in range(3)]


def cell_from_pos(pos):              # first try: no status-bar check
    x, y = pos
    return y // CELL, x // CELL


row, col = cell_from_pos((300, 650))   # a click on the status bar
print("row", row, "col", col)
board[row][col] = "X"

Output (it crashes):

row 3 col 1
Traceback (most recent call last):
  File "C:\pyguides\click_status_bar.py", line 12, in <module>
    board[row][col] = "X"
    ~~~~~^^^^^
IndexError: list index out of range
Command Prompt traceback showing IndexError list index out of range after a click on the status bar gives row 3
Row 3 doesn’t exist, so the game crashes with an IndexError on the first click below the board.
ProblemFix
IndexError: list index out of range on a clickReturn None when y >= BOARD, as in Step 3
Window shows “Not Responding”Call pygame.event.get() every frame, even before the game logic exists
Marks don’t appearCall pygame.display.flip() after drawing, once per frame
ModuleNotFoundError: No module named 'pygame'Install pygame-ce into the same interpreter that runs the file: python -m pip install pygame-ce
Player switches after a winOnly swap player when check_winner() returns None

For the last row, test with winner is None rather than not winner; the difference is covered in checking if a variable is None in Python.

Note: each pygame.draw.line() call redraws only one shape, so if old marks stick around after R, you skipped screen.fill(BG) at the top of draw_grid().

Where to Take It Next with Pygame games

With the loop, events, drawing and rule checks in place, you’ve got the skeleton of most 2D games. A moving game like the Snake game in Python adds a timer and keyboard arrows to the same structure.

Three upgrades I’d try first: a short delay with pygame.time.set_timer() so the computer seems to think, a hover highlight using pygame.mouse.get_pos(), and sounds with pygame.mixer. For desktop apps rather than games, see Python GUI programming.

More Python game and drawing projects:

Frequently asked questions

How do I create a game using Python Pygame?

Install pygame-ce, open a window with pygame.display.set_mode(), then run a loop that reads pygame.event.get(), updates the game state, draws the frame and calls pygame.display.flip().

Should I use pygame or pygame-ce?

Either works for this game, and both are imported as pygame. I use pygame-ce because it’s actively released; just don’t install both in the same environment.

How do I detect which square was clicked in Pygame?

Read event.pos from a MOUSEBUTTONDOWN event and floor-divide by the square size: row = y // 200, col = x // 200.

How do I check for a tic tac toe winner in Python?

List the eight winning lines (3 rows, 3 columns, 2 diagonals) and test whether all three cells in any line hold the same mark. A full board with no line is a draw.

Why does my Pygame window say Not Responding?

The loop isn’t reading events. Call pygame.event.get() every frame, or Windows decides the program has stopped.

Where is the Pygame documentation?

See the pygame-ce documentation for display, draw, font and event reference pages.