DEV Community

Cover image for Multiplayer Tic Tac Toe Game in React Native for iOS and Android: Lobby and Joining
Oscar Castro for PubNub

Posted on • Originally published at pubnub.com

Multiplayer Tic Tac Toe Game in React Native for iOS and Android: Lobby and Joining

Tic tac toe is a classic paper-and-pencil game that we're all too familiar with. The rudimentary rules of tic tac toe are simple: two players, X and O, take turns placing their pieces in a square on a 3x3 table. A winner is declared when one of the two players places three of their pieces in a horizontal, vertical or diagonal row.

In this tutorial series, we will develop the classic tic tac toe game in React Native, and allow two players to play against one another in realtime. In this part (Part One), we'll set up the lobby where players will be able to enter their username, then create or join a room to play. In Part Two, we'll implement and test the game.

Multiplayer Gaming and PubNub

Our aim is to provide a connected shared experience for players, where they can play with their friends anytime, anywhere around the world. To do so, we'll use PubNub to power our game's realtime infrastructure, so we'll just focus on developing a great experience for the players.

PubNub provides a secure, scalable and reliable realtime infrastructure to power any application through its global Data Stream Network. With over 70+ SDKs supporting most of the programming languages, PubNub makes it easy to send and receive messages on any device in under 100 milliseconds.

We use the PubNub React SDK to connect two players to a game channel where they will play against each other. Each move the player makes will be published to the channel, as a JSON payload, so the other player's table updates with the current move. By updating the table in realtime for each move, players will feel as if they were playing next to each other!

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

Tutorial Overview

Our app will work on both Android and iOS (the beauty of React Native). This is how it will look once we're finished:

Android/iOS screenshot

We add a lobby where players can join or create a room. If a player creates a room, they become the room creator and waits for another player to join their room.

Create room channel

If another player wants to join that same room, they enter the room name in the alert prompt. That player becomes the opponent. 

Join room channel

If the opponent tries to join a room that already has two people, they will not be able to join. But, if the room only has one player, the opponent will be able to join the room and the game will start for both players. Once the game starts, the tic tac toe board is displayed, along with the initialized score of the players.

Start of a new game

If the game ends in a draw, then neither player gets a point. But if there's a winner, the winner's score is updated. The room creator gets an alert asking them if they want to play another round or exit the game. If the room creator continues the game, the board will reset for the new round. If the room creator decides to exit the game, both players will return to the lobby.

Exit game and go to the lobby

Before we can start implementing the game, there are a few requirements you need to take care of.

Setting up the App

If you don’t already have React Native set up on your machine, then follow the Get Started instructions. Make sure you click on the second tab, React Native CLI Quickstart, and choose your Target OS. Follow the rest of the instructions in the documentation to install the dependencies.

In your terminal, go to the directory you want to save your project in and type the following to create a new application:

react-native init ReactNativeTicTacToe
Enter fullscreen mode Exit fullscreen mode

Next, you need to install five dependencies and link them to the app you just created. To make this easy, add the following script file, dependencies.sh, to your app's root directory:

npm install --save pubnub pubnub-react
npm install --save react-native-prompt-android
npm install --save react-native-spinkit
npm install --save shortid
npm install --save lodash
react-native link
Enter fullscreen mode Exit fullscreen mode

Make the script executable with the command:

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

Run the script with:

./dependencies.sh
Enter fullscreen mode Exit fullscreen mode

Now that your machine is set up, sign up for a free PubNub account. You can get your unique pub/sub keys in PubNub Admin Dashboard.

Now that we got the requirements out of the way, let's start coding!

Initializing the Project

Create a file named index.js in your app's root directory and copy the following code:

import {AppRegistry} from 'react-native';
import App from './App.js';
import {name as appName} from './app.json';

AppRegistry.registerComponent(appName, () => App);
Enter fullscreen mode Exit fullscreen mode

Next, create a new file named App.js. This is the main file for the game and it contains the components for the lobby and the table. To make App.js easy to follow along, we will break it into separate parts and go into detail for each part. Let’s first import the components and dependencies that will be used throughout the project.

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

import Game from './src/components/Game';
import Lobby from './src/components/Lobby';
import shortid  from 'shortid';
import Spinner from 'react-native-spinkit';
import prompt from 'react-native-prompt-android';
Enter fullscreen mode Exit fullscreen mode

Next, add the base constructor where you will insert your Pub/Sub keys to connect to PubNub, initialize the local state objects and initialize the variables.

export default class App extends Component {
  constructor(props) {
    super(props);
    this.pubnub = new PubNubReact({
      publishKey: "ENTER_YOUR_PUBLISH_KEY_HERE",
      subscribeKey: "ENTER_YOUR_SUBSCRIBE_KEY_HERE"
    });

    this.state = {
      username: '',
      piece: '', // Creator of the room is 'X' and the opponent is 'O'
      x_username: '', // Username for the room creator
      o_username: '', // Username for the opponent
      is_playing: false, // True when the opponent joins a room channel
      is_waiting: false, // True when the room creator waits for an opponent
      is_room_creator: false, 
      isDisabled: false // True when the 'Create' button is pressed
    };

    this.channel = null;
    this.pubnub.init(this); // Initialize PubNub
  }
 }
Enter fullscreen mode Exit fullscreen mode

We will go into more detail about each state object and variable later on. Also, make sure that you initialize PubNub after initializing the state.

Next, subscribe to the channel "gameLobby" when the component mounts.

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

The channel "gameLobby"  is the main channel that players subscribe and publish to when they are in the lobby. We will add more logic to this method later. For now, let's take a look at the render method.

render() {
  return (
    <View style={styles.container}>
      <View style={styles.title_container}>
        <Text style={styles.title}>RN Tic-Tac-Toe</Text>
      </View>

      <Spinner 
        style={styles.spinner} 
        isVisible={this.state.is_waiting} 
        size={75} 
        type={"Circle"} 
        color={'rgb(208,33,41)'}
      />

      {
        !this.state.is_playing &&
        <Lobby 
          username={this.state.name} 
          onChangeUsername={this.onChangeUsername}
          onPressCreateRoom={this.onPressCreateRoom} 
          onPressJoinRoom={this.onPressJoinRoom}
          isDisabled={this.state.isDisabled}
        />
      }

      {
          this.state.is_playing &&
          <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}
          />
        }
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

The Lobby component is shown first because this.state.is_playing is initialized to false. Once an opponent has joined a room channel that is waiting for another player, then this.state.is_playing is set to true and the Lobby component will be replaced by the Game component. The Spinner component is displayed to the room creator as long as the room creator is waiting for another player to join the game.

Spinner component

Make sure to add the styles at the end:

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 20,
    backgroundColor: 'white',
  },
  spinner: {
    flex: 1,
    alignSelf: 'center',
    marginTop: 20,
    marginBottom: 50
  },
  title_container: {
    flex: 1,
    marginTop: 18
  },
  title: {
    alignSelf: 'center',
    fontWeight: 'bold',
    fontSize: 30,
    color: 'rgb(208,33,41)'
  },
});
Enter fullscreen mode Exit fullscreen mode

Before we finish the rest of App.js, let's take a look at the Lobby component.

Implementing the Lobby Component

In the lobby, players can enter their username and create or join a room. We implement the logic, such as saving the username and calling the right method when a button is pressed, in App.js. All the methods in the lobby component are used to style the buttons, so we won't go into detail for those methods. The only three methods we will go into detail is onChangeUsername(), onPressCreateRoom() and onPressJoinRoom(), which are passed in as props from App.js.

In the app's root directory, create a new folder named src and within that folder, create another folder named components. Inside of components, create a new file named Lobby.js. Add the following to the new file:

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

export default class Lobby extends Component {
  constructor() {
    super();
    this.state = {
      pressCreateConfirm: false, // Set to true when the Create button is pressed
      pressJoinConfirm: false // Set to true when the Join button is pressed
     };
  }

  onHideUnderlayCreateButton = () => {
    this.setState({ pressCreateConfirm: false });
  }

  onShowUnderlayCreateButton = () => {
    this.setState({ pressCreateConfirm: true });
  }

  onHideUnderlayJoinButton = () => {
    this.setState({ pressJoinConfirm: false });
  }

  onShowUnderlayJoinButton = () => {
    this.setState({ pressJoinConfirm: true });
  }

  render() {
    return (        
      <View style={styles.content_container}>
        <View style={styles.input_container}>
          <TextInput
            style={styles.text_input}
            onChangeText={this.props.onChangeUsername}
            placeholder={" Enter your username"}
            maxLength={15}
            value={this.props.username}
          />
        </View>

        <View style={styles.container}>
          <View style={styles.buttonContainer}>
            <TouchableHighlight
              activeOpacity={1}
              underlayColor={'white'}
              style={
                this.state.pressCreateConfirm
                    ? styles.buttonPressed
                    : styles.buttonNotPressed
              }
                onHideUnderlay={this.onHideUnderlayCreateButton}
                onShowUnderlay={this.onShowUnderlayCreateButton}
                disabled={this.props.isDisabled}
                onPress={this.props.onPressCreateRoom}
              >
                <Text
                  style={
                  this.state.pressCreateConfirm
                      ? styles.cancelPressed
                      : styles.cancelNotPressed
                      }
                  >
                  Create
                </Text>
            </TouchableHighlight>
          </View>

          <View style={styles.buttonBorder}/>
            <View style={styles.buttonContainer}>
                <TouchableHighlight
                activeOpacity={1}
                underlayColor={'white'}
                style={
                  this.state.pressJoinConfirm
                      ? styles.buttonPressed
                      : styles.buttonNotPressed
                }
                  onHideUnderlay={this.onHideUnderlayJoinButton}
                  onShowUnderlay={this.onShowUnderlayJoinButton}
                  onPress={this.props.onPressJoinRoom}
                >
                  <Text
                    style={
                    this.state.pressJoinConfirm
                        ? styles.cancelPressed
                        : styles.cancelNotPressed
                        }
                    >
                    Join
                  </Text>
            </TouchableHighlight>
          </View>
        </View>
      </View>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

This may look like a lot, but really, all we are doing here is setting up the username field and the two buttons. Like previously mentioned, the only logic we do is to style the buttons. In this case, the buttons background color, border color, and text color change when the button is pressed and unpressed.

Button styling demo

Make sure to add the styles to the end of the file:

const styles = StyleSheet.create({
  content_container: {
    flex: 1,
  },
  input_container: {
    marginBottom: 20,
  },
  container: {
    flexDirection: 'row',
    paddingLeft: 11,
    paddingRight: 11
  },
  buttonContainer: {
    flex: 1,
    textAlign: 'center',
  },
  buttonBorder: {
    borderLeftWidth: 4,
    borderLeftColor: 'white'
  },
  text_input: {
    backgroundColor: '#FFF',
    height: 40,
    borderColor: '#CCC', 
    borderWidth: 1
  },
  buttonPressed:{
    borderColor: 'rgb(208,33,41)',
    borderWidth: 1,
    padding: 10,
    borderRadius: 5
  },
  buttonNotPressed: {
    backgroundColor: 'rgb(208,33,41)',
    borderColor: 'rgb(208,33,41)',
    borderWidth: 1,
    padding: 10,
    borderRadius: 5
  },
  cancelPressed:{
    color: 'rgb(208,33,41)',
    fontSize: 16,
    textAlign: 'center',
    alignItems: 'center',
  },
  cancelNotPressed: {
    color: 'white',
    fontSize: 16,
    textAlign: 'center',
    alignItems: 'center',
  },
});
Enter fullscreen mode Exit fullscreen mode

Saving the Username

Whenever the player types in the username field, onChangeUsername() is called. This method, along with the rest of the methods in this post, is found in App.js.

onChangeUsername = (username) => {
    this.setState({username});
}
Enter fullscreen mode Exit fullscreen mode

We save the username in the username state and limit the number of characters to 15 characters so the username won't be too long. You can increase or decrease this number if you want to.

Creating the Room Channel

Next, let's implement the method for onPressCreateRoom(), which is called when the user presses the Create button.

onPressCreateRoom = () => {
  if(this.state.username === ''){
    Alert.alert('Error','Please enter a username');
  }
Enter fullscreen mode Exit fullscreen mode

We first check that the username field is not empty; if so, we alert the player to enter a username. A random room ID is generated and truncated to 5 characters. The ID is then appended to 'tictactoe--', which will be used as the game channel that players will subscribe and publish to. Below the if statement, add the following code:

else{
      let roomId = shortid.generate(); // Random channel name generated
      let shorterRoomId = roomId.substring(0,5); // Truncate to a shorter string value
      roomId = shorterRoomId;
      this.channel = 'tictactoe--' + roomId;
      this.pubnub.subscribe({
        channels: [this.channel],
        withPresence: true
      });
   ...
Enter fullscreen mode Exit fullscreen mode

In order to obtain the number of people in the channel, we use Presence. We care about the channel occupancy because we only want two people to be connected to one channel at a time, as tic tac toe is a game for two people. That's why we set withPresence to true.

Once the room creator subscribes to the new channel, we alert them to share the room ID with their friends.

Share the room id

Add the following code to the else statement we used above:

// alert the room creator to share the room ID with their friend
Alert.alert(
  'Share this room ID with your friend',
  roomId,
  [
    {text: 'Done'},
  ],
  { cancelable: false }
);
Enter fullscreen mode Exit fullscreen mode

Since we want to change the state for certain objects, we use setState() to do so. Below the alert and still in the else statement, add the following:

this.setState({
      piece: 'X',
      is_room_creator: true,
      is_waiting: true,
      isDisabled: true
    });

    this.pubnub.publish({
      message: {
        is_room_creator: true,
        username: this.state.username
      },
      channel: 'gameLobby'
    });  
  } // Close the else statement
Enter fullscreen mode Exit fullscreen mode

After changing the state of four objects, the boolean is_room_creator and the room creator's username will be published to "gameLobby". The Spinner component will be displayed to the room creator while they wait for someone to join the game.

Getting back to componentDidMount(), we need to set up a listener to listen for certain messages that arrive in "gameLobby".

componentDidMount(){
  ...
  this.pubnub.getMessage('gameLobby', (msg) => {
    // Set username for Player X
    if(msg.message.is_room_creator){
      this.setState({
        x_username: msg.message.username
      })
    }
   ...
  });
}
Enter fullscreen mode Exit fullscreen mode

We want to get the room creator's username, so we do an if statement to check if the message arrived is msg.message.is_room_creator. If so, we change the state, x_username, to the room creators username.

Joining the Room Channel

We will now implement the last method, onPressJoinRoom()

onPressJoinRoom = () => {
  if(this.state.username === ''){
    Alert.alert('Error','Please enter a username');
  }
  else{
    // Check for platform
    if (Platform.OS === "android") {
      prompt(
        'Enter the room name',
        '',
        [
         {text: 'Cancel', onPress: () => console.log('Cancel Pressed'), style: 'cancel'},
         {text: 'OK', onPress: (value) =>  
         (value === '') ? '' : this.joinRoom(value)},
        ],
        {
            type: 'default',
            cancelable: false,
            defaultValue: '',
            placeholder: ''
          }
      );      
    }
    else{
      Alert.prompt(
        'Enter the room name',
        '',
        [
         {text: 'Cancel', onPress: () => console.log('Cancel Pressed'), style: 'cancel'},
         {text: 'OK', onPress: (value) =>  
         (value === '') ? '' : this.joinRoom(value)},
        ],
        'plain-text',
      );
    }  
  }
}
Enter fullscreen mode Exit fullscreen mode

Again, we make sure that the username field is not empty. If it's not empty, then a prompt is shown to the opponent to enter the room name.

Join room alert prompt

Since we want to take into consideration both platforms (iOS and Android), we check which platform the app is running on and use the appropriate prompt. For Android, we use the prompt dependency react-native-prompt-android. We do so since Alert.prompt() is only supported for iOS devices. Essentially, both prompts accomplish the same goal: call joinRoom(value), where value is the room name and cannot be an empty value, when OK is pressed.

joinRoom = (room_id) => {
  this.channel = 'tictactoe--' + room_id;

  // Check that the lobby is not full
  this.pubnub.hereNow({
    channels: [this.channel], 
  }).then((response) => { 
    // If totalOccupancy is less than or equal to 1, then the player can't join a room since it has not been created
    if(response.totalOccupancy <= 1){
      Alert.alert('Lobby is empty','Please create a room or wait for someone to create a room to join.');
    }
    // Room is available to join
    else if(response.totalOccupancy === 2){
      this.pubnub.subscribe({
        channels: [this.channel],
        withPresence: true
      });

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

      this.pubnub.publish({
        message: {
          readyToPlay: true, // Game can now start
          not_room_creator: true,
          username: this.state.username
        },
        channel: 'gameLobby'
      });
    } 
    // Room already has two players
    else{
      Alert.alert('Room full','Please enter another room name');
    }
  }).catch((error) => { 
      console.log(error)
  });
}
Enter fullscreen mode Exit fullscreen mode

We don't want more than two people to be in the same game channel, so we use the hereNow() function to check the total occupancy for the channel. If the total occupancy is less than 1, the player is trying to join a room that has not been created, or there is a typo in the room name.

If the total occupancy is 2, then there is a player in the channel, the room creator, and is waiting for another player to start the game.

If the total occupancy is greater than 2, then the player is trying to join a room with a game in progress, so an alert tells the player that the room is full and to join another room.

Once the opponent successfully subscribes to the game channel, a message is published with the opponent's username and readyToPlay set to true. Since the player is not the room creator, not_room_creator is set to true.

Finishing the Lobby Component

We will add the last logic for the listener in componentDidMount().

componentDidMount() {
  ...
  this.pubnub.getMessage('gameLobby', (msg) => {
    ...

    else if(msg.message.not_room_creator){
      this.pubnub.unsubscribe({
        channels : ['gameLobby']
      }); 
      // Start the game
      this.setState({
        o_username: msg.message.username,
        is_waiting: false,
        is_playing: true,
      });  
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Both players will unsubscribe from "gameLobby"  since they are subscribed to the game room channel. In setState(), we do three things: set the opponents username to o_username, set is_waiting to false so the Spinner component will disappear from the room creator's view, and set is_playing to true so the game between the two players can start.

The last method we need to include is componentWillUnmount().

componentWillUnmount() {
  this.pubnub.unsubscribe({
    channels : ['gameLobby', this.channel]
  });
}
Enter fullscreen mode Exit fullscreen mode

This method is called when the component is unmounted and destroyed. We unsubscribe from "gameLobby," if the user is still subscribed to that channel, and this.channel, the channel the player is subscribed to for the game.

Now that we have finished the logic for the lobby, we will work on setting up the table, adding the game logic and implementing realtime interactivity between the players. On to Part Two!

Top comments (0)