In this tutorial, we will create a two-player maze game with React Native and Pusher.
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:
We’ll also be using ngrok to expose the Pusher auth server to the internet.
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.
Start by cloning the repo:
1git clone https://github.com/anchetaWern/RNMaze.git 2 cd RNMaze
Next, switch to the starter
branch and install the dependencies:
1git checkout starter 2 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:
1PUSHER_APP_KEY="YOUR PUSHER APP KEY" 2 PUSHER_APP_CLUSTER="YOUR PUSHER APP CLUSTER"
Let’s start by adding the code for the login screen:
1// src/screens/Login.js 2 import React, { Component } from "react"; 3 import { View, Text, TextInput, TouchableOpacity, Alert } from "react-native"; 4 5 import Pusher from "pusher-js/react-native"; 6 import Config from "react-native-config"; // for reading the .env file 7 8 const pusher_app_key = Config.PUSHER_APP_KEY; 9 const pusher_app_cluster = Config.PUSHER_APP_CLUSTER; 10 const base_url = "YOUR HTTPS NGROK URL"; 11 12 class LoginScreen extends Component { 13 static navigationOptions = { 14 title: "Login" 15 }; 16 17 state = { 18 username: "", 19 enteredGame: false 20 }; 21 22 constructor(props) { 23 super(props); 24 this.pusher = null; 25 this.myChannel = null; // the current user's Pusher channel 26 this.opponentChannel = null; // the opponent's Pusher channel 27 } 28 29 // next: add render() 30 }
Next, render the UI for the login screen. This asks for the user’s username so they can log in:
1render() { 2 return ( 3 <View style={styles.wrapper}> 4 <View style={styles.container}> 5 <View style={styles.main}> 6 <View> 7 <Text style={styles.label}>Enter your username</Text> 8 <TextInput 9 style={styles.textInput} 10 onChangeText={username => this.setState({ username })} 11 value={this.state.username} 12 /> 13 </View> 14 15 {!this.state.enteredGame && ( 16 <TouchableOpacity onPress={this.enterGame}> 17 <View style={styles.button}> 18 <Text style={styles.buttonText}>Login</Text> 19 </View> 20 </TouchableOpacity> 21 )} 22 23 {this.state.enteredGame && ( 24 <Text style={styles.loadingText}>Loading...</Text> 25 )} 26 </View> 27 </View> 28 </View> 29 ); 30 }
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:
1enterGame = async () => { 2 const myUsername = this.state.username; 3 4 if (myUsername) { 5 this.setState({ 6 enteredGame: true // show login activity indicator 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: myUsername } 14 }, 15 encrypted: true 16 }); 17 18 // next: add code for subscribing the user to their own channel 19 20 } 21 };
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:
1this.myChannel = this.pusher.subscribe(`private-user-${myUsername}`); 2 this.myChannel.bind("pusher:subscription_error", status => { 3 Alert.alert( 4 "Error", 5 "Subscription error occurred. Please restart the app" 6 ); 7 }); 8 9 // 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:
1this.myChannel.bind("pusher:subscription_succeeded", () => { 2 3 this.myChannel.bind("opponent-found", data => { 4 const opponentUsername = 5 myUsername == data.player_one ? data.player_two : data.player_one; 6 7 const isPlayerOne = myUsername == data.player_one ? true : false; 8 9 const ballColor = (isPlayerOne) ? 'pink' : 'blue'; // pink ball always goes to the first player 10 11 Alert.alert("Opponent found!", `Use the ${ballColor} ball`); 12 13 this.opponentChannel = this.pusher.subscribe( 14 `private-user-${opponentUsername}` 15 ); 16 this.opponentChannel.bind("pusher:subscription_error", data => { 17 console.log("Error subscribing to opponent's channel: ", data); 18 }); 19 20 this.opponentChannel.bind("pusher:subscription_succeeded", () => { 21 this.props.navigation.navigate("Game", { 22 pusher: this.pusher, 23 isPlayerOne: isPlayerOne, 24 myUsername: myUsername, 25 myChannel: this.myChannel, 26 opponentUsername: opponentUsername, 27 opponentChannel: this.opponentChannel 28 }); 29 }); 30 31 this.setState({ 32 username: "", 33 enteredGame: false // hides the login activity indicator 34 }); 35 }); 36 });
Now we’re ready to add the code for the Game screen. Start by importing the packages, components, and helpers we need:
1// src/screens/Game.js 2 import React, { PureComponent } from "react"; 3 import { View, Text, Alert, ActivityIndicator } from "react-native"; 4 5 import Matter from "matter-js"; // physics engine, collision detection 6 import { GameEngine } from "react-native-game-engine"; // rendering game objects 7 8 import Circle from '../components/Circle'; // renderer for the balls 9 import Rectangle from '../components/Rectangle'; // renderer for the maze walls 10 11 import CreateMaze from '../helpers/CreateMaze'; // for generating the maze 12 import GetRandomPoint from '../helpers/GetRandomPoint'; // for getting a random point in the grid 13 14 // the hardcoded width and height contraints of the app 15 import dimensions from '../data/constants'; 16 const { width, height } = dimensions;
Next, create the constants file (src/data/constants.js
) we used above:
1const constants = { 2 width: 360, 3 height: 686.67 4 } 5 6 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:
1const BALL_SIZE = Math.floor(width * .02); 2 const ballSettings = { 3 inertia: 0, 4 friction: 0, 5 frictionStatic: 0, 6 frictionAir: 0, 7 restitution: 0, 8 density: 1 9 }; 10 11 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:
1const GRID_X = 15; // the number of cells in the X axis 2 const GRID_Y = 18; // the number of cells in the Y axis 3 4 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:
1export default class Game extends PureComponent { 2 3 static navigationOptions = { 4 header: null 5 }; 6 7 state = { 8 isMazeReady: false, // whether to show the maze or not 9 isGameFinished: false // whether someone has already reached the goal or not 10 } 11 12 constructor(props) { 13 super(props); 14 15 const { navigation } = this.props; 16 17 this.pusher = navigation.getParam('pusher'); 18 this.myUsername = navigation.getParam('myUsername'); 19 this.opponentUsername = navigation.getParam('opponentUsername'); 20 21 this.myChannel = navigation.getParam('myChannel'); 22 this.opponentChannel = navigation.getParam('opponentChannel'); 23 this.isPlayerOne = navigation.getParam('isPlayerOne'); 24 25 // next: add code for adding the entities 26 } 27 }
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:
1this.entities = {}; 2 3 if (this.isPlayerOne) { 4 const ballOneStartPoint = GetRandomPoint(GRID_X, GRID_Y); // generate a random point to put the pink ball 5 const ballTwoStartPoint = GetRandomPoint(GRID_X, GRID_Y); // generate a random point to put the blue ball 6 7 const ballOne = this._createBall(ballOneStartPoint, 'ballOne'); // create the pink ball (for player one) 8 const ballTwo = this._createBall(ballTwoStartPoint, 'ballTwo'); // create the blue ball (for player two) 9 10 this.myBall = ballOne; 11 this.myBallName = 'ballOne'; 12 this.opponentBall = ballTwo; 13 this.opponentBallName = 'ballTwo'; 14 15 const goalPoint = GetRandomPoint(GRID_X, GRID_Y); // generate a random goal point 16 const goal = this._createGoal(goalPoint); // create the goal box 17 18 const { engine, world } = this._addObjectsToWorld(maze, ballOne, ballTwo, goal); // add all the objects to the world 19 20 this.entities = this._getEntities(engine, world, maze, ballOne, ballTwo, goal); // get the entities of the game 21 22 this._setupPositionUpdater(); // call the interval timer for updating the opponent of the current user's ball position 23 this._setupGoalListener(engine); // setup the sensor for listening if a ball has touched the goal 24 25 // send the position of the generated objects to player two 26 this.opponentChannel.trigger('client-generated-objects', { 27 ballOneStartPoint, 28 ballTwoStartPoint, 29 goalPoint 30 }); 31 }
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:
1this.myChannel.bind('client-generated-objects', ({ ballOneStartPoint, ballTwoStartPoint, goalPoint }) => { 2 3 const ballOne = this._createBall(ballOneStartPoint, 'ballOne'); 4 const ballTwo = this._createBall(ballTwoStartPoint, 'ballTwo'); 5 const goal = this._createGoal(goalPoint); 6 7 this.myBall = ballTwo; 8 this.myBallName = 'ballTwo'; 9 this.opponentBall = ballOne; 10 this.opponentBallName = 'ballOne'; 11 12 const { engine, world } = this._addObjectsToWorld(maze, ballOne, ballTwo, goal); 13 14 this.entities = this._getEntities(engine, world, maze, ballOne, ballTwo, goal); 15 16 this._setupPositionUpdater(); 17 this._setupGoalListener(engine); 18 });
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:
1this.physics = (entities, { time }) => { 2 let engine = entities["physics"].engine; 3 engine.world.gravity = { 4 x: 0, 5 y: 0 6 }; 7 Matter.Engine.update(engine, time.delta); 8 return entities; 9 }; 10 11 // 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:
1this.moveBall = (entities, { touches }) => { 2 let move = touches.find(x => x.type === "move"); 3 if (move) { 4 // move.delta.pageX is negative if moving fingers to the left 5 // move.delta.pageX is negative if moving fingers to the top 6 const newPosition = { 7 x: this.myBall.position.x + move.delta.pageX, 8 y: this.myBall.position.y + move.delta.pageY 9 }; 10 Matter.Body.setPosition(this.myBall, newPosition); 11 } 12 13 return entities; 14 }; 15 16 // 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:
1this.myChannel.bind('start-game', () => { 2 Alert.alert('Game Start!', 'You may now navigate towards the black square.'); 3 this.setState({ 4 isMazeReady: true 5 }); 6 }); 7 // next: listen for client-moved-ball
Next, listen for the event for moving the opponent’s ball:
1this.myChannel.bind('client-moved-ball', ({ positionX, positionY }) => { 2 Matter.Body.setPosition(this.opponentBall, { 3 x: positionX, 4 y: positionY 5 }); 6 });
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:
1constructor(props) { 2 // ... 3 } 4 5 _createBall = (startPoint, name) => { 6 const ball = Matter.Bodies.circle( 7 startPoint.x, 8 startPoint.y, 9 BALL_SIZE, 10 { 11 ...ballSettings, 12 label: name 13 } 14 ); 15 return ball; 16 }
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
:
1_createGoal = (goalPoint) => { 2 const goal = Matter.Bodies.rectangle(goalPoint.x, goalPoint.y, GOAL_SIZE, GOAL_SIZE, { 3 isStatic: true, 4 isSensor: true, 5 label: 'goal' 6 }); 7 return goal; 8 }
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:
1_addObjectsToWorld = (maze, ballOne, ballTwo, goal) => { 2 const engine = Matter.Engine.create({ enableSleeping: false }); // enableSleeping tells the engine to stop updating and collision checking bodies that have come to rest 3 const world = engine.world; 4 5 Matter.World.add(world, [ 6 maze, ballOne, ballTwo, goal 7 ]); 8 9 return { 10 engine, 11 world 12 } 13 }
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
):
1_getEntities = (engine, world, maze, ballOne, ballTwo, goal) => { 2 const entities = { 3 physics: { 4 engine, 5 world 6 }, 7 playerOneBall: { 8 body: ballOne, 9 bgColor: '#FF5877', 10 borderColor: '#FFC1C1', 11 renderer: Circle 12 }, 13 playerTwoBall: { 14 body: ballTwo, 15 bgColor: '#458ad0', 16 borderColor: '#56a4f3', 17 renderer: Circle 18 }, 19 goalBox: { 20 body: goal, 21 size: [GOAL_SIZE, GOAL_SIZE], 22 color: '#414448', 23 renderer: Rectangle 24 } 25 }; 26 27 const walls = Matter.Composite.allBodies(maze); // get the children of the composite body 28 walls.forEach((body, index) => { 29 30 const { min, max } = body.bounds; 31 const width = max.x - min.x; 32 const height = max.y - min.y; 33 34 Object.assign(entities, { 35 ['wall_' + index]: { 36 body: body, 37 size: [width, height], 38 color: '#fbb050', 39 renderer: Rectangle 40 } 41 }); 42 }); 43 44 return entities; 45 }
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:
1_setupPositionUpdater = () => { 2 setInterval(() => { 3 if (!this.state.isGameFinished) { // nobody has reached the goal yet 4 this.opponentChannel.trigger('client-moved-ball', { 5 positionX: this.myBall.position.x, 6 positionY: this.myBall.position.y 7 }); 8 } 9 }, 1000); 10 }
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:
1_setupGoalListener = (engine) => { 2 3 Matter.Events.on(engine, "collisionStart", event => { 4 var pairs = event.pairs; 5 6 var objA = pairs[0].bodyA.label; 7 var objB = pairs[0].bodyB.label; 8 9 if (objA == this.myBallName && objB == 'goal') { 10 Alert.alert("You won", "And that's awesome!"); 11 this.setState({ 12 isGameFinished: true 13 }); 14 } else if (objA == this.opponentBallName && objB == 'goal') { 15 Alert.alert("You lose", "And that sucks!"); 16 this.setState({ 17 isGameFinished: true 18 }); 19 } 20 }); 21 }
Lastly, render the UI:
1render() { 2 if (this.state.isMazeReady) { 3 return ( 4 <GameEngine 5 systems={[this.physics, this.moveBall]} 6 entities={this.entities} 7 > 8 </GameEngine> 9 ); 10 } 11 12 return <ActivityIndicator size="large" color="#0064e1" />; 13 }
Here’s the code for the Circle component:
1// src/components/Circle.js 2 import React from "react"; 3 import { View, Dimensions } from "react-native"; 4 import dimensions from '../data/constants'; 5 const { width, height } = dimensions; 6 7 const BODY_DIAMETER = Math.floor(width * .02); 8 const BORDER_WIDTH = 2; 9 10 const Circle = ({ body, bgColor, borderColor }) => { 11 const { position } = body; 12 13 const x = position.x; 14 const y = position.y; 15 return <View style={[styles.head, { 16 left: x, 17 top: y, 18 backgroundColor: bgColor, 19 borderColor: borderColor 20 }]} />; 21 22 }; 23 24 export default Circle; 25 26 const styles = { 27 head: { 28 borderWidth: BORDER_WIDTH, 29 width: BODY_DIAMETER, 30 height: BODY_DIAMETER, 31 position: "absolute", 32 borderRadius: BODY_DIAMETER * 2 33 } 34 };
Here’s the code for the Rectangle component:
1// src/components/Rectangle.js 2 import React from "react"; 3 import { View } from "react-native"; 4 5 const Rectangle = ({ body, size, color }) => { 6 const width = size[0]; 7 const height = size[1]; 8 9 const x = body.position.x - width / 2; 10 const y = body.position.y - height / 2; 11 12 return ( 13 <View 14 style={{ 15 position: "absolute", 16 left: x, 17 top: y, 18 width: width, 19 height: height, 20 backgroundColor: color 21 }} 22 /> 23 ); 24 }; 25 26 export default Rectangle;
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:
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:
1// src/helpers/CreateMaze.js 2 import Matter from 'matter-js'; 3 4 import GetRandomNumber from './GetRandomNumber'; 5 6 import dimensions from '../data/constants'; 7 const { width, height } = dimensions; 8 9 // convenience variables: 10 const TOP = 'T'; 11 const BOTTOM = 'B'; 12 const RIGHT = 'R'; 13 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:
1const CreateMaze = (gridX = 15, gridY = 18) => { 2 3 this.width = gridX; 4 this.height = gridY; 5 6 this.blockWidth = Math.floor(width / this.width); // 24 7 this.blockHeight = Math.floor(height / this.height); // 38 8 9 this.grid = new Array(this.height) 10 for (var i = 0; i < this.grid.length; i++) { 11 this.grid[i] = new Array(this.width); 12 } 13 14 // next: initialize the composite body 15 16 }
Next, initialize the composite body for containing the maze:
1const wallOpts = { 2 isStatic: true, 3 }; 4 5 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:
1carvePathFrom = (x, y, grid) => { 2 3 const directions = [TOP, BOTTOM, RIGHT, LEFT] 4 .sort(f => 0.5 - GetRandomNumber()); // whichever direction is closest to the random number is first in the list. 5 6 directions.forEach(dir => { 7 const nX = x + this.getDirectionX(dir); 8 const nY = y + this.getDirectionY(dir); 9 const xNeighborOK = nX >= 0 && nX < this.width; 10 const yNeighborOK = nY >= 0 && nY < this.height; 11 12 // next: check if cell can be visited 13 }); 14 }
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:
1if (xNeighborOK && yNeighborOK && grid\[nY\][nX] == undefined) { 2 grid\[y\][x] = grid\[y\][x] || dir; 3 grid\[nY\][nX] = grid\[nY\][nX] || this.getOpposite(dir); 4 this.carvePathFrom(nX, nY, grid); 5 }
Here are the variables we used in the above function:
1const LEFT = 'L'; 2 3 // add these: 4 const directionX = { 5 'T': 0, // neutral because were moving in the X axs 6 'B': 0, 7 'R': 1, // +1 because were moving forward (we started at 0,0 so we move from left to right) 8 'L': -1 // -1 because were moving backward 9 }; 10 11 const directionY = { 12 'T': -1, // -1 because were moving backward 13 'B': 1, // +1 because were moving forward (we started at 0,0 so we move from top to bottom) 14 'R': 0, // neutral because were moving in the Y axis 15 'L': 0 16 }; 17 18 // opposite directions 19 const op = { 20 'T': BOTTOM, // top's opposite is bottom 21 'B': TOP, 22 'R': LEFT, 23 'L': RIGHT 24 };
And here are the functions:
1this.matter = Matter.Composite.create(wallOpts); 2 3 // add these: 4 getDirectionX = (dir) => { 5 return directionX[dir]; 6 } 7 8 getDirectionY = (dir) => { 9 return directionY[dir]; 10 } 11 12 getOpposite = (dir) => { 13 return op[dir]; 14 }
Now that we have the grid and the path in place, the next step is to construct the walls:
1this.carvePathFrom(0, 0, this.grid); 2 3 // add these: 4 for (var i = 0; i < this.grid.length; i++) { // rows 5 for (var j = 0; j < this.grid[i].length; j++) { // columns 6 Matter.Composite.add(this.matter, this.generateWall(j, i)); 7 } 8 }
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:
1generateWall = (x, y) => { 2 const walls = Matter.Composite.create({ isStatic: true }); 3 const gridPoint = this.grid\[y\][x]; 4 const opts = { 5 isStatic: true 6 }; 7 8 const wallThickness = 5; // how thick the wall is in pixels 9 10 const topPoint = this.getPointInDirection(x, y, TOP); 11 if (gridPoint !== TOP && topPoint !== this.getOpposite(TOP)) { 12 Matter.Composite.add(walls, Matter.Bodies.rectangle(this.blockWidth / 2, 0, this.blockWidth, wallThickness, opts)); 13 } 14 const bottomPoint = this.getPointInDirection(x, y, BOTTOM); 15 if (gridPoint !== BOTTOM && bottomPoint !== this.getOpposite(BOTTOM)) { 16 Matter.Composite.add(walls, Matter.Bodies.rectangle(this.blockWidth / 2, this.blockHeight, this.blockWidth, wallThickness, opts)); 17 } 18 const leftPoint = this.getPointInDirection(x, y, LEFT); 19 if (gridPoint !== LEFT && leftPoint !== this.getOpposite(LEFT)) { 20 Matter.Composite.add(walls, Matter.Bodies.rectangle(0, this.blockHeight / 2, wallThickness, this.blockHeight + wallThickness, opts)); 21 } 22 const rightPoint = this.getPointInDirection(x, y, RIGHT); 23 if (gridPoint !== RIGHT && rightPoint !== this.getOpposite(RIGHT)) { 24 Matter.Composite.add(walls, Matter.Bodies.rectangle(this.blockWidth, this.blockHeight / 2, wallThickness, this.blockHeight + wallThickness, opts)); 25 } 26 27 // next: create vector 28 }
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:
1const translate = Matter.Vector.create(x * this.blockWidth, y * this.blockHeight); 2 Matter.Composite.translate(walls, translate); 3 4 return walls;
Here’s the getPointInDirection
function:
1getOpposite = (dir) => { 2 // ... 3 } 4 5 getPointInDirection = (x, y, dir) => { 6 const newXPoint = x + this.getDirectionX(dir); 7 const newYPoint = y + this.getDirectionY(dir); 8 9 if (newXPoint < 0 || newXPoint >= this.width) { 10 return; 11 } 12 13 if (newYPoint < 0 || newYPoint >= this.height) { 14 return; 15 } 16 17 return this.grid\[newYPoint\][newXPoint]; 18 }
Here’s the code for getting a random point within the grid:
1// src/helpers/GetRandomNumber.js 2 import dimensions from '../data/constants'; 3 const { width, height } = dimensions; 4 import GetRandomNumber from './GetRandomNumber'; 5 6 const GetRandomPoint = (gridX, gridY) => { 7 const gridXPart = (width / gridX); 8 const gridYPart = (height / gridY); 9 const x = Math.floor(GetRandomNumber() * gridX); 10 const y = Math.floor(GetRandomNumber() * gridY); 11 12 return { 13 x: x * gridXPart + gridXPart / 2, 14 y: y * gridYPart + gridYPart / 2 15 } 16 } 17 18 export default GetRandomPoint;
Here’s the code for generating random numbers:
1// src/helpers/GetRandomNumber.js 2 var seed = 1; 3 const GetRandomNumber = () => { 4 var x = Math.sin(seed++) * 10000; 5 return x - Math.floor(x); 6 } 7 8 export default GetRandomNumber;
Now we’re ready to work with the server. Start by navigating to the app’s server
directory and install the dependencies:
1cd server 2 yarn
Next, update the .env
file with your Pusher 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, create a server.js
file and add the following. This sets up the server and Pusher:
1var express = require("express"); 2 var bodyParser = require("body-parser"); 3 var Pusher = require("pusher"); 4 5 require("dotenv").config(); 6 7 var app = express(); 8 app.use(bodyParser.json()); 9 app.use(bodyParser.urlencoded({ extended: false })); 10 11 // setup Pusher 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, add the route for authenticating users with Pusher:
1var users = []; // for storing the users who logs in 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); 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 that an opponent was found:
1app.get("/opponent-found", 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("opponent found: " + player_one + " and " + player_two); 10 11 pusher.trigger( 12 ["private-user-" + player_one, "private-user-" + player_two], 13 "opponent-found", 14 { 15 player_one: player_one, 16 player_two: player_two 17 } 18 ); 19 20 res.send("opponent found!"); 21 });
Next, add the route for triggering the event for starting the game:
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 });
Lastly, run the server on port 5000
:
1var port = process.env.PORT || 5000; 2 app.listen(port);
Start by running the server:
1cd server 2 node server 3 ./ngrok http 5000
Next, update the the login screen with the ngrok URL:
1// src/screens/Login.js 2 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:
http://localhost:5000/opponent-found
on your browser.http://localhost:5000/start-game
on your browser.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:
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.