Create a two-player Pong game with React Native

Introduction

In this tutorial, we’ll re-create the classic video game Pong. For those unfamiliar, Pong is short for Ping-pong. It’s another term for table tennis in which two players hit a lightweight ball back and forth across a table using small rackets. So Pong is basically the video game equivalent of the sport.

Prerequisites

Basic knowledge of React Native and React Navigation is required. We’ll also be using Node, but knowledge is optional since we’ll only use it minimally.

We’ll be using the following package versions:

  • Yarn 1.13.0
  • Node 11.2.0
  • React Native 0.57.8
  • React Native Game Engine 0.10.1
  • MatterJS 0.14.2

For compatibility reasons, I recommend you to install the same package versions used in this tutorial before trying to update to the latest ones.

We’ll be using Pusher Channels in this tutorial. Create a free sandbox Pusher account or sign in. The only requirement is for the app to allow client events. You can enable it from your app settings page.

Lastly, you’ll need an ngrok account, so you can use it for exposing the server to the internet.

App overview

We’ll re-create the Pong game with React Native and Pusher Channels. Users have to log in using a unique username before they can start playing the game. The server is responsible for signaling for when an opponent is found and when the game starts. Once in the game, all the users have to do is land the ball on their opponent’s base and also prevent them from landing the ball on their base. For the rest of the tutorial, I’ll be referring to the object which the users will move as “plank”.

You can find the code on this GitHub repo.

Creating the app

Start by initializing a new React Native project:

1react-native init RNPong
2    cd RNPong

Once the project is created, open your package.json file and add the following to your dependencies:

1"dependencies": {
2      "matter-js": "^0.14.2",
3      "pusher-js": "^4.3.1",
4      "react-native-game-engine": "^0.10.1",
5      "react-native-gesture-handler": "^1.0.12",
6      "react-navigation": "^3.0.9",
7      // your existing dependencies..
8    }

Execute yarn install to install the packages.

While that’s doing its thing, here’s a brief overview of what each package does:

  • matter-js - a JavaScript physics engine. This allows us to simulate how objects respond to applied forces and collisions. It’s responsible for animating the ball and the planks as they move through space.
  • pusher-js - used for sending realtime messages between the two users so the UI stays in sync.
  • react-native-game-engine - provides useful components for effectively managing and rendering the objects in our game. As you’ll see later, it’s the one which orchestrates the different objects so they can be managed by a system which specifies how the objects will move or react to collisions.
  • react-navigation - for handling navigation between the login and the game screen.
  • react-native-gesture-handler - you might think that we’re using it for handling the swiping motion for moving the planks. But the truth is we don’t really need this directly. react-navigation uses it for handling gestures when navigating between pages.

Once that’s done, link all the packages with react-native link.

Next, set the permission to access the network state and set the orientation to landscape:

1// android/app/src/main/AndroidManifest.xml
2    <manifest ...>
3      <uses-permission android:name="android.permission.INTERNET" />
4      <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
5      <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
6      <application
7        android:name=".MainApplication"
8        ...
9      >
10        <activity
11          android:name=".MainActivity"
12          android:screenOrientation="landscape"
13          ...
14        >
15          ...
16        </activity>
17      </application>
18    </manifest>

React Navigation boilerplate code

Start by adding the boilerplate code for setting up React Navigation. This includes the main app file and the root file for specifying the app screens:

1// App.js
2    import React, { Component } from "react";
3    import { View } from "react-native";
4    
5    import Root from "./Root";
6    
7    export default class App extends Component {
8      render() {
9        return (
10          <View style={styles.container}>
11            <Root />
12          </View>
13        );
14      }
15    }
16    
17    const styles = {
18      container: {
19        flex: 1
20      }
21    };
22
23
24    // Root.js
25    import React, { Component } from "react";
26    import { YellowBox } from 'react-native';
27    import { createStackNavigator, createAppContainer } from "react-navigation";
28    import LoginScreen from './src/screens/Login';
29    import GameScreen from './src/screens/Game';
30    
31    // to suppress timer warnings (has to do with Pusher)
32    YellowBox.ignoreWarnings([
33      'Setting a timer'
34    ]);
35    
36    const RootStack = createStackNavigator(
37      {
38        Login: LoginScreen,
39        Game: GameScreen
40      },
41      {
42        initialRouteName: "Login"
43      }
44    );
45    
46    const AppContainer = createAppContainer(RootStack);
47    
48    class Router extends Component {
49      render() {
50        return (
51          <AppContainer />
52        );
53      }
54    }
55    
56    export default Router;

If you don’t know what’s going on with the code above, be sure to check out the React Navigation docs.

Login screen

We’re now ready to add the code for the login screen of the app. Start by importing the things we need. If you haven’t created a Pusher app instance yet, now is a good time to do so. Then replace the placeholders below. As for the ngrok URL, you can add it later once we run the app:

1// src/screens/Login.js
2    import React, { Component } from "react";
3    import {
4      View,
5      Text,
6      TextInput,
7      TouchableOpacity,
8      Alert
9    } from "react-native";
10    
11    import Pusher from 'pusher-js/react-native';
12    
13    const pusher_app_key = 'YOUR PUSHER APP KEY';
14    const pusher_app_cluster = 'YOUR PUSHER APP CLUSTER';
15    const base_url = 'YOUR HTTPS NGROK URL';

Next, initialize the state and instance variables that we’ll be using:

1class LoginScreen extends Component {
2      static navigationOptions = {
3        title: "Login"
4      };
5    
6      state = {
7        username: "",
8        enteredGame: false
9      };
10    
11      constructor(props) {
12        super(props);
13        this.pusher = null;
14        this.myChannel = null;
15      }
16      
17      // next: add render method
18      
19    }

In the render method, we have the login form:

1render() {
2    
3      return (
4        <View style={styles.wrapper}>
5    
6          <View style={styles.container}>
7            <View style={styles.main}>
8              <View>
9                <Text style={styles.label}>Enter your username</Text>
10                <TextInput
11                  style={styles.textInput}
12                  onChangeText={username => this.setState({ username })}
13                  value={this.state.username}
14                />
15              </View>
16    
17              {
18                !this.state.enteredGame &&
19                <TouchableOpacity onPress={this.enterGame}>
20                  <View style={styles.button}>
21                    <Text style={styles.buttonText}>Login</Text>
22                  </View>
23                </TouchableOpacity>
24              }
25    
26              {this.state.enteredGame && (
27                <Text style={styles.loadingText}>Loading...</Text>
28              )}
29            </View>
30          </View>
31        </View>
32      );
33    }

When the Login button is clicked, we authenticate the user through the server. This is a requirement for Pusher apps that communicate directly from the client side. So to save on requests, we also submit the username as an additional request parameter. Once the app receives a response from the server, we subscribe to the current user’s own channel. This allows the app to receive messages from the server, and from their opponent later on:

1enterGame = async () => {
2      const username = this.state.username;
3      
4      if (username) {
5        this.setState({
6          enteredGame: true // show loading text
7        });
8      
9        this.pusher = new Pusher(pusher_app_key, {
10          authEndpoint: `${base_url}/pusher/auth`,
11          cluster: pusher_app_cluster,
12          auth: {
13            params: { username: username }
14          },
15          encrypted: true
16        });
17      
18        this.myChannel = this.pusher.subscribe(`private-user-${username}`);
19        this.myChannel.bind("pusher:subscription_error", status => {
20          Alert.alert(
21            "Error",
22            "Subscription error occurred. Please restart the app"
23          );
24        });
25      
26        this.myChannel.bind("pusher:subscription_succeeded", () => {
27          // next: add code for when the opponent is found
28        });
29      }
30    };

When the opponent-found event is triggered by the server, this is the cue for the app to navigate to the game screen. But before that, we first subscribe to the opponent’s channel and determine which objects should be assigned to the current user. The game is set up in a way that the first player who logs in is always considered “player one”, and the next one is always “player two”. Player one always assumes the left side of the screen, while player two assumes the right side. Each player has a plank and a wall assigned to them. Most of the code below is used to determine which objects should be assigned to the current player:

1this.myChannel.bind("opponent-found", data => {
2      let opponent = username == data.player_one ? data.player_two : data.player_one;
3    
4      const playerOneObjects = {
5        plank: "plankOne",
6        wall: "leftWall",
7        plankColor: "green"
8      };
9    
10      const playerTwoObjects = {
11        plank: "plankTwo",
12        wall: "rightWall",
13        plankColor: "blue"
14      };
15    
16      const isPlayerOne = username == data.player_one ? true : false;
17    
18      const myObjects = isPlayerOne ? playerOneObjects : playerTwoObjects;
19      const opponentObjects = isPlayerOne
20        ? playerTwoObjects
21        : playerOneObjects;
22    
23      const myPlank = myObjects.plank;
24      const myPlankColor = myObjects.plankColor;
25      const opponentPlank = opponentObjects.plank;
26      const opponentPlankColor = opponentObjects.plankColor;
27    
28      const myWall = myObjects.wall;
29      const opponentWall = opponentObjects.wall;
30    
31      Alert.alert("Opponent found!", `Your plank color is ${myPlankColor}`);
32    
33      this.opponentChannel = this.pusher.subscribe(
34        `private-user-${opponent}`
35      );
36      this.opponentChannel.bind("pusher:subscription_error", data => {
37        console.log("Error subscribing to opponent's channel: ", data);
38      });
39    
40      this.opponentChannel.bind("pusher:subscription_succeeded", () => {
41        
42        this.props.navigation.navigate("Game", {
43          pusher: this.pusher,
44          username: username,
45          myChannel: this.myChannel,
46          opponentChannel: this.opponentChannel,
47    
48          opponent: opponent,
49          isPlayerOne: isPlayerOne,
50          myPlank: myPlank,
51          opponentPlank: opponentPlank,
52          myPlankColor: myPlankColor,
53          opponentPlankColor: opponentPlankColor,
54    
55          myWall: myWall,
56          opponentWall: opponentWall
57        });
58      });
59    
60      this.setState({
61        username: "",
62        enteredGame: false
63      });
64    });

Next, add the styles for the login screen. You can get it from this file.

Server code

Create a server folder inside the root of the React Native project. Inside, create a package.json file with the following contents:

1{
2      "name": "pong-authserver",
3      "version": "1.0.0",
4      "description": "",
5      "main": "index.js",
6      "scripts": {
7        "start": "node server.js"
8      },
9      "author": "",
10      "license": "ISC",
11      "dependencies": {
12        "body-parser": "^1.17.2",
13        "dotenv": "^4.0.0",
14        "express": "^4.15.3",
15        "pusher": "^1.5.1"
16      }
17    }

Execute yarn install to install the dependencies.

Next, create a .env file and add your Pusher app credentials:

1APP_ID="YOUR PUSHER APP ID"
2    APP_KEY="YOUR PUSHER APP KEY"
3    APP_SECRET="YOUR PUSHER APP SECRET"
4    APP_CLUSTER="YOUR PUSHER APP CLUSTER"

Next, import all the packages we need and initialize Pusher:

1// server/server.js
2    var express = require('express');
3    var bodyParser = require('body-parser');
4    var Pusher = require('pusher');
5    
6    require('dotenv').config();
7    
8    var app = express();
9    app.use(bodyParser.json());
10    app.use(bodyParser.urlencoded({ extended: false }));
11    
12    var pusher = new Pusher({
13      appId: process.env.APP_ID,
14      key: process.env.APP_KEY,
15      secret:  process.env.APP_SECRET,
16      cluster: process.env.APP_CLUSTER,
17    });

Next, we add the route for authenticating the user. I said authentication, but to simplify things, we’re going to skip the actual authentication. Normally, you would have a database for checking whether the user has a valid account before you call the pusher.authenticate method:

1var users = [];
2    
3    app.post("/pusher/auth", function(req, res) {
4      var socketId = req.body.socket_id;
5      var channel = req.body.channel_name;
6      var username = req.body.username;
7    
8      users.push(username); // temporarily store the username to be used later
9      console.log(username + " logged in");
10    
11      var auth = pusher.authenticate(socketId, channel);
12      res.send(auth);
13    });

Next, add the route for triggering the event for informing the users that an opponent was found. When you access this route on the browser, it will show an alert that an opponent is found, and the game screen will show up. Again, this isn’t what you’d do in a production app. This is only a demo, so this is done to have finer control over when things are triggered:

1app.get("/opponent-found", function(req, res) {
2      var unique_users = users.filter((value, index, self) => {
3        return self.indexOf(value) === index;
4      });
5      var player_one = unique_users[0];
6      var player_two = unique_users[1];
7    
8      console.log("opponent found: " + player_one + " and " + player_two);
9    
10      pusher.trigger(
11        ["private-user-" + player_one, "private-user-" + player_two],
12        "opponent-found",
13        {
14          player_one: player_one,
15          player_two: player_two
16        }
17      );
18    
19      res.send("opponent found!");
20    });

Lastly, the start game route is what triggers the ball to actually start moving:

1app.get("/start-game", function(req, res) {
2      var unique_users = users.filter((value, index, self) => {
3        return self.indexOf(value) === index;
4      });
5    
6      var player_one = unique_users[0];
7      var player_two = unique_users[1];
8      
9      console.log("start game: " + player_one + " and " + player_two);
10    
11      pusher.trigger(
12        ["private-user-" + player_one, "private-user-" + player_two],
13        "start-game",
14        {
15          start: true
16        }
17      );
18    
19      users = [];
20    
21      res.send("start game!");
22    });
23    
24    // run the server on a specific port
25    var port = 5000;
26    app.listen(port);

Game screen

Let’s go back the app itself. This time, we proceed to coding the game screen. Start by importing the packages and components we need:

1// src/screens/Game.js
2    import React, { PureComponent } from 'react';
3    import { View, Text, Alert } from "react-native";
4    import { GameEngine } from "react-native-game-engine";
5    import Matter from "matter-js";
6    
7    import Circle from '../components/Circle'; // for rendering the ball
8    import Box from '../components/Box'; // for rendering the planks and walls

Next, we declare the size of the objects. Here, we’re using hard-coded dimensions to constrain the world to a single size. Because someone might be playing the game on a tablet, and their opponent is only playing on a phone with a small screen. This means that the ball will travel longer distances compared the phone, and the UI won’t be perfectly synced:

1import React, { PureComponent } from "react";
2    import { View, Text, Dimensions, Alert } from "react-native";
3    import { GameEngine } from "react-native-game-engine";
4    import Circle from "../components/Circle";
5    import Box from "../components/Box";
6    
7    import Matter from "matter-js";
8    
9    const BALL_SIZE = 50;
10    const PLANK_HEIGHT = 70;
11    const PLANK_WIDTH = 20;
12    
13    const GAME_WIDTH = 650;
14    const GAME_HEIGHT = 340;
15    
16    const BALL_START_POINT_X = GAME_WIDTH / 2 - BALL_SIZE;
17    const BALL_START_POINT_Y = GAME_HEIGHT / 2;
18    const BORDER = 15;
19    
20    const WINNING_SCORE = 5;

Next, we specify the properties of the objects in the game. These properties decide how they move through space and respond to collisions with other objects:

1const plankSettings = {
2      isStatic: true
3    };
4    
5    const wallSettings = {
6      isStatic: true
7    };
8    
9    const ballSettings = {
10      inertia: 0,
11      friction: 0,
12      frictionStatic: 0,
13      frictionAir: 0,
14      restitution: 1
15    };

Here’s what each property does. Note that most of these properties are only applicable to the ball. All the ones applied to other objects are simply used to replace the default values:

  • isStatic - used for specifying that the object is immovable. This means that it won’t change position no matter the amount of force applied to it by another object.
  • inertia - the amount of external force it takes to move a specific object. We’re specifying a value of 0 for the ball so it requires no force at all to move it.
  • friction - used for specifying the kinetic friction of the object. This can have a value between 0 and 1. A value of 0 means that the object doesn’t produce any friction when it slides through another object which has also a friction of 0. This means that when a force is applied to it, it will simply slide indefinitely until such a time that another force stops it. 1 is the maximum amount of friction. And any value between it and 0 is used to control the amount of friction it produces as it slides to through or collides with another object. For the ball, we’re specifying a friction of 0 so it can move indefinitely.
  • frictionStatic - aside from inertia, this is another property you can use to specify how much harder it will be to move a static object. So a higher value will require a greater amount of force to move the object.
  • frictionAir - used for specifying the air resistance of an object. We’re specifying a value of 0 so the ball can move indefinitely through space even if it doesn’t collide to anything.
  • restitution - used for specifying the bounce of the ball when it collides with walls and planks. It can have a value between 0 and 1. 0 means it won’t bounce at all when it collides with another object. So 1 produces the maximum amount of bounce.

Next, create the actual objects using the settings from earlier. In MatterJS, we can create objects using the Matter.Bodies module. We can create different shapes using the methods in this module. But for the purpose of this tutorial, we only need to create a circle (ball) and a rectangle (planks and walls). The circle and rectangle methods both require the initial x and y position of the object as their first and second arguments. As for the third one, the circle method requires the radius of the circle. While the rectangle method requires the width and the height. The last argument is the object’s properties we declared earlier. In addition, we’re also specifying a label to make it easy to determine the object we’re working with. The isSensor is set to true for the left and right walls so they will only act as a sensor for collisions instead of affecting the object which collides to it. This means that the ball will simply pass through those walls:

1const ball = Matter.Bodies.circle(
2      BALL_START_POINT_X,
3      BALL_START_POINT_Y,
4      BALL_SIZE,
5      {
6        ...ballSettings,
7        label: "ball"
8      }
9    );
10    
11    const plankOne = Matter.Bodies.rectangle(
12      BORDER,
13      95,
14      PLANK_WIDTH,
15      PLANK_HEIGHT,
16      {
17        ...plankSettings,
18        label: "plankOne"
19      }
20    );
21    const plankTwo = Matter.Bodies.rectangle(
22      GAME_WIDTH - 50,
23      95,
24      PLANK_WIDTH,
25      PLANK_HEIGHT,
26      { ...plankSettings, label: "plankTwo" }
27    );
28    
29    const topWall = Matter.Bodies.rectangle(
30      GAME_HEIGHT - 20,
31      -30,
32      GAME_WIDTH,
33      BORDER,
34      { ...wallSettings, label: "topWall" }
35    );
36    const bottomWall = Matter.Bodies.rectangle(
37      GAME_HEIGHT - 20,
38      GAME_HEIGHT + 33,
39      GAME_WIDTH,
40      BORDER,
41      { ...wallSettings, label: "bottomWall" }
42    );
43    const leftWall = Matter.Bodies.rectangle(-50, 160, 10, GAME_HEIGHT, {
44      ...wallSettings,
45      isSensor: true,
46      label: "leftWall"
47    });
48    const rightWall = Matter.Bodies.rectangle(
49      GAME_WIDTH + 50,
50      160,
51      10,
52      GAME_HEIGHT,
53      { ...wallSettings, isSensor: true, label: "rightWall" }
54    );
55    
56    const planks = {
57      plankOne: plankOne,
58      plankTwo: plankTwo
59    };

Next, we add all the objects to the “world”. In MatterJS, all objects that you need to interact with one another need to be added to the world. This allows them to be simulated by the “engine”. The engine is used for updating the simulation of the world:

1const engine = Matter.Engine.create({ enableSleeping: false });
2    const world = engine.world;
3    
4    Matter.World.add(world, [
5      ball,
6      plankOne,
7      plankTwo,
8      topWall,
9      bottomWall,
10      leftWall,
11      rightWall
12    ]);

In the above code, enableSleeping is set to false to prevent the objects from sleeping. This is a state similar to adding the isStatic property to the object, the only difference is that objects that are asleep can be woken up and continue their motion. As you’ll see later on, we’re actually going to make the ball sleep manually as a technique for keeping the UI synced.

Next, create the component and initialize the state. Note that we’re using a PureComponent instead of the usual Component. This is because the game screen needs to be pretty performant. PureComponent automatically handles the shouldComponentUpdate method for you. When props or state changes, PureComponent will do a shallow comparison on both props and state. And the component won’t actually re-render if nothing has changed:

1export default class Game extends PureComponent {
2      static navigationOptions = {
3        header: null // we don't need a header
4      };
5    
6      state = {
7        myScore: 0,
8        opponentScore: 0
9      };
10      
11      // next: add constructor
12    
13    }

The constructor is where we specify the systems to be used by the React Native Game Engine and subscribe the user to their opponent’s channel. Start by getting all the navigation params that we passed from the login screen earlier:

1constructor(props) {
2      super(props);
3    
4      const { navigation } = this.props;
5      this.movePlankInterval = null;
6      
7      this.pusher = navigation.getParam("pusher");
8      this.username = navigation.getParam("username");
9      
10      this.myChannel = navigation.getParam("myChannel");
11      this.opponentChannel = navigation.getParam("opponentChannel");
12      
13      this.isPlayerOne = navigation.getParam("isPlayerOne");
14      
15      const myPlankName = navigation.getParam("myPlank");
16      const opponentPlankName = navigation.getParam("opponentPlank");
17      
18      this.myPlank = planks[myPlankName];
19      this.opponentPlank = planks[opponentPlankName];
20      
21      this.myPlankColor = navigation.getParam("myPlankColor");
22      this.opponentPlankColor = navigation.getParam("opponentPlankColor");
23      
24      this.opponentWall = navigation.getParam("opponentWall");
25      this.myWall = navigation.getParam("myWall");
26      
27      const opponent = navigation.getParam("opponent");
28    
29      // next: add code for adding systems
30    }

Next, add the systems for the physics engine and moving the plank. The React Native Game Engine doesn’t come with a physics engine out of the box. Thus, we use MatterJS to handle the physics of the game. Later on, in the component’s render method, we will pass physics and movePlank as systems:

1this.physics = (entities, { time }) => {
2      let engine = entities["physics"].engine;
3      engine.world.gravity.y = 0; // no downward pull
4      Matter.Engine.update(engine, time.delta); // move the simulation forward
5      return entities;
6    };
7    
8    this.movePlank = (entities, { touches }) => {
9      let move = touches.find(x => x.type === "move");
10      if (move) {
11        const newPosition = {
12          x: this.myPlank.position.x, // x is constant
13          y: this.myPlank.position.y + move.delta.pageY // add the movement distance to the current Y position
14        };      
15        Matter.Body.setPosition(this.myPlank, newPosition);
16      }
17      return entities;
18    };
19    
20    // next: add code for binding to events for syncing the UI

All the entities (the objects we added earlier) that are added to the world are passed to each of the systems. Each entity has properties like time and touches which you can manipulate. In the case of the physics engine, the engine is considered as an entity. In the code below, we’re manipulating the world’s Y gravity (downward pull) to be equal to zero. This means that the objects won’t be pulled downwards as the simulation goes on.

The movePlank system is used for moving the plank. So we extract the touches from the entities. touches contains an array of all the touches the user performed. Each item in the array contains all sorts of data about the touch, but we’re only concerned with the type. The type can be touch, press, or in this case, move. move is when the user moves their finger/s across the screen. Since we only need to listen for this one event, we don’t actually need to target the plank precisely. Which means that the user doesn’t have to place their index finger on their assigned plank in order to move it. They simply have to move their finger across the screen, and the distance from that movement will automatically be added to the current Y position of their plank. Of course, this considers the direction of the movement as well. So if the direction is upwards, then the value of move.delta.pageY will be negative.

Next, we bind to the events that will be triggered by the opponent. These will keep the UI of the two players synced. First is the event for syncing the planks. This updates the UI to show the current position of the opponent’s plank:

1this.myChannel.bind("client-opponent-moved", opponentData => {
2      Matter.Body.setPosition(this.opponentPlank, {
3        x: this.opponentPlank.position.x,
4        y: opponentData.opponentPlankPositionY
5      });
6    });
7    
8    // next: listen to the event for moving the ball

Next, add the event which updates the balls current position and velocity. The way this works is that the two players will continuously pass the ball’s current position and velocity to one another. Between each pass, we add a 200-millisecond delay so that the ball actually moves between each pass. Making the ball sleep between each pass is important because the ball will look like it’s going back and forth a few millimeters while it’s reaching its destination:

1this.myChannel.bind("client-moved-ball", ({ position, velocity }) => {
2      Matter.Sleeping.set(ball, false); // awaken the ball so it can move
3      Matter.Body.setPosition(ball, position);
4      Matter.Body.setVelocity(ball, velocity);
5    
6      setTimeout(() => {
7        if (position.x != ball.position.x || position.y != ball.position.y) {
8          this.opponentChannel.trigger("client-moved-ball", {
9            position: ball.position,
10            velocity: ball.velocity
11          });
12    
13          Matter.Sleeping.set(ball, true); // make the ball sleep while waiting for the event to be triggered by the opponent
14        }
15      }, 200);
16    });
17    
18    // next: add code for sending plank updates to the opponent

Next, trigger the event for updating the opponent’s screen of the current position of the user’s plank. This is executed every 300 milliseconds so we’re still within the 10 messages per second limit per client:

1setInterval(() => {
2      this.opponentChannel.trigger("client-opponent-moved", {
3        opponentPlankPositionY: this.myPlank.position.y
4      });
5    }, 300);
6    
7    // next: add code for updating player two's score

Next, we bind to the event for updating the scores on player two’s side. Player one (the first user who logs in) is responsible for triggering this event:

1if (!this.isPlayerOne) {
2      this.myChannel.bind(
3        "client-update-score",
4        ({ playerOneScore, playerTwoScore }) => {
5          this.setState({
6            myScore: playerTwoScore,
7            opponentScore: playerOneScore
8          });
9        }
10      );
11    }
12    
13    // next: add componentDidMount

Once the component is mounted, we wait for the start-game event to be triggered by the server before accelerating the ball. Once the ball is accelerated, we initiate the back and forth passing of the ball’s position and velocity. This is the reason why only player one runs this code:

1componentDidMount() {
2      
3      if (this.isPlayerOne) {
4        this.myChannel.bind("start-game", () => {
5          Matter.Body.setVelocity(ball, { x: 3, y: 0 }); // throw the ball straight to the right
6    
7          this.opponentChannel.trigger("client-moved-ball", {
8            position: ball.position,
9            velocity: ball.velocity
10          });
11    
12          Matter.Sleeping.set(ball, true); // make the ball sleep and wait for the same event to be triggered on this side
13        });
14    
15         // next: add scoring code
16      }
17    }

Next, we need to handle collisions. We already know that the ball can collide with any of the objects we added into the world. But if it hits either the left wall or right wall, the player who hit it will score a point. And since this block of code is still within the this.isPlayerOne condition, we also need to trigger an event for informing player two of the score change:

1Matter.Events.on(engine, "collisionStart", event => {
2      var pairs = event.pairs;
3    
4      var objA = pairs[0].bodyA.label;
5      var objB = pairs[0].bodyB.label;
6    
7      if (objA == "ball" && objB == this.opponentWall) {
8        this.setState(
9          {
10            myScore: +this.state.myScore + 1
11          },
12          () => {
13            // bring back the ball to its initial position
14            Matter.Body.setPosition(ball, {
15              x: BALL_START_POINT_X,
16              y: BALL_START_POINT_Y
17            });
18    
19            Matter.Body.setVelocity(ball, { x: -3, y: 0 });
20            
21            // inform player two of the change in scores
22            this.opponentChannel.trigger("client-update-score", {
23              playerOneScore: this.state.myScore,
24              playerTwoScore: this.state.opponentScore
25            });
26          }
27        );
28      } else if (objA == "ball" && objB == this.myWall) {
29        this.setState(
30          {
31            opponentScore: +this.state.opponentScore + 1
32          },
33          () => {
34            Matter.Body.setPosition(ball, {
35              x: BALL_START_POINT_X,
36              y: BALL_START_POINT_Y
37            });
38            Matter.Body.setVelocity(ball, { x: 3, y: 0 });
39            
40            this.opponentChannel.trigger("client-update-score", {
41              playerOneScore: this.state.myScore,
42              playerTwoScore: this.state.opponentScore
43            });
44          }
45        );
46      }
47    });

Next, add the render function. The majority of the rendering is taken care of by the React Native Game Engine. To render the objects, we pass them as the value for the entities prop. This accepts an object containing all the objects that we want to render. The only required property for an object is the body and the renderer, the rest are props to be passed to the renderer itself. Note that you also need to pass the engine and the world as entities:

1render() {
2      return (
3        <GameEngine
4          style={styles.container}
5          systems={[this.physics, this.movePlank]}
6          entities={{
7            physics: {
8              engine: engine,
9              world: world
10            },
11            pongBall: {
12              body: ball,
13              size: [BALL_SIZE, BALL_SIZE],
14              renderer: Circle
15            },
16            playerOnePlank: {
17              body: plankOne,
18              size: [PLANK_WIDTH, PLANK_HEIGHT],
19              color: "#a6e22c",
20              renderer: Box,
21              xAdjustment: 30
22            },
23            playerTwoPlank: {
24              body: plankTwo,
25              size: [PLANK_WIDTH, PLANK_HEIGHT],
26              color: "#7198e6",
27              renderer: Box,
28              type: "rightPlank",
29              xAdjustment: -33
30            },
31    
32            theCeiling: {
33              body: topWall,
34              size: [GAME_WIDTH, 10],
35              color: "#f9941d",
36              renderer: Box,
37              yAdjustment: -30
38            },
39            theFloor: {
40              body: bottomWall,
41              size: [GAME_WIDTH, 10],
42              color: "#f9941d",
43              renderer: Box,
44              yAdjustment: 58
45            },
46            theLeftWall: {
47              body: leftWall,
48              size: [5, GAME_HEIGHT],
49              color: "#333",
50              renderer: Box,
51              xAdjustment: 0
52            },
53            theRightWall: {
54              body: rightWall,
55              size: [5, GAME_HEIGHT],
56              color: "#333",
57              renderer: Box,
58              xAdjustment: 0
59            }
60          }}
61        >
62          <View style={styles.scoresContainer}>
63            <View style={styles.score}>
64              <Text style={styles.scoreLabel}>{this.myPlankColor}</Text>
65              <Text style={styles.scoreValue}> {this.state.myScore}</Text>
66            </View>
67            <View style={styles.score}>
68              <Text style={styles.scoreLabel}>{this.opponentPlankColor}</Text>
69              <Text style={styles.scoreValue}> {this.state.opponentScore}</Text>
70            </View>
71          </View>
72        </GameEngine>
73      );
74    }

Note that the xAdjustment and yAdjustment are mainly used for adjusting the x and y positions of the objects. This is because the formula (see src/components/Box.js) that we’re using to calculate the x and y positions of the object doesn’t accurately adjust it to where it needs to be. This results in the ball seemingly bumping into an invisible wall before it actually hits the plank. This is because of the difference between the actual position of the object in the world (as far as MatterJS is concerned) and where it’s being rendered on the screen.

You can view the styles for the Game screen here.

Here’s the code for the Circle and Box components:

1// src/components/Circle.js
2    import React, { Component } from "react";
3    import { View } from "react-native";
4    
5    const Box = ({ body, size, xAdjustment, yAdjustment, color }) => {
6      const width = size[0];
7      const height = size[1];
8      const xAdjust = xAdjustment ? xAdjustment : 0;
9      const yAdjust = yAdjustment ? yAdjustment : 0;
10    
11      const x = body.position.x - width / 2 + xAdjust;
12      const y = body.position.y - height / 2 - yAdjust;
13    
14      return (
15        <View
16          style={{
17            position: "absolute",
18            left: x,
19            top: y,
20            width: width,
21            height: height,
22            backgroundColor: color
23          }}
24        />
25      );
26    };
27    
28    export default Box;
1// src/components/Box.js
2    import React, { Component } from "react";
3    import { View } from "react-native";
4    
5    const Box = ({ body, size, xAdjustment, yAdjustment, color }) => {
6      const width = size[0];
7      const height = size[1];
8      const xAdjust = xAdjustment ? xAdjustment : 0;
9      const yAdjust = yAdjustment ? yAdjustment : 0;
10    
11      const x = body.position.x - width / 2 + xAdjust;
12      const y = body.position.y - height / 2 - yAdjust;
13    
14      return (
15        <View
16          style={{
17            position: "absolute",
18            left: x,
19            top: y,
20            width: width,
21            height: height,
22            backgroundColor: color
23          }}
24        />
25      );
26    };
27    
28    export default Box;

At this point you can now run the app:

1cd server
2    node server.js
3    ./ngrok http 5000
4    cd ..
5    react-native run-android

Here are the steps that I used for testing the app:

  • Login user “One” on Android device #1.
  • Login user “Two” on Android device #2.
  • Access the /opponent-found route from the browser. This should show an alert on both devices that an opponent was found.
  • Access the /start-game route from the browser. This should start moving the ball.

At this point, the two players can now start moving their planks and play the game.

Conclusion

In this tutorial, you learned how to create a realtime game with React Native and Pusher Channels. Along the way, you also learned how to use the React Native Game Engine and MatterJS.

There’s a hard limit of 10 messages per second, which stopped us from really going all out with syncing the UI. But the game we created is actually acceptable in terms of performance.

You can find the code on this GitHub repo.