Akshay Rana I'm a self-taught, full-time programmer. I have experience working with various technologies including React, React Native, WordPress, Node.js, Express.js, Raspberry Pi, Python, REST APIs, and GraphQL. I love to explore new technologies and update my skills.

How to build a simple game in React Native

11 min read 3229

Introduction

Today we are going to learn how to make a game using React Native. Because we are using React Native, this game will be cross-platform, meaning you can play the same game on Android, iOS, and on the web, too. Today, however, we are going to focus only on mobile devices. So let’s begin.

Getting started

To make any game, we need a loop that updates our game while we play. This loop is optimized to run the game smoothly, and for this purpose we are going to use React Native Game Engine.

First let’s create a new React Native app with the following command:

npx react-native init ReactNativeGame

After creating the project we need to add a dependency in order to add a game engine:

npm i -S react-native-game-engine

This command will add React Native Game Engine to our project.

So what kind of game are we going to make? For the sake of simplicity, let’s make a game with a snake that eats pieces of food and grows in length.

A brief introduction to React Native Game Engine

React Native Game Engine is a lightweight game engine. It includes a component that allows us to add arrays of objects as entities so that we can manipulate them. To write our logic for the game, we use an array of system props that allow us to manipulate entities (game objects), detect touches, and many other awesome details that help us to make a simple, functional game.

Let’s build a snake game in React Native

To make a game we need a canvas or container where we will add game objects. To make a canvas we simply add a view component with styling like so:

// App.js     
<View style={styles.canvas}>
</View>

And we can add our styling like this:

const styles = StyleSheet.create({
  canvas: {
    flex: 1,
    backgroundColor: "#000000",
    alignItems: "center",
    justifyContent: "center",
  }
});

In the canvas we will use the GameEngine component with some styling from React Native Game Engine:

import { GameEngine } from "react-native-game-engine";
import React, { useRef } from "react";
import Constants from "./Constants";


export default function App() {
  const BoardSize = Constants.GRID_SIZE * Constants.CELL_SIZE;
  const engine = useRef(null);
  return (
    <View style={styles.canvas}>
      <GameEngine
              ref={engine}
              style={{
                width: BoardSize,
                height: BoardSize,
                flex: null,
                backgroundColor: "white",
              }}
            />
    </View>
);

We also added a ref to the game engine using the useRef() React Hook for later use.

We also created a Constants.js file at the root of the project to store our constant values:

// Constants.js
import { Dimensions } from "react-native";
export default {
  MAX_WIDTH: Dimensions.get("screen").width,
  MAX_HEIGHT: Dimensions.get("screen").height,
  GRID_SIZE: 15,
  CELL_SIZE: 20
};

You will notice we are making a 15-by-15 grid where our snake will move.

At this time our game engine is set up to show the snake and its food. We need to add entities and props to the GameEngine component, but before that we need to create a snake and food component that will render on the device.

Creating game entities

Let’s first make the snake. The snake is divided into two parts, the head and the body (or tail). For now we will make the snake’s head and we will add the snake’s tail later in this tutorial.

To make the snake’s head we will make a Head component in the components folder:

Screenshot of components folder

As you can see we have three components: Head, Food, and Tail. We will see what’s inside of these files one by one in this tutorial.

In the Head component, we will create a view with some styling:

import React from "react";
import { View } from "react-native";
export default function Head({ position, size }) {
  return (
    <View
      style={{
        width: size,
        height: size,
        backgroundColor: "red",
        position: "absolute",
        left: position[0] * size,
        top: position[1] * size,
      }}
    ></View>
  );
} 

We will pass some props to set the size and position of the head.

We are using position: "absolute" property to easily move the head.

This will render a square, and we are not going to use anything more complex; a square or rectangle shape for the snake body, and a circle shape for the food.



Now let’s add this snake’s head to GameEngine.

To add any entity we need to pass an object in the entities props in GameEngine:

//App.js
import Head from "./components/Head";


 <GameEngine
        ref={engine}
        style={{
          width: BoardSize,
          height: BoardSize,
          flex: null,
          backgroundColor: "white",
        }}
        entities={{
          head: {
            position: [0, 0],
            size: Constants.CELL_SIZE,
            updateFrequency: 10,
            nextMove: 10,
            xspeed: 0,
            yspeed: 0,
            renderer: <Head />,
          }
        }} 
/>

We have passed an object in to the entities prop with the head key. These are the properties that it defines:

  • position is a set of coordinates in which to place the snake head
  • size is the value to set the size of the snake head
  • xspeed and yspeed are the values that determine the snake movement and direction, and can be 1, 0, or -1. Note that when xspeed is set to 1 or -1, then the value of yspeed must be 0 and vice versa
  • Finally, the renderer is responsible for rendering the component
  • updateFrequency and nextMove will be discussed later.

After adding the Head component, let’s add other components, too:

// commponets/Food/index.js
import React from "react";
import { View } from "react-native";
export default function Food({ position, size }) {
  return (
    <View
      style={{
        width: size,
        height: size,
        backgroundColor: "green",
        position: "absolute",
        left: position[0] * size,
        top: position[1] * size,
        borderRadius: 50
      }}
    ></View>
  );
}

The Food component is similar to the Head component, but we have changed the background color and border radius to make it a circle.

Now create a Tail component. This one can be tricky:

// components/Tail/index.js

import React from "react";
import { View } from "react-native";
import Constants from "../../Constants";
export default function Tail({ elements, position, size }) {
  const tailList = elements.map((el, idx) => (
    <View
      key={idx}
      style={{
        width: size,
        height: size,
        position: "absolute",
        left: el[0] * size,
        top: el[1] * size,
        backgroundColor: "red",
      }}
    />
  ));
  return (
    <View
      style={{
        width: Constants.GRID_SIZE * size,
        height: Constants.GRID_SIZE * size,
      }}
    >
      {tailList}
    </View>
  );
}

When the snake eats the food, we will add an element in the snake body so that our snake will grow. These elements will pass into the Tail component, which will indicate that it must get larger.

We will loop through all of the elements to create the whole snake body, append it, and then render.

After making all of the required components, let’s add these two components as GameEngine entities:

// App.js

import Food from "./components/Food";
import Tail from "./components/Tail";



// App.js
const randomPositions = (min, max) => {
    return Math.floor(Math.random() * (max - min + 1) + min);
  };


// App.js

<GameEngine
        ref={engine}
        style={{
          width: BoardSize,
          height: BoardSize,
          flex: null,
          backgroundColor: "white",
        }}
        entities={{
          head: {
            position: [0, 0],
            size: Constants.CELL_SIZE,
            updateFrequency: 10,
            nextMove: 10,
            xspeed: 0,
            yspeed: 0,
            renderer: <Head />,
          },
          food: {
            position: [
              randomPositions(0, Constants.GRID_SIZE - 1),
              randomPositions(0, Constants.GRID_SIZE - 1),
            ],
            size: Constants.CELL_SIZE,
            renderer: <Food />,
          },
          tail: {
            size: Constants.CELL_SIZE,
            elements: [],
            renderer: <Tail />,
          },
        }}

      /> 

To ensure randomness of food position, we have made a randomPositions function with minimum and maximum parameters.

In the tail, we have added an empty array in the initial state, so when the snake eats the food it will store each tail length in the elements: space.

At this point we have successfully created our game components. Now it’s time to add game logic in the game loop.

Game logic

To make a game loop, the GameEngine component has a prop called systems that accepts an array of functions.


More great articles from LogRocket:


To keep everything structured, I am creating a folder called systems and inserting a file called GameLoop.js:

Screenshot of systems folder

In this file we are exporting a function with certain parameters:

// GameLoop.js

export default function (entities, { events, dispatch }) {
  ...

  return entities;
}

The first parameter is entities, which contains all of the entities we passed to the GameEngine component so we can manipulate them. Another parameter is an object with properties, i.e. events and dispatch.

Moving the snake head

Let’s write the code to move the snake head in the right direction.

In the GameLoop.js function we will update the head position, as this function will be called in every frame:

// GameLoop.js
export default function (entities, { events, dispatch }) {
  const head = entities.head;
  head.position[0] += head.xspeed;
  head.position[1] += head.yspeed;
}

We are accessing the head using the entities parameter, and on every frame we are updating snake head’s position.

If you play the game now, nothing will happen because we have set our xspeed and yspeed to 0. If you set the xspeed or yspeed to 1, the snake’s head will move very fast.

To slow down the speed of the snake we will use the nextMove and updateFrequency values like this:

const head = entities.head;

head.nextMove -= 1;
if (head.nextMove === 0) {
  head.nextMove = head.updateFrequency;

  head.position[0] += head.xspeed;
  head.position[1] += head.yspeed;
}

We are updating the value of nextMove to 0 by subtracting by 1 on every frame. When the value is 0, the if condition is set to true and the nextMove value is updated back to the initial value, thereby moving the snake’s head.

Now the speed of the snake should be slower than before.

”Game over!” conditions

At this point we haven’t added a “Game over!” condition. The first “Game over!” condition is when the snake touches the wall, and the game stops running and shows the user a message to indicate that the game is over.

To add this condition we are using this code:

if (head.nextMove === 0) {
  head.nextMove = head.updateFrequency;
  if (
        head.position[0] + head.xspeed < 0 ||
        head.position[0] + head.xspeed >= Constants.GRID_SIZE ||
        head.position[1] + head.yspeed < 0 ||
        head.position[1] + head.yspeed >= Constants.GRID_SIZE
      ) {
        dispatch("game-over");
      } else {
        head.position[0] += head.xspeed;
        head.position[1] += head.yspeed;
    }

The second if condition is checking whether the snake head touched the walls or not. If that condition is true, then we are dispatching a "game-over" event using the dispatch function.

With the else, we are updating the snake’s head position.

Now let’s add the “Game over!” functionality.

Whenever we dispatch a "game-over" event, we will stop the game and show an alert which will say “Game over!” Let’s implement it.

To listen for the "game-over" event we need to pass the onEvent prop to the GameEngine component. To stop the game, we need to add a running prop and pass in useState.

Our GameEngine should look like this:

// App.js
import React, { useRef, useState } from "react";
import GameLoop from "./systems/GameLoop";

....
....

const [isGameRunning, setIsGameRunning] = useState(true);

....
....

 <GameEngine
        ref={engine}
        style={{
          width: BoardSize,
          height: BoardSize,
          flex: null,
          backgroundColor: "white",
        }}
        entities={{
          head: {
            position: [0, 0],
            size: Constants.CELL_SIZE,
            updateFrequency: 10,
            nextMove: 10,
            xspeed: 0,
            yspeed: 0,
            renderer: <Head />,
          },
          food: {
            position: [
              randomPositions(0, Constants.GRID_SIZE - 1),
              randomPositions(0, Constants.GRID_SIZE - 1),
            ],
            size: Constants.CELL_SIZE,
            renderer: <Food />,
          },
          tail: {
            size: Constants.CELL_SIZE,
            elements: [],
            renderer: <Tail />,
          },
        }}
        systems={[GameLoop]}
        running={isGameRunning}
        onEvent={(e) => {
          switch (e) {
            case "game-over":
              alert("Game over!");
              setIsGameRunning(false);
              return;
          }
        }}
      />

In the GameEngine we have added the systems prop and passed in an array with our GameLoop function, along with a running prop with an isGameRunning state. Finally, we have added the onEvent prop, which accepts a function with an event parameter so that we can listen for our events.

In this case we are listening for the "game-over" event in the switch statement, so when we receive the event we show the "Game over!" alert and set the isGameRunning state to false to stop the game.

Eating the food

We have written the “Game over!” logic, now let’s work on the logic of making the snake eat the food.

When the snake eats the food, then the position of food should change randomly.

Open GameLoop.js and write the below code:

// GameLoop.js

const randomPositions = (min, max) => {
  return Math.floor(Math.random() * (max - min + 1) + min);
};

export default function (entities, { events, dispatch }) {
  const head = entities.head;
  const food = entities.food;

  ....
  ....
  ....
  if (
        head.position[0] + head.xspeed < 0 ||
        head.position[0] + head.xspeed >= Constants.GRID_SIZE ||
        head.position[1] + head.yspeed < 0 ||
        head.position[1] + head.yspeed >= Constants.GRID_SIZE
      ) {
        dispatch("game-over");
      } else {

     head.position[0] += head.xspeed;
     head.position[1] += head.yspeed;

     if (
          head.position[0] == food.position[0] &&
          head.position[1] == food.position[1]
        ) {

          food.position = [
            randomPositions(0, Constants.GRID_SIZE - 1),
            randomPositions(0, Constants.GRID_SIZE - 1),
          ];
        }
  }

We have added an if condition to check if the snake head and the food’s position is the same (which would indicate the snake has “eaten” the food). Then, we are updating the food position using the randomPositions function as we did above in App.js. Notice that we are accessing the food from entities parameter.

Controlling the snake

Now let’s add controls of the snake. We are going to use buttons to control where the snake moves.

To do that we need to add buttons in the screen below the canvas:

// App.js

import React, { useRef, useState } from "react";
import { StyleSheet, Text, View } from "react-native";
import { GameEngine } from "react-native-game-engine";
import { TouchableOpacity } from "react-native-gesture-handler";
import Food from "./components/Food";
import Head from "./components/Head";
import Tail from "./components/Tail";
import Constants from "./Constants";
import GameLoop from "./systems/GameLoop";
export default function App() {
  const BoardSize = Constants.GRID_SIZE * Constants.CELL_SIZE;
  const engine = useRef(null);
  const [isGameRunning, setIsGameRunning] = useState(true);
  const randomPositions = (min, max) => {
    return Math.floor(Math.random() * (max - min + 1) + min);
  };
  const resetGame = () => {
    engine.current.swap({
      head: {
        position: [0, 0],
        size: Constants.CELL_SIZE,
        updateFrequency: 10,
        nextMove: 10,
        xspeed: 0,
        yspeed: 0,
        renderer: <Head />,
      },
      food: {
        position: [
          randomPositions(0, Constants.GRID_SIZE - 1),
          randomPositions(0, Constants.GRID_SIZE - 1),
        ],
        size: Constants.CELL_SIZE,
        updateFrequency: 10,
        nextMove: 10,
        xspeed: 0,
        yspeed: 0,
        renderer: <Food />,
      },
      tail: {
        size: Constants.CELL_SIZE,
        elements: [],
        renderer: <Tail />,
      },
    });
    setIsGameRunning(true);
  };
  return (
    <View style={styles.canvas}>
      <GameEngine
        ref={engine}
        style={{
          width: BoardSize,
          height: BoardSize,
          flex: null,
          backgroundColor: "white",
        }}
        entities={{
          head: {
            position: [0, 0],
            size: Constants.CELL_SIZE,
            updateFrequency: 10,
            nextMove: 10,
            xspeed: 0,
            yspeed: 0,
            renderer: <Head />,
          },
          food: {
            position: [
              randomPositions(0, Constants.GRID_SIZE - 1),
              randomPositions(0, Constants.GRID_SIZE - 1),
            ],
            size: Constants.CELL_SIZE,
            renderer: <Food />,
          },
          tail: {
            size: Constants.CELL_SIZE,
            elements: [],
            renderer: <Tail />,
          },
        }}
        systems={[GameLoop]}
        running={isGameRunning}
        onEvent={(e) => {
          switch (e) {
            case "game-over":
              alert("Game over!");
              setIsGameRunning(false);
              return;
          }
        }}
      />
      <View style={styles.controlContainer}>
        <View style={styles.controllerRow}>
          <TouchableOpacity onPress={() => engine.current.dispatch("move-up")}>
            <View style={styles.controlBtn} />
          </TouchableOpacity>
        </View>
        <View style={styles.controllerRow}>
          <TouchableOpacity
            onPress={() => engine.current.dispatch("move-left")}
          >
            <View style={styles.controlBtn} />
          </TouchableOpacity>
          <View style={[styles.controlBtn, { backgroundColor: null }]} />
          <TouchableOpacity
            onPress={() => engine.current.dispatch("move-right")}
          >
            <View style={styles.controlBtn} />
          </TouchableOpacity>
        </View>
        <View style={styles.controllerRow}>
          <TouchableOpacity
            onPress={() => engine.current.dispatch("move-down")}
          >
            <View style={styles.controlBtn} />
          </TouchableOpacity>
        </View>
      </View>
      {!isGameRunning && (
        <TouchableOpacity onPress={resetGame}>
          <Text
            style={{
              color: "white",
              marginTop: 15,
              fontSize: 22,
              padding: 10,
              backgroundColor: "grey",
              borderRadius: 10
            }}
          >
            Start New Game
          </Text>
        </TouchableOpacity>
      )}
    </View>
  );
}
const styles = StyleSheet.create({
  canvas: {
    flex: 1,
    backgroundColor: "#000000",
    alignItems: "center",
    justifyContent: "center",
  },
  controlContainer: {
    marginTop: 10,
  },
  controllerRow: {
    flexDirection: "row",
    justifyContent: "center",
    alignItems: "center",
  },
  controlBtn: {
    backgroundColor: "yellow",
    width: 100,
    height: 100,
  },
});

Other than controls, we have also added a button to start a new game when the previous game is over. This button only appears when the game is not running. On the click of that button, we are resetting the game by using the swap function from the game engine, passing in the entity’s initial object, and updating the game’s running state.

Now to the controls. We have added touchables, which, when pressed, dispatch the events that will be handled in the game loop:

// GameLoop.js
....
....
 export default function (entities, { events, dispatch }) {
    const head = entities.head;
    const food = entities.food;

  if (events.length) {
    events.forEach((e) => {
      switch (e) {
        case "move-up":
          if (head.yspeed === 1) return;
          head.yspeed = -1;
          head.xspeed = 0;
          return;
        case "move-right":
          if (head.xspeed === -1) return;
          head.xspeed = 1;
          head.yspeed = 0;
          return;
        case "move-down":
          if (head.yspeed === -1) return;
          head.yspeed = 1;
          head.xspeed = 0;
          return;
        case "move-left":
          if (head.xspeed === 1) return;
          head.xspeed = -1;
          head.yspeed = 0;
          return;
      }
    });
  }

....
....
});

In the above code we have added a switch statement to identify the events and update the snake direction.

Still with me? Great! The only thing left is the tail.

Tail functionality

When the snake eats the food, we want its tail to grow. We also want to dispatch a “Game over!” event when the snake bites its own tail or body.

Let’s add tail logic:

// GameLoop.js

const tail = entities.tail;

....
....

....

    else {
      tail.elements = [[head.position[0], head.position[1]], ...tail.elements];
      tail.elements.pop();

      head.position[0] += head.xspeed;
      head.position[1] += head.yspeed;

      tail.elements.forEach((el, idx) => {
        if (
          head.position[0] === el[0] &&
          head.position[1] === el[1] 
        )
          dispatch("game-over");
      });
      if (
        head.position[0] == food.position[0] &&
        head.position[1] == food.position[1]
      ) {
        tail.elements = [
          [head.position[0], head.position[1]],
          ...tail.elements,
        ];

        food.position = [
          randomPositions(0, Constants.GRID_SIZE - 1),
          randomPositions(0, Constants.GRID_SIZE - 1),
        ];
      }
    }

To make the tail follow the snake’s head, we are updating the tail’s elements. We do this by adding the head’s position to the beginning of the element array and then removing the last element on the tail’s elements array.

Now after this, we write a condition that if the snake bites its own body, we dispatch the "game-over" event.

And finally, whenever the snake eats the food, we are appending the tail elements by the head’s current position to grow the tail’s length.

Here is the full code of GameLoop.js:

// GameLoop.js

import Constants from "../Constants";
const randomPositions = (min, max) => {
  return Math.floor(Math.random() * (max - min + 1) + min);
};
  export default function (entities, { events, dispatch }) {
    const head = entities.head;
    const food = entities.food;
    const tail = entities.tail;
  if (events.length) {
    events.forEach((e) => {
      switch (e) {
        case "move-up":
          if (head.yspeed === 1) return;
          head.yspeed = -1;
          head.xspeed = 0;
          return;
        case "move-right":
          if (head.xspeed === -1) return;
          head.xspeed = 1;
          head.yspeed = 0;
          // ToastAndroid.show("move right", ToastAndroid.SHORT);
          return;
        case "move-down":
          if (head.yspeed === -1) return;
          // ToastAndroid.show("move down", ToastAndroid.SHORT);
          head.yspeed = 1;
          head.xspeed = 0;
          return;
        case "move-left":
          if (head.xspeed === 1) return;
          head.xspeed = -1;
          head.yspeed = 0;
          // ToastAndroid.show("move left", ToastAndroid.SHORT);
          return;
      }
    });
  }
  head.nextMove -= 1;
  if (head.nextMove === 0) {
    head.nextMove = head.updateFrequency;
    if (
      head.position[0] + head.xspeed < 0 ||
      head.position[0] + head.xspeed >= Constants.GRID_SIZE ||
      head.position[1] + head.yspeed < 0 ||
      head.position[1] + head.yspeed >= Constants.GRID_SIZE
    ) {
      dispatch("game-over");
    } else {
      tail.elements = [[head.position[0], head.position[1]], ...tail.elements];
      tail.elements.pop();
      head.position[0] += head.xspeed;
      head.position[1] += head.yspeed;
      tail.elements.forEach((el, idx) => {
        console.log({ el, idx });
        if (
          head.position[0] === el[0] &&
          head.position[1] === el[1] 
        )
          dispatch("game-over");
      });
      if (
        head.position[0] == food.position[0] &&
        head.position[1] == food.position[1]
      ) {
        tail.elements = [
          [head.position[0], head.position[1]],
          ...tail.elements,
        ];

        food.position = [
          randomPositions(0, Constants.GRID_SIZE - 1),
          randomPositions(0, Constants.GRID_SIZE - 1),
        ];
      }
    }
  }
  return entities;
}

Conclusion

Now your first game in React Native is done! You can run this game on your own device to play it. I hope you learned something new, and that you can share with your friends as well.

Thanks for reading and have a great day.

LogRocket: Instantly recreate issues in your React Native apps.

LogRocket is a React Native monitoring solution that helps you reproduce issues instantly, prioritize bugs, and understand performance in your React Native apps.

LogRocket also helps you increase conversion rates and product usage by showing you exactly how users are interacting with your app. LogRocket's product analytics features surface the reasons why users don't complete a particular flow or don't adopt a new feature.

Start proactively monitoring your React Native apps — .

Akshay Rana I'm a self-taught, full-time programmer. I have experience working with various technologies including React, React Native, WordPress, Node.js, Express.js, Raspberry Pi, Python, REST APIs, and GraphQL. I love to explore new technologies and update my skills.

Leave a Reply