Lecture 1

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 with love.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 = 413
    

    Notice 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
    end
    

    Notice 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()
    end
    

    Notice 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() and self.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. From bird2/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)
    end
    

    Notice 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 local word. 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. From bird3/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
    end
    

    Notice 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 actual love.keypressed callback function in main.lua. But we can’t just define that in our Bird file. What we want to do is cleverly get around main.lua’s predefined callbacks. From bird4/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 = {}
    end
    

    Notice that we create a table called love.keyboard.keysPressed and add our own function love.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.keyboard comes predefined, but it does not have a keysPressed table or a wasPressed function. We are adding these ourselves. The important thing that lets this work per frame is that at the end of update, we reinitialize keysPressed to 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
    end
    

    Notice that pressing space sets self.dy to -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.update that the dt field 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. From bird5/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))
    end
    

    Notice 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 saying for k, pipe in 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. From bird5/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
    end
    

    Notice that we use table.insert() to add a new Pipe to the pipes table every 2 seconds. When we call table.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. From bird5/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
    end
    

    Notice 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 use table.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. From bird8/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)
    end
    

    Notice 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 by PIPE_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.lua just calls update and render on the pipe pair. The pipe pair manages and wraps over the data of both pipes. From bird8/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
    end
    

    Notice 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. From bird7/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
    end
    

    Notice 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, pair in this outer loop iterating over every pair, but within every pair, we’re also doing an iteration over both pipes. This is using convention k, l, and v, and m, just whatever letter makes sense. We could call this maybe p or 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.lua gets 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 g prefix 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. From bird8/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/BaseState is 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()
    end
    

    Notice that the change() method calls exit() on the current state, then calls the function to create a new state instance, and finally calls enter() 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() end
    

    Notice that all methods are empty. Other states inherit from BaseState using __includes so 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 = BaseState means that it will already come preloaded with all those functions. We don’t have to actually implement them ourselves. From bird8/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')
    end
    

    Notice 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 in main.lua in update has been moved to gStateMachine:update(), and everything has been moved to gStateMachine: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)
        end
    

    Notice that we flag each pipe pair as scored = true once 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 in main.lua. The play state is the only place where we need the bird. The more you can take out of main.lua in 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. From bird10/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')
    end
    

    Notice how the enter() function receives params and stores params.score in self.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')
    end
    

    Notice 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 g prefix 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. From bird11/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 call sounds['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 to love.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.mouse a buttonsPressed table. We’re doing the same thing here that allows us to do these checks for single clicks, single button presses anywhere in our code. From bird12/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 custom wasPressed() function. From bird12/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 = {}
    end
    

    Notice 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
    end
    

    Notice 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.keyboard with 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!