How to Create an Unbeatable Tic Tac Toe Game AI With HTML, CSS and JavaScript

How to Create an Unbeatable Tic Tac Toe Game AI With HTML, CSS and JavaScript

Tic Tac Toe is usually the first game anyone builds when learning to code, and it’s also one of the only games where “unbeatable” isn’t an exaggeration. The board is small enough — just nine squares — that a computer can actually look ahead to every possible way the rest of the game could play out, every single time it’s asked to move. This walkthrough covers how that works, using an algorithm called minimax, and how to build the rest of the game around it.

Why Tic Tac Toe Can Be Solved Perfectly

Games like chess are far too large for a computer to look at every possible outcome — there are more possible chess games than atoms in the observable universe, roughly speaking. Tic Tac Toe is nothing like that. At most, a game lasts nine moves, and after the first couple of moves there are only a handful of empty squares left to consider at each turn. That small size means a program can genuinely simulate every remaining possible game from any given position, all the way to the end, and know with certainty which move leads to the best outcome. This is what “unbeatable” actually means here — not a clever heuristic that’s usually right, but a search that has already looked at every possible future and picked the one that can never lose.

The Core Idea Behind Minimax

Minimax works from a simple assumption: both players are trying to win, and both players will always make the best move available to them. The algorithm imagines the AI’s turn as trying to maximize its own outcome, and imagines the opponent’s turn as trying to minimize that same outcome — hence the name. It walks through the game tree by trying every possible move, then recursively imagining what the opponent’s best response would be to that move, then what the AI’s best response would be to that response, all the way down until the board is full or someone has won.

Each finished game gets a score: a win for the AI scores positively, a loss scores negatively, and a draw scores zero. As the recursion unwinds back up from those finished positions, each level picks the score that’s best for whoever’s turn it represents at that level — the AI’s turns pick the highest score among their options, the opponent’s turns (from the AI’s perspective) pick the lowest. By the time the recursion finishes climbing back to the very first move, every possible opening move has been assigned a score representing the best outcome the AI can guarantee for itself if it plays perfectly from that point onward — and the AI simply picks whichever move has the best score.

Making the AI Prefer Faster Wins

A subtle detail that’s easy to miss on a first attempt: without any adjustment, a basic minimax implementation is happy to win in nine moves just as much as it’s happy to win in five, since both are just “a win” with the same score. That produces AI behavior that occasionally looks strange — passing up an immediate winning move in favor of a longer path that also happens to win.

The fix is to factor the depth of the search into the score. A win found early in the search tree — meaning it happens sooner in actual gameplay — gets a slightly higher score than the exact same win found deeper in the tree. Subtracting the current depth from a win score, and adding it to a loss score, nudges the algorithm toward taking a fast win when one is available, and toward delaying a loss as long as possible when a loss is unavoidable. It’s a small adjustment, but it’s the difference between an AI that plays confidently and one that seems to hesitate for no visible reason.

Structuring the Game State Cleanly

Before any of the AI logic can run, the game itself needs a clean way to represent its state and check for a winner. A flat array of nine values — one for each square, either empty, X, or O — is the simplest representation, and checking for a win just means testing that array against the eight possible winning combinations: three rows, three columns, and two diagonals. If any of those eight combinations contains the same non-empty value in all three positions, that value has won. If every square is filled and none of those combinations matched, the game is a draw.

Keeping this win-checking logic as a single, small, reusable function matters more than it might seem, because the minimax algorithm calls it constantly — every single hypothetical move it tries gets checked against this same function to see if the simulated game has ended. Any inefficiency or bug here gets multiplied across potentially hundreds of recursive calls per actual move the AI makes.

Giving the AI a Personality Setting

A perfectly unbeatable opponent is interesting to study but not always fun to actually play against, especially for someone still learning the game. Adding an easier difficulty setting is straightforward: instead of always calling the full minimax search, an “easy” mode can flip a coin on each of the AI’s turns, and on a loss of that coin flip, pick a completely random empty square instead of the calculated best move. This keeps the underlying minimax logic completely untouched — the AI is still capable of playing perfectly, it’s just occasionally choosing not to, which produces a much more beatable, forgiving opponent for casual play.

Supporting a Second Human Player

Since the same board, win-checking, and turn-tracking logic already exists regardless of who’s controlling each side, adding a two-player mode is less about new logic and more about turning the AI’s move-making off. When two-player mode is active, every move — for both X and O — comes from a click on the board rather than from the AI function, and the game simply alternates whose turn it is after each move, the same way it always did. The scoreboard, win detection, and restart logic all keep working identically no matter which mode is active, because none of that logic actually cares where a move came from.

Why This Exercise Is Worth Doing Even Though Tic Tac Toe Is “Solved”

It might seem pointless to build an unbeatable AI for a game that’s already completely understood, but the minimax pattern learned here scales directly into far more interesting territory. The same core idea — search forward through possible future moves, assume both sides play optimally, and pick the move with the best guaranteed outcome — is the foundation beneath AI for checkers, chess engines, and countless other turn-based strategy games, just combined with additional techniques to handle search spaces too large to explore completely. Understanding minimax on a small, fully solvable game like this one is often the clearest way to actually grasp the idea before applying it somewhere far more complex.

The complete game is a single self-contained HTML file — open it, try to beat the AI on Unbeatable difficulty a few times, and then look through the minimax function itself to see exactly how a handful of lines of recursive code produce an opponent that genuinely cannot be beaten.

<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stellix Tac — Tic Tac Toe vs Unbeatable AI | Coding Stellix</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Jost:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
  :root{
    --bg:#0c0f1a;
    --bg-soft:#131829;
    --card:rgba(255,255,255,.05);
    --card-border:rgba(255,255,255,.1);
    --text:#eef1fb;
    --muted:#8890b0;
    --brand:#ff5e7d;
    --brand-soft:#5ec8ff;
    --glow:rgba(255,94,125,.28);
    --cell-bg:rgba(255,255,255,.05);
    --shadow:0 24px 70px rgba(0,0,0,.55);
  }
  [data-theme="light"]{
    --bg:#f4f5fb;
    --bg-soft:#ffffff;
    --card:rgba(255,255,255,.8);
    --card-border:rgba(20,20,60,.1);
    --text:#12142b;
    --muted:#5f6485;
    --glow:rgba(255,94,125,.2);
    --cell-bg:rgba(20,20,60,.04);
    --shadow:0 20px 55px rgba(30,30,90,.14);
  }
  *{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
  body{
    font-family:'Jost',sans-serif;
    background:var(--bg);color:var(--text);
    min-height:100vh;overflow-x:hidden;
    transition:background .45s,color .45s;
  }
  body::before{
    content:"";position:fixed;inset:0;pointer-events:none;z-index:0;
    background:
      radial-gradient(650px 450px at 92% -10%,var(--glow),transparent 65%),
      radial-gradient(550px 420px at -12% 108%,rgba(94,200,255,.16),transparent 60%);
  }
  .wrap{position:relative;z-index:1;max-width:480px;margin:0 auto;padding:0 18px}

  header{display:flex;align-items:center;justify-content:space-between;padding:18px 0}
  .logo{display:flex;align-items:center;gap:11px;user-select:none}
  .logo-mark{
    width:40px;height:40px;border-radius:12px;
    background:linear-gradient(135deg,var(--brand),var(--brand-soft));
    display:grid;place-items:center;color:#fff;font-size:1.1rem;font-weight:800;
    box-shadow:0 6px 20px var(--glow);
  }
  .logo-name{font-weight:700;font-size:1.15rem}
  .logo-name span{color:var(--brand)}
  .logo small{display:block;font-size:.58rem;letter-spacing:2.4px;text-transform:uppercase;color:var(--muted);font-weight:500}
  #themeBtn{
    width:42px;height:42px;border-radius:50%;cursor:pointer;
    border:1px solid var(--card-border);background:var(--card);color:var(--text);
    font-size:1.05rem;backdrop-filter:blur(10px);
    display:grid;place-items:center;transition:transform .3s,box-shadow .3s;
  }
  #themeBtn:hover{transform:rotate(18deg) scale(1.08);box-shadow:0 0 18px var(--glow)}
  #themeBtn:focus-visible{outline:2px solid var(--brand);outline-offset:3px}

  .hero{text-align:center;padding:6px 0 4px}
  .hero h1{font-size:clamp(1.6rem,4.6vw,2.3rem);font-weight:800;letter-spacing:-.4px;line-height:1.2}
  .hero h1 em{
    font-style:normal;background:linear-gradient(120deg,var(--brand),var(--brand-soft));
    -webkit-background-clip:text;background-clip:text;color:transparent;
  }

  .mode-row{display:flex;gap:8px;justify-content:center;margin:18px 0 6px}
  .mode-row button{
    font-family:inherit;font-weight:600;font-size:.84rem;
    padding:9px 16px;border-radius:999px;cursor:pointer;
    border:1px solid var(--card-border);background:var(--card);color:var(--muted);
    transition:all .2s;
  }
  .mode-row button:hover{color:var(--text)}
  .mode-row button.active{
    background:linear-gradient(135deg,var(--brand),var(--brand-soft));color:#fff;
    border-color:transparent;box-shadow:0 5px 16px var(--glow);
  }

  .diff-row{display:flex;gap:8px;justify-content:center;margin-bottom:16px;min-height:36px}
  .diff-row button{
    font-family:inherit;font-weight:600;font-size:.78rem;
    padding:7px 14px;border-radius:999px;cursor:pointer;
    border:1px solid var(--card-border);background:var(--card);color:var(--muted);
    transition:all .2s;
  }
  .diff-row button.active{background:var(--brand);color:#fff;border-color:transparent}

  .status-bar{
    text-align:center;font-weight:700;font-size:1rem;margin-bottom:14px;
    min-height:26px;
  }
  .status-bar .turn-x{color:var(--brand)}
  .status-bar .turn-o{color:var(--brand-soft)}

  .board{
    display:grid;grid-template-columns:repeat(3,1fr);gap:9px;
    background:var(--card);border:1px solid var(--card-border);border-radius:20px;
    padding:14px;backdrop-filter:blur(14px);box-shadow:var(--shadow);margin-bottom:18px;
  }
  .cell{
    aspect-ratio:1/1;border-radius:14px;background:var(--cell-bg);border:1px solid var(--card-border);
    display:flex;align-items:center;justify-content:center;cursor:pointer;
    font-size:2.6rem;font-weight:800;transition:transform .15s,background .2s;
  }
  .cell:hover:not(.filled){background:rgba(255,255,255,.09)}
  .cell.x{color:var(--brand)}
  .cell.o{color:var(--brand-soft)}
  .cell.win{background:linear-gradient(150deg,var(--brand),var(--brand-soft));color:#fff !important;box-shadow:0 0 20px var(--glow)}
  .cell:focus-visible{outline:2px solid var(--brand);outline-offset:2px}

  .score-row{
    display:flex;justify-content:space-around;
    background:var(--card);border:1px solid var(--card-border);border-radius:16px;
    padding:12px;margin-bottom:16px;backdrop-filter:blur(12px);
  }
  .score-item{text-align:center}
  .score-item b{display:block;font-size:1.3rem;font-weight:800}
  .score-item span{font-size:.62rem;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:var(--muted)}
  .score-item.x b{color:var(--brand)}
  .score-item.o b{color:var(--brand-soft)}

  .restart-row{text-align:center;margin-bottom:20px}
  .restart-row button{
    font-family:inherit;font-weight:700;font-size:.86rem;
    padding:11px 26px;border-radius:999px;cursor:pointer;
    background:var(--card);border:1px solid var(--card-border);color:var(--text);
    transition:transform .25s;
  }
  .restart-row button:hover{transform:translateY(-2px)}

  footer{text-align:center;padding:20px 0 34px;color:var(--muted);font-size:.85rem}
  footer b{color:var(--brand)}

  @media(max-width:380px){
    .cell{font-size:2.1rem}
  }
</style>
</head>
<body>
<div class="wrap">

  <header>
    <div class="logo">
      <div class="logo-mark">✕</div>
      <div>
        <div class="logo-name">Stellix <span>Tac</span></div>
        <small>by Coding Stellix</small>
      </div>
    </div>
    <button id="themeBtn" aria-label="Toggle light and dark mode" title="Toggle theme">🌙</button>
  </header>

  <section class="hero">
    <h1>Tic Tac Toe vs an <em>unbeatable</em> AI</h1>
  </section>

  <div class="mode-row" id="modeRow">
    <button data-mode="ai" class="active">🤖 vs AI</button>
    <button data-mode="2p">👥 2 Players</button>
  </div>
  <div class="diff-row" id="diffRow">
    <button data-diff="easy">Easy</button>
    <button data-diff="hard" class="active">Unbeatable</button>
  </div>

  <div class="status-bar" id="statusBar">Your turn — pick a square</div>

  <div class="board" id="board"></div>

  <div class="score-row">
    <div class="score-item x"><b id="scoreX">0</b><span>X Wins</span></div>
    <div class="score-item"><b id="scoreDraw">0</b><span>Draws</span></div>
    <div class="score-item o"><b id="scoreO">0</b><span>O Wins</span></div>
  </div>

  <div class="restart-row"><button id="restartBtn">↻ New Round</button></div>

  <footer>Crafted with 🧡 by <b>Coding Stellix</b> — Stellix Tac v1.0</footer>
</div>

<script>
// ============================================================
//  Stellix Tac v1.0 — Tic Tac Toe with Minimax AI
//  Crafted by Coding Stellix (coding_stellix)
// ============================================================
(function(){
  "use strict";
  var $ = function(id){ return document.getElementById(id); };

  var boardEl = $("board");
  var board = Array(9).fill(null);
  var current = "X";
  var mode = "ai";
  var difficulty = "hard";
  var gameOver = false;
  var scores = { X:0, O:0, draw:0 };

  var WIN_LINES = [
    [0,1,2],[3,4,5],[6,7,8],
    [0,3,6],[1,4,7],[2,5,8],
    [0,4,8],[2,4,6]
  ];

  function renderBoard(){
    boardEl.innerHTML = "";
    board.forEach(function(val, idx){
      var cell = document.createElement("div");
      cell.className = "cell" + (val ? " filled " + val.toLowerCase() : "");
      cell.textContent = val || "";
      cell.addEventListener("click", function(){ handleMove(idx); });
      boardEl.appendChild(cell);
    });
  }

  function checkWinner(b){
    for(var i=0;i<WIN_LINES.length;i++){
      var line = WIN_LINES[i];
      var a=b[line[0]], c=b[line[1]], d=b[line[2]];
      if(a && a===c && a===d) return { winner:a, line:line };
    }
    if(b.every(function(v){ return v; })) return { winner:"draw" };
    return null;
  }

  function handleMove(idx){
    if(gameOver || board[idx]) return;
    if(mode === "ai" && current === "O") return;

    board[idx] = current;
    renderBoard();
    var result = checkWinner(board);
    if(result){ endGame(result); return; }

    current = current === "X" ? "O" : "X";
    updateStatus();

    if(mode === "ai" && current === "O" && !gameOver){
      setTimeout(aiMove, 400);
    }
  }

  function updateStatus(){
    if(mode === "ai"){
      $("statusBar").innerHTML = current === "X" ?
        'Your turn — pick a square' :
        '<span class="turn-o">AI is thinking…</span>';
    } else {
      $("statusBar").innerHTML = current === "X" ?
        '<span class="turn-x">Player X\'s turn</span>' :
        '<span class="turn-o">Player O\'s turn</span>';
    }
  }

  function endGame(result){
    gameOver = true;
    if(result.winner === "draw"){
      scores.draw++;
      $("statusBar").textContent = "🤝 It's a draw!";
    } else {
      scores[result.winner]++;
      highlightWin(result.line);
      if(mode === "ai"){
        $("statusBar").textContent = result.winner === "X" ? "🎉 You win!" : "🤖 AI wins!";
      } else {
        $("statusBar").textContent = "🎉 Player " + result.winner + " wins!";
      }
    }
    $("scoreX").textContent = scores.X;
    $("scoreO").textContent = scores.O;
    $("scoreDraw").textContent = scores.draw;
  }

  function highlightWin(line){
    var cells = boardEl.querySelectorAll(".cell");
    line.forEach(function(i){ cells[i].classList.add("win"); });
  }

  function aiMove(){
    if(gameOver) return;
    var move;
    if(difficulty === "easy" && Math.random() < 0.5){
      var empties = board.map(function(v,i){return v?null:i;}).filter(function(v){return v!==null;});
      move = empties[Math.floor(Math.random()*empties.length)];
    } else {
      move = bestMove();
    }
    board[move] = "O";
    renderBoard();
    var result = checkWinner(board);
    if(result){ endGame(result); return; }
    current = "X";
    updateStatus();
  }

  function bestMove(){
    var bestScore = -Infinity, move = null;
    for(var i=0;i<9;i++){
      if(!board[i]){
        board[i] = "O";
        var score = minimax(board, 0, false);
        board[i] = null;
        if(score > bestScore){ bestScore = score; move = i; }
      }
    }
    return move;
  }

  function minimax(b, depth, isMax){
    var result = checkWinner(b);
    if(result){
      if(result.winner === "O") return 10 - depth;
      if(result.winner === "X") return depth - 10;
      return 0;
    }
    if(isMax){
      var best = -Infinity;
      for(var i=0;i<9;i++){
        if(!b[i]){
          b[i] = "O";
          best = Math.max(best, minimax(b, depth+1, false));
          b[i] = null;
        }
      }
      return best;
    } else {
      var worst = Infinity;
      for(var j=0;j<9;j++){
        if(!b[j]){
          b[j] = "X";
          worst = Math.min(worst, minimax(b, depth+1, true));
          b[j] = null;
        }
      }
      return worst;
    }
  }

  function newRound(){
    board = Array(9).fill(null);
    current = "X";
    gameOver = false;
    renderBoard();
    updateStatus();
  }

  $("modeRow").addEventListener("click", function(e){
    var btn = e.target.closest("button");
    if(!btn) return;
    mode = btn.dataset.mode;
    document.querySelectorAll("#modeRow button").forEach(function(b){ b.classList.remove("active"); });
    btn.classList.add("active");
    $("diffRow").style.visibility = mode === "ai" ? "visible" : "hidden";
    newRound();
  });
  $("diffRow").addEventListener("click", function(e){
    var btn = e.target.closest("button");
    if(!btn) return;
    difficulty = btn.dataset.diff;
    document.querySelectorAll("#diffRow button").forEach(function(b){ b.classList.remove("active"); });
    btn.classList.add("active");
  });
  $("restartBtn").addEventListener("click", newRound);

  var themeBtn = $("themeBtn");
  themeBtn.addEventListener("click", function(){
    var html = document.documentElement;
    var next = html.getAttribute("data-theme") === "dark" ? "light" : "dark";
    html.setAttribute("data-theme", next);
    themeBtn.textContent = next === "dark" ? "🌙" : "☀️";
  });

  renderBoard();
  updateStatus();
})();
</script>
</body>
</html>

Leave a Reply

Your email address will not be published. Required fields are marked *

SHARE:-

Trending Post

Latest Post