Build a game using device sensors in React Native
You will need Node and React Native installed on your machine. Some knowledge of React Native development is expected.
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:
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:
git clone https://github.com/anchetaWern/RNSensorDemo.git
cd RNSensorDemo
git checkout starter
yarn
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:
import React, { Component } from "react";
import { StyleSheet, Text, View, Dimensions, Button, Alert } from "react-native";
import {
accelerometer,
setUpdateIntervalForType,
SensorTypes
} from "react-native-sensors"; // for getting sensor data
import { GameEngine } from "react-native-game-engine"; // for implementing the game
import Matter from "matter-js"; // for implementing game physics (gravity, collision)
import randomInt from "random-int"; // for generating random integer
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:
import Circle from "./src/components/Circle";
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:
const { height, width } = Dimensions.get('window');
const BALL_SIZE = 20; // the ball's radius
const DEBRIS_HEIGHT = 70; // the block's height
const DEBRIS_WIDTH = 20; // the block's width
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:
const ballSettings = {
isStatic: true
};
const debrisSettings = { // blocks physical settings
isStatic: false
};
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:
const ball = Matter.Bodies.circle(0, height - 30, BALL_SIZE, {
...ballSettings, // spread the object
label: "ball" // add label as a property
});
const floor = Matter.Bodies.rectangle(width / 2, height, width, 10, {
isStatic: true,
isSensor: true,
label: "floor"
});
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:
export default class App extends Component {
state = {
x: 0, // the ball's initial X position
y: height - 30, // the ball's initial Y position
isGameReady: false, // game is not ready by default
score: 0 // the player's score
}
// next: add constructor
}
Next, add the constructor. This contains the code for initializing the objects (also called entities) in the game and setting up the collision handler:
constructor(props) {
super(props);
this.debris = [];
const { engine, world } = this._addObjectsToWorld(ball);
this.entities = this._getEntities(engine, world, ball);
this._setupCollisionHandler(engine);
this.physics = (entities, { time }) => {
let engine = entities["physics"].engine; // get the reference to the physics engine
engine.world.gravity.y = 0.5; // set the gravity of Y axis
Matter.Engine.update(engine, time.delta); // move the game forward in time
return entities;
};
}
// 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:
componentDidMount() {
accelerometer.subscribe(({ x }) => {
Matter.Body.setPosition(ball, {
x: this.state.x + x,
y: height - 30 // should be constant
});
this.setState(state => ({
x: x + state.x
}), () => {
// next: add code for resetting the ball's position if it goes out of view
});
});
this.setState({
isGameReady: true
});
}
// 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):
if (this.state.x < 0 || this.state.x > width) {
Matter.Body.setPosition(ball, {
x: mid_point,
y: height - 30
});
this.setState({
x: mid_point
});
}
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:
componentWillUnmount() {
this.accelerometer.stop();
}
// 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:
_addObjectsToWorld = (ball) => {
const engine = Matter.Engine.create({ enableSleeping: true });
const world = engine.world;
let objects = [
ball,
floor
];
// create the bodies for the blocks
for (let x = 0; x <= 5; x++) {
const debris = Matter.Bodies.rectangle(
randomInt(1, width - 30), // x position
randomInt(0, 200), // y position
DEBRIS_WIDTH,
DEBRIS_HEIGHT,
{
frictionAir: getRandomDecimal(0.01, 0.5),
label: 'debris'
}
);
this.debris.push(debris);
}
objects = objects.concat(this.debris); // add the blocks to the array of objects
Matter.World.add(world, objects); // add the objects
return {
engine,
world
}
}
// 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:
_getEntities = (engine, world, ball) => {
const entities = {
physics: {
engine,
world
},
playerBall: {
body: ball,
size: [BALL_SIZE, BALL_SIZE], // width, height
renderer: Circle
},
gameFloor: {
body: floor,
size: [width, 10],
color: '#414448',
renderer: Box
}
};
for (let x = 0; x <= 5; x++) { // generate the entities for the blocks
Object.assign(entities, {
['debris_' + x]: {
body: this.debris[x],
size: [DEBRIS_WIDTH, DEBRIS_HEIGHT],
color: randomColor({
luminosity: 'dark', // only generate dark colors so they can be easily seen
}),
renderer: Box
}
});
}
return entities;
}
// 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:
_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 === 'floor' && objB === 'debris') {
Matter.Body.setPosition(pairs[0].bodyB, { // set new initial position for the block
x: randomInt(1, width - 30),
y: randomInt(0, 200)
});
// increment the player score
this.setState(state => ({
score: state.score + 1
}));
}
if (objA === 'ball' && objB === 'debris') {
Alert.alert('Game Over', 'You lose...');
this.debris.forEach((debris) => {
Matter.Body.set(debris, {
isStatic: true
});
});
}
});
}
// 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:
render() {
const { isGameReady, score } = this.state;
if (isGameReady) {
return (
<GameEngine
style={styles.container}
systems={[this.physics]}
entities={this.entities}
>
<View style={styles.header}>
<Button
onPress={this.reset}
title="Reset"
color="#841584"
/>
<Text style={styles.scoreText}>{score}</Text>
</View>
</GameEngine>
);
}
return null;
}
// next: add reset
Here’s the code for resetting the game:
reset = () => {
this.debris.forEach((debris) => { // loop through all the blocks
Matter.Body.set(debris, {
isStatic: false // make the block susceptible to gravity again
});
Matter.Body.setPosition(debris, { // set new position for the block
x: randomInt(1, width - 30),
y: randomInt(0, 200)
});
});
this.setState({
score: 0 // reset the player score
});
}
Lastly, here are the styles:
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5FCFF',
},
header: {
padding: 20,
alignItems: 'center'
},
scoreText: {
fontSize: 25,
fontWeight: 'bold'
}
});
Box component
Here’s the code for the Box component:
// src/components/Box.js
import React, { Component } from "react";
import { View } from "react-native";
const Box = ({ 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 Box;
Circle component
Here’s the code for the Circle component:
// src/components/Circle.js
import React, { Component } from "react";
import { View, StyleSheet, Dimensions } from "react-native";
const { height, width } = Dimensions.get('window');
const BODY_DIAMETER = Math.trunc(Math.max(width, height) * 0.05);
const BORDER_WIDTH = Math.trunc(BODY_DIAMETER * 0.1);
const Circle = ({ body }) => {
const { position } = body;
const x = position.x - BODY_DIAMETER / 2;
const y = position.y - BODY_DIAMETER / 2;
return <View style={[styles.head, { left: x, top: y }]} />;
};
export default Circle;
const styles = StyleSheet.create({
head: {
backgroundColor: "#FF5877",
borderColor: "#FFC1C1",
borderWidth: BORDER_WIDTH,
width: BODY_DIAMETER,
height: BODY_DIAMETER,
position: "absolute",
borderRadius: BODY_DIAMETER * 2
}
});
Random decimal helper
Here’s the code for generating a random decimal:
// src/helpers/getRandomDecimal.js
const getRandomDecimal = (min, max) => {
return Math.random() * (max - min) + min;
}
export default getRandomDecimal;
Running the app
At this point, you should be able to run the app and play the game:
react-native run-android
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.
25 July 2019
by Wern Ancheta