Developer Relations Engineer, PubNub
IN THIS ARTICLE
    GitHubGitHub

    GitHub Repo

    GitHub Repo

    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 3×3 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 have to focus on is 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!

    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. 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

    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:

    # dependencies.sh
    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
    

    Make the script executable with the command:

    chmod +x dependencies.sh

    Run the script with:

    ./dependencies.sh

    Now that your machine is set up, sign up for a free PubNub account to create your Pub/Sub API keys. Sign up and log in using the form below:

    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);

    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';

    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
      }
     }

    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']
       });
     }
    

    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>
      );
    }
    

    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)'
      },
    });
    

    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>
        );
      }
    }
    

    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',
      },
    });
    

    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});
    }

    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');
      }
    }

    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
          });
       ...

    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 }
    );

    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

    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
          })
        }
       ...
      });
    }
    

    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',
          );
        }  
      }
    }
    

    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)
      });
    }

    Since we don’t want more than two people to be in the same game channel, 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,
          });  
        }
      });
    }
    

    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]
      });
    }

    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!

    Have suggestions or questions about the content of this post? Reach out at devrel@pubnub.com.

    Try PubNub today!

    Build realtime applications that perform reliably and securely, at global scale.
    Try Our APIs
    Try PubNub today!
    More From PubNub