I built it to learn minimax, the classic algorithm for game-playing computers, and noughts and crosses is the classic small case to try it on. This is the write-up for Noughts and Crosses v1, one of the Python and Flask projects on my portfolio page. The full source isn’t published, but I can show the interesting part.

You can play it here. Fair warning: play fairly and you can’t win.

Noughts and Crosses v1 game page part-way through a game, with the player name and both scores
Noughts and Crosses v1 part-way through a game. The scores carry over between rounds.

What the app does

You type a name, press Start Game and get a three-by-three board. You are O, you click a cell, and the computer answers with an X straight away. The page keeps a running score for you and for the computer, and a Restart button starts another round without losing the scores.

The page is a handful of Jinja templates, a stylesheet and a little jQuery on the game page. Each click sends one POST to Flask, which returns the new board and whether anyone has won, drawn or neither. The browser only draws what it is told. All of the game logic is on the server.

My portfolio page invites you to “pretend you are in the 80’s and do a little bit of Wargaming”. The reference is WarGames (1983), where the computer works out from endless games of noughts and crosses that the only winning move is not to play. The joke fits, because that is more or less what happens here.

How the computer chooses its move

The opponent uses minimax, the textbook algorithm for two-player games with perfect information. It tries every legal move, then every reply to that move, and so on until the game ends, and it scores each finished game: ten for an X win, minus ten for an O win, zero for a draw. X picks the move that leads to the highest score, assuming O will always pick the reply that leads to the lowest. Here is the function:

def minimax(board, depth, is_maximizing):
    score = evaluate(board)
    if score == 10:
        return score - depth
    if score == -10:
        return score + depth
    if not any('_' in row for row in board):
        return 0

    if is_maximizing:
        best_score = -1000
        for i in range(3):
            for j in range(3):
                if board[i][j] == '_':
                    board[i][j] = 'X'
                    best_score = max(best_score, minimax(board, depth+1, not is_maximizing))
                    board[i][j] = '_'
        return best_score
    else:
        # …the same loop for O, using min() and starting from 1000

Two details are worth explaining.

Depth-adjusted scoring. A win scores ten minus the number of moves it took to get there, and a loss scores minus ten plus the depth. Without that, a win in two moves and a win in six are both worth ten, and the computer might wander about for no reason. With it, the computer takes the quickest win on offer and, if it is losing, drags the game out for as long as it can. It makes the opponent look purposeful, which costs one line each way.

Why you can’t win. The board is small enough for the search to look at every possible game, so there is no heuristic and no guessing: the computer never misjudges a position. Noughts and crosses is a solved game, and with perfect play from both sides it ends in a draw. Against a perfect opponent the best you can do is draw, and any slip hands the computer the win.

The search is cheap here. Even the computer’s first reply looks at no more than about 40,000 move sequences, which Python gets through quickly, so there is no pruning and no caching. Alpha-beta pruning would matter on a bigger board; on this one it would be extra code for no visible gain.

The “user system” is a name in a cookie

The portfolio page says the app has “the starts of a user system”. To be exact, it is a name field. What you type is stored in Flask’s session, along with the board and both scores. The session is a signed cookie held in your browser, so the server keeps no state of its own: no accounts, no passwords, no database.

That has a few consequences:

  • Clear your cookies and the scores go with them.
  • Two browsers, two separate players, even if they use the same name.
  • Nothing is shared, so there is no leaderboard.
  • The data lives on the client. It is signed, but anyone can read it.

The plan was proper logins with a history of your games. Two unused files in the project are the start of it: a dashboard template that welcomes you by username, lists your game history and has a Logout button, and a script that posts each move to a numbered game. Neither is wired up. Version 1 stopped at a name in the session.

What is rough or missing

  • No check that a square is empty. The server never checks that the square you click is empty, and the page lets you click any square. Click one of the computer’s Xs and it becomes an O, so you can cheat your way to a win. “Unbeatable” therefore means unbeatable in a fair game. Clicks after a game has ended carry on being scored: after a win, each further click re-evaluates the same winning line and adds another point.
  • Restart doesn’t redraw the board. The server resets the board, but its reply doesn’t include the new one, and the page tries to redraw from that reply. The script stops with an error, so the old game stays on screen until your next click (or a reload) draws the fresh board.
  • Development server. The container, built on python:3.9 with Flask unpinned, runs python app.py: Flask’s built-in Werkzeug server, behind a reverse proxy. That is fine for a demo and wrong for anything with real traffic.
  • No tests, no README, no history. The code was only put under version control in October 2026, so I can’t show you how it evolved.

What I would do differently

Minimax was the easy part. The function is under 30 lines. The bugs all ended up in the plumbing around it: whose turn it is, which squares are free, when a game is over, what Restart sends back and how the score is kept. Both of the flaws at the top of that list live there, not in the algorithm. If I were starting again, I would give that game-state code as much care as the clever bit.

Have a go

If you can get a draw, you’ve played as well as anyone can against it. Try it at the live demo, and if you want the other projects, they’re all on the portfolio page. Good luck!