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:

  1. If O can win on this move, take that square.
  2. Otherwise, if X could win next, take that square to block it.
  3. 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:

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:

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.