Creating an accelerometer-powered maze game in React Native

Most smartphones come with a sensor of some sort: Gyroscope, accelerometer, ambient light, proximity, GPS, among others. These sensors each have their own functions, ranging from detecting the your phone’s orientation to determining your current position on the globe.

In this tutorial, we’ll take a look at how we can use the phone’s accelerometer to move a ball through a maze. We will be using React Native to create the app. For the accelerometer data, we’ll employ the help of React Native Sensors and build the game with MatterJS and React Native Game Engine.

Here’s what the final output will look like:

In the gif above, the ball is moving slowly because hitting the walls will make the ball go back to its starting position. This is one of the challenging aspects of the game because it’s really hard to balance the ball through the maze. Once the ball reaches the black square (the goal), the game ends with the option to start over with a new maze.

You can find the source code of the app on this GitHub repo.

Prerequisites

Basic knowledge of React Native is required to follow this tutorial.

We’ll be using a standard React Native project (using react native init), so I’ll assume you have set up your standard React Native development environment (Xcode or Android Studio). If you’re using Expo, you can also follow along, because they also have an accelerometer API available.

Setting up the app

Now we’re ready to set things up. Start by cloning the repo and installing the dependencies:

Once that’s done, you can start running the project to see if everything works:

Note that since we’re using device sensors, the only way to effectively test the app is by using a real device.

By default, you’ll only see a screen similar to the one below:

If you see it, you’re ready to start working with the code. Otherwise, check to see if you missed any of the above steps.

Building the app

Now we’re ready to start building the app. First, open the App.js file and add the following code. This will import all the modules that we’re going to need:

// App.js
import React, { Component } from 'react';
import { View, Alert, Dimensions } from 'react-native';
import { GameEngine } from 'react-native-game-engine';
import RNRestart from 'react-native-restart';

import {
  accelerometer,
  setUpdateIntervalForType,
  SensorTypes
} from "react-native-sensors";

import Matter from 'matter-js';
import Circle from './src/components/Circle';
import Rectangle from './src/components/Rectangle';

import CreateMaze from './src/helpers/CreateMaze';
import GetRandomPoint from './src/helpers/GetRandomPoint';

const { height, width } = Dimensions.get('window');

Next, generate the maze using the CreateMaze helper. We’ll go through the code for this one later. For now, know that this accepts how many rows (GRID_Y) and columns (GRID_X) make up the grid for generating the maze.

The greater the numbers are, the more complex the maze will be. For this game, we’ll stick with a relatively low number to make sure the game is challenging enough, but also not too frustrating:

// App.js
const GRID_X = 11; 
const GRID_Y = 16; 
const maze = CreateMaze(GRID_X, GRID_Y);

Next, we create the objects that will represent the ball and the goal. This uses standard MatterJS code for creating bodies. The size of the bodies depends on the screen width, and their position is random. This means that the difficulty of the individual game is highly dependent on those random values:

// App.js
const BALL_SIZE = Math.floor(width * .02);
const ballStartPoint = GetRandomPoint(GRID_X, GRID_Y);
const theBall = Matter.Bodies.circle(
  ballStartPoint.x,
  ballStartPoint.y,
  BALL_SIZE,
  {
    label: "ball"
  }
);

const GOAL_SIZE = Math.floor(width * .04); 
const goalPoint = GetRandomPoint(GRID_X, GRID_Y);
const goal = Matter.Bodies.rectangle(goalPoint.x, goalPoint.y, GOAL_SIZE, GOAL_SIZE, {
  isStatic: true,
  isSensor: true,
  label: 'goal'
});

For those unfamiliar with MatterJS, let’s break down the code a bit. In the code above, we’re creating two kinds of bodies: Circle (for the ball) and Rectangle (for the goal). Each of them accepts the following arguments:

  • Circle: x position, y position, radius, options
  • Rectangle: x position, y position, width, height, options

The GetRandomPoint() function is used to get a random point within the grid. The maze walls act as a border for the individual boxes that make up the grid, so there’s no possibility for the ball or the goal to be rendered within the wall.

The options represent the physical properties of the body:

  • isStatic — used for specifying whether the body can change its position or angle by itself (acted upon by a force such as gravity). Setting this property to true means that the body can only be moved by manually setting its position. Only the goal is a static body—even though it seems like the ball would be considered as a static body, it’s not because static bodies can pass through other bodies as well.
  • isSensor — used for indicating that the body can detect collisions but cannot be physically affected by them. The goal is set as a sensor because we don’t want the ball colliding into it. All we want is for the collision to get registered so we know that the player has reached the goal.
  • label — used for setting a descriptive name for the body. This allows us to easily check which of the bodies have collided.

Going back to the code, we now proceed to setting the update interval for the accelerometer data. In this case, we update it every 100ms. The smaller the number, the faster it updates.

We’re sticking to a relatively high number because we don’t want the ball to become too responsive. If we try setting the value to something like 10, the ball is going to move with every micro-movement.

// App.js
setUpdateIntervalForType(SensorTypes.accelerometer, 100);

Next, create the main component and initialize the state. In this case, we’re using it to keep track of the X and Y position of the ball. Having it in the state allows us to re-render the ball in its new position as the device is being moved:

// App.js
export default class App extends Component {
  
  state = {
    ballX: theBall.position.x,
    ballY: theBall.position.y,
  }
  
  // next: add componentDidMount
  
}

const styles = {
  container: {
    flex: 1
  }
}

Next, we construct the world and listen for collisions. The _addObjectsToWorld() function is used for adding the bodies that we’ve created earlier (maze, ball, and body), while the _getEntities() function is used for getting the entities to be rendered by React Native Game Engine (RNGE).

The bodies we created using MatterJS are different from the entities needed by RNGE. Think of the MatterJS bodies as the soul and the entities as the body. MatterJS is responsible for setting the physical properties of the components rendered in the UI. Lastly, the setupCollisionHandler() function is used for setting up the collision listener:

// App.js
componentDidMount() {
 
  const { engine, world } = this._addObjectsToWorld(maze, theBall, goal);
  this.entities = this._getEntities(engine, world, maze, theBall, goal);

  this._setupCollisionHandler(engine);

  // next: subscribe to accelerometer
  
}

We’ll take a look at the code for the functions we used later. For now, let’s look at the code for subscribing to the accelerometer data.

In the code below, we used the subscribe() function from the accelerometer module to listen for changes in the accelerometer sensor.

This accepts the function that we want to execute every time the data changes. The x, y, z, and timestamp is passed to the function.

In this case, we only need the x and y values. This represents the normalized x and y position of the device in the axis.

To update the ball’s position, we call the setPosition() method from MatterJS. This accepts the body as its first argument, and an object containing the new x and y position as its second argument. After that, we update the state with the current position of the ball:

// App.js
accelerometer.subscribe(({ x, y }) => {

  Matter.Body.setPosition(theBall, {
    x: this.state.ballX + x,
    y: this.state.ballY + y
  });

  this.setState({
    ballX: x + this.state.ballX,
    ballY: y + this.state.ballY,
  });

});

Next, let’s take a look at the code for the functions we used earlier.

First, _addObjectsToWorld() is used to create the “Engine”. Think of this as the thing which makes everything move—the battery of the clock if you will—while the individual bodies are the gears in the clock.

The engine has a property called world, which is where we add the individual bodies. Once they’re added, those bodies will be affected by physical phenomena such as gravity, and they can also collide with each other (provided they’re not a static body):

// App.js
_addObjectsToWorld = (maze, ball, goal) => {
  const engine = Matter.Engine.create({ enableSleeping: false });
  const world = engine.world;

  Matter.World.add(world, [
    maze, 
    ball, 
    goal
  ]);

  return {
    engine,
    world
  }
}

The _getEntities() function is used to construct the objects required by RNGE to render something on the screen.

In this case, physics is considered as a property, and it has both the engine and the world as its properties. The other entities after that are the objects that you want to render on the screen. This includes the ball (playerBall), the goal (goalBox), and the walls:

// App.js
_getEntities = (engine, world, maze, ball, goal) => {
  const entities = {
    physics: {
      engine,
      world
    },
    playerBall: {
      body: ball,
      bgColor: '#FF5877',
      borderColor: '#FFC1C1',
      renderer: Circle
    },

    goalBox: {
      body: goal,
      size: [GOAL_SIZE, GOAL_SIZE],
      color: '#414448',
      renderer: Rectangle
    }

  }

  const walls = Matter.Composite.allBodies(maze);
  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; 
}

Breaking down the code above, each entity only requires the body and the renderer as its properties. All the others—bgColor, borderColor, color, and size—are just passed as props to the renderer (as you’ll see later in the code for the Circle and Rectangle components).

The body is the MatterJS body that we created earlier, while the renderer is the component we want to use for rendering the entity:

As for the walls, they’re a bit different because we haven’t actually created them individually. Instead, they’re returned by the CreateMaze helper as a single composite object.

In simple terms, a composite object is an object made up of multiple objects. We can get the individual objects by using the Matter.Composite.allBodies() function. We then use it to add new properties to the entities object.

Lastly, here’s the code for the _setupCollisionHandler() function. This listens for the engine’s collisionStart event. Each collision only involves two objects: bodyA and bodyB. In this case, there can only be two possibilities:

  • ball collides with the goal
  • ball collides with the wall

Since we assigned a label to each of the bodies, all we have to do is get the label of the colliding bodies to check which ones have collided.

If the ball collides with the goal, then it means the player has won. When that happens, we ask them whether they want to start over. If they answer “Yes” then we restart the app using React Native Restart.

This is going to remove the existing listeners and clear the existing entities from memory (as if the app was re-opened). More importantly, it’s going to generate a new maze for the player to solve. On the other hand, if the ball and a wall collides, we bring the ball back to its starting position:

// App.js
_setupCollisionHandler = (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 === 'ball' && objB === 'goal') {
      Alert.alert(
        'Goal Reached!',
        'Do you want to start over?',
        [
          {
            text: 'Yes', 
            onPress: () => {
              RNRestart.Restart();
            }
          }
        ],
        { cancelable: true },
      );
    } else if (objA === 'wall' && objB === 'ball') {
      Matter.Body.setPosition(theBall, {
        x: ballStartPoint.x,
        y: ballStartPoint.y
      });

      this.setState({
        ballX: ballStartPoint.x,
        ballY: ballStartPoint.y
      });

    }
  });
}

Circle component

Here’s the code for the Circle component. The size of the ball is dependent upon the size of the screen. The current position is passed as a prop via body.position.

This is what the React Native Game Engine does. If you check the code from the _getEntities() function we wrote earlier, all the entities have a body property.

As you learned, this is the corresponding MatterJS body, and it has a property called body.position. We subtract the circle’s radius from this position to get the ball’s position:

// src/components/Circle.js
import React, { Component } from "react";
import { View,  Dimensions } from "react-native";

const { height, width } = Dimensions.get('window');

const BODY_DIAMETER = Math.trunc(Math.max(width, height) * 0.02);
const BORDER_WIDTH = Math.trunc(BODY_DIAMETER * 0.1);

const Circle = ({ body, bgColor, borderColor }) => {
  const { position } = body;
  const radius = BODY_DIAMETER / 2;
  
  const x = position.x - radius;
  const y = position.y - radius;
  return <View style={[styles.head, { left: x, top: y, backgroundColor: bgColor, 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. The position is calculated the same way as with the Circle 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;

GetRandomPoint and GetRandomNumber helper

Here’s the code for the GetRandomPoint and GetRandomNumber helpers. These work together to generate a random number and use it to calculate a random position within the grid where the maze is laid down:

// src/helpers/GetRandomPoint.js
import { Dimensions } from 'react-native';
const { height, width } = Dimensions.get('window');

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: Math.floor(x * gridXPart + gridXPart / 2),
    y: Math.floor(y * gridYPart + gridYPart / 2)
  }
}


export default GetRandomPoint;

CreateMaze helper

The CreateMaze helper uses the Recursive Backtracking algorithm to generate the maze. Here’s a bird’s-eye view of how the algorithm works:

  1. Pick a random starting point in the grid.
  2. From that point, pick a random wall and carve a route through an adjacent cell that hasn’t been visited yet. If it has already been visited, pick another random wall.
  3. If all the adjacent cells have been visited, go back to the previous cell by popping the last item out of the stack. Go back until it finds a cell with uncarved walls.
  4. Repeat step #3 until it has gone back to the starting point.

Note that that is how the algorithm works, but we’re not actually going to stick to it to the letter. As you’ll see later, we’ll break a few rules and change some things up.

Now that you know how it works, we can proceed with the code.

Start by importing the packages we need and the convenience objects that we’ll be using throughout this file:

// src/helpers/CreateMaze.js
import { Dimensions } from 'react-native';
import Matter from 'matter-js';

import GetRandomNumber from './GetRandomNumber';
const { height, width } = Dimensions.get('window');

const TOP = 'T';
const BOTTOM = 'B';
const RIGHT = 'R';
const LEFT = 'L';

const directionX = {
  'T': 0, 
  'B': 0,
  'R': 1,
  'L': -1
};

const directionY = {
  'T': -1,
  'B': 1,
  'R': 0,
  'L': 0
};

const op = {
  'T': BOTTOM, 
  'B': TOP,
  'R': LEFT,
  'L': RIGHT
};

Next, create the function for creating the maze. This is going to fill the grid’s entire height with an array of items. We’ll be filling this up later with the directions in which the path will be carved:

// src/helpers/CreateMaze.js
const CreateMaze = (gridX, gridY) => {

  this.width = gridX; 
  this.height = gridY; 

  this.blockWidth = Math.floor(width / this.width); 
  this.blockHeight = Math.floor(height / this.height); 

  this.grid = new Array(this.height)
  for (var i = 0; i < this.grid.length; i++) {
    this.grid[i] = new Array(this.width);
  }
  
  // next: initialize composite body
  
}

Next, initialize the composite body, which will represent the whole maze:

// src/helpers/CreateMaze.js
const wallOpts = {
  isStatic: true,
};

this.matter = Matter.Composite.create(wallOpts);

Next, we start carving a path in the grid. This uses the carvePathFrom() to fill the grid with the directions of the path (top, left, bottom, right). We then loop through each cell in the grid and create the walls using the generateWall() function. We’ll step through those two functions shortly:

// src/helpers/CreateMaze.js
this.carvePathFrom(0, 0, this.grid);

for (var i = 0; i < this.grid.length; i++) {
  for (var j = 0; j < this.grid[i].length; j++) {
    Matter.Composite.add(this.matter, this.generateWall(j, i));
  }
}

return this.matter;

The carvePathFrom() function accepts the x and y starting points, and the grid to fill. It will repeatedly get called until it goes back all the way to its starting point.

This function will first pick out random directions. R, L, T, B for example. It then loops through those directions and determines if it can still visit it. There are two conditions for this:

  1. Traversing the direction doesn’t make the wall go out of bounds of the grid.
  2. The cell hasn’t been visited yet.

After that, all we have to do is fill out the grid with the direction.

Here’s the code:

// src/helpers/CreateMaze.js
carvePathFrom = (x, y, grid) => {
 
  const directions = [TOP, BOTTOM, RIGHT, LEFT]
    .sort(f => 0.5 - GetRandomNumber()); 

  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;

    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);
    }
  }); 
}

getDirectionX(), getDirectionY() are convenience functions for getting the step for a specific movement. getDirectionX() is for movements along the X axis, while getDirectionY() is for movements along the Y axis.

For example, moving to the top (north) or bottom (south) direction on the X axis isn’t possible, so it’s 0. When moving to the right (east), the value is 1 since it’s moving forward.

But when moving to the left (west), the corresponding value is -1 since it’s moving backward. On the other hand, getOpposite() just returns the opposite of the supplied direction:

// src/helpers/CreateMaze.js  
getDirectionX = (dir) => {
  return directionX[dir];
}

getDirectionY = (dir) => {
  return directionY[dir];
}

getOpposite = (dir) => {
  return op[dir];
}

Next is the generateWall() function. This accepts the position of the cells within the grid where the walls will be created.

Each of the conditions helps us figure out whether to draw a wall on that side of the cell or not. gridPoint is the current cell being visited, and it contains the direction of the next cell in the path to be visited (T, B, L, R).

A wall will be drawn on every other direction but not the direction of the next cell in the path (and its opposite).

For example, if the current cell is at position0,3 and the next cell in the path is 0,4(direction is east/right), we don’t want to be creating walls to the right or left of the current cell.

The Matter.Composite.add() function is used to add a body to a composite body. Once that’s done, we set the position of the body that we’ve added by using Matter.Vector.create() to set the position, and Matter.Composite.translate() to actually change its position:

// src/helpers/CreateMaze.js
generateWall = (x, y) => {
  const walls = Matter.Composite.create({ isStatic: true });
  const gridPoint = this.grid[y][x];
  const opts = { 
    isStatic: true,
    label: 'wall'
  };

  const wallThickness = 5;

  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));
  }

  const translate = Matter.Vector.create(x * this.blockWidth, y * this.blockHeight);
  Matter.Composite.translate(walls, translate);

  return walls;
}

Lastly, here’s the code for the getPointInDirection() function. As you might have already guessed, this is responsible for returning the direction of the next cell to be visited:

// src/helpers/CreateMaze.js
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];
}

At this point, you can now run the app.

Conclusion

In this tutorial, you learned how to make use of the phone’s accelerometer to control the movement of a ball through a maze. Along the way, you also learned how to use React Native Game Engine and MatterJS to build a game in React Native.

You can find the source code of the app on this GitHub repo.

Avatar photo

Fritz

Our team has been at the forefront of Artificial Intelligence and Machine Learning research for more than 15 years and we're using our collective intelligence to help others learn, understand and grow using these new technologies in ethical and sustainable ways.

Comments 0 Responses

Leave a Reply

Your email address will not be published. Required fields are marked *