← Back to blog

How to Build Tic-Tac-Toe in Python (Complete Beginner Tutorial)

A step-by-step guide. From empty file to unbeatable AI in about 100 lines of code. Β· 15 min read

Tic-tac-toe is the perfect first game to program. The rules fit in five lines. The whole project fits in around a hundred. And along the way, you'll touch nearly every fundamental Python concept: lists, loops, conditions, functions, user input, and a little bit of AI.

This tutorial assumes you know Python basics β€” variables, lists, functions, if statements, for loops. You don't need to know anything about game development or algorithms. We'll build the whole thing from scratch.

What we're building

A command-line tic-tac-toe game in Python 3 with three pieces:

  1. A working 2-player mode where two humans play on the same computer.
  2. A "dumb" AI that plays random moves.
  3. An "unbeatable" AI using the minimax algorithm.

You'll end up with a program that looks something like this when you run it:

Welcome to Tic-Tac-Toe!

 1 | 2 | 3
-----------
 4 | 5 | 6
-----------
 7 | 8 | 9

X, enter your move (1-9): 5

 1 | 2 | 3
-----------
 4 | X | 6
-----------
 7 | 8 | 9

O, enter your move (1-9): _

Let's get started.

Step 1: Representing the board

Open a new file called tictactoe.py. The first thing we need is some way to represent the board.

The simplest approach: a list of 9 strings, one per cell. Cells start as a space character (which renders as blank) and become "X" or "O" as moves happen.

board = [" "] * 9

We'll use index positions 0 through 8 internally, but display them to the user as 1 through 9 (like a phone keypad β€” top-left is 1, top-right is 3, bottom-right is 9). The mapping is just position - 1.

Now we need a function to print the board:

def print_board(board):
    print()
    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]} ")
    print()

That's it. Run it with a test board and you'll see your tic-tac-toe grid render in the terminal.

Step 2: Making moves

To make a move, we need to ask the player which cell, validate their answer, and update the board. Here's the function:

def get_move(board, player):
    while True:
        choice = input(f"{player}, enter your move (1-9): ")
        if not choice.isdigit():
            print("Please enter a number between 1 and 9.")
            continue
        position = int(choice) - 1
        if position < 0 or position > 8:
            print("That's not a valid square. Try 1-9.")
            continue
        if board[position] != " ":
            print("That square is already taken.")
            continue
        return position

This function keeps asking until the player gives valid input β€” a digit between 1 and 9 that refers to an empty cell. It returns the chosen position as a 0-indexed integer.

Step 3: Detecting wins

To know when the game is over, we need to check the eight winning lines (three rows, three columns, two diagonals).

WIN_LINES = [
    (0, 1, 2), (3, 4, 5), (6, 7, 8),   # rows
    (0, 3, 6), (1, 4, 7), (2, 5, 8),   # columns
    (0, 4, 8), (2, 4, 6),              # diagonals
]

def check_win(board, player):
    for line in WIN_LINES:
        if all(board[i] == player for i in line):
            return True
    return False

def is_draw(board):
    return all(cell != " " for cell in board)

The check_win function returns True if the specified player has three in a row. The is_draw function returns True when no empty cells remain. (You should check for wins before checking for a draw β€” a board can be full but with the last move being a winning one.)

Step 4: The full game loop

Now let's wire everything together. Here's the main loop:

def play_two_player():
    board = [" "] * 9
    current = "X"
    print("Welcome to Tic-Tac-Toe!")
    print_board(board)

    while True:
        position = get_move(board, current)
        board[position] = current
        print_board(board)

        if check_win(board, current):
            print(f"{current} wins!")
            return
        if is_draw(board):
            print("It's a draw!")
            return

        current = "O" if current == "X" else "X"

if __name__ == "__main__":
    play_two_player()

Save the file and run python tictactoe.py. You should be able to play a full two-player game in the terminal. Test it with yourself a few times β€” try a winning game, a losing game, and a draw. Make sure the win-detection catches all three rows, both diagonals, and so on.

Step 5: A random AI

Two-player mode is fun, but the real challenge is playing against a computer. Let's start with the simplest possible AI: one that picks moves at random.

import random

def get_random_move(board):
    available = [i for i, cell in enumerate(board) if cell == " "]
    return random.choice(available)

This is a bad AI. It misses obvious wins, fails to block, and generally plays like a six-year-old who's never seen the game before. But it's a useful starting point β€” you can verify your game loop works against an AI opponent before adding sophistication.

Modify your main loop to call get_random_move for O instead of asking the human:

def play_vs_random():
    board = [" "] * 9
    current = "X"
    print("You are X. The computer is O (random).")
    print_board(board)

    while True:
        if current == "X":
            position = get_move(board, current)
        else:
            position = get_random_move(board)
            print(f"Computer plays {position + 1}")
        board[position] = current
        print_board(board)

        if check_win(board, current):
            print(f"{current} wins!")
            return
        if is_draw(board):
            print("It's a draw!")
            return

        current = "O" if current == "X" else "X"

You should be able to easily beat this AI most of the time.

Step 6: A rule-based AI (smarter, still beatable)

Before jumping to minimax, let's build an AI that follows the priority list from our strategy guide. This kind of "rule-based" AI is a useful exercise β€” it forces you to think about how a human plays the game.

def get_smart_move(board, ai_player):
    opponent = "O" if ai_player == "X" else "X"

    # Priority 1: Win if possible
    for line in WIN_LINES:
        cells = [board[i] for i in line]
        if cells.count(ai_player) == 2 and cells.count(" ") == 1:
            for i in line:
                if board[i] == " ":
                    return i

    # Priority 2: Block opponent's win
    for line in WIN_LINES:
        cells = [board[i] for i in line]
        if cells.count(opponent) == 2 and cells.count(" ") == 1:
            for i in line:
                if board[i] == " ":
                    return i

    # Priority 3: Take the center
    if board[4] == " ":
        return 4

    # Priority 4: Take a corner
    for i in [0, 2, 6, 8]:
        if board[i] == " ":
            return i

    # Priority 5: Take an edge
    for i in [1, 3, 5, 7]:
        if board[i] == " ":
            return i

    return None  # No moves left

This AI plays reasonably well β€” it'll never miss an immediate win or fail to block. But it doesn't look more than one move ahead, so it misses fork setups. A determined human player can beat it.

Step 7: The unbeatable AI (minimax)

For an AI that never loses, we need minimax β€” the algorithm we explain in depth in our minimax article. The short version: the AI explores every possible sequence of moves to the end of the game, scores each outcome, and picks the move that gives the best score assuming the opponent plays optimally.

Here's the implementation:

def minimax(board, current, ai_player, depth=0):
    opponent = "O" if ai_player == "X" else "X"

    if check_win(board, ai_player):
        return 10 - depth, None
    if check_win(board, opponent):
        return depth - 10, None
    if is_draw(board):
        return 0, None

    available = [i for i, cell in enumerate(board) if cell == " "]
    best_move = None

    if current == ai_player:
        best_score = -float('inf')
        for move in available:
            board[move] = current
            score, _ = minimax(board, opponent, ai_player, depth + 1)
            board[move] = " "
            if score > best_score:
                best_score = score
                best_move = move
    else:
        best_score = float('inf')
        for move in available:
            board[move] = current
            score, _ = minimax(board, ai_player, ai_player, depth + 1)
            board[move] = " "
            if score < best_score:
                best_score = score
                best_move = move

    return best_score, best_move

def get_perfect_move(board, ai_player):
    _, move = minimax(board, ai_player, ai_player)
    return move

That's the entire algorithm. About 25 lines of code, and you have a tic-tac-toe player that has never lost a game (a fact verified mathematically β€” it's proven, not just observed).

Now plug it into your game loop:

def play_vs_perfect():
    board = [" "] * 9
    current = "X"
    ai = "O"
    print("You are X. The computer is O (perfect play).")
    print_board(board)

    while True:
        if current != ai:
            position = get_move(board, current)
        else:
            position = get_perfect_move(board, ai)
            print(f"Computer plays {position + 1}")
        board[position] = current
        print_board(board)

        if check_win(board, current):
            print(f"{current} wins!")
            return
        if is_draw(board):
            print("It's a draw!")
            return

        current = "O" if current == "X" else "X"

Try to beat it. You can't. With the best you can possibly do, you'll draw.

Why subtract the depth?

In the minimax code, I wrote return 10 - depth and return depth - 10 instead of just 10 and -10. Why?

Without the depth adjustment, the AI treats all wins as equally valuable β€” whether it wins in 2 moves or 6 moves. That can lead to weird behavior, like the AI dragging out a win when it could end immediately. Subtracting depth makes faster wins more valuable and slower losses less painful. The AI now prefers to win quickly and to lose slowly (giving the opponent more chances to slip up if it's in a losing position). Small change, big improvement in how the AI feels to play against.

Where to go from here

You now have a complete, working tic-tac-toe game with three AI levels. Some ideas for extending it:

Tic-tac-toe is a tiny project. But every concept you used here β€” game state, input loops, win detection, recursive search, optimization β€” scales up to almost every game AI ever written. Deep Blue and AlphaGo are not fundamentally different from what you just built. They just have a lot more numbers and a lot more compute. The skeleton is the same.


Play the JavaScript Version β†’

Related reading