Create a two-player Pong game with React Native
You will need Node 11+, Yarn and React Native 0.57.8+ installed on your machine.
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. So you should know how to create and set up a new app instance on their website. 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”.
Here’s what it will look like:
You can find the code on this GitHub repo.
Creating the app
Start by initializing a new React Native project:
react-native init RNPong
cd RNPong
Once the project is created, open your package.json
file and add the following to your dependencies:
"dependencies": {
"matter-js": "^0.14.2",
"pusher-js": "^4.3.1",
"react-native-game-engine": "^0.10.1",
"react-native-gesture-handler": "^1.0.12",
"react-navigation": "^3.0.9",
// your existing dependencies..
}
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
:
// android/app/src/main/AndroidManifest.xml
<manifest ...>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<application
android:name=".MainApplication"
...
>
<activity
android:name=".MainActivity"
android:screenOrientation="landscape"
...
>
...
</activity>
</application>
</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:
// App.js
import React, { Component } from "react";
import { View } from "react-native";
import Root from "./Root";
export default class App extends Component {
render() {
return (
<View style={styles.container}>
<Root />
</View>
);
}
}
const styles = {
container: {
flex: 1
}
};
// Root.js
import React, { Component } from "react";
import { YellowBox } from 'react-native';
import { createStackNavigator, createAppContainer } from "react-navigation";
import LoginScreen from './src/screens/Login';
import GameScreen from './src/screens/Game';
// to suppress timer warnings (has to do with Pusher)
YellowBox.ignoreWarnings([
'Setting a timer'
]);
const RootStack = createStackNavigator(
{
Login: LoginScreen,
Game: GameScreen
},
{
initialRouteName: "Login"
}
);
const AppContainer = createAppContainer(RootStack);
class Router extends Component {
render() {
return (
<AppContainer />
);
}
}
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:
// src/screens/Login.js
import React, { Component } from "react";
import {
View,
Text,
TextInput,
TouchableOpacity,
Alert
} from "react-native";
import Pusher from 'pusher-js/react-native';
const pusher_app_key = 'YOUR PUSHER APP KEY';
const pusher_app_cluster = 'YOUR PUSHER APP CLUSTER';
const base_url = 'YOUR HTTPS NGROK URL';
Next, initialize the state and instance variables that we’ll be using:
class LoginScreen extends Component {
static navigationOptions = {
title: "Login"
};
state = {
username: "",
enteredGame: false
};
constructor(props) {
super(props);
this.pusher = null;
this.myChannel = null;
}
// next: add render method
}
In the render
method, we have the login form:
render() {
return (
<View style={styles.wrapper}>
<View style={styles.container}>
<View style={styles.main}>
<View>
<Text style={styles.label}>Enter your username</Text>
<TextInput
style={styles.textInput}
onChangeText={username => this.setState({ username })}
value={this.state.username}
/>
</View>
{
!this.state.enteredGame &&
<TouchableOpacity onPress={this.enterGame}>
<View style={styles.button}>
<Text style={styles.buttonText}>Login</Text>
</View>
</TouchableOpacity>
}
{this.state.enteredGame && (
<Text style={styles.loadingText}>Loading...</Text>
)}
</View>
</View>
</View>
);
}
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:
enterGame = async () => {
const username = this.state.username;
if (username) {
this.setState({
enteredGame: true // show loading text
});
this.pusher = new Pusher(pusher_app_key, {
authEndpoint: `${base_url}/pusher/auth`,
cluster: pusher_app_cluster,
auth: {
params: { username: username }
},
encrypted: true
});
this.myChannel = this.pusher.subscribe(`private-user-${username}`);
this.myChannel.bind("pusher:subscription_error", status => {
Alert.alert(
"Error",
"Subscription error occurred. Please restart the app"
);
});
this.myChannel.bind("pusher:subscription_succeeded", () => {
// next: add code for when the opponent is found
});
}
};
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:
this.myChannel.bind("opponent-found", data => {
let opponent = username == data.player_one ? data.player_two : data.player_one;
const playerOneObjects = {
plank: "plankOne",
wall: "leftWall",
plankColor: "green"
};
const playerTwoObjects = {
plank: "plankTwo",
wall: "rightWall",
plankColor: "blue"
};
const isPlayerOne = username == data.player_one ? true : false;
const myObjects = isPlayerOne ? playerOneObjects : playerTwoObjects;
const opponentObjects = isPlayerOne
? playerTwoObjects
: playerOneObjects;
const myPlank = myObjects.plank;
const myPlankColor = myObjects.plankColor;
const opponentPlank = opponentObjects.plank;
const opponentPlankColor = opponentObjects.plankColor;
const myWall = myObjects.wall;
const opponentWall = opponentObjects.wall;
Alert.alert("Opponent found!", `Your plank color is ${myPlankColor}`);
this.opponentChannel = this.pusher.subscribe(
`private-user-${opponent}`
);
this.opponentChannel.bind("pusher:subscription_error", data => {
console.log("Error subscribing to opponent's channel: ", data);
});
this.opponentChannel.bind("pusher:subscription_succeeded", () => {
this.props.navigation.navigate("Game", {
pusher: this.pusher,
username: username,
myChannel: this.myChannel,
opponentChannel: this.opponentChannel,
opponent: opponent,
isPlayerOne: isPlayerOne,
myPlank: myPlank,
opponentPlank: opponentPlank,
myPlankColor: myPlankColor,
opponentPlankColor: opponentPlankColor,
myWall: myWall,
opponentWall: opponentWall
});
});
this.setState({
username: "",
enteredGame: false
});
});
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:
{
"name": "pong-authserver",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node server.js"
},
"author": "",
"license": "ISC",
"dependencies": {
"body-parser": "^1.17.2",
"dotenv": "^4.0.0",
"express": "^4.15.3",
"pusher": "^1.5.1"
}
}
Execute yarn install
to install the dependencies.
Next, create a .env
file and add your Pusher app credentials:
APP_ID="YOUR PUSHER APP ID"
APP_KEY="YOUR PUSHER APP KEY"
APP_SECRET="YOUR PUSHER APP SECRET"
APP_CLUSTER="YOUR PUSHER APP CLUSTER"
Next, import all the packages we need and initialize Pusher:
// server/server.js
var express = require('express');
var bodyParser = require('body-parser');
var Pusher = require('pusher');
require('dotenv').config();
var app = express();
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
var pusher = new Pusher({
appId: process.env.APP_ID,
key: process.env.APP_KEY,
secret: process.env.APP_SECRET,
cluster: process.env.APP_CLUSTER,
});
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:
var users = [];
app.post("/pusher/auth", function(req, res) {
var socketId = req.body.socket_id;
var channel = req.body.channel_name;
var username = req.body.username;
users.push(username); // temporarily store the username to be used later
console.log(username + " logged in");
var auth = pusher.authenticate(socketId, channel);
res.send(auth);
});
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:
app.get("/opponent-found", function(req, res) {
var unique_users = users.filter((value, index, self) => {
return self.indexOf(value) === index;
});
var player_one = unique_users[0];
var player_two = unique_users[1];
console.log("opponent found: " + player_one + " and " + player_two);
pusher.trigger(
["private-user-" + player_one, "private-user-" + player_two],
"opponent-found",
{
player_one: player_one,
player_two: player_two
}
);
res.send("opponent found!");
});
Lastly, the start game route is what triggers the ball to actually start moving:
app.get("/start-game", function(req, res) {
var unique_users = users.filter((value, index, self) => {
return self.indexOf(value) === index;
});
var player_one = unique_users[0];
var player_two = unique_users[1];
console.log("start game: " + player_one + " and " + player_two);
pusher.trigger(
["private-user-" + player_one, "private-user-" + player_two],
"start-game",
{
start: true
}
);
users = [];
res.send("start game!");
});
// run the server on a specific port
var port = 5000;
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:
// src/screens/Game.js
import React, { PureComponent } from 'react';
import { View, Text, Alert } from "react-native";
import { GameEngine } from "react-native-game-engine";
import Matter from "matter-js";
import Circle from '../components/Circle'; // for rendering the ball
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:
import React, { PureComponent } from "react";
import { View, Text, Dimensions, Alert } from "react-native";
import { GameEngine } from "react-native-game-engine";
import Circle from "../components/Circle";
import Box from "../components/Box";
import Matter from "matter-js";
const BALL_SIZE = 50;
const PLANK_HEIGHT = 70;
const PLANK_WIDTH = 20;
const GAME_WIDTH = 650;
const GAME_HEIGHT = 340;
const BALL_START_POINT_X = GAME_WIDTH / 2 - BALL_SIZE;
const BALL_START_POINT_Y = GAME_HEIGHT / 2;
const BORDER = 15;
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:
const plankSettings = {
isStatic: true
};
const wallSettings = {
isStatic: true
};
const ballSettings = {
inertia: 0,
friction: 0,
frictionStatic: 0,
frictionAir: 0,
restitution: 1
};
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
and1
. A value of0
means that the object doesn’t produce any friction when it slides through another object which has also a friction of0
. 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 and0
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 of0
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
and1
.0
means it won’t bounce at all when it collides with another object. So1
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:
const ball = Matter.Bodies.circle(
BALL_START_POINT_X,
BALL_START_POINT_Y,
BALL_SIZE,
{
...ballSettings,
label: "ball"
}
);
const plankOne = Matter.Bodies.rectangle(
BORDER,
95,
PLANK_WIDTH,
PLANK_HEIGHT,
{
...plankSettings,
label: "plankOne"
}
);
const plankTwo = Matter.Bodies.rectangle(
GAME_WIDTH - 50,
95,
PLANK_WIDTH,
PLANK_HEIGHT,
{ ...plankSettings, label: "plankTwo" }
);
const topWall = Matter.Bodies.rectangle(
GAME_HEIGHT - 20,
-30,
GAME_WIDTH,
BORDER,
{ ...wallSettings, label: "topWall" }
);
const bottomWall = Matter.Bodies.rectangle(
GAME_HEIGHT - 20,
GAME_HEIGHT + 33,
GAME_WIDTH,
BORDER,
{ ...wallSettings, label: "bottomWall" }
);
const leftWall = Matter.Bodies.rectangle(-50, 160, 10, GAME_HEIGHT, {
...wallSettings,
isSensor: true,
label: "leftWall"
});
const rightWall = Matter.Bodies.rectangle(
GAME_WIDTH + 50,
160,
10,
GAME_HEIGHT,
{ ...wallSettings, isSensor: true, label: "rightWall" }
);
const planks = {
plankOne: plankOne,
plankTwo: plankTwo
};
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:
const engine = Matter.Engine.create({ enableSleeping: false });
const world = engine.world;
Matter.World.add(world, [
ball,
plankOne,
plankTwo,
topWall,
bottomWall,
leftWall,
rightWall
]);
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:
export default class Game extends PureComponent {
static navigationOptions = {
header: null // we don't need a header
};
state = {
myScore: 0,
opponentScore: 0
};
// next: add constructor
}
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:
constructor(props) {
super(props);
const { navigation } = this.props;
this.movePlankInterval = null;
this.pusher = navigation.getParam("pusher");
this.username = navigation.getParam("username");
this.myChannel = navigation.getParam("myChannel");
this.opponentChannel = navigation.getParam("opponentChannel");
this.isPlayerOne = navigation.getParam("isPlayerOne");
const myPlankName = navigation.getParam("myPlank");
const opponentPlankName = navigation.getParam("opponentPlank");
this.myPlank = planks[myPlankName];
this.opponentPlank = planks[opponentPlankName];
this.myPlankColor = navigation.getParam("myPlankColor");
this.opponentPlankColor = navigation.getParam("opponentPlankColor");
this.opponentWall = navigation.getParam("opponentWall");
this.myWall = navigation.getParam("myWall");
const opponent = navigation.getParam("opponent");
// next: add code for adding systems
}
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:
this.physics = (entities, { time }) => {
let engine = entities["physics"].engine;
engine.world.gravity.y = 0; // no downward pull
Matter.Engine.update(engine, time.delta); // move the simulation forward
return entities;
};
this.movePlank = (entities, { touches }) => {
let move = touches.find(x => x.type === "move");
if (move) {
const newPosition = {
x: this.myPlank.position.x, // x is constant
y: this.myPlank.position.y + move.delta.pageY // add the movement distance to the current Y position
};
Matter.Body.setPosition(this.myPlank, newPosition);
}
return entities;
};
// 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:
this.myChannel.bind("client-opponent-moved", opponentData => {
Matter.Body.setPosition(this.opponentPlank, {
x: this.opponentPlank.position.x,
y: opponentData.opponentPlankPositionY
});
});
// 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:
this.myChannel.bind("client-moved-ball", ({ position, velocity }) => {
Matter.Sleeping.set(ball, false); // awaken the ball so it can move
Matter.Body.setPosition(ball, position);
Matter.Body.setVelocity(ball, velocity);
setTimeout(() => {
if (position.x != ball.position.x || position.y != ball.position.y) {
this.opponentChannel.trigger("client-moved-ball", {
position: ball.position,
velocity: ball.velocity
});
Matter.Sleeping.set(ball, true); // make the ball sleep while waiting for the event to be triggered by the opponent
}
}, 200);
});
// 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:
setInterval(() => {
this.opponentChannel.trigger("client-opponent-moved", {
opponentPlankPositionY: this.myPlank.position.y
});
}, 300);
// 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:
if (!this.isPlayerOne) {
this.myChannel.bind(
"client-update-score",
({ playerOneScore, playerTwoScore }) => {
this.setState({
myScore: playerTwoScore,
opponentScore: playerOneScore
});
}
);
}
// 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:
componentDidMount() {
if (this.isPlayerOne) {
this.myChannel.bind("start-game", () => {
Matter.Body.setVelocity(ball, { x: 3, y: 0 }); // throw the ball straight to the right
this.opponentChannel.trigger("client-moved-ball", {
position: ball.position,
velocity: ball.velocity
});
Matter.Sleeping.set(ball, true); // make the ball sleep and wait for the same event to be triggered on this side
});
// next: add scoring code
}
}
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:
Matter.Events.on(engine, "collisionStart", event => {
var pairs = event.pairs;
var objA = pairs[0].bodyA.label;
var objB = pairs[0].bodyB.label;
if (objA == "ball" && objB == this.opponentWall) {
this.setState(
{
myScore: +this.state.myScore + 1
},
() => {
// bring back the ball to its initial position
Matter.Body.setPosition(ball, {
x: BALL_START_POINT_X,
y: BALL_START_POINT_Y
});
Matter.Body.setVelocity(ball, { x: -3, y: 0 });
// inform player two of the change in scores
this.opponentChannel.trigger("client-update-score", {
playerOneScore: this.state.myScore,
playerTwoScore: this.state.opponentScore
});
}
);
} else if (objA == "ball" && objB == this.myWall) {
this.setState(
{
opponentScore: +this.state.opponentScore + 1
},
() => {
Matter.Body.setPosition(ball, {
x: BALL_START_POINT_X,
y: BALL_START_POINT_Y
});
Matter.Body.setVelocity(ball, { x: 3, y: 0 });
this.opponentChannel.trigger("client-update-score", {
playerOneScore: this.state.myScore,
playerTwoScore: this.state.opponentScore
});
}
);
}
});
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:
render() {
return (
<GameEngine
style={styles.container}
systems={[this.physics, this.movePlank]}
entities={{
physics: {
engine: engine,
world: world
},
pongBall: {
body: ball,
size: [BALL_SIZE, BALL_SIZE],
renderer: Circle
},
playerOnePlank: {
body: plankOne,
size: [PLANK_WIDTH, PLANK_HEIGHT],
color: "#a6e22c",
renderer: Box,
xAdjustment: 30
},
playerTwoPlank: {
body: plankTwo,
size: [PLANK_WIDTH, PLANK_HEIGHT],
color: "#7198e6",
renderer: Box,
type: "rightPlank",
xAdjustment: -33
},
theCeiling: {
body: topWall,
size: [GAME_WIDTH, 10],
color: "#f9941d",
renderer: Box,
yAdjustment: -30
},
theFloor: {
body: bottomWall,
size: [GAME_WIDTH, 10],
color: "#f9941d",
renderer: Box,
yAdjustment: 58
},
theLeftWall: {
body: leftWall,
size: [5, GAME_HEIGHT],
color: "#333",
renderer: Box,
xAdjustment: 0
},
theRightWall: {
body: rightWall,
size: [5, GAME_HEIGHT],
color: "#333",
renderer: Box,
xAdjustment: 0
}
}}
>
<View style={styles.scoresContainer}>
<View style={styles.score}>
<Text style={styles.scoreLabel}>{this.myPlankColor}</Text>
<Text style={styles.scoreValue}> {this.state.myScore}</Text>
</View>
<View style={styles.score}>
<Text style={styles.scoreLabel}>{this.opponentPlankColor}</Text>
<Text style={styles.scoreValue}> {this.state.opponentScore}</Text>
</View>
</View>
</GameEngine>
);
}
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:
// src/components/Circle.js
import React, { Component } from "react";
import { View } from "react-native";
const Box = ({ body, size, xAdjustment, yAdjustment, color }) => {
const width = size[0];
const height = size[1];
const xAdjust = xAdjustment ? xAdjustment : 0;
const yAdjust = yAdjustment ? yAdjustment : 0;
const x = body.position.x - width / 2 + xAdjust;
const y = body.position.y - height / 2 - yAdjust;
return (
<View
style={{
position: "absolute",
left: x,
top: y,
width: width,
height: height,
backgroundColor: color
}}
/>
);
};
export default Box;
// src/components/Box.js
import React, { Component } from "react";
import { View } from "react-native";
const Box = ({ body, size, xAdjustment, yAdjustment, color }) => {
const width = size[0];
const height = size[1];
const xAdjust = xAdjustment ? xAdjustment : 0;
const yAdjust = yAdjustment ? yAdjustment : 0;
const x = body.position.x - width / 2 + xAdjust;
const y = body.position.y - height / 2 - yAdjust;
return (
<View
style={{
position: "absolute",
left: x,
top: y,
width: width,
height: height,
backgroundColor: color
}}
/>
);
};
export default Box;
At this point you can now run the app:
cd server
node server.js
./ngrok http 5000
cd ..
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.
20 February 2019
by Wern Ancheta