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:

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

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:
| Setting | Value | Why |
|---|---|---|
CELL | 200 px | One square of the board |
BOARD | 600 px | 3 squares across and down |
BAR | 80 px | Status bar for turn, result and score |
| Window | 600 x 680 px | BOARD wide, BOARD + BAR tall |
| Players | X then O | X always opens a round |
| Restart key | R | Clears 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.

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.

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()

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

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()

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()

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"

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.

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"

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

| Problem | Fix |
|---|---|
IndexError: list index out of range on a click | Return 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 appear | Call 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 win | Only 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:
- Create a Snake game with Python Tkinter
- Turtle programming in Python
- Python turtle graphics examples
- Handle mouse clicks with Python turtle
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.

Bijay Kumar is a 13-time Microsoft MVP with more than 18 years in software development, and the founder of Python Guides and TSinfo Technologies. He started out building .NET and SharePoint solutions at HP, TCS and KPIT before moving into Python, machine learning and AI, and he also builds web apps with TypeScript and React. He writes the tutorials here himself, and every example is run before publishing so you see the real output. More about Bijay · Microsoft MVP profile · LinkedIn