Introduction
Do you love Tic-Tac-Toe? Want to show off your React skills and swindle fools who think they can beat your AI? If you answered yes to any of these questions, you've come to the right place! Today we are building an unbeatable tic-tac-toe game.
Check out the finished demo below!
Prerequisites
- Basic CSS, HTML, and JavaScript knowledge
- Knowledge of React and hooks.
Dependencies
- React - JavaScript framework for building the UI.
- Tailwind CSS - A utility-first css library for styling components.
- Open Sans - UI font
Building the UI
Below is the boilerplate for the board and UI:
import React from "react";
import "./styles.css";
export default function App() {
const Square = (props) => {
return (
<div
className="shadow-md h-24 w-24 rounded-lg bg-white text-7xl text-center cursor-default font-light flex items center justify-center x-player"
>
X
</div>
);
};
return (
<>
<div className="text-center py-2 shadow-sm text-gray-400 z-50 sticky">
Your Turn
</div>
<section className="game-board py-10">
<div className="max-w-md mx-auto">
<div className="max-w-lg flex flex-col gap-5 mx-auto">
<div className="flex gap-5 mx-auto">
<Square squareIndex={0} />
<Square squareIndex={1} />
<Square squareIndex={2} />
</div>
<div className="flex gap-5 mx-auto">
<Square squareIndex={3} />
<Square squareIndex={4} />
<Square squareIndex={5} />
</div>
<div className="flex gap-5 mx-auto">
<Square squareIndex={6} />
<Square squareIndex={7} />
<Square squareIndex={8} />
</div>
</div>
<div className="text-center">
<button className="bg-blue-500 text-white w-full py-2 font-semibold mt-10 rounded-md shadow-lg">
Reset
</button>
</div>
</div>
</section>
</>
);
}
html,
body {
font-family: "Open Sans", sans-serif;
height: 100%;
background-color: #f9fafb;
}
.game-board {
font-family: "Open Sans", sans-serif;
}
.shadow-md {
box-shadow: rgba(7, 65, 210, 0.1) 0px 9px 30px !important;
}
.o-player {
background: #cb6893;
background: -webkit-linear-gradient(to right, #cb6893 0%, #f6d9d7 100%);
background: -moz-linear-gradient(to right, #cb6893 0%, #f6d9d7 100%);
background: linear-gradient(to right, #cb6893 0%, #f6d9d7 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.x-player {
background: #746dd0;
background: -webkit-linear-gradient(to right, #746dd0 0%, #c4e1eb 100%);
background: -moz-linear-gradient(to right, #746dd0 0%, #c4e1eb 100%);
background: linear-gradient(to right, #746dd0 0%, #c4e1eb 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.x-winner {
text-shadow: 0 0 10px #746dd0, 0 0 0px #746dd0, 0 0 40px #746dd0,
0 0 2px #746dd0;
}
.o-winner {
text-shadow: 0 0 10px #ff9bc6, 0 0 0px #ff9bc6, 0 0 40px #ff9bc6,
0 0 2px #ff9bc6;
}
Build Game Logic
Let's start writing game logic; a board that does nothing isn't much fun!
The game flows as follows:
- Player clicks a `Square`. If space is empty fill with X, else go to step 1.
- Check if game won or draw.
- AI fills empty space with O.
- Check if game won or draw.
- Go to step 1.
Types for representing State
Imagine having a state called gameWon
represented with a boolean
for true
or false
. Soon after, you add a game draw condition and another boolean
and logic. A week later, you're adding a gameOvertime
condition and writing more logic. See how this can become a problem?
Using primitive data types like integers
or booleans
to represent state is flaky, limited, and riddles code with if/else
statements! Using enums or objects/types is a far better alternative.
Below is the above scenario, but represented with an object:
const GAME_WON = {
YES: 'game_won_yes',
NO: 'game_won_no',
DRAW: 'game_draw',
OVERTIME: 'game_overtime',
}
As a result, we can easily add new states into the GAME_WON
type and cut down on redundant logic.
Game State
Defining a game state type and hook based on the game flow is easy.
const GAME_STATE = {
PLAYER_TURN: "player_turn",
AI_TURN: "ai_turn",
PLAYER_WON: "player_won",
AI_WON: "player_o_won",
DRAW: "game_draw",
ERROR: "game_error"
};
// Current game state
const [gameState, setGameState] = useState(GAME_STATE.PLAYER_TURN);
Game Board
The board represents an array with a length of nine that corresponds to each Square
. Each Square
can either be empty or filled by the player or AI. To easily represent the state of a Square
, we will create a type to represent who owns it. The createEmptyGrid
function returns an array filled with SPACE_STATE.EMPTY
.
export const GRID_LENGTH = 9;
export const SPACE_STATE = {
PLAYER: "player_filled",
AI: "ai_filled",
EMPTY: "empty_space"
};
const createEmptyGrid = () => {
return Array(GRID_LENGTH).fill(SPACE_STATE.EMPTY);
};
const [grid, setGrid] = useState(createEmptyGrid());
Move Count
Tracking the number of moves taken is vital for determining a draw. AI logic also depends on the move count to formulate the best strategy.
// Count of moves made
const [moveCount, setMoveCount] = useState(0);
Handling Player Clicks
In the JSX, each Square
has an index passed as a prop that corresponds to a grid index.
...
<Square squareIndex={0} />
<Square squareIndex={1} />
<Square squareIndex={2} />
...
Inside the Square
function, an onClick
handler pulls the squareIndex
from its props to call handlePlayerClick
to fill in the corresponding grid
index with SPACE_STATE.PLAYER
. After filling the player's Square
, the function fills the correct symbol with getSquareSymbol
then updates the gameState
to GAME_STATE.AI_TURN
.
Because the AI and player's symbols have different colors, we introduce the getSpaceStateClass
function to get the correct CSS class names.
// Get the correct space class names
const getSpaceStateClass = (spaceState) => {
let space = "";
if (spaceState === SPACE_STATE.AI) {
return "o-player";
}
if (spaceState === SPACE_STATE.PLAYER) {
return "x-player";
}
return "";
};
const getSquareSymbol = (spaceStatus) => {
switch (spaceStatus) {
case SPACE_STATE.PLAYER: {
return "X";
}
case SPACE_STATE.AI: {
return "O";
}
case SPACE_STATE.EMPTY: {
return "";
}
default: {
return "";
}
}
};
// Fill in a grid square with status
const fillGridSpace = (gridIndex, spaceStatus) => {
setGrid((oldGrid) => {
oldGrid[gridIndex] = spaceStatus;
return [...oldGrid];
});
};
// Fill in the grid array with the player space state.
const handlePlayerClick = (gridIndex) => {
// If not the player turn, then exit.
if (gameState !== GAME_STATE.PLAYER_TURN) {
return;
}
// If the current square is empty, then fill in space.
if (grid[gridIndex] === SPACE_STATE.EMPTY) {
// Fill grid space
fillGridSpace(gridIndex, SPACE_STATE.PLAYER);
// Update game state to AI's turn.
setGameState(GAME_STATE.AI_TURN);
// Update move count
setMoveCount((oldMoves) => {
return oldMoves + 1;
});
}
};
const Square = (props) => {
return (
<div
className="shadow-md h-24 w-24 rounded-lg bg-white text-7xl text-center cursor-default font-light flex items-center justify-center "
// Connect click listener
onClick={() => {
handlePlayerClick(props.squareIndex);
}}
>
// Get square symbol
{getSquareSymbol(grid[props.squareIndex])}
</div>
);
};
Writing the AI Logic
For the AI, the Tic-tac-toe Wikipedia details a strategy to get a perfect game meaning each game is a draw or a win.
- Win: If the player has two in a row, they can place a third to get three in a row.
- Block: If the opponent has two in a row, the player must play the third themselves to block the opponent.
- Fork: Cause a scenario where the player has two ways to win (two non-blocked lines of 2).
- Blocking an opponent's fork: If there is only one possible fork for the opponent, the player should block it. Otherwise, the player should block all forks in any way that simultaneously allows them to make two in a row. Otherwise, the player should make a two in a row to force the opponent into defending, as long as it does not result in them producing a fork. For example, if "X" has two opposite corners and "O" has the center, "O" must not play a corner move to win. (Playing a corner move in this scenario produces a fork for "X" to win.)
- Center: A player marks the center. (If it is the first move of the game, playing a corner move gives the second player more opportunities to make a mistake and may therefore be the better choice; however, it makes no difference between perfect players.)
- Opposite corner: If the opponent is in the corner, the player plays the opposite corner.
- Empty corner: The player plays in a corner square.
- Empty side: The player plays in a middle square on any of the four sides.
The calculateAITurn
function uses the strategy above to determine the best Square
to fill to achieve a perfect game.
import { SPACE_STATE } from "./App";
// Calculate the best space for the AI to fill to get a perfect game.
export const calculateAITurn = (grid, moveCount) => {
let aiSpace = aiCanWin(grid);
if (Number.isInteger(aiSpace)) {
console.log("Ai winning");
return aiSpace;
}
aiSpace = aiCanBlock(grid);
if (Number.isInteger(aiSpace)) {
console.log("Ai blocking");
return aiSpace;
}
aiSpace = aiCanBlockFork(grid, moveCount);
if (Number.isInteger(aiSpace)) {
console.log("AI forking");
return aiSpace;
}
aiSpace = aiCanCenter(grid);
if (Number.isInteger(aiSpace)) {
console.log("AI centering");
return aiSpace;
}
aiSpace = aiCanFillOppositeCorner(grid);
if (Number.isInteger(aiSpace)) {
console.log("AI filling opposite corner");
return aiSpace;
}
aiSpace = aiCanFillEmptyCorner(grid);
if (Number.isInteger(aiSpace)) {
console.log("AI filling empty corner");
return aiSpace;
}
aiSpace = aiCanFillEmptySide(grid);
if (Number.isInteger(aiSpace)) {
console.log("AI filling empty side");
return aiSpace;
}
// console.log("AI can't move");
return null;
};
// Convert row, col to grid index.
const convertCordToIndex = (row, col) => {
return row * 3 + col;
};
/**
* Check if AI can win
* @returns Space for AI to win
*/
const aiCanWin = (grid) => {
let count = 0;
let row, col;
// Check Rows
for (let i = 0; i < 3; ++i) {
count = 0;
for (let j = 0; j < 3; ++j) {
if (grid[convertCordToIndex(i, j)] === SPACE_STATE.AI) {
count++;
} else if (grid[convertCordToIndex(i, j)] === SPACE_STATE.PLAYER) {
count--;
} else if (grid[convertCordToIndex(i, j)] === SPACE_STATE.EMPTY) {
row = i;
col = j;
}
}
// Has two consecutive spaces, return third to win.
if (count === 2) {
return convertCordToIndex(row, col);
}
}
// Check Cols
for (let i = 0; i < 3; ++i) {
count = 0;
for (let j = 0; j < 3; ++j) {
if (grid[convertCordToIndex(j, i)] === SPACE_STATE.AI) {
count++;
} else if (grid[convertCordToIndex(j, i)] === SPACE_STATE.PLAYER) {
count--;
} else if (grid[convertCordToIndex(j, i)] === SPACE_STATE.EMPTY) {
row = j;
col = i;
}
}
// Has two consecutive spaces, return third to win.
if (count === 2) {
return convertCordToIndex(row, col);
}
}
count = 0;
// Check Diag
for (let i = 0; i < 3; ++i) {
if (grid[convertCordToIndex(i, i)] === SPACE_STATE.AI) {
count++;
} else if (grid[convertCordToIndex(i, i)] === SPACE_STATE.PLAYER) {
count--;
} else if (grid[convertCordToIndex(i, i)] === SPACE_STATE.EMPTY) {
row = i;
col = i;
}
}
// Has two consecutive spaces, return third to win.
if (count === 2) {
return convertCordToIndex(row, col);
}
count = 0;
// Check Anti-Diag
for (var i = 0; i < 3; ++i) {
if (grid[convertCordToIndex(i, 3 - 1 - i)] === SPACE_STATE.AI) {
count++;
} else if (grid[convertCordToIndex(i, 3 - 1 - i)] === SPACE_STATE.PLAYER) {
count--;
} else if (grid[convertCordToIndex(i, 3 - 1 - i)] === SPACE_STATE.EMPTY) {
row = i;
col = 3 - 1 - i;
}
}
// Has two consecutive spaces, return third to win.
if (count === 2) {
return convertCordToIndex(row, col);
}
return null;
};
/**
* Ai checks if it can block opponents win
* @returns Can ai block opponent
*/
function aiCanBlock(grid) {
var count = 0;
var row, col;
// Check Rows
for (let i = 0; i < 3; ++i) {
count = 0;
for (let j = 0; j < 3; ++j) {
if (grid[convertCordToIndex(i, j)] === SPACE_STATE.PLAYER) {
count++;
} else if (grid[convertCordToIndex(i, j)] === SPACE_STATE.AI) {
count--;
} else if (grid[convertCordToIndex(i, j)] === SPACE_STATE.EMPTY) {
row = i;
col = j;
}
}
// Opponent two consecutive spaces, return third to block.
if (count === 2) {
return convertCordToIndex(row, col);
}
}
// Check Cols
for (let i = 0; i < 3; ++i) {
count = 0;
for (let j = 0; j < 3; ++j) {
if (grid[convertCordToIndex(j, i)] === SPACE_STATE.PLAYER) {
count++;
} else if (grid[convertCordToIndex(j, i)] === SPACE_STATE.AI) {
count--;
} else if (grid[convertCordToIndex(j, i)] === SPACE_STATE.EMPTY) {
row = j;
col = i;
}
}
// Opponent two consecutive spaces, return third to block.
if (count === 2) {
return convertCordToIndex(row, col);
}
}
count = 0;
// Check Diag
for (let i = 0; i < 3; ++i) {
if (grid[convertCordToIndex(i, i)] === SPACE_STATE.PLAYER) {
count++;
} else if (grid[convertCordToIndex(i, i)] === SPACE_STATE.AI) {
count--;
} else if (grid[convertCordToIndex(i, i)] === SPACE_STATE.EMPTY) {
row = i;
col = i;
}
}
// Opponent two consecutive spaces, return third to block.
if (count === 2) {
return convertCordToIndex(row, col);
}
count = 0;
// Check Anti-Diag
for (let i = 0; i < 3; ++i) {
if (grid[convertCordToIndex(i, 3 - 1 - i)] === SPACE_STATE.PLAYER) {
count++;
} else if (grid[convertCordToIndex(i, 3 - 1 - i)] === SPACE_STATE.AI) {
count--;
} else if (grid[convertCordToIndex(i, 3 - 1 - i)] === SPACE_STATE.EMPTY) {
row = i;
col = 3 - 1 - i;
}
}
// Opponent two consecutive spaces, return third to block.
if (count === 2) {
return convertCordToIndex(row, col);
}
return null;
}
/**
* Ai checks if it can block a fork
* @returns Can ai block opponent
*/
function aiCanBlockFork(grid, moveCount) {
if (moveCount === 3) {
if (
grid[convertCordToIndex(0, 0)] === SPACE_STATE.PLAYER &&
grid[convertCordToIndex(1, 1)] === SPACE_STATE.AI &&
grid[convertCordToIndex(2, 2)] === SPACE_STATE.PLAYER
) {
aiCanFillEmptySide(grid);
return true;
}
if (
grid[convertCordToIndex(2, 0)] === SPACE_STATE.PLAYER &&
grid[convertCordToIndex(1, 1)] === SPACE_STATE.AI &&
grid[convertCordToIndex(0, 2)] === SPACE_STATE.PLAYER
) {
aiCanFillEmptySide(grid);
return true;
}
if (
grid[convertCordToIndex(2, 1)] === SPACE_STATE.PLAYER &&
grid[convertCordToIndex(1, 2)] === SPACE_STATE.PLAYER
) {
return convertCordToIndex(2, 2);
}
}
return null;
}
/**
* Ai checks if it can fill center square
* @returns Can ai fill center square
*/
function aiCanCenter(grid) {
if (grid[convertCordToIndex(1, 1)] === SPACE_STATE.EMPTY) {
return convertCordToIndex(1, 1);
}
return false;
}
/**
* Ai checks if it can fill opposite corner
* @returns Can ai fill opposite corner
*/
function aiCanFillOppositeCorner(grid) {
if (
grid[convertCordToIndex(0, 0)] === SPACE_STATE.PLAYER &&
grid[convertCordToIndex(2, 2)] === SPACE_STATE.EMPTY
) {
return convertCordToIndex(2, 2);
}
if (
grid[convertCordToIndex(2, 2)] === SPACE_STATE.PLAYER &&
grid[convertCordToIndex(0, 0)] === SPACE_STATE.EMPTY
) {
return convertCordToIndex(0, 0);
}
if (
grid[convertCordToIndex(0, 2)] === SPACE_STATE.PLAYER &&
grid[convertCordToIndex(2, 0)] === SPACE_STATE.EMPTY
) {
return convertCordToIndex(2, 0);
}
if (
grid[convertCordToIndex(2, 0)] === SPACE_STATE.PLAYER &&
grid[convertCordToIndex(0, 2)] === SPACE_STATE.EMPTY
) {
return convertCordToIndex(0, 2);
}
return null;
}
/**
* Ai checks if it can fill empty corner
* @returns Can ai fill empty corner
*/
function aiCanFillEmptyCorner(grid) {
if (grid[convertCordToIndex(0, 0)] === SPACE_STATE.EMPTY) {
return convertCordToIndex(0, 0);
}
if (grid[convertCordToIndex(0, 2)] === SPACE_STATE.EMPTY) {
return convertCordToIndex(0, 2);
}
if (grid[convertCordToIndex(2, 0)] === SPACE_STATE.EMPTY) {
return convertCordToIndex(2, 0);
}
if (grid[convertCordToIndex(2, 2)] === SPACE_STATE.EMPTY) {
return convertCordToIndex(2, 2);
}
return null;
}
/**
* Ai checks if it can fill empty side
* @returns Can ai fill empty side
*/
function aiCanFillEmptySide(grid) {
if (grid[convertCordToIndex(0, 1)] === SPACE_STATE.EMPTY) {
return convertCordToIndex(0, 1);
}
if (grid[convertCordToIndex(1, 0)] === SPACE_STATE.EMPTY) {
return convertCordToIndex(1, 0);
}
if (grid[convertCordToIndex(1, 2)] === SPACE_STATE.EMPTY) {
return convertCordToIndex(1, 2);
}
if (grid[convertCordToIndex(2, 1)] === SPACE_STATE.EMPTY) {
return convertCordToIndex(2, 1);
}
return null;
}
Checking for a Winner
A draw or winner is checked after every turn. Counting the move count against the maximum moves determines if the game is drawn.
For a winner, a check is made for three consecutive filled horizontal, vertical, or diagonal squares by either the player or AI. The 3-indexes required for a win are defined as a 2d-array then compared against the grid
.
const MAX_MOVES = 10;
const isDraw = (moveCount) => {
return moveCount === MAX_MOVES;
};
const checkWinner = (grid, moveCount) => {
const winnerSpaces = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6]
];
if (isDraw(moveCount)) {
return {
winner: GAME_STATE.DRAW,
winSpaces: []
};
}
for (let i = 0; i < winnerSpaces.length; i++) {
const [a, b, c] = winnerSpaces[i];
if (
grid[a] === SPACE_STATE.EMPTY &&
grid[b] === SPACE_STATE.EMPTY &&
grid[c] === SPACE_STATE.EMPTY
) {
continue;
}
if (grid[a] && grid[a] === grid[b] && grid[a] === grid[c]) {
let winner = null;
if (grid[a] === SPACE_STATE.PLAYER) {
winner = GAME_STATE.PLAYER_WON;
} else {
winner = GAME_STATE.AI_WON;
}
return {
winner: winner,
winSpaces: [a, b, c]
};
}
}
return null;
};
Game Loop
The useEffect
hook is responsible for game flow. You control when this hook runs by providing a dependency that tells it to re-run each time the dependency changes. The gameState
variable is the perfect dependency, as each game action updates it, allowing the game to flow smoothly.
useEffect(() => {
...
// I need to re-run on gameState change.
}, [gameState]);
After each turn, useEffect
checks for a winner, calculates the AI's turn, checks for a winner again, then changes the gameState
to GAME_STATE.PLAYER_TURN
and waits to repeat the loop.
// Spaces used to get a win
const [winSpaces, setWinSpaces] = useState([]);
useEffect(() => {
// Player took turn and changed game state,
// check for a winner.
let winner = checkWinner(grid, moveCount);
// If the someone won, update state to reflect and set winner spaces.
if (winner) {
setGameState(winner.winner);
setWinSpaces(winner.winSpaces);
}
// Run AI turn
if (gameState === GAME_STATE.AI_TURN && moveCount < 10) {
const aiSpace = calculateAITurn(grid, moveCount);
setMoveCount((oldMoves) => {
return oldMoves + 1;
});
fillGridSpace(aiSpace, SPACE_STATE.AI);
winner = checkWinner(grid, moveCount);
}
// If AI won, update state to reflect, else
// go back to player turn.
if (winner) {
setGameState(winner.winner);
setWinSpaces(winner.winSpaces);
} else {
setGameState(GAME_STATE.PLAYER_TURN);
}
// I need to re-run on gameState change.
}, [gameState]);
Highlighting Winner Spaces
We track winner spaces, modifying the getSpaceStateClass
function to account for the gameState
and winSpaces
when determining the CSS class names is an easy change.
const getSpaceStateClass = (spaceState, gameState, winSpaces, spaceIndex) => {
let space = "";
if (spaceState === SPACE_STATE.AI) {
space += "o-player";
if (gameState === GAME_STATE.AI_WON && winSpaces.includes(spaceIndex)) {
space += " o-winner";
}
}
if (spaceState === SPACE_STATE.PLAYER) {
space += "x-player";
if (gameState === GAME_STATE.PLAYER_WON && winSpaces.includes(spaceIndex)) {
space += " x-winner";
}
}
return space;
};
Resetting
Having to refresh the browser every time you want to restart the game is irritating. So we create a reset
function that resets all state variables to their default values.
// Reset state to default values
const reset = () => {
setGrid(createEmptyGrid());
setGameState(GAME_STATE.PLAYER_TURN);
setMoveCount(0);
setWinSpaces([]);
};
<button
className="bg-blue-500 text-white w-full py-2 font-semibold mt-10 rounded-md shadow-lg"
onClick={() => {
reset();
}}
>
Reset
</button>
Conclusion
This unbeatable playable tic-tac-toe game was super fun to implement and made me think about:
- Using types to represent state.
- Creating an AI using a strategy.
- Utilizing
useEffect
for game flow.
I hope you learned as much as I did! Now swindle money from bets you know you'll win (I take a 15% cut naturally 😉). If you're successful, let me know in the comments below.
Consider signing up for my newsletter or supporting me if this was helpful. Thanks for reading!
Top comments (3)
Not really an AI though, is it? Very misleading title
Hey cool implementation but there is a bug when we play corner box first followed by diagonally opposite box the code breaks 😛
Nice catch!!! Forgot to pass a parameter 😅 .