DEV Community

Cover image for Multiplayer Tic Tac Toe Game in React Native for iOS and Android: Player Turns and Deployment
Oscar Castro for PubNub

Posted on • Originally published at pubnub.com

Multiplayer Tic Tac Toe Game in React Native for iOS and Android: Player Turns and Deployment

Welcome to Part Two of our tutorial series on building a mobile multiplayer tic tac toe game with React Native and PubNub. In this section, we will work on the actual game itself and testing the game using two methods.

If you haven't already, check out and go through Part One before working on this section, as we initialized the project and set up the lobby.

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

Implementing the Game Component

From the project root directory, go to src/components and create a new file named Game.js. All the game logic will be inside of this file. Add the following code to the file:

import React, { Component } from 'react';
import {
  StyleSheet,
  Text,
  View,
  TouchableHighlight,
  Alert
} from 'react-native';

import range from 'lodash';
const _ = range;

export default class Game extends Component {

  constructor(props) {
    super(props);   

    // Winning combinations
    this.possible_combinations = [
      [0, 3, 6],
      [1, 4, 7],
      [0, 1, 2],
      [3, 4, 5],
      [2, 5, 8],
      [6, 7, 8],
      [0, 4, 8],
      [2, 4, 6]
    ];

    // Corresponds to the square number on the table
    this.ids = [
      [0, 1, 2],
      [3, 4, 5],
      [6, 7, 8]
    ];

    // For the 3x3 table
    this.rows = [
      _.range(3).fill(''),
      _.range(3).fill(''),
      _.range(3).fill(''),
    ];

    this.state = {
      moves: _.range(9).fill(''),
      x_score: 0,
      o_score: 0,
    }
    this.turn = 'X' // Changes every time a player makes a move
    this.game_over = false; // Set to true when the game is over
    this.count = 0; // used to check if the game ends in a draw
  }
}
Enter fullscreen mode Exit fullscreen mode

We do several things in the base constructor:

  1. First, we set up an array of possible combinations to win the game. If a player matches with any of the combinations, that player is the winner of the game.
  2. Second, we set up another array named ids that labels every square of the table with a unique id to check if a square has been occupied or not.
  3. Then, we set up rows which contain three empty arrays with each size of three.
  4. After, for the state objects, we initialize moves to be an array with empty string values and initialize the score for both players to 0. The array moves will be used to check if there is a winner, as we will see later on.
  5. Finally, we add three variables, turn, game_over and count that will be useful throughout the game.

Setting up the UI

Let's set up the UI for the component, which includes the table and the current score and username for each player. Also, include the styles.

render() {
  return (
    <View style={styles.table_container}>
      <View style={styles.table}>
        {this.generateRows()}
      </View>

      <View style={styles.scores_container}>
        <View style={styles.score}>
          <Text style={styles.user_score}>{this.state.x_score}</Text>
          <Text style={styles.username}>{this.props.x_username} (X)</Text>
        </View>

        <View style={styles.score}>
          <Text style={styles.user_score}>{this.state.o_score}</Text>
          <Text style={styles.username}>{this.props.o_username} (O)</Text>
        </View>
      </View>               
    </View>
  );
 }
}

const styles = StyleSheet.create({
  table_container: {
    flex: 9
  },
  table: {
    flex: 7,
    flexDirection: 'column',
    color: 'black'
  },
  row: {
    flex: 1,
    flexDirection: 'row',
    borderBottomWidth: 1,
  },
  block: {
    flex: 1,
    borderRightWidth: 1,
    borderColor: '#000',
    alignItems: 'center',
    justifyContent: 'center'
  },
  block_text: {
    fontSize: 30,
    fontWeight: 'bold',
    color: 'black'
  },
  scores_container: {
    flex: 2,
    flexDirection: 'row',
    alignItems: 'center'
  },
  score: {
    flex: 1,
    alignItems: 'center',
  },
  user_score: {
    fontSize: 25,
    fontWeight: 'bold',
    color: 'black'
  },
  username: {
    fontSize: 20,
    color: 'black'
  }
});

Enter fullscreen mode Exit fullscreen mode

Notice that for the username, we use this.props since we get the value from App.js. As a refresher, here are the values that we'll be using from App.js.

<Game 
  pubnub={this.pubnub}
  channel={this.channel} 
  username={this.state.username} 
  piece={this.state.piece}
  x_username={this.state.x_username}
  o_username={this.state.o_username}
  is_room_creator={this.state.is_room_creator}
  endGame={this.endGame}
/>
Enter fullscreen mode Exit fullscreen mode

To build the tic tac toe table, we call the method generateRows(), which calls generateBlocks(). These methods are responsible for creating the table.

generateRows = () => {
  return this.rows.map((row, col_index) => {
    return (
      <View style={styles.row} key={col_index}>
        {this.generateBlocks(row, col_index)}
      </View>
    );
  });
}

generateBlocks = (row, row_index) => {
  return row.map((block, col_index) => {
    let id = this.ids[row_index][col_index];
    return (
      <TouchableHighlight 
        key={col_index} 
        onPress={
          this.onMakeMove.bind(this, row_index, col_index)
        } 
        underlayColor={"#CCC"} 
        style={styles.block}>

        <Text style={styles.block_text}>
          {
            this.state.moves[id]
          }
        </Text>

      </TouchableHighlight> 
    );
  });
}
Enter fullscreen mode Exit fullscreen mode

We use TouchableHightlight so players can touch any square on the table. In order for their corresponding piece to be placed on that square, set the block text to be the value of this.state.moves[id], which will contain the correct piece for the player that made the move. This will be explained more below.

Adding the Logic

Make a call to onMakeMove() with row_index, the row that the piece was placed on, and col_index, the column the piece was placed on, as the method arguments.

onMakeMove(row_index, col_index) {
  let moves = this.state.moves;
  let id = this.ids[row_index][col_index];

  // Check that the square is empty
  if(!moves[id] && (this.turn === this.props.piece)){ 
    moves[id] = this.props.piece;

    this.setState({
      moves
    });

    // Change the turn so the next player can make a move
    this.turn = (this.turn === 'X') ? 'O' : 'X';

    // Publish the data to the game channel
    this.props.pubnub.publish({
      message: {
        row: row_index,
        col: col_index,
        piece: this.props.piece,
        is_room_creator: this.props.is_room_creator,
        turn: this.turn
      },
      channel: this.props.channel
    });
    this.updateScores.call(this, moves);
  }
}
Enter fullscreen mode Exit fullscreen mode

We get the integer id of the square pressed by getting the value of this.ids[row_index][col_index]. The if statement checks if the square the player touched is empty and if it's that player's current turn to place their piece. We ignore the touch if these two conditions are not met. If the conditions are met, we add the piece to the array moves with id as the index.

For example, if the room creator makes a move on row 0 column 2 on the table, then this.ids[0][2] returns the integer 2.

this.ids = [
  [0, 1, 2],
  [3, 4, 5],
  [6, 7, 8]
];
Enter fullscreen mode Exit fullscreen mode

The piece X is added to the array moves on the second index: ["", "", "X", "", "", "", "", "", ""]. Later in updateScores(), we will see how moves is used to check for a winner.

Getting back to onMakeMove(), after we change the state for moves, we update turns so the next player can make their move. We publish the data to the game channel, such as the piece that moved and the position it was placed at. The other player's table updates with the current data. Finally, make a call to updateScores() to check if there is a winner or if the game ended in a draw.

Before we get to updateScores(), implement the channel listener in componentDidMount() to listen to incoming messages from the channel.

componentDidMount() {
  // Listen for messages in the channel
  this.props.pubnub.getMessage(this.props.channel, (msg) => {
    // Add the other player's move to the table
    if(msg.message.turn === this.props.piece){
      let moves = this.state.moves;
      let id = this.ids[msg.message.row][msg.message.col];

      moves[id] = msg.message.piece;

      this.setState({
        moves
      });

      this.turn = msg.message.turn;
      this.updateScores.call(this, moves);
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Both players will receive this message since both are subscribed to the same channel. When the message is received, we do several of the same steps we did for onMakeMove(), in particular, update the table and check if there's a winner. But, we don't want the player that made the move to do the steps over again. So, we do an if statement to make sure that only the opposite player performs the steps. We do so by checking if turn (which is either X or O) matches the player's piece.

Let's now implement the method updateScores().

updateScores = (moves) => {
 // Iterate the double array possible_combinations to check if there is a winner
 for (let i = 0; i < this.possible_combinations.length; i++) {
    const [a, b, c] = this.possible_combinations[i];
    if (moves[a] && moves[a] === moves[b] && moves[a] === moves[c]) {
      this.determineWinner(moves[a]);   
      break;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

To determine if there is a winner, we have to iterate possible_combinations to check if any of the combinations are present in the table. This is where the array moves_comes in handy. We use [a,b,c]_ to get each array of possible_combinations. Then check if that matches any pattern in moves. To make this more clear, let's follow an example.

Let's say the room creator makes a winning move on row 2 column 2, with an id of 8, on the table. The table will look like the image below:

Winning move example

As you can see, the winning moves are in positions 2, 5, and 8, according to their id's. The array moves is now:  ["O", "", "X", "", "O", "X", "", "", "X"]. In updateScores(), we iterate through every possible winning combination.

For this example, the winning combination is [2,5,8]. So when [a,b,c] has the values of [2,5,8], the if statement in the for loop with be true since  [2,5,8] all have the same value of X. A call is made to determineWinner() to update the score of the winning player, which in this case, is the room creator. Before we get to that method, let's finish the rest of updateScores().

If no winner is found and the game ends in a draw, then neither player gets a point. To check if there is a draw, add the following code below the above for loop.

this.count++;
// Check if the game ends in a draw
if(this.count === 9){
  this.game_over = true;
  this.newGame();   
}
Enter fullscreen mode Exit fullscreen mode

Every time a square is pressed on the table, updateScores() is called. If no winner is found, then count increments by one. If the count is equal to 9, the game ends in a draw. This occurs when a player makes a final move on the last square of the table that is not a winning move, so count is incremented from 8 to 9. The method newGame() is then called.

We will get back to newGame() in a bit. Let's first implement determineWinner().

determineWinner = (winner) => {
  var pieces = {
    'X': this.state.x_score,
    'O': this.state.o_score
  }

  // Update score for the winner
  if(winner === 'X'){
    pieces['X'] += 1;
    this.setState({
      x_score: pieces['X']
    });
  }
  else{
    pieces['O'] += 1;
    this.setState({
      o_score: pieces['O']
    });
  }
  // End the game once there is a winner
  this.game_over = true;
  this.newGame();   
}
Enter fullscreen mode Exit fullscreen mode

We check if the winner is the room creator or the opponent and we increment the winner's current score by 1 point. Once the score has been updated, we set game_over to true and call the method newGame().

Game Over

new_game = () => {
  // Show this alert if the player is not the room creator
  if((this.props.is_room_creator === false) && this.game_over){
    Alert.alert('Game Over','Waiting for rematch...');
    this.turn = 'X'; // Set turn to X so opponent can't make a move
  }

  // Show this alert to the room creator
  else if(this.props.is_room_creator && this.game_over){
    Alert.alert(
      "Game Over!", 
      "Do you want to play another round?",
      [
        {
          text: "Nah", 
          onPress: () => {
            this.props.pubnub.publish({
              message: {
                gameOver: true
              },
              channel: this.props.channel
            });             
          },
          style: 'cancel'
        },
        {
          text: 'Yea', 
          onPress: () => {
            this.props.pubnub.publish({
              message: {
                reset: true
              },
              channel: this.props.channel
            });
          }  
        },
      ],
      { cancelable: false } 
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

We show two different alerts to the room creator and the opponent. The alert for the room creator has a message asking them if they want to play another round or exit the game.

Game over alert for the room creator

For the opponent, we show them an alert with a message telling them to wait for a new round, which will be decided by the room creator.

Game over alert for the opponent

If the room creator decides to end the game, then the message gameOver is published to the channel; else the message restart is published to the channel. We take care of these messages by adding some more logic inside of getMessage()  in componentDidMount().

componentDidMount() {
  // Listen for messages in the channel
  this.props.pubnub.getMessage(this.props.channel, (msg) => {
    ...

    if(msg.message.reset){
      this.setState({
        moves: _.range(9).fill('')
      });
      this.turn = 'X';
      this.game_over = false;
    }

    if(msg.message.gameOver){
      this.props.pubnub.unsubscribe({
        channels : [this.props.channel]
      });
      this.props.endGame();
    }
  }); 
}
Enter fullscreen mode Exit fullscreen mode

If the room creator wants to play another round, then we reset the table as it was at the start of the game. We do so by setting the array moves to its original state. We reset turn to be X again and reset game_over to false.

But if the room creator decides to end the game, then both players unsubscribe from the current channel and a call to the method endGame() is made. This method will end the game and take the players back to the lobby since is_playing is reset to false. The method endGame() is a prop from App.js, so we have to go back to App.js to implement it.

// In App.js
endGame = () => {
  // Reset the state values
  this.setState({
    username: '',
    rival_username: '',
    is_playing: false,
    is_waiting: false,
    isDisabled: false
  });

  // Subscribe to gameLobby again on a new game
  this.channel = null;
  this.pubnub.subscribe({
    channels: ['gameLobby'],
    withPresence: true
  });
}
Enter fullscreen mode Exit fullscreen mode

We reset all the state values back to its original state and reset the value of channel to null since there will be a new room_id when the Create button is pressed again. Finally, subscribe again to the "gameLobby"  channel so the players can continue playing tic tac toe if they choose to.

That is all we need for our React Native tic tac toe game to work! Now, it's time to test the app and play around with it.

Testing the App

Before running the app, we need to enable the Presence feature to detect the number of people in the channel. To turn it on, 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 

To run the app, make sure you are in the project folder and enter the following command in the terminal:

react-native run-ios
Enter fullscreen mode Exit fullscreen mode

This command will open the simulator and run the app. You can also run the app on the Android emulator by replacing run-ios with run-android (Note: Make sure you open an emulator first before running the command). Once the simulator opens, the lobby UI will be displayed.

iOS lobby

We can test the app by either using PubNub's Debug Console or by running the React app version of the tic tac toe game. The React app is already connected to the React Native app and the necessary logic is taken care of. All you need to do is insert the same Pub/Sub keys from the React Native app.

This section will be broken into two parts to go into detail on how to test the app using the above two methods. For each section, there will be a video that goes over what we did in that section. If you prefer to watch the video instead of reading, go to the end of the section.

Play the Game using the Debug Console

In this section, we will use the debug console to create a channel and the simulator to join that channel. Make sure you have the app opened in the simulator.

To get into the debug console, go the PubNub Admin Dashboard and click on Debug Console that's on the left sidebar.

debug console

On the top bar, select the app and the keys you are using for this project.

Select app and key

You should now see a panel where you can enter the channel name. Change the default channel to "gameLobby"  and add the client.

Debug Channel Panel

Once the client has been created, replace the default message with:

{"is_room_creator":true, "username":"Player X"}
Enter fullscreen mode Exit fullscreen mode

You can put any username, but for this example, we will be using Player X for the room creator and Player O for the opponent. Click the Send button and you should see the message in the console. Here is a screenshot of what the debug console should look like:

Create a room in the debug console

Next, we have to create a new channel for the game. Go to the panel next to the current panel we used and change the default channel to "tictactoe--gameChannel" (Note: While we chose a fixed channel name for the game, in the app if the player presses the Create button, a random channel name will be generated).

Add the client to connect to the new channel. Go back to the previous panel where we sent the first message and unsubscribe from that channel as we won't be using it anymore.

Connect to the game channel in the debug console

Now, go to the simulator and for the username type Player O. Click the "Join" button and enter the channel name "gameChannel" (Note: "tictactoe--" will be appended to the channel name, so you don't have to include that).

Once you press OK, the lobby component will be replaced with the game component and the table UI will be displayed. Since we used the simulator to join the channel, you won't be able to do anything on the table since the room creator makes the first move. So, on the debug console, replace the default message with:

{"row":0, "col":1, "piece":"X", "is_room_creator":true, "turn":"O"}
Enter fullscreen mode Exit fullscreen mode

Make sure to capitalize both "X" and "O". Send the above message and you should see an "X" on the simulator's table. Here is a screenshot of the debug console and the simulator:

Gameplay for the debug console and the simulator

Now you are able to make a move in the simulator. To continue playing, only change the values of row and col in the debug console, when it's the room creator's turn, to place "X" in different parts of the table. If a player has won that round, the score for the winning player will update in the simulator. An alert message in the simulator will notify the opponent to wait for a new round.

End of the game alert for the opponent

If you want to play another round, type the following message in the debug console:

{"reset":true}
Enter fullscreen mode Exit fullscreen mode

The above message will reset the table in the simulator for a new round. To end the game, type the following message instead:

{"gameOver":true}
Enter fullscreen mode Exit fullscreen mode

Ending the game will send the opponent back to the lobby.

Check out the video below to review what we did for this section.

Gameplay for the debug console and the simulator

You might have noticed that there are a lot of manual steps when using the debug console. In the app itself, a lot of the manual steps we did are automated, like unsubscribing from "gameLobby" or subscribing to a new channel for the game. We used the debug console because there might be some complications when running two simulators or emulators on your machine. The debug console is a better approach to simulate gameplay of the app.

Instead of the debug console, you could also use the React version of the app to simulate gameplay. By using the React app, you don't have to worry above manually running commands since everything will be automated, as it should be in the app.

Play the Game using the React App

We will use the simulator to create a channel and the React app to join that channel (Note: The React app is currently set up to only join channels and not create them). You can clone the React app from this repo. Once you open the project, go to the file Game.js and in the constructor, add the same Pub/Sub keys you used for the React Native app. That's all you have to edit for the React project.

Replace pub/sub keys

Run the following command in the terminal to install the dependencies:

npm install
Enter fullscreen mode Exit fullscreen mode

To run the app, type the following command in the terminal:

npm start
Enter fullscreen mode Exit fullscreen mode

The app will open in http://localhost:3000 with an empty table and two input fields.

React app empty board

Go to the simulator and in the lobby, type Player X for the username and press the "Create" button to create a new room id. Go back to the browser and for the username field, type Player O. In the room name field, type the room id that was created in the simulator.

join room React app

Once both fields are filled in, press the Submit button and the game will start for both players. Since the simulator is the room creator, press any square on the table to place an X. You should see the X in both the simulator and in the browser. Try pressing another square in the simulator's table and you will notice that nothing happens as it's the opponent's turn to make a move. In the browser, press any square to place an O.

React/ React Native gameplay

Keep playing until someone wins or the game ends in a draw. If there is a winner, the winner will be announced in the browser along with the winning squares background color turned to green. In the simulator, the score for the winning player will update and an alert will ask the room creator if they want to play another round or exit the game.

game over react app

If the room creator decides to play another round, both the simulator's and the browser's table will reset for the new round. If the room creator decides to exit the game, the room creator will be taken to the lobby while the browser resets the table. You can create a new channel in the simulator and in the browser you can join that new channel.

Check out the video below to review what we did for this section.

React/ React Native gameplay

What's Next

If you want to see more examples on how to use PubNub for a React Native project, check out all our React Native tutorials. Or if you want to see the different ways that PubNub is used to power multiplayer games, check out our multiplayer gaming tutorials.

Top comments (0)