You can view the pretty version of the notes here.
- Images (Sprites): How can we load images from memory to our game and draw them on the screen?
- Infinite Scrolling: How can we make our game map appear to scroll infinitely from left to right without using all our memory?
- "Games Are Illusions": We'll see how camera trickery is often the crucial piece for bringing games to life.
- Procedural Generation: Going hand in hand with infinite scrolling, we'll learn how to use procedural generation to draw additional sprites (such as the pipes in Flappy Bird) to the screen as our game map scrolls from left to right.
- State Machines: Last week we used a rudimentary "state machine" for pong, which was really just a string variable and a few if statements in our love.update() function. This week we'll see how we can actually use a state machine class to allow us to transition in and out of different states more cleanly, and abstract this logic away from our main.js file and into separate classes.
- Mouse Input: Last week we worked with keyboard input for pong, and this week we'll see how to process mouse input for Flappy!
- Music: Similarly to how we added sound effects to our game last week, we'll see how to add music to our game this week and ensure that it loops during game execution.
Flappy Bird is a mobile game by Dong Nguyen that went viral in 2013, utilizing a very simple but effective gameplay mechanic of avoiding pipes indefinitely by tapping the screen, making the player's bird avatar flap its wings and move upwards slightly. It's a variant of popular games like "Helicopter Game" that floated around the internet for years prior. This game illustrates some of the most basic procedural generation of game levels possible as by having pipes stick out of the ground by varying amounts, acting as an infinitely generated obstacle course for the player.
Image courtesy of MacRumors
- Clone the repo (or download the zip) for today's lecture, which you can find here.
- Open the repo in Visual Studio Code.
- Open the VSC terminal (
CTRL + `) and runnpx http-server(assuming you have NodeJS installed, if you don't, download and install it from here) inside the root folder of the repo. - In your browser, navigate to
http://localhost:8080(or whatever the URL is that is displayed in the terminal).
Flappy-Bird-0 simply draws two images to the screen - a foreground and a background.
new Image(width, height)- This function creates a new
Imageobject with the specified width and height. To set the image source from a graphics file (JPEG, PNG, GIF, etc.), assign a string containing the path to the image file to theImage's.srcproperty.
- This function creates a new
context.drawImage(image, x, y, width, height)- This function draws an image to the screen at a given
xandyon our 2D coordinate system. Recall that last week we usedcontext.fillRect()to draw our paddles and ball. This time, we'll want to draw actual images, so we'll be using this function.
- This function draws an image to the screen at a given
π‘ Notice we've encapsulated our core game engine logic into a dedicated
Game.jsclass as well as declared aglobals.jsfile for any variables/constants that will be required throughout our game.
π¨ Be extremely cautious when adding things to
globals.jssince it can easily become bad practice. Always ask yourself before adding something to this file: "does this NEED to potentially be available anywhere throughout my codebase?". If the answer is no, think of how you can pass that value internally in your code.
You should be able to recognize most of the code in this update from last week aside from a new images variable:
export const images = {
background: new Image(1157, 288),
ground: new Image(1100, 16),
};
images.background.src = "./images/background.png";
images.ground.src = "./images/ground.png";Here we are defining an object which has two properties that contain image objects for our background and our ground. This will come into play shortly.
Take a look at render():
context.drawImage(images.background, 0, 0, images.background.width, images.background.height);
context.drawImage(images.ground, 0, CANVAS_HEIGHT - images.ground.height, images.ground.width, images.ground.height);The rendering logic, for now, consists of merely drawing our background and group. We draw the background at the top of the screen, and the ground at the bottom.
Flappy-Bird-1 allows us to "scroll" the background and foreground along the screen to simulate motion.
- Parallax Scrolling refers to the illusion of movement given two frames of reference that are moving at different rates.
- Consider riding in a car and looking out the window. Suppose you're driving near mountains and there are railings on the side of the road. You will notice that the railings appear to be moving faster in your frame of vision than the mountains.
- We want to simulate this behavior in our game with the ground and background to give the appearance of motion and depth.
We use variables to keep track of how much each image has scrolled (in order to know at which position to draw them) as well as how quickly each image is scrolling so that the images can move at different speeds in order to produce Parallax Scrolling:
// Scene.js
this.backgroundScroll = 0;
this.backgroundScrollSpeed = 25;
this.groundScroll = 0;
this.groundScrollSpeed = 100;
this.backgroundLoopingPoint = 413;You may be wondering why this.backgroundLoopingPoint = 413. Everything in games is smoke and mirrors. To create the illusion that we are "moving forward" in our game, we move the background from right to left over time. To make it seem like this is an infinite world, we reset the position of the background image to where it was at the very beginning after a magic number of frames. The magic number is determined by whoever created the asset as they decide where the image should start repeating itself. If this still seems confusing, be sure to run Flappy-Bird-1 and use a different value for this variable (ex. this.backgroundLoopingPoint = 270) so that you can observe the difference.
We now loop the scrolling effect in update() so that we can continue reusing each image for as long as the game goes on:
// Scene.js
this.backgroundScroll = (this.backgroundScroll + this.backgroundScrollSpeed * dt) % this.backgroundLoopingPoint;
this.groundScroll = (this.groundScroll + this.groundScrollSpeed * dt) % CANVAS_WIDTH;Notice how we're updating our scroll position variables for each image by adding the corresponding scroll speed variable (scaled by DeltaTime to ensure consistent movement regardless of our computer's framerate). The looping occurs by taking the modulo of the scroll position by our looping point. In the case of our ground image, we don't bother choosing a looping point within the image since it looks so similar at every point, so the looping point is just the width of our virtual resolution.
Lastly, we update render() to use the appropriate variables rather than static coordinates.
// Scene.js
context.drawImage(images.background, -this.backgroundScroll, 0);
context.drawImage(images.ground, -this.groundScroll, CANVAS_HEIGHT - images.ground.height);In order to "loop" each image, we are rendering a small portion of it at a time and shifting it left each frame, until we reach the looping point and reset it back to (0, 0). This means that as the scrolling takes place, the portions of the images that we've already seen will be to the left of the screen at a negative x coordinate until they are shifted back right to recommence the looping effect.
Here's a visualization that may also help understand what is actually happening!
Flappy-Bird-2 adds a Bird sprite to our game and renders it in the center of the screen.
We create a Bird class and give it functionality to initialize and render itself:
// Bird.js
export default class Bird {
constructor(x, y, width, height) {
this.width = width;
this.height = height;
this.x = x - width / 2;
this.y = y - height / 2;
}
render(context) {
context.drawImage(images.bird, this.x, this.y)
}
}We draw the bird's image from by referencing the images object located in globals.js which is our sprite's image file located in the ./images directory:
// globals.js
export const images = {
bird: new Image(38, 24),
};
images.bird.src = "./images/bird.png";In Game.js, we only need to add a couple of lines to instantiate our bird object:
import Bird from "./Bird.js";
this.bird = new Bird(CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2, 38, 24);And, inside render(), we can call our bird object's render method to draw it to the screen:
this.bird.render();It's important to declare this only after rendering the scene. Otherwise, the scene would be in front of the bird!
Flappy-Bird-3 introduces gravity into our game, which causes our Bird to fall off the screen almost immediately.
In Bird.js, we add an arbitrary gravity attribute and an update function, which we'll then call in Game.js's update() function.
// Bird.js
update(dt) {
this.dy += GRAVITY;
this.y += this.dy * dt;
}The trick is to manipulate the bird's position using DeltaTime, which we discussed in last week's lecture, by applying our gravity value to the Bird's velocity and subsequently applying the updated velocity to the Bird's position.
Our approach will be to toggle the dy attribute between a negative and positive value in order to simulate jumping/flapping and falling.
Recall that our 2D Coordinate System has its origin at the top-left corner of the screen, so a positive dy value will cause our Bird to fall, while a negative dy value will cause our Bird to rise (since the Bird's y value is higher at the bottom of the screen and lower at the top of the screen).
We'll create a keys object in globals.js:
export const keys = {};Next, we'll register an event listener so that whenever a key is pressed, we can add it to the keys object:
canvas.addEventListener('keydown', event => {
keys[event.key] = true;
});
canvas.addEventListener('keyup', event => {
keys[event.key] = false;
});With this keys object, we can now add our flapping functionality to our Bird such that when the user presses space, we set the bird's dy to an arbitrary negative value:
// Game.js
if (keys[' ']) {
keys[' '] = false;
this.bird.flap();
}
// Bird.js
flap() {
this.dy -= 300;
}Flappy-Bird-4 adds the Pipe sprite to our game, rendering it an "infinite" number of times.
Unsurprisingly, we create our Pipe sprite by modeling it with a Pipe class.
Inside this class's constructor, we generate a random vertical position at the right-most edge and within the lower quarter of the screen. We also create an update() method that "scrolls" the Pipe to the left of the screen based on its previous position, a negative scroll value, and its dt. Lastly, we include functionality for rendering the Pipe to the screen:
// Pipe.js
export default class Pipe {
constructor(x, width, height) {
this.x = x;
this.y = getRandomPositiveNumber(height / 4, height - 10);
this.width = width;
this.scrollSpeed = -0.5;
}
update(dt) {
this.x += this.scrollSpeed * dt;
}
render(context) {
context.drawImage(images.pipe, this.x, this.y);
}
}We draw the Pipe image from by referencing the images object located in globals.js which is our sprite's image file located in the ./images directory:
// globals.js
export const images = {
pipe: new Image(70, 288),
};
images.pipe.src = "./images/pipe.png";It's notable to mention that we are never creating multiple Pipe sprites. Rather, we are always re-rendering the same Pipe sprite we created globals.js. This helps us save memory while accomplishing the same goal.
In Game.js we'll declare an array of pipes, a pipeSpawnTimer that will track how often a new Pipe should be added to the array, and a pipeInterval that represents how many seconds a new Pipe should spawn on the screen:
this.pipes = [];
this.pipeSpawnTimer = 0;
this.pipeInterval = 2;And then, in update():
this.pipeSpawnTimer += dt;
if (this.pipeSpawnTimer > this.pipeInterval) {
this.pipes.push(new Pipe(CANVAS_WIDTH, 70, 288));
this.pipeSpawnTimer = 0;
}
this.pipes.forEach((pipe, index) => {
pipe.update(dt);
if (pipe.x < -pipe.width) {
this.pipes.splice(index, 1);
}
});We spawn a new pipe every pipeInterval seconds, and remove it from the array once it's no longer visible past the left edge of the screen.
Finally, in render(), we pass the pipes into the scene so that we can render the pipes between the foreground and background:
this.scene.render(this.pipes);In the render() of Scene.js, we loop through the array and call each pipe's render().
// Scene.js
pipes.forEach(pipe => pipe.render());Flappy-Bird-5 spawns the Pipe sprites in "pairs", with one Pipe facing up and the other facing down.
Image courtesy of Harvard CS50
context.translate(x, y)- This function moves the origin of the canvas to the specified coordinates. For example, if you called
context.translate(10, 10)followed bycontext.fillRect(0, 0, 20, 20), this would draw a 20x20 box at the coordinates (10, 10). Read more and see the examples from MDN.
- This function moves the origin of the canvas to the specified coordinates. For example, if you called
context.rotate(angle)- This function rotates the axis of the canvas to the specified angle. Any shape drawn after calling this function will be rotated by the specified angle. Read more and see the examples from MDN.
context.save()- Pushes the current state of context on a stack so that you can pop the stack later to restore the current state of context with
context.restore(). Read more and see the examples from MDN.
- Pushes the current state of context on a stack so that you can pop the stack later to restore the current state of context with
context.restore()- Pops the last state of context that was pushed onto the stack with
context.save()Read more and see the examples from MDN.
- Pops the last state of context that was pushed onto the stack with
The reason why save() and restore() are useful is because they save us having to "undo" the transformations to the canvas we made.
For example:
// This...
context.translate(10, 10);
context.rotate(45 * Math.PI / 180); // 45 degrees.
context.rotate(-45 * Math.PI / 180)
context.translate(-10, -10);
// Is equivalent to this...
context.save();
context.translate(10, 10);
context.rotate(45 * Math.PI / 180);
context.restore();We're going to make use of these functions to draw the upper pipes!
Previously, in Flappy-Bird-4, we were only concerned about spawning individual pipes, so having a simple Pipe class sufficed. However, now that we want to spawn pairs of pipes, it makes sense to create a PipePair class to tackle this problem:
export default class PipePair {
constructor() {
this.y = getRandomPositiveNumber(25, 175);
this.gapHeight = 75;
this.canRemove = false;
this.pipes = {
upper: new Pipe(this.y, 'top'),
lower: new Pipe(this.y + this.gapHeight, 'bottom'),
};
}
update(dt) {
if (this.pipes.upper.x < -this.pipes.upper.width) {
this.canRemove = true;
}
else {
this.pipes.upper.update(dt);
this.pipes.lower.update(dt);
}
}
render() {
this.pipes.upper.render();
this.pipes.lower.render();
}
}The result of having a PipePair class is that we replace a lot of our previous Pipe logic in Game.js with analogous PipePair logic. The implications of this are that the flow of our Game.js file need not change drastically, but with the caveat that now we need to accommodate for PipePair rather than Pipe.
In our case, we can mimic the Pipe class to an extent, as long as we provide logic for ensuring a reasonable gap height between the pipes, as well as accurate y values for our sprites, since we will be mirroring the top Pipe. Be sure to read carefully through the changes in Game.js, paying close attention to the comments!
Drawing the upper pipes is a little tricky. Since we only have one pipe sprite, we need to rotate it by 180 degrees in order to draw it upsidedown. The way we can rotate it is using the context.rotate() function outlined above.
// Pipe.js
render() {
if (this.orientation === 'top') {
context.save();
context.translate(this.x + this.width, this.y + this.height);
context.rotate(Math.PI);
context.drawImage(images.pipe, 0, 0);
context.restore();
}
else {
context.drawImage(images.pipe, this.x, this.y);
}
}One last important change is altering the position of our upper pipes since they're being drawn rotated:
// Pipe.js
this.y = orientation === 'top' ? y - this.height : y;Flappy-Bird-6 introduces collision detection, pausing the game when a collision occurs.
The collision detection itself is handled within our Bird class (it's the same AABB Collision Detection algorithm we saw last week), but with some leeway in order to give the user a little more leniency.
didCollide(pipe) {
if (this.x + this.width - 6 >= pipe.x
&& this.x + 3 <= pipe.x + pipe.width
&& this.y + this.height - 6 >= pipe.y
&& this.y + 3 <= pipe.y + pipe.height) {
return true;
}
return false;
}The pausing of the game is handled by toggling a boolean variable this.paused in Game.js upon collision detection.
// Game.js
if (this.bird.didCollide(pipePair.pipes.upper)
|| this.bird.didCollide(pipePair.pipes.lower)) {
this.paused = true;
}At the top of update(), if we're currently paused, then render everything as usual but return instead of updating anything.
// Game.js
if (this.paused) {
this.render();
return;
}Flappy-Bird-7 modularizes our code as a State Machine:
While our eventual goal is the above, this update lays the foundations with the following states: State, TitleScreenState, and PlayState.
We will manage all our game states using an overarching StateMachine class, which handles the logic for initializing and transitioning between them.
State.js is an abstract base class for the other states - it defines empty methods and passes them on to its children via inheritance.
In globals.js is the creation of our stateMachine object and the loading of our different states. The last state added is always set as the current state.
import PlayState from "./states/PlayState.js";
import StateMachine from "./StateMachine.js";
import TitleScreenState from "./states/TitleScreenState.js";
export const stateMachine = new StateMachine();
stateMachine.add('play', new PlayState());
stateMachine.add('title-screen', new TitleScreenState());The TitleScreenState will transition to the PlayState via keyboard input:
// TitleScreenState.js
if (keys.Enter) {
stateMachine.change('countdown', { scene: this.scene });
}The PlayState will transition to the TitleScreenState when either there is a collision, or if you fall off the bottom edge of the screen:
if (this.bird.didCollide(pipePair.pipes.upper)
|| this.bird.didCollide(pipePair.pipes.lower)) {
stateMachine.change('title-screen');
}
if (this.bird.didFall()) {
stateMachine.change('title-screen');
}By representing our game states as classes, we vastly simplify the logic in our Game.js file. Now, each major part of our code will be in its own dedicated state class, and each state can be accessed through our global state machine object.
Be sure to read carefully through the new classes, paying close attention to the comments, to understand how our Game.js has been simplified so cleanly! You should see that the PlayState should contain much of the logic previously in Game.js.
Flappy-Bird-8 introduces a new state, GameOverState, to help keep track of the score.
First, let's add the new state to our StateMachine in globals.js:
import GameOverState from "./states/GameOverState.js";
stateMachine.add('game-over', new GameOverState());Once a collision is detected, the PlayState will transition to the GameOverState, which will display the user's final score and transition back to the PlayState if the "enter" key is pressed. Note our addition to the update() function to implement this transition logic:
if (this.bird.didCollide(pipePair.pipes.upper)
|| this.bird.didCollide(pipePair.pipes.lower)) {
stateMachine.change('game-over', {
score: this.score,
scene: this.scene,
});
}
if (this.bird.didFall()) {
stateMachine.change('game-over', {
score: this.score,
scene: this.scene,
});
}The score itself is also tracked in update() by incrementing a score counter each time the bird flies successfully through a PipePair.
// PlayState.js
if (!pipePair.wasScored) {
if (pipePair.didScore(this.bird)) {
this.score++;
}
}
// PipePair.js
didScore(bird) {
if (this.pipes.upper.x + this.pipes.upper.width < bird.x) {
this.wasScored = true;
return true;
}
return false;
}Logic for displaying the score to the screen during the PlayState is added to the render() function:
context.font = "20px Joystix";
context.fillStyle = "white";
context.fillText(`Score: ${Math.floor(this.score)}`, 20, 30);The GameOverState is implemented as a class with implementations for the empty methods in the base State class:
export default class GameOverState extends State {
enter(parameters) {
this.score = parameters.score;
this.scene = parameters.scene;
}
update(dt) {
if (keys.Enter) {
stateMachine.change('play', { scene: this.scene });
}
this.scene.update(dt);
}
render() {
this.scene.render();
context.save();
context.fillStyle = "white";
context.font = "40px Flappy";
context.textBaseline = 'middle';
context.textAlign = 'center';
context.fillText(`game over`, CANVAS_WIDTH * 0.5, CANVAS_HEIGHT * 0.5);
context.font = "20px Joystix";
context.fillText(`Score: ${this.score}`, CANVAS_WIDTH * 0.5, CANVAS_HEIGHT * 0.5 + 40);
context.fillText(`Press Enter to Play Again!`, CANVAS_WIDTH * 0.5, CANVAS_HEIGHT * 0.5 + 70);
context.restore();
}
}Flappy-Bird-9 introduces yet another state, CountdownState, whose purpose is to give the user time to get ready before being thrust into the game.
First, let's add the new state to our StateMachine in globals.js:
import CountdownState from "./states/CountdownState.js";
stateMachine.add('countdown', new CountdownState());The CountdownState is implemented as another class and it displays a 3-second countdown on the screen before play begins:
export default class CountdownState extends State {
constructor() {
super();
this.countdownTime = 0.75;
}
enter(parameters) {
this.scene = parameters.scene;
this.count = 3;
this.timer = 0;
}
update(dt) {
this.timer = this.timer + dt;
if (this.timer > this.countdownTime) {
this.timer = this.timer % this.countdownTime;
this.count = this.count - 1;
if (this.count == 0) {
stateMachine.change('play', { scene: this.scene });
}
}
this.scene.update(dt);
}
render() {
this.scene.render();
context.save();
context.fillStyle = "white";
context.font = "120px Joystix";
context.textBaseline = 'middle';
context.textAlign = 'center';
context.fillText(`${this.count}`, CANVAS_WIDTH * 0.5, CANVAS_HEIGHT * 0.5);
context.restore();
}
}- We modify our code in
TitleScreenState.jssuch thatTitleScreenStatetransitions toCountdownStaterather than directly toPlayState. - Then, in
CountdownState.jswe transition toPlayStateonce the countdown reaches 0. - In
PlayState.js, we ensure that upon collision, we transition toGameOverState. - Finally, in
GameOverState.js, we transition back toCountdownStateon keyboard input.
Flappy-Bird-10 adds some music and sound effects to the game
We initialize a sounds object in globals.js that includes the sound files we reference in our project directory, and also set the volume of each sound so that we don't blow out our player's ears:
export const sounds = {
jump: new Audio('./sounds/jump.wav'),
explosion: new Audio('./sounds/explosion.wav'),
hurt: new Audio('./sounds/hurt.wav'),
score: new Audio('./sounds/score.wav'),
music: new Audio('./sounds/marios_way.mp3'), // https://freesound.org/people/xsgianni/sounds/388079/
};
sounds.jump.volume = 0.01;
sounds.explosion.volume = 0.01;
sounds.hurt.volume = 0.01;
sounds.score.volume = 0.01;
sounds.music.volume = 0.01;
sounds.music.loop = true;Then, in Game.js, begin playing it in the start() function:
sounds.music.play();Lastly, we play the remaining sound effects in the PlayState class (jumps, score increases, collisions, etc.).
And with that, we have a fully functioning game of Flappy Bird!


