Creating a realtime game with React Native

  • Wern Ancheta
May 11th, 2018
You will need an Android emulator for testing, and the Android SDK. You should have a basic knowledge of React Native.

In this tutorial, we’ll be implementing the classic game Tic-Tac-Toe with React Native and Pusher. This tutorial assumes that you already have a basic knowledge of React Native.

Prerequisites

  • Pusher Account - a Pusher account is needed to create a Pusher instance that we will be using in this tutorial. If you don’t already have an account, you can sign up here. After creating an account, you can go ahead and create a new Pusher app.
  • Android SDK - we’ll be specifically deploying the app as an Android App so you need the Android SDK to run the app on an Android device or emulator.
  • A Machine that’s ready for React Native Development - if you don’t already have your machine set up for React Native, you can follow the Getting Started Guide on the official docs. Be sure to follow the instructions in the "Building Projects with Native Code” tab.
  • Genymotion or Android Emulator - this is optional, as you can always use a real device for testing.

What we’re going to build

Here’s what the app is going to look like by default:

When a user has chosen to create a room, the room ID will be generated by the app. This room ID should be entered by another user so the game can begin. Throughout this tutorial, I’ll be referring to the user other than the current user as the “rival”.

Once someone has joined the room, the Tic-Tac-Toe board will be shown. At this point, any of the players can start the first move.

Once the last move is used to fill the board, an alert will be shown to the room creator asking whether they want to restart the game (empty the board and start over) or end the game. If the room creator has chosen to end the game, the app state will reset and the default screen will be shown.

You can find the full source code of the app in its Github repo.

Coding the server component

The server component authenticates the requests that will come from the app. This is needed because we’ll be using client events to send data from client to client. The server component authenticates the request whenever an app tries to connect using the API key of the app you created earlier. This way you can verify if the request really came from your app.

Start by initializing a package.json file:

    npm init

Install the dependencies:

    npm install --save express body-parser pusher dotenv

Create a .env file in the same folder as the package.json file and add your Pusher app details:

    APP_ID="YOUR PUSHER APP ID"
    APP_KEY="YOUR PUSHER APP KEY"
    APP_SECRET="YOUR PUSHER APP SECRET"
    APP_CLUSTER="YOUR PUSHER APP CLUSTER"

Create a server.js file and add the following code:

    var express = require('express'); // for running a server
    var bodyParser = require('body-parser'); // for processing JSON submitted in the request body
    var Pusher = require('pusher'); // for connecting to Pusher

    require('dotenv').config();

    var app = express();
    app.use(bodyParser.json()); // for parsing JSON strings passed in the request body
    app.use(bodyParser.urlencoded({ extended: false })); // for parsing URL encoded request body

    var pusher = new Pusher({ // connect to pusher
      appId: process.env.APP_ID, // load the Pusher app settings from the .env file
      key: process.env.APP_KEY, 
      secret:  process.env.APP_SECRET,
      cluster: process.env.APP_CLUSTER, 
    });

    app.get('/', function(req, res){ // for testing if the server is running
      res.send('everything is good...');
    });

    app.post('/pusher/auth', function(req, res) { // authenticate user's who's trying to connect
      var socketId = req.body.socket_id;
      var channel = req.body.channel_name;
      var auth = pusher.authenticate(socketId, channel);
      res.send(auth);
    });

    var port = process.env.PORT || 5000;
    app.listen(port);

Here’s what the code above does:

  • Line 1 imports express, a web framework for Node.js which allows us to create a server and respond to specific routes.
  • Line 2 imports body-parser, a middleware for parsing the request body so that the data passed in the request body can be accessed like an object. For example, in the /pusher/auth route, this allows us to do the following to access the socket ID from the request body: req.body.socket_id.
  • Line 3 imports the pusher package. This allows us to communicate with the Pusher app you created earlier in order to authenticate the user (line 25).
  • Line 5 imports the dotenv package which loads up the config in the .env file you created earlier. You can see them being accessed as environment variables on lines 12 to 15.
  • Lines 7 to 9 tells Express to use body-parser to create two different middleware entries, one for parsing JSON strings and the other for parsing URL encoded strings. The extended option is set to false because we’re not really expecting rich objects and arrays to be included in the request body. Instead, we’re only expecting plain JSON strings to be passed in the request body.
  • Lines 18 to 20 are for testing if the server is running, you can access http://localhost:5000 from your browser. If you see the string output “everything is good…” then it works.
  • Lines 22 to 27 are for processing the authentication requests coming from the app. The authentication request is sent every time a client connects to Pusher from the app that we’ll be creating. Note that the code for authenticating users doesn’t really have any security measures in place. This means anyone can just use your Pusher app if they happen to get a hold of your Pusher app credentials.

Coding the app

Now we’re ready to add the code for the app. First bootstrap a new React Native app:

    react-native init RNPusherTicTacToe

Once it’s done, you can now install the dependencies:

    npm install --save lodash.range pusher-js react-native-prompt shortid react-native-spinkit@latest

Out of these dependencies, React Native Spinkit has some assets which need to be linked, so execute the following command to link those:

    react-native link

Here’s how the packages you’ve just installed are used in the app:

  • pusher-js - for using Pusher. This allows us to send messages to channels, and receive messages from channels in real-time.
  • react-native-prompt - for showing a prompt box, used for getting user input.
  • react-native-spinkit - for showing a spinner while waiting for another player to join the room.
  • lodash.range - for generating arrays which has a specific number of items.
  • shortid - for generating unique IDs when creating a room.

Now we’re ready to add the code for the app. First, open the index.android.js file and replace the default code with the following:

    import React, { Component } from 'react';
    import {
      AppRegistry
    } from 'react-native';

    import Main from './components/Main';

    export default class RNPusherTicTacToe extends Component {

      render() {
        return (
          <Main />
        );
      }

    }

    AppRegistry.registerComponent('RNPusherTicTacToe', () => RNPusherTicTacToe);

Make sure that RNPusherTicTacToe matches the name you’ve given to the app when you created it with react-native init.

Next, create a components/Main.js file and add the following:

    import React, { Component } from 'react';

    import {
      StyleSheet,
      Text,
      View,
      Button,
      Alert
    } from 'react-native';

    // include the dependencies
    import Pusher from 'pusher-js/react-native';
    import shortid  from 'shortid';
    import Spinner from 'react-native-spinkit';

    // include the components
    import Header from './Header'; 
    import Home from './Home'; // the default screen
    import Board from './Board'; // the tic-tac-toe board and score UI

Inside the constructor, initialize the state and the functions that will be used throughout the component:

    export default class Main extends Component {

      constructor() {
        super();
        this.state = {
          username: '', // the name of the user
          piece: '', // the piece assigned to the user
          rival_username: '', // the name of the rival player
          is_playing: false, // whether the user is currently playing or not 
          show_prompt: false, // whether the prompt box for entering the room name is visible
          is_waiting: false, // whether the user is currently waiting for another player (rival) or not
          is_room_creator: false // whether the user is the room's creator
        }

        this.game_channel = null; // the Pusher channel where data regarding the game will be sent
        this.is_channel_binded = false; // whether a channel has already been binded or not

        this.onChangeUsername = this.onChangeUsername.bind(this); // executes when the value of the username text field changes
        this.onPressCreateRoom = this.onPressCreateRoom.bind(this); // executes when user creates a room
        this.onPressJoinRoom = this.onPressJoinRoom.bind(this); // executes when user taps on the join room button
        this.joinRoom = this.joinRoom.bind(this); // the function for joining a room
        this.onCancelJoinRoom = this.onCancelJoinRoom.bind(this); // executes when user cancels joining a room
        this.endGame = this.endGame.bind(this); // the function for ending the game

      }
    }

Before the component is mounted, connect to Pusher using the credentials you’ve been given when you created the Pusher app:

    componentWillMount() {
      this.pusher = new Pusher('YOUR PUSHER API KEY', {
        authEndpoint: 'YOUR AUTH ENDPOINT',
        cluster: 'YOUR PUSHER APP CLUSTER',
        encrypted: true
      });
    }

When the component is updated, we need to check whether the user is already waiting for a rival and that a Pusher channel has not been bound to any events yet. If that’s the case, we listen for the client-joined event. When this happens, update the state so that the UI shows the game board. If the user is the room creator, trigger the same event so that the rival (the one who joined the room) is informed that the game can already start.

    componentDidUpdate() {
      if(this.state.is_waiting && !this.is_channel_binded){

        this.game_channel.bind('client-joined', (data) => {
          this.setState({
            is_waiting: false,
            is_playing: true,
            rival_username: data.username
          });

          if(this.state.is_room_creator){
            // inform the one who joined the room that the game can begin
            this.game_channel.trigger('client-joined', {
              username: this.state.username // send the name of the room creator to the one who joined
            });
          }
        });

        this.is_channel_binded = true;
      }
    }

In the render method, the Home component is shown by default. It displays the UI for letting the user enter their name, and either join or create a new room. Once a rival joins a room, the game board will be shown. The Spinner component is used as the transition state between the two while waiting for a rival to join a room.

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

          <Spinner 
            style={styles.spinner} 
            isVisible={this.state.is_waiting} 
            size={75} 
            type={"WanderingCubes"} 
            color={"#549eff"}
          />

          {
            !this.state.is_playing && !this.state.is_waiting &&
            <Home 
              username={this.state.name} 
              onChangeUsername={this.onChangeUsername}
              onPressCreateRoom={this.onPressCreateRoom} 
              onPressJoinRoom={this.onPressJoinRoom}  
              show_prompt={this.state.show_prompt}
              onCancelJoinRoom={this.onCancelJoinRoom}
            />
          }

          {
            this.state.is_playing &&
            <Board 
              channel={this.game_channel} 
              username={this.state.username} 
              piece={this.state.piece}
              rival_username={this.state.rival_username}
              is_room_creator={this.state.is_room_creator}
              endGame={this.endGame}
            />
          }

        </View>
      );
    }

Here’s the function that’s executed when the text field for entering the user’s name changes:

    onChangeUsername(username) {
      this.setState({username});
    }

When a user taps on the Create Room button, generate a unique ID for the room and subscribe to a new Pusher channel using that ID. Here we’re using a private channel so that we can send messages directly from the app:

    onPressCreateRoom() {

      let room_id = shortid.generate(); // generate a unique ID for the room
      this.game_channel = this.pusher.subscribe('private-' + room_id); // subscribe to a channel

      // alert the user of the ID that the friend needs to enter 
      Alert.alert(
        'Share this room ID to your friend',
        room_id,
        [
          {text: 'Done'},
        ],
        { cancelable: false }
      );

      // show loading state while waiting for someone to join the room
      this.setState({
        piece: 'X', // room creator is always X
        is_waiting: true,
        is_room_creator: true
      });

    }

When a rival taps on the Join Room button, the prompt box is shown:

    onPressJoinRoom() {
      this.setState({
        show_prompt: true
      });
    }

Once the rival joins the room, the following function is executed. The room_id is provided by the prompt box so we simply use it to subscribe to the same channel as the room creator. This allows the two users to communicate directly using this channel. Note that the code below doesn’t handle if a third person happens to join the room. You can add the functionality to check for the number of users in the room if you want. That way the app will reject it if there are already two users in the room.

    joinRoom(room_id) {
      this.game_channel = this.pusher.subscribe('private-' + room_id);
      // inform the room creator that a rival has joined
      this.game_channel.trigger('client-joined', {
        username: this.state.username
      });

      this.setState({
        piece: 'O', // the one who joins the room is always O
        show_prompt: false,
        is_waiting: true // wait for the room creator to confirm
      });
    }

When the user cancels joining of a room, simply hide the prompt box:

    onCancelJoinRoom() {
      this.setState({
        show_prompt: false
      });
    }

When the room creator decides to end the game, the app is reset back to its default state:

    endGame() {
      // reset to the default state
      this.setState({
        username: '',
        piece: '',
        rival_username: '',
        is_playing: false,
        show_prompt: false,
        is_waiting: false,
        is_room_creator: false
      });
      // reset the game channel
      this.game_channel = null;
      this.is_channel_binded = false;
    }

Lastly, add the styles:

    const styles = StyleSheet.create({
      container: {
        flex: 1,
        padding: 20,
        backgroundColor: '#F5FCFF',
      },
      spinner: {
        flex: 1,
        alignSelf: 'center',
        marginTop: 20,
        marginBottom: 50
      }
    });

Next is the Header component. Create a components/Header.js file and add the following:

    import React, { Component } from 'react';

    import {
      StyleSheet,
      Text,
      View
    } from 'react-native';

    export default class Header extends Component {

      render() {
        return (
          <View style={styles.title_container}>
            <Text style={styles.title}>{this.props.title}</Text>
          </View>
        );
      }

    }

    const styles = StyleSheet.create({
      title_container: {
        flex: 1,
      },
      title: {
        alignSelf: 'center',
        fontWeight: 'bold',
        fontSize: 30
      }
    });

All this component does is to display the title of the app in the header.

Next, create a components/Home.js file. As mentioned earlier, this is the default component that is shown the first time the user opens the app or when the room creator ends the game.

    import React, { Component } from 'react';

    import {
      StyleSheet,
      Text,
      View,
      TextInput,
      Button
    } from 'react-native';

    import Prompt from 'react-native-prompt';

    export default class Home extends Component {

      render() {

        return (        
          <View style={styles.content_container}>
            <View style={styles.input_container}>
              <TextInput
                style={styles.text_input}
                onChangeText={this.props.onChangeUsername}
                placeholder={"What's your name?"}
                maxLength={20}
                value={this.props.username}
              />
            </View>

            <View style={styles.button_container}>
              <Button
                onPress={this.props.onPressCreateRoom}
                title="Create Room"
                color="#4c87ea"
                style={styles.button}
              />
              <Button
                onPress={this.props.onPressJoinRoom}
                title="Join Room"
                color="#1C1C1C"
                style={styles.button}
              />
            </View>

            <Prompt
              title="Enter Room Name"
              visible={this.props.show_prompt}
              onSubmit={this.props.joinRoom}
              onCancel={this.props.onCancelJoinRoom}
            />
          </View>
        );
      }
    }

    const styles = StyleSheet.create({
      content_container: {
        flex: 1
      },
      input_container: {
        marginBottom: 20
      },
      button_container: {
        flexDirection: 'row',
        justifyContent: 'space-around',
        alignItems: 'center'
      },
      text_input: {
        backgroundColor: '#FFF',
        height: 40,
        borderColor: '#CCC', 
        borderWidth: 1
      },
      button: {
        flex: 1
      }
    });

Next, create a components/Board.js file. This component serves as the main meat of the app because it’s where the game happens.

First, include the components and packages that we’ll be needing:

    import React, { Component } from 'react';

    import {
      StyleSheet,
      Text,
      View,
      TextInput,
      Button,
      TouchableHighlight,
      Alert
    } from 'react-native';

    import range from 'lodash.range'; 

In the constructor, bind the methods for generating the content for the board (3x3 board). The possible combinations for getting a score are also declared. The ids are used as the IDs for referring to the individual blocks. As you can see, it’s an array which has three arrays in it. Each of these arrays pertains to the rows in the board, and its items pertains to the individual blocks. So when referring to the second column in the first row of the board, you can get the ID for that by using this.ids[0][1]. This will then return 1. The ID will be used later on to determine the scores based on the possible_combinations array.

    export default class Board extends Component {

      constructor() {
        super();
        this.generateRows = this.generateRows.bind(this); // bind the method for generating the rows for the board
        this.generateBlocks = this.generateBlocks.bind(this); // bind the method for generating individual blocks for each row

        // the possible combinations for getting a score in a 3x3 tic-tac-toe board 
        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]
        ];

        // the IDs of the individual blocks
        this.ids = [
          [0, 1, 2],
          [3, 4, 5],
          [6, 7, 8]
        ];

        // the individual rows
        this.rows = [
          range(3).fill(''), // make an array with 3 elements and set each item to an empty string
          range(3).fill(''),
          range(3).fill('')
        ];

        this.state = {
          moves: range(9).fill(''), // the pieces (X or O) used on each block
          x_score: 0, // score of the room creator
          o_score: 0 // score of the rival
        }

    }

Right below the declaration for this.ids is the array which will be used to generate the rows in the board.

Once the component is mounted, we then want to listen for the client-make-move event to happen. This event is triggered every time a user places their piece (either “X” or “O”) on the board. Note that this will only be triggered on the rival and not the user who has sent the event.

    componentDidMount() {
      this.props.channel.bind('client-make-move', (data) => {
        let moves = this.state.moves;
        let id = this.ids[data.row_index][data.index]; // get the ID based on the row index and block index
        moves[id] = data.piece; // set the piece

        // update the UI
        this.setState({
          moves
        });

        this.updateScores.call(this, moves); // update the user scores
      });
    }

Every time a move is made, the updateScores function is executed. This loops through all the possible combinations. It uses the every() method to check whether a specific piece was used on each of the items for a possible combination. For example, if “X” is used for blocks 0, 1, and 2, then 1 point is rewarded to the user who has “X” as their piece.

    updateScores(moves) {

      var pieces = {
        'X': 0,
        'O': 0
      }

      function isInArray(moves, piece, element, index, array){
        return moves[element] && moves[element] == piece; // check if there's a piece assigned to a specific block and that piece is the piece we're looking for (either "X" or "O")
      }

      this.possible_combinations.forEach((p_row) => {
        if(p_row.every(isInArray.bind(null, moves, 'X'))){
          pieces['X'] += 1;
        }else if(p_row.every(isInArray.bind(null, moves, 'O'))){
          pieces['O'] += 1;
        }
      });

      this.setState({
        x_score: pieces['X'],
        o_score: pieces['O']
      });

    }

Here’s the render() method. It uses the generateRows() method to generate the content for the board. Below that is the score display for the two users.

    render() {
      return (
        <View style={styles.board_container}>
          <View style={styles.board}>
          {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.username} (x)</Text>
            </View>

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

Here’s the generateRows() method:

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

The generateBlocks() method is used for generating the individual blocks on each row. It uses the TouchableHighlight component to create a view which can be tapped on by the user. Each block displays the piece of the user who first tapped on it. Tapping on a block executes the onMakeMove() method which places the user’s piece on that block.

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

The onMakeMove() method receives the row_index and the block index. These allow us to get the block id which is used to set the piece on a specific block. After that, the updateScores() is also called to update the user scores. To update the UI of the rival, the details of the move is sent using the client-make-move event.

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

      if(!moves[id]){ // nobody has occupied the space yet
        moves[id] = this.props.piece;
        this.setState({
          moves
        });

        this.updateScores.call(this, moves);

        // inform the rival that a move is made
        this.props.channel.trigger('client-make-move', {
          row_index: row_index,
          index: index,
          piece: this.props.piece
        });   
      }
    }

Once the board has been filled up with pieces, ask the room creator if they want to restart or end the game. If the room creator decides to restart the game the board is simply reset to its default state, otherwise the app is reset to its default state (the same as when the app is first opened).

    if(this.props.is_room_creator && moves.indexOf('') == -1){
      Alert.alert(
        "Restart Game", 
        "Do you want to restart the game?",
        [
          {
            text: "Nope. Let's call it quits.", 
            onPress: () => {
              this.setState({
                moves: range(9).fill(''),
                x_score: 0,
                o_score: 0
              });
              this.props.endGame();
            },
            style: 'cancel'
          },
          {
            text: 'Heck yeah!', 
            onPress: () => {
              this.setState({
                moves: range(9).fill(''),
                x_score: 0,
                o_score: 0
              });
            }  
          },
        ],
        { cancelable: false } 
      );
    }

Lastly, add the styles:

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

Testing the app

Now that you’ve built the app, it’s now time to try it out. The first thing that you need to do is run the server:

    node server.js

You can run the app with the following command:

    react-native run-android

Be sure that you already have a connected device or an emulator opened when you execute this.

If you’re using either Genymotion or the Android emulator, and you don’t really want to test on a real device, then you can use the browser to simulate the rival.

Once that’s done, run the app and create a new room. Then copy the room ID shown in the alert box.

Next, go to your Pusher app’s dashboard and click on the Debug Console tab. Click on Show event creator and enter private-ROOM_ID for the Channel. Be sure to replace ROOM_ID with the actual ID of the room then set client-joined as the value of the Event. The value for the Data is:

    {
      "username": "doraemon"
    }

Use the screenshot below as a reference:

Once that’s done, click on the Send event button. This should trigger the app to change its UI to the actual game board. To trigger some moves, set the Event name to client-make-move then add the details of the move on the Data field:

    {
      "row_index": 0,
      "index": 0,
      "piece": "O"
    }

This will place the “O” piece on the first box in the game board.

From there you can place a different value for the index and row_index to emulate the game play.

Deploying the server

The method I showed you above is great if you only want to test inside your own local network. But what if you want to test it out with your friends outside the network? For that, you could use Now. I’m not going to go into details on how to deploy the server, but you can check out their docs. Now is free to use, the only downside is that your code will be available publicly.

Conclusion

That’s it! In this tutorial you’ve learned how to re-create Tic-Tac-Toe using Pusher. As you have seen, Pusher really makes it easy to implement real-time features in games. While Tic-Tac-Toe is a very simple game, this doesn’t mean that Pusher can only be used in simple games. You can pretty much use Pusher in any real-time game you can think of. You can find the source code for this tutorial on GitHub

  • Channels

© 2018 Pusher Ltd. All rights reserved.

Pusher Limited is a company registered in England and Wales (No. 07489873) whose registered office is at 160 Old Street, London, EC1V 9BW.