Build a game using device sensors in React Native

Introduction

In this tutorial, we’ll take a look at how you can get the device’s accelerometer data to create a simple dodge game.

Most modern smartphones are equipped with sensors such as the gyroscope, accelerometer, and magnetometer. These sensors are responsible for getting the data required for apps like the compass and your health app.

Prerequisites

You will need a good level of understanding of React Native, and familiarity with building and running apps in your development environment to follow this tutorial.

The following package versions are used to create the app:

  • Node 11.2
  • Yarn 1.13
  • React Native 0.59

If you have trouble running the app later on, try to use the versions above.

You will also need a real device for testing the app as you can’t really tilt in an emulator.

App overview

The app that we will create is a simple game of dodge. Blocks will be falling from the top part of the screen. The player will then have to slightly tilt their device to the left or to the right to move the ball so they can dodge the falling blocks.

Tilting the device to the right will make the ball go to the right, while tilting it to the left does the opposite. If the ball goes off all the way to the left or right where the player can’t see it, it automatically goes back to the middle part of the screen. The bottom part of the screen is where the floor is.

Once a block collides with the floor, it means that the player has successfully evaded it and their score will be incremented. At any point in the game, the player can also click on the RESET button to restart the game. We will be using React Native Sensors to get the sensor data, React Native Game Engine to implement the game, and MatterJS as the physics engine.

Here’s what the app will look like:

react-native-sensors-game-demo

You can view the code used in this tutorial on its GitHub repo.

Bootstrapping the app

I’ve prepared a repo which you can clone in order to get the exact same package versions that I used for creating the app. Execute the following commands to bootstrap the app:

1git clone https://github.com/anchetaWern/RNSensorDemo.git
2    cd RNSensorDemo
3    git checkout starter
4    yarn
5    react-native eject

React Native Sensors is a native module, so you have to follow the additional steps in setting it up on their website.

Building the app

Once you’ve bootstrapped the app, update the App.js file at the root of the project directory and add the following. This will import all the packages we’ve installed:

1import React, { Component } from "react";
2    import { StyleSheet, Text, View, Dimensions, Button, Alert } from "react-native";
3    import {
4      accelerometer,
5      setUpdateIntervalForType,
6      SensorTypes
7    } from "react-native-sensors"; // for getting sensor data
8    
9    import { GameEngine } from "react-native-game-engine"; // for implementing the game
10    import Matter from "matter-js"; // for implementing game physics (gravity, collision)
11    
12    import randomInt from "random-int"; // for generating random integer
13    import randomColor from "randomcolor"; // for generating random hex color codes

Next, import the components for rendering the blocks and the ball. We will be creating these later:

1import Circle from "./src/components/Circle";
2    import Box from "./src/components/Box";

Each of the blocks won’t be falling at the same rate, otherwise, it would be impossible for the player to dodge them all. MatterJS is responsible for implementing game physics. This way, all of the objects in the game (ball, blocks, and floor) will have their own physical attributes. One of the physical attributes which we can assign is the frictionAir. This allows us to define the air resistance of the object. The higher the value of this attribute, the faster it will travel through space. The getRandomDecimal helper allows us to generate a random value to make the blocks fall faster or slower. We will also create this later:

    import getRandomDecimal from "./src/helpers/getRandomDecimal";

Next, get the device’s height and width. We will be using those to calculate either the position or the dimensions of each of the objects. Below, we also calculate for the middle part of the screen. We’ll use this later on as the initial position for the ball, as well as the position it goes back to if it goes out of the visible area:

1const { height, width } = Dimensions.get('window');
2    
3    const BALL_SIZE = 20; // the ball's radius
4    const DEBRIS_HEIGHT = 70; // the block's height
5    const DEBRIS_WIDTH = 20; // the block's width
6    
7    const mid_point = (width / 2) - (BALL_SIZE / 2); // position of the middle part of the screen

Next, declare the physical attributes of the ball and blocks. The main difference between these two objects is that the ball is static. This means it cannot move on its own. It has to rely on the device’s accelerometer in order to calculate its new position. While the blocks are non-static, which means that they can be affected by physical phenomena such as gravity. This allows us to automatically make the blocks fall without actually doing anything:

1const ballSettings = {
2      isStatic: true
3    };
4    
5    const debrisSettings = { // blocks physical settings
6      isStatic: false
7    };

Next, create the bodies to be used for each of the objects. For now, we’re only creating the bodies for the ball and the floor. Because the blocks needs to have varying physical attributes and positioning, we’ll generate their corresponding bodies when the component is mounted:

1const ball = Matter.Bodies.circle(0, height - 30, BALL_SIZE, {
2      ...ballSettings, // spread the object
3      label: "ball" // add label as a property
4    });
5    
6    const floor = Matter.Bodies.rectangle(width / 2, height, width, 10, {
7      isStatic: true,
8      isSensor: true,
9      label: "floor"
10    });

The code above uses the Matter.Bodies.Circle and Matter.Bodies.Rectangle methods from MatterJS to create a body with circular and rectangular frame. Both methods expect the x and y position of the body for the first and second arguments. While the third argument for the Circle is the radius, and the third and fourth argument for the Rectangle is the width and height of the body. The last argument is an object containing the object’s physical settings. A label is also added so we can easily tell each object apart when they collide.

Next, set the update interval for a specific sensor type. In this case, we’re using the accelerometer and we want to update every 15 milliseconds. This means that the function for getting the accelerometer data will only fire off every 15 milliseconds:

    setUpdateIntervalForType(SensorTypes.accelerometer, 15);

Note: For production apps, play around with the interval to come up with the best value to balance between the ball’s responsiveness and battery drain. 15 is just an arbitrary value I came up with during testing.

Next, create the main app component and initialize the state. The state is mainly used for setting the ball’s position and keeping track of the score:

1export default class App extends Component {
2      
3      state = {
4        x: 0, // the ball's initial X position
5        y: height - 30, // the ball's initial Y position
6        isGameReady: false, // game is not ready by default
7        score: 0 // the player's score
8      }
9      
10      // next: add constructor
11    
12    }

Next, add the constructor. This contains the code for initializing the objects (also called entities) in the game and setting up the collision handler:

1constructor(props) {
2      super(props);
3    
4      this.debris = [];
5    
6      const { engine, world } = this._addObjectsToWorld(ball);
7      this.entities = this._getEntities(engine, world, ball);
8    
9      this._setupCollisionHandler(engine);
10    
11      this.physics = (entities, { time }) => {
12        let engine = entities["physics"].engine; // get the reference to the physics engine
13        engine.world.gravity.y = 0.5; // set the gravity of Y axis
14        Matter.Engine.update(engine, time.delta); // move the game forward in time
15        return entities;
16      };
17    }
18    
19    // next: add componentDidMount

Once the component is mounted, we subscribe to get the accelerometer data. In this case, we only need to get the data in the x axis because the ball is constrained to move only within the x axis. From there, we can set the ball’s current position by using the body’s setPosition method. All we have to do is add x to the current value of x in the state. This gives us the new position to be used for the ball:

1componentDidMount() {
2      accelerometer.subscribe(({ x }) => {
3    
4        Matter.Body.setPosition(ball, {
5          x: this.state.x + x, 
6          y: height - 30 // should be constant
7        });
8    
9        this.setState(state => ({
10          x: x + state.x
11        }), () => {
12          // next: add code for resetting the ball's position if it goes out of view
13        });
14    
15      });
16    
17      this.setState({
18        isGameReady: true
19      });
20    }
21    
22    // next: add componentWillUnmount

If the ball goes off to the part of the screen which the user cannot see, we want to the bring it back to its initial position. That way, they can start controlling it again. this.state.x contains the current position of the ball, so we can simply check if its less than 0 (disappeared off to the left part of the screen) or greater than the device's width (disappeared off to the right part of the screen):

1if (this.state.x < 0 || this.state.x > width) {
2      Matter.Body.setPosition(ball, {
3        x: mid_point,
4        y: height - 30
5      });
6    
7      this.setState({
8        x: mid_point
9      });
10    }

Next, unsubscribe from getting the accelerometer data once the component is unmounted. We don’t want to continuously drain the user’s battery if it’s no longer needed:

1componentWillUnmount() {
2      this.accelerometer.stop();
3    }
4    
5    // next: _addObjectsToWorld

Next, add the code for adding the objects to the world. Earlier, we already created the objects for the ball and the floor. But we’re still yet to create the objects for the blocks. The physics engine is still unaware of the ball and floor object, so we have to add them to the world. Here’s the code for that:

1_addObjectsToWorld = (ball) => {
2      const engine = Matter.Engine.create({ enableSleeping: true });
3      const world = engine.world;
4    
5      let objects = [
6        ball,
7        floor
8      ];
9      
10      // create the bodies for the blocks
11      for (let x = 0; x <= 5; x++) {
12        const debris = Matter.Bodies.rectangle(
13          randomInt(1, width - 30), // x position
14          randomInt(0, 200), // y position
15          DEBRIS_WIDTH,
16          DEBRIS_HEIGHT,
17          {
18            frictionAir: getRandomDecimal(0.01, 0.5),
19            label: 'debris'
20          }
21        );
22    
23        this.debris.push(debris);
24      }
25    
26      objects = objects.concat(this.debris); // add the blocks to the array of objects 
27      Matter.World.add(world, objects); // add the objects
28    
29      return {
30        engine,
31        world
32      }
33    }
34    
35    // next: add _getEntities

In the above code, we’re using MatterJS to create the physics engine. enableSleeping is set to true so that the engine will stop updating and collision tracking objects that have come to rest. This setting is mostly used as a performance boost. Once the engine is created, we create six rectangle bodies. These are the blocks (or debris) that will fall from the top part of the screen. Their initial y position and frictionAir will vary depending on the random numeric value that’s generated. Once all the blocks are generated, we add it to the array of objects and add them to the world.

Next, add the code for getting the entities to be rendered by React Native Game Engine. Note that each of these corresponds to a MatterJS object (ball, floor, and blocks). Each entity has a body, size, and renderer. The color we assigned to the gameFloor and debris is just passed to its renderer as a prop. As you’ll see in the code for the Box component later, the color is assigned as the background color:

1_getEntities = (engine, world, ball) => {
2      const entities = {
3        physics: {
4          engine,
5          world
6        },
7    
8        playerBall: {
9          body: ball,
10          size: [BALL_SIZE, BALL_SIZE], // width, height
11          renderer: Circle
12        },
13    
14        gameFloor: {
15          body: floor,
16          size: [width, 10],
17          color: '#414448',
18          renderer: Box
19        }
20      };
21    
22      for (let x = 0; x <= 5; x++) { // generate the entities for the blocks
23        Object.assign(entities, {
24          ['debris_' + x]: {
25            body: this.debris[x],
26            size: [DEBRIS_WIDTH, DEBRIS_HEIGHT],
27            color: randomColor({
28              luminosity: 'dark', // only generate dark colors so they can be easily seen
29            }),
30            renderer: Box
31          }
32        });
33      }
34    
35      return entities;
36    }
37    
38    // next: _setupCollisionHandler

Next, add the code for setting up the collision handler. In the code below, we listen for the collisionStart event. This event is triggered when any of the objects in the world starts colliding. event.pairs stores the information on which objects have started colliding. If a block hits the floor, it means the player have successfully evaded it. We don’t really want to generate new objects as the game proceeds so we simply reuse the existing objects. We can do this by setting a new initial position, that way, it can start falling again. In the case that the ball hit a block, we loop through all the blocks and set them as a static object. This will have a similar effect to gravity being turned off, so the blocks are actually frozen in mid air. At this point, the game is considered over:

1_setupCollisionHandler = (engine) => {
2      Matter.Events.on(engine, "collisionStart", (event) => {
3        var pairs = event.pairs;
4    
5        var objA = pairs[0].bodyA.label;
6        var objB = pairs[0].bodyB.label;
7    
8        if(objA === 'floor' && objB === 'debris') {
9          Matter.Body.setPosition(pairs[0].bodyB, { // set new initial position for the block
10            x: randomInt(1, width - 30),
11            y: randomInt(0, 200)
12          });
13          
14          // increment the player score
15          this.setState(state => ({
16            score: state.score + 1
17          }));
18        }
19    
20        if (objA === 'ball' && objB === 'debris') {
21          Alert.alert('Game Over', 'You lose...');
22          this.debris.forEach((debris) => {
23            Matter.Body.set(debris, {
24              isStatic: true
25            });
26          });
27        }
28      });
29    }
30    // next: add render

Next, render the UI. The GameEngine component from React Native Game Engine is used to render the entities that we’ve generated earlier. Inside it is the button for resetting the game, and a text for showing the player’s current score:

1render() {
2      const { isGameReady, score } = this.state;
3    
4      if (isGameReady) {
5        return (
6          <GameEngine
7            style={styles.container}
8            systems={[this.physics]}
9            entities={this.entities}
10          >
11            <View style={styles.header}>
12              <Button
13                onPress={this.reset}
14                title="Reset"
15                color="#841584"
16              />
17              <Text style={styles.scoreText}>{score}</Text>
18            </View>
19          </GameEngine>
20        );
21      }
22      return null;
23    }
24    
25    // next: add reset

Here’s the code for resetting the game:

1reset = () => {
2      this.debris.forEach((debris) => { // loop through all the blocks
3        Matter.Body.set(debris, {
4          isStatic: false // make the block susceptible to gravity again
5        });
6        Matter.Body.setPosition(debris, { // set new position for the block
7          x: randomInt(1, width - 30),
8          y: randomInt(0, 200)
9        });
10      });
11    
12      this.setState({ 
13        score: 0 // reset the player score
14      });
15    }

Lastly, here are the styles:

1const styles = StyleSheet.create({
2      container: {
3        flex: 1,
4        backgroundColor: '#F5FCFF',
5      },
6      header: {
7        padding: 20,
8        alignItems: 'center'
9      },
10      scoreText: {
11        fontSize: 25,
12        fontWeight: 'bold'
13      }
14    });

Box component

Here’s the code for the Box component:

1// src/components/Box.js
2    import React, { Component } from "react";
3    import { View } from "react-native";
4    
5    const Box = ({ 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 Box;

Circle component

Here’s the code for the Circle component:

1// src/components/Circle.js
2    import React, { Component } from "react";
3    import { View, StyleSheet, Dimensions } from "react-native";
4    
5    const { height, width } = Dimensions.get('window');
6    
7    const BODY_DIAMETER = Math.trunc(Math.max(width, height) * 0.05);
8    const BORDER_WIDTH = Math.trunc(BODY_DIAMETER * 0.1);
9    
10    const Circle = ({ body }) => {
11      const { position } = body;
12      const x = position.x - BODY_DIAMETER / 2;
13      const y = position.y - BODY_DIAMETER / 2;
14      return <View style={[styles.head, { left: x, top: y }]} />;
15    };
16    
17    export default Circle;
18    
19    const styles = StyleSheet.create({
20      head: {
21        backgroundColor: "#FF5877",
22        borderColor: "#FFC1C1",
23        borderWidth: BORDER_WIDTH,
24        width: BODY_DIAMETER,
25        height: BODY_DIAMETER,
26        position: "absolute",
27        borderRadius: BODY_DIAMETER * 2
28      }
29    });

Random decimal helper

Here’s the code for generating a random decimal:

1// src/helpers/getRandomDecimal.js
2    const getRandomDecimal = (min, max) => {
3      return Math.random() * (max - min) + min;
4    }
5    
6    export default getRandomDecimal;

Running the app

At this point, you should be able to run the app and play the game:

1react-native run-android
2    react-native run-ios

Conclusion

In this tutorial, you learned how to get the device’s accelerometer data from a React Native app and use it to control the ball.

You can view the code used in this tutorial on its GitHub repo.