Build a Tic-Tac-Toe Game with TypeScript, React and Minimax

Updated on · 12 min read
Build a Tic-Tac-Toe Game with TypeScript, React and Minimax

React is widely used for developing websites and mobile applications. However, it can also be used for making other things, like games. That's what we'll do in this tutorial - build a Tic-Tac-Toe game using React. It is also a good practice with React Hooks since the structure of Tic-Tac-Toe provides a lot of opportunities to get closely familiar with React component's lifecycle. On top of that, we'll use TypeScript to provide the types for the game. Styled Components will be our css-in-js library. The AI moves will be powered by the Minimax algorithm, making the game unbeatable at the highest difficulty.

The final version of the game can be found on GitHub pages with the code available on GitHub, in case you'd like to dive straight into it. There's also a CodeSandbox with the complete Tic-Tac-Toe game available for those who prefer a more interactive coding experience.

Setting up

For setting up the game, we'll use create-react-app with the TypeScript template. Additionally, to achieve a nice minimalistic look, we'll utilise PaperCSS, and for displaying the game results, React-modal will be used. Let's begin by creating an empty project and installing the necessary dependencies.

bash
npx create-react-app tic_tac_toe --template typescript cd tic_tac_toe npm i styled-components papercss react-modal @types/react-modal @types/styled-components
bash
npx create-react-app tic_tac_toe --template typescript cd tic_tac_toe npm i styled-components papercss react-modal @types/react-modal @types/styled-components

After the project is set up, we can start by making changes to App.tsx to incorporate the main game components and apply PaperCSS styles.

tsx
// App.tsx import React from "react"; import styled from "styled-components"; import TicTacToe from "./TicTacToe"; import "papercss/dist/paper.min.css"; export default function App() { return ( <Main> <TicTacToe /> </Main> ); } const Main = styled.main` display: flex; justify-content: center; align-items: center; height: 100vh; `;
tsx
// App.tsx import React from "react"; import styled from "styled-components"; import TicTacToe from "./TicTacToe"; import "papercss/dist/paper.min.css"; export default function App() { return ( <Main> <TicTacToe /> </Main> ); } const Main = styled.main` display: flex; justify-content: center; align-items: center; height: 100vh; `;

This will center the game component on the screen. Non-essential elements, like the footer, are excluded to keep the focus on the key components. The next step is to create the TicTacToe component. Before that, we'll group some of the game's constants in a dedicated constants.ts file.

typescript
// Dimensions of the board (3x3 squares), game outcomes and players, // and dimensions for the board squares, in pixels. export const DIMENSIONS = 3; export const SQUARE_DIMS = 100; export const DRAW = 0; export const PLAYER_X = 1; export const PLAYER_O = 2;
typescript
// Dimensions of the board (3x3 squares), game outcomes and players, // and dimensions for the board squares, in pixels. export const DIMENSIONS = 3; export const SQUARE_DIMS = 100; export const DRAW = 0; export const PLAYER_X = 1; export const PLAYER_O = 2;

Here, we define the initial dimensions for the grid, the pixel width of each grid cell, and constants representing the game's draw state and player roles. Now, let's create TicTacToe.tsx and begin the process of configuring and rendering the game grid.

tsx
import React, { useState } from "react"; import styled from "styled-components"; import { DIMENSIONS, PLAYER_X, PLAYER_O, SQUARE_DIMS } from "./constants"; const emptyGrid = new Array(DIMENSIONS ** 2).fill(null); export default function TicTacToe() { const [grid, setGrid] = useState(emptyGrid); const [players, setPlayers] = useState({ human: PLAYER_X, ai: PLAYER_O, }); const move = (index: number, player: number) => { setGrid((grid) => { const gridCopy = grid.concat(); gridCopy[index] = player; return gridCopy; }); }; const humanMove = (index: number) => { if (!grid[index]) { move(index, players.human); } }; return ( <Container dims={DIMENSIONS}> {grid.map((value, index) => { const isActive = value !== null; return ( <Square key={index} onClick={() => humanMove(index)}> {isActive && <Marker>{value === PLAYER_X ? "X" : "O"}</Marker>} </Square> ); })} </Container> ); } const Container = styled.div<{ dims: number }>` display: flex; justify-content: center; width: ${({ dims }) => `${dims * (SQUARE_DIMS + 5)}px`}; flex-flow: wrap; position: relative; `; const Square = styled.div` display: flex; justify-content: center; align-items: center; width: ${SQUARE_DIMS}px; height: ${SQUARE_DIMS}px; border: 1px solid black; &:hover { cursor: pointer; } `; const Marker = styled.p` font-size: 68px; `;
tsx
import React, { useState } from "react"; import styled from "styled-components"; import { DIMENSIONS, PLAYER_X, PLAYER_O, SQUARE_DIMS } from "./constants"; const emptyGrid = new Array(DIMENSIONS ** 2).fill(null); export default function TicTacToe() { const [grid, setGrid] = useState(emptyGrid); const [players, setPlayers] = useState({ human: PLAYER_X, ai: PLAYER_O, }); const move = (index: number, player: number) => { setGrid((grid) => { const gridCopy = grid.concat(); gridCopy[index] = player; return gridCopy; }); }; const humanMove = (index: number) => { if (!grid[index]) { move(index, players.human); } }; return ( <Container dims={DIMENSIONS}> {grid.map((value, index) => { const isActive = value !== null; return ( <Square key={index} onClick={() => humanMove(index)}> {isActive && <Marker>{value === PLAYER_X ? "X" : "O"}</Marker>} </Square> ); })} </Container> ); } const Container = styled.div<{ dims: number }>` display: flex; justify-content: center; width: ${({ dims }) => `${dims * (SQUARE_DIMS + 5)}px`}; flex-flow: wrap; position: relative; `; const Square = styled.div` display: flex; justify-content: center; align-items: center; width: ${SQUARE_DIMS}px; height: ${SQUARE_DIMS}px; border: 1px solid black; &:hover { cursor: pointer; } `; const Marker = styled.p` font-size: 68px; `;

To begin, we import all the necessary dependencies and declare the default array for the grid. utilising the exponentiation operator, introduced in ES2016, along with Array.prototype.fill() from ES2015/ES6, we construct an array with a length of 9, populated with null values. This declaration exists outside the component to prevent re-creation upon re-rendering. Instead of generating a multidimensional array and then rendering it recursively, we are going to render a one-dimensional array and limit its width using CSS.

The line width: ${({ dims }) => `${dims * (SQUARE_DIMS + 5)}px`}; is the Styled Components' approach to pass a variable to a component. An alternative expression would be width: ${(props) => `${props.dims * (SQUARE_DIMS + 5)}px`};. This constrains the container's width to that of 3 squares, each 100 pixels wide (plus a margin for borders). By setting flex-flow: wrap, we arrange excess squares onto subsequent lines, thus forming a 3x3 square grid. After executing npm start and executing a few moves, we can confirm the proper functionality of our grid.

Initial Grid

Looks good, however, it's not too exciting since we haven't set up the AI's moves. We will fix it by adding the aiMove function.

typescript
// TicTacToe.tsx // ... const aiMove = () => { let index = getRandomInt(0, 8); while (grid[index]) { index = getRandomInt(0, 8); } move(index, players.ai); }; const humanMove = (index: number) => { if (!grid[index]) { move(index, players.human); aiMove(); } };
typescript
// TicTacToe.tsx // ... const aiMove = () => { let index = getRandomInt(0, 8); while (grid[index]) { index = getRandomInt(0, 8); } move(index, players.ai); }; const humanMove = (index: number) => { if (!grid[index]) { move(index, players.human); aiMove(); } };
typescript
// utils.ts // Get a random integer in a range min-max export const getRandomInt = (min: number, max: number) => { min = Math.ceil(min); max = Math.floor(max); return Math.floor(Math.random() * (max - min + 1)) + min; };
typescript
// utils.ts // Get a random integer in a range min-max export const getRandomInt = (min: number, max: number) => { min = Math.ceil(min); max = Math.floor(max); return Math.floor(Math.random() * (max - min + 1)) + min; };

With the introduction of the aiMove function, the game now has greater interactivity. Following the human player's turn, the aiMove function is triggered, leading to a move being made in a randomly selected unoccupied square on the board. Additionally, we've incorporated a utils.ts file into the project to house various helper functions, such as the one employed to generate a random number within a specified range.

The game is still far from perfect and has several opportunities for improvement. We start improving it by adding three game states - Not started, In progress, and Over. In the initial state, we present a player selection screen; during the second state, the game board is displayed, allowing players to take their turns; and in the final state, the game's outcome is announced.

typescript
// constants.ts export const GAME_STATES = { notStarted: "not_started", inProgress: "in_progress", over: "over", };
typescript
// constants.ts export const GAME_STATES = { notStarted: "not_started", inProgress: "in_progress", over: "over", };

Now, we can leverage these states within our component to render distinct screens corresponding to each stage of the game.

typescript
// utils.ts import { PLAYER_O, PLAYER_X } from "./constants"; export const switchPlayer = (player: number) => { return player === PLAYER_X ? PLAYER_O : PLAYER_X; };
typescript
// utils.ts import { PLAYER_O, PLAYER_X } from "./constants"; export const switchPlayer = (player: number) => { return player === PLAYER_X ? PLAYER_O : PLAYER_X; };
tsx
// TicTacToe.tsx const TicTacToe = () => { //... const [players, setPlayers] = useState<Record<string, number | null>>({ human: null, ai: null, }); const [gameState, setGameState] = useState(GAME_STATES.notStarted); const move = (index: number, player: number | null) => { if (player !== null) { setGrid((grid) => { const gridCopy = grid.concat(); gridCopy[index] = player; return gridCopy; }); } }; // ... const choosePlayer = (option: number) => { setPlayers({ human: option, ai: switchPlayer(option) }); setGameState(GAME_STATES.inProgress); }; return gameState === GAME_STATES.notStarted ? ( <div> <Inner> <p>Choose your player</p> <ButtonRow> <button onClick={() => choosePlayer(PLAYER_X)}>X</button> <p>or</p> <button onClick={() => choosePlayer(PLAYER_O)}>O</button> </ButtonRow> </Inner> </div> ) : ( <Container dims={DIMENSIONS}> {grid.map((value, index) => { const isActive = value !== null; return ( <Square key={index} onClick={() => humanMove(index)}> {isActive && <Marker>{value === PLAYER_X ? "X" : "O"}</Marker>} </Square> ); })} </Container> ); }; const ButtonRow = styled.div` display: flex; width: 150px; justify-content: space-between; `; const Inner = styled.div` display: flex; flex-direction: column; align-items: center; margin-bottom: 30px; `;
tsx
// TicTacToe.tsx const TicTacToe = () => { //... const [players, setPlayers] = useState<Record<string, number | null>>({ human: null, ai: null, }); const [gameState, setGameState] = useState(GAME_STATES.notStarted); const move = (index: number, player: number | null) => { if (player !== null) { setGrid((grid) => { const gridCopy = grid.concat(); gridCopy[index] = player; return gridCopy; }); } }; // ... const choosePlayer = (option: number) => { setPlayers({ human: option, ai: switchPlayer(option) }); setGameState(GAME_STATES.inProgress); }; return gameState === GAME_STATES.notStarted ? ( <div> <Inner> <p>Choose your player</p> <ButtonRow> <button onClick={() => choosePlayer(PLAYER_X)}>X</button> <p>or</p> <button onClick={() => choosePlayer(PLAYER_O)}>O</button> </ButtonRow> </Inner> </div> ) : ( <Container dims={DIMENSIONS}> {grid.map((value, index) => { const isActive = value !== null; return ( <Square key={index} onClick={() => humanMove(index)}> {isActive && <Marker>{value === PLAYER_X ? "X" : "O"}</Marker>} </Square> ); })} </Container> ); }; const ButtonRow = styled.div` display: flex; width: 150px; justify-content: space-between; `; const Inner = styled.div` display: flex; flex-direction: column; align-items: center; margin-bottom: 30px; `;

Given that the players are initialized as null, it's necessary to explicitly define their types when using the useState hook. This can be achieved by declaring them as useState<Record<string, number | null>>. It's important to note that this change means that we need to modify the move function to check that the player is not null before proceeding with making a move.

Adding the useEffect hook

The previously implemented changes enable player selection. However, since we don't check whose move it currently is, the human player can make several moves out of turn. To fix this issue, we will implement turn-based moves. This involves designating the player slated for the next move as nextMove, effectively enforcing the proper sequence of turns.

tsx
//TicTacToe.tsx //... const [nextMove, setNextMove] = useState<null | number>(null); const humanMove = (index: number) => { if (!grid[index] && nextMove === players.human) { move(index, players.human); setNextMove(players.ai); } }; useEffect(() => { let timeout: NodeJS.Timeout; if ( nextMove !== null && nextMove === players.ai && gameState !== GAME_STATES.over ) { // Delay AI moves to make them seem more natural timeout = setTimeout(() => { aiMove(); }, 500); } return () => timeout && clearTimeout(timeout); }, [nextMove, aiMove, players.ai, gameState]); const choosePlayer = (option: number) => { setPlayers({ human: option, ai: switchPlayer(option) }); setGameState(GAME_STATES.inProgress); // Set the Player X to make the first move setNextMove(PLAYER_X); };
tsx
//TicTacToe.tsx //... const [nextMove, setNextMove] = useState<null | number>(null); const humanMove = (index: number) => { if (!grid[index] && nextMove === players.human) { move(index, players.human); setNextMove(players.ai); } }; useEffect(() => { let timeout: NodeJS.Timeout; if ( nextMove !== null && nextMove === players.ai && gameState !== GAME_STATES.over ) { // Delay AI moves to make them seem more natural timeout = setTimeout(() => { aiMove(); }, 500); } return () => timeout && clearTimeout(timeout); }, [nextMove, aiMove, players.ai, gameState]); const choosePlayer = (option: number) => { setPlayers({ human: option, ai: switchPlayer(option) }); setGameState(GAME_STATES.inProgress); // Set the Player X to make the first move setNextMove(PLAYER_X); };

Quite a few things are going on here. Firstly, we're adding nextMove to the useEffect's dependency array, so when it changes, the code inside the effect is run. For this to work, inside our humanMove function, instead of calling aiMove, we will set the AI as the one that makes the next move.

Additionally, we'll check that it's the human player's turn before allowing them to make a move. As an enhancement, a slight timeout to make AI moves non-instantaneous is added. We need also to remember to remove the timeout in the effect's cleanup function.

Apart from nextMove, we also need to track other variables from the component scope that are accessed inside the effect. Considering that aiMove is a function and will be recreated on every render, we will wrap it in the useCallback hook to memoize it and prevent it from changing unless any of its dependencies change. For a more in-depth look, this article provides an excellent overview of the main caveats of the useEffect hook.

tsx
const aiMove = useCallback(() => { let index = getRandomInt(0, 8); while (grid[index]) { index = getRandomInt(0, 8); } move(index, players.ai); setNextMove(players.human); }, [move, grid, players]);
tsx
const aiMove = useCallback(() => { let index = getRandomInt(0, 8); while (grid[index]) { index = getRandomInt(0, 8); } move(index, players.ai); setNextMove(players.human); }, [move, grid, players]);

We're currently tracking the move function, so it's necessary to memoize it as well. If you're worried about the complexity of hook dependencies spiraling out of control, an alternative is to define the move function outside the component. This approach maintains its identity across renders. However, since it interacts with the component's state, the state setters must be passed as arguments to it.

tsx
//TicTacToe.tsx const move = useCallback( (index: number, player: number | null) => { if (player && gameState === GAME_STATES.inProgress) { setGrid((grid) => { const gridCopy = grid.concat(); gridCopy[index] = player; return gridCopy; }); } }, [gameState], );
tsx
//TicTacToe.tsx const move = useCallback( (index: number, player: number | null) => { if (player && gameState === GAME_STATES.inProgress) { setGrid((grid) => { const gridCopy = grid.concat(); gridCopy[index] = player; return gridCopy; }); } }, [gameState], );

Players are now able to execute their moves, and the game's progression feels quite intuitive. However, if you play the game to its conclusion by filling all available squares, you'll encounter an unexpected issue - an infinite loop. The reason is that the while loop in the aiMove does not have a termination condition after the final square is filled.

Adding Board class

Looking closely, we see that we're not setting the game state to over at any point of the game. But before doing that, we need a way to find the game's winner. This seems like a good opportunity to make a separate Board class, which will encapsulate all the non-render-related board logic.

tsx
// Board.tsx import { DIMENSIONS, DRAW } from "./constants"; type Grid = Array<null | number>; export default class Board { grid: Grid; constructor(grid?: Grid) { this.grid = grid || new Array(DIMENSIONS ** 2).fill(null); } // Collect indices of the empty squares and return them getEmptySquares = (grid = this.grid) => { let squares: number[] = []; grid.forEach((square, i) => { if (square === null) squares.push(i); }); return squares; }; isEmpty = (grid = this.grid) => { return this.getEmptySquares(grid).length === DIMENSIONS ** 2; }; getWinner = (grid = this.grid) => { const winningCombos = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6], ]; let res: number | null = null; winningCombos.forEach((el, i) => { if ( grid[el[0]] !== null && grid[el[0]] === grid[el[1]] && grid[el[0]] === grid[el[2]] ) { res = grid[el[0]]; } else if (res === null && this.getEmptySquares(grid).length === 0) { res = DRAW; } }); return res; }; clone = () => { return new Board(this.grid.concat()); }; }
tsx
// Board.tsx import { DIMENSIONS, DRAW } from "./constants"; type Grid = Array<null | number>; export default class Board { grid: Grid; constructor(grid?: Grid) { this.grid = grid || new Array(DIMENSIONS ** 2).fill(null); } // Collect indices of the empty squares and return them getEmptySquares = (grid = this.grid) => { let squares: number[] = []; grid.forEach((square, i) => { if (square === null) squares.push(i); }); return squares; }; isEmpty = (grid = this.grid) => { return this.getEmptySquares(grid).length === DIMENSIONS ** 2; }; getWinner = (grid = this.grid) => { const winningCombos = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6], ]; let res: number | null = null; winningCombos.forEach((el, i) => { if ( grid[el[0]] !== null && grid[el[0]] === grid[el[1]] && grid[el[0]] === grid[el[2]] ) { res = grid[el[0]]; } else if (res === null && this.getEmptySquares(grid).length === 0) { res = DRAW; } }); return res; }; clone = () => { return new Board(this.grid.concat()); }; }

The class itself is fairly straightforward. We start by adding a method to identify all the empty squares on the board. Following that, we incorporate a utility method to verify whether the board is empty. The class also includes a function to create a copy of the board. Finally, we have the getWinner method which inspects the current board status for any win combinations hardcoded into it, returning the game's outcome accordingly.

Besides initializing the board with an empty grid, the class methods accept an optional grid parameter. This addition enables us to apply these methods to the grid from our game component.

Now that we have a way to determine the game's winner, let's use it to indicate when the game is over. At the same time, we'll add a method to save the game result to the state so that we can display it later. To achieve this, we'll check whether the game has ended after each move is made. To do so, we'll introduce another useEffect hook to track these changes.

tsx
//TicTactToe.tsx import Board from "./Board"; const board = new Board(); const TicTacToe = () => { //... const [winner, setWinner] = useState<null | string>(null); //... useEffect(() => { const boardWinner = board.getWinner(grid); const declareWinner = (winner: number) => { let winnerStr = ""; switch (winner) { case PLAYER_X: winnerStr = "Player X wins!"; break; case PLAYER_O: winnerStr = "Player O wins!"; break; case DRAW: default: winnerStr = "It's a draw"; } setGameState(GAME_STATES.over); setWinner(winnerStr); }; if (boardWinner !== null && gameState !== GAME_STATES.over) { declareWinner(boardWinner); } }, [gameState, grid, nextMove]); };
tsx
//TicTactToe.tsx import Board from "./Board"; const board = new Board(); const TicTacToe = () => { //... const [winner, setWinner] = useState<null | string>(null); //... useEffect(() => { const boardWinner = board.getWinner(grid); const declareWinner = (winner: number) => { let winnerStr = ""; switch (winner) { case PLAYER_X: winnerStr = "Player X wins!"; break; case PLAYER_O: winnerStr = "Player O wins!"; break; case DRAW: default: winnerStr = "It's a draw"; } setGameState(GAME_STATES.over); setWinner(winnerStr); }; if (boardWinner !== null && gameState !== GAME_STATES.over) { declareWinner(boardWinner); } }, [gameState, grid, nextMove]); };

Now that we have a way to decide the winner, we can take that information and display a corresponding result message. We'll pair this with a New game button that serves the dual purpose of resetting the grid state and reverting the game to its not started phase.

tsx
//TicTacToe.tsx const startNewGame = () => { setGameState(GAME_STATES.notStarted); setGrid(emptyGrid); }; switch (gameState) { case GAME_STATES.notStarted: default: return ( <div> <Inner> <p>Choose your player</p> <ButtonRow> <button onClick={() => choosePlayer(PLAYER_X)}>X</button> <p>or</p> <button onClick={() => choosePlayer(PLAYER_O)}>O</button> </ButtonRow> </Inner> </div> ); case GAME_STATES.inProgress: return ( <Container dims={DIMENSIONS}> {grid.map((value, index) => { const isActive = value !== null; return ( <Square key={index} onClick={() => humanMove(index)}> {isActive && <Marker>{value === PLAYER_X ? "X" : "O"}</Marker>} </Square> ); })} </Container> ); case GAME_STATES.over: return ( <div> <p>{winner}</p> <button onClick={startNewGame}>Start over</button> </div> ); }
tsx
//TicTacToe.tsx const startNewGame = () => { setGameState(GAME_STATES.notStarted); setGrid(emptyGrid); }; switch (gameState) { case GAME_STATES.notStarted: default: return ( <div> <Inner> <p>Choose your player</p> <ButtonRow> <button onClick={() => choosePlayer(PLAYER_X)}>X</button> <p>or</p> <button onClick={() => choosePlayer(PLAYER_O)}>O</button> </ButtonRow> </Inner> </div> ); case GAME_STATES.inProgress: return ( <Container dims={DIMENSIONS}> {grid.map((value, index) => { const isActive = value !== null; return ( <Square key={index} onClick={() => humanMove(index)}> {isActive && <Marker>{value === PLAYER_X ? "X" : "O"}</Marker>} </Square> ); })} </Container> ); case GAME_STATES.over: return ( <div> <p>{winner}</p> <button onClick={startNewGame}>Start over</button> </div> ); }

Introducing Minimax

With these updates, our Tic-Tac-Toe game is now a complete experience. However, there's one aspect still missing: the AI's moves are currently random, which makes it quite easy to outmaneuver. To raise the stakes, we can shift the balance by introducing the Minimax algorithm. This addition will compute optimal moves for the AI, rendering the game virtually unbeatable. Even the most skilled human player can only hope for a draw.

What is the Minimax algorithm and how does it work?

The Minimax algorithm is a decision-making algorithm commonly used in game theory and AI. It works by analyzing a game's decision tree to identify the best move to make in a given situation. The algorithm does this by considering all possible moves that can be made by both players and assigns a score to each outcome based on how advantageous or disadvantageous it is to the player. The algorithm then alternates between maximizing and minimizing the score at each level of the decision tree, to identify the move that maximizes the player's chances of winning while minimizing the opponent's chances. Considering that the number of possible moves in Tic-Tac-Toe is quite small, compared to other games, like chess, the algorithm will not take much time to calculate the best moves.

The TypeScript implementation of the Minimax algorithm for our Tic-Tac-Toe game will look like this:

typescript
// constants.ts export const SCORES: Record<string, number> = { 1: 1, 0: 0, 2: -1, }; // minimax.ts import { SCORES } from "./constants"; import { switchPlayer } from "./utils"; export const minimax = ( board: Board, player: number, ): [number, number | null] => { // initialize the multiplier to adjust scores based on the player's perspective const multiplier = SCORES[String(player)]; let thisScore; let maxScore = -1; let bestMove = null; // check if the game is over and return the score and move if so const winner = board.getWinner(); if (winner !== null) { return [SCORES[winner], 0]; } else { // loop through each empty square on the board for (const square of board.getEmptySquares()) { // create a copy of the board and make a move for the current player let copy: Board = board.clone(); copy.makeMove(square, player); // recursively call minimax on the resulting board state, // switching the player and multiplying the resulting score by the multiplier thisScore = multiplier * minimax(copy, switchPlayer(player))[0]; // update the maxScore and bestMove variables if the current move // produces a higher score than previous moves if (thisScore >= maxScore) { maxScore = thisScore; bestMove = square; } } // return the best score found, multiplied by the multiplier, // and the corresponding best move as a tuple return [multiplier * maxScore, bestMove]; } };
typescript
// constants.ts export const SCORES: Record<string, number> = { 1: 1, 0: 0, 2: -1, }; // minimax.ts import { SCORES } from "./constants"; import { switchPlayer } from "./utils"; export const minimax = ( board: Board, player: number, ): [number, number | null] => { // initialize the multiplier to adjust scores based on the player's perspective const multiplier = SCORES[String(player)]; let thisScore; let maxScore = -1; let bestMove = null; // check if the game is over and return the score and move if so const winner = board.getWinner(); if (winner !== null) { return [SCORES[winner], 0]; } else { // loop through each empty square on the board for (const square of board.getEmptySquares()) { // create a copy of the board and make a move for the current player let copy: Board = board.clone(); copy.makeMove(square, player); // recursively call minimax on the resulting board state, // switching the player and multiplying the resulting score by the multiplier thisScore = multiplier * minimax(copy, switchPlayer(player))[0]; // update the maxScore and bestMove variables if the current move // produces a higher score than previous moves if (thisScore >= maxScore) { maxScore = thisScore; bestMove = square; } } // return the best score found, multiplied by the multiplier, // and the corresponding best move as a tuple return [multiplier * maxScore, bestMove]; } };

For the Minimax algorithm to work, we need to add the makeMove method to our board class, which will put the current player on the board.

typescript
// Board.ts makeMove = (square: number, player: number) => { if (this.grid[square] === null) { this.grid[square] = player; } };
typescript
// Board.ts makeMove = (square: number, player: number) => { if (this.grid[square] === null) { this.grid[square] = player; } };

The reason why we're not just using the move function from the TicTacToe component is that triggering it inside the loop of Minimax will change the component's state and cause numerous re-renders, which will quickly result in the stack overflow.

To help you learn about other mistakes to avoid in React and improve your React skills I wrote a post about the most common mistakes React developers make.

Now we can introduce the algorithm into our Tic-Tac-Toe and enable the AI to make Minimax-powered moves.

typescript
// TicTacToe.ts import { minimax } from "./minimax"; //... const aiMove = useCallback(() => { const board = new Board(grid.concat()); const index = board.isEmpty(grid) ? getRandomInt(0, 8) : minimax(board, players.ai!)[1]; if (index !== null && !grid[index]) { move(index, players.ai); setNextMove(players.human); } }, [move, grid, players]);
typescript
// TicTacToe.ts import { minimax } from "./minimax"; //... const aiMove = useCallback(() => { const board = new Board(grid.concat()); const index = board.isEmpty(grid) ? getRandomInt(0, 8) : minimax(board, players.ai!)[1]; if (index !== null && !grid[index]) { move(index, players.ai); setNextMove(players.human); } }, [move, grid, players]);

It's important to note that we should explicitly check that index is not null in the condition. If we do this instead - if (index && !grid[index]) {, the condition will also be false for zero values, so the move won't be made for an index of 0.

Another important thing to keep in mind is to pass a copy of the grid to the Board constructor so that the minimax function doesn't change the actual grid used in the TicTacToe component. That's why we use concat, which when called on an array without arguments will return a copy of that array. The same effect can be achieved with grid.slice() or by using the JS array spread syntax: [...grid].

If the board is empty when it is the AI's turn, meaning that the AI is making the first move, we're going to make a random move for the AI to greatly speed up Minimax calculation.

Adding difficulty levels

At this point the base game is pretty much ready, however, we can still improve it a bit. While the random AI version was too easy and the Minimax version was too hard, we can combine them to create a "medium" level. In this level, roughly half of the moves will be random, and the other half will use the Minimax algorithm. We will also add the already implemented "easy" and "difficult" levels. To make these changes, we will introduce a mode to the component state. This will allow the player to select their desired game mode at the beginning of each game. Finally, we will modify the aiMove function to accommodate the player's game mode selection.

tsx
// constants.ts // ... export const GAME_MODES: Record<string, string> = { easy: "easy", medium: "medium", difficult: "difficult", }; // TicTacToe.tsx import { GAME_MODES /* ... */ } from "./constants"; const TicTacToe = () => { // ... const [mode, setMode] = useState(GAME_MODES.medium); // ... /** * Make the AI move. If it's the first move (the board is empty), * make the move at any random cell to skip unnecessary Minimax calculations */ const aiMove = useCallback(() => { // Important to pass a copy of the grid here const board = new Board(grid.concat()); const emptyIndices = board.getEmptySquares(grid); let index; switch (mode) { case GAME_MODES.easy: do { index = getRandomInt(0, 8); } while (!emptyIndices.includes(index)); break; // Medium level is approx. half of the moves are Minimax and the other half random case GAME_MODES.medium: const smartMove = !board.isEmpty(grid) && Math.random() < 0.5; if (smartMove) { index = minimax(board, players.ai!)[1]; } else { do { index = getRandomInt(0, 8); } while (!emptyIndices.includes(index)); } break; case GAME_MODES.difficult: default: index = board.isEmpty(grid) ? getRandomInt(0, 8) : minimax(board, players.ai!)[1]; } if (index && !grid[index]) { if (players.ai !== null) { move(index, players.ai); } setNextMove(players.human); } }, [move, grid, players, mode]); const changeMode = (e: React.ChangeEvent<HTMLSelectElement>) => { setMode(e.target.value); }; switch (gameState) { case GAME_STATES.notStarted: default: return ( <div> <Inner> <p>Select difficulty</p> <select onChange={changeMode} value={mode}> {Object.keys(GAME_MODES).map((key) => { const gameMode = GAME_MODES[key]; return ( <option key={gameMode} value={gameMode}> {key} </option> ); })} </select> </Inner> <Inner> <p>Choose your player</p> <ButtonRow> <button onClick={() => choosePlayer(PLAYER_X)}>X</button> <p>or</p> <button onClick={() => choosePlayer(PLAYER_O)}>O</button> </ButtonRow> </Inner> </div> ); case GAME_STATES.inProgress: // ... } };
tsx
// constants.ts // ... export const GAME_MODES: Record<string, string> = { easy: "easy", medium: "medium", difficult: "difficult", }; // TicTacToe.tsx import { GAME_MODES /* ... */ } from "./constants"; const TicTacToe = () => { // ... const [mode, setMode] = useState(GAME_MODES.medium); // ... /** * Make the AI move. If it's the first move (the board is empty), * make the move at any random cell to skip unnecessary Minimax calculations */ const aiMove = useCallback(() => { // Important to pass a copy of the grid here const board = new Board(grid.concat()); const emptyIndices = board.getEmptySquares(grid); let index; switch (mode) { case GAME_MODES.easy: do { index = getRandomInt(0, 8); } while (!emptyIndices.includes(index)); break; // Medium level is approx. half of the moves are Minimax and the other half random case GAME_MODES.medium: const smartMove = !board.isEmpty(grid) && Math.random() < 0.5; if (smartMove) { index = minimax(board, players.ai!)[1]; } else { do { index = getRandomInt(0, 8); } while (!emptyIndices.includes(index)); } break; case GAME_MODES.difficult: default: index = board.isEmpty(grid) ? getRandomInt(0, 8) : minimax(board, players.ai!)[1]; } if (index && !grid[index]) { if (players.ai !== null) { move(index, players.ai); } setNextMove(players.human); } }, [move, grid, players, mode]); const changeMode = (e: React.ChangeEvent<HTMLSelectElement>) => { setMode(e.target.value); }; switch (gameState) { case GAME_STATES.notStarted: default: return ( <div> <Inner> <p>Select difficulty</p> <select onChange={changeMode} value={mode}> {Object.keys(GAME_MODES).map((key) => { const gameMode = GAME_MODES[key]; return ( <option key={gameMode} value={gameMode}> {key} </option> ); })} </select> </Inner> <Inner> <p>Choose your player</p> <ButtonRow> <button onClick={() => choosePlayer(PLAYER_X)}>X</button> <p>or</p> <button onClick={() => choosePlayer(PLAYER_O)}>O</button> </ButtonRow> </Inner> </div> ); case GAME_STATES.inProgress: // ... } };

With these changes, we can see the updated start screen, with medium game difficulty selected as default.

Tic-Tac-Toe select player screen

Wrapping up

In this optional section, we will add a few finishing touches to enhance the ultimate Tic-Tac-Toe experience. Specifically, we will show the game result modal, tweak the grid's border styling, and add strike-through styling for the winning combination. Let's begin with the easiest task, which is to show the game result modal.

tsx
// ResultModal.tsx import React from "react"; import styled from "styled-components"; import Modal from "react-modal"; import { border } from "./styles"; const customStyles = { overlay: { backgroundColor: "rgba(0,0,0, 0.6)", }, }; interface Props { isOpen: boolean; close: () => void; startNewGame: () => void; winner: null | string; } export const ResultModal = ({ isOpen, close, startNewGame, winner }: Props) => { return ( <StyledModal isOpen={isOpen} onRequestClose={close} style={customStyles} ariaHideApp={false} > <ModalWrapper> <ModalTitle>Game over</ModalTitle> <ModalContent>{winner}</ModalContent> <ModalFooter> <Button onClick={close}>Close</Button> <Button onClick={startNewGame}>Start over</Button> </ModalFooter> </ModalWrapper> </StyledModal> ); }; const StyledModal = styled(Modal)` height: 300px; position: relative; margin: 0 auto; top: 10%; right: auto; bottom: auto; width: 320px; outline: none; display: flex; flex-direction: column; `; const ModalWrapper = styled.div` display: flex; flex-direction: column; background-color: #fff; max-height: 100%; height: 100%; align-items: center; backface-visibility: hidden; padding: 1.25rem; ${border}; `; const ModalTitle = styled.p` display: flex; align-items: center; margin-bottom: 20px; font-size: 24px; font-weight: bold; text-transform: uppercase; `; const ModalContent = styled.p` flex: 1 1 auto; text-align: center; `; ModalContent.displayName = "ModalContent"; const ModalFooter = styled.div` display: flex; justify-content: space-between; flex: 0 0 auto; width: 100%; `; const Button = styled.button` font-size: 16px; `;
tsx
// ResultModal.tsx import React from "react"; import styled from "styled-components"; import Modal from "react-modal"; import { border } from "./styles"; const customStyles = { overlay: { backgroundColor: "rgba(0,0,0, 0.6)", }, }; interface Props { isOpen: boolean; close: () => void; startNewGame: () => void; winner: null | string; } export const ResultModal = ({ isOpen, close, startNewGame, winner }: Props) => { return ( <StyledModal isOpen={isOpen} onRequestClose={close} style={customStyles} ariaHideApp={false} > <ModalWrapper> <ModalTitle>Game over</ModalTitle> <ModalContent>{winner}</ModalContent> <ModalFooter> <Button onClick={close}>Close</Button> <Button onClick={startNewGame}>Start over</Button> </ModalFooter> </ModalWrapper> </StyledModal> ); }; const StyledModal = styled(Modal)` height: 300px; position: relative; margin: 0 auto; top: 10%; right: auto; bottom: auto; width: 320px; outline: none; display: flex; flex-direction: column; `; const ModalWrapper = styled.div` display: flex; flex-direction: column; background-color: #fff; max-height: 100%; height: 100%; align-items: center; backface-visibility: hidden; padding: 1.25rem; ${border}; `; const ModalTitle = styled.p` display: flex; align-items: center; margin-bottom: 20px; font-size: 24px; font-weight: bold; text-transform: uppercase; `; const ModalContent = styled.p` flex: 1 1 auto; text-align: center; `; ModalContent.displayName = "ModalContent"; const ModalFooter = styled.div` display: flex; justify-content: space-between; flex: 0 0 auto; width: 100%; `; const Button = styled.button` font-size: 16px; `;

To customize the modal overlay's styling, we use the customStyles object, as specified in the package documentation. The other elements of the modal are styled using styled-components.

With the styling out of the way, let's import the modal in our main component and show it when the game is over.

tsx
// TicTacToe.tsx import { ResultModal } from "./ResultModal"; const TicTacToe = () => { // ... const [modalOpen, setModalOpen] = useState(false); // ... useEffect(() => { const boardWinner = board.getWinner(grid); const declareWinner = (winner: number) => { let winnerStr; switch (winner) { case PLAYER_X: winnerStr = "Player X wins!"; break; case PLAYER_O: winnerStr = "Player O wins!"; break; case DRAW: default: winnerStr = "It's a draw"; } setGameState(GAME_STATES.over); setWinner(winnerStr); // Slight delay for the modal so there is some time to see the last move setTimeout(() => setModalOpen(true), 300); }; if (boardWinner !== null && gameState !== GAME_STATES.over) { declareWinner(boardWinner); } }, [gameState, grid, nextMove]); const startNewGame = () => { setGameState(GAME_STATES.notStarted); setGrid(arr); setModalOpen(false); // Close the modal when the new game starts }; return gameState === GAME_STATES.notStarted ? ( <div> <Inner> <p>Select difficulty</p> <select onChange={changeMode} value={mode}> {Object.keys(GAME_MODES).map((key) => { const gameMode = GAME_MODES[key]; return ( <option key={gameMode} value={gameMode}> {key} </option> ); })} </select> </Inner> <Inner> <p>Choose your player</p> <ButtonRow> <button onClick={() => choosePlayer(PLAYER_X)}>X</button> <p>or</p> <button onClick={() => choosePlayer(PLAYER_O)}>O</button> </ButtonRow> </Inner> </div> ) : ( <Container dims={DIMENSIONS}> {grid.map((value, index) => { const isActive = value !== null; return ( <Square key={index} onClick={() => humanMove(index)}> {isActive && <Marker>{value === PLAYER_X ? "X" : "O"}</Marker>} </Square> ); })} <ResultModal isOpen={modalOpen} winner={winner} close={() => setModalOpen(false)} startNewGame={startNewGame} /> </Container> ); };
tsx
// TicTacToe.tsx import { ResultModal } from "./ResultModal"; const TicTacToe = () => { // ... const [modalOpen, setModalOpen] = useState(false); // ... useEffect(() => { const boardWinner = board.getWinner(grid); const declareWinner = (winner: number) => { let winnerStr; switch (winner) { case PLAYER_X: winnerStr = "Player X wins!"; break; case PLAYER_O: winnerStr = "Player O wins!"; break; case DRAW: default: winnerStr = "It's a draw"; } setGameState(GAME_STATES.over); setWinner(winnerStr); // Slight delay for the modal so there is some time to see the last move setTimeout(() => setModalOpen(true), 300); }; if (boardWinner !== null && gameState !== GAME_STATES.over) { declareWinner(boardWinner); } }, [gameState, grid, nextMove]); const startNewGame = () => { setGameState(GAME_STATES.notStarted); setGrid(arr); setModalOpen(false); // Close the modal when the new game starts }; return gameState === GAME_STATES.notStarted ? ( <div> <Inner> <p>Select difficulty</p> <select onChange={changeMode} value={mode}> {Object.keys(GAME_MODES).map((key) => { const gameMode = GAME_MODES[key]; return ( <option key={gameMode} value={gameMode}> {key} </option> ); })} </select> </Inner> <Inner> <p>Choose your player</p> <ButtonRow> <button onClick={() => choosePlayer(PLAYER_X)}>X</button> <p>or</p> <button onClick={() => choosePlayer(PLAYER_O)}>O</button> </ButtonRow> </Inner> </div> ) : ( <Container dims={DIMENSIONS}> {grid.map((value, index) => { const isActive = value !== null; return ( <Square key={index} onClick={() => humanMove(index)}> {isActive && <Marker>{value === PLAYER_X ? "X" : "O"}</Marker>} </Square> ); })} <ResultModal isOpen={modalOpen} winner={winner} close={() => setModalOpen(false)} startNewGame={startNewGame} /> </Container> ); };

The modal is now available. From here, the player can start a new game or close the modal to see the final board again. Note that in the latter case, the page will have to be reloaded to start a new game.

Tic-Tac-Toe modal

Looking at the buttons, you'll notice that they have irregularly shaped borders, which go nicely with the overall styling of the app. Wouldn't it be nice if our grid squares and the result modal had similarly shaped borders? With a bit of experimentation and tweaking, we can come up with a satisfactory styling that will be added to a separate styles.ts file.

typescript
// styles.ts import { css } from "styled-components"; export const border = css` border-radius: 255px 15px 225px 15px / 15px 255px 15px; border: 2px solid #41403e; `;
typescript
// styles.ts import { css } from "styled-components"; export const border = css` border-radius: 255px 15px 225px 15px / 15px 255px 15px; border: 2px solid #41403e; `;

We're declaring the CSS styles as a template string, which we can reuse in various components.

tsx
// TicTacToe.tsx import { border } from "./styles"; // ... const Square = styled.div` display: flex; justify-content: center; align-items: center; width: ${SQUARE_DIMS}px; height: ${SQUARE_DIMS}px; ${border}; // Adding new border styles &:hover { cursor: pointer; } `; // ResultModal.tsx import { border } from "./styles"; // ... const ModalWrapper = styled.div` display: flex; flex-direction: column; padding: 24px; background-color: #fff; max-height: 100%; height: 100%; align-items: center; backface-visibility: hidden; padding: 1.25rem; ${border}; // Adding new border styles `;
tsx
// TicTacToe.tsx import { border } from "./styles"; // ... const Square = styled.div` display: flex; justify-content: center; align-items: center; width: ${SQUARE_DIMS}px; height: ${SQUARE_DIMS}px; ${border}; // Adding new border styles &:hover { cursor: pointer; } `; // ResultModal.tsx import { border } from "./styles"; // ... const ModalWrapper = styled.div` display: flex; flex-direction: column; padding: 24px; background-color: #fff; max-height: 100%; height: 100%; align-items: center; backface-visibility: hidden; padding: 1.25rem; ${border}; // Adding new border styles `;

Note that the syntax for adding reusable styles to a styled component is a variable interpolation inside a template. After these changes, the grid looks more consistent with the overall styling.

Tic-Tact-Toe styled grid

As a final touch, we're going to add a strike-through style to highlight the winning square sequence. To achieve this, the Board class will return the strike-through styling according to the winning combination (unless the game was a draw).

typescript
// Board.ts export default class Board { constructor(grid) { // ... this.winningIndex = null; // track the index of the winning combination } getWinner = (grid = this.grid) => { //... winningCombos.forEach((el, i) => { if ( grid[el[0]] !== null && grid[el[0]] === grid[el[1]] && grid[el[0]] === grid[el[2]] ) { res = grid[el[0]]; this.winningIndex = i; } else if (res === null && this.getEmptySquares(grid).length === 0) { res = DRAW; this.winningIndex = null; } }); return res; }; /** * Get the styles for strike-through based on the combination that won */ getStrikethroughStyles = () => { const defaultWidth = 285; const diagonalWidth = 400; switch (this.winningIndex) { case 0: return ` transform: none; top: 41px; left: 15px; width: ${defaultWidth}px; `; case 1: return ` transform: none; top: 140px; left: 15px; width: ${defaultWidth}px; `; case 2: return ` transform: none; top: 242px; left: 15px; width: ${defaultWidth}px; `; case 3: return ` transform: rotate(90deg); top: 145px; left: -86px; width: ${defaultWidth}px; `; case 4: return ` transform: rotate(90deg); top: 145px; left: 15px; width: ${defaultWidth}px; `; case 5: return ` transform: rotate(90deg); top: 145px; left: 115px; width: ${defaultWidth}px; `; case 6: return ` transform: rotate(45deg); top: 145px; left: -44px; width: ${diagonalWidth}px; `; case 7: return ` transform: rotate(-45deg); top: 145px; left: -46px; width: ${diagonalWidth}px; `; default: return null; } }; }
typescript
// Board.ts export default class Board { constructor(grid) { // ... this.winningIndex = null; // track the index of the winning combination } getWinner = (grid = this.grid) => { //... winningCombos.forEach((el, i) => { if ( grid[el[0]] !== null && grid[el[0]] === grid[el[1]] && grid[el[0]] === grid[el[2]] ) { res = grid[el[0]]; this.winningIndex = i; } else if (res === null && this.getEmptySquares(grid).length === 0) { res = DRAW; this.winningIndex = null; } }); return res; }; /** * Get the styles for strike-through based on the combination that won */ getStrikethroughStyles = () => { const defaultWidth = 285; const diagonalWidth = 400; switch (this.winningIndex) { case 0: return ` transform: none; top: 41px; left: 15px; width: ${defaultWidth}px; `; case 1: return ` transform: none; top: 140px; left: 15px; width: ${defaultWidth}px; `; case 2: return ` transform: none; top: 242px; left: 15px; width: ${defaultWidth}px; `; case 3: return ` transform: rotate(90deg); top: 145px; left: -86px; width: ${defaultWidth}px; `; case 4: return ` transform: rotate(90deg); top: 145px; left: 15px; width: ${defaultWidth}px; `; case 5: return ` transform: rotate(90deg); top: 145px; left: 115px; width: ${defaultWidth}px; `; case 6: return ` transform: rotate(45deg); top: 145px; left: -44px; width: ${diagonalWidth}px; `; case 7: return ` transform: rotate(-45deg); top: 145px; left: -46px; width: ${diagonalWidth}px; `; default: return null; } }; }

Let's add a Strikethrough element to our main component and see if the styles work.

tsx
// TicTactToe.tsx // ... return gameState === GAME_STATES.notStarted ? ( // ... <Container dims={DIMENSIONS}> {grid.map((value, index) => { const isActive = value !== null; return ( <Square data-testid={`square_${index}`} key={index} onClick={() => humanMove(index)} > {isActive && <Marker>{value === PLAYER_X ? "X" : "O"}</Marker>} </Square> ); })} <Strikethrough styles={ gameState === GAME_STATES.over ? board.getStrikethroughStyles() : "" } /> <ResultModal isOpen={modalOpen} winner={winner} close={() => setModalOpen(false)} startNewGame={startNewGame} /> </Container> // ... const Strikethrough = styled.div<{ styles: string | null }>` position: absolute; ${({ styles }) => styles} background-color: indianred; height: 5px; width: ${({ styles }) => !styles && "0px"}; `;
tsx
// TicTactToe.tsx // ... return gameState === GAME_STATES.notStarted ? ( // ... <Container dims={DIMENSIONS}> {grid.map((value, index) => { const isActive = value !== null; return ( <Square data-testid={`square_${index}`} key={index} onClick={() => humanMove(index)} > {isActive && <Marker>{value === PLAYER_X ? "X" : "O"}</Marker>} </Square> ); })} <Strikethrough styles={ gameState === GAME_STATES.over ? board.getStrikethroughStyles() : "" } /> <ResultModal isOpen={modalOpen} winner={winner} close={() => setModalOpen(false)} startNewGame={startNewGame} /> </Container> // ... const Strikethrough = styled.div<{ styles: string | null }>` position: absolute; ${({ styles }) => styles} background-color: indianred; height: 5px; width: ${({ styles }) => !styles && "0px"}; `;

If board.getStrikethroughStyles() returns styles, indicating that the game has a winner, we apply them to our element. Otherwise, the element is hidden by setting its width to 0px.

Tic-Tac_Toe Strike-Through Screen

Now we see a nice strike-through whenever the game has a winner.

Conclusion

And that is a wrap! Building a Tic-Tac-Toe game with TypeScript and React Hooks is a great way to learn and practice web development skills. If you made it this far, you should now have a basic understanding of how to use TypeScript and React to create a simple game application and make it smart by utilising the power of the Minimax algorithm. With this foundation, you can continue to build on your knowledge and create more complex applications.

By using TypeScript, we added static type-checking to the code, making it easier to identify and fix errors. React with its hooks provided a powerful and flexible way to manage state and logic in the application, allowing us to create more responsive and dynamic user interfaces.

Overall, building a Tic-Tac-Toe game with TypeScript, React, React Hooks and Minimax is a fun and rewarding way to improve web development skills and create a simple yet engaging application. Hopefully, this article has helped get to know what's possible to do with React, and you're encouraged to tweak and customise the game to your liking!

References and resources