Build a Tic-Tac-Toe Game with TypeScript, React and Minimax
Updated on · 12 min read|
React is widely used for developing websites and mobile applications. However, it can be also 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 get a nice minimalistic look, we'll use PaperCSS, and React-modal for displaying the game results. Let's start by creating an empty project and installing the necessary dependencies.
bashnpx 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
bashnpx 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 begin by modifying App.tsx to include the main game components and 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. Not relevant elements, like footer
, are omitted, so we can focus on the most important parts. The next step is to create the TicTacToe component. Before that, we add some of the game's constants to a separate constants.ts.
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 have the seed dimensions for the grid, the width (in pixels) of the grid cell, and constants for the draw state of the game and both players. Now we create TicTacToe.tsx and start setting up and rendering the game's grid.
tsximport 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; `;
tsximport 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; `;
Firstly, we import all the necessary dependencies and declare the default array for the grid. We use exponentiation operator, added in ES2016, and Array.prototype.fill()
from ES2015/ES6, to create an array of length 9 and fill it with null
values. It's declared outside the component, so it doesn't get re-created when the component re-renders. Also instead of creating a multidimensional array and then recursively rendering it, we are going to render a one-dimensional array and limit its width with CSS.
width: ${({ dims }) => `${dims * (SQUARE_DIMS + 5)}px`};
is styled components' way to pass a variable to a component, which can also be written as width: ${(props) => `${props.dims * (SQUARE_DIMS + 5)}px`};
Here we limit the container's width by 3 squares of 100 pixels (plus a few px to account for borders) and set flex-flow: wrap
, which will push the extra squares to the next line and so on, in the end creating a 3 x 3 squares grid. After running npm start
and making a few moves, we can validate that our grid functions properly.

Looks good, however, it's not too exciting since we haven't set up the AI's moves. We will fix it by adding 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; };
Now the game is more interactive. After the human player's turn, the aiMove
function is called, which makes a move to a random empty square on the board. Note that we have also added a utils.ts file to the project, where all the helpers, like the one used to get a random number in a range, will be stored.
Clearly, 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. When the game is in the first state, we will show a select player screen, the second state will render the board and allow players to make moves, and the final state will declare the game's outcome.
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 use them in our component to render different screens for each state 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; `;
Since the players are null
by default now, we need to explicitly specify their types for the useState
hook - useState<Record<string, number | null>>
will take care of that. Also, 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 above changes allow choosing a player. However, since we don't check whose move it currently is, the human player can make several moves out of turn. To fix it we will introduce turn-based moves, assigning the player whose turn is next to nextMove.
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.
tsxconst aiMove = useCallback(() => { let index = getRandomInt(0, 8); while (grid[index]) { index = getRandomInt(0, 8); } move(index, players.ai); setNextMove(players.human); }, [move, grid, players]);
tsxconst aiMove = useCallback(() => { let index = getRandomInt(0, 8); while (grid[index]) { index = getRandomInt(0, 8); } move(index, players.ai); setNextMove(players.human); }, [move, grid, players]);
Since we're tracking the move
function here, we'll need to memoize it as well. If you're concerned about the hooks dependencies spiraling out of control, another option would be to define the move
function outside the component, which will preserve its identity between renders. However, as it interacts with the component's state, those state setters will need to be passed to it as arguments.
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] );
The players can make their moves now and the flow of the game already seems quite natural. However, if you run the game to the end, i.e. fill all the available squares, it will be stuck in the 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
If you look at the code closely, you'll 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 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. First, we add a method to retrieve the indices of all empty squares on the board. Next, we include a utility method to check if the board is empty. We also add the ability to make a copy of the board. Finally, we implement the getWinner
method, which checks the current state of the board for any of the winning combinations that are hardcoded into the method, and returns the game's result accordingly. In addition to initializing the board with an empty grid, we allow the class's methods to accept an optional grid
parameter, so that we can apply them 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 we can render the result message together with a New game button, which will reset the grid state and set the game to not started.
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 changes, we now have a proper Tic-Tac-Toe game. One thing is still missing though: the AI moves at random, which makes it quite easy to beat. We can tip the situation to the other extreme by introducing the Minimax algorithm for calculating the best moves for AI. Properly implemented this will make the game unbeatable, the best human player can count on is 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.

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.

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.

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
.

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 been helpful in getting 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
- A Complete Guide to useEffect
- Code for the tutorial
- CodeSandbox with the code for the tutorial
- Create React App
- Deployed version of the Tic-Tact-Toe game on GitHub pages
- MDN Array#concat
- MDN Array#fill
- MDN Array#slice
- MDN Array spread syntax
- MDN Exponential Operator
- Minimax on Wikipedia
- PaperCSS
- React Hooks Intro
- React Modal
- Styled Components
- The Most Common Mistakes When Using React
P.S.: Are you looking for a reliable and user-friendly hosting solution for your website or blog? Cloudways is a managed cloud platform that offers hassle-free and high-performance hosting experience. With 24/7 support and a range of features, Cloudways is a great choice for any hosting needs.
