Game Development

Develop an Unbeatable Tic-Tac-Toe AI Using React

Ready to swindle money from your fools that believe they are the best at tic-tac-toe!

Written by Gregory Gaines
5 min read
0 views
Tic-tac-toe
Photo by Alicja on Pixabay

Table of Contents

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:

JS
App.js
1import React from "react"; 2import "./styles.css"; 3 4export default function App() { 5 6 const Square = (props) => { 7 return ( 8 <div 9 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" 10 > 11 X 12 </div> 13 ); 14 }; 15 16 return ( 17 <> 18 <div className="text-center py-2 shadow-sm text-gray-400 z-50 sticky"> 19 Your Turn 20 </div> 21 <section className="game-board py-10"> 22 <div className="max-w-md mx-auto"> 23 <div className="max-w-lg flex flex-col gap-5 mx-auto"> 24 <div className="flex gap-5 mx-auto"> 25 <Square squareIndex={0} /> 26 <Square squareIndex={1} /> 27 <Square squareIndex={2} /> 28 </div> 29 <div className="flex gap-5 mx-auto"> 30 <Square squareIndex={3} /> 31 <Square squareIndex={4} /> 32 <Square squareIndex={5} /> 33 </div> 34 <div className="flex gap-5 mx-auto"> 35 <Square squareIndex={6} /> 36 <Square squareIndex={7} /> 37 <Square squareIndex={8} /> 38 </div> 39 </div> 40 41 <div className="text-center"> 42 <button className="bg-blue-500 text-white w-full py-2 font-semibold mt-10 rounded-md shadow-lg"> 43 Reset 44 </button> 45 </div> 46 </div> 47 </section> 48 </> 49 ); 50}
Css
styles.css
1html, 2body { 3 font-family: "Open Sans", sans-serif; 4 height: 100%; 5 background-color: #f9fafb; 6} 7 8.game-board { 9 font-family: "Open Sans", sans-serif; 10} 11 12.shadow-md { 13 box-shadow: rgba(7, 65, 210, 0.1) 0px 9px 30px !important; 14} 15 16.o-player { 17 background: #cb6893; 18 background: -webkit-linear-gradient(to right, #cb6893 0%, #f6d9d7 100%); 19 background: -moz-linear-gradient(to right, #cb6893 0%, #f6d9d7 100%); 20 background: linear-gradient(to right, #cb6893 0%, #f6d9d7 100%); 21 -webkit-background-clip: text; 22 -webkit-text-fill-color: transparent; 23} 24 25.x-player { 26 background: #746dd0; 27 background: -webkit-linear-gradient(to right, #746dd0 0%, #c4e1eb 100%); 28 background: -moz-linear-gradient(to right, #746dd0 0%, #c4e1eb 100%); 29 background: linear-gradient(to right, #746dd0 0%, #c4e1eb 100%); 30 -webkit-background-clip: text; 31 -webkit-text-fill-color: transparent; 32} 33 34.x-winner { 35 text-shadow: 0 0 10px #746dd0, 0 0 0px #746dd0, 0 0 40px #746dd0, 36 0 0 2px #746dd0; 37} 38 39.o-winner { 40 text-shadow: 0 0 10px #ff9bc6, 0 0 0px #ff9bc6, 0 0 40px #ff9bc6, 41 0 0 2px #ff9bc6; 42}

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:

JS
1const GAME_WON = { 2 YES: 'game_won_yes', 3 NO: 'game_won_no', 4 DRAW: 'game_draw', 5 OVERTIME: 'game_overtime', 6}

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.

JS
1const GAME_STATE = { 2 PLAYER_TURN: "player_turn", 3 AI_TURN: "ai_turn", 4 PLAYER_WON: "player_won", 5 AI_WON: "player_o_won", 6 DRAW: "game_draw", 7 ERROR: "game_error" 8}; 9 10// Current game state 11const [gameState, setGameState] = useState(GAME_STATE.PLAYER_TURN);

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.

JS
1export const GRID_LENGTH = 9; 2 3export const SPACE_STATE = { 4 PLAYER: "player_filled", 5 AI: "ai_filled", 6 EMPTY: "empty_space" 7}; 8 9const createEmptyGrid = () => { 10 return Array(GRID_LENGTH).fill(SPACE_STATE.EMPTY); 11}; 12 13const [grid, setGrid] = useState(createEmptyGrid());

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.

JS
1 // Count of moves made 2 const [moveCount, setMoveCount] = useState(0);

Handling Player Clicks

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

JS
1... 2<Square squareIndex={0} /> 3<Square squareIndex={1} /> 4<Square squareIndex={2} /> 5...

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.

JS
1// Get the correct space class names 2const getSpaceStateClass = (spaceState) => { 3let space = ""; 4 5if (spaceState === SPACE_STATE.AI) { 6 return "o-player"; 7} 8 9if (spaceState === SPACE_STATE.PLAYER) { 10 return "x-player"; 11} 12 13return ""; 14}; 15 16const getSquareSymbol = (spaceStatus) => { 17 switch (spaceStatus) { 18 case SPACE_STATE.PLAYER: { 19 return "X"; 20 } 21 case SPACE_STATE.AI: { 22 return "O"; 23 } 24 case SPACE_STATE.EMPTY: { 25 return ""; 26 } 27 default: { 28 return ""; 29 } 30 } 31}; 32 33// Fill in a grid square with status 34const fillGridSpace = (gridIndex, spaceStatus) => { 35 setGrid((oldGrid) => { 36 oldGrid[gridIndex] = spaceStatus; 37 return [...oldGrid]; 38 }); 39}; 40 41// Fill in the grid array with the player space state. 42const handlePlayerClick = (gridIndex) => { 43 // If not the player turn, then exit. 44 if (gameState !== GAME_STATE.PLAYER_TURN) { 45 return; 46 } 47 48 // If the current square is empty, then fill in space. 49 if (grid[gridIndex] === SPACE_STATE.EMPTY) { 50 // Fill grid space 51 fillGridSpace(gridIndex, SPACE_STATE.PLAYER); 52 // Update game state to AI's turn. 53 setGameState(GAME_STATE.AI_TURN); 54 // Update move count 55 setMoveCount((oldMoves) => { 56 return oldMoves + 1; 57 }); 58 } 59}; 60 61 const Square = (props) => { 62 return ( 63 <div 64 className="shadow-md h-24 w-24 rounded-lg bg-white text-7xl text-center cursor-default font-light flex items-center justify-center " 65 // Connect click listener 66 onClick={() => { 67 handlePlayerClick(props.squareIndex); 68 }} 69 > 70 // Get square symbol 71 {getSquareSymbol(grid[props.squareIndex])} 72 </div> 73 ); 74 };

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.

JS
AI.js
1import { SPACE_STATE } from "./App"; 2 3// Calculate the best space for the AI to fill to get a perfect game. 4export const calculateAITurn = (grid, moveCount) => { 5 let aiSpace = aiCanWin(grid); 6 7 if (Number.isInteger(aiSpace)) { 8 console.log("Ai winning"); 9 return aiSpace; 10 } 11 12 aiSpace = aiCanBlock(grid); 13 14 if (Number.isInteger(aiSpace)) { 15 console.log("Ai blocking"); 16 return aiSpace; 17 } 18 19 aiSpace = aiCanBlockFork(grid, moveCount); 20 21 if (Number.isInteger(aiSpace)) { 22 console.log("AI forking"); 23 return aiSpace; 24 } 25 26 aiSpace = aiCanCenter(grid); 27 28 if (Number.isInteger(aiSpace)) { 29 console.log("AI centering"); 30 return aiSpace; 31 } 32 33 aiSpace = aiCanFillOppositeCorner(grid); 34 35 if (Number.isInteger(aiSpace)) { 36 console.log("AI filling opposite corner"); 37 return aiSpace; 38 } 39 40 aiSpace = aiCanFillEmptyCorner(grid); 41 42 if (Number.isInteger(aiSpace)) { 43 console.log("AI filling empty corner"); 44 return aiSpace; 45 } 46 47 aiSpace = aiCanFillEmptySide(grid); 48 49 if (Number.isInteger(aiSpace)) { 50 console.log("AI filling empty side"); 51 return aiSpace; 52 } 53 54 // console.log("AI can't move"); 55 return null; 56}; 57 58// Convert row, col to grid index. 59const convertCordToIndex = (row, col) => { 60 return row * 3 + col; 61}; 62/** 63 * Check if AI can win 64 * @returns Space for AI to win 65 */ 66const aiCanWin = (grid) => { 67 let count = 0; 68 let row, col; 69 70 // Check Rows 71 for (let i = 0; i < 3; ++i) { 72 count = 0; 73 74 for (let j = 0; j < 3; ++j) { 75 if (grid[convertCordToIndex(i, j)] === SPACE_STATE.AI) { 76 count++; 77 } else if (grid[convertCordToIndex(i, j)] === SPACE_STATE.PLAYER) { 78 count--; 79 } else if (grid[convertCordToIndex(i, j)] === SPACE_STATE.EMPTY) { 80 row = i; 81 col = j; 82 } 83 } 84 85 // Has two consecutive spaces, return third to win. 86 if (count === 2) { 87 return convertCordToIndex(row, col); 88 } 89 } 90 91 // Check Cols 92 for (let i = 0; i < 3; ++i) { 93 count = 0; 94 95 for (let j = 0; j < 3; ++j) { 96 if (grid[convertCordToIndex(j, i)] === SPACE_STATE.AI) { 97 count++; 98 } else if (grid[convertCordToIndex(j, i)] === SPACE_STATE.PLAYER) { 99 count--; 100 } else if (grid[convertCordToIndex(j, i)] === SPACE_STATE.EMPTY) { 101 row = j; 102 col = i; 103 } 104 } 105 106 // Has two consecutive spaces, return third to win. 107 if (count === 2) { 108 return convertCordToIndex(row, col); 109 } 110 } 111 112 count = 0; 113 114 // Check Diag 115 for (let i = 0; i < 3; ++i) { 116 if (grid[convertCordToIndex(i, i)] === SPACE_STATE.AI) { 117 count++; 118 } else if (grid[convertCordToIndex(i, i)] === SPACE_STATE.PLAYER) { 119 count--; 120 } else if (grid[convertCordToIndex(i, i)] === SPACE_STATE.EMPTY) { 121 row = i; 122 col = i; 123 } 124 } 125 126 // Has two consecutive spaces, return third to win. 127 if (count === 2) { 128 return convertCordToIndex(row, col); 129 } 130 131 count = 0; 132 133 // Check Anti-Diag 134 for (var i = 0; i < 3; ++i) { 135 if (grid[convertCordToIndex(i, 3 - 1 - i)] === SPACE_STATE.AI) { 136 count++; 137 } else if (grid[convertCordToIndex(i, 3 - 1 - i)] === SPACE_STATE.PLAYER) { 138 count--; 139 } else if (grid[convertCordToIndex(i, 3 - 1 - i)] === SPACE_STATE.EMPTY) { 140 row = i; 141 col = 3 - 1 - i; 142 } 143 } 144 145 // Has two consecutive spaces, return third to win. 146 if (count === 2) { 147 return convertCordToIndex(row, col); 148 } 149 150 return null; 151}; 152 153/** 154 * Ai checks if it can block opponents win 155 * @returns Can ai block opponent 156 */ 157function aiCanBlock(grid) { 158 var count = 0; 159 var row, col; 160 161 // Check Rows 162 for (let i = 0; i < 3; ++i) { 163 count = 0; 164 165 for (let j = 0; j < 3; ++j) { 166 if (grid[convertCordToIndex(i, j)] === SPACE_STATE.PLAYER) { 167 count++; 168 } else if (grid[convertCordToIndex(i, j)] === SPACE_STATE.AI) { 169 count--; 170 } else if (grid[convertCordToIndex(i, j)] === SPACE_STATE.EMPTY) { 171 row = i; 172 col = j; 173 } 174 } 175 176 // Opponent two consecutive spaces, return third to block. 177 if (count === 2) { 178 return convertCordToIndex(row, col); 179 } 180 } 181 182 // Check Cols 183 for (let i = 0; i < 3; ++i) { 184 count = 0; 185 186 for (let j = 0; j < 3; ++j) { 187 if (grid[convertCordToIndex(j, i)] === SPACE_STATE.PLAYER) { 188 count++; 189 } else if (grid[convertCordToIndex(j, i)] === SPACE_STATE.AI) { 190 count--; 191 } else if (grid[convertCordToIndex(j, i)] === SPACE_STATE.EMPTY) { 192 row = j; 193 col = i; 194 } 195 } 196 197 // Opponent two consecutive spaces, return third to block. 198 if (count === 2) { 199 return convertCordToIndex(row, col); 200 } 201 } 202 203 count = 0; 204 205 // Check Diag 206 for (let i = 0; i < 3; ++i) { 207 if (grid[convertCordToIndex(i, i)] === SPACE_STATE.PLAYER) { 208 count++; 209 } else if (grid[convertCordToIndex(i, i)] === SPACE_STATE.AI) { 210 count--; 211 } else if (grid[convertCordToIndex(i, i)] === SPACE_STATE.EMPTY) { 212 row = i; 213 col = i; 214 } 215 } 216 217 // Opponent two consecutive spaces, return third to block. 218 if (count === 2) { 219 return convertCordToIndex(row, col); 220 } 221 222 count = 0; 223 224 // Check Anti-Diag 225 for (let i = 0; i < 3; ++i) { 226 if (grid[convertCordToIndex(i, 3 - 1 - i)] === SPACE_STATE.PLAYER) { 227 count++; 228 } else if (grid[convertCordToIndex(i, 3 - 1 - i)] === SPACE_STATE.AI) { 229 count--; 230 } else if (grid[convertCordToIndex(i, 3 - 1 - i)] === SPACE_STATE.EMPTY) { 231 row = i; 232 col = 3 - 1 - i; 233 } 234 } 235 236 // Opponent two consecutive spaces, return third to block. 237 if (count === 2) { 238 return convertCordToIndex(row, col); 239 } 240 241 return null; 242} 243 244/** 245 * Ai checks if it can block a fork 246 * @returns Can ai block opponent 247 */ 248function aiCanBlockFork(grid, moveCount) { 249 if (moveCount === 3) { 250 if ( 251 grid[convertCordToIndex(0, 0)] === SPACE_STATE.PLAYER && 252 grid[convertCordToIndex(1, 1)] === SPACE_STATE.AI && 253 grid[convertCordToIndex(2, 2)] === SPACE_STATE.PLAYER 254 ) { 255 aiCanFillEmptySide(grid); 256 return true; 257 } 258 if ( 259 grid[convertCordToIndex(2, 0)] === SPACE_STATE.PLAYER && 260 grid[convertCordToIndex(1, 1)] === SPACE_STATE.AI && 261 grid[convertCordToIndex(0, 2)] === SPACE_STATE.PLAYER 262 ) { 263 aiCanFillEmptySide(grid); 264 return true; 265 } 266 if ( 267 grid[convertCordToIndex(2, 1)] === SPACE_STATE.PLAYER && 268 grid[convertCordToIndex(1, 2)] === SPACE_STATE.PLAYER 269 ) { 270 return convertCordToIndex(2, 2); 271 } 272 } 273 274 return null; 275} 276 277/** 278 * Ai checks if it can fill center square 279 * @returns Can ai fill center square 280 */ 281function aiCanCenter(grid) { 282 if (grid[convertCordToIndex(1, 1)] === SPACE_STATE.EMPTY) { 283 return convertCordToIndex(1, 1); 284 } 285 return false; 286} 287 288/** 289 * Ai checks if it can fill opposite corner 290 * @returns Can ai fill opposite corner 291 */ 292function aiCanFillOppositeCorner(grid) { 293 if ( 294 grid[convertCordToIndex(0, 0)] === SPACE_STATE.PLAYER && 295 grid[convertCordToIndex(2, 2)] === SPACE_STATE.EMPTY 296 ) { 297 return convertCordToIndex(2, 2); 298 } 299 300 if ( 301 grid[convertCordToIndex(2, 2)] === SPACE_STATE.PLAYER && 302 grid[convertCordToIndex(0, 0)] === SPACE_STATE.EMPTY 303 ) { 304 return convertCordToIndex(0, 0); 305 } 306 307 if ( 308 grid[convertCordToIndex(0, 2)] === SPACE_STATE.PLAYER && 309 grid[convertCordToIndex(2, 0)] === SPACE_STATE.EMPTY 310 ) { 311 return convertCordToIndex(2, 0); 312 } 313 314 if ( 315 grid[convertCordToIndex(2, 0)] === SPACE_STATE.PLAYER && 316 grid[convertCordToIndex(0, 2)] === SPACE_STATE.EMPTY 317 ) { 318 return convertCordToIndex(0, 2); 319 } 320 321 return null; 322} 323 324/** 325 * Ai checks if it can fill empty corner 326 * @returns Can ai fill empty corner 327 */ 328function aiCanFillEmptyCorner(grid) { 329 if (grid[convertCordToIndex(0, 0)] === SPACE_STATE.EMPTY) { 330 return convertCordToIndex(0, 0); 331 } 332 333 if (grid[convertCordToIndex(0, 2)] === SPACE_STATE.EMPTY) { 334 return convertCordToIndex(0, 2); 335 } 336 337 if (grid[convertCordToIndex(2, 0)] === SPACE_STATE.EMPTY) { 338 return convertCordToIndex(2, 0); 339 } 340 341 if (grid[convertCordToIndex(2, 2)] === SPACE_STATE.EMPTY) { 342 return convertCordToIndex(2, 2); 343 } 344 345 return null; 346} 347 348/** 349 * Ai checks if it can fill empty side 350 * @returns Can ai fill empty side 351 */ 352function aiCanFillEmptySide(grid) { 353 if (grid[convertCordToIndex(0, 1)] === SPACE_STATE.EMPTY) { 354 return convertCordToIndex(0, 1); 355 } 356 357 if (grid[convertCordToIndex(1, 0)] === SPACE_STATE.EMPTY) { 358 return convertCordToIndex(1, 0); 359 } 360 361 if (grid[convertCordToIndex(1, 2)] === SPACE_STATE.EMPTY) { 362 return convertCordToIndex(1, 2); 363 } 364 365 if (grid[convertCordToIndex(2, 1)] === SPACE_STATE.EMPTY) { 366 return convertCordToIndex(2, 1); 367 } 368 369 return null; 370}

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.

JS
1const MAX_MOVES = 10; 2 3const isDraw = (moveCount) => { 4 return moveCount === MAX_MOVES; 5}; 6 7const checkWinner = (grid, moveCount) => { 8 const winnerSpaces = [ 9 [0, 1, 2], 10 [3, 4, 5], 11 [6, 7, 8], 12 [0, 3, 6], 13 [1, 4, 7], 14 [2, 5, 8], 15 [0, 4, 8], 16 [2, 4, 6] 17 ]; 18 19 if (isDraw(moveCount)) { 20 return { 21 winner: GAME_STATE.DRAW, 22 winSpaces: [] 23 }; 24 } 25 26 for (let i = 0; i < winnerSpaces.length; i++) { 27 const [a, b, c] = winnerSpaces[i]; 28 29 if ( 30 grid[a] === SPACE_STATE.EMPTY && 31 grid[b] === SPACE_STATE.EMPTY && 32 grid[c] === SPACE_STATE.EMPTY 33 ) { 34 continue; 35 } 36 37 if (grid[a] && grid[a] === grid[b] && grid[a] === grid[c]) { 38 let winner = null; 39 40 if (grid[a] === SPACE_STATE.PLAYER) { 41 winner = GAME_STATE.PLAYER_WON; 42 } else { 43 winner = GAME_STATE.AI_WON; 44 } 45 46 return { 47 winner: winner, 48 winSpaces: [a, b, c] 49 }; 50 } 51 } 52 53 return null; 54};

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.

JS
1useEffect(() => { 2 ... 3 // I need to re-run on gameState change. 4 }, [gameState]);

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.

JS
1// Spaces used to get a win 2const [winSpaces, setWinSpaces] = useState([]); 3 4useEffect(() => { 5 // Player took turn and changed game state, 6 // check for a winner. 7 let winner = checkWinner(grid, moveCount); 8 9 // If the someone won, update state to reflect and set winner spaces. 10 if (winner) { 11 setGameState(winner.winner); 12 setWinSpaces(winner.winSpaces); 13 } 14 15 // Run AI turn 16 if (gameState === GAME_STATE.AI_TURN && moveCount < 10) { 17 const aiSpace = calculateAITurn(grid, moveCount); 18 setMoveCount((oldMoves) => { 19 return oldMoves + 1; 20 }); 21 22 fillGridSpace(aiSpace, SPACE_STATE.AI); 23 winner = checkWinner(grid, moveCount); 24 } 25 26 // If AI won, update state to reflect, else 27 // go back to player turn. 28 if (winner) { 29 setGameState(winner.winner); 30 setWinSpaces(winner.winSpaces); 31 } else { 32 setGameState(GAME_STATE.PLAYER_TURN); 33 } 34 35 // I need to re-run on gameState change. 36}, [gameState]);

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.

JS
1const getSpaceStateClass = (spaceState, gameState, winSpaces, spaceIndex) => { 2 let space = ""; 3 4 if (spaceState === SPACE_STATE.AI) { 5 space += "o-player"; 6 7 if (gameState === GAME_STATE.AI_WON && winSpaces.includes(spaceIndex)) { 8 space += " o-winner"; 9 } 10 } 11 12 if (spaceState === SPACE_STATE.PLAYER) { 13 space += "x-player"; 14 15 if (gameState === GAME_STATE.PLAYER_WON && winSpaces.includes(spaceIndex)) { 16 space += " x-winner"; 17 } 18 } 19 20 return space; 21};

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.

JS
1// Reset state to default values 2const reset = () => { 3 setGrid(createEmptyGrid()); 4 setGameState(GAME_STATE.PLAYER_TURN); 5 setMoveCount(0); 6 setWinSpaces([]); 7}; 8 9<button 10 className="bg-blue-500 text-white w-full py-2 font-semibold mt-10 rounded-md shadow-lg" 11 onClick={() => { 12 reset(); 13 }} 14> 15 Reset 16</button>

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!

About the author.

I'm Gregory Gaines, a software engineer that loves blogging, studying computer science, and reverse engineering.

I'm currently employed at Google; all opinions are my own.

Ko-fi donationsBuy Me a CoffeeBecome a Patron
Gregory Gaines

You may also like.

Comments.

Get updates straight to your mailbox!

Get the latest blog updates about programming and the industry ins and outs for free!

You have my spam free, guarantee. 🥳

Subscribe