Making a Production-Ready Flappy Bird in React Native

Making a fun, playable, and good looking clone of a hit game

Tamas Szikszai
Sep 14 · 11 min read
Photo by Hello Lightbulb on Unsplash

In the previous piece, we created a proof of concept version of Flappy Bird using react-native-game-engine and Matter.js. While it was fun, it wasn’t even remotely the kind of game that any sane user would enjoy or even use for more than ten seconds.

Today we are aiming to make the game to be (more or less) production-ready.

TL;DR #1: Watch how I made this on Youtube.

TL;DR #2: Grab the source code on Github

This is what we are going to make:

I love working with lists so I’ve created a little to do list to accomplish the above:


Adding Graphics

A game is only as good as its graphics. Normally I would ask my designer or hire someone on UpWork to create a unique design for me, but in this case I’m happy with a pre-made template. I went on GraphicRiver and spent $7 for the Flappy Duck pack, which is perfect for demonstration purposes.

I did a little bit of slicing and dicing and ended up with the following assets:

I moved these to the folder in my project and created an Images.js file in to conveniently reference them later on.

export default Images = {
background: require('./img/background.png'),
floor: require('./img/floor.png'),
pipeCore: require('./img/pipe_core.png'),
pipeTop: require('./img/pipe_top.png'),
bird1: require('./img/bird1.png'),
bird2: require('./img/bird2.png'),
bird3: require('./img/bird3.png'),
}

In the previous episode we used the same component for both the and the objects. But now we want these to behave slightly differently, so we need to create a separate component.

import React, { Component } from "react";
import { View, Image } from "react-native";
import Images from "./assets/Images";

export default class Floor extends Component {
render() {
const width = this.props.body.bounds.max.x - this.props.body.bounds.min.x;
const height = this.props.body.bounds.max.y - this.props.body.bounds.min.y;
const x = this.props.body.position.x - width / 2;
const y = this.props.body.position.y - height / 2;

const imageIterations = Math.ceil(width / height);

return (
<View
style={{
position: 'absolute',
left: x,
top: y,
width: width,
height: height,
overflow: 'hidden',
flexDirection: 'row'
}}>
{Array.apply(null, Array(imageIterations)).map((el, idx) => {
return <Image style={{ width: height, height: height }} key={idx} source={Images.floor} resizeMode="stretch" />
})}
</View>
);
}
}

OK, I need to explain some things here:

  • We used to get the width and height from passed in props. That became inconvenient, so in this component I decided to derive width and height from the underlying Matter.js Body. All bodies have bounds, so you calculate the width and height using bounds.max.x minus bounds.min.x
  • Our floor background image is square, so we need to apply multiple images to cover the floor. The number of iterations needed is defined by Math.ceil(width/height)
  • Since the above calculation is likely will result with one more iteration than we need, we have to add overflow: hidden to the container

Now onto our bird…

The bird graphic is a set of 120x99 transparent png files. However, our original bird, represented by a rectangle is a 50x50 point square — it will be distorted if we just apply the image. Let’s fix this by adding some constants:

import { Dimensions } from 'react-native';

export default Constants = {
MAX_WIDTH: Dimensions.get("screen").width,
MAX_HEIGHT: Dimensions.get("screen").height,
GAP_SIZE: 220,
PIPE_WIDTH: 100,
BIRD_WIDTH: 50,
BIRD_HEIGHT: 41
}

We still set our to be 50, but the will be calculated by 50 / 120 (the image width) x 99 (the image height) = 41

Now we need to update Bird.js to have the same logic to get the width / height as our Floor.js:

import React, { Component } from "react";
import { View, Image } from "react-native";
import Images from './assets/Images';

export default class Bird extends Component {

render() {
const width = this.props.body.bounds.max.x - this.props.body.bounds.min.x;
const height = this.props.body.bounds.max.y - this.props.body.bounds.min.y;
const x = this.props.body.position.x - width / 2;
const y = this.props.body.position.y - height / 2;


let image = Images['bird' + this.props.pose];
return (
<Image
style={{
position: "absolute",
left: x,
top: y,
width: width,
height: height,
}}
resizeMode="stretch"
source={image} />
);
}
}

I’ve also added support for a “pose” prop. We will use later to animate the bird’s wings.

Now let’s update the method of our to pull this all together:

setupWorld = () => {
let engine = Matter.Engine.create({ enableSleeping: false });
let world = engine.world;
world.gravity.y = 0.0;

let bird = Matter.Bodies.rectangle( Constants.MAX_WIDTH / 2, Constants.MAX_HEIGHT / 2, Constants.BIRD_WIDTH, Constants.BIRD_HEIGHT);
//bird.restitution = 20;

let floor1 = Matter.Bodies.rectangle(
Constants.MAX_WIDTH / 2,
Constants.MAX_HEIGHT - 25,
Constants.MAX_WIDTH + 4,
50, { isStatic: true }
);
let floor2 = Matter.Bodies.rectangle(
Constants.MAX_WIDTH + (Constants.MAX_WIDTH / 2),
Constants.MAX_HEIGHT - 25,
Constants.MAX_WIDTH + 4,
50, { isStatic: true }
);

Matter.World.add(world, [bird, floor1]);
Matter.Events.on(engine, 'collisionStart', (event) => {
var pairs = event.pairs;

this.gameEngine.dispatch({ type: "game-over"});

});

return {
physics: { engine: engine, world: world },
floor1: { body: floor1, renderer: Floor },
floor2: { body: floor2, renderer: Floor },
bird: { body: bird, pose: 1, renderer: Bird},
}
}

Notice the following things:

  • We changed the world’s gravity to be 0 at the start. This stops the bird from falling at the beginning (todo item #2). We will control this value from our Physics.js
  • Removed the initialisation of pipes. We will take care of it in Physics.js as well.
  • Now we have two Floors. This will be useful later when we work on to-do item #3

Adding a background to our game is easy. We just add this to our render method:

<Image style={styles.backgroundImage} resizeMode="stretch" source={Images.background} />

And add this to our StyleSheet in App.js:

backgroundImage: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
width: Constants.MAX_WIDTH,
height: Constants.MAX_HEIGHT
},

Let’s also add a score indicator. I’ve installed a custom font using the process outlined in this article. I added the following code to the render method of our App.js after the GameEngine definition:

<Text style={styles.score}>{this.state.score}</Text>

Of course, changed the state initialisation to be this:

this.state = {
running: true,
score: 0
};

And applied the following style

score: {
color: 'white',
fontSize: 72,
fontFamily: '04b_19',
position: 'absolute',
top: 50,
left: Constants.MAX_WIDTH / 2 - 24,
textShadowColor:'#222222',
textShadowOffset:{width: 2, height: 2},
textShadowRadius:2,
},

Finally, we get rid of everything from our Physics.js except for the return entities part. We will rework the entire system later.

If everything has been done well, now you should have a nice looking, but very stationary game.


Animating the Bird’s Wings

As you may remember, we added a pose prop to our Bird component that should control the image of our Bird.

In order to do so, let’s modify our :

let tick = 0;
let pose = 1

const Physics = (entities, { touches, time, dispatch }) => {
let engine = entities.physics.engine;
let world = entities.physics.world;
let bird = entities.bird.body;

Matter.Engine.update(engine, time.delta);

tick += 1;
if (tick % 5 === 0){
pose = pose + 1;
if (pose > 3){
pose = 1;
}
entities.bird.pose = pose;
}

return entities;
};

What’s happening here is pretty straightforward. Every fifth tick, we increment the pose of our bird. If the pose is greater than three we reset it back to one.

Now we should have a bird with a nicely animated set of wings.


Moving the Floor

Before we get into gravity and pipes, let’s take care of the floor. A moving floor should give the user the illusion that our bird is moving.

In our Physics.js add the following code:

Object.keys(entities).forEach(key => {
if (key.indexOf("floor") === 0){
if (entities[key].body.position.x <= -1 * (Constants.MAX_WIDTH / 2)){
Matter.Body.setPosition( entities[key].body, {x: Constants.MAX_WIDTH + (Constants.MAX_WIDTH / 2), y: entities[key].body.position.y});
} else {
Matter.Body.translate( entities[key].body, {x: -2, y: 0});
}
}
});

Matter.Engine.update(engine, time.delta);

All we’re doing here is looping through our entities. If the entity name starts with “floor” we move their x position by -2. If the piece of floor moves out of the screen we quickly move it one screen to the right.

That should take care of the moving floor problem.


Fix the Bird’s Flappiness

In our previous iteration the bird exhibited some strange behaviour when the user tapped the screen. We used Matter.js’ applyForce method to bump the red square. The issue with this method is that you can apply force to an object that already had some force applied to it.

I realised that using setVelocity is a much better solution. Let’s add the following code to our :

let hadTouches = false;
touches.filter(t => t.type === "press").forEach(t => {
if (!hadTouches){
if (world.gravity.y === 0.0){
// first press really
world.gravity.y = 1.2;

}
hadTouches = true;
//Matter.Body.applyForce( bird, bird.position, {x: 0.00, y: -0.05});
Matter.Body.setVelocity(bird, {
x: bird.velocity.x,
y: -10
});
}
});

There are a couple of things to unpack here:

  • The touches variable contains a list of touch events. Using multiple fingers could result in multiple “press” events. To prevent this and only bump the bird once per tap we define
  • If the value is 0, it means the game has just started. We should set the world’s gravity to 1.2
  • works very similarly to , except it caps the velocity our bird can have, so it is much better for this game.

OK, our Bird is flapping away and gravity has been applied. It should even collide with the floor.


Pipes

Our previous iteration of Pipe was a single rectangle. For this game, we want to be a bit fancier, so let’s define two components: Pipe (for the core of the pipe) and PipeTop (surprise, surprise, for the top of the Pipe)

import React, { Component } from "react";
import { View, Image } from "react-native";
import Images from "./assets/Images";

export default class Pipe extends Component {
render() {
const width = this.props.body.bounds.max.x - this.props.body.bounds.min.x;
const height = this.props.body.bounds.max.y - this.props.body.bounds.min.y;
const x = this.props.body.position.x - width / 2;
const y = this.props.body.position.y - height / 2;

const pipeRatio = 160 / width; // 160 is the original image size
const pipeHeight = 33 * pipeRatio;
const pipeIterations = Math.ceil(height / pipeHeight);

return (
<View
style={{
position: "absolute",
left: x,
top: y,
width: width,
height: height,
overflow: 'hidden',
flexDirection: 'column'
}}>
{Array.apply(null, Array(pipeIterations)).map((el, idx) => {
return <Image style={{ width: width, height: pipeHeight }} key={idx} source={Images.pipeCore} resizeMode="stretch" />
})}
</View>
);
}
}
import React, { Component } from "react";
import { View, Image } from "react-native";
import Images from "./assets/Images";

export default class PipeTop extends Component {
render() {
const width = this.props.body.bounds.max.x - this.props.body.bounds.min.x;
const height = this.props.body.bounds.max.y - this.props.body.bounds.min.y;
const x = this.props.body.position.x - width / 2;
const y = this.props.body.position.y - height / 2;


return (
<Image
style={{
position: "absolute",
left: x,
top: y,
width: width,
height: height,
}}
resizeMode="stretch"
source={Images.pipeTop}
/>
);
}
}

I don’t think these need any explanation. Pipe is using the same tiling solution what we used for Floor, but this time it’s vertical. PipeTop is a boring, old, static image.

To add the pipes to our world we have to modify Physics.js. The following snippet might seem scary, but don’t worry — I will explain!

import Matter from "matter-js";
import Constants from './Constants';
import Pipe from './Pipe';
import PipeTop from './PipeTop';

let tick = 0;
let pose = 1;
let pipes = 0;

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

export const generatePipes = () => {
let topPipeHeight = randomBetween(100, (Constants.MAX_HEIGHT / 2) - 100);
let bottomPipeHeight = Constants.MAX_HEIGHT - topPipeHeight - Constants.GAP_SIZE - 50;

let sizes = [topPipeHeight, bottomPipeHeight]

if (Math.random() < 0.5) {
sizes = sizes.reverse();
}

return sizes;
}

export const resetPipeCount = () => {
pipes = 0;
}

export const addPipesAtLocation = (x, world, entities) => {
let [pipe1Height, pipe2Height] = generatePipes();

let pipeTopWidth = Constants.PIPE_WIDTH + 20;
let pipeTopHeight = (pipeTopWidth / 205) * 95; // original image is 205x95

pipe1Height = pipe1Height - pipeTopHeight;

let pipe1Top = Matter.Bodies.rectangle(
x,
pipe1Height + (pipeTopHeight / 2),
pipeTopWidth,
pipeTopHeight,
{ isStatic: true }
);

let pipe1 = Matter.Bodies.rectangle(
x,
pipe1Height / 2,
Constants.PIPE_WIDTH,
pipe1Height,
{ isStatic: true }
);

pipe2Height = pipe2Height - pipeTopHeight;

let pipe2Top = Matter.Bodies.rectangle(
x,
Constants.MAX_HEIGHT - pipe2Height - 50 - (pipeTopHeight / 2),
pipeTopWidth,
pipeTopHeight,
{ isStatic: true }
);

let pipe2 = Matter.Bodies.rectangle(
x,
Constants.MAX_HEIGHT - (pipe2Height / 2) - 50,
Constants.PIPE_WIDTH, pipe2Height,
{ isStatic: true }
);

Matter.World.add(world, [pipe1, pipe1Top, pipe2, pipe2Top]);

entities["pipe" + (pipes + 1)] = {
body: pipe1, scored: false, renderer: Pipe
}

entities["pipe" + (pipes + 1) + "Top"] = {
body: pipe1Top, scored: false, renderer: PipeTop
}

entities["pipe" + (pipes + 2)] = {
body: pipe2, scored: false, renderer: Pipe
}

entities["pipe" + (pipes + 2) + "Top"] = {
body: pipe2Top, scored: false, renderer: PipeTop
}

pipes += 2;
}

const Physics = (entities, { touches, time, dispatch }) => {
let engine = entities.physics.engine;
let world = entities.physics.world;
let bird = entities.bird.body;

let hadTouches = false;
touches.filter(t => t.type === "press").forEach(t => {
if (!hadTouches){
if (world.gravity.y === 0.0){
// first press really
world.gravity.y = 1.2;

addPipesAtLocation((Constants.MAX_WIDTH * 2) - (Constants.PIPE_WIDTH / 2), world, entities)
addPipesAtLocation((Constants.MAX_WIDTH * 3) - (Constants.PIPE_WIDTH / 2), world, entities)

}
hadTouches = true;
//Matter.Body.applyForce( bird, bird.position, {x: 0.00, y: -0.05});
Matter.Body.setVelocity(bird, {
x: bird.velocity.x,
y: -10
});
}
});

Object.keys(entities).forEach(key => {
if (key.indexOf("pipe") === 0 && entities.hasOwnProperty(key)){
Matter.Body.translate( entities[key].body, {x: -2, y: 0});

if (key.indexOf("Top") === -1 && parseInt(key.replace("pipe", "")) % 2 === 0){
let pipeIndex = parseInt(key.replace("pipe", ""));
if (entities[key].body.position.x < entities.bird.body.position.x && !entities[key].scored){
entities[key].scored = true;
dispatch({ type: "score"})
}

if (entities[key].body.position.x <= -1 * (Constants.PIPE_WIDTH / 2)){
addPipesAtLocation((Constants.MAX_WIDTH * 2) - (Constants.PIPE_WIDTH / 2), world, entities)

delete(entities["pipe" + (pipeIndex - 1)]);
delete(entities["pipe" + (pipeIndex - 1) + "Top"]);
delete(entities["pipe" + pipeIndex]);
delete(entities["pipe" + pipeIndex + "Top"]);
}
}


} else if (key.indexOf("floor") === 0){
if (entities[key].body.position.x <= -1 * (Constants.MAX_WIDTH / 2)){
Matter.Body.setPosition( entities[key].body, {x: Constants.MAX_WIDTH + (Constants.MAX_WIDTH / 2), y: entities[key].body.position.y});
} else {
Matter.Body.translate( entities[key].body, {x: -2, y: 0});
}
}
});

Matter.Engine.update(engine, time.delta);

tick += 1;
if (tick % 5 === 0){
pose = pose + 1;
if (pose > 3){
pose = 1;
}
entities.bird.pose = pose;
}

return entities;
};

export default Physics;

It is a lot to unpack, but don’t worry.

  • We define a variable to keep track of the number of pipe(set)s in our world.
  • We expose a function to our main world to reset the active pipes, in case the user dies.
  • is the same as last time. It generates two sets of heights with a set gap between them.
  • adds a set of pipe at a given x location to our world and to our entities. It will create the following entities: , , , (of course, depending on our pipes variable’s value, the name will change)
  • When the user first taps the screen we add two sets of pipes. One at the end of the 3rd screen, one at the end of the 4th screen
addPipesAtLocation((Constants.MAX_WIDTH * 2) - (Constants.PIPE_WIDTH / 2), world, entities)
addPipesAtLocation((Constants.MAX_WIDTH * 3) - (Constants.PIPE_WIDTH / 2), world, entities)
  • Every entity that starts with the word “pipe” we keep moving to the left by two units on every tick:
Matter.Body.translate( entities[key].body, {x: -2, y: 0});
  • On every bottom pipe (even number pipe id) we check if the bird's position has passed the pipe’s position — if so we emit a “score” event:
if (entities[key].body.position.x < entities.bird.body.position.x && !entities[key].scored){
entities[key].scored = true;
dispatch({ type: "score"})
}
  • Also on every bottom pipe we check if the given pipe has left the screen. If so we remove the entire pipe set and generate a new one:
if (entities[key].body.position.x <= -1 * (Constants.PIPE_WIDTH / 2)){
addPipesAtLocation((Constants.MAX_WIDTH * 2) - (Constants.PIPE_WIDTH / 2), world, entities)
delete(entities["pipe" + (pipeIndex - 1)]);
delete(entities["pipe" + (pipeIndex - 1) + "Top"]);
delete(entities["pipe" + pipeIndex]);
delete(entities["pipe" + pipeIndex + "Top"]);
}

And that's about it! The only thing left to do is to modify our to listen to “score” type events:

onEvent = (e) => {
if (e.type === "game-over"){
//Alert.alert("Game Over");
this.setState({
running: false
});
} else if (e.type === "score"){
this.setState({
score: this.state.score + 1
})
}
}

We’re finished. You now have a fully functional Flappy Bird type of game that’s actually fun to play.

Thank you for reading.

If you are interested in the source code you can grab it here

Better Programming

Advice for programmers.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade