Python for Humans · Draft v0.1 · Oct 28, 2026 · Project · First recurring-project draft.
Tic-Tac-Toe: Can You Beat It?
Optional project · After Testing
- You should already know
- Meet Your Opponent and Testing, including direct calls and assert.
- You'll build
- An opponent that takes an immediate win and blocks an immediate loss.
- You'll be able to
- Describe an algorithm, try possible moves, restore state, and test decisions.
- You need
- This page. Each example runs in its own code box; nothing to install.
Our computer can make legal moves, but it sometimes watches you complete a row without doing anything about it. Let's give it a little foresight.
This optional project takes about 25–40 minutes. First we'll describe a plan in plain English, then check its decisions on tiny examples before playing a whole game.
Start with the Random Opponent
This is the complete Meet Your Opponent game. Run it once, then keep this box as your working copy.
import random def show_board(board): print(f"{board[0]} | {board[1]} | {board[2]}") print("--+---+--") print(f"{board[3]} | {board[4]} | {board[5]}") print("--+---+--") print(f"{board[6]} | {board[7]} | {board[8]}") def make_move(board, square, player): if square in ['1', '2', '3', '4', '5', '6', '7', '8', '9']: position = int(square) - 1 if board[position] in ['X', 'O']: print("That square is taken. Try another one.") return False board[position] = player return True print("Please choose a number from 1 to 9.") return False def check_winner(board, player): lines = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6] ] for line in lines: matches = 0 for position in line: if board[position] == player: matches = matches + 1 if matches == 3: return True return False def available_squares(board): choices = [] for square in board: if square in ['1', '2', '3', '4', '5', '6', '7', '8', '9']: choices.append(square) return choices board = ['1', '2', '3', '4', '5', '6', '7', '8', '9'] player = 'X' moves = 0 while moves < 9: show_board(board) if player == 'X': square = input("X, choose a square (1-9, or quit): ") else: square = random.choice(available_squares(board)) print("Computer chooses", square) if square == 'quit': break if make_move(board, square, player): moves = moves + 1 if check_winner(board, player): show_board(board) print(f"{player} wins!") break elif moves == 9: show_board(board) print("It's a draw!") else: if player == 'X': player = 'O' else: player = 'X' print("Thanks for playing!")
Give It a Plan
An algorithm is a set of steps for solving a problem. Our opponent's algorithm will be:
- If O can win on this move, take that square.
- Otherwise, if X could win next, take that square to block it.
- Otherwise, choose randomly from the available squares.
The order matters. If both players have a possible win, O can finish the game now instead of blocking X.
This isn't an unbeatable strategy. Two threats at once can still catch it out. We're giving it a small plan we can understand and check.
Try a Move, Then Undo It
How do we find a winning opportunity? We already have a win checker. Temporarily place a mark in an available square, ask check_winner(), then restore the square's original label.
Write winning_move(board, player) following these steps:
- Loop over
available_squares(board). - Convert each label to a position and place the supplied player's mark there.
- Store the result of
check_winner(board, player)inwins. - Restore the label before returning or moving to another square.
- If
winsis true, return that square label. If no square wins, return''.
The empty string means we didn't find a winning move. It can't be confused with a valid square label.
Here's the helper to compare with your attempt:
def winning_move(board, player): for square in available_squares(board): position = int(square) - 1 board[position] = player wins = check_winner(board, player) board[position] = square if wins: return square return ''
The board is shared mutable state. Forgetting to undo a trial move would put extra marks on the real board, even though we hadn't accepted a turn.
Choose in Order
Now write choose_move(board). Ask winning_move(board, 'O') first. If that returns a label, return it immediately. Otherwise try 'X', then fall back to random.choice().
If there are no available squares, return ''. The main game won't request a move then, but giving this helper an explicit answer makes it easier to check by itself.
Check Before Playing
Use fixed boards so you know what should happen. The first board below gives both players a threat; O should choose its own win. The second needs a block. The third has no immediate threat, so we check that the result is legal rather than demanding one particular random answer.
This box includes all the helpers it needs. Run it, then compare its definitions with yours:
import random def check_winner(board, player): lines = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6] ] for line in lines: matches = 0 for position in line: if board[position] == player: matches = matches + 1 if matches == 3: return True return False def available_squares(board): choices = [] for square in board: if square in ['1', '2', '3', '4', '5', '6', '7', '8', '9']: choices.append(square) return choices def winning_move(board, player): for square in available_squares(board): position = int(square) - 1 board[position] = player wins = check_winner(board, player) board[position] = square if wins: return square return '' def choose_move(board): square = winning_move(board, 'O') if square != '': return square square = winning_move(board, 'X') if square != '': return square choices = available_squares(board) if len(choices) == 0: return '' return random.choice(choices) board = ['O', 'O', '3', 'X', 'X', '6', '7', '8', '9'] assert choose_move(board) == '3' assert board == ['O', 'O', '3', 'X', 'X', '6', '7', '8', '9'] board = ['X', 'X', '3', '4', 'O', '6', '7', '8', '9'] assert choose_move(board) == '3' assert board == ['X', 'X', '3', '4', 'O', '6', '7', '8', '9'] board = ['1', '2', '3', '4', 'X', '6', '7', '8', '9'] assert choose_move(board) in available_squares(board) assert board == ['1', '2', '3', '4', 'X', '6', '7', '8', '9'] print('Strategy checks passed.')
An assert stops with an error if its condition is false. Reaching Strategy checks passed. means every check held. The checks after each choice also confirm that trial moves left the original board alone.
Break it on purpose: In this checking box, comment out board[position] = square inside winning_move(). Run again. Which assertion fails? Put the restoration back and rerun.
Put the Plan in the Game
Add the two new helpers to your working game. In O's turn, replace the random-choice line with square = choose_move(board). Keep the normal move validation and win checking afterward.
Here's the full result, ready to play independently:
import random def show_board(board): print(f"{board[0]} | {board[1]} | {board[2]}") print("--+---+--") print(f"{board[3]} | {board[4]} | {board[5]}") print("--+---+--") print(f"{board[6]} | {board[7]} | {board[8]}") def make_move(board, square, player): if square in ['1', '2', '3', '4', '5', '6', '7', '8', '9']: position = int(square) - 1 if board[position] in ['X', 'O']: print("That square is taken. Try another one.") return False board[position] = player return True print("Please choose a number from 1 to 9.") return False def check_winner(board, player): lines = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6] ] for line in lines: matches = 0 for position in line: if board[position] == player: matches = matches + 1 if matches == 3: return True return False def available_squares(board): choices = [] for square in board: if square in ['1', '2', '3', '4', '5', '6', '7', '8', '9']: choices.append(square) return choices def winning_move(board, player): for square in available_squares(board): position = int(square) - 1 board[position] = player wins = check_winner(board, player) board[position] = square if wins: return square return '' def choose_move(board): square = winning_move(board, 'O') if square != '': return square square = winning_move(board, 'X') if square != '': return square choices = available_squares(board) if len(choices) == 0: return '' return random.choice(choices) board = ['1', '2', '3', '4', '5', '6', '7', '8', '9'] player = 'X' moves = 0 while moves < 9: show_board(board) if player == 'X': square = input("X, choose a square (1-9, or quit): ") else: square = choose_move(board) print("Computer chooses", square) if square == 'quit': break if make_move(board, square, player): moves = moves + 1 if check_winner(board, player): show_board(board) print(f"{player} wins!") break elif moves == 9: show_board(board) print("It's a draw!") else: if player == 'X': player = 'O' else: player = 'X' print("Thanks for playing!")
Give It Another Test
Choose one bonus:
- Add a test for a winning diagonal and one for a winning column. Check that trial moves still leave each board unchanged.
- Prefer the center when neither player has an immediate win. Write a board and expected result before changing the algorithm.
An unbeatable opponent is a larger optional capstone after the core course. Searching several moves ahead needs its own explanation and tests; a win-or-block rule alone doesn't promise perfect play.
Describe one bug a trial move could cause if it weren't undone, and the test that would catch it.