Lecture 1
- Welcome!
- Infinite Scrolling and Parallax
- Images and Sprites
- Gravity and Input
- Procedural Generation
- Pipe Pairs
- Collision Detection
- State Machines
- Scoring
- Sound Effects and Music
- Mouse Input
- Summing Up
Welcome!
- This is CS50’s introduction to 2D game development, Lecture 1 on Flappy Bird.
- Flappy Bird was a mobile game by Dong Nguyen that went viral around 2013 or so. It had a bit of a controversy related to the pipe graphics having been stolen, but a lot of people for a brief period of time were really enjoying it. It utilizes a very simple but effective gameplay mechanic of avoiding pipes indefinitely by just tapping the screen, making the player’s bird avatar flap its wings and move upwards slightly.
- Flappy Bird is a variant of popular games like “Helicopter Game” that floated around the internet for years prior. It illustrates some of the most basic procedural generation of game levels possible by having pipes stick out of the ground by varying amounts, acting as an infinitely generated obstacle course for the player.
- This lecture is a bit of a more modern take instead of the Pong, Atari take from last time. Last time when we were looking at Pong, it was just essentially shapes, rectangles, and just white and an off-black color. But this week we want to actually begin looking at a lot more interesting things that will take us to not just shapes, but rather things that are 2D artwork that move around on the screen, namely sprites.
- The goal today is a recreation of Flappy Bird, not using the original artwork, but rather some open source artwork. It is essentially the same game. It has just a small bird whose goal is to jump up and navigate this randomly generated obstacle course. It’s also got a ground and a background that will scroll at different rates to give us this parallax artificial look as if we’re in a space.
Infinite Scrolling and Parallax
- Rather than drawing rectangles like we did with Pong, we’re going to be drawing images. A sprite is essentially typically an image that will move around on screen. When people think sprite, they’ll think of a character graphic or maybe a fireball or something versus what might be a background texture or the like. A sprite and an image are a bit interchangeable.
- The most important function of the day is
love.graphics.newImage(), which takes a path. This allows us to load an image file of any type we want. It supports most image types—PNGs are very common, but you might also have GIFs or JPEGs. We’ll store that in memory, and then we can draw that onto the screen withlove.graphics.draw(). - Parallax is this idea where, and we can see this in the real world, if we see fences and mountains, for example, back to back, or if you’re driving down the highway and you look out the side, you can see things move at different speeds. Parallax essentially is an illusion that we can use to simulate distance or depth in our game.
- One of the instructor’s favorite YouTube channels for exploring illusions in games is a series called “Boundary Break.” In Ocarina of Time, there is a shopkeeper in Hyrule Castle that you can visit. If you actually go behind where he’s standing, you’ll see humorously that the developers, because you can’t actually physically move behind him in the game, decided not to model him with legs. Despite the fact that he looks like a real person greeting you, in fact, he’s just a torso without any legs. This is a common theme, and this is typically how you achieve most things in games that feel like you’re living somewhere. Most things, if you can avoid it, aren’t rendered in the background or off the sides of the screen. It’s called culling in 2D and 3D.
- In Flappy Bird, the background scrolls infinitely to give the illusion of movement. The texture has a looping look to it. You can imagine it as being if you were to almost glue it together and form a ring with it and just spin it around, it would ultimately look like it was infinitely scrolling. In order to achieve this infinitely scrollable world, using just a single texture that loops a couple of times, we can achieve that exact aesthetic.
-
We use delta time (dt) for frame-independent movement, just like in Pong. We multiply the scroll speed by delta time, add it to whatever our background scroll already is. From
bird2/main.lua:-- background image and starting scroll location (X axis) local background = love.graphics.newImage('background.png') local backgroundScroll = 0 -- ground image and starting scroll location (X axis) local ground = love.graphics.newImage('ground.png') local groundScroll = 0 -- speed at which we should scroll our images, scaled by dt local BACKGROUND_SCROLL_SPEED = 30 local GROUND_SCROLL_SPEED = 60 -- point at which we should loop our background back to X 0 local BACKGROUND_LOOPING_POINT = 413Notice that the ground scrolls at 60 pixels per second while the background scrolls at only 30 pixels per second, creating the parallax effect. The slower things move, the farther away they’re going to be perceived to be. These mountains are perceived as a background element, and the ground is perceived as a closer element to the camera.
-
We use the modulo operator to seamlessly loop the images. Modulo is essentially division, but instead of giving us the actual divided number, it’ll give us whatever is leftover. If we modulo by background looping point, once we hit that 413 pixels where it’s really the same exact image, it’s going to modulo it back to zero. From
bird2/main.lua:function love.update(dt) -- scroll background by preset speed * dt, looping back to 0 after the looping point backgroundScroll = (backgroundScroll + BACKGROUND_SCROLL_SPEED * dt) % BACKGROUND_LOOPING_POINT -- scroll ground by preset speed * dt, looping back to 0 after the screen width passes groundScroll = (groundScroll + GROUND_SCROLL_SPEED * dt) % VIRTUAL_WIDTH endNotice how the modulo operator (%) causes the scroll value to wrap back to 0 once it exceeds the looping point. If we’re at 413 and we modulo by 413, we’ll get back the value of zero because 413 divided by 413 is one, but there’s zero remainder.
-
In the draw function, we draw the images at a negative scroll offset to make them shift to the left. From
bird2/main.lua:function love.draw() push:start() -- here, we draw our images shifted to the left by their looping point; eventually, -- they will revert back to 0 once a certain distance has elapsed, which will make it -- seem as if they are infinitely scrolling. choosing a looping point that is seamless -- is key, so as to provide the illusion of looping -- draw the background at the negative looping point love.graphics.draw(background, -backgroundScroll, 0) -- draw the ground on top of the background, toward the bottom of the screen, -- at its negative looping point love.graphics.draw(ground, -groundScroll, VIRTUAL_HEIGHT - 16) -- render our bird to the screen using its own render logic bird:render() push:finish() endNotice that choosing a looping point that is seamless is key to providing the illusion of infinite scrolling. This is a technique used in infinite scrolling all throughout 2D games and in 3D games for certain effects for texture scrolling.
Images and Sprites
- Just like last week, we use the push library for virtual resolution and
love.graphics.setDefaultFilter('nearest', 'nearest')for crisp pixel art scaling. -
We introduce the class library for object-oriented programming. If we know we’re going to need a bird, if in general we know we’re going to need to model some data in some way that we could think of as being real-world entities or the like, it’s good to start off just already getting the class in place. From
bird2/main.lua:-- virtual resolution handling library push = require 'push' -- classic OOP class library Class = require 'class' -- bird class we've written require 'Bird'Notice that we require the push library, the class library, and our own Bird class file.
-
Our Bird class encapsulates all the sprite data. The benefit of using variables and methods like
self.image:getWidth()andself.image:getHeight()instead of actually hard coding the width and the height is that in theory, we could just load in whatever texture we want to. It could be some larger or smaller bird, whatever we feel like. Frombird2/Bird.lua:Bird = Class{} function Bird:init() -- load bird image from disk and assign its width and height self.image = love.graphics.newImage('bird.png') self.width = self.image:getWidth() self.height = self.image:getHeight() -- position bird in the middle of the screen self.x = VIRTUAL_WIDTH / 2 - (self.width / 2) self.y = VIRTUAL_HEIGHT / 2 - (self.height / 2) end function Bird:render() love.graphics.draw(self.image, self.x, self.y) endNotice how we load the bird image from disk and dynamically get its dimensions. This is quite a bit more readable than having a ton of magic numbers all over the place, which in general we want to avoid.
- Syntactically, what’s the difference between the colon syntax and the dot notation? A colon render means that we’re going to pass an implicit self to this function called render. Tables can be defined with functions within them. This is the prototypal method of doing object-oriented programming, which Lua does, and JavaScript also does this. The colon essentially performs the equivalent of doing
bird.render(self). Without the colon, we would need that argument defined. With the colon, we no longer need to do that. We can treat every function as just essentially not even needing that argument defined in its definition.
Gravity and Input
- For the bird to feel like it’s flying, we need to simulate gravity. Gravity is really the element that makes Flappy Bird a game. It is a constant that’s pushing down on things to make them fall down, increase their Y velocity, to make it more positive over time. It’s about 9.8 meters per second squared in real life, and we’re going to model something like that numerically.
- You’ll notice that we define the gravity variable using this
localword. Everything so far that we’ve defined has just been without local. What that means is that anything throughout the entire application can see that variable. But there are certain situations where you might not want every variable visible across your entire application. If you’re using somewhat simple names for variables, it’s higher likelihood that you accidentally reuse or overwrite a particular variable between files. It’s generally better practice to avoid globals as much as you can. -
We add a
dy(delta Y) velocity variable to the bird that gets modified by gravity each frame. Frombird3/Bird.lua:local GRAVITY = 20 function Bird:init() -- load bird image from disk and assign its width and height self.image = love.graphics.newImage('bird.png') self.width = self.image:getWidth() self.height = self.image:getHeight() -- position bird in the middle of the screen self.x = VIRTUAL_WIDTH / 2 - (self.width / 2) self.y = VIRTUAL_HEIGHT / 2 - (self.height / 2) -- Y velocity; gravity self.dy = 0 end function Bird:update(dt) -- apply gravity to velocity self.dy = self.dy + GRAVITY * dt -- apply current velocity to Y position self.y = self.y + self.dy endNotice that gravity is applied to velocity (
self.dy = self.dy + GRAVITY * dt), and then velocity is applied to position (self.y = self.y + self.dy). Over a second’s worth of time, our dy will increase by the gravity value. Not only do we have to do that dy operation, we also have to add the dy to our Y value, because that’s what we use to render the bird. - The way that Flappy Bird works is that even if we have gravity pulling us down constantly, we can just hard set the velocity negative—the opposite of a positive Y velocity is going to be going negative, which means we’ll just go towards the top of the screen. Rather than do a continuous updating of our velocity, minusing by some delta time amount per frame, we can just hard set it to a high negative value, and it will immediately start going up really fast. But because we’re always applying gravity per frame, it’s going to immediately start to correct for that. That’s what’s going to give us this appearance of jumping. We go up really fast, but within a very short span of time, gravity is already doing its thing again.
- Tweaking gravity, tweaking the jump velocity, the pipe speed as they move through, and the gap between the pipes—those would all be the balancing variables that you would use if you wanted to tweak how hard or easy your game is.
-
To allow the bird to jump, we implement a global input tracking system. The unfortunate thing is that in LÖVE, most of those callbacks can only be defined in one place, namely your
main.lua. If we want to just have an instant click or an instant key press, the only way that we’ve known to do that so far has been through our actuallove.keypressedcallback function inmain.lua. But we can’t just define that in our Bird file. What we want to do is cleverly get aroundmain.lua’s predefined callbacks. Frombird4/main.lua:function love.load() -- initialize our nearest-neighbor filter love.graphics.setDefaultFilter('nearest', 'nearest') -- app window title love.window.setTitle('Fifty Bird') -- initialize our virtual resolution push:setupScreen(VIRTUAL_WIDTH, VIRTUAL_HEIGHT, WINDOW_WIDTH, WINDOW_HEIGHT, { vsync = true, fullscreen = false, resizable = true }) -- initialize input table love.keyboard.keysPressed = {} end function love.keypressed(key) -- add to our table of keys pressed this frame love.keyboard.keysPressed[key] = true if key == 'escape' then love.event.quit() end end --[[ New function used to check our global input table for keys we activated during this frame, looked up by their string value. ]] function love.keyboard.wasPressed(key) if love.keyboard.keysPressed[key] then return true else return false end end function love.update(dt) -- scroll background by preset speed * dt, looping back to 0 after the looping point backgroundScroll = (backgroundScroll + BACKGROUND_SCROLL_SPEED * dt) % BACKGROUND_LOOPING_POINT -- scroll ground by preset speed * dt, looping back to 0 after the screen width passes groundScroll = (groundScroll + GROUND_SCROLL_SPEED * dt) % VIRTUAL_WIDTH bird:update(dt) -- reset input table love.keyboard.keysPressed = {} endNotice that we create a table called
love.keyboard.keysPressedand add our own functionlove.keyboard.wasPressed(). Everything in Lua is dynamic—tables are all dynamic. You can just add whatever key you want and put in whatever value you want. In this case,love.keyboardcomes predefined, but it does not have akeysPressedtable or awasPressedfunction. We are adding these ourselves. The important thing that lets this work per frame is that at the end of update, we reinitializekeysPressedto be an empty table so that everything is essentially emptied out and there is no memory of whatever we pressed this frame on the next frame. Everything’s a blank slate. -
In the Bird class, we check for the space bar and apply a sudden burst of negative velocity to make the bird jump. From
bird4/Bird.lua:function Bird:update(dt) -- apply gravity to velocity self.dy = self.dy + GRAVITY * dt -- add a sudden burst of negative gravity if we hit space if love.keyboard.wasPressed('space') then self.dy = -5 end -- apply current velocity to Y position self.y = self.y + self.dy endNotice that pressing space sets
self.dyto -5, which is a negative velocity that moves the bird upward. Within about a third of a second, that’s going to be brought to zero from gravity because it’s going at 980 pixels per second (in other examples). Gravity will then gradually pull it back down.
Procedural Generation
- In Flappy Bird, pipes spawn at regular intervals and scroll from right to left. This is a form of procedural generation where game content is created algorithmically rather than by hand.
- We use a timer to spawn pipes and a table to store all active pipes. If we need infinite pipes, we need a way to have some data structure that can store them. We’re not going to define pipe one, pipe two, pipe three, pipe four. We’re going to put them in a table or an array of sorts.
- Recall from
love.updatethat thedtfield that it passes in is just a time. It’ll be some 0.016, or depending on what frame you’re running, it might be higher or lower than that. If we just keep adding that fractional value to some variable, once this gets to one, we’ll know that a whole second’s worth of time has passed, or two, we’ll know that two seconds’ worth of time has passed. Through using a timer and adding dt just over time to it and doing a conditional check, we’ll know, “Okay, a second’s passed, two seconds passed. I want a pipe to spawn every 2.5 seconds or every five seconds.” This is how you do that. -
We use
table.insert()to add pipes to our collection. This is a function that’s in Lua—this isn’t a LÖVE thing. Frombird5/Pipe.lua:Pipe = Class{} -- since we only want the image loaded once, not per instantation, define it externally local PIPE_IMAGE = love.graphics.newImage('pipe.png') local PIPE_SCROLL = -60 function Pipe:init() self.x = VIRTUAL_WIDTH -- set the Y to a random value halfway below the screen self.y = math.random(VIRTUAL_HEIGHT / 4, VIRTUAL_HEIGHT - 10) self.width = PIPE_IMAGE:getWidth() end function Pipe:update(dt) self.x = self.x + PIPE_SCROLL * dt end function Pipe:render() love.graphics.draw(PIPE_IMAGE, math.floor(self.x + 0.5), math.floor(self.y)) endNotice that pipes spawn at the right edge of the screen (
VIRTUAL_WIDTH) and scroll left at a constant speed. The Y position is randomized to create variety. Every pipe will randomly be tall a certain amount, but they’ll always be moving and eventually getting off screen, and they’ll always start at the right edge of the screen. - We iterate over pipes using
pairs()to update and remove them when they go off-screen. This is using a for loop in Lua. There are a few different ways to do for loops, and this is doing an iterative approach using an iterator. What it’s doing is sayingfor k, pipein pairs of pipes—these are variables that we’re choosing. You can say for something comma something, meaning for every key value pair in this table. By default, if you use a table and just do sequential inserts, it’s going to treat it much like an array in C or C++ or Java. One of the quirks of Lua is that it is not zero indexed—tables are one indexed. We’ll start at index one, then index two, then index three. -
When we call this iterator
for k, pipe, this just means for one pipe, for two pipe, for three pipe. It’s key value pairs. The key is the index, and then the value is going to be what’s actually stored in that table at that index. Frombird5/main.lua:spawnTimer = spawnTimer + dt -- spawn a new Pipe if the timer is past 2 seconds if spawnTimer > 2 then table.insert(pipes, Pipe()) print('Added new pipe!') spawnTimer = 0 endNotice that we use
table.insert()to add a new Pipe to the pipes table every 2 seconds. When we calltable.insert(pipes, Pipe()), Lua automatically assigns the next numerical index (1, 2, 3, etc.) to the new pipe. - Lua, in addition to storing numerical indices, allows us to store strings and have those be our keys. For example, if we want to have a bunch of sounds and we want to add the particular sound for a crash, you could say at index “crash”, store this audio source. That’s going to be a string index. If we were to do that same thing, for k, sound would be for “crash” would be the key, and then sound would be the actual audio source.
- We use
table.remove()to clean up pipes that have moved off-screen. If we just kept adding pipes every 2 seconds and stored them forever, our data structure in memory would just keep getting bigger and bigger, and the iteration would take longer and be more processing. -
When you call
table.remove()to remove a pipe once it’s off screen, Lua’s garbage collector will eventually clean up that memory. It is a garbage collected runtime, just like JavaScript or Python—any of these other scripted languages. It’ll do garbage collection behind the scenes when things are no longer referenceable or keys in tables are removed. Frombird5/main.lua:-- for every pipe in the scene... for k, pipe in pairs(pipes) do pipe:update(dt) -- if pipe is no longer visible past left edge, remove it from scene if pipe.x < -pipe.width then table.remove(pipes, k) end endNotice that for every pipe in the scene, we call
pipe:update(dt)to move it, then check if it has gone off-screen by comparing its x position to its negative width. If so, we usetable.remove()to clean it up.
Pipe Pairs
- In Flappy Bird, pipes come in pairs with a gap between them for the bird to fly through. You have pipes that are above and below each other, oppositely oriented, but with a space between them that’s just small enough to get through with practice. We create a PipePair class that manages both an upper and lower pipe.
- This is again another example of an illusion where in real life, you might see pipes that are built into the ground in this way, and it makes sense, but certainly not just floating from the ceiling, from the sky, and not connected to anything. But because they’re rendered off screen and they’re tall enough, we don’t ever really see anything to suggest that they’re not part of the world in some way.
- The idea of a pipe pair, ultimately, is because they move together, we envision these pipe pairs literally as an entity, even though it’s comprised of two entities. That’s the benefit of object-oriented programming or just more modeled programming. You can have things that are relatively complicated and intricate, have multiple pieces nested within them, and hide the details of how they work so that you can just think in terms of these high-level concepts.
- One of the bigger issues that you could have, if you were to go purely random with the way that you organize the generation for these pipes, is that they’ll just be all over the place. They’ll be very staggered and crooked, and it’ll be pretty much impossible to play the game no matter how good you are. If we keep a record of our last Y, then we can just simply adjust whatever that value is by just adding or subtracting some small amount from it. That will ensure that we move in a gradation. It’ll look like it’s smooth and intended.
-
To flip the top pipe upside down, we use negative scaling in
love.graphics.draw(). The interesting thing about scale factors is that they will flip something based on the XY coordinate of whatever is being flipped or scaled. By default, everything that has a normal scale factor of one is going to just render down and to the right. If we want to set a negative scale factor, the weird thing about that is that a negative scale factor on the Y-axis means that this upper pipe is going to look as if it is drawing upwards—it’s going to flip. We don’t want that. We want it to essentially do a flip, but we want it to stay in place. To do that, we’re adding the pipe’s height to the Y value when we draw it. Frombird8/Pipe.lua:Pipe = Class{} -- since we only want the image loaded once, not per instantation, define it externally local PIPE_IMAGE = love.graphics.newImage('pipe.png') -- speed at which the pipe should scroll right to left PIPE_SPEED = 60 -- height of pipe image, globally accessible PIPE_HEIGHT = 430 PIPE_WIDTH = 70 function Pipe:init(orientation, y) self.x = VIRTUAL_WIDTH self.y = y self.width = PIPE_WIDTH self.height = PIPE_HEIGHT self.orientation = orientation end function Pipe:update(dt) end function Pipe:render() love.graphics.draw(PIPE_IMAGE, self.x, (self.orientation == 'top' and self.y + PIPE_HEIGHT or self.y), 0, 1, self.orientation == 'top' and -1 or 1) endNotice the scale parameter in
love.graphics.draw(). When orientation is ‘top’, we use -1 for the Y scale to flip the pipe vertically, and we offset the Y position byPIPE_HEIGHT. We’re essentially emulating a center rotation about its midpoint. The result is that even though we flip the pipe vertically, we end up actually shifting it down by that exact same amount, and it has the intended effect of just in-place vertically flipping. -
The PipePair class manages both pipes together. We defer the updating of those pipes to this function. This function will take care of passing a call to both pipes to render them so that we don’t have to think of them from
main.lua.main.luajust calls update and render on the pipe pair. The pipe pair manages and wraps over the data of both pipes. Frombird8/PipePair.lua:PipePair = Class{} -- size of the gap between pipes local GAP_HEIGHT = 90 function PipePair:init(y) -- initialize pipes past the end of the screen self.x = VIRTUAL_WIDTH + 32 -- y value is for the topmost pipe; gap is a vertical shift of the second lower pipe self.y = y -- instantiate two pipes that belong to this pair self.pipes = { ['upper'] = Pipe('top', self.y), ['lower'] = Pipe('bottom', self.y + PIPE_HEIGHT + GAP_HEIGHT) } -- whether this pipe pair is ready to be removed from the scene self.remove = false end function PipePair:update(dt) -- remove the pipe from the scene if it's beyond the left edge of the screen, -- else move it from right to left if self.x > -PIPE_WIDTH then self.x = self.x - PIPE_SPEED * dt self.pipes['lower'].x = self.x self.pipes['upper'].x = self.x else self.remove = true end end function PipePair:render() for k, pipe in pairs(self.pipes) do pipe:render() end endNotice that we have a table of two pipes with string keys (‘upper’ and ‘lower’). The gap is created by offsetting the lower pipe by
PIPE_HEIGHT + GAP_HEIGHT. We use a remove flag instead of removing immediately, because there’s a problem with removing things from a table while we’re iterating over it. - When you remove something in the middle of an iteration, the table is going to essentially skip the next index. If you have three pipes and you delete pipe 2, then as soon as you delete pipe 2, pipe 2 becomes what was pipe 3, and pipe 3 is now nil. Nil is Lua’s way to say nothing. You effectively would skip over that pipe. Typically, anytime you want to do a removal from a table, you either want to do it in this way where we are updating and then removing afterwards with a flag, or you could also iterate from the end of your table to the front and then delete things. Then because things get shifted from the later end of the table to the front, deletions won’t actually impact further iterations.
Collision Detection
- To detect when the bird hits a pipe, we use AABB (Axis-Aligned Bounding Box) collision detection, the same algorithm we used in Pong. Thankfully, everything that we’re dealing with today is all rectangular. Even the bird, even though it doesn’t look quite rectangular, we can think of it rectangularly.
-
We add a
collides()method to the Bird class that checks for overlaps with pipes. The only difference from last time is that we are checking on a slightly smaller rectangle than the bird’s actual box by offsetting. Frombird7/Bird.lua:--[[ AABB collision that expects a pipe, which will have an X and Y and reference global pipe width and height values. ]] function Bird:collides(pipe) -- the 2's are left and top offsets -- the 4's are right and bottom offsets -- both offsets are used to shrink the bounding box to give the player -- a little bit of leeway with the collision if (self.x + 2) + (self.width - 4) >= pipe.x and self.x + 2 <= pipe.x + PIPE_WIDTH then if (self.y + 2) + (self.height - 4) >= pipe.y and self.y + 2 <= pipe.y + PIPE_HEIGHT then return true end end return false endNotice that we offset the collision box by 2 pixels on each side. This shrinks the effective hitbox slightly, giving the player a little bit of leeway. This is a common thing that you’ll see game designers doing just to try to overall make their games not feel as unfair. If your game gives you just a slight amount of wiggle room, particularly in shoot-em-up games for old arcade games, scrolling shooters like 1942—you want to have just a little bit of wiggle room to navigate projectiles and the like. The way that sprites are modeled, sometimes with slightly transparent pixels and just weird shapes and circular shapes and whatnot, it can feel better sometimes to just shrink your collision box just a little bit.
- We check for collisions with both pipes in every pipe pair using nested iteration. You have
for k, pairin this outer loop iterating over every pair, but within every pair, we’re also doing an iteration over both pipes. This is using conventionk,l, andv, andm, just whatever letter makes sense. We could call this maybepor some other key if we wanted to.
State Machines
- When you just have a flag or a string variable that you’re using to check game state, your
main.luagets really big and complex, hard to read. It’s just hard to grok to see, “Okay, what state am I in? Am I in the serve state? Am I in the high score state? If I’m in that state, what do I need to do?” It gets to be a little bit cumbersome. Very cumbersome, especially at scale. - A state machine is a way to model the transitions in our game. We have states which are modeled by rectangles, which have terms like ducking, diving, jumping, standing. Then we have the transitions between them. If you go from ducking to standing by releasing down, you can see that relationship modeled. This is how you can diagram out the overall flow for your entire game.
- The benefit of using objects to represent states is they’re their own files. They can have their own objects, their own things that they manage. We don’t need the bird, for example, when we’re in the title menu because we’re not moving a bird around. But we do want it in the play state, but we don’t want it in the score state, maybe. Now each of the states can maintain their own state, their own entities, and so forth, keep things a little bit better encapsulated.
-
We use a global state machine with the
gprefix to indicate it’s global. We’re going to need this accessible anywhere in our code, because every state, especially if we’re going to have individual files that now manage whether we’re in the score state, whether it’s the play state, whatever—if they’re their own files, they need a way to transition to the other states. Frombird8/main.lua:-- all code related to game state and state machines require 'StateMachine' require 'states/BaseState' require 'states/PlayState' require 'states/TitleScreenState'Notice that we require the StateMachine and all our state files from the states folder. The syntax
states/BaseStateis how Lua does directory paths—we have a directory called states within which is a Lua file called BaseState. You do not need the.lua—that’s automatically inferred by Lua. -
The StateMachine class manages state transitions. From
bird8/StateMachine.lua:StateMachine = Class{} function StateMachine:init(states) self.empty = { render = function() end, update = function() end, enter = function() end, exit = function() end } self.states = states or {} -- [name] -> [function that returns states] self.current = self.empty end function StateMachine:change(stateName, enterParams) assert(self.states[stateName]) -- state must exist! self.current:exit() self.current = self.states[stateName]() self.current:enter(enterParams) end function StateMachine:update(dt) self.current:update(dt) end function StateMachine:render() self.current:render() endNotice that the
change()method callsexit()on the current state, then calls the function to create a new state instance, and finally callsenter()on the new state with any parameters we pass in. Part of implementing a state means that all of your functions need to have these methods defined as a contract. -
We initialize the state machine with anonymous functions that return new state instances. The benefit of using anonymous functions here is that typically when we have states, we want them to be fresh every time, at least for relatively simple games. Depending on the game, you might want your states to preserve certain aspects of themselves when you’re transitioning back and forth. We’ll look at examples of that in Pokémon later towards the end of the course, when we look at something more complicated like the state stack. For right now, we’re just going to look at a very simple state machine. From
bird8/main.lua:-- initialize state machine with all state-returning functions gStateMachine = StateMachine { ['title'] = function() return TitleScreenState() end, ['play'] = function() return PlayState() end, } gStateMachine:change('title')Notice that we use anonymous functions that return brand new state instances. This is a thing that certain languages like Lua and JavaScript and Python let you do: A function actually in line like this and assign it to something. This is called a first class function or an anonymous function. What this function does is it just returns a brand new state. Every time we do a change and we call this title, we’re going to get a fresh title screen state. There’s going to be no lingering state anywhere. We’re just going to have a completely blank slate to work with.
-
The BaseState class provides empty implementations of all the interface methods. This is also a useful reminder of what the functions are that any given state should implement in order to be valid within the state machine. From
bird8/states/BaseState.lua:BaseState = Class{} function BaseState:init() end function BaseState:enter() end function BaseState:exit() end function BaseState:update(dt) end function BaseState:render() endNotice that all methods are empty. Other states inherit from BaseState using
__includesso they don’t have to implement methods they don’t need. This is a form of inheritance, which is another object-oriented programming concept where we can have some subclass inherit from a parent class. -
The TitleScreenState is a simple state that waits for Enter to be pressed. The
__includes = BaseStatemeans that it will already come preloaded with all those functions. We don’t have to actually implement them ourselves. Frombird8/states/TitleScreenState.lua:TitleScreenState = Class{__includes = BaseState} function TitleScreenState:update(dt) if love.keyboard.wasPressed('enter') or love.keyboard.wasPressed('return') then gStateMachine:change('play') end end function TitleScreenState:render() love.graphics.setFont(flappyFont) love.graphics.printf('Fifty Bird', 0, 64, VIRTUAL_WIDTH, 'center') love.graphics.setFont(mediumFont) love.graphics.printf('Press Enter', 0, 100, VIRTUAL_WIDTH, 'center') endNotice that when Enter is pressed, we call
gStateMachine:change('play')to transition to the play state. Notice also that we’ve trimmed things down in main. In update, we still scroll things because this is going to happen no matter what state we’re in. But everything else inmain.luain update has been moved togStateMachine:update(), and everything has been moved togStateMachine:render()in the draw function, except for the backgrounds. Anything that you want to apply to all states outside of all states and still happen, you can do outside of your gStateMachine.
Scoring
- In Flappy Bird, a score is accrued through clearing pipes. If the bird’s X coordinate goes past the right edge of any pipe pair, we increment the score. We’ve effectively cleared that pipe pair as an obstacle.
-
Since we’re already iterating over every pipe pair to do a collision test and every pipe therein, we can say, “If there’s a collision, let’s just transition to the score state.” We also need to not rescore pipe pairs over and over again—any pipe pair that would be to the left of the bird would still be to the left of the bird for a while before it despawns. We just flag the pipe pair as being scored if we happen to score on it. From
bird10/states/PlayState.lua:function PlayState:init() self.bird = Bird() self.pipePairs = {} self.timer = 0 -- now keep track of our score self.score = 0 -- initialize our last recorded Y value for a gap placement to base other gaps off of self.lastY = -PIPE_HEIGHT + math.random(80) + 20 end function PlayState:update(dt) -- update timer for pipe spawning self.timer = self.timer + dt -- spawn a new pipe pair every second and a half if self.timer > 2 then -- modify the last Y coordinate we placed so pipe gaps aren't too far apart -- no higher than 10 pixels below the top edge of the screen, -- and no lower than a gap length (90 pixels) from the bottom local y = math.max(-PIPE_HEIGHT + 10, math.min(self.lastY + math.random(-20, 20), VIRTUAL_HEIGHT - 90 - PIPE_HEIGHT)) self.lastY = y -- add a new pipe pair at the end of the screen at our new Y table.insert(self.pipePairs, PipePair(y)) -- reset timer self.timer = 0 end -- for every pair of pipes.. for k, pair in pairs(self.pipePairs) do -- score a point if the pipe has gone past the bird to the left all the way -- be sure to ignore it if it's already been scored if not pair.scored then if pair.x + PIPE_WIDTH < self.bird.x then self.score = self.score + 1 pair.scored = true end end -- update position of pair pair:update(dt) endNotice that we flag each pipe pair as
scored = trueonce we’ve counted it. This prevents us from rescoring the same pipe pair over and over. Notice also that the bird is now here in the play state, not inmain.lua. The play state is the only place where we need the bird. The more you can take out ofmain.luain a lot of ways, the better, because now that gives you an easier way to see what’s happening at a high level. -
When we collide, we transition to the score state and pass the score. The score state, or rather the play state, needs to talk to the score state. Because these are separated domains, separated areas of play that don’t know each other’s variables, each other’s state, the score state needs to know the score that we got during our play state. To do that, we pass a table to the
change()method:gStateMachine:change('score', { score = self.score })Notice that we pass a table with the score. This is the handshake of sorts between our states to allow us to pass data back and forth between the two.
-
The ScoreState receives the score through its
enter()method. This gets called on every state change, so it has to exist. We’re going to store the score now in the score state, and we can now render that. Frombird10/states/ScoreState.lua:ScoreState = Class{__includes = BaseState} --[[ When we enter the score state, we expect to receive the score from the play state so we know what to render to the State. ]] function ScoreState:enter(params) self.score = params.score end function ScoreState:update(dt) -- go back to play if enter is pressed if love.keyboard.wasPressed('enter') or love.keyboard.wasPressed('return') then gStateMachine:change('countdown') end end function ScoreState:render() -- simply render the score to the middle of the screen love.graphics.setFont(flappyFont) love.graphics.printf('Oof! You lost!', 0, 64, VIRTUAL_WIDTH, 'center') love.graphics.setFont(mediumFont) love.graphics.printf('Score: ' .. tostring(self.score), 0, 100, VIRTUAL_WIDTH, 'center') love.graphics.printf('Press Enter to Play Again!', 0, 160, VIRTUAL_WIDTH, 'center') endNotice how the
enter()function receives params and storesparams.scoreinself.score. The score was transferred over through the state machine’s change, and it just has it there as a value. This is the process by which you get state that you can transfer between things while keeping them separate, logically. -
We also add a CountdownState to give the player time to prepare. It’s a little jarring when it starts off and we just press Enter and we’re just already falling. It’s like, “Oh, shoot, maybe I wasn’t ready for that.” It’s not the title screen, it’s not the play state—it’s like a lead into the play state. From
bird10/states/CountdownState.lua:CountdownState = Class{__includes = BaseState} -- takes 1 second to count down each time COUNTDOWN_TIME = 0.75 function CountdownState:init() self.count = 3 self.timer = 0 end --[[ Keeps track of how much time has passed and decreases count if the timer has exceeded our countdown time. If we have gone down to 0, we should transition to our PlayState. ]] function CountdownState:update(dt) self.timer = self.timer + dt if self.timer > COUNTDOWN_TIME then self.timer = self.timer % COUNTDOWN_TIME self.count = self.count - 1 if self.count == 0 then gStateMachine:change('play') end end end function CountdownState:render() love.graphics.setFont(hugeFont) love.graphics.printf(tostring(self.count), 0, 120, VIRTUAL_WIDTH, 'center') endNotice how we use a timer to count down from 3. Every 0.75 seconds, we decrement the count. When it reaches 0, we transition to the play state. One second felt a little bit too long, but it’s about one second. A nice little softening of the process of getting into the game.
Sound Effects and Music
- Sound adds a lot of value and makes the game feel immersive and polished. We use
love.audio.newSource()to load sounds, just like in Pong. -
We create a global sounds table using the
gprefix to designate it’s a global table. Globals can be dangerous—you don’t want to overuse them. But if you’re using a data supply of textures or sounds or what have you, it can be convenient and often the most elegant way to do things. Frombird11/main.lua:-- seed the RNG math.randomseed(os.time()) -- app window title love.window.setTitle('Fifty Bird') -- initialize our nice-looking retro text fonts smallFont = love.graphics.newFont('font.ttf', 8) mediumFont = love.graphics.newFont('flappy.ttf', 14) flappyFont = love.graphics.newFont('flappy.ttf', 28) hugeFont = love.graphics.newFont('flappy.ttf', 56) love.graphics.setFont(flappyFont) -- initialize our table of sounds sounds = { ['jump'] = love.audio.newSource('jump.wav', 'static'), ['explosion'] = love.audio.newSource('explosion.wav', 'static'), ['hurt'] = love.audio.newSource('hurt.wav', 'static'), ['score'] = love.audio.newSource('score.wav', 'static'), -- https://freesound.org/people/xsgianni/sounds/388079/ ['music'] = love.audio.newSource('marios_way.mp3', 'static') } -- kick off music sounds['music']:setLooping(true) sounds['music']:play()Notice that we use
setLooping(true)for music so it plays continuously. Just like we saw looping textures, sometimes you want looping music that is also designed so the end of your track is immediately the same as what would be the front of your music track so you don’t feel a perceived audio gap or a harsh new change. For sound effects, we callsounds['jump']:play()at the appropriate moments in the code. - Sometimes it can work to layer sounds. If you don’t have quite the right sound, it can be easier to just play two sounds at the same time than to try to synthesize the ideal sound. The explosion and hurt sounds are played together when hitting a pipe.
Mouse Input
- Because Flappy Bird was a mobile game from the outset, we add mouse input support so players can click to flap. This emulates the tap-to-flap gameplay of the original. LÖVE gives us a function called
love.mousepressed(), which is very similar tolove.keypressed(). It will trigger any time that you click the mouse. This is essentially also a deferral to touches on iOS or Android, and this is often how it’s modeled in the web world too. -
We create a mouse input tracking system similar to our keyboard system. We’re adding to
love.mouseabuttonsPressedtable. We’re doing the same thing here that allows us to do these checks for single clicks, single button presses anywhere in our code. Frombird12/main.lua:-- initialize input table love.keyboard.keysPressed = {} -- initialize mouse input table love.mouse.buttonsPressed = {}Notice that we create a parallel table for mouse buttons.
-
We add the
love.mousepressed()callback and a customwasPressed()function. Frombird12/main.lua:--[[ LÖVE2D callback fired each time a mouse button is pressed; gives us the X and Y of the mouse, as well as the button in question. ]] function love.mousepressed(x, y, button) love.mouse.buttonsPressed[button] = true end function love.keyboard.wasPressed(key) return love.keyboard.keysPressed[key] end --[[ Equivalent to our keyboard function from before, but for the mouse buttons. ]] function love.mouse.wasPressed(button) return love.mouse.buttonsPressed[button] end function love.update(dt) if scrolling then backgroundScroll = (backgroundScroll + BACKGROUND_SCROLL_SPEED * dt) % BACKGROUND_LOOPING_POINT groundScroll = (groundScroll + GROUND_SCROLL_SPEED * dt) % VIRTUAL_WIDTH end gStateMachine:update(dt) love.keyboard.keysPressed = {} love.mouse.buttonsPressed = {} endNotice that
love.mousepressed()takes x, y, and button parameters. The x and y will be useful when we look at Angry Birds, which uses actual tracking based on where you clicked and moved the mouse or moved the pointer. But in this case, it doesn’t really matter what the X and the Y is. We don’t care about that—if we just click the mouse, we essentially just want the bird to bounce up. -
In the Bird class, we check for both keyboard and mouse input. From
bird12/Bird.lua:function Bird:update(dt) self.dy = self.dy + GRAVITY * dt if love.keyboard.wasPressed('space') or love.mouse.wasPressed(1) then self.dy = -3 sounds['jump']:play() end self.y = self.y + self.dy endNotice we check for either space bar OR left mouse click (button 1). One being the integer that maps to left click on computers. This supports multiple input methods, making the game more accessible. Very trivial, ultimately, to add left click support in your game, especially if you were thinking of maybe exporting it to mobile platforms.
Summing Up
In this lecture, you learned to build Flappy Bird from scratch. Specifically, you learned…
- How to load and render images using sprites instead of just rectangles.
- How to create infinite scrolling with parallax effects using the modulo operator.
- How to simulate gravity and implement jump mechanics with velocity.
- About the difference between local and global variables in Lua.
- About custom input tracking by extending
love.keyboardwith your own functions. - About procedural generation with timer-based spawning.
- About table iteration with
pairs()and how Lua tables are one-indexed, not zero-indexed. - How to create composite objects like pipe pairs with image flipping using negative scale factors.
- About the importance of separating removal from iteration when modifying tables.
- About AABB collision detection with bounding box offsets for player forgiveness.
- How to implement state machines for clean game architecture using first-class functions.
- About inheritance in Lua using
__includes. - How to pass data between states using enter parameters.
- How to add sound effects and looping background music.
- How to support multiple input methods (keyboard and mouse).
Next time we’ll dive into Breakout, which will be a little bit more intricate and visually interesting. We’ll look at sprite sheets, procedural layouts for our actual maps, levels, health, particle systems, fancier collision detection, and persistent save data.
See you next time!