DEV Community

Cover image for Develop an Unbeatable Tic-Tac-Toe AI Using React
Gregory Gaines
Gregory Gaines

Posted on • Originally published at gregorygaines.com

Develop an Unbeatable Tic-Tac-Toe AI Using React

Introduction

Do you love Tic-Tac-Toe? Want to show off your React skills and swindle fools who think they can beat your AI? If you answered yes to any of these questions, you've come to the right place! Today we are building an unbeatable tic-tac-toe game.

Check out the finished demo below!

Prerequisites

  • Basic CSS, HTML, and JavaScript knowledge
  • Knowledge of React and hooks.

Dependencies

  • React - JavaScript framework for building the UI.
  • Tailwind CSS - A utility-first css library for styling components.
  • Open Sans - UI font

Building the UI

Below is the boilerplate for the board and UI:

First tic-tac-toe UI iterration

import React from "react";
import "./styles.css";

export default function App() {

  const Square = (props) => {
    return (
      <div
        className="shadow-md h-24 w-24 rounded-lg bg-white text-7xl text-center cursor-default font-light flex items center justify-center x-player"
      >
        X
      </div>
    );
  };

  return (
    <>
      <div className="text-center py-2 shadow-sm text-gray-400 z-50 sticky">
        Your Turn
      </div>
      <section className="game-board py-10">
        <div className="max-w-md mx-auto">
          <div className="max-w-lg flex flex-col gap-5 mx-auto">
            <div className="flex gap-5 mx-auto">
              <Square squareIndex={0} />
              <Square squareIndex={1} />
              <Square squareIndex={2} />
            </div>
            <div className="flex gap-5 mx-auto">
              <Square squareIndex={3} />
              <Square squareIndex={4} />
              <Square squareIndex={5} />
            </div>
            <div className="flex gap-5 mx-auto">
              <Square squareIndex={6} />
              <Square squareIndex={7} />
              <Square squareIndex={8} />
            </div>
          </div>

          <div className="text-center">
            <button className="bg-blue-500 text-white w-full py-2 font-semibold mt-10 rounded-md shadow-lg">
              Reset
            </button>
          </div>
        </div>
      </section>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode
html,
body {
  font-family: "Open Sans", sans-serif;
  height: 100%;
  background-color: #f9fafb;
}

.game-board {
  font-family: "Open Sans", sans-serif;
}

.shadow-md {
  box-shadow: rgba(7, 65, 210, 0.1) 0px 9px 30px !important;
}

.o-player {
  background: #cb6893;
  background: -webkit-linear-gradient(to right, #cb6893 0%, #f6d9d7 100%);
  background: -moz-linear-gradient(to right, #cb6893 0%, #f6d9d7 100%);
  background: linear-gradient(to right, #cb6893 0%, #f6d9d7 100%);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
}

.x-player {
  background: #746dd0;
  background: -webkit-linear-gradient(to right, #746dd0 0%, #c4e1eb 100%);
  background: -moz-linear-gradient(to right, #746dd0 0%, #c4e1eb 100%);
  background: linear-gradient(to right, #746dd0 0%, #c4e1eb 100%);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
}

.x-winner {
  text-shadow: 0 0 10px #746dd0, 0 0 0px #746dd0, 0 0 40px #746dd0,
    0 0 2px #746dd0;
}

.o-winner {
  text-shadow: 0 0 10px #ff9bc6, 0 0 0px #ff9bc6, 0 0 40px #ff9bc6,
    0 0 2px #ff9bc6;
}
Enter fullscreen mode Exit fullscreen mode

Build Game Logic

Let's start writing game logic; a board that does nothing isn't much fun!

The game flows as follows:

  1. Player clicks a `Square`. If space is empty fill with X, else go to step 1.
  2. Check if game won or draw.
  3. AI fills empty space with O.
  4. Check if game won or draw.
  5. Go to step 1.

Types for representing State

Imagine having a state called gameWon represented with a boolean for true or false. Soon after, you add a game draw condition and another boolean and logic. A week later, you're adding a gameOvertime condition and writing more logic. See how this can become a problem?

Using primitive data types like integers or booleans to represent state is flaky, limited, and riddles code with if/else statements! Using enums or objects/types is a far better alternative.

Below is the above scenario, but represented with an object:

const GAME_WON = {
  YES: 'game_won_yes',
  NO: 'game_won_no',
  DRAW: 'game_draw',
  OVERTIME: 'game_overtime',
}
Enter fullscreen mode Exit fullscreen mode

As a result, we can easily add new states into the GAME_WON type and cut down on redundant logic.

Game State

Defining a game state type and hook based on the game flow is easy.

const GAME_STATE = {
  PLAYER_TURN: "player_turn",
  AI_TURN: "ai_turn",
  PLAYER_WON: "player_won",
  AI_WON: "player_o_won",
  DRAW: "game_draw",
  ERROR: "game_error"
};

// Current game state
const [gameState, setGameState] = useState(GAME_STATE.PLAYER_TURN);
Enter fullscreen mode Exit fullscreen mode

Game Board

The board represents an array with a length of nine that corresponds to each Square. Each Square can either be empty or filled by the player or AI. To easily represent the state of a Square, we will create a type to represent who owns it. The createEmptyGrid function returns an array filled with SPACE_STATE.EMPTY.

export const GRID_LENGTH = 9;

export const SPACE_STATE = {
  PLAYER: "player_filled",
  AI: "ai_filled",
  EMPTY: "empty_space"
};

const createEmptyGrid = () => {
  return Array(GRID_LENGTH).fill(SPACE_STATE.EMPTY);
};

const [grid, setGrid] = useState(createEmptyGrid());
Enter fullscreen mode Exit fullscreen mode

Move Count

Tracking the number of moves taken is vital for determining a draw. AI logic also depends on the move count to formulate the best strategy.

  // Count of moves made
  const [moveCount, setMoveCount] = useState(0);
Enter fullscreen mode Exit fullscreen mode

Handling Player Clicks

In the JSX, each Square has an index passed as a prop that corresponds to a grid index.

...
<Square squareIndex={0} />
<Square squareIndex={1} />
<Square squareIndex={2} />
...
Enter fullscreen mode Exit fullscreen mode

Inside the Square function, an onClick handler pulls the squareIndex from its props to call handlePlayerClick to fill in the corresponding grid index with SPACE_STATE.PLAYER. After filling the player's Square, the function fills the correct symbol with getSquareSymbol then updates the gameState to GAME_STATE.AI_TURN.

Because the AI and player's symbols have different colors, we introduce the getSpaceStateClass function to get the correct CSS class names.

// Get the correct space class names
const getSpaceStateClass = (spaceState) => {
let space = "";

if (spaceState === SPACE_STATE.AI) {
  return "o-player";
}

if (spaceState === SPACE_STATE.PLAYER) {
  return "x-player";
}

return "";
};

const getSquareSymbol = (spaceStatus) => {
  switch (spaceStatus) {
    case SPACE_STATE.PLAYER: {
      return "X";
    }
    case SPACE_STATE.AI: {
      return "O";
    }
    case SPACE_STATE.EMPTY: {
      return "";
    }
    default: {
      return "";
    }
  }
};

// Fill in a grid square with status
const fillGridSpace = (gridIndex, spaceStatus) => {
    setGrid((oldGrid) => {
    oldGrid[gridIndex] = spaceStatus;
    return [...oldGrid];
  });
};

// Fill in the grid array with the player space state.
const handlePlayerClick = (gridIndex) => {
  // If not the player turn, then exit.
  if (gameState !== GAME_STATE.PLAYER_TURN) {
    return;
  }

  // If the current square is empty, then fill in space.
  if (grid[gridIndex] === SPACE_STATE.EMPTY) {
     // Fill grid space
      fillGridSpace(gridIndex, SPACE_STATE.PLAYER);
      // Update game state to AI's turn.
      setGameState(GAME_STATE.AI_TURN);
      // Update move count
      setMoveCount((oldMoves) => {
        return oldMoves + 1;
      });
  }
};

 const Square = (props) => {
    return (
      <div
        className="shadow-md h-24 w-24 rounded-lg bg-white text-7xl text-center cursor-default font-light flex items-center justify-center "
        // Connect click listener
        onClick={() => {
           handlePlayerClick(props.squareIndex);
        }}
      >
        // Get square symbol
        {getSquareSymbol(grid[props.squareIndex])}
      </div>
    );
  };
Enter fullscreen mode Exit fullscreen mode

Writing the AI Logic

For the AI, the Tic-tac-toe Wikipedia details a strategy to get a perfect game meaning each game is a draw or a win.

  1. Win: If the player has two in a row, they can place a third to get three in a row.
  2. Block: If the opponent has two in a row, the player must play the third themselves to block the opponent.
  3. Fork: Cause a scenario where the player has two ways to win (two non-blocked lines of 2).
  4. Blocking an opponent's fork: If there is only one possible fork for the opponent, the player should block it. Otherwise, the player should block all forks in any way that simultaneously allows them to make two in a row. Otherwise, the player should make a two in a row to force the opponent into defending, as long as it does not result in them producing a fork. For example, if "X" has two opposite corners and "O" has the center, "O" must not play a corner move to win. (Playing a corner move in this scenario produces a fork for "X" to win.)
  5. Center: A player marks the center. (If it is the first move of the game, playing a corner move gives the second player more opportunities to make a mistake and may therefore be the better choice; however, it makes no difference between perfect players.)
  6. Opposite corner: If the opponent is in the corner, the player plays the opposite corner.
  7. Empty corner: The player plays in a corner square.
  8. Empty side: The player plays in a middle square on any of the four sides.

The calculateAITurn function uses the strategy above to determine the best Square to fill to achieve a perfect game.

import { SPACE_STATE } from "./App";

// Calculate the best space for the AI to fill to get a perfect game.
export const calculateAITurn = (grid, moveCount) => {
  let aiSpace = aiCanWin(grid);

  if (Number.isInteger(aiSpace)) {
    console.log("Ai winning");
    return aiSpace;
  }

  aiSpace = aiCanBlock(grid);

  if (Number.isInteger(aiSpace)) {
    console.log("Ai blocking");
    return aiSpace;
  }

  aiSpace = aiCanBlockFork(grid, moveCount);

  if (Number.isInteger(aiSpace)) {
    console.log("AI forking");
    return aiSpace;
  }

  aiSpace = aiCanCenter(grid);

  if (Number.isInteger(aiSpace)) {
    console.log("AI centering");
    return aiSpace;
  }

  aiSpace = aiCanFillOppositeCorner(grid);

  if (Number.isInteger(aiSpace)) {
    console.log("AI filling opposite corner");
    return aiSpace;
  }

  aiSpace = aiCanFillEmptyCorner(grid);

  if (Number.isInteger(aiSpace)) {
    console.log("AI filling empty corner");
    return aiSpace;
  }

  aiSpace = aiCanFillEmptySide(grid);

  if (Number.isInteger(aiSpace)) {
    console.log("AI filling empty side");
    return aiSpace;
  }

  // console.log("AI can't move");
  return null;
};

// Convert row, col to grid index.
const convertCordToIndex = (row, col) => {
  return row * 3 + col;
};
/**
 * Check if AI can win
 * @returns Space for AI to win
 */
const aiCanWin = (grid) => {
  let count = 0;
  let row, col;

  // Check Rows
  for (let i = 0; i < 3; ++i) {
    count = 0;

    for (let j = 0; j < 3; ++j) {
      if (grid[convertCordToIndex(i, j)] === SPACE_STATE.AI) {
        count++;
      } else if (grid[convertCordToIndex(i, j)] === SPACE_STATE.PLAYER) {
        count--;
      } else if (grid[convertCordToIndex(i, j)] === SPACE_STATE.EMPTY) {
        row = i;
        col = j;
      }
    }

    // Has two consecutive spaces, return third to win.
    if (count === 2) {
      return convertCordToIndex(row, col);
    }
  }

  // Check Cols
  for (let i = 0; i < 3; ++i) {
    count = 0;

    for (let j = 0; j < 3; ++j) {
      if (grid[convertCordToIndex(j, i)] === SPACE_STATE.AI) {
        count++;
      } else if (grid[convertCordToIndex(j, i)] === SPACE_STATE.PLAYER) {
        count--;
      } else if (grid[convertCordToIndex(j, i)] === SPACE_STATE.EMPTY) {
        row = j;
        col = i;
      }
    }

    // Has two consecutive spaces, return third to win.
    if (count === 2) {
      return convertCordToIndex(row, col);
    }
  }

  count = 0;

  // Check Diag
  for (let i = 0; i < 3; ++i) {
    if (grid[convertCordToIndex(i, i)] === SPACE_STATE.AI) {
      count++;
    } else if (grid[convertCordToIndex(i, i)] === SPACE_STATE.PLAYER) {
      count--;
    } else if (grid[convertCordToIndex(i, i)] === SPACE_STATE.EMPTY) {
      row = i;
      col = i;
    }
  }

  // Has two consecutive spaces, return third to win.
  if (count === 2) {
    return convertCordToIndex(row, col);
  }

  count = 0;

  // Check Anti-Diag
  for (var i = 0; i < 3; ++i) {
    if (grid[convertCordToIndex(i, 3 - 1 - i)] === SPACE_STATE.AI) {
      count++;
    } else if (grid[convertCordToIndex(i, 3 - 1 - i)] === SPACE_STATE.PLAYER) {
      count--;
    } else if (grid[convertCordToIndex(i, 3 - 1 - i)] === SPACE_STATE.EMPTY) {
      row = i;
      col = 3 - 1 - i;
    }
  }

  // Has two consecutive spaces, return third to win.
  if (count === 2) {
    return convertCordToIndex(row, col);
  }

  return null;
};

/**
 * Ai checks if it can block opponents win
 * @returns Can ai block opponent
 */
function aiCanBlock(grid) {
  var count = 0;
  var row, col;

  // Check Rows
  for (let i = 0; i < 3; ++i) {
    count = 0;

    for (let j = 0; j < 3; ++j) {
      if (grid[convertCordToIndex(i, j)] === SPACE_STATE.PLAYER) {
        count++;
      } else if (grid[convertCordToIndex(i, j)] === SPACE_STATE.AI) {
        count--;
      } else if (grid[convertCordToIndex(i, j)] === SPACE_STATE.EMPTY) {
        row = i;
        col = j;
      }
    }

    // Opponent two consecutive spaces, return third to block.
    if (count === 2) {
      return convertCordToIndex(row, col);
    }
  }

  // Check Cols
  for (let i = 0; i < 3; ++i) {
    count = 0;

    for (let j = 0; j < 3; ++j) {
      if (grid[convertCordToIndex(j, i)] === SPACE_STATE.PLAYER) {
        count++;
      } else if (grid[convertCordToIndex(j, i)] === SPACE_STATE.AI) {
        count--;
      } else if (grid[convertCordToIndex(j, i)] === SPACE_STATE.EMPTY) {
        row = j;
        col = i;
      }
    }

    // Opponent two consecutive spaces, return third to block.
    if (count === 2) {
      return convertCordToIndex(row, col);
    }
  }

  count = 0;

  // Check Diag
  for (let i = 0; i < 3; ++i) {
    if (grid[convertCordToIndex(i, i)] === SPACE_STATE.PLAYER) {
      count++;
    } else if (grid[convertCordToIndex(i, i)] === SPACE_STATE.AI) {
      count--;
    } else if (grid[convertCordToIndex(i, i)] === SPACE_STATE.EMPTY) {
      row = i;
      col = i;
    }
  }

  // Opponent two consecutive spaces, return third to block.
  if (count === 2) {
    return convertCordToIndex(row, col);
  }

  count = 0;

  // Check Anti-Diag
  for (let i = 0; i < 3; ++i) {
    if (grid[convertCordToIndex(i, 3 - 1 - i)] === SPACE_STATE.PLAYER) {
      count++;
    } else if (grid[convertCordToIndex(i, 3 - 1 - i)] === SPACE_STATE.AI) {
      count--;
    } else if (grid[convertCordToIndex(i, 3 - 1 - i)] === SPACE_STATE.EMPTY) {
      row = i;
      col = 3 - 1 - i;
    }
  }

  // Opponent two consecutive spaces, return third to block.
  if (count === 2) {
    return convertCordToIndex(row, col);
  }

  return null;
}

/**
 * Ai checks if it can block a fork
 * @returns Can ai block opponent
 */
function aiCanBlockFork(grid, moveCount) {
  if (moveCount === 3) {
    if (
      grid[convertCordToIndex(0, 0)] === SPACE_STATE.PLAYER &&
      grid[convertCordToIndex(1, 1)] === SPACE_STATE.AI &&
      grid[convertCordToIndex(2, 2)] === SPACE_STATE.PLAYER
    ) {
      aiCanFillEmptySide(grid);
      return true;
    }
    if (
      grid[convertCordToIndex(2, 0)] === SPACE_STATE.PLAYER &&
      grid[convertCordToIndex(1, 1)] === SPACE_STATE.AI &&
      grid[convertCordToIndex(0, 2)] === SPACE_STATE.PLAYER
    ) {
      aiCanFillEmptySide(grid);
      return true;
    }
    if (
      grid[convertCordToIndex(2, 1)] === SPACE_STATE.PLAYER &&
      grid[convertCordToIndex(1, 2)] === SPACE_STATE.PLAYER
    ) {
      return convertCordToIndex(2, 2);
    }
  }

  return null;
}

/**
 * Ai checks if it can fill center square
 * @returns Can ai fill center square
 */
function aiCanCenter(grid) {
  if (grid[convertCordToIndex(1, 1)] === SPACE_STATE.EMPTY) {
    return convertCordToIndex(1, 1);
  }
  return false;
}

/**
 * Ai checks if it can fill opposite corner
 * @returns Can ai fill opposite corner
 */
function aiCanFillOppositeCorner(grid) {
  if (
    grid[convertCordToIndex(0, 0)] === SPACE_STATE.PLAYER &&
    grid[convertCordToIndex(2, 2)] === SPACE_STATE.EMPTY
  ) {
    return convertCordToIndex(2, 2);
  }

  if (
    grid[convertCordToIndex(2, 2)] === SPACE_STATE.PLAYER &&
    grid[convertCordToIndex(0, 0)] === SPACE_STATE.EMPTY
  ) {
    return convertCordToIndex(0, 0);
  }

  if (
    grid[convertCordToIndex(0, 2)] === SPACE_STATE.PLAYER &&
    grid[convertCordToIndex(2, 0)] === SPACE_STATE.EMPTY
  ) {
    return convertCordToIndex(2, 0);
  }

  if (
    grid[convertCordToIndex(2, 0)] === SPACE_STATE.PLAYER &&
    grid[convertCordToIndex(0, 2)] === SPACE_STATE.EMPTY
  ) {
    return convertCordToIndex(0, 2);
  }

  return null;
}

/**
 * Ai checks if it can fill empty corner
 * @returns Can ai fill empty corner
 */
function aiCanFillEmptyCorner(grid) {
  if (grid[convertCordToIndex(0, 0)] === SPACE_STATE.EMPTY) {
    return convertCordToIndex(0, 0);
  }

  if (grid[convertCordToIndex(0, 2)] === SPACE_STATE.EMPTY) {
    return convertCordToIndex(0, 2);
  }

  if (grid[convertCordToIndex(2, 0)] === SPACE_STATE.EMPTY) {
    return convertCordToIndex(2, 0);
  }

  if (grid[convertCordToIndex(2, 2)] === SPACE_STATE.EMPTY) {
    return convertCordToIndex(2, 2);
  }

  return null;
}

/**
 * Ai checks if it can fill empty side
 * @returns Can ai fill empty side
 */
function aiCanFillEmptySide(grid) {
  if (grid[convertCordToIndex(0, 1)] === SPACE_STATE.EMPTY) {
    return convertCordToIndex(0, 1);
  }

  if (grid[convertCordToIndex(1, 0)] === SPACE_STATE.EMPTY) {
    return convertCordToIndex(1, 0);
  }

  if (grid[convertCordToIndex(1, 2)] === SPACE_STATE.EMPTY) {
    return convertCordToIndex(1, 2);
  }

  if (grid[convertCordToIndex(2, 1)] === SPACE_STATE.EMPTY) {
    return convertCordToIndex(2, 1);
  }

  return null;
}
Enter fullscreen mode Exit fullscreen mode

Checking for a Winner

A draw or winner is checked after every turn. Counting the move count against the maximum moves determines if the game is drawn.

For a winner, a check is made for three consecutive filled horizontal, vertical, or diagonal squares by either the player or AI. The 3-indexes required for a win are defined as a 2d-array then compared against the grid.

const MAX_MOVES = 10;

const isDraw = (moveCount) => {
  return moveCount === MAX_MOVES;
};

const checkWinner = (grid, moveCount) => {
  const winnerSpaces = [
    [0, 1, 2],
    [3, 4, 5],
    [6, 7, 8],
    [0, 3, 6],
    [1, 4, 7],
    [2, 5, 8],
    [0, 4, 8],
    [2, 4, 6]
  ];

  if (isDraw(moveCount)) {
    return {
      winner: GAME_STATE.DRAW,
      winSpaces: []
    };
  }

  for (let i = 0; i < winnerSpaces.length; i++) {
    const [a, b, c] = winnerSpaces[i];

    if (
      grid[a] === SPACE_STATE.EMPTY &&
      grid[b] === SPACE_STATE.EMPTY &&
      grid[c] === SPACE_STATE.EMPTY
    ) {
      continue;
    }

    if (grid[a] && grid[a] === grid[b] && grid[a] === grid[c]) {
      let winner = null;

      if (grid[a] === SPACE_STATE.PLAYER) {
        winner = GAME_STATE.PLAYER_WON;
      } else {
        winner = GAME_STATE.AI_WON;
      }

      return {
        winner: winner,
        winSpaces: [a, b, c]
      };
    }
  }

  return null;
};
Enter fullscreen mode Exit fullscreen mode

Game Loop

The useEffect hook is responsible for game flow. You control when this hook runs by providing a dependency that tells it to re-run each time the dependency changes. The gameState variable is the perfect dependency, as each game action updates it, allowing the game to flow smoothly.

useEffect(() => {
  ...
  // I need to re-run on gameState change.
  }, [gameState]);
Enter fullscreen mode Exit fullscreen mode

After each turn, useEffect checks for a winner, calculates the AI's turn, checks for a winner again, then changes the gameState to GAME_STATE.PLAYER_TURN and waits to repeat the loop.

// Spaces used to get a win
const [winSpaces, setWinSpaces] = useState([]);

useEffect(() => {
  // Player took turn and changed game state,
  // check for a winner.
  let winner = checkWinner(grid, moveCount);

  // If the someone won, update state to reflect and set winner spaces.
  if (winner) {
    setGameState(winner.winner);
    setWinSpaces(winner.winSpaces);
  }

  // Run AI turn
  if (gameState === GAME_STATE.AI_TURN && moveCount < 10) {
    const aiSpace = calculateAITurn(grid, moveCount);
    setMoveCount((oldMoves) => {
      return oldMoves + 1;
  });

    fillGridSpace(aiSpace, SPACE_STATE.AI);
    winner = checkWinner(grid, moveCount);
  }

  // If AI won, update state to reflect, else
  // go back to player turn.
  if (winner) {
    setGameState(winner.winner);
    setWinSpaces(winner.winSpaces);
  } else {
    setGameState(GAME_STATE.PLAYER_TURN);
  }

  // I need to re-run on gameState change.
}, [gameState]);
Enter fullscreen mode Exit fullscreen mode

Highlighting Winner Spaces

We track winner spaces, modifying the getSpaceStateClass function to account for the gameState and winSpaces when determining the CSS class names is an easy change.

const getSpaceStateClass = (spaceState, gameState, winSpaces, spaceIndex) => {
  let space = "";

  if (spaceState === SPACE_STATE.AI) {
    space += "o-player";

    if (gameState === GAME_STATE.AI_WON && winSpaces.includes(spaceIndex)) {
      space += " o-winner";
    }
  }

  if (spaceState === SPACE_STATE.PLAYER) {
    space += "x-player";

    if (gameState === GAME_STATE.PLAYER_WON && winSpaces.includes(spaceIndex)) {
      space += " x-winner";
    }
  }

  return space;
};
Enter fullscreen mode Exit fullscreen mode

Resetting

Having to refresh the browser every time you want to restart the game is irritating. So we create a reset function that resets all state variables to their default values.

// Reset state to default values
const reset = () => {
  setGrid(createEmptyGrid());
  setGameState(GAME_STATE.PLAYER_TURN);
  setMoveCount(0);
  setWinSpaces([]);
};

<button
  className="bg-blue-500 text-white w-full py-2 font-semibold mt-10 rounded-md shadow-lg"
  onClick={() => {
    reset();
  }}
>
  Reset
</button>
Enter fullscreen mode Exit fullscreen mode

Conclusion

This unbeatable playable tic-tac-toe game was super fun to implement and made me think about:

  • Using types to represent state.
  • Creating an AI using a strategy.
  • Utilizing useEffect for game flow.

I hope you learned as much as I did! Now swindle money from bets you know you'll win (I take a 15% cut naturally 😉). If you're successful, let me know in the comments below.

Consider signing up for my newsletter or supporting me if this was helpful. Thanks for reading!

Top comments (3)

Collapse
 
jonrandy profile image
Jon Randy 🎖️ • Edited

Not really an AI though, is it? Very misleading title

Collapse
 
nivedin profile image
Nivedin

Hey cool implementation but there is a bug when we play corner box first followed by diagonally opposite box the code breaks 😛

Collapse
 
gregorygaines profile image
Gregory Gaines

Nice catch!!! Forgot to pass a parameter 😅 .