I'm trying to create a game of Tic-Tac-Toe with an AI using react. I have a function for my AI that retuns a random row and column to place a piece in. However, when using strict mode the AI takes two turns because the random number gets generated again. From the reading that I've done this indicates that I'm updating my board state incorrectly but I'm not sure where I've gone wrong. Below is my code for placing the mark on the board.
const handlePlacePiece = (row: number, col: number) => {
let currentPiece = board[row][col];
if (currentPiece === "-") {
const newBoard = Array.from(board);
newBoard[row][col] = currentPlayer; // The current players mark either 'X' or 'O'
setBoard(newBoard);
setCurrentPlayer(currentPlayer === "X" ? "O" : "X");
}
};
And here is my initial board state:
const [board, setBoard] = useState([
["-", "-", "-"],
["-", "-", "-"],
["-", "-", "-"],
]);
Here is my ai function:
export function easyAi(board: any) {
let col = getRandomMove(); //Math.floor(Math.random() * 3);
let row = getRandomMove();
while (board[row][col] !== "-") {
col = getRandomMove();
row = getRandomMove();
}
return { row, col };
}
Calling handlePlacePiece (this is also an onClick but this produces the correct outcome):
if (gameType === "AI") {
if (currentPlayer === aiPiece) {
const { row, col } = easyAi(board);
handlePlacePiece(row, col);
}
}
Full file on GitHub: https://github.com/lukeypap/Tic-Tac-Toe-React/blob/master/src/components/Board/index.tsx
Please let me know if you need extra details, Thank you.
The logic for the "AI" to take its turn is right in the function body as an unintentional side-effect. This means anytime the component is rendered for any reason at all the code it invoked. The React.StrictMode
runs certain functions twice as a way to help you detect unexpected side-effects.
if (gameType === "AI" && !gameOver) {
if (checkDraw(board) || checkWinner(board)) {
console.log("setting game over true");
setGameOver(true);
} else if (currentPlayer === aiPiece && !gameOver) {
const { row, col } = easyAi(board);
handlePlacePiece(row, col);
}
}
React.StricMode
Strict mode can’t automatically detect side effects for you, but it can help you spot them by making them a little more deterministic. This is done by intentionally double-invoking the following functions:
- Class component
constructor
,render
, andshouldComponentUpdate
methods- Class component static
getDerivedStateFromProps
method- Function component bodies <--
- State updater functions (the first argument to
setState
)- Functions passed to
useState
,useMemo
, oruseReducer
The handlePlacePiece
callback is mutating the board
state object. All state, and nested state, that is being updated should be shallow copied in order to create new references. The callback only creates a shallow copy of the outer board
array, but all row arrays are still references to the previous state.
const newBoard = Array.from(board);
newBoard[row][col] = currentPlayer; // <-- mutation!
setBoard([...newBoard]);
Use a functional state update to update the board state from the previous value, using Array.prototype.map
to shallow copy the rows and columns that are being updated.
Move the "AI" turn logic into a useEffect
hook. I suggest moving also the game check into a useEffect
hook to be called when the board
state updates.
Suggestions:
enum PIECE {
X = "X",
O = "O",
EMPTY = "-",
}
interface props {
gameType: "AI" | "HUMAN";
selectedPiece: PIECE;
startingPlayer: PIECE;
}
const index = ({ gameType, selectedPiece, startingPlayer }: props) => {
const [board, setBoard] = useState(
Array(3).fill(Array(3).fill(PIECE.EMPTY))
);
const [currentPlayer, setCurrentPlayer] = useState(startingPlayer);
const [aiPiece, setAiPiece] = useState(
selectedPiece === PIECE.O ? PIECE.X : PIECE.O
);
const [gameOver, setGameOver] = useState(false);
const [checkingMove, setCheckingMove] = useState(false);
const handlePlacePiece = (row: number, col: number) => {
let mark = board[row][col];
if (mark === PIECE.EMPTY) {
setCheckingMove(true);
setBoard((board) =>
board.map((boardRow: PIECE[], i: number) =>
i === row
? boardRow.map((boardCol: PIECE, j: number) =>
j === col ? currentPlayer : boardCol
)
: boardRow
)
);
}
};
useEffect(() => {
const isWinner = checkWinner(board); // *
const isDraw = checkDraw(board);
if (isWinner || isDraw) {
if (isWinner) {
console.log(`Winner is: ${isWinner}`);
} else if (isDraw) {
console.log("Draw");
}
setGameOver(true);
} else {
setCurrentPlayer((current) => (current === PIECE.X ? PIECE.O : PIECE.X));
setCheckingMove(false);
}
}, [board]);
useEffect(() => {
if (
!checkingMove &&
gameType === "AI" &&
!gameOver &&
currentPlayer === aiPiece
) {
const { row, col } = easyAi(board);
handlePlacePiece(row, col);
}
}, [checkingMove, currentPlayer, aiPiece, gameType, gameOver, board]);
return (
...
);
};
* Note: The checkWinner
utility was updated to return the winning piece.
export const checkWinner = (board: string[][]) => {
//Rows
if (
board[0][0] == board[0][1] &&
board[0][1] == board[0][2] &&
board[0][0] != "-"
) {
return board[0][0];
}
if (
board[1][0] == board[1][1] &&
board[1][1] == board[1][2] &&
board[1][0] != "-"
) {
return board[1][0];
}
if (
board[2][0] == board[2][1] &&
board[2][1] == board[2][2] &&
board[2][0] != "-"
) {
return board[2][0];
}
//Cols
if (
board[0][0] == board[1][0] &&
board[1][0] == board[2][0] &&
board[0][0] != "-"
) {
return board[0][0];
}
if (
board[0][1] == board[1][1] &&
board[1][1] == board[2][1] &&
board[0][1] != "-"
) {
return board[0][1];
}
if (
board[0][2] == board[1][2] &&
board[1][2] == board[2][2] &&
board[0][2] != "-"
) {
return board[0][2];
}
//Diags
if (
board[0][0] == board[1][1] &&
board[1][1] == board[2][2] &&
board[0][0] != "-"
) {
return board[0][0];
}
if (
board[0][2] == board[1][1] &&
board[1][1] == board[2][0] &&
board[0][2] != "-"
) {
return board[0][2];
}
};