Lecture 0

Welcome!

  • This is CS50’s introduction to 2D game development, also known as CS50 2D.
  • What got many into programming to begin with was, as a kid, really loving video games: Going to the bookstore and looking for whatever book seemed the most appealing or the most likely to help in the endeavor to learn video game development. Unfortunately for me, opening the book and seeing dollar signs and ampersands and pipes and all these things was very overwhelming. But by happenstance, finding “C for Dummies” sparked in me a different mindset and led to really enjoying programming in general, not just related to game programming.
  • When putting this course together, the thinking was about all those iconic games from those game systems of my childhood: Super Nintendo, Game Boy, NES, Legend of Zelda, Final Fantasy, Pokémon, Mario. Most people that want to learn how to program games are probably keeping these sorts of games in mind. In building this course, we decided to go with this whole smorgasbord of video games: replications of famous games from Pong, which is what today’s lecture is about, all the way to Flappy Bird, Angry Birds, Pokémon, Mario, and Zelda.
  • Really, Pong and today’s topics set the stage really well for game programming in general. Even though on the surface Pong is such a simple game, most of the fundamentals of 2D game development exist within Pong, and then they transcend Pong to all the other examples, all the other games that we’ll be building in this course.

Lua and LÖVE2D

  • The two main key tools we’ll be using are Lua and LÖVE2D. Lua is the programming language we’ll use. LÖVE2D is a micro engine framework, a runtime that uses Lua. LÖVE2D is what gives us the ability to draw things to the screen and do audio, all the things that a game typically needs.
  • Lua is a Portuguese name, which means “moon.” It’s used very often in the industry. It’s very flexible and lightweight. A lot of engines use it for scripting. If anybody has used JavaScript, Lua is very similar actually in syntax to JavaScript, although it does introduce its own syntax. Just like JavaScript, it’s very easy to store data. JavaScript has JSON. Lua itself stores everything in what are called tables, which are like JavaScript’s objects, and it makes for very clean, easy storage of information.
  • LÖVE2D is a very fast, lightweight C++ compiled framework/binary for developing games, but it’s coded and scripted in Lua. It’s got all the modules that you could hope to want for 2D game development: graphics, keyboard input, everything. It even comes with modules for math and is going to start incorporating networking in upcoming releases. It’s completely free, open source, and portable. It has a very readable API, which is great for learning and great for real-world projects.
  • Baletro is a very popular game released last year that was written in LÖVE2D. It might be the most popular, well-known game as of right now in LÖVE2D. This is an amazing game, and it’s testament to the fact that you can make full games in LÖVE2D. It’s not just for prototyping.
  • Everything that you run in LÖVE2D needs a main.lua file that has all of the code that bootstraps your program. You can either drag your folder onto the LÖVE executable, or you can install a VS Code extension called “Love 2D Support” by Pixelbyte Studios, which allows you to conveniently just press Command or Control L to run your code.
  • In Lua, the function keyword declares a function, and the end keyword terminates a block. This applies to function blocks, if statements, and loops. The end keyword is used for terminating the definition of a block, while parentheses are used for function invocations to actually trigger a function call.
  • Lua supports both single quotes and double quotes for strings. Use whichever you prefer. You might want to use double quotes if you have an apostrophe in your string, and vice versa. Single quotes are commonly used for consistency.
  • Here is a demonstration of Lua’s syntax. From lua_demo.lua:

    --
    -- Variables
    --
    
    -- global (accessible from other Lua modules)
    hello = 'hello'
    
    -- local (accessible only in this scope)
    local world = ' world!'
    
    --
    -- Functions
    --
    
    -- declaring our function
    function say(text)
        print(text)
    end
    
    -- calling our function (note the .. operator to concatenate strings!)
    say(hello .. world)
    

    Notice that global variables are declared without a specifier, while local variables use the local keyword. String concatenation in Lua is done with .. (dot dot), and comments are written with two hyphens. All of these variables that are declared without a specifier in front of them are all global variables, meaning that we can declare them anywhere we want to, and they’ll just be accessible anywhere we want to.

Game Loops

  • All games, whether 2D or 3D, are going to have some inner game loop. A game loop is a really simple series of steps when you look at it diagrammed out. It gets complicated, and there’s obviously very many permutations of this, and the engineering of it gets complicated. But in a nutshell, we can look at a game loop as a series of pretty simple steps.
  • The first step is processing of input. This means we need to check which keys have been pressed on any given frame. A game will run between one to 60, ideally, frames per second on most computers. A game is essentially like a flipbook that you just run constantly. By processing input each frame, each one-sixtieth of a second, you’re checking what the player is doing. You can think of this as a while loop that just runs, and it tries to run about 60 frames a second on average. It can fluctuate. 30 frames is console standard, 60 frames for PC. But if you have a higher hertz monitor, your graphics card will fit that refresh rate, assuming the engine supports it.
  • After processing input, you’ll then update the game based on that input. If you pressed the Enter key, do something. If you pressed W or S, move or set the velocity of some object. That’s typically what happens, or it could change based on AI decision making.
  • Based on what we’ve just updated, you’ll want to render those changes. That’s what essentially gives us this illusion of things happening on the screen: we’ve taken our input, we’ve made changes to state variables, and then we have made those changes visible through rendering, through drawing sprites, through drawing shapes, through moving those shapes and sprites that are all contingent on the state variables, these X and Y values that we’ll see shortly.
  • Sometimes, depending on your game’s architecture or the speed of your computer, you might repeatedly update in one frame. Maybe your computer is too slow to render 60 times a second, and so you need to update multiple times in between frames. Or maybe you need to update multiple times per frame to account for particular physics calculations that have to ensure that no matter what your frame rate is, they stay consistent.
  • A sprite is simply an image that we draw to the screen and can move around and do things with. Unlike shapes, which is the focus of today for Pong and simplicity, generally 2D engines will show sprite art much more than just shapes being drawn.
  • LÖVE2D embraces this paradigm elegantly with its function structure. From pong-0/main.lua:

    --[[
        Runs when the game first starts up, only once; used to initialize the game.
    ]]
    function love.load()
        love.window.setMode(WINDOW_WIDTH, WINDOW_HEIGHT, {
            fullscreen = false,
            resizable = false,
            vsync = true
        })
    end
    
    --[[
        Called after update by LÖVE2D, used to draw anything to the screen, updated or otherwise.
    ]]
    function love.draw()
        love.graphics.printf(
            'Hello Pong!',          -- text to render
            0,                      -- starting X (0 since we're going to center it based on width)
            WINDOW_HEIGHT / 2 - 6,  -- starting Y (halfway down the screen)
            WINDOW_WIDTH,           -- number of pixels to center within (the entire screen here)
            'center')               -- alignment mode, can be 'center', 'left', or 'right'
    end
    

    Notice that love.load runs once at the very beginning when you run your main.lua. It’s good for setup and initialization. love.draw is the render part of our loop that LÖVE calls for us every frame. These are functions it expects to exist in your code. It will call these for you. It maintains its own game loop that’s always running, aspiring for the refresh rate of your monitor, 60 frames a second.

2D Coordinate Systems

  • When we’re looking at 2D games, we’re dealing with 2D coordinate systems. This is essentially just a way of looking at the world as a series of X,Y coordinate pairs. Different engines and frameworks will implement this in different ways. Some will have Y going up instead of down, and so forth.
  • In LÖVE2D, positive Y goes down, negative Y goes up, positive X goes right, and negative X goes left. The origin (0,0) is at the top-left corner of the screen.
  • All 2D programming, especially when looking at coordinates, essentially maps to the actual pixels on your screen. In order to draw anything on your screen, whether it’s a sprite, whether it’s text, whether it’s a shape, the pixels on your screen are essentially following this coordinate system.
  • To set up a window, we use love.window.setMode, which takes a width, height, and a table of options. When you pass a table as an argument, it doesn’t matter what order you pass the key value pairs in. V-Sync syncs the game’s rendering to your monitor’s refresh rate so that you don’t see screen tearing, which is when half of the screen or some portion of it seems slightly off because the rendering was interrupted in the middle.

Virtual Resolution

  • The fact that we’re only drawing text right now is somewhat on the austere side. In order to get that pixelated retro look, especially for older games like Pong, we need virtual resolution.
  • Most games back in the day were programmed with a maximum size of around 300 by 200 pixels, plus or minus. Now we’re dealing with 1080p or 4K resolutions. If you try to render something at that resolution, it’s going to be tiny. We want the ability to fill our 1280 by 720 window, but we want it to look pixelated just like we were on a CRT or full screen TV playing a classic retro video game.
  • By default, graphics libraries apply bilinear or trilinear filtering to fonts and images, which makes things look blurry when scaled. The N64 is notorious for having bilinear filtering on everything. It had very tiny texture cache, very tiny textures in general. Developers would have to be stretching these very tiny textures over these large surfaces. For pixel art, we want nearest-neighbor filtering (also called point filtering), which preserves crisp, sharp pixels.
  • Point filtering can be used for aesthetic positives. Minecraft does not have filtering on its textures. It is notorious for this, but it’s to its benefit because it has this reputation now where things that are pixelated look like Minecraft textures. This is an example of using point filter textures for actual aesthetic positive.
  • There’s a library called push that allows us to spoof as if we were drawing to a virtual canvas of whatever size we want to, while keeping it within a window size we prefer. This is really important, especially for this course and its aesthetic. One of the things that can turn people off to 2D game development in certain learning contexts is that things look very “programmer art” at times, where there just isn’t a coherence to the fonts versus the art. Pixelated fonts and appealing more to that retro aesthetic solves this problem pretty elegantly. From pong-1/main.lua:

    -- push is a library that will allow us to draw our game at a virtual
    -- resolution, instead of however large our window is; used to provide
    -- a more retro aesthetic
    --
    -- https://github.com/Ulydev/push
    push = require 'push'
    
    WINDOW_WIDTH = 1280
    WINDOW_HEIGHT = 720
    
    VIRTUAL_WIDTH = 432
    VIRTUAL_HEIGHT = 243
    
    --[[
        Runs when the game first starts up, only once; used to initialize the game.
    ]]
    function love.load()
        -- use nearest-neighbor filtering on upscaling and downscaling to prevent blurring of text
        -- and graphics; try removing this function to see the difference!
        love.graphics.setDefaultFilter('nearest', 'nearest')
    
        -- sets up the actual window within the OS, before we virtualize our resolution
        love.window.setMode(WINDOW_WIDTH, WINDOW_HEIGHT, {
            resizable = true,
            fullscreen = false,
            vsync = true
        })
    
        -- initialize our virtual resolution, which will be rendered within our
        -- actual window no matter its dimensions
        push.setupScreen(VIRTUAL_WIDTH, VIRTUAL_HEIGHT, { upscale = 'normal' })
    end
    

    Notice that love.graphics.setDefaultFilter('nearest', 'nearest') applies nearest-neighbor filtering to everything, whether it’s textures or fonts. The push.setupScreen function takes the virtual resolution first, then the actual window resolution, and finally options. Now we’re pretending that instead of a 1280 by 720 screen, we’re at a 432 by 243, and we want to also be thinking in terms of those XY coordinates.

  • To handle keyboard input and quit the application, we use love.keypressed. From pong-1/main.lua:

    --[[
        Keyboard handling, called by LÖVE2D each frame;
        passes in the key we pressed so we can access.
    ]]
    function love.keypressed(key)
        -- keys can be accessed by string name
        if key == 'escape' then
            -- function LÖVE gives us to terminate application
            love.event.quit()
        end
    end
    

    Notice that love.keypressed fires once when a key is pressed, and we can check which key using the key parameter. If statements in Lua use the then keyword to specify the beginning of a block, and then these then blocks will end with an end, just like function blocks do.

  • In the draw function, we wrap everything in push.start() and push.finish(). From pong-1/main.lua:

    --[[
        Called after update by LÖVE2D, used to draw anything to the screen,
        updated or otherwise.
    ]]
    function love.draw()
        -- begin rendering at virtual resolution
        push.start()
    
        -- condensed onto one line from last example
        -- note we are now using virtual width and height now for text placement
        love.graphics.printf('Hello Pong!', 0, VIRTUAL_HEIGHT / 2 - 6, VIRTUAL_WIDTH, 'center')
    
        -- end rendering at virtual resolution
        push.finish()
    end
    

    Notice that push.start() puts us in virtual resolution mode, essentially saying “okay, everything you draw right now is going to be drawn to this virtual screen.” Then push.finish() turns off push, so whatever you draw after is going to be native resolution. All calculations should be done in virtual width and height terms.

Drawing Shapes

  • Pong is essentially just rectangles and a little bit of text. In order to draw rectangles, LÖVE2D gives us love.graphics.rectangle, which takes a mode (‘fill’ or ‘line’), an X and Y position, and a width and height. Fill rectangles are literally filled in. Line rectangles can be useful for rendering debug collision boxes for your sprites, which we’ll see uses of in the future.
  • To change the background color, we use love.graphics.clear with RGBA values. RGB is very prevalent historically and still today in game development and CSS. You have three different lights that go from zero to one or zero to 255 that if you combine them in different ways, you produce many millions of colors. It expects zero-to-one floating point values, but RGB is traditionally thought of as zero to 255, so we divide by 255 to normalize them.
  • Aesthetics matter a good deal. Font, meaningfully, is a good aesthetic thing to take into consideration. There’s even a Final Fantasy One remaster that initially had a lot of complaints about the font because it was using some Arial or Helvetica that was very tightly smashed together, and aesthetically it just didn’t quite mesh with the pixel JRPG look.
  • To create custom fonts, we use love.graphics.newFont and love.graphics.setFont. From pong-2/main.lua:

    function love.load()
        love.graphics.setDefaultFilter('nearest', 'nearest')
    
        -- more "retro-looking" font object we can use for any text
        smallFont = love.graphics.newFont('font.ttf', 8)
    
        -- set LÖVE2D's active font to the smallFont obect
        love.graphics.setFont(smallFont)
    
        love.window.setMode(WINDOW_WIDTH, WINDOW_HEIGHT, {
            resizable = true,
            fullscreen = false,
            vsync = true
        })
    
        -- initialize window with virtual resolution
        push.setupScreen(VIRTUAL_WIDTH, VIRTUAL_HEIGHT, { upscale = 'normal' })
    end
    

    Notice that we can create font objects at different sizes and switch between them using love.graphics.setFont.

  • To draw the paddles and ball, everything gets drawn by its top-left coordinate. When we say 10 pixels by 30 pixels for the first paddle, we’re saying the top-left corner of that rectangle should start at that position, and then it will draw its width and height towards the right and down. From pong-2/main.lua:

    function love.draw()
        -- begin rendering at virtual resolution
        push.start()
    
        -- clear the screen with a specific color; in this case, a color similar
        -- to some versions of the original Pong
        love.graphics.clear(40/255, 45/255, 52/255, 255/255)
    
        -- draw welcome text toward the top of the screen
        love.graphics.printf('Hello Pong!', 0, 20, VIRTUAL_WIDTH, 'center')
    
        --
        -- paddles are simply rectangles we draw on the screen at certain points,
        -- as is the ball
        --
    
        -- render first paddle (left side)
        love.graphics.rectangle('fill', 10, 30, 5, 20)
    
        -- render second paddle (right side)
        love.graphics.rectangle('fill', VIRTUAL_WIDTH - 10, VIRTUAL_HEIGHT - 50, 5, 20)
    
        -- render ball (center)
        love.graphics.rectangle('fill', VIRTUAL_WIDTH / 2 - 2, VIRTUAL_HEIGHT / 2 - 2, 4, 4)
    
        -- end rendering at virtual resolution
        push.finish()
    end
    

    Notice that for the right paddle, we use VIRTUAL_WIDTH - 15 to accommodate for not only the 10 pixels away from the edge, but also the 5 pixels of the width because we have to remember to accommodate for the fact that we’re drawing based on the top-left coordinate. If we want to show it 10 pixels from the right, it actually has to be drawn 15 pixels from that right side because the width of the rectangle is baked into that calculation.

Delta Time and Velocity

  • Now that we have a visual skeleton of Pong, the question is how to get the game to behave more like a game. We need things to move.
  • If we want to press and hold a key to move a paddle up and down consistently, love.keypressed won’t work because it only fires once per key press. You can hold it all day, but it’s just only going to fire the one time. Instead, we use love.keyboard.isDown, which will just continuously do a check whenever you call it.
  • love.update takes a dt parameter, which stands for delta time. This is the time in seconds that has passed since the last frame. This will be some fraction, some zero point zero or zero one six or something, which is a very small value.
  • Delta time is maybe the most crucial value we use because it’s the driver for how we get consistent behavior across different computers. If a computer runs at 30 fps or 144 fps, we need to normalize movement across all frame rates. If we’re just doing things every frame, well, in one second, 60 iterations could happen, 144 iterations could happen, 10, 20 iterations could happen. We need some way to normalize across all possible frame rates. The way we do this is with this DT variable. LÖVE keeps track of this, processes it, and sends it to us via this parameter. That Delta value will be smaller the more frames run in a given second, and therefore, those things will move more times, but smaller increments, and everything will be normalized across variable frame rates.
  • From pong-3/main.lua:

    -- speed at which we will move our paddle; multiplied by dt in update
    PADDLE_SPEED = 200
    
    --[[
        Runs every frame, with "dt" passed in, our delta in seconds
        since the last frame, which LÖVE2D supplies us.
    ]]
    function love.update(dt)
        -- player 1 movement
        if love.keyboard.isDown('w') then
            -- add negative paddle speed to current Y scaled by deltaTime
            player1Y = player1Y + -PADDLE_SPEED * dt
        elseif love.keyboard.isDown('s') then
            -- add positive paddle speed to current Y scaled by deltaTime
            player1Y = player1Y + PADDLE_SPEED * dt
        end
    
        -- player 2 movement
        if love.keyboard.isDown('up') then
            -- add negative paddle speed to current Y scaled by deltaTime
            player2Y = player2Y + -PADDLE_SPEED * dt
        elseif love.keyboard.isDown('down') then
            -- add positive paddle speed to current Y scaled by deltaTime
            player2Y = player2Y + PADDLE_SPEED * dt
        end
    end
    

    Notice that we multiply PADDLE_SPEED by dt. Every one-sixtieth of a second (assuming 60 fps), it will decrease the Y value by some fraction of 200 pixels. By the time 60 frames have passed, we will have moved 200 pixels regardless of frame rate. If only one frame passed between this second and the last second, this DT value would just be one, which would effectively mean your computer could be horrendously slow, and the movement is going to always scale by this delta in time.

  • To prevent paddles from going off-screen, we use math.max and math.min to clamp values. These functions allow us to effectively clamp values. We can take the greater or the lesser of two numbers. From pong-4/main.lua:

    -- player 1 movement
    if love.keyboard.isDown('w') then
        -- add negative paddle speed to current Y scaled by deltaTime
        -- now, we clamp our position between the bounds of the screen
        -- math.max returns the greater of two values; 0 and player Y
        -- will ensure we don't go above it
        player1Y = math.max(0, player1Y + -PADDLE_SPEED * dt)
    elseif love.keyboard.isDown('s') then
        -- add positive paddle speed to current Y scaled by deltaTime
        -- math.min returns the lesser of two values; bottom of the egde minus paddle height
        -- and player Y will ensure we don't go below it
        player1Y = math.min(VIRTUAL_HEIGHT - 20, player1Y + PADDLE_SPEED * dt)
    end
    

    Notice how math.max prevents the paddle from going above the screen (less than 0). In the case that player 1 Y goes less than zero, math.max is always going to choose zero. And math.min prevents it from going below (more than VIRTUAL_HEIGHT - 20, accounting for paddle height). It will always ever be virtual height minus 20 at the lowest point. Through this means, we end up ensuring that our paddles are always hard-clamped to the screen.

  • For the ball, we need both X and Y velocity since it can move all over the place. It can bounce, go left and right, bounce off the top, bounce off the bottom. Because it’s going to need this velocity that can be on the X and/or the Y axis, we need to keep track of both DX and DY variables. We also use a game state variable. From pong-4/main.lua:

    -- velocity and position variables for our ball when play starts
    ballX = VIRTUAL_WIDTH / 2 - 2
    ballY = VIRTUAL_HEIGHT / 2 - 2
    
    -- math.random returns a random value between the left and right number
    ballDX = math.random(2) == 1 and 100 or -100
    ballDY = math.random(-50, 50)
    
    -- game state variable used to transition between different parts of the game
    -- (used for beginning, menus, main game, high score list, etc.)
    -- we will use this to determine behavior during render and update
    gameState = 'start'
    

    Notice that we seed the random number generator with math.randomseed(os.time()) so it’s different every time we run the game. os.time() returns that value in seconds since the start of the Unix operating system, which is always going to be an incrementing value throughout time, so our random number generator will always be different every time we run the source code. The and ... or pattern is Lua’s way of doing a ternary expression. If the first value is true, then the and will result and return that value. Otherwise, it will fall to the or value. If you see this and/or pattern, that’s essentially what we’re doing: a ternary.

Object-Oriented Programming

  • Rather than continue to add variables for every paddle, ball, or entity, we should look at object-oriented programming (or OOP). A class is like a blueprint that defines the shape of an object. An object is a way to containerize data and methods. Think of all the velocities and XY position and everything, and then functions that those entities might apply to that data inside them.
  • The fact that we can just have all of the data compartmentalized inside of something rather than have an X, Y variable for every paddle or ball or entity, Delta X, Delta Y, and all these different global variables, gets to be very unwieldy, especially if you can imagine having like 100 creatures in your game. A class allows us to think: how can I define maybe the idea of a paddle as a piece of data that has all of its own information and also functions that deal with that information together in one place that I can just reference and call and not bog up my main.lua file?
  • Games are essentially just this big collection of entities doing things, working and sending messages to each other, invoking methods between one another in order to accomplish things. Game development lends itself well to object-oriented programming.
  • We use a library called class that helps with OOP in Lua. From pong-5/main.lua:

    -- the "Class" library we're using will allow us to represent anything in
    -- our game as code, rather than keeping track of many disparate variables and
    -- methods
    --
    -- https://github.com/vrld/hump/blob/master/class.lua
    Class = require 'class'
    
    -- our Paddle class, which stores position and dimensions for each Paddle
    -- and the logic for rendering them
    require 'Paddle'
    
    -- our Ball class, which isn't much different than a Paddle structure-wise
    -- but which will mechanically function very differently
    require 'Ball'
    

    Notice that we require the class library and then our own class files. The syntax require 'Paddle' means Lua will look for a Paddle.lua file. You don’t need to specify .lua; it’s automatically inferred by Lua. We’re defining those as globals within those modules, so we don’t actually have to say something = require 'Paddle'.

  • A Paddle class contains all its own data and methods. From pong-5/Paddle.lua:

    Paddle = Class{}
    
    --[[
        The `init` function on our class is called just once, when the object
        is first created. Used to set up all variables in the class and get it
        ready for use.
    
        Our Paddle should take an X and a Y, for positioning, as well as a width
        and height for its dimensions.
    
        Note that `self` is a reference to *this* object, whichever object is
        instantiated at the time this function is called. Different objects can
        have their own x, y, width, and height values, thus serving as containers
        for data. In this sense, they're very similar to structs in C.
    ]]
    function Paddle:init(x, y, width, height)
        self.x = x
        self.y = y
        self.width = width
        self.height = height
        self.dy = 0
    end
    
    function Paddle:update(dt)
        -- math.max here ensures that we're the greater of 0 or the player's
        -- current calculated Y position when pressing up so that we don't
        -- go into the negatives; the movement calculation is simply our
        -- previously-defined paddle speed scaled by dt
        if self.dy < 0 then
            self.y = math.max(0, self.y + self.dy * dt)
        -- similar to before, this time we use math.min to ensure we don't
        -- go any farther than the bottom of the screen minus the paddle's
        -- height (or else it will go partially below, since position is
        -- based on its top left corner)
        else
            self.y = math.min(VIRTUAL_HEIGHT - self.height, self.y + self.dy * dt)
        end
    end
    
    --[[
        To be called by our main function in `love.draw`, ideally. Uses
        LÖVE2D's `rectangle` function, which takes in a draw mode as the first
        argument as well as the position and dimensions for the rectangle. To
        change the color, one must call `love.graphics.setColor`. As of the
        newest version of LÖVE2D, you can even draw rounded rectangles!
    ]]
    function Paddle:render()
        love.graphics.rectangle('fill', self.x, self.y, self.width, self.height)
    end
    

    Notice the self keyword, which refers to whichever paddle is calling the method. Self is just going to be whatever the object is that’s being defined with the call. When we say Paddle(10, 30, 5, 20), the init function gets called by default as part of the class library, and self is going to be that Paddle object that becomes player1. The init function is called a constructor. This is just something that will flesh out our paddle with initial values and create it as an actual thing. All of these self variables, these fields as they’re called, will be referenceable later in any other function.

  • In main.lua, we create paddle and ball objects. From pong-5/main.lua:

    -- initialize our player paddles; make them global so that they can be
    -- detected by other functions and modules
    player1 = Paddle(10, 30, 5, 20)
    player2 = Paddle(VIRTUAL_WIDTH - 10, VIRTUAL_HEIGHT - 30, 5, 20)
    
    -- place a ball in the middle of the screen
    ball = Ball(VIRTUAL_WIDTH / 2 - 2, VIRTUAL_HEIGHT / 2 - 2, 4, 4)
    

    Notice how we now think in terms of paddles and a ball. We’re not thinking in terms of 8, 10, 12 different variables that we’re trying to juggle altogether. We’ve condensed our code highly down now.

  • The colon syntax in Lua (e.g., player1:update(dt)) is different from dot in that it will take an object, take a table or a piece of data, and actually implicitly pass itself to a function defined on the class. It allows us to call methods on ball, player1, and player2 that are a part of that blueprint definition. Every paddle has a function that that paddle can use by itself to reference. This ball’s update will know about ball’s information. This player1 update will know about player1’s information. From pong-5/main.lua:

    -- update our ball based on its DX and DY only if we're in play state;
    -- scale the velocity by dt so movement is framerate-independent
    if gameState == 'play' then
        ball:update(dt)
    end
    
    player1:update(dt)
    player2:update(dt)
    

    Notice the readability of this code. We’ve abstracted out the details into the class definitions. No longer do we have to put all of the logic needed to update them inside of love.update. In fact, it’s better practice to keep all the LÖVE functions in your main.lua as clean and concise as possible and defer the heavy lifting to other modules.

Collision Detection

  • A game isn’t a game if you can’t lose. We have to talk about collision detection. In the world of 2D collision detection, we use AABB (Axis-Aligned Bounding Box) collision detection.
  • AABB is simply testing to see if two boxes that are axis-aligned (not rotated) are overlapping. You can think of it as checking: do my two rectangles have a gap between them? If we want the paddle and the ball to collide with each other, to get a message that they have collided, and then set the velocity of the ball to invert on the X axis if it hits a paddle and on the Y axis if it hits the ceiling or floor, we need this algorithm.
  • The algorithm checks if any edge of rectangle A is outside the bounds of rectangle B. Basically checking to see is there no gap on all four sides? Is the left edge of one to the right of the right edge of the other? If any of these conditions is true, there’s a gap, so no collision. Only if none are true do we have a collision. From pong-7/Ball.lua:

    --[[
        Expects a paddle as an argument and returns true or false, depending
        on whether their rectangles overlap.
    ]]
    function Ball:collides(paddle)
        -- first, check to see if the left edge of either is farther to the right
        -- than the right edge of the other
        if self.x > paddle.x + paddle.width or paddle.x > self.x + self.width then
            return false
        end
    
        -- then check to see if the bottom edge of either is higher than the top
        -- edge of the other
        if self.y > paddle.y + paddle.height or paddle.y > self.y + self.height then
            return false
        end
    
        -- if the above aren't true, they're overlapping
        return true
    end
    

    Notice that we check both the X axis and Y axis for gaps. If none of them return false, then that means we have a collision. The gaps are completely closed in.

  • When a collision occurs, we need to respond by reversing the ball’s velocity and moving it outside the paddle. This is important. Anytime you do collision detection resolving, you have to move the thing that collided outside of the rectangle it collided with. Because as you’re moving things, the ball is moving, it can be moving pretty quickly, and frame by frame it’s going to clip into the paddle some amount, maybe several pixels. We want to make sure that on the next frame, as it’s updating, it’s not still inside the paddle because then it’ll just be infinitely colliding with the paddle. We want to essentially knock it out of the bounds, nudge it in a correct position. From pong-7/main.lua:

    -- detect ball collision with paddles, reversing dx if true and
    -- slightly increasing it, then altering the dy based on the position of collision
    if ball:collides(player1) then
        ball.dx = -ball.dx * 1.03
        ball.x = player1.x + 5
    
        -- keep velocity going in the same direction, but randomize it
        if ball.dy < 0 then
            ball.dy = -math.random(10, 150)
        else
            ball.dy = math.random(10, 150)
        end
    end
    

    Notice that we reverse the X velocity, multiply by 1.03 to gradually increase speed (the escalation of difficulty helps spur the game along), and move the ball just outside the paddle. Whether it’s positive or negative, it’s just going to grow faster with time with every collision until somebody gets scored on. This “nudging” prevents the ball from getting stuck inside the paddle.

  • For wall collisions (top and bottom of screen), we flip the Y velocity. If we’re going up and we hit the top of the screen, we want it to bounce down. Then if we’re going down and it hits the bottom, we want it to bounce back up. From pong-7/main.lua:

    -- detect upper and lower screen boundary collision and reverse if collided
    if ball.y <= 0 then
        ball.y = 0
        ball.dy = -ball.dy
    end
    
    -- -4 to account for the ball's size
    if ball.y >= VIRTUAL_HEIGHT - 4 then
        ball.y = VIRTUAL_HEIGHT - 4
        ball.dy = -ball.dy
    end
    

    Notice how we also set the ball’s position to exactly at the boundary to prevent it from going past.

Scoring and Game States

  • The ball collides with the ceiling, the floor, and the paddles, but we still need scoring. When the ball goes past the left or right edge of the screen, the opponent scores. A game doesn’t really make sense if you can play to infinity but you can’t win.
  • We use a game state variable to track what phase of the game we’re in. A state machine is a way to model all the transitions between states. All of the transitions between whether we’re playing, whether we’re serving, whether we’re in a victory, whether it’s game over, all those are essentially just states. A game can be looked at as just this big state machine with all these complicated transitions. If you duck and release down, you stand. If you press B, you jump. The actual transitions are modeled and what they represent.
  • We’re doing it in a simplistic way for now, just a string variable, but we’ll build up a much better abstraction for state machines next lecture.
  • To detect scoring, we check if the ball passes an edge. If somebody scores against the other player, it’s probably fair that the player who had just gotten scored on gets to serve. The ball will move first towards the opponent rather than them. It’s an arbitrary decision, but it feels like good sportsmanship. From pong-10/main.lua:

    -- if we reach the left or right edge of the screen,
    -- go back to start and update the score
    if ball.x < 0 then
        servingPlayer = 1
        player2Score = player2Score + 1
    
        -- if we've reached a score of 10, the game is over; set the
        -- state to done so we can show the victory message
        if player2Score == 10 then
            winningPlayer = 2
            gameState = 'done'
        else
            gameState = 'serve'
            -- places the ball in the middle of the screen, no velocity
            ball:reset()
        end
    end
    
    if ball.x > VIRTUAL_WIDTH then
        servingPlayer = 2
        player1Score = player1Score + 1
    
        if player1Score == 10 then
            winningPlayer = 1
            gameState = 'done'
        else
            gameState = 'serve'
            ball:reset()
        end
    end
    

    Notice that when the ball passes an edge, we increment the opponent’s score, reset the ball, and go to the serve state. The player who got scored on becomes the serving player.

  • In the serve state, we set the ball’s initial velocity based on who’s serving. The ball moves toward the opponent of whoever is serving. From pong-10/main.lua:

    if gameState == 'serve' then
        -- before switching to play, initialize ball's velocity based
        -- on player who last scored
        ball.dy = math.random(-50, 50)
        if servingPlayer == 1 then
            ball.dx = math.random(140, 200)
        else
            ball.dx = -math.random(140, 200)
        end
    

    Notice that this influences what direction the ball starts off DX-wise.

  • Different states display different messages and allow different actions. The ball is locked behind the play state, but the paddle moving is not locked behind anything. You can move at any time. This is a choice that you can make. From pong-10/main.lua:

    if key == 'escape' then
        love.event.quit()
    -- if we press enter during either the start or serve phase, it should
    -- transition to the next appropriate state
    elseif key == 'enter' or key == 'return' then
        if gameState == 'start' then
            gameState = 'serve'
        elseif gameState == 'serve' then
            gameState = 'play'
        elseif gameState == 'done' then
            -- game is simply in a restart phase here, but will set the serving
            -- player to the opponent of whomever won for fairness!
            gameState = 'serve'
    
            ball:reset()
    
            -- reset scores to 0
            player1Score = 0
            player2Score = 0
    
            -- decide serving player as the opposite of who won
            if winningPlayer == 1 then
                servingPlayer = 2
            else
                servingPlayer = 1
            end
        end
    end
    

    Notice how pressing Enter transitions us through the states. In the ‘done’ state, we reset the scores and whoever won, the opposite person gets to serve for the first time as an arbitrary fun thing.

Sound Effects

  • The game works, but it’s dead quiet. It feels a little bit stifling, to be honest. Sound is a very important function, and thankfully LÖVE makes it super easy with love.audio.newSource. You load a file, it can be a music file or a sound file, and then you can trigger this sound to play, stop, loop whenever you want.
  • There are cool free tools online for creating sound effects. BFXR and ChipTone are browser-based tools that let you create custom retro sounds. ChipTone is on sfbgames.itch.io/chiptone. If you’re thinking about classic Mario games, you’ve got coins, jumps, the sound when you run into an enemy, when you grab a mushroom and grow larger. A lot of the categories of sounds you can generate are modeled off of that.
  • When thinking about what sounds you want, think about the semantic meaning. We want minimally a sound when the ball bounces off the paddles, something bright, not too high pitch, nice and almost metallic and plasticy. When it bounces off the top and bottom, something more dull. When it goes off the left or right side, something harsher that sounds like “Oh, I just suffered some loss.” There’s a lot of information that you can bake into sounds. The semantic meaning of a sound in the game can be a subtle but very effective thing that gives it a lot of life.
  • We load sounds in love.load using a table. This is a good opportunity to see how tables can work in a basic way in Lua as key value pairs. From pong-11/main.lua:

    -- set up our sound effects; later, we can just index this table and
    -- call each entry's `play` method
    sounds = {
        ['paddle_hit'] = love.audio.newSource('sounds/paddle_hit.wav', 'static'),
        ['score'] = love.audio.newSource('sounds/score.wav', 'static'),
        ['wall_hit'] = love.audio.newSource('sounds/wall_hit.wav', 'static')
    }
    

    Notice that we use ‘static’ for small sound files that load into memory immediately. For larger files like music, you’d use ‘stream’, which allows us to stream it over time and give our memory more leg room. The square brackets around the keys are not strictly necessary for alphanumeric keys, but if you wanted hyphens or other special syntax, you would not be able to use those as keys because they’d be interpreted by Lua as not valid variable names. Just for consistency and flexibility, it’s good to keep it consistent with strings.

  • To play a sound, we call the :play() method. Sounds are objects, so they take colon syntax, and play is a method on that sound object. From pong-11/main.lua:

    if ball:collides(player1) then
        ball.dx = -ball.dx * 1.03
        ball.x = player1.x + 5
    
        -- keep velocity going in the same direction, but randomize it
        if ball.dy < 0 then
            ball.dy = -math.random(10, 150)
        else
            ball.dy = math.random(10, 150)
        end
    
        sounds['paddle_hit']:play()
    end
    

    Notice how we play appropriate sounds at different game events. Just with that very small adjustment, adding a few sounds in specific places for the things that interact in our game world, we now can feel the game in a way that’s different. It feels very immersive. It feels a lot more polished and complete.

  • To handle window resizing with the push library, we implement love.resize. Push does things with the resizing of a virtual canvas that it uses that has to be synced up with the window, and that occurs during LÖVE’s own callback function. From pong-12/main.lua:

    --[[
        Called by LÖVE whenever we resize the screen; here, we just want to pass in the
        width and height to push so our virtual resolution can be resized as needed.
    ]]
    function love.resize(w, h)
        push.resize(w, h)
    end
    

    Notice that push needs to be told when the window resizes so it can adjust the virtual resolution scaling accordingly. Make sure resizable = true is set in your window options.

Summing Up

In this lesson, you learned the fundamentals of 2D game development by building Pong from scratch. Specifically, you learned…

  • About Lua as a programming language and LÖVE2D as a game framework.
  • How game loops work: input, update, and render running every frame, like a flipbook.
  • About 2D coordinate systems where Y goes down and X goes right.
  • How to use virtual resolution with the push library for a retro pixelated look.
  • About the difference between bilinear/trilinear filtering and nearest-neighbor (point) filtering.
  • How to draw shapes, text, and use custom fonts.
  • About delta time and why multiplying velocity by dt creates frame-rate independent movement.
  • How to use object-oriented programming with classes to organize game code and avoid having many disparate variables.
  • About AABB (Axis-Aligned Bounding Box) collision detection and the importance of nudging collided objects out of bounds.
  • How to implement game states and state transitions, which form a state machine.
  • How to add sound effects to make the game feel polished and immersive.

Next time we’re going to transition away from the world of just shapes and simple colors and take a look at our very first images. The next lecture is going to be Flappy Bird, where we’ll look at images, infinite scrolling, the idea that games are illusions in various ways, procedural generation, state machines in a more elegant way, and mouse input.

See you next time!