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.
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.
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.
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.
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:
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 headsize
is the value to set the size of the snake headxspeed
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 versarenderer
is responsible for rendering the componentupdateFrequency
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.
To make a game loop, the GameEngine
component has a prop called systems
that accepts an array of functions.
To keep everything structured, I am creating a folder called systems
and inserting a file called GameLoop.js
:
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
.
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.
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.
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.
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.
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; }
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 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 — try LogRocket for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to implement one-way and two-way data binding in Vue.js, using v-model and advanced techniques like defineModel for better apps.
Compare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.