Skip to content

SYSTEMS / JUNE 2026

Tic-Tac-Toe Minimax AI

A terminal game with an AI that picks the best move by searching every outcome.

Built on my own.

Source code
MY ROLE
Solo project
KEY RESULT
Never lost in 1,200 games against a random player

Built with

  • Python
  • Minimax
  • Recursion

What it is

This is a Tic-Tac-Toe game for the terminal, with a computer player that never loses. It decides each move by trying out every way the rest of the game could go. That method is called Minimax.

To check it works, I made it play against a computer that moves at random. Out of 1,200 games, the perfect player won 988, drew 212, and lost none.

What it can do

  • Three kinds of player

    A human, a random player, and the perfect one. Any two can play each other.

  • Perfect play

    The AI always picks the best move available, so the worst it can do is draw.

  • Faster wins

    Among winning moves, it prefers the one that wins sooner.

  • Watch it play

    A visual mode shows the board move by move, with a short pause between moves.

  • Run many games at once

    A silent mode plays any number of games and counts the wins, losses, and draws.

How a request flows

  1. game.pyBoard, winner, game loop
  2. player.pyRandom, Human, Minimax
  3. MinimaxRecursive search, undo each move
  4. SimulationWin, loss, tie counts

How it works

  1. 01

    For each empty square, the AI pretends to play there.

  2. 02

    It imagines both players playing their best until the game ends.

  3. 03

    It scores how that imagined game ends: a win is good, a loss is bad, and a draw is zero.

  4. 04

    It undoes the pretend move and remembers the score.

  5. 05

    Once it has tried every square, it plays the one with the best score.

Illustration

A Tic-Tac-Toe board in the middle of a game, with X and O placed.
Illustration, not a real screenshot. A drawing of a game in progress, where the AI picks the best move. It is a sketch, so the real app may look different.

Why

Minimax is easy to describe and easy to get subtly wrong. I wanted to write the recursion myself and check it against a player that moves at random.

What I built

  1. 01

    Wrote the board, winner detection, and game loop in game.py, and the Random, Human, and Minimax players in player.py.

  2. 02

    Implemented the recursive Minimax search: try a move, simulate the rest of the game, undo the move, and keep the best score.

  3. 03

    Weighted a win by the number of empty squares left, so the AI prefers winning sooner.

  4. 04

    Added a simulation mode that runs any number of games silently and reports win, loss, and tie counts.

The code

player.py
    def minimax(self, state, player):
        max_player = self.letter  # yourself
        other_player = "O" if player == "X" else "X"  # othe other player

        if state.current_winner == other_player:
            return {
                "position": None,
                "score": 1 * (state.num_empty_squares() + 1)
                if other_player == max_player
                else -1 * (state.num_empty_squares() + 1),
            }

        elif not state.empty_squares():  # no empty squares
            return {"position": None, "score": 0}

        if player == max_player:
            best = {"position": None, "score": -math.inf}  # each score should maximize
        else:
            best = {
                "position": None,
                "score": math.inf,
            }  # each score should be minimize

        for possible_move in state.available_moves():
            # step 1 - make a move and try that spot
            state.make_move(possible_move, player)

            # step 2 - use minimax to simuilate a game after making that move
            sim_score = self.minimax(
                state, other_player
            )  # now, we alternate the players

            # step 3 - undo the move
            state.board[possible_move] = " "
            state.current_winner = None

            sim_score["position"] = possible_move  # otherwise this will get messed up

            # step 4 - update the dictionaries if necessary
            if player == max_player:
                if sim_score["score"] > best["score"]:
                    best = sim_score  # replaces best
            else:  # minimzies the other player
                if sim_score["score"] < best["score"]:
                    best = sim_score  # replaces best

        return best
The AI’s thinking. For every empty square it pretends to play there, imagines the rest of the game, scores how it ends, then undoes the pretend move. It keeps the move with the best score.

See it run

python game.py
After 1200 iterations, we see 0 X wins, 988 O wins, and 212 ties
Recorded from the real program. Input typed at each prompt is shown after it.

Outcome

A game you can play in the terminal. Run against a random player for 1,200 games, the Minimax player won 988 and tied 212, with no losses.

The trade-off

It searches the full game tree on every move. That is fine for a 3×3 board, but it would not scale to larger boards without something like alpha-beta pruning.

What I learned

Undoing a move correctly, including the winner flag, is as important as the scoring, because the search shares one board.

What I'd fix next

Add alpha-beta pruning and a board-size option to see where the plain search stops being practical.

Working on something like Tic-Tac-Toe Minimax AI?

Tell me what you're building and I'll reply within two business days.

Open to co-op, internship and software roles in Vancouver, on-site or hybrid. Graduating May 2027.