Create a two-player maze game with React Native
You will need React Native, Node and Yarn installed on your machine.
In this tutorial, we will create a two-player maze game with React Native and Pusher.
Prerequisites
Basic knowledge of React Native is required.
We’ll be using the following package versions. If you encounter any issues getting the app to work, try using the following:
- Yarn 1.13.0
- React Native 0.58.4
We’ll also be using ngrok to expose the Pusher auth server to the internet.
Lastly, you’ll need a Pusher app instance.
App overview
As mentioned earlier, we will create a maze game in which two players have to navigate. When the app starts, they will be greeted with a login screen. This is where they enter a unique username and wait for an opponent:
Once there are two players, a Pusher event is manually triggered by accessing a specific route of the app’s server component. This event informs both users that an opponent is found. This serves as the cue for the app to automatically navigate to the game screen where the maze will be generated.
After that, the event for starting the game is also manually triggered. Once the app receives that, it will inform the players that they can start navigating the maze. The first player to reach the goal wins the game.
Here’s what the app looks like. The black square is the goal, the pink circle is the current player, and the blue circle is their opponent:
You can view the code on this GitHub repo.
Building the app
Start by cloning the repo:
git clone https://github.com/anchetaWern/RNMaze.git
cd RNMaze
Next, switch to the starter
branch and install the dependencies:
git checkout starter
yarn
This branch has the styling and navigation already set up so we don’t have to go through them in this tutorial.
Next, update the .env
file with your Pusher app credentials:
PUSHER_APP_KEY="YOUR PUSHER APP KEY"
PUSHER_APP_CLUSTER="YOUR PUSHER APP CLUSTER"
Login screen
Let’s start by adding the code for the login screen:
// 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";
import Config from "react-native-config"; // for reading the .env file
const pusher_app_key = Config.PUSHER_APP_KEY;
const pusher_app_cluster = Config.PUSHER_APP_CLUSTER;
const base_url = "YOUR HTTPS NGROK URL";
class LoginScreen extends Component {
static navigationOptions = {
title: "Login"
};
state = {
username: "",
enteredGame: false
};
constructor(props) {
super(props);
this.pusher = null;
this.myChannel = null; // the current user's Pusher channel
this.opponentChannel = null; // the opponent's Pusher channel
}
// next: add render()
}
Next, render the UI for the login screen. This asks for the user’s username so they can log in:
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>
);
}
Once the Login button is clicked, the enterGame
function is executed. This authenticates the user with Pusher via an auth endpoint in the app’s server component (we’ll create this later). From the prerequisites section, one of the requirements is that your Pusher app instance needs to have client events enabled. This authentication step is a required step for using the client events feature. This allows us to trigger events directly from the app itself:
enterGame = async () => {
const myUsername = this.state.username;
if (myUsername) {
this.setState({
enteredGame: true // show login activity indicator
});
this.pusher = new Pusher(pusher_app_key, {
authEndpoint: `${base_url}/pusher/auth`,
cluster: pusher_app_cluster,
auth: {
params: { username: myUsername }
},
encrypted: true
});
// next: add code for subscribing the user to their own channel
}
};
Next, subscribe the user to their own channel. The username they entered is used for this. This channel is what’s used by their opponent to pass messages to them in realtime:
this.myChannel = this.pusher.subscribe(`private-user-${myUsername}`);
this.myChannel.bind("pusher:subscription_error", status => {
Alert.alert(
"Error",
"Subscription error occurred. Please restart the app"
);
});
// next: add code for when subscription succeeds
When the subscription succeeds, we wait for the opponent-found
event to get triggered by the server. When this happens, we determine who among the players is the first player (Player One) and assign the ball color based on that. From here, we also subscribe to the opponent’s channel. Once it succeeds, we navigate to the game screen with a few data we’re going to need:
this.myChannel.bind("pusher:subscription_succeeded", () => {
this.myChannel.bind("opponent-found", data => {
const opponentUsername =
myUsername == data.player_one ? data.player_two : data.player_one;
const isPlayerOne = myUsername == data.player_one ? true : false;
const ballColor = (isPlayerOne) ? 'pink' : 'blue'; // pink ball always goes to the first player
Alert.alert("Opponent found!", `Use the ${ballColor} ball`);
this.opponentChannel = this.pusher.subscribe(
`private-user-${opponentUsername}`
);
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,
isPlayerOne: isPlayerOne,
myUsername: myUsername,
myChannel: this.myChannel,
opponentUsername: opponentUsername,
opponentChannel: this.opponentChannel
});
});
this.setState({
username: "",
enteredGame: false // hides the login activity indicator
});
});
});
Game screen
Now we’re ready to add the code for the Game screen. Start by importing the packages, components, and helpers we need:
// src/screens/Game.js
import React, { PureComponent } from "react";
import { View, Text, Alert, ActivityIndicator } from "react-native";
import Matter from "matter-js"; // physics engine, collision detection
import { GameEngine } from "react-native-game-engine"; // rendering game objects
import Circle from '../components/Circle'; // renderer for the balls
import Rectangle from '../components/Rectangle'; // renderer for the maze walls
import CreateMaze from '../helpers/CreateMaze'; // for generating the maze
import GetRandomPoint from '../helpers/GetRandomPoint'; // for getting a random point in the grid
// the hardcoded width and height contraints of the app
import dimensions from '../data/constants';
const { width, height } = dimensions;
Next, create the constants file (src/data/constants.js
) we used above:
const constants = {
width: 360,
height: 686.67
}
export default constants;
Next, go back to the game screen (src/screens/Game.js
) and initialize the physics settings for the ball as well as the goal size:
const BALL_SIZE = Math.floor(width * .02);
const ballSettings = {
inertia: 0,
friction: 0,
frictionStatic: 0,
frictionAir: 0,
restitution: 0,
density: 1
};
const GOAL_SIZE = Math.floor(width * .04);
You can find what each of the ball settings does here.
Next, create the maze. As you’ll see later, this generates a composite body which makes up the walls of the maze:
const GRID_X = 15; // the number of cells in the X axis
const GRID_Y = 18; // the number of cells in the Y axis
const maze = CreateMaze(GRID_X, GRID_Y);
Next, create the Game component. Inside the constructor
, get the navigation params that were passed earlier from the Login screen:
export default class Game extends PureComponent {
static navigationOptions = {
header: null
};
state = {
isMazeReady: false, // whether to show the maze or not
isGameFinished: false // whether someone has already reached the goal or not
}
constructor(props) {
super(props);
const { navigation } = this.props;
this.pusher = navigation.getParam('pusher');
this.myUsername = navigation.getParam('myUsername');
this.opponentUsername = navigation.getParam('opponentUsername');
this.myChannel = navigation.getParam('myChannel');
this.opponentChannel = navigation.getParam('opponentChannel');
this.isPlayerOne = navigation.getParam('isPlayerOne');
// next: add code for adding the entities
}
}
Next, we need to construct the object containing the entities to be rendered by the React Native Game Engine. In this game, there are four entities we need to render, three of them are physical (two balls, one goal), while the other is logical (physics). Since there is a need to mirror the objects (and their positions) in both player instances, we first generate the objects in player one’s instance. Once the objects are generated, we send the object’s position to player two via Pusher:
this.entities = {};
if (this.isPlayerOne) {
const ballOneStartPoint = GetRandomPoint(GRID_X, GRID_Y); // generate a random point to put the pink ball
const ballTwoStartPoint = GetRandomPoint(GRID_X, GRID_Y); // generate a random point to put the blue ball
const ballOne = this._createBall(ballOneStartPoint, 'ballOne'); // create the pink ball (for player one)
const ballTwo = this._createBall(ballTwoStartPoint, 'ballTwo'); // create the blue ball (for player two)
this.myBall = ballOne;
this.myBallName = 'ballOne';
this.opponentBall = ballTwo;
this.opponentBallName = 'ballTwo';
const goalPoint = GetRandomPoint(GRID_X, GRID_Y); // generate a random goal point
const goal = this._createGoal(goalPoint); // create the goal box
const { engine, world } = this._addObjectsToWorld(maze, ballOne, ballTwo, goal); // add all the objects to the world
this.entities = this._getEntities(engine, world, maze, ballOne, ballTwo, goal); // get the entities of the game
this._setupPositionUpdater(); // call the interval timer for updating the opponent of the current user's ball position
this._setupGoalListener(engine); // setup the sensor for listening if a ball has touched the goal
// send the position of the generated objects to player two
this.opponentChannel.trigger('client-generated-objects', {
ballOneStartPoint,
ballTwoStartPoint,
goalPoint
});
}
If the second player is the one who’s logged in, the following event is triggered. This contains the positions for the two balls and the goal. Using this data, we construct player two’s world:
this.myChannel.bind('client-generated-objects', ({ ballOneStartPoint, ballTwoStartPoint, goalPoint }) => {
const ballOne = this._createBall(ballOneStartPoint, 'ballOne');
const ballTwo = this._createBall(ballTwoStartPoint, 'ballTwo');
const goal = this._createGoal(goalPoint);
this.myBall = ballTwo;
this.myBallName = 'ballTwo';
this.opponentBall = ballOne;
this.opponentBallName = 'ballOne';
const { engine, world } = this._addObjectsToWorld(maze, ballOne, ballTwo, goal);
this.entities = this._getEntities(engine, world, maze, ballOne, ballTwo, goal);
this._setupPositionUpdater();
this._setupGoalListener(engine);
});
Next, we add physics to the world. By default, MatterJS applies gravity to the world. We don’t really want that so we set the gravity to zero for both X and Y axis:
this.physics = (entities, { time }) => {
let engine = entities["physics"].engine;
engine.world.gravity = {
x: 0,
y: 0
};
Matter.Engine.update(engine, time.delta);
return entities;
};
// next: add this.moveBall
Next, we add the system for moving the ball. This filters move
events. This event is triggered when the user moves their finger across the screen. Note that this listens for that event on the entire screen so the user doesn’t actually need to place their finger directly on top of the ball in order to move it. As you can see, this function specifically targets this.myBall
. this.myBall.position
contains the current position of the ball, while move.delta
contains the data on how much the finger was moved across the screen. We add that up to the ball’s current position in order to move it to that direction:
this.moveBall = (entities, { touches }) => {
let move = touches.find(x => x.type === "move");
if (move) {
// move.delta.pageX is negative if moving fingers to the left
// move.delta.pageX is negative if moving fingers to the top
const newPosition = {
x: this.myBall.position.x + move.delta.pageX,
y: this.myBall.position.y + move.delta.pageY
};
Matter.Body.setPosition(this.myBall, newPosition);
}
return entities;
};
// next: listen for the start-game event
Next, listen for the event for starting the game. All we do here is show an alert and update the state so it shows the generated maze instead of the activity indicator:
this.myChannel.bind('start-game', () => {
Alert.alert('Game Start!', 'You may now navigate towards the black square.');
this.setState({
isMazeReady: true
});
});
// next: listen for client-moved-ball
Next, listen for the event for moving the opponent’s ball:
this.myChannel.bind('client-moved-ball', ({ positionX, positionY }) => {
Matter.Body.setPosition(this.opponentBall, {
x: positionX,
y: positionY
});
});
That’s pretty much all there is to it for the Game screen. Let’s now go over the functions we used for constructing the world.
First is the function for creating a ball. This accepts the ball’s start point and the name you want to assign to it. The name is very important here because it’s what we use to determine which ball touched the goal:
constructor(props) {
// ...
}
_createBall = (startPoint, name) => {
const ball = Matter.Bodies.circle(
startPoint.x,
startPoint.y,
BALL_SIZE,
{
...ballSettings,
label: name
}
);
return ball;
}
Next is the function for creating the goal box. Not unlike the ball, we don’t need to add a whole lot of physics settings to the goal. That’s because it only acts as a sensor. It gets rendered to the world, but it doesn’t actually interact or affect the rest of it (For example, the ball shouldn’t bounce if it touches it, nor does it move because of the force applied by the ball). The key setting here is isSensor: true
:
_createGoal = (goalPoint) => {
const goal = Matter.Bodies.rectangle(goalPoint.x, goalPoint.y, GOAL_SIZE, GOAL_SIZE, {
isStatic: true,
isSensor: true,
label: 'goal'
});
return goal;
}
Next is the function for adding the objects to the world. Aside from the two balls and the goal, we also need to add the maze that we generated earlier:
_addObjectsToWorld = (maze, ballOne, ballTwo, goal) => {
const engine = Matter.Engine.create({ enableSleeping: false }); // enableSleeping tells the engine to stop updating and collision checking bodies that have come to rest
const world = engine.world;
Matter.World.add(world, [
maze, ballOne, ballTwo, goal
]);
return {
engine,
world
}
}
Next is the _getEntities
function. This is responsible for constructing the entities
object that we need to pass to the React Native Game Engine. This includes the physics, the two balls, the goal, and the maze walls. All of these objects (except for the physics
), requires the body
and renderer
. All the other options are simply passed as a prop to the renderer to customize its style (bgColor
, size
, borderColor
):
_getEntities = (engine, world, maze, ballOne, ballTwo, goal) => {
const entities = {
physics: {
engine,
world
},
playerOneBall: {
body: ballOne,
bgColor: '#FF5877',
borderColor: '#FFC1C1',
renderer: Circle
},
playerTwoBall: {
body: ballTwo,
bgColor: '#458ad0',
borderColor: '#56a4f3',
renderer: Circle
},
goalBox: {
body: goal,
size: [GOAL_SIZE, GOAL_SIZE],
color: '#414448',
renderer: Rectangle
}
};
const walls = Matter.Composite.allBodies(maze); // get the children of the composite body
walls.forEach((body, index) => {
const { min, max } = body.bounds;
const width = max.x - min.x;
const height = max.y - min.y;
Object.assign(entities, {
['wall_' + index]: {
body: body,
size: [width, height],
color: '#fbb050',
renderer: Rectangle
}
});
});
return entities;
}
The _setupPositionUpdater
function triggers the event for updating the current user’s ball position on their opponent’s side. We can actually do this inside the system for moving the ball (this.moveBall
) but that gets called multiple times over a span of a few milliseconds so it’s not really recommended. Also, make sure to only execute it if no one has reached the goal yet:
_setupPositionUpdater = () => {
setInterval(() => {
if (!this.state.isGameFinished) { // nobody has reached the goal yet
this.opponentChannel.trigger('client-moved-ball', {
positionX: this.myBall.position.x,
positionY: this.myBall.position.y
});
}
}, 1000);
}
The _setupGoalListener
is responsible for listening for collision events. These collision events are triggered from the engine so we’re attaching the listener to the engine itself. collisionStart
gets fired at the very beginning of a collision. This provides data on the bodies which collided. The first body (bodyA
) always contain the body which initiated the collision. In this case, it’s always one of the two balls. bodyB
, on the other hand, contains the body which receives the collision. In this case, it’s the goal box. But since the goal box is set as a sensor (isSensor: true
), it won’t actually affect the ball in any way. It will only register that it collided with the ball:
_setupGoalListener = (engine) => {
Matter.Events.on(engine, "collisionStart", event => {
var pairs = event.pairs;
var objA = pairs[0].bodyA.label;
var objB = pairs[0].bodyB.label;
if (objA == this.myBallName && objB == 'goal') {
Alert.alert("You won", "And that's awesome!");
this.setState({
isGameFinished: true
});
} else if (objA == this.opponentBallName && objB == 'goal') {
Alert.alert("You lose", "And that sucks!");
this.setState({
isGameFinished: true
});
}
});
}
Lastly, render the UI:
render() {
if (this.state.isMazeReady) {
return (
<GameEngine
systems={[this.physics, this.moveBall]}
entities={this.entities}
>
</GameEngine>
);
}
return <ActivityIndicator size="large" color="#0064e1" />;
}
Circle component
Here’s the code for the Circle component:
// src/components/Circle.js
import React from "react";
import { View, Dimensions } from "react-native";
import dimensions from '../data/constants';
const { width, height } = dimensions;
const BODY_DIAMETER = Math.floor(width * .02);
const BORDER_WIDTH = 2;
const Circle = ({ body, bgColor, borderColor }) => {
const { position } = body;
const x = position.x;
const y = position.y;
return <View style={[styles.head, {
left: x,
top: y,
backgroundColor: bgColor,
borderColor: borderColor
}]} />;
};
export default Circle;
const styles = {
head: {
borderWidth: BORDER_WIDTH,
width: BODY_DIAMETER,
height: BODY_DIAMETER,
position: "absolute",
borderRadius: BODY_DIAMETER * 2
}
};
Rectangle component
Here’s the code for the Rectangle component:
// src/components/Rectangle.js
import React from "react";
import { View } from "react-native";
const Rectangle = ({ body, size, color }) => {
const width = size[0];
const height = size[1];
const x = body.position.x - width / 2;
const y = body.position.y - height / 2;
return (
<View
style={{
position: "absolute",
left: x,
top: y,
width: width,
height: height,
backgroundColor: color
}}
/>
);
};
export default Rectangle;
CreateMaze helper
The CreateMaze helper is really the main meat of this app because it’s the one which generates the maze that the players have to navigate. There are lots of maze generation algorithms out there. This helper uses the recursive backtracking algorithm. Here’s the way it works:
- Create a grid.
- Choose a random cell within the grid. In this case, we’re selecting the very first cell in the grid to start with.
- Check if there are any cells you can visit from the current cell.
- If there is, then:
- Pick a random neighbor.
- Put the neighbor on the stack.
- Mark the path from the current cell to the neighbor.
- If there are no more cells you can go to, mark the cell as “visited” and pop it out of the stack. The cell you’ve gone to prior to the cell that was popped out is now the current cell.
- Repeat steps 3 to 5 until all the cells in the grid have been visited or popped out of the stack.
Now that you have a general idea of how we’re going to implement this, it’s time to proceed with the code. Start by importing the things we need:
// src/helpers/CreateMaze.js
import Matter from 'matter-js';
import GetRandomNumber from './GetRandomNumber';
import dimensions from '../data/constants';
const { width, height } = dimensions;
// convenience variables:
const TOP = 'T';
const BOTTOM = 'B';
const RIGHT = 'R';
const LEFT = 'L';
Next, we represent the grid using an array. The CreateMaze
class accepts the number of cells in the X and Y axis for its constructor. By default, it’s going to generate a 15x18 grid. this.grid
contains a two-dimensional array of null
values. This will be filled later with the directions in which a path is carved on each row in the grid:
const CreateMaze = (gridX = 15, gridY = 18) => {
this.width = gridX;
this.height = gridY;
this.blockWidth = Math.floor(width / this.width); // 24
this.blockHeight = Math.floor(height / this.height); // 38
this.grid = new Array(this.height)
for (var i = 0; i < this.grid.length; i++) {
this.grid[i] = new Array(this.width);
}
// next: initialize the composite body
}
Next, initialize the composite body for containing the maze:
const wallOpts = {
isStatic: true,
};
this.matter = Matter.Composite.create(wallOpts);
Next, start carving the path. We’re going to start at the very first cell for this one, but you can pretty much start anywhere. Just remember that the grid is only 15x18 so you can only pick numbers between 0 to 14 for the X axis, and numbers between 0 to 17 for the Y axis:
this.carvePathFrom(0, 0, this.grid);
carvePathFrom
is a recursive function. It will call itself recursively until it has visited all the cells in the grid. It works by randomly picking which direction to go to first from the current cell. It then loops through those directions to determine if they can be visited or not. As you learned earlier, a cell can be visited if it can be accessed from the current cell and that it hasn’t already been visited. The getDirectionX
and getDirectionY
function checks if the next cell in the X or Y axis can be visited:
carvePathFrom = (x, y, grid) => {
const directions = [TOP, BOTTOM, RIGHT, LEFT]
.sort(f => 0.5 - GetRandomNumber()); // whichever direction is closest to the random number is first in the list.
directions.forEach(dir => {
const nX = x + this.getDirectionX(dir);
const nY = y + this.getDirectionY(dir);
const xNeighborOK = nX >= 0 && nX < this.width;
const yNeighborOK = nY >= 0 && nY < this.height;
// next: check if cell can be visited
});
}
Only when both these functions return either 0
or 1
, and that the next cell hasn’t already been visited will it proceed to call itself again. Don’t forget to put the visited direction into the grid. This will tell the function that the specific cell has already been visited. We also need to add the direction opposite to the current direction as the next path:
if (xNeighborOK && yNeighborOK && grid\[nY\][nX] == undefined) {
grid\[y\][x] = grid\[y\][x] || dir;
grid\[nY\][nX] = grid\[nY\][nX] || this.getOpposite(dir);
this.carvePathFrom(nX, nY, grid);
}
Here are the variables we used in the above function:
const LEFT = 'L';
// add these:
const directionX = {
'T': 0, // neutral because were moving in the X axs
'B': 0,
'R': 1, // +1 because were moving forward (we started at 0,0 so we move from left to right)
'L': -1 // -1 because were moving backward
};
const directionY = {
'T': -1, // -1 because were moving backward
'B': 1, // +1 because were moving forward (we started at 0,0 so we move from top to bottom)
'R': 0, // neutral because were moving in the Y axis
'L': 0
};
// opposite directions
const op = {
'T': BOTTOM, // top's opposite is bottom
'B': TOP,
'R': LEFT,
'L': RIGHT
};
And here are the functions:
this.matter = Matter.Composite.create(wallOpts);
// add these:
getDirectionX = (dir) => {
return directionX[dir];
}
getDirectionY = (dir) => {
return directionY[dir];
}
getOpposite = (dir) => {
return op[dir];
}
Now that we have the grid and the path in place, the next step is to construct the walls:
this.carvePathFrom(0, 0, this.grid);
// add these:
for (var i = 0; i < this.grid.length; i++) { // rows
for (var j = 0; j < this.grid[i].length; j++) { // columns
Matter.Composite.add(this.matter, this.generateWall(j, i));
}
}
The generateWall
function accepts the cell’s address in the X and Y axis. The first one is always going to be 0,0
since that’s the very first cell we visited. From there, we figure out which part of the cell do we draw the walls. We do that by checking if the current cell we are in isn’t part of the path. gridPoint
is the cell we’re currently iterating over and it contains the direction of the path to be visited from there (either T
, L
, B
or R
). For example, if we’re visiting 0,0
and it contains B
as part of the path then only the top, right, and left walls will be generated. Aside from that, we also need to consider the opposite. The getPointInDirection
is key for that. This function is responsible for returning the direction (either T
, L
, B
or R
) of the next cell to visit, but it only does so if the address to the next cell in the given direction is greater than 0
. So it’s just checking if we’ve actually moved forward in that specific direction:
generateWall = (x, y) => {
const walls = Matter.Composite.create({ isStatic: true });
const gridPoint = this.grid\[y\][x];
const opts = {
isStatic: true
};
const wallThickness = 5; // how thick the wall is in pixels
const topPoint = this.getPointInDirection(x, y, TOP);
if (gridPoint !== TOP && topPoint !== this.getOpposite(TOP)) {
Matter.Composite.add(walls, Matter.Bodies.rectangle(this.blockWidth / 2, 0, this.blockWidth, wallThickness, opts));
}
const bottomPoint = this.getPointInDirection(x, y, BOTTOM);
if (gridPoint !== BOTTOM && bottomPoint !== this.getOpposite(BOTTOM)) {
Matter.Composite.add(walls, Matter.Bodies.rectangle(this.blockWidth / 2, this.blockHeight, this.blockWidth, wallThickness, opts));
}
const leftPoint = this.getPointInDirection(x, y, LEFT);
if (gridPoint !== LEFT && leftPoint !== this.getOpposite(LEFT)) {
Matter.Composite.add(walls, Matter.Bodies.rectangle(0, this.blockHeight / 2, wallThickness, this.blockHeight + wallThickness, opts));
}
const rightPoint = this.getPointInDirection(x, y, RIGHT);
if (gridPoint !== RIGHT && rightPoint !== this.getOpposite(RIGHT)) {
Matter.Composite.add(walls, Matter.Bodies.rectangle(this.blockWidth, this.blockHeight / 2, wallThickness, this.blockHeight + wallThickness, opts));
}
// next: create vector
}
The final step before we return the walls is to actually put the walls in their proper position. In the code above, all we did was create Rectangle bodies and add it to the composite body. We haven’t actually specified the correct position for them (which cells they need to be added to). That’s where the Vector class comes in. We use it to change the position of the walls so that they’re on the cells where they need to be. For this, we simply multiply the cell address with the width or height of each cell in order to get their proper position. Then we use the translate
method to actually move the walls to that position:
const translate = Matter.Vector.create(x * this.blockWidth, y * this.blockHeight);
Matter.Composite.translate(walls, translate);
return walls;
Here’s the getPointInDirection
function:
getOpposite = (dir) => {
// ...
}
getPointInDirection = (x, y, dir) => {
const newXPoint = x + this.getDirectionX(dir);
const newYPoint = y + this.getDirectionY(dir);
if (newXPoint < 0 || newXPoint >= this.width) {
return;
}
if (newYPoint < 0 || newYPoint >= this.height) {
return;
}
return this.grid\[newYPoint\][newXPoint];
}
GetRandomPoint helper
Here’s the code for getting a random point within the grid:
// src/helpers/GetRandomNumber.js
import dimensions from '../data/constants';
const { width, height } = dimensions;
import GetRandomNumber from './GetRandomNumber';
const GetRandomPoint = (gridX, gridY) => {
const gridXPart = (width / gridX);
const gridYPart = (height / gridY);
const x = Math.floor(GetRandomNumber() * gridX);
const y = Math.floor(GetRandomNumber() * gridY);
return {
x: x * gridXPart + gridXPart / 2,
y: y * gridYPart + gridYPart / 2
}
}
export default GetRandomPoint;
GetRandomNumber helper
Here’s the code for generating random numbers:
// src/helpers/GetRandomNumber.js
var seed = 1;
const GetRandomNumber = () => {
var x = Math.sin(seed++) * 10000;
return x - Math.floor(x);
}
export default GetRandomNumber;
Server
Now we’re ready to work with the server. Start by navigating to the app’s server
directory and install the dependencies:
cd server
yarn
Next, update the .env
file with your Pusher 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, create a server.js
file and add the following. This sets up the server and Pusher:
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 }));
// setup Pusher
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, add the route for authenticating users with Pusher:
var users = []; // for storing the users who logs in
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);
console.log(username + " logged in");
var auth = pusher.authenticate(socketId, channel);
res.send(auth);
});
Next, add the route for triggering the event that an opponent was found:
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!");
});
Next, add the route for triggering the event for starting the game:
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!");
});
Lastly, run the server on port 5000
:
var port = process.env.PORT || 5000;
app.listen(port);
Running the app
Start by running the server:
cd server
node server
./ngrok http 5000
Next, update the the login screen with the ngrok URL:
// src/screens/Login.js
const base_url = "YOUR NGROK HTTPS URL";
Finally, run the app:
react-native run-android
Since this app requires two players, I recommend that you set up the development server on one or two of the devices (or emulator). That way, you don’t need to physically connect the device to their computer (they only need to be connected to the same WI-FI network). This also helps avoid the confusion of where the React Native CLI will deploy the app if two devices are connected at the same time.
The screen above can be accessed by shaking the device (or executing adb shell input keyevent 82
in the terminal), select Dev Settings, and select Debug server host & port for device. Then enter your computer’s internal IP address in the box. The default port where the bundler runs is 8081
.
Here’s the workflow I use for running the app:
- Connect device A and run the app.
- Set up development server on device A.
- Disconnect device A.
- Repeat the above steps for the next device.
- Log in user on device A.
- Log in user on device B.
- Access
http://localhost:5000/opponent-found
on your browser. - Dismiss the alerts that show up in the app.
- Access
http://localhost:5000/start-game
on your browser. - Dismiss the alert and start navigating the maze on both devices. The position of each ball should be updated every 500 milliseconds. Once a player finds their way to the goal, both players are notified.
Conclusion
In this tutorial, you learned how to construct a maze using MatterJS. By using Pusher, we were able to update the position of each player within the maze in realtime.
But as you’ve seen, the game we created isn’t really production-ready. If you’re looking for a challenge, here are a few things that need additional work:
- Automate Pusher events - automate the events for informing the players that an opponent was found, and the event for starting the game. As you’ve seen, we’re manually triggering these events by accessing a specific server route.
- Remove the dimension constraint - the game is limited to a certain dimension. This means that if the player is on a tablet or a phone with a larger screen or smaller screen than those dimensions, then it won’t really look good. For smaller screens, it will also end up hindering the functionality because the maze will be bigger than the screen.
- Generate random mazes - even though we have a random number generator, it doesn’t really generate a random maze. It’s set up that way because of the need to have the same maze generated for both players.
- Generate random points that make sense - the app generates random points within the grid for the starting point of each player as well as the goal. So it also becomes a game of luck because the goal might be nearer to another player.
- Prevent the players from cheating - there’s a long time MatterJS issue which allows objects to pass through walls if they have sufficient velocity. The app isn’t really an exception because if you swipe your finger fast enough, the ball can actually pass through the walls. One way to solve this is to change the game setup. The player will now have to change the device orientation in order to move the ball (using the device’s gyroscope). You can use the React Native Sensors package to implement this. This should prevent the ball from gaining too much velocity because the walls are usually tightly packed. Another solution would be to detect collisions between the ball and the walls, such that it immediately set the ball’s velocity to a safe one.
- Add a basic game loop - once a player wins, the game ends there. It would be good to have a basic game loop. Such that the app will ask the players if they want to continue playing the game after it finishes. If they answer “yes”, then a new maze will be generated and the game will start all over again.
Once you’ve implemented the above improvements, the game is basically ready for the app store.
You can find the code for this app on its GitHub repo.
15 March 2019
by Wern Ancheta