Lecture 0
- Welcome!
- Lua and LÖVE2D
- Game Loops
- 2D Coordinate Systems
- Virtual Resolution
- Drawing Shapes
- Delta Time and Velocity
- Object-Oriented Programming
- Collision Detection
- Scoring and Game States
- Sound Effects
- Summing Up
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.luafile 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
functionkeyword declares a function, and theendkeyword terminates a block. This applies to function blocks, if statements, and loops. Theendkeyword 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
localkeyword. 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' endNotice that
love.loadruns once at the very beginning when you run yourmain.lua. It’s good for setup and initialization.love.drawis 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' }) endNotice that
love.graphics.setDefaultFilter('nearest', 'nearest')applies nearest-neighbor filtering to everything, whether it’s textures or fonts. Thepush.setupScreenfunction 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. Frompong-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 endNotice that
love.keypressedfires once when a key is pressed, and we can check which key using thekeyparameter. If statements in Lua use thethenkeyword to specify the beginning of a block, and then these then blocks will end with anend, just like function blocks do. -
In the draw function, we wrap everything in
push.start()andpush.finish(). Frompong-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() endNotice 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.” Thenpush.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.clearwith 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.newFontandlove.graphics.setFont. Frompong-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' }) endNotice 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() endNotice that for the right paddle, we use
VIRTUAL_WIDTH - 15to 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.keypressedwon’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 uselove.keyboard.isDown, which will just continuously do a check whenever you call it. -
love.updatetakes adtparameter, 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 endNotice that we multiply
PADDLE_SPEEDbydt. 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.maxandmath.minto clamp values. These functions allow us to effectively clamp values. We can take the greater or the lesser of two numbers. Frompong-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) endNotice how
math.maxprevents 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. Andmath.minprevents it from going below (more thanVIRTUAL_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. Theand ... orpattern is Lua’s way of doing a ternary expression. If the first value is true, then theandwill result and return that value. Otherwise, it will fall to theorvalue. 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.luafile? - 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
classthat helps with OOP in Lua. Frompong-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 aPaddle.luafile. 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 saysomething = 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) endNotice the
selfkeyword, 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 sayPaddle(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. Theinitfunction 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. Frompong-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. Frompong-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 endNotice 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 endNotice 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 endNotice 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 endNotice 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) endNotice 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 endNotice 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.loadusing a table. This is a good opportunity to see how tables can work in a basic way in Lua as key value pairs. Frompong-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. Frompong-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() endNotice 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. Frompong-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) endNotice that push needs to be told when the window resizes so it can adjust the virtual resolution scaling accordingly. Make sure
resizable = trueis 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!