Course: Section

Get Started with Modern React: Learn by Doing

Episode: Title

S03・V20: Time Travel (Part 3 – Implementing Time Travel)

Date Created: July 17th, 2019
Last Updated: 27 days ago

Objective
  • We will implement the ability to jump to any previous move in the game’s history, and also to allow for a new sequence of moves from that point forward.
Watch Video
Duration: 6m 31s

Implementing Time Travel

We will now implement the final feature of our game. When clicking on a button specifying a certain move, the game will jump to that move in the game’s history. This will complete our time travel feature.

First, we will remember the index of the move that we want to jump to, in our Game’s state. We will call the state variable moveIndex, and we will initialize it with 0.

const Game = () => {
  const initialHistory = [
    { squares: Array(9).fill(null) },
  ];
  const [history, setHistory] = useState(initialHistory);
  const [xIsNext, setXIsNext] = useState(true);
  const [moveIndex, setMoveIndex] = useState(0);

Jump to Move

Next, we will create a function that will be called when the user clicks on a move button, to jump to the move specified by the move’s index. Inside this function, we will set the move index to be the index of the move button: setMoveIndex(index).

In order to check if player X or player O is next, we first have to ascertain if the move index is even or odd. We will use the modulus operator to get the remainder after dividing the index by 2. If the remainder is 0, then the index is even, otherwise it is odd: const moveIndexIsEven = index % 2 === 0.

Player X is next if the move index is even: setXIsNext(moveIndexIsEven).

  const jumpTo = index => {
    setMoveIndex(index);
    const moveIndexIsEven = index % 2 === 0;
    setXIsNext(moveIndexIsEven);
  };

We will now pass this jumpTo(index) function to the move button’s onClick event.

  const moves = history.map((move, index) => {
    const description = Boolean(index)
      ? `Go to move #${index}`
      : `Go to game start`;
    return (
      <li key={index}>
        <button onClick={() => jumpTo(index)}>          {description}
        </button>
      </li>
    );
  });

Update ‘handleClickEvent’

We will now make a few changes to the handleClickEvent function which fires when you click on a square. First off, we need to make a copy of history, and slice off the moves past the selected move index, so that fresh moves can be made by the game players after that point in the game: history.slice(0, moveIndex + 1). We will assign this copy of history to a variable called slicedHistory.

Now, we will need to update the following references to history to read slicedHistory. This ensures that if we “go back in time” and then make a new move from that point, we throw away all the “future” history that would now become incorrect.

The moveIndex state variable we’ve added reflects the move displayed to the user now. After we make a new move, we need to update the moveIndex with the setMoveIndex function, passing in the history up to and including the move selected by the user: setMoveIndex(slicedHistory.length). This ensures we don’t get stuck showing the same move after a new one has been made.

  const handleClickEvent = i => {
    const slicedHistory = history.slice(0, moveIndex + 1);    const currentMove = slicedHistory[slicedHistory.length - 1];    const newSquares = [...currentMove.squares];

    // Return early if winner declared or square filled
    const winnerDeclared = Boolean(calculateWinner(newSquares));
    const squareFilled = Boolean(newSquares[i]);
    if (winnerDeclared || squareFilled) {
      return;
    }

    newSquares[i] = xIsNext ? 'X' : 'O';
    const newMove = { squares: newSquares };
    const newHistory = [...slicedHistory, newMove];    setHistory(newHistory);
    setXIsNext(!xIsNext);
    setMoveIndex(slicedHistory.length);  };

Finally, we will modify the Game component from always rendering the last move, to rendering the currently selected move, according to the moveIndex.

  const currentMove = history[moveIndex];  const winner = calculateWinner(currentMove.squares);
  const status = winner ?
    `Winner: ${winner}` :
    `Next player: ${xIsNext ? 'X' : 'O'}`;

Now the time travel feature is complete!

Let’s try it out in the browser.

  • Player X starts in the top left square.
  • Player O follows with a move in the square below.
  • Player X’s second move is in the top right square.
  • Player O’s second move is in the top middle square.
  • Player X then moves to the bottom left square.
  • Player O blocks with a move in the center square.
  • Player X follows with a move to the bottom middle square.
  • And, player O wins with a move to the right central square.

Now, with time travel, the game allows us to jump to a previous move. For example, we could Go to move #1.

Or, we could Go to move #4, and play new moves from there.

  • Player X is next, and this time around, moves to the center square.
  • Note that move #5 is now different from before, and all moves beyond #5 have been “sliced off”.
  • Now, next up is player O, who moves to the bottom right square.
  • And, this time, player X wins with a move to the bottom left square.

We have now verified that the game works as we intended it to.

Game Features

Congratulations! You’ve created a tic-tac-toe game that:

  1. Lets you play tic-tac-toe,
  2. Indicates when a player has won the game,
  3. Stores a game’s history as a game progresses,
  4. And, allows players to review a game’s history and see previous versions of a game’s board.

Nice work! We hope you now feel like you have a decent grasp on how React works.

Code Snapshot

src/index.js
import React, { useState } from 'react';
import ReactDOM from 'react-dom';

import './index.css';

const Square = props => {
  return (
    <button
      className="square"
      onClick={props.onClickEvent}
    >
      {props.value}
    </button>
  );
};

const Board = props => {
  const renderSquare = i => {
    return (
      <Square
        value={squares[i]}
        onClickEvent={() => handleClickEvent(i)}
      />
    );
  };

  return (
    <div>
      <div className="board-row">
        {renderSquare(0)}
        {renderSquare(1)}
        {renderSquare(2)}
      </div>
      <div className="board-row">
        {renderSquare(3)}
        {renderSquare(4)}
        {renderSquare(5)}
      </div>
      <div className="board-row">
        {renderSquare(6)}
        {renderSquare(7)}
        {renderSquare(8)}
      </div>
    </div>
  );
};

const Game = () => {
  const initialHistory = [
    { squares: Array(9).fill(null) },
  ];
  const [history, setHistory] = useState(initialHistory);
  const [xIsNext, setXIsNext] = useState(true);
  const [moveIndex, setMoveIndex] = useState(0);

  const handleClickEvent = i => {
    const slicedHistory = history.slice(0, moveIndex + 1);
    const currentMove = slicedHistory[slicedHistory.length - 1];
    const newSquares = [...currentMove.squares];

    // Return early if winner declared or square filled
    const winnerDeclared = Boolean(calculateWinner(newSquares));
    const squareFilled = Boolean(newSquares[i]);
    if (winnerDeclared || squareFilled) {
      return;
    }

    newSquares[i] = xIsNext ? 'X' : 'O';
    const newMove = { squares: newSquares };
    const newHistory = [...slicedHistory, newMove];
    setHistory(newHistory);
    setXIsNext(!xIsNext);
    setMoveIndex(slicedHistory.length);
  };

  const jumpTo = index => {
    setMoveIndex(index);
    const moveIndexIsEven = index % 2 === 0;
    setXIsNext(moveIndexIsEven);
  };

  const moves = history.map((move, index) => {
    const description = Boolean(index)
      ? `Go to move #${index}`
      : `Go to game start`;
    return (
      <li key={index}>
        <button onClick={() => jumpTo(index)}>
          {description}
        </button>
      </li>
    );
  });

  const currentMove = history[moveIndex];
  const winner = calculateWinner(currentMove.squares);
  const status = winner ?
    `Winner: ${winner}` :
    `Next player: ${xIsNext ? 'X' : 'O'}`;

  return (
    <div className="game">
      <Board 
        squares={currentMove.squares}
        onClickEvent={i => handleClickEvent(i)}
      />
      <div className="status">{status}</div>
      <ol>{moves}</ol>
    </div>
  );
};

ReactDOM.render(
  <Game />,
  document.getElementById('root')
);

function calculateWinner(squares) {
  /*
  0 1 2
  3 4 5
  6 7 8
  */
  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 line of lines) {
    const [a, b, c] = line;
    if (
      squares[a] &&
      squares[a] === squares[b] &&
      squares[a] === squares[c]
    ) {
      return squares[a];
    }
  }

  return null;
}
src/index.css
body {
  background-color: #444;
  color: white;
  margin: 20px;
  font: 32px "Century Gothic", Futura, sans-serif;
}

button {
  font-size: 32px;
}

li {
  padding-bottom: 16px;
}

.game {
  display: flex;
  flex-direction: column;
  align-items: center;
}

.board-row {
  display: flex;
  flex-flow: row nowrap;
}

.square {
  background-color: #444;
  color: white;
  border: 1px solid #999;
  padding: 0;
  font-size: 84px;
  text-align: center;
  width: 100px;
  height: 100px;
}

.square:focus {
  outline: none;
  background-color: #222;
}

.status {
  margin: 20px;
}

Summary

We implemented the ability to jump to any previous move in the game’s history, which allowed players to make new moves from that point forward. This was the final explanatory video for the React game tutorial.

Next Up…

In the next video, we will issue some challenge questions that cover this section.