03/09/2025
We'll be creating the beloved classic game of tic-tac-toe using React. This simple game has a very simple rule, get 3 of same type in a row, column, or diagonal. Here's a functional game of tic-tac-toe running on React:
Here's a functional game of tic-tac-toe running on React:
Turn: Player 1
When developing a game like this, there's fundamentals things we'll have to think:
Let's break these down and focus on them one by one, starting with drawing out the board.
We know that for this game, the board consists of 9 squares since there are 3 rows and 3 columns. We'll do something simple with just divs and the lines are drawn with css borders. The data structure for the board is a 2d array with the values being of two possible types, null when there nothing placed there, and/or the value of the player.
1const TicTacToeGame = () => {
2
3 const [board, setBoard] = useState<Array<number | null>[]>([
4 [null, null, null],
5 [null, null, null],
6 [null, null, null],
7 ]);
8
9 const renderBoard = () => { ... }
10
11 return (
12 <div
13 style={{
14 width: "300px",
15 height: "300px"
16 }}
17 >
18 {renderBoard()}
19 </div>
20 );
21};For drawing out the borders of the squares, renderBoard() is called. This function will go through each cell of the 2nd array and determine if it should draw the right and/or bottom border. For example, a square should draw the right border if its not the last cell on the row (left to right). Likewise when drawing the bottom border, it should not be drawn if the last cell in the column (top to bottom).
1const renderBoard = () => {
2 return board.map((_, rowI) => {
3 return board[rowI].map((_, colI) => {
4 return (
5 <div
6 style={{
7 borderRight: colI !== board[rowI].length - 1 ? "2px solid" : "none",
8 borderBottom: rowI !== board.length - 1 ? "2px solid" : "none",
9 width: "33.33%",
10 height: "33.33%"
11 onClick={() => placePosition(rowI, colI) }
12 }}
13 > .... </div>
14 );
15 });
16 });
17}Note the width and height of 33.33%; this is calculated from 100 / {count of row/column}. This can done dynamically instead of hardcoded here, especially if the game type requires a much larger board size.
Now, the current player is set as a value in the state currentPlayer which alternates between 1 and -1, respectively player 1 and player 2.
1const [currentPlayer, setCurrentPlayer] = useState<number>(1);
2
3const placePosition = (rowI: number, colI: number) => {
4 // check that this position is empty (null)
5 if (board[rowI][colI] !== null) return;
6
7 // update the board
8 setBoard((prev) => {
9 prev[rowI][colI] = currentPlayer;
10 return prev;
11 });
12
13 // Now change the player
14 setCurrentPlayer((prev) => prev * -1);
15};Here, the function placePosition(...) is called when the user clicks on a square. If the square is allowed (value is currently is null), then the value for the board at the row/col position is updated to the value of the current player. Then, the current player is switched by simply inverting the between 1 and -1.
Now, displaying the current placed and/or empty positions on the board is pretty simple. We basically just need to check the value for a specific square. If its null, we know that its empty and that a "piece" has not been placed there yet. Otherwise, if not null, then based on the value, we can determine which player placed the piece there and render the type.
1const renderPositionValue = useCallback(
2 (rowI, colI) => {
3 let value = board[rowI][colI];
4 if (value) {
5 return value === 1 ? (
6 <CloseIcon style={{ color: "green", fontSize: "48px" }} />
7 ) : (
8 <PanoramaFishEyeIcon style={{ color: "red", fontSize: "48px" }} />
9 );
10 }
11 return "";
12 },
13 [board]
14);This step to me is the most interesting because there could be a few different ways to solve it. One approach is to just scan each row, column, and diagonal and check if all values are the same. Given the 9 x 9 board, the most "checks" that it will need to do is 8, 3 rows, 3 columns, and 2 diagonals. Here's the code for what that looks like:
1useEffect(() => {
2 if (winner === null) {
3
4 // Check each row
5 for (let row of board) {
6 if (new Set(row).size === 1 && row[0] !== null) {
7 setWinner(row[0]);
8 return;
9 }
10 }
11
12 // Checking each column
13 for (let r = 0; r < 3; r++) {
14 if (board[0][r] !== null && board[0][r] === board[1][r] && board[1][r] === board[2][r]) {
15 setWinner(board[0][r]);
16 return;
17 }
18 }
19
20 // Checking the diagonal
21 if (board[1][1] !== null) {
22 if ((board[0][0] === board[1][1] && board[1][1] === board[2][2])
23 || (board[0][2] === board[1][1] && board[1][1] === board[2][0])) {
24
25 setWinner(board[0][0]);
26 return;
27 }
28 }
29 }
30}, [board, currentPlayer, winner]);Now, another approach that we can do is to explicity look at the cells for each row/column/diagonal and do a simple equal comparison. For example, if the top row has a winner, then that means that board[0][0] === board[0][1] && board[0][1] === board[0][2]. We would then repeat this direct equal check for the other rows/columns/diagonals.
1useEffect(() => {
2 if (winner === null) {
3 if (board[0][0] !== null && board[0][0] === board[0][1] && board[0][1] === board[0][2]) {
4 setWinner(board[0][0]);
5 } else if (board[1][0] !== null && board[1][0] === board[1][1] && board[1][1] === board[1][2]) {
6 setWinner(board[1][0]);
7 } else if (board[2][0] !== null && board[2][0] === board[2][1] && board[2][1] === board[2][2]) {
8 setWinner(board[2][0]);
9 }
10
11 if (board[0][0] !== null && board[0][0] === board[1][0] && board[1][0] === board[2][0]) {
12 setWinner(board[0][0]);
13 } else if (board[0][1] !== null && board[0][1] === board[1][1] && board[1][1] === board[2][1]) {
14 setWinner(board[0][1]);
15 } else if (board[0][2] !== null && board[0][2] === board[1][2] && board[1][2] === board[2][2]) {
16 setWinner(board[0][2]);
17 }
18
19 if (board[1][1] !== null) {
20 if (
21 (board[0][0] === board[1][1] && board[1][1] === board[2][2]) ||
22 (board[0][2] === board[1][1] && board[1][1] === board[2][0])
23 ) {
24 setWinner(board[1][1]);
25 }
26 }
27 }
28}, [board, currentPlayer, winner]);Now, it probably doesn't as clean as the first approach but I suppose its more focused on efficient, even though for tic-tac-toe it doesn't really matter. We are only improving from O(8) to now, O(1) which in the grand scheme of things, is virtually the same.
For the next board game react project, the efficiency of winner detection will be much more critical, Gomoku. The game Gomoku uses a board identical in size to the game Go, 15 x 15 or 19 x 19. If we try to use approach 1 or 2 from the tic-tac-toe here, it would be extremely inefficient and a nightmare to read/maintain.