Lecture 2

Welcome!

  • This lecture covers Breakout, which has an interesting history. It ties into Pong very nicely because it was created by Atari back in the ’70s, allowing us to extend a couple of the ideas we saw in Pong. Pong and Breakout are very similar in a lot of ways, but Breakout has a whole lot of bricks instead of just one other paddle at the very top. It is also a single player game, which allows us to look at a whole bunch of different things like scoring and levels and some nice visuals.
  • Breakout represents probably the biggest jump in the course in terms of overall complexity and difficulty. But the things we’ll look at today, in particular Sprite sheets, will help simplify projects tremendously going forward. They’ll also segue us nicely into next week’s lecture and into animations, which we’ll be looking at a lot with Mario and Zelda and other problem sets.
  • Breakout as a game has a lot of states. Pong and Flappy Bird were quite simple. Flappy Bird only had four states in total, but Breakout has eight. All roads lead back to the start state. From there, we can go to high scores or to paddle select. From paddle select, we can begin serving the ball, which gives us a buffer before the core game loop. When we press Enter in the serve state, we transition to the play state. If we take damage, we go back to serve to let the player regain their composure. If we clear all the bricks, we go to a victory state, then back to serve for the next level. If we lose all our health, we go to game over, which either goes back to start or to an enter high score state if we achieved a high score.

Project Organization

  • Previously, we had all of our files in a hodgepodge, just all at the surface in one layer in our folder. That’s okay for something like Pong or even Flappy Bird where things are a little bit on the simple side. But as projects scale and you have larger and more complicated projects with many different types of graphics and fonts and sounds, it’s just very unwieldy. It’s better to try to consolidate things into semantically meaningful folders.
  • In Breakout, we have:
    • A source folder for our Lua files (instead of putting them at the parent level)
    • A sounds folder for audio files
    • A lib folder for libraries like push and class (some folks prefer to differentiate their own project’s code from other projects’ code)
    • A graphics or images folder for textures
    • A fonts folder for font files
  • Rather than just requiring everything at the top of main.lua, we can put all our dependencies in one central file. We’ve created a file called Dependencies.lua, which contains all our requires.
  • We also have a constants.lua file because we had a bunch of constants like our window width and whatnot. This keeps them organized in one place.

State Machines

  • We’ve created a StateMachine class to manage our game states. The idea is that all roads will lead back to the start state, but from there we can transition to various other states like high scores, paddle select, serve, play, victory, game over, and enter high score. From breakout0/src/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 {}
        self.current = self.empty
    end
    
    function StateMachine:change(stateName, enterParams)
        assert(self.states[stateName])
        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 when we call change, it will call the current state’s exit function if defined, then create a new instance of the target state and call its enter function. This allows us to pass data from one state to another using the enterParams table.

  • Every time we call gStateMachine:change, recall it will call its own exit function if defined, and then it will call its enter function if also defined. What that lets us do is bring data from one state into another state. Because every time we create a new state with that state machine, it’s going to create a brand new copy of that state. That’s often what we want, but often also not what we want, depending on whether the states should preserve some amount of continuity between them.

Sprite Sheets and Quads

  • One of the biggest things we’ll look at in this lecture, besides the collision detection, is this idea of quads. Previously, we just talked about images. Images are relatively easy to draw. You just do love.graphics.draw and then whatever texture you want. But in Breakout, we have a blue paddle, a green paddle, a purple paddle. If we want a bunch of different paddles, those would have to be their own images. If we were to make individual textures for each and every one of these images, we’d have close to 50 to 100 images. It would be very unwieldy. It would be horrible in code to be like, “Okay, what’s the name of this one? Blue? Blue with green stripes? Blue with yellow stripes?”
  • Folks typically in industry find it a little bit easier to work with these things called Sprite sheets or texture atlases, which allow you to condense all of your artwork into one file rather than have many separate images. With Sprite sheets, we can just load one texture atlas and then think in terms of rectangles. Think about how to splice that up into pieces that we can then reference. We can say, “Okay, draw our main texture at this particular rectangle’s offset, at this particular width and height.” That allows us to pretend as if we have maybe 100 images, but in truth, we’re just drawing one image. The graphics card is taking those UV coordinates, that quad data, and just drawing a tiny little piece of that for us.
  • A couple of the functions we need to look at are love.graphics.newQuad and passing a quad to love.graphics.draw. love.graphics.newQuad is just essentially declaring a rectangle. It’s not a lot different from love.graphics.rectangle in terms of how we think about it, except it’s a piece of data that’s going to apply to our texture when we want to draw it. If we pass in a texture, a quad, and an X and Y, at XY it will draw our texture, but it will only draw the rectangle of that texture that we tell it to.
  • We have a utility function called GenerateQuads that takes an atlas and gives it the width and height that we want to assume every image within that texture is. Then it will just do the job of automatically splicing the whole thing for us top to bottom in those X by Y increments. From breakout1/src/Util.lua:

    function GenerateQuads(atlas, tilewidth, tileheight)
        local sheetWidth = atlas:getWidth() / tilewidth
        local sheetHeight = atlas:getHeight() / tileheight
    
        local sheetCounter = 1
        local spritesheet = {}
    
        for y = 0, sheetHeight - 1 do
            for x = 0, sheetWidth - 1 do
                spritesheet[sheetCounter] =
                    love.graphics.newQuad(x * tilewidth, y * tileheight, tilewidth,
                    tileheight, atlas:getDimensions())
                sheetCounter = sheetCounter + 1
            end
        end
    
        return spritesheet
    end
    

    Notice how we iterate through rows (Y) and columns (X), creating a quad for each tile position. For every Y, we grab a tile’s width all the way until the end of the X axis, then loop back down to the next Y value. This is a common thing you’ll see when you have a Sprite sheet with uniform-sized elements.

  • The paddles are different sizes, so we have to do those a little more manually. We can’t just do a loop that iterates through the entirety of the texture because the paddles are 32, 64, 96, and 128 pixels wide. From breakout1/src/Util.lua:

    function GenerateQuadsPaddles(atlas)
        local x = 0
        local y = 64
    
        local counter = 1
        local quads = {}
    
        for i = 0, 3 do
            -- smallest
            quads[counter] = love.graphics.newQuad(x, y, 32, 16, atlas:getDimensions())
            counter = counter + 1
            -- medium
            quads[counter] = love.graphics.newQuad(x + 32, y, 64, 16, atlas:getDimensions())
            counter = counter + 1
            -- large
            quads[counter] = love.graphics.newQuad(x + 96, y, 96, 16, atlas:getDimensions())
            counter = counter + 1
            -- huge
            quads[counter] = love.graphics.newQuad(x, y + 16, 128, 16, atlas:getDimensions())
            counter = counter + 1
    
            x = 0
            y = y + 32
        end
    
        return quads
    end
    

    Notice that we manually specify the width for each paddle size (32, 64, 96, 128). We iterate four times for the four different paddle colors (skins), shifting down by 32 pixels each time to get to the next color row. Eventually, we have all the paddle rectangles in our quads table, and we can index into them by skin and size.

Paddle and Ball

  • The Paddle class is very similar to the paddles from Pong, except now we have a dx instead of a dy because we’re moving on the X axis instead of the Y axis. We also have skin and size variables that can influence our appearance. From breakout1/src/Paddle.lua:

    Paddle = Class{}
    
    function Paddle:init()
        self.x = VIRTUAL_WIDTH / 2 - 32
        self.y = VIRTUAL_HEIGHT - 32
        self.dx = 0
        self.width = 64
        self.height = 16
        self.skin = 1
        self.size = 2
    end
    
    function Paddle:update(dt)
        if love.keyboard.isDown('left') then
            self.dx = -PADDLE_SPEED
        elseif love.keyboard.isDown('right') then
            self.dx = PADDLE_SPEED
        else
            self.dx = 0
        end
    
        if self.dx < 0 then
            self.x = math.max(0, self.x + self.dx * dt)
        else
            self.x = math.min(VIRTUAL_WIDTH - self.width, self.x + self.dx * dt)
        end
    end
    
    function Paddle:render()
        love.graphics.draw(gTextures['main'], gFrames['paddles'][self.size + 4 * (self.skin - 1)],
            self.x, self.y)
    end
    

    Notice we’re using the same clamping logic we saw with Pong using math.max and math.min to make sure we don’t go off the left and right edges of the screen. In the render function, we index into the paddles quad table using self.size + 4 * (self.skin - 1). Size is which of the four sizes, and multiplying by four and adding the skin offset gets us the correctly colored paddle.

  • The Ball class uses the same AABB collision detection we saw with Pong. It can have a skin which is chosen at random just for visual variety. From breakout2/src/Ball.lua:

    Ball = Class{}
    
    function Ball:init(skin)
        self.width = 8
        self.height = 8
        self.dy = 0
        self.dx = 0
        self.skin = skin
    end
    
    function Ball:collides(target)
        if self.x > target.x + target.width or target.x > self.x + self.width then
            return false
        end
    
        if self.y > target.y + target.height or target.y > self.y + self.height then
            return false
        end
    
        return true
    end
    
    function Ball:update(dt)
        self.x = self.x + self.dx * dt
        self.y = self.y + self.dy * dt
    
        if self.x <= 0 then
            self.x = 0
            self.dx = -self.dx
            gSounds['wall-hit']:play()
        end
    
        if self.x >= VIRTUAL_WIDTH - 8 then
            self.x = VIRTUAL_WIDTH - 8
            self.dx = -self.dx
            gSounds['wall-hit']:play()
        end
    
        if self.y <= 0 then
            self.y = 0
            self.dy = -self.dy
            gSounds['wall-hit']:play()
        end
    end
    

    Notice that the ball bounces off the left, right, and top walls by inverting its velocity. The ball can go below the screen (bottom edge), which is how the player loses health.

The Level Maker

  • The LevelMaker is a class that encapsulates the idea of generating a level. Rather than having a method that we call on an instance, we create a static function because we don’t need a LevelMaker instance living anywhere. We can just call this function that exists in the LevelMaker class. From breakout3/src/LevelMaker.lua:

    LevelMaker = Class{}
    
    function LevelMaker.createMap(level)
        local bricks = {}
        local numRows = math.random(1, 5)
        local numCols = math.random(7, 13)
    
        for y = 1, numRows do
            for x = 1, numCols do
                b = Brick(
                    (x-1) * 32 + 8 + (13 - numCols) * 16,
                    y * 16
                )
                table.insert(bricks, b)
            end
        end
    
        return bricks
    end
    

    Notice the X coordinate calculation. We subtract X by 1 before multiplying by 32 because coordinates are zero-indexed even though Lua tables are one-indexed. We add 8 pixels of padding, and then we add an offset based on the number of columns vs. 13 (the max that fits) times 16 pixels. This ensures everything is centered. The Y coordinate is simpler: just y * 16 since the bricks are 16 pixels tall.

  • The Brick class is simple. It has a tier and color for scoring and appearance, an X and Y position, and an inPlay flag that determines whether it should be rendered and can be collided with. From breakout3/src/Brick.lua:

    Brick = Class{}
    
    function Brick:init(x, y)
        self.tier = 0
        self.color = 1
        self.x = x
        self.y = y
        self.width = 32
        self.height = 16
        self.inPlay = true
    end
    
    function Brick:hit()
        gSounds['brick-hit-2']:play()
        self.inPlay = false
    end
    
    function Brick:render()
        if self.inPlay then
            love.graphics.draw(gTextures['main'],
                gFrames['bricks'][1 + ((self.color - 1) * 4) + self.tier],
                self.x, self.y)
        end
    end
    

    Notice the render math: we multiply color by 4 (minus 1 for zero-indexing) to get our color offset, then add tier to draw the correct tier and color brick. Using inPlay as a flag rather than removing bricks from the table is useful if we want to restart the level with the same layout.

Collision

  • Unlike Pong where the ball always bounced in a predictable way, in Breakout we want to give the paddle some physical control and have an influence on the angle. If we think of the ball coming down and hitting the paddle, the closer to the edge it hits, the faster we think it should bounce and more sharply bounce. That’s the feeling one gets when playing Pong or Breakout.
  • We need to figure out the midpoint of the paddle and the offset of the ball. Then, depending on which side the ball hits and whether the paddle is moving, we adjust the ball’s horizontal velocity. If we’re moving left and the ball hits the left side of the paddle, we want the ball to bounce sharply to the left. The further from center, the sharper the angle. From breakout4/src/states/PlayState.lua:

    if self.ball:collides(self.paddle) then
        self.ball.y = self.paddle.y - 8
        self.ball.dy = -self.ball.dy
    
        -- if we hit the paddle on its left side while moving left...
        if self.ball.x < self.paddle.x + (self.paddle.width / 2) and self.paddle.dx < 0 then
            self.ball.dx = -50 + -(8 * (self.paddle.x + self.paddle.width / 2 - self.ball.x))
    
        -- else if we hit the paddle on its right side while moving right...
        elseif self.ball.x > self.paddle.x + (self.paddle.width / 2) and self.paddle.dx > 0 then
            self.ball.dx = 50 + (8 * math.abs(self.paddle.x + self.paddle.width / 2 - self.ball.x))
        end
    
        gSounds['paddle-hit']:play()
    end
    

    Notice how we calculate the offset from the center of the paddle. We multiply by 8 (a magic number found through balancing) and add or subtract from a starting bounce velocity of 50. This gives it the feeling of a physical paddle, like putting English on the ball.

  • Brick collision is a bit more complicated. Unlike Pong where the ball would always bounce off in a predictable direction, with Breakout the ball can hit bricks from any direction. If it bounces off the top, we want it to go back up. But if it hits from the left side and it’s going right, we want it to bounce back to the left. And so on throughout all four directions.
  • We check to see if the opposite side of our velocity is outside of the brick. If it is, we trigger a collision on that side. Otherwise, we’re within the X plus width of the brick and should check to see if the top or bottom edge is outside of the brick. From breakout5/src/states/PlayState.lua:

    for k, brick in pairs(self.bricks) do
        if brick.inPlay and self.ball:collides(brick) then
            self.score = self.score + 10
            brick:hit()
    
            -- left edge; only check if we're moving right
            if self.ball.x + 2 < brick.x and self.ball.dx > 0 then
                self.ball.dx = -self.ball.dx
                self.ball.x = brick.x - 8
    
            -- right edge; only check if we're moving left
            elseif self.ball.x + 6 > brick.x + brick.width and self.ball.dx < 0 then
                self.ball.dx = -self.ball.dx
                self.ball.x = brick.x + 32
    
            -- top edge if no X collisions
            elseif self.ball.y < brick.y then
                self.ball.dy = -self.ball.dy
                self.ball.y = brick.y - 8
    
            -- bottom edge if no X collisions or top collision
            else
                self.ball.dy = -self.ball.dy
                self.ball.y = brick.y + 16
            end
    
            -- slightly scale the y velocity to speed up the game
            self.ball.dy = self.ball.dy * 1.02
    
            break
        end
    end
    

    Notice we offset the checks by a couple of pixels (using +2 and +6) so that flush corner hits register as Y flips, not X flips. We also multiply dy by 1.02 on each hit to gradually speed up the game. The break ensures we only process one brick collision per frame to handle corner cases properly.

Hearts and Score

  • Now we need a way to determine what’s our loss condition. We have a few hearts at the top right of the screen. Hearts are like a very Zelda, very video game-y thing. We also have a score that we’ve added. Every brick is worth 10 points in this tier.
  • The serve state, for example, has a score, HP, and the bricks. That state needs to communicate with the play state. When we press Enter in the serve state, we pass all the important data to the play state. From breakout5/src/states/ServeState.lua:

    function ServeState:update(dt)
        self.paddle:update(dt)
        self.ball.x = self.paddle.x + (self.paddle.width / 2) - 4
        self.ball.y = self.paddle.y - 8
    
        if love.keyboard.wasPressed('enter') or love.keyboard.wasPressed('return') then
            gStateMachine:change('play', {
                paddle = self.paddle,
                bricks = self.bricks,
                health = self.health,
                score = self.score,
                ball = self.ball
            })
        end
    end
    

    Notice how the ball tracks the player’s paddle position before serving. When Enter is pressed, we pass in all important state info to the PlayState. These are all the variables we want to pass to say, “Hey, you now take charge of these variables. We are going to get deallocated. The state is going to be lost. But before we do that, here, take these things.”

  • We have a couple of helper functions defined in main.lua because they’re drawn in several different places. Rather than define them in every state and just have them repeat over and over again, it makes sense to have them defined here. From breakout5/main.lua:

    function renderHealth(health)
        local healthX = VIRTUAL_WIDTH - 100
    
        for i = 1, health do
            love.graphics.draw(gTextures['hearts'], gFrames['hearts'][1], healthX, 4)
            healthX = healthX + 11
        end
    
        for i = 1, 3 - health do
            love.graphics.draw(gTextures['hearts'], gFrames['hearts'][2], healthX, 4)
            healthX = healthX + 11
        end
    end
    

    Notice that we draw red hearts (frame 1) for each point of health we have, then gray/unfilled hearts (frame 2) for the remaining slots. If you have 3 HP, draw 3 red hearts. If you have 2 HP, draw 2 red hearts and 1 gray heart.

  • When the ball goes below the screen, we lose health. If health reaches zero, we transition to game over. Otherwise, we go back to the serve state with our data preserved. From breakout5/src/states/PlayState.lua:

    if self.ball.y >= VIRTUAL_HEIGHT then
        self.health = self.health - 1
        gSounds['hurt']:play()
    
        if self.health == 0 then
            gStateMachine:change('game-over', {
                score = self.score
            })
        else
            gStateMachine:change('serve', {
                paddle = self.paddle,
                bricks = self.bricks,
                health = self.health,
                score = self.score
            })
        end
    end
    

    Notice how we pass the appropriate data to each state. The game over state only needs the score for display. The serve state needs the paddle, bricks, health, and score to continue the game.

Tiers and Colors

  • Currently we’ve only been dealing with very boring brick layouts where we just have blue bricks and they’re just worth 10 points. But if people are familiar with Breakout, there are various colored bricks, and they all mean different things. They can be a higher tier that takes multiple hits to break. They can be worth more points accordingly.
  • Through relatively simple flag-based logic, we can create alternating colors, alternating spaces (skip patterns), and vary the visual appearance significantly. The level maker can decide: are we in an alternating row? If so, we need an on/off flag. So it’ll be on/off, on/off. Are we skipping bricks? Then brick, no brick, brick, no brick.
  • Lua doesn’t have a continue statement, but it does have a form of goto that we can simulate continue with. We use labels and a goto statement. If we’re skipping and should skip this brick, we don’t actually generate a brick. We just say goto continue where continue is a label defined with double colons ::continue::.
  • When a brick gets hit, instead of immediately setting inPlay to false, we can decrease its color first. Green goes to blue, red goes to green, etc. When it finally reaches the lowest color at tier zero, then it gets removed from play. From breakout7/src/Brick.lua:

    function Brick:hit()
        gSounds['brick-hit-2']:stop()
        gSounds['brick-hit-2']:play()
    
        if self.tier > 0 then
            if self.color == 1 then
                self.tier = self.tier - 1
                self.color = 5
            else
                self.color = self.color - 1
            end
        else
            if self.color == 1 then
                self.inPlay = false
            else
                self.color = self.color - 1
            end
        end
    
        if not self.inPlay then
            gSounds['brick-hit-1']:stop()
            gSounds['brick-hit-1']:play()
        end
    end
    

    Notice that if we’re in a higher tier and our color is 1 (blue), we go down a tier and reset to color 5 (the highest color). Otherwise, we just decrease the color. At tier 0, if the color is 1, the brick is removed from play. This gives us much more visual variety and challenge.

Particle Systems

  • Particle systems are really cool because they’re essentially just a way to add visual flair. They’re called a system because the particles inside this emitter or generator have behavior that you can configure. Right now, when we hit bricks, they essentially just disappear right away. There’s some element of simplicity to that that’s a little bit unsatisfactory. Particle systems are a way to add just a little, literally, sparkle to your game.

  • To create a particle system, we use love.graphics.newParticleSystem. Particles take a texture. In today’s example, we’re using a simple circular texture, and it’s going to then apply various alpha and colorization functions to those particles, emit them in a particular way, fade them out, and so forth. From breakout8/src/Brick.lua:

    paletteColors = {
        [1] = { ['r'] = 99, ['g'] = 155, ['b'] = 255 },   -- blue
        [2] = { ['r'] = 106, ['g'] = 190, ['b'] = 47 },   -- green
        [3] = { ['r'] = 217, ['g'] = 87, ['b'] = 99 },    -- red
        [4] = { ['r'] = 215, ['g'] = 123, ['b'] = 186 },  -- purple
        [5] = { ['r'] = 251, ['g'] = 242, ['b'] = 54 }    -- gold
    }
    
    -- in Brick:init
    self.psystem = love.graphics.newParticleSystem(gTextures['particle'], 64)
    self.psystem:setParticleLifetime(0.5, 1)
    self.psystem:setLinearAcceleration(-15, 0, 15, 80)
    self.psystem:setEmissionArea('normal', 10, 10)
    

    Notice we set a lifetime between 0.5 and 1 second for each particle. The linear acceleration creates a range: X can be between -15 and 15 (so particles spread left and right), but Y is only between 0 and 80 (positive), so particles fall down like there’s artificial gravity.

  • When we hit a brick, we set the particle colors to match the brick color and emit the particles. From breakout8/src/Brick.lua:

    self.psystem:setColors(
        paletteColors[self.color].r / 255,
        paletteColors[self.color].g / 255,
        paletteColors[self.color].b / 255,
        55 * (self.tier + 1) / 255,
        paletteColors[self.color].r / 255,
        paletteColors[self.color].g / 255,
        paletteColors[self.color].b / 255,
        0
    )
    self.psystem:emit(64)
    

    Notice setColors takes two RGBA color sets: the start color and end color. We use the same RGB but fade from some alpha (higher for higher tiers) to zero alpha (transparent). Then emit(64) shoots out 64 particles. Each brick has its own particle system, and a particle system will be triggered to emit, decay over its lifetime, and then be ready to reuse.

Progression and Levels

  • Whenever we finish a level by clearing all bricks, we want to display “Level X Complete” and then progress to the next level. With every victory, you can have a new grid of bricks that you make progressively more difficult. This is really important because now we have something to aspire to. “I made it past level 5” or “I got to level 10.”
  • Victory detection is simple: if all bricks are no longer in play, we’ve won. From breakout9/src/states/PlayState.lua:

    function PlayState:checkVictory()
        for k, brick in pairs(self.bricks) do
            if brick.inPlay then
                return false
            end
        end
    
        return true
    end
    

    Notice we iterate through all bricks. If any are still in play, return false. If we’ve gone through all of them and none are in play, return true.

  • When transitioning to the next level, we increment the level number and create a new map with the higher level value, which the level maker can use to generate harder layouts. From breakout9/src/states/VictoryState.lua:

    if love.keyboard.wasPressed('enter') or love.keyboard.wasPressed('return') then
        gStateMachine:change('serve', {
            level = self.level + 1,
            bricks = LevelMaker.createMap(self.level + 1),
            paddle = self.paddle,
            health = self.health,
            score = self.score
        })
    end
    

    Notice we pass self.level + 1 to both the serve state and to LevelMaker.createMap. The level maker can then generate more sophisticated maps with higher tiers and colors as the level increases.

High Scores and Persistence

  • If you think of any game you’ve ever played, a long time ago, back in the day, you didn’t really have persistent data. Legend of Zelda was one of the first that did, with a battery that kept memory active. Nowadays, with modern computers, we expect save files. It’s a little bit deflating to think you’re going to put all this time and effort into playing this game and just completely lose your high score.
  • Love2D provides filesystem functions for data persistence. love.filesystem.setIdentity says where we should assume we have free reign for a folder to put save data in. This is operating system dependent: on Mac it’s in Library/Application Support, on Windows it’s in AppData, on Linux it’s somewhere else. From breakout10/main.lua:

    function loadHighScores()
        love.filesystem.setIdentity('breakout')
    
        if not love.filesystem.getInfo('breakout.lst') then
            local scores = ''
            for i = 10, 1, -1 do
                scores = scores .. 'CTO\n'
                scores = scores .. tostring(i * 1000) .. '\n'
            end
            love.filesystem.write('breakout.lst', scores)
        end
    
        local name = true
        local counter = 1
        local scores = {}
    
        for i = 1, 10 do
            scores[i] = { name = nil, score = nil }
        end
    
        for line in love.filesystem.lines('breakout.lst') do
            if name then
                scores[counter].name = string.sub(line, 1, 3)
            else
                scores[counter].score = tonumber(line)
                counter = counter + 1
            end
            name = not name
        end
    
        return scores
    end
    

    Notice we think of every high score as initials then score, blocks of two lines. If no file exists, we generate default scores. Then we iterate over the lines, alternating between reading a name and reading a score.

  • For entering initials, we use ASCII values. 65 represents ‘A’, 90 represents ‘Z’. We store three numbers that we increment/decrement with up/down arrows, then convert them to characters using string.char. From breakout11/src/states/EnterHighScoreState.lua:

    local chars = { [1] = 65, [2] = 65, [3] = 65 }
    local highlightedChar = 1
    
    if love.keyboard.wasPressed('up') then
        chars[highlightedChar] = chars[highlightedChar] + 1
        if chars[highlightedChar] > 90 then
            chars[highlightedChar] = 65
        end
    elseif love.keyboard.wasPressed('down') then
        chars[highlightedChar] = chars[highlightedChar] - 1
        if chars[highlightedChar] < 65 then
            chars[highlightedChar] = 90
        end
    end
    
    local name = string.char(chars[1]) .. string.char(chars[2]) .. string.char(chars[3])
    

    Notice we clamp the values between 65 (A) and 90 (Z), wrapping around if we go past either end. This is a very common presentation for arcade games.

  • When saving a new high score, we shift all the scores below down by one and insert the new score at the appropriate position, then write the entire table back to disk.

Summing Up

Breakout is probably the biggest hurdle stepwise in difficulty throughout the term. There will be other things we look at in Mario and Zelda that have their own difficulties, but Breakout is probably the biggest jump up to feeling like we made a full game rather than Pong which is simple and two-player, or Flappy Bird which is single-player but also very simple. Breakout has got a lot of pieces to make it a good implementation. In this lecture, we learned about…

  • Sprite sheets and quads for efficiently managing many graphics from a single texture.
  • Project organization with separate folders for source, graphics, sounds, and libraries.
  • State machines for managing complex game flow across multiple states with data passing.
  • Paddle and ball classes that use quads for rendering.
  • Brick collision that handles hits from all four directions.
  • Procedural level generation with the LevelMaker class.
  • Health systems with visual heart displays.
  • Tier and color systems for multi-hit bricks.
  • Particle systems for visual polish and feedback.
  • Level progression and victory states.
  • Data persistence using Love2D’s filesystem API for high scores.

Next week we’ll look at anonymous functions, tweening and timers for smooth animations, and discrete problem solving in the context of a puzzle game. We’ll be looking at Match 3, also known as Candy Crush or Bejeweled.

See you next time!