DEV Community

Cover image for Build a Multiplayer Tic Tac Toe Game in React
Oscar Castro for PubNub

Posted on • Originally published at pubnub.com

Build a Multiplayer Tic Tac Toe Game in React

Tic tac toe is a quintessential childhood game. All it requires is something to write on and something to write with. But what if you want to play with someone that's in another location? In this case, you would need to use an application that connects you and another player to the game.

The application needs to provide a realtime experience so every move you make is seen instantly by the other player and vice versa. If the application does not provide this experience, then you and many people, won't probably use it anymore.

So how does a developer provide a connected experience where the players can play tic tac toe, or any game, no matter where they are in the world?

Realtime Multiplayer Game Concepts

There are several ways to provide the realtime infrastructure for multiplayer games. You can go the route of building your own infrastructure from the ground up by using technologies and open-source protocols like Socket.IO, SignalR, or WebSockets.

While this may seem like an appealing route to take, you will encounter several issues; one such issue being scalability. It's not hard to handle 100 users, but how do you handle 100,000+ users? Besides infrastructure issues, you still have to worry about maintaining your game.

At the end of the day, the only thing that matters is providing a great experience for the players of your game. But how do you do solve the infrastructure problem? This is where PubNub comes in.

PubNub provides the realtime infrastructure to power any application through its global Data Stream Network.  With over 70+ SDKs, including the most popular programming languages, PubNub simplifies sending and receiving messages to any device in under 100 ms. It is secure, scalable and reliable so you don't have to worry about creating and maintaining your own infrastructure.

In order to show how easy it is to develop a multiplayer game using PubNub, we will build a simple React tic tac toe game using the PubNub React SDK. In this game, two players will connect to a unique game channel where they will play against each other. Every move a player makes will be published to the channel to update the other player's board in realtime.

You can check out the complete project in the GitHub repository.

App Overview

Here is what our app will look like once we finish. Click here to try out our live version of the game.

Screen shot of the React Tic Tac Toe Game

Players first join the lobby where they can create a channel or join a channel. If the player creates a channel, they get a room id to share with another player. The player that created the channel becomes Player X and will make the first move when the game starts.

Create a room channel

The player that joins a channel with the room id they were given becomes Player O. Players can only join channels when there is one other person in the channel. If there is more than one person, then a game is in progress for that channel and the player won't be able to join. The game starts once there are two players in the channel.

Join the room channel

At the end of the game, the winner's score is incremented by one point. If the game ends in a tie, then neither player is awarded a point. A modal is displayed to Player X asking them to start a new round or to end the game. If Player X continues the game, the board resets for the new round. Otherwise, the game ends and both players go back to the lobby.

Exit to lobby

Set Up the Lobby

Before we set up the lobby, sign up for a free PubNub account. You can get your unique pub/sub keys in PubNub Admin Dashboard

Once you get your keys, insert them into the constructor of App.js.

// App.js
import React, { Component } from 'react';
import Game from './Game';
import Board from './Board';
import PubNubReact from 'pubnub-react';
import Swal from "sweetalert2";  
import shortid  from 'shortid';
import './Game.css';

class App extends Component {
  constructor(props) {  
    super(props);
    // REPLACE with your keys
    this.pubnub = new PubNubReact({
      publishKey: "YOUR_PUBLISH_KEY_HERE", 
      subscribeKey: "YOUR_SUBSCRIBE_KEY_HERE"    
    });

    this.state = {
      piece: '', // X or O
      isPlaying: false, // Set to true when 2 players are in a channel
      isRoomCreator: false,
      isDisabled: false,
      myTurn: false,
    };

    this.lobbyChannel = null; // Lobby channel
    this.gameChannel = null; // Game channel
    this.roomId = null; // Unique id when player creates a room   
    this.pubnub.init(this); // Initialize PubNub
  }  

  render() {
    return ();
    }
  }

  export default App;
Enter fullscreen mode Exit fullscreen mode

Also in the constructor, the state objects and variables are initialized. We will go over the objects and the variables when they come up throughout the file. Finally, we initialized PubNub at the end of the constructor.

Inside of the render method and inside the return statement, we add the markup for the Lobby component.

return (  
    <div> 
      <div className="title">
        <p> React Tic Tac Toe </p>
      </div>

      {
        !this.state.isPlaying &&
        <div className="game">
          <div className="board">
            <Board
                squares={0}
                onClick={index => null}
              />  

            <div className="button-container">
              <button 
                className="create-button "
                disabled={this.state.isDisabled}
                onClick={(e) => this.onPressCreate()}
                > Create 
              </button>
              <button 
                className="join-button"
                onClick={(e) => this.onPressJoin()}
                > Join 
              </button>
            </div>                        

          </div>
        </div>
      }

      {
        this.state.isPlaying &&
        <Game 
          pubnub={this.pubnub}
          gameChannel={this.gameChannel} 
          piece={this.state.piece}
          isRoomCreator={this.state.isRoomCreator}
          myTurn={this.state.myTurn}
          xUsername={this.state.xUsername}
          oUsername={this.state.oUsername}
          endGame={this.endGame}
        />
      }
    </div>
);  
Enter fullscreen mode Exit fullscreen mode

The Lobby component consists of a title, an empty tic tac toe board (nothing happens if the player presses the squares) and the 'Create' and 'Join' buttons. This component is displayed only if the state value isPlaying is false. If it's set to true, then the game has begun and the component is changed to the Game component, which we will go over in the second part of the tutorial.

The Board component is part of the Lobby component as well. Within the Board component is the Square component. We won't go into detail for these two components in order to focus on the Lobby and Game components.

When the player presses the 'Create' button, the button is disabled so the player can't create multiple channels. The 'Join' button is not disabled, just in case the player decides to join a channel instead. Once the 'Create' button is pressed, the method onPressCreate() is called.

Create a Channel

The first thing we do in onPressCreate() is generate a random string id that's truncated to 5 characters. We do so by using shortid(). We append the string to 'tictactoelobby--', which will be the unique lobby channel players subscribe to.

// Create a room channel
onPressCreate = (e) => {
  // Create a random name for the channel
  this.roomId = shortid.generate().substring(0,5);
  this.lobbyChannel = 'tictactoelobby--' + this.roomId; // Lobby channel name

  this.pubnub.subscribe({
    channels: [this.lobbyChannel],
    withPresence: true // Checks the number of people in the channel
  });
}
Enter fullscreen mode Exit fullscreen mode

In order to prevent more than two players from joining a given channel, we use PubNub Presence. Later on, we will look at the logic for checking the occupancy of the channel.

Once the player subscribes to the lobby channel, a modal is displayed with the room id so another player can join that channel.

Share the room id

This modal, and all the modals used in this app, are created by SweetAlert2 to replace JavaScript's default alert() popup boxes.

// Inside of onPressCreate()
// Modal
Swal.fire({
  position: 'top',
  allowOutsideClick: false,
  title: 'Share this room ID with your friend',
  text: this.roomId,
  width: 275,
  padding: '0.7em',
  // Custom CSS to change the size of the modal
  customClass: {
      heightAuto: false,
      title: 'title-class',
      popup: 'popup-class',
      confirmButton: 'button-class'
  }
})
Enter fullscreen mode Exit fullscreen mode

At the end of onPressCreate(), we change the state values to reflect the new state of the app.

this.setState({
  piece: 'X',
  isRoomCreator: true,
  isDisabled: true, // Disable the 'Create' button
  myTurn: true, // Player X makes the 1st move
});
Enter fullscreen mode Exit fullscreen mode

Once the player creates a room, they have to wait for another player to join that room. Let's look at the logic for joining a room.

Join a Channel

When a player presses the 'Join' button, a call to onPressJoin() is called. A modal is displayed to the player asking them to enter the room id in the input field.

Enter the room id

If the player types in the room id and presses the 'OK' button, then joinRoom(value) is called where value is the room id. This method is not called if the input field is empty or if the player presses the 'Cancel' button.

// The 'Join' button was pressed
onPressJoin = (e) => {
  Swal.fire({
    position: 'top',
    input: 'text',
    allowOutsideClick: false,
    inputPlaceholder: 'Enter the room id',
    showCancelButton: true,
    confirmButtonColor: 'rgb(208,33,41)',
    confirmButtonText: 'OK',
    width: 275,
    padding: '0.7em',
    customClass: {
      heightAuto: false,
      popup: 'popup-class',
      confirmButton: 'join-button-class',
      cancelButton: 'join-button-class'
    } 
  }).then((result) => {
    // Check if the user typed a value in the input field
    if(result.value){
      this.joinRoom(result.value);
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

The first thing we do in joinRoom() is append value to  'tictactoelobby--', similar to what we did in onPressCreate().

// Join a room channel
joinRoom = (value) => {
  this.roomId = value;
  this.lobbyChannel = 'tictactoelobby--' + this.roomId;
}
Enter fullscreen mode Exit fullscreen mode

Before the player subscribes to the lobby channel, we have to check the total occupancy of the channel by using hereNow().  If the total occupancy is less than 2, the player can successfully subscribe to the lobby channel.

// Check the number of people in the channel
this.pubnub.hereNow({
  channels: [this.lobbyChannel], 
}).then((response) => { 
    if(response.totalOccupancy < 2){
      this.pubnub.subscribe({
        channels: [this.lobbyChannel],
        withPresence: true
      });

      this.setState({
        piece: 'O', // Player O
      });  

      this.pubnub.publish({
        message: {
          notRoomCreator: true,
        },
        channel: this.lobbyChannel
      });
    } 
}).catch((error) => { 
  console.log(error);
});
Enter fullscreen mode Exit fullscreen mode

After the player subscribes to the lobby channel, the state value of piece is changed to 'O' and a message is published to that lobby channel. This message notifies the Player X that another player has joined the channel. We set up the message listener in componentDidUpdate(), which we will get to shortly.

If the total occupancy is greater than 2, then a game is in progress and the player attempting to join the channel will be denied access. The following code is below the if statement in hereNow().

// Below the if statement in hereNow()
else{
  // Game in progress
  Swal.fire({
    position: 'top',
    allowOutsideClick: false,
    title: 'Error',
    text: 'Game in progress. Try another room.',
    width: 275,
    padding: '0.7em',
    customClass: {
        heightAuto: false,
        title: 'title-class',
        popup: 'popup-class',
        confirmButton: 'button-class'
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

Let's now take a look at componentDidUpdate().

Start the Game

In componentDidUpdate(), we check if the player is connected to a channel, that is, check that this.lobbyChannel is not null. If it's not null, we set up a listener that listens to all messages that arrive on the channel.

componentDidUpdate() {
  // Check that the player is connected to a channel
  if(this.lobbyChannel != null){
    this.pubnub.getMessage(this.lobbyChannel, (msg) => {
      // Start the game once an opponent joins the channel
      if(msg.message.notRoomCreator){
        // Create a different channel for the game
        this.gameChannel = 'tictactoegame--' + this.roomId;

        this.pubnub.subscribe({
          channels: [this.gameChannel]
        });
      }
    }); 
  }
}
Enter fullscreen mode Exit fullscreen mode

We check if the message arrived is msg.message.notRoomCreator, which is published by the player that joins the channel. If so, we create a new channel, 'tictactoegame--', with the room id appended to the string. The game channel is used to publish all the moves made by the players which will update their boards.

Finally, after subscribing to the game channel, the state value of isPlaying is set to true. Doing so will replace the lobby component with the game component.

this.setState({
   isPlaying: true
 });  

 // Close the modals if they are opened
 Swal.close();
}
Enter fullscreen mode Exit fullscreen mode

Once the game component is shown, we want to close all the modals, if opened, from the Lobby component by doing Swal.close().

Now that we have two players connected to a unique game channel, they can begin playing tic tac toe! In the next section, we will implement the UI and logic for the game component.

Build Game Features

The first thing we do in Game.js is set up the base constructor:

// Game.js
import React from 'react';
import Board from './Board';
import Swal from "sweetalert2";  

class Game extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      squares: Array(9).fill(''), // 3x3 board
      xScore: 0,
      oScore: 0,
      whosTurn: this.props.myTurn // Player X goes first
    };

    this.turn = 'X';
    this.gameOver = false;
    this.counter = 0; // Game ends in a tie when counter is 9
  }

  render() { 
    return (); 
  } 
 } 
export default Game;
Enter fullscreen mode Exit fullscreen mode

For the state objects, we initialize the array squares property, which is used to store the player's positions in the board. This will be explained furthermore below. We also set the players score to 0 and set the value of whosTurn to myTurn, which is initialized to true for Player X and false for Player O.

The value of the variables turn and counter will change throughout the progression of the game. At the end of the game, gameOver is set to true.

Add the UI

Next, let's set up the markup for the Game component inside of the render method.

render() {
  let status;
  // Change to current player's turn
  status = `${this.state.whosTurn ? "Your turn" : "Opponent's turn"}`;

  return (
    <div className="game">
      <div className="board">
        <Board
            squares={this.state.squares}
            onClick={index => this.onMakeMove(index)}
          />  
          <p className="status-info">{status}</p>
      </div>

      <div className="scores-container">
        <div>
          <p>Player X: {this.state.xScore} </p>
        </div>

        <div>
          <p>Player O: {this.state.oScore} </p>
        </div>
      </div>   
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

We show the value of status in the UI to let the players know if it's their turn to make a move or if the other player's turn. The boolean value of the state whosTurn is updated every time a move is made. The rest of the UI consists of the Board component and the player's score.

Add the Logic

When the player makes a move on the board, a call to onMakeMove(index) is made where index is the position the piece is placed on the board. The board has 3 rows and 3 columns, so 9 squares total. Each square has its own unique index value, starting with the value 0 and ending with the value 8.

onMakeMove = (index) =>{
  const squares = this.state.squares;

  // Check if the square is empty and if it's the player's turn to make a move
  if(!squares[index] && (this.turn === this.props.piece)){ 
    squares[index] = this.props.piece;

    this.setState({
      squares: squares,
      whosTurn: !this.state.whosTurn 
    });

    // Other player's turn to make a move
    this.turn = (this.turn === 'X') ? 'O' : 'X';

    // Publish move to the channel
    this.props.pubnub.publish({
      message: {
        index: index,
        piece: this.props.piece,
        turn: this.turn
      },
      channel: this.props.gameChannel
    });  

    // Check if there is a winner
    this.checkForWinner(squares)
  }
}
Enter fullscreen mode Exit fullscreen mode

After getting the state of the array squares, a conditional statement is used to check if the square the player touched is empty and if it's their turn to make a move. If one or both conditions are not met, then the player's piece is not placed on the square. Otherwise, the player's piece is added to the array squares in the index the piece was placed on.

For example, if Player X makes a move in row 0, column 2 and the conditional statement is true, then squares[2] will have the value of "X".

Example with the squares array

Next, the state is changed to reflect the new state of the game and turn is updated so the other player can make their move. In order for the other player's board to update with the current data, we publish the data to the game channel. All of this is happening in realtime, so both players will immediately see their boards update as soon as a valid move is made. The last thing to do in this method is to call checkForWinner(squares) to check if there is a winner.

Before we do that, let's take a look at componentDidMount() where we set up the listener for new messages that arrive in the game channel.

componentDidMount(){
  this.props.pubnub.getMessage(this.props.gameChannel, (msg) => {
    // Update other player's board
    if(msg.message.turn === this.props.piece){
      this.publishMove(msg.message.index, msg.message.piece);
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Since both players are connected to the same game channel, they will both receive this message. The method publishMove(index, piece) is called, where index is the position that piece was placed and piece is the piece of the player that made the move. This method updates the board with the current move and checks if there is a winner. To prevent the player that made the current move to have to redo this process again, the if statement checks if the player's piece matches the value of turn. If so, their board is updated.

// Opponent's move is published to the board
publishMove = (index, piece) => {
  const squares = this.state.squares;

  squares[index] = piece;
  this.turn = (squares[index] === 'X')? 'O' : 'X';

  this.setState({
    squares: squares,
    whosTurn: !this.state.whosTurn
  });

  this.checkForWinner(squares)
}
Enter fullscreen mode Exit fullscreen mode

The logic of updating the board is the same as onMakeMove(). Let's now go over checkForWinner().

checkForWinner = (squares) => {
  // Possible winning combinations
  const possibleCombinations = [
    [0, 1, 2],
    [3, 4, 5],
    [6, 7, 8],
    [0, 3, 6],
    [1, 4, 7],
    [2, 5, 8],
    [0, 4, 8],
    [2, 4, 6],
  ];

  // Iterate every combination to see if there is a match
  for (let i = 0; i < possibleCombinations.length; i += 1) {
    const [a, b, c] = possibleCombinations[i];
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      this.announceWinner(squares[a]);
      return;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

All the winning combinations are in the double array possibleCombinations, where every array is a possible combination to winning the game. Every array in possibleCombinations is checked against the array squares. If there is a match, then there is a winner. Let's follow an example to make this more clear.

Say that Player X makes a winning move in row 2 column 0. The index of that position is 6. The board now looks like this:

Example of a winning move

The winning combination for Player X is [2,4,6]. The array squares is updated to: ["O", "", "X", "O", "X", "", "X", "", ""].

In the for loop, when [a,b,c] has the values of [2,4,6], the if statement in the for loop with be true since [2,4,6]all have the same value of X. The score of the winner needs to be updated, so announceWinner() is called to award the winning player.

If the game ends in a tie, there is no winner for that round. To check for tie games, we use a counter that increments by one every time a move is made on the board.

// Below the for loop in checkForWinner()
// Check if the game ends in a draw
this.counter++;
// The board is filled up and there is no winner
if(this.counter === 9){
  this.gameOver = true;
  this.newRound(null);
}
Enter fullscreen mode Exit fullscreen mode

If the counter reaches 9, then the game ends in a draw because the player did not make a winning move in the last square of the board. When this happens, the method newRound() is called with a null argument since there is no winner.

Before we go to this method, let's go back to announceWinner().

// Update score for the winner
announceWinner = (winner) => {
  let pieces = {
    'X': this.state.xScore,
    'O': this.state.oScore
  }

  if(winner === 'X'){
    pieces['X'] += 1;
    this.setState({
      xScore: pieces['X']
    });
  }
  else{
    pieces['O'] += 1;
    this.setState({
      oScore: pieces['O']
    });
  }
  // End the game once there is a winner
  this.gameOver = true;
  this.newRound(winner);    
}
Enter fullscreen mode Exit fullscreen mode

The parameter of this method is winner, which is the player that won the game. We check if the winner is 'X' or 'O' and increment the winner's score by one point. Since the game is over, the variable gameOver is set to true and the method newRound() is called.

Start a New Round

Player X has the option to play another round or to end the game and go back to the lobby.

Endgame modal for Player O

The other player has told to wait until Player X decides what to do.

Endgame modal for Player X

Once the Player X decides what to do, a message is published to the game channel to let the other player know. The UI is then updated.

newRound = (winner) => {
  // Announce the winner or announce a tie game
  let title = (winner === null) ? 'Tie game!' : `Player ${winner} won!`;
  // Show this to Player O
  if((this.props.isRoomCreator === false) && this.gameOver){
    Swal.fire({  
      position: 'top',
      allowOutsideClick: false,
      title: title,
      text: 'Waiting for a new round...',
      confirmButtonColor: 'rgb(208,33,41)',
      width: 275,
      customClass: {
          heightAuto: false,
          title: 'title-class',
          popup: 'popup-class',
          confirmButton: 'button-class',
      } ,
    });
    this.turn = 'X'; // Set turn to X so Player O can't make a move 
  } 

  // Show this to Player X
  else if(this.props.isRoomCreator && this.gameOver){
    Swal.fire({      
      position: 'top',
      allowOutsideClick: false,
      title: title,
      text: 'Continue Playing?',
      showCancelButton: true,
      confirmButtonColor: 'rgb(208,33,41)',
      cancelButtonColor: '#aaa',
      cancelButtonText: 'Nope',
      confirmButtonText: 'Yea!',
      width: 275,
      customClass: {
          heightAuto: false,
          title: 'title-class',
          popup: 'popup-class',
          confirmButton: 'button-class',
          cancelButton: 'button-class'
      } ,
    }).then((result) => {
      // Start a new round
      if (result.value) {
        this.props.pubnub.publish({
          message: {
            reset: true
          },
          channel: this.props.gameChannel
        });
      }

      else{
        // End the game
        this.props.pubnub.publish({
          message: {
            endGame: true
          },
          channel: this.props.gameChannel
        });
      }
    })      
  }
 }
Enter fullscreen mode Exit fullscreen mode

If the message is reset, then all the state values and variables, except the score for the players, are reset to their initial values. Any modals that are still open are closed and a new round starts for both players.

For the message endGame, all the modals are closed and the method endGame() is called. This method is in App.js.

// Reset everything
endGame = () => {
  this.setState({
    piece: '',
    isPlaying: false,
    isRoomCreator: false,
    isDisabled: false,
    myTurn: false,
  });

  this.lobbyChannel = null;
  this.gameChannel = null;
  this.roomId = null;  

  this.pubnub.unsubscribe({
    channels : [this.lobbyChannel, this.gameChannel]
  });
}
Enter fullscreen mode Exit fullscreen mode

All the state values and variables are reset to their initial values. The channel names are reset to null because a new name is generated every time a player creates a room. Since the channel names won't be useful anymore, the players unsubscribe from both the lobby and the game channel. The value of isPlaying is reset to false, so the game component will be replaced with the lobby component.

The last method to include in App.js is componentWillUnmount(), which unsubscribes the players from both channels.

componentWillUnmount() {
  this.pubnub.unsubscribe({
    channels : [this.lobbyChannel, this.gameChannel]
  });
}
Enter fullscreen mode Exit fullscreen mode

This is all we need to do for the game to work! You can get the CSS file for the game in the repo. Now, let's get the game up and running.

Run the Game

There are a couple of small steps we need to do before running the game. First, we need to enable the PubNub Presence feature because we use it to get the number of people in the channel (we used withPresence when subscribing to the lobby channel). Go to the PubNub Admin Dashboard and click on your application. Click on Keyset and scroll down to Application add-ons. Toggle the Presence switch to on. Keep the default values the same.

Enable presence in PubNub Admin Dashboard

To install the three dependencies used in the app and to run the app, you can run the script dependencies.sh that's in the root directory of the app.

# dependencies.sh
npm install --save pubnub pubnub-react
npm install --save shortid
npm install --save sweetalert2

npm start
Enter fullscreen mode Exit fullscreen mode

In the terminal, go to the app's root directory and type the following command to make the script executable:

chmod +x dependencies.sh
Enter fullscreen mode Exit fullscreen mode

Run the script with this command:

./dependencies.sh
Enter fullscreen mode Exit fullscreen mode

The app will open in http://localhost:3000 with the lobby component displaying.

Run the React app locally

Open another tab, or preferably window, and copy and paste http://localhost:3000. In one window, create a channel by clicking the 'Create' button. A modal will pop up displaying the room id. Copy and paste that id. Go to the other window and click the 'Join' button. When the modal pops up, type the room id in the input field and press the 'Okay' button.

Create and join the channel

Once the players are connected, the game will start. The window you used to create the channel makes the first move. Press any square on the board and see as the piece X is displayed on the board in realtime for both windows. If you try to press another square in the same board, nothing will happen because it's no longer your turn to make a move. In the other window, press any square on the board and the piece O is placed in the square.

Place the piece on the board

Keep on playing until there is a winner or a tie. A modal is then displayed announcing the winner of the round, or announcing that the game ended in a tie. In the same modal, Player X will have to decide whether to continue playing or to exit the game. The modal for Player O will tell them to wait for a new round.

End of game modals

Everything, except the score, is reset if Player X continues the game. Otherwise, both players are taken back to the lobby where they can create or join new channels. Check out this video for a game demo.

Create a Native Mobile Version

Now that you've got your game working beautifully in web browsers, let's take it mobile! Check out how to build a Multiplayer Tic Tac Toe Game in React Native for Android and iOS. And if you want to build more realtime games and want to know how PubNub can help you with that, check out the multiplayer gaming tutorials.

Top comments (0)