I've been teaching myself React.js by following the tic-tac-toe tutorial on reactjs.org,
https://reactjs.org/tutorial/tutorial.html.
I then added a minimax algorithm to make the game more interactive; however, the addition of an AI opponent also introduced a bug into the Time Travel feature of the original tutorial. The game is set up so that the user is always Player X and the AI is always Player O. When the player marks a square 'X' the AI immediately marks its square 'O' and the user always gets the first move.
Using the Time Travel feature, I can roll the game back to any turn, including turns for Player O. I can then mark a square as Player O and the AI then continues to play the game as Player X. This "switcheroo" is a bug I'd like to fix.
Below is the code for the game:
index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css'
function Square(props) {
return (
<button className="square" onClick={props.onClick}>
{props.value}
</button>
);
}
class Board extends React.Component {
renderSquare(i) {
return (
<Square
value={this.props.squares[i]}
onClick={() => this.props.onClick(i)}
/>
);
}
render() {
return (
<div>
<div className="board-row">
{this.renderSquare(0)}
{this.renderSquare(1)}
{this.renderSquare(2)}
</div>
<div className="board-row">
{this.renderSquare(3)}
{this.renderSquare(4)}
{this.renderSquare(5)}
</div>
<div className="board-row">
{this.renderSquare(6)}
{this.renderSquare(7)}
{this.renderSquare(8)}
</div>
</div>
);
}
}
class Game extends React.Component {
constructor(props) {
super(props);
this.state = {
history: [
{
squares: Array(9).fill(null)
}
],
stepNumber: 0,
xIsNext: true
};
}
makeMove(i) {
const history = this.state.history.slice(0, this.state.stepNumber + 1);
const current = history[history.length - 1];
const squares = current.squares.slice();
if (calculateWinner(squares) || squares[i]) {
return;
}
squares[i] = this.state.xIsNext ? "X" : "O";
const nextState = {
history: history.concat([
{
squares: squares
}
]),
stepNumber: history.length,
xIsNext: !this.state.xIsNext
};
return new Promise((resolve, reject) => {
this.setState(nextState, resolve);
});
}
async handleClick(i) {
await this.makeMove(i);
const squares = this.state.history[this.state.stepNumber].squares.slice();
const bestSquare = findBestSquare(squares, this.state.xIsNext ? "X" : "O");
if(bestSquare !== -1) {
await this.makeMove(bestSquare);
}
}
jumpTo(step) {
this.setState({
stepNumber: step,
xIsNext: (step % 2) === 0
});
}
render() {
const history = this.state.history;
const current = history[this.state.stepNumber];
const winner = calculateWinner(current.squares);
const moves = history.map((step, move) => {
const desc = move ?
'Go to move #' + move :
'Go to game start';
return (
<li key={move}>
<button onClick={() => this.jumpTo(move)}>{desc}</button>
</li>
);
});
let status;
if (winner) {
status = "Winner: " + winner;
}
else if (isBoardFilled(current.squares)) {
status = "It's a tie!";
}
else {
status = "Next player: " + (this.state.xIsNext ? "X" : "O");
}
return (
<div className="game">
<div className="game-board">
<Board
squares={current.squares}
onClick={i => this.handleClick(i)}
/>
</div>
<div className="game-info">
<div>{status}</div>
<ol>{moves}</ol>
</div>
</div>
);
}
}
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<Game />);
function calculateWinner(squares) {
const lines = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6]
];
for (let i = 0; i < lines.length; i++) {
const [a, b, c] = lines[i];
if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
return squares[a];
}
}
return null;
}
function isBoardFilled(squares) {
for (let i = 0; i < squares.length; i++) {
if (squares[i] === null) {
return false;
}
}
return true;
}
function findBestSquare(squares, player) {
const opponent = player === 'X' ? 'O' : 'X';
const minimax = (squares, isMax) => {
const winner = calculateWinner(squares);
if (winner === player) return { square: -1, score: 1 };
if (winner === opponent) return { square: -1, score: -1 };
if (isBoardFilled(squares)) return { square: -1, score: 0 };
const best = { square: -1, score: isMax ? -1000 : 1000 };
for (let i = 0; i < squares.length; i++) {
if (squares[i]) {
continue;
}
squares[i] = isMax ? player : opponent;
const score = minimax(squares, !isMax).score;
squares[i] = null;
if (isMax) {
if (score > best.score) {
best.score = score;
best.square = i;
}
}
else {
if (score < best.score) {
best.score = score;
best.square = i;
}
}
}
return best;
};
return minimax(squares, true).square;
}
What I have tried:
The easiest way I can think of to fix this issue is to only allow the user to Time Travel to previous turns for Player X, but I'm not sure how to go about doing this while also rolling back turns for Player O, as well.