Back to Portfolio
AlgorithmsGame TheoryJavaScript

Building an Unbeatable Tic-Tac-Toe AI

A weekend project that turned into a proper lesson in game theory — implementing minimax with alpha-beta pruning, then deliberately making the AI worse so people could actually enjoy it.

RJ
Romanch Jung Rayamajhi
April 2026
8 min read
255k
Possible games
~5k
Nodes without pruning
~500
Nodes with pruning
0
Losses on hard mode
Table of Contents
  1. Why Tic-Tac-Toe?
  2. How Minimax Thinks
  3. The Implementation
  4. Preferring Faster Wins
  5. Alpha-Beta Pruning
  6. Making It Beatable
  7. Takeaways

01Why Tic-Tac-Toe?

Calenote is a productivity app. A game seems out of place — until you notice how many people open the calendar, check a date, and have thirty idle seconds before their next meeting.

But the real reason is that tic-tac-toe is the perfect vehicle for learning minimax. The game tree is small enough to search exhaustively, yet large enough that brute-forcing it teaches you why optimization matters.

💡
Tic-tac-toe is a solved game. With perfect play from both sides, it is always a draw. So an AI that plays perfectly can never lose — which turns out to be a UX problem, not a triumph.

02How Minimax Thinks

Minimax assumes both players play optimally. It recursively explores every possible future and assigns each terminal position a score:

On the AI's turn it picks the move with the maximum score. On the human's turn it assumes they will pick the minimum — the worst outcome for the AI. Hence “minimax.”

the search tree
                    AI to move (MAX)
                    /       |       \
              move A   move B   move C
                /         |         \
        Human (MIN)  Human (MIN)  Human (MIN)
          /    \       /   \       /   \
        +10   0    -10   0     0   +10
                                    
       MIN=0            MIN=-10     MIN=0

              MAX picks 0 → move A or C

03The Implementation

minimax.js
const AI = "O", HUMAN = "X";

function minimax(board, depth, isMaximizing) {
  const result = checkWinner(board);

  // Terminal states — stop recursing
  if (result === AI)     return 10 - depth;
  if (result === HUMAN)  return depth - 10;
  if (result === "draw") return 0;

  if (isMaximizing) {
    let best = -Infinity;
    for (let i = 0; i < 9; i++) {
      if (board[i]) continue;          // square taken
      board[i] = AI;                     // try it
      best = Math.max(best, minimax(board, depth + 1, false));
      board[i] = null;                 // undo
    }
    return best;
  } else {
    let best = Infinity;
    for (let i = 0; i < 9; i++) {
      if (board[i]) continue;
      board[i] = HUMAN;
      best = Math.min(best, minimax(board, depth + 1, true));
      board[i] = null;
    }
    return best;
  }
}

// Pick the best available move
function bestMove(board) {
  let bestScore = -Infinity, move = null;

  for (let i = 0; i < 9; i++) {
    if (board[i]) continue;
    board[i] = AI;
    const score = minimax(board, 0, false);
    board[i] = null;

    if (score > bestScore) { bestScore = score; move = i; }
  }
  return move;
}
⚠️
The undo step is essential. Forgetting board[i] = null after recursing leaves phantom moves on the board and produces baffling bugs. Ask me how I know.

04Preferring Faster Wins

Notice 10 - depth rather than a flat 10. Without it, minimax treats winning in one move and winning in five as equally good — and the AI will happily stall, making pointless moves before finishing you off.

With depth weighting:

The AI now wins as fast as possible and loses as slowly as possible — which reads as genuinely intelligent play.

05Alpha-Beta Pruning

Plain minimax evaluates roughly 5,000 board positions for the opening move. Most of that work is wasted: once you know a branch is worse than one you have already examined, there is no reason to keep exploring it.

minimax with alpha-beta
function minimax(board, depth, isMax, alpha, beta) {
  const result = checkWinner(board);
  if (result === AI)      return 10 - depth;
  if (result === HUMAN)   return depth - 10;
  if (result === "draw")  return 0;

  if (isMax) {
    let best = -Infinity;
    for (let i = 0; i < 9; i++) {
      if (board[i]) continue;
      board[i] = AI;
      best = Math.max(best, minimax(board, depth+1, false, alpha, beta));
      board[i] = null;

      alpha = Math.max(alpha, best);
      if (beta <= alpha) break;   // ✂ prune — opponent avoids this
    }
    return best;
  } else {
    let best = Infinity;
    for (let i = 0; i < 9; i++) {
      if (board[i]) continue;
      board[i] = HUMAN;
      best = Math.min(best, minimax(board, depth+1, true, alpha, beta));
      board[i] = null;

      beta = Math.min(beta, best);
      if (beta <= alpha) break;   // ✂ prune
    }
    return best;
  }
}

// Call with: minimax(board, 0, false, -Infinity, Infinity)
Pruning cuts the search by roughly 90% with identical results. For tic-tac-toe it's a nicety; for chess it is the difference between playable and impossible.

06Making It Beatable

Here is the uncomfortable discovery: a perfect AI is not fun. Players tried three times, drew three times, and left. Never losing isn't satisfying if winning is impossible.

So the AI got difficulty levels — implemented by deliberately injecting imperfection:

🟢 Easy

Random legal move most of the time. Blocks an immediate loss only occasionally. Beginners win regularly.

🟡 Medium

Runs full minimax, but a fixed percentage of turns fall back to a random move. Feels human — sharp, then a lapse.

🔴 Hard

Pure minimax, every turn, no mercy. Mathematically cannot be beaten. Draw is the best you get.

difficulty.js
const MISTAKE_RATE = { easy: 0.8, medium: 0.25, hard: 0 };

function chooseMove(board, difficulty) {
  if (Math.random() < MISTAKE_RATE[difficulty]) {
    return randomLegalMove(board);
  }
  return bestMove(board);
}
🎮
The design lesson generalises well beyond games: optimal is not the same as good. The goal was an enjoyable minute, not a proof of correctness.

07Takeaways

Small games teach big algorithms.

Everything here — recursive search, position evaluation, pruning — is the same machinery behind serious chess engines, just at a scale you can hold in your head.

Always undo your moves.

Mutating a shared board and forgetting to restore it is the single most common minimax bug. Restore immediately after every recursive call.

Depth-weight your scores.

Without it the AI plays technically correct but visibly stupid moves. It's a one-line change with an outsized effect on perceived intelligence.

Ship the imperfect version.

The unbeatable AI was the interesting engineering problem. The beatable one was the better product.

🚀
Play it free at calenote.app/tools/tic-tac-toe — no login, works offline. See if you can force a draw on hard.