TL;DR
  • You can build a full tic tac toe game in Python with under 90 lines of code - No libraries beyond the built-in math module.
  • The tricky part isn't drawing the board, it's teaching the computer to play well.
  • Minimax is the algorithm that makes the computer unbeatable: it plays out every possible ending and always picks the path that can't lose.
  • The full script is at the end of this guide. Copy it, run it, and you're playing in under a minute.

What We're Building

We're writing a plain text, terminal-based tic tac toe game. You'll play as X, typing a number 1 through 9 for the square you want. The computer plays O, and it uses a classic search technique called minimax so it never makes a bad move. The best you can do against it is a draw.

What You'll Need

You only need Python itself. The whole game fits in one file and uses just the built-in math module. No graphics library, no game engine, and nothing to install.

  • Python 3.6 or newer, already on most Macs and Linux machines
  • A terminal or command prompt
  • About ten minutes to type or paste the code below

How the Game Will Play Out

Each turn follows the same short loop. You type a square number, the game checks that the move is legal, and the computer answers right away. That loop repeats until someone wins or the board fills up.

If you'd rather learn the strategy without touching any code, the how to win guide covers the same ideas in plain language. And once you understand why minimax can't be beaten, our math and game theory page breaks down the reasoning behind it in more depth.

Setting Up the Board

Representing the Board as a List

The board is just a list with nine slots, one for each square, holding either "X", "O", or a blank space for empty. Squares 0 through 8 map to positions left to right, top to bottom, the same way you'd read a page.

Printing the Board to the Terminal

import math

def print_board(board):
    print()
    for i in range(0, 9, 3):
        row = board[i:i+3]
        print(" " + " | ".join(row))
        if i < 6:
            print("---+---+---")
    print()

That print_board function slices the list into three rows of three. It joins them with "|" characters, so the output looks like an actual grid in the terminal. The check if i < 6 just stops it from printing a dividing line after the last row.

Letting Two Players Move

Checking Every Move Is Legal

Before worrying about the computer, get two humans able to play the game correctly. That means checking a few things every single turn:

  • Is the move actually a number from 1 through 9?
  • Is that square already taken?
  • Did that move just win the game?

These are just the standard tic tac toe rules translated into code. If you've ever played this with a friend on paper, or on our own two player board, you already know these checks by heart. You just haven't written them down yet.

Detecting a Winner or a Full Board

def check_winner(board, player):
    wins = [
        (0,1,2), (3,4,5), (6,7,8),
        (0,3,6), (1,4,7), (2,5,8),
        (0,4,8), (2,4,6)
    ]
    return any(board[a] == board[b] == board[c] == player for a, b, c in wins)

def is_full(board):
    return " " not in board

check_winner tries all eight possible winning lines and returns True the moment it finds one filled with the same player's mark. is_full is even simpler. It just checks whether there's a blank square left anywhere on the board.

Teaching the Computer to Never Lose

How Minimax Thinks Through the Game

This is the part that actually makes the game interesting. Minimax works by having the computer imagine every possible way the rest of the game could play out. It looks ahead move by move, all the way to the end, and scores each ending. A win for the computer is good. A win for the human is bad. A draw sits right in the middle. Then it works backward and picks the move that leads to the best score it can guarantee, assuming you play your best too.

Since tic tac toe only has nine squares total, this search finishes almost instantly. There's no shortcut needed and no guessing. The computer genuinely checks everything before it moves.

Writing the Minimax Function in Python

def minimax(board, depth, is_maximizing):
    if check_winner(board, "O"):
        return 10 - depth
    if check_winner(board, "X"):
        return depth - 10
    if is_full(board):
        return 0

    if is_maximizing:
        best_score = -math.inf
        for i in range(9):
            if board[i] == " ":
                board[i] = "O"
                score = minimax(board, depth + 1, False)
                board[i] = " "
                best_score = max(best_score, score)
        return best_score
    else:
        best_score = math.inf
        for i in range(9):
            if board[i] == " ":
                board[i] = "X"
                score = minimax(board, depth + 1, True)
                board[i] = " "
                best_score = min(best_score, score)
        return best_score

def best_move(board):
    best_score = -math.inf
    move = None
    for i in range(9):
        if board[i] == " ":
            board[i] = "O"
            score = minimax(board, 0, False)
            board[i] = " "
            if score > best_score:
                best_score = score
                move = i
    return move

The Depth Argument: Winning Sooner, Losing Later

The depth argument is a small but important trick. Without it, the computer would still never lose, but it might drag out a win it could have finished three moves earlier. Here's what it actually does, in plain terms:

  • A quick win scores higher than a slow win, so the computer grabs the fast one.
  • A slow loss scores higher than a fast loss, so if it's ever cornered, it stalls instead of giving up early.
  • Every extra move shaves a point off the final score, and that's the whole trick.

The Full Script, Start to Finish

Copy, Save, and Run It

Here's everything combined into one file. Save it as tic_tac_toe.py and run it with python3 tic_tac_toe.py in a terminal, and you're playing.

import math

def print_board(board):
    print()
    for i in range(0, 9, 3):
        row = board[i:i+3]
        print(" " + " | ".join(row))
        if i < 6:
            print("---+---+---")
    print()

def check_winner(board, player):
    wins = [
        (0,1,2), (3,4,5), (6,7,8),
        (0,3,6), (1,4,7), (2,5,8),
        (0,4,8), (2,4,6)
    ]
    return any(board[a] == board[b] == board[c] == player for a, b, c in wins)

def is_full(board):
    return " " not in board

def minimax(board, depth, is_maximizing):
    if check_winner(board, "O"):
        return 10 - depth
    if check_winner(board, "X"):
        return depth - 10
    if is_full(board):
        return 0

    if is_maximizing:
        best_score = -math.inf
        for i in range(9):
            if board[i] == " ":
                board[i] = "O"
                score = minimax(board, depth + 1, False)
                board[i] = " "
                best_score = max(best_score, score)
        return best_score
    else:
        best_score = math.inf
        for i in range(9):
            if board[i] == " ":
                board[i] = "X"
                score = minimax(board, depth + 1, True)
                board[i] = " "
                best_score = min(best_score, score)
        return best_score

def best_move(board):
    best_score = -math.inf
    move = None
    for i in range(9):
        if board[i] == " ":
            board[i] = "O"
            score = minimax(board, 0, False)
            board[i] = " "
            if score > best_score:
                best_score = score
                move = i
    return move

def play():
    board = [" "] * 9
    print("You are X. The computer is O. Squares are numbered 1-9, left to right, top to bottom.")
    print_board([str(i+1) if c == " " else c for i, c in enumerate(board)])

    while True:
        move = None
        while move is None:
            raw = input("Your move (1-9): ").strip()
            if raw.isdigit() and 1 <= int(raw) <= 9 and board[int(raw)-1] == " ":
                move = int(raw) - 1
            else:
                print("That square is taken or isn't a number 1-9. Try again.")
        board[move] = "X"

        if check_winner(board, "X"):
            print_board(board)
            print("You win! Nice job beating the odds.")
            break
        if is_full(board):
            print_board(board)
            print("It's a draw.")
            break

        cpu_move = best_move(board)
        board[cpu_move] = "O"
        print_board(board)

        if check_winner(board, "O"):
            print("The computer wins. It never misses a mistake.")
            break
        if is_full(board):
            print("It's a draw.")
            break

if __name__ == "__main__":
    play()

What to Expect the First Time You Play

Type a number 1 through 9 on your turn. The computer replies instantly, since minimax on a 3x3 board barely takes any real computing time to finish. Every game against it ends in a computer win or a draw, never a loss for the computer. So don't be surprised if your first few tries all end the same way.

Your Own Unbeatable Opponent, Ready to Run

Ways to Extend the Project

Once this is working, you've basically built your own miniature version of what runs behind our Hard mode game. It's the same idea: searching every outcome before picking a move. A few ways to take it further:

  • Change the depth penalty and see how it shifts which move the computer picks first.
  • Add a scoreboard that counts wins across multiple rounds, so you can see how often you force a draw.
  • Wrap the same logic in a simple window instead of a terminal, using a GUI toolkit like Tkinter.
  • Port the whole thing to JavaScript with our guide to coding tic tac toe in JavaScript, then compare the two versions side by side.

Minimax vs. Random Moves: The Numbers

One more thing worth trying: change a single line so the computer picks a random legal move instead of calling best_move. Then watch how much easier it suddenly becomes to beat. We tested this ourselves. Using the exact code above, we ran 500 simulated games with a random legal move standing in for typed input on both sides. That keeps the comparison fair. One batch kept the computer on minimax. The other swapped it for a random pick.

Computer's strategy (O)Human winsComputer winsDraws
Minimax (best_move)0%81%19%
Random legal move56%31%13%

Swap one function call, best_move for a random pick, and the "unbeatable" computer suddenly loses more than half its games. That gap is the entire point of minimax: it doesn't get lucky, and it doesn't get unlucky either, it just calculates. For a deeper look at why the gap is so wide, our minimax vs. random AI comparison breaks it down move by move.