Lecture 7

Welcome!

  • Hello, world. This is CS50 2D, Lecture 7, the final lecture of the course. Today we’ll be looking at a game that’s a big part of my childhood and sort of a culmination of some of the ideas and some new ideas with which we’ve been talking about in this course. And that is Pokémon.
  • Here’s a screenshot of what Pokémon looked like: The very first Pokémon game that I ever played, which was Pokémon Red. I remember the very first time being able to play this on a handheld device back in the day on a Game Boy and thinking just how incredible it was that you could go around while you’re out on the playground in school and play as this character in this world, going around and catching these creatures called Pokémon. There were 151 original Pokémon, and that was a big deal. Again, a huge franchise, just like Mario and Zelda. It spawned countless different products, including an anime, trading cards, all sorts of things.
  • Huge part of my childhood, huge part of I think many folks’ childhood. They evolved over the years, much like the Pokémon themselves do. This is Gold and Silver and Crystal, which was the one I spent probably the most time playing after Red. Then they got to the Game Boy Advance with titles like FireRed and LeafGreen. Then there were games for the DS where they incorporated the dual screen. And then here’s Pokémon X and Y, which is their first foray into 3D. Now they’ve spawned a bunch of new games on the Switch. But the same core formula is the same: You have a Pokémon, you still have the same dialog, the same sort of view of the front and back. And though they now have more dynamic camera movements, the underlying turn-based battle structure has largely stayed the same.
  • Some of the topics we’ll be looking at today:
    • Turn-based systems - Unlike the real-time games we’ve looked at, Pokémon is turn-based where you and your opponent take alternating actions.
    • State stacks - We’ve been looking at state machines thus far. But today we’ll look at a way to model states that layer on top of each other. Think about pause menus where you might want to render something that is a different state, but on top of a state that is paused in the background.
    • GUI elements - Graphical user interfaces. Games like Pokémon are very menu and UI driven. We need to look at how to compose those: Panels, text boxes, selections, menus, and progress bars.
    • RPG mechanics - How the battle system, XP, and things like that work to allow us to build up our team of Pokémon, fight, get stronger, and so on.
  • Playing the game, we’ve got a little title screen here with Pokémon sort of transitioning back and forth. Press Enter and that takes us to the actual field state. Notice the transition that occurred there. Here we have the first example of an actual dialog window. This is actually a state, but notice we also have this other state underneath it. Press Enter to dismiss the dialog. You can now walk around. There’s flat sort of low grass, but also this darker, deeper grass. If you spend a couple moments walking through there. There we go. We’ve transitioned now into a new state. You’ve got a Pokémon that you’ve been given for free. Press Enter to get a set of dialog prompts. Now you’ve got two options. Use the arrow keys to choose: Do you want to fight or do you want to run?
  • Fighting does indeed inflict some damage. You got some effects there, some graphical effects. Your stats are higher than the enemy, so you did more damage. The turn-based system works: Your turn, then their turn. You defeated the enemy, got some victory music. Press Enter. You might notice a little bit of XP there. Part of the game is you level up over time, you fight more creatures. If you wanted to run instead of fighting, you flee and come back. You have that choice not to fight. If you get knocked out from fighting a Pokémon, you might go to a different state: You fainted. Press Enter, you get kindly revived with a notice that says you have been fully restored. That’s overall the gist of Pokémon.

GUI Elements

  • We saw a lot of pieces in that demo. There are a lot of things happening invisibly that structurally might not look all that different from what we’ve done. But we had dialog boxes for the first time: These boxes that are actually rendering a rectangle with some text. We had a menu there in the battle state which allowed us to choose options which then affected the game in particular ways.
  • This is just the beginning of what GUI means: Graphical user interfaces. Pokémon is a great test bed for which we can start to implement some basic GUI elements. Even the progress bars that were shown there (the health and the XP) were also GUI elements with their own values and their own ways of rendering and behaving.
  • GUIs are very frequently built up of many smaller parts that compose to give us more complicated UIs. We had panels that were relatively similarly styled for the dialog box and for the battle message states and the menu. Within them we render text. Then you can have things like cursors, progress bars, and you can do all kinds of really fancy things with GUIs.
  • When I was first learning Lua and LÖVE 2D, one of the things I was looking at was this resource, How to Make an RPG, which I learned some of the things that go into this lecture from. I encourage people to give that a look if they’re curious.

Panels

  • The very first GUI element we’ll look at is the panel. A panel is some kind of flat surface upon which we can draw something and contain something. You can use panels to affect certain things like modals on the screen. They’re essentially just a contained canvas upon which we can draw things.
  • There’s a little bit of styling going on for visual flair. To not just make it a plain rectangle, you can add some border. We’re just going to essentially use two rectangles to accomplish this.
  • Consider from gui-0/Panel.lua:

    Panel = Class{}
    
    function Panel:init(x, y, width, height)
        self.x = x
        self.y = y
        self.width = width
        self.height = height
        self.visible = true
    end
    
    function Panel:render()
        if self.visible then
            -- draw white border rectangle
            love.graphics.setColor(1, 1, 1, 1)
            love.graphics.rectangle('fill', self.x, self.y, self.width, self.height, 3)
    
            -- draw dark gray interior rectangle
            love.graphics.setColor(56/255, 56/255, 56/255, 1)
            love.graphics.rectangle('fill', self.x + 2, self.y + 2,
                self.width - 4, self.height - 4, 3)
    
            love.graphics.setColor(1, 1, 1, 1)
        end
    end
    
    function Panel:toggle()
        self.visible = not self.visible
    end
    

    Notice that we’re drawing a white rectangle at its full size with a rounding value of 3, which is how many pixels of beveling or rounding occur at the edges. Then we set the color to a dark gray and draw the same rectangle but shrunk in by two pixels on either side. This makes it look like we’ve got a two pixel border around our rectangle. The toggle function lets us show or hide the panel.

  • To use the panel in gui-0/main.lua:

    panel = Panel(16, 16, VIRTUAL_WIDTH - 32, VIRTUAL_HEIGHT - 32)
    

    Notice that we create a panel at position (16, 16) with dimensions that account for 16-pixel offsets from the corners.

Text Boxes

  • A text box is essentially just a panel with text inside it. But there’s more complicated code involved if you want your text box to contain no more text than fits inside it. The text box needs to be intelligent enough to know that should the text overflow the bounds of the box, we don’t want to keep rendering it past the border; we want it to store the section of text it’s currently rendering and change what it renders on the next click of Enter.
  • Consider from gui-1/Textbox.lua:

    Textbox = Class{}
    
    function Textbox:init(x, y, width, height, text, font)
        self.panel = Panel(x, y, width, height)
        self.x = x
        self.y = y
        self.width = width
        self.height = height
        self.text = text
        self.font = font or gFonts['small']
    
        self.padding = 8
        self.lineHeight = self.font:getHeight()
        self.linePadding = 2
    
        -- total lines that can fit in the textbox
        self.maxLines = math.floor((self.height - 2 * self.padding) /
            (self.lineHeight + self.linePadding))
    
        -- break the text into chunks that fit within the textbox width
        _, self.textChunks = self.font:getWrap(self.text, self.width - 2 * self.padding)
    
        self.chunkCounter = 1
        self.endOfText = false
        self.closed = false
        self:next()
    end
    

    Notice that we’re including a Panel because the text box contains a panel within its constructor. We’re building on top of the GUI element we created previously. We have a global padding for the edges. LÖVE 2D gives us the ability to get the line height of any font with font:getHeight(). If the line height is 12 or 16 pixels, and we know the height of the box, we can calculate how many lines of text fit. The font:getWrap() function is really cool: You give it a large block of text and a width, and it figures out where to break each line so text doesn’t exceed the panel width. It returns a table of text chunks.

  • The text pagination logic from gui-1/Textbox.lua:

    function Textbox:nextChunks()
        local chunks = {}
        local startIdx = self.chunkCounter
        local endIdx = math.min(self.chunkCounter + self.maxLines - 1, #self.textChunks)
    
        for i = startIdx, endIdx do
            table.insert(chunks, self.textChunks[i])
        end
    
        if endIdx == #self.textChunks then
            self.endOfText = true
        else
            self.chunkCounter = endIdx + 1
        end
    
        return chunks
    end
    

    Notice that we iterate over the text chunks, grabbing as many as will fit based on maxLines. We preserve an offset so that on further iterations, we can keep rendering until we reach the end of the text. When we reach the end, we set endOfText to true.

Selections and Menus

  • In the battle state, we had what looked similar to a text box but was actually a menu with a cursor. You could move options around, and when you pressed Enter on them, they did a different action. We’ll call the core of what’s on top of a panel a selection. A selection has varying options which we can click on or select, and then on select, there’s some anonymous callback function that performs some operation.
  • Consider from gui-2/Selection.lua:

    Selection = Class{}
    
    function Selection:init(def)
        self.items = def.items
        self.x = def.x
        self.y = def.y
        self.height = def.height
        self.width = def.width
        self.font = def.font or gFonts['small']
        self.gapHeight = self.height / #self.items
        self.currentSelection = 1
    end
    
    function Selection:update(dt)
        if love.keyboard.wasPressed('up') then
            if self.currentSelection == 1 then
                self.currentSelection = #self.items
            else
                self.currentSelection = self.currentSelection - 1
            end
            gSounds['blip']:stop()
            gSounds['blip']:play()
        elseif love.keyboard.wasPressed('down') then
            if self.currentSelection == #self.items then
                self.currentSelection = 1
            else
                self.currentSelection = self.currentSelection + 1
            end
            gSounds['blip']:stop()
            gSounds['blip']:play()
        elseif love.keyboard.wasPressed('return') or love.keyboard.wasPressed('enter') then
            self.items[self.currentSelection].onSelect()
            gSounds['blip']:stop()
            gSounds['blip']:play()
        end
    end
    

    Notice that we have gapHeight which uniformly distributes the selection’s items within its container. currentSelection tracks which item has the cursor. When pressing up or down, we shift the cursor with wrap-around (going from top to bottom or bottom to top). When pressing Return, we call self.items[self.currentSelection].onSelect(). Whatever that function does, it doesn’t matter to Selection itself.

  • Using the selection in gui-2/main.lua:

    selection = Selection {
        x = 32, y = 32,
        width = VIRTUAL_WIDTH - 64,
        height = VIRTUAL_HEIGHT - 64,
        items = {
            {
                text = 'Red',
                onSelect = function()
                    bg = {1, 0, 0}
                end
            },
            {
                text = 'Green',
                onSelect = function()
                    bg = {0, 1, 0}
                end
            },
            {
                text = 'Blue',
                onSelect = function()
                    bg = {0, 0, 1}
                end
            },
            {
                text = 'Quit',
                onSelect = function()
                    love.event.quit()
                end
            }
        }
    }
    

    Notice that each item has text for what to render and an onSelect callback function. Selecting Red, Green, or Blue changes the background color; Quit exits the game. In Pokémon, when we do the menu, fighting triggers a function that causes one Pokémon to attack another, and Run triggers a transition back to the field state.

  • A menu is essentially just a selection on top of a panel. Consider from pokemon/src/gui/Menu.lua:

    Menu = Class{}
    
    function Menu:init(def)
        self.panel = Panel(def.x, def.y, def.width, def.height)
    
        self.selection = Selection {
            items = def.items,
            x = def.x,
            y = def.y,
            width = def.width,
            height = def.height
        }
    end
    
    function Menu:update(dt)
        self.selection:update(dt)
    end
    
    function Menu:render()
        self.panel:render()
        self.selection:render()
    end
    

    Notice that Menu is just a composition: It contains both a Panel and a Selection, rendering the panel first as the background, then the selection on top.

Progress Bars

  • Progress bars are omnipresent: For loading indicators, HP, stats that go down that you want to pay attention to. We’ll be using them for HP and XP. XP is experience points, a value that represents how close you are to gaining a level.
  • Consider from gui-3/ProgressBar.lua:

    ProgressBar = Class{}
    
    function ProgressBar:init(def)
        self.x = def.x
        self.y = def.y
        self.width = def.width
        self.height = def.height
        self.color = def.color
        self.value = def.value
        self.max = def.max
    end
    
    function ProgressBar:render()
        -- multiplier on width based on progress
        local renderWidth = (self.value / self.max) * self.width
    
        -- draw main bar with calculated width based on value / max
        love.graphics.setColor(self.color.r, self.color.g, self.color.b, 1)
    
        if self.value > 0 then
            love.graphics.rectangle('fill', self.x, self.y, renderWidth, self.height, 3)
        end
    
        -- draw outline around actual bar
        love.graphics.setColor(0, 0, 0, 1)
        love.graphics.rectangle('line', self.x, self.y, self.width, self.height, 3)
        love.graphics.setColor(1, 1, 1, 1)
    end
    

    Notice that renderWidth is (value / max) * width, a multiplier between 0 and 1 that scales the bar’s fill. If dealing with Pokémon that have 30 HP, you’d set max to 30. For XP, if you need 150 XP to level up, max would be 150. The color is customizable: Red for HP, blue for XP, green for magic points.

  • Tweening the progress bar in gui-3/main.lua:

    animating = false
    
    if not animating then
        if key == 'right' and pb.value < 1 then
            Timer.tween(1, {
                [pb] = { value = 1 }
            }):finish(function()
                animating = false
            end)
            animating = true
        elseif key == 'left' and pb.value > 0 then
            Timer.tween(1, {
                [pb] = { value = 0 }
            }):finish(function()
                animating = false
            end)
            animating = true
        end
    end
    

    Notice that we use Timer.tween to smoothly animate the progress bar’s value from 0 to max or vice versa over one second. Progress bars are more fun when they animate versus when they just snap to values.

State Stacks

  • So far, we’ve talked about state machines, where you have a transition from one state to another based on behavior. But we can only be in one state at a time in that model. In a state machine, you’re not walking and idle at the same time. They’re mutually exclusive.
  • But games are unique in that you often put certain states on hold. Think about pressing Escape to pause: You often see a menu, but you can also see what you were doing in the game in the background. That’s not possible if we envision a model where you have a state machine transitioning from one state to another, with only one reference to one state at a time.
  • We accomplish that via a state stack instead. If we take a state and stack a pause state on top of a play state, now we’ve got two states. If we only update one state (the pause state), recall that anything only moves if we’re actually updating with delta time. If we’re not updating the play state, we can hold it in memory and nothing’s going to change. Then we can focus only on the pause state, which might have a menu of “Resume Game” or “Options” and “Quit” that we can move between, and still see the play state in the background.
  • With this one tool, we can do much more sophisticated rendering of UI and game states that feels more robust and true to what most games typically do.
  • Consider from stack/StateStack.lua:

    StateStack = Class{}
    
    function StateStack:init()
        self.states = {}
    end
    
    function StateStack:update(dt)
        if #self.states == 0 then
            return
        end
        -- only update the topmost state
        self.states[#self.states]:update(dt)
    end
    
    function StateStack:render()
        -- render ALL states from bottom to top
        for i, state in ipairs(self.states) do
            state:render()
        end
    end
    
    function StateStack:push(state)
        table.insert(self.states, state)
        state:enter()
    end
    
    function StateStack:pop()
        if #self.states == 0 then
            return
        end
        self.states[#self.states]:exit()
        table.remove(self.states)
    end
    

    Notice the key difference from a state machine: We now have a states table with multiple states. update only updates whatever is at the end of states, the topmost state. But render renders the entirety of our stack in order from oldest to newest. When you insert into a Lua table, it goes to the end; when you remove, it removes from the end. First in, first out (LIFO), which is how stacks behave.

  • To illustrate, the stack demo creates 100 popup states that layer on top of each other, reminiscent of old Windows virus popups. They all render, but only the topmost one updates and receives input:

    gStateStack = StateStack()
    local popupCap = 100
    local popupCount = 0
    
    local function spawnPopup()
        if popupCount < popupCap then
            gStateStack:push(NewPopupState())
            popupCount = popupCount + 1
        end
    end
    
    Timer.every(0.05, spawnPopup)
    

    Notice that every 0.05 seconds, we push a new popup state onto the stack. You can have as much memory as your computer holds worth of states. In practice, you probably won’t have more than a few: A play state, a menu, and that menu might pop up additional menus, maybe a depth of 10 to 20 at max.

  • When a popup’s text box is closed, it pops itself off the stack. Consider from stack/NewPopupState.lua:

    NewPopupState = Class{__includes = BaseState}
    
    function NewPopupState:init()
        local w = math.random(120, 220)
        local h = math.random(40, 100)
        local x = math.random(0, VIRTUAL_WIDTH - w)
        local y = math.random(0, VIRTUAL_HEIGHT - h)
        local text = random_lorem()
        self.textbox = Textbox(x, y, w, h, text, gFonts['small'])
    end
    
    function NewPopupState:update(dt)
        self.textbox:update(dt)
    
        if self.textbox:isClosed() then
            gStateStack:pop()
        end
    end
    
    function NewPopupState:render()
        self.textbox:render()
    end
    

    Notice that when textbox:isClosed() returns true, we pop the topmost state, which is this NewPopupState itself. This is how states can manage their own lifecycle.

The Field State

  • The field state is where your character walks around in the overworld, moving through grass and potentially triggering random encounters. JRPGs tend to have grid-based movement as opposed to continuous movement. If I press up once, the character continues to move in discrete motion, locked to a grid.
  • This probably has to do with early hardware limitations, but it became a traditional facet of JRPGs. We accomplish this by tweening position when you press a direction, not stopping or taking input until you’ve arrived at that tile’s offset. Consider from pokemon/src/states/entity/EntityWalkState.lua:

    function EntityWalkState:attemptMove()
        self.entity:changeAnimation('walk-' .. tostring(self.entity.direction))
    
        local toX, toY = self.entity.mapX, self.entity.mapY
    
        if self.entity.direction == 'left' then
            toX = toX - 1
        elseif self.entity.direction == 'right' then
            toX = toX + 1
        elseif self.entity.direction == 'up' then
            toY = toY - 1
        else
            toY = toY + 1
        end
    
        if toX < 1 or toX > 24 or toY < 1 or toY > 13 then
            self.entity:changeState('idle')
            self.entity:changeAnimation('idle-' .. tostring(self.entity.direction))
            return
        end
    
        self.entity.mapY = toY
        self.entity.mapX = toX
    
        Timer.tween(0.5, {
            [self.entity] = {x = (toX - 1) * TILE_SIZE, y = (toY - 1) * TILE_SIZE - self.entity.height / 2}
        }):finish(function()
            if love.keyboard.isDown('left') then
                self.entity.direction = 'left'
                self.entity:changeState('walk')
            elseif love.keyboard.isDown('right') then
                self.entity.direction = 'right'
                self.entity:changeState('walk')
            elseif love.keyboard.isDown('up') then
                self.entity.direction = 'up'
                self.entity:changeState('walk')
            elseif love.keyboard.isDown('down') then
                self.entity.direction = 'down'
                self.entity:changeState('walk')
            else
                self.entity:changeState('idle')
            end
        end)
    end
    

    Notice that we tween the entity’s pixel position over 0.5 seconds. First, we check for boundary conditions to prevent walking off the map. The map position is updated immediately, then the visual tween smoothly moves the sprite. When the tween finishes, if we’re still pressing a direction key, we set the direction and continue walking; otherwise, we change to idle.

  • In the Level class, we render two tile maps: A base layer and a grass layer. The tall grass tile doesn’t have an underlying color (it’s transparent), so we render regular grass underneath and tall grass on top. Consider from pokemon/src/world/Level.lua:

    function Level:init()
        self.tileWidth = 50
        self.tileHeight = 50
    
        self.baseLayer = TileMap(self.tileWidth, self.tileHeight)
        self.grassLayer = TileMap(self.tileWidth, self.tileHeight)
    
        self:createMaps()
    end
    
    function Level:createMaps()
        -- fill the base tiles table with random grass IDs
        for y = 1, self.tileHeight do
            table.insert(self.baseLayer.tiles, {})
    
            for x = 1, self.tileWidth do
                local id = TILE_IDS['grass'][math.random(#TILE_IDS['grass'])]
    
                table.insert(self.baseLayer.tiles[y], Tile(x, y, id))
            end
        end
    
        -- place tall grass in the tall grass layer
        for y = 1, self.tileHeight do
            table.insert(self.grassLayer.tiles, {})
    
            for x = 1, self.tileWidth do
                local id = y > 10 and TILE_IDS['tall-grass'] or TILE_IDS['empty']
    
                table.insert(self.grassLayer.tiles[y], Tile(x, y, id))
            end
        end
    end
    

    Notice that you can render as many tile maps as you want to achieve varying effects. You could achieve depth this way too: Have a layer that renders over the player, like an archway the player walks underneath.

Random Encounters

  • When the player walks through tall grass, there’s a random chance to trigger an encounter. This is a Pokémon trope: “Beware of the tall grass, because that’s where you’ll find wild Pokémon that will try to attack you.”
  • Consider from pokemon/src/states/entity/PlayerWalkState.lua:

    function PlayerWalkState:checkForEncounter()
        local x, y = self.entity.mapX, self.entity.mapY
    
        -- chance to go to battle if we're walking into a grass tile, else move as normal
        if self.level.grassLayer.tiles[y][x].id == TILE_IDS['tall-grass'] and math.random(10) == 1 then
            self.entity:changeState('idle')
    
            -- trigger music changes
            gSounds['field-music']:pause()
            gSounds['battle-music']:play()
    
            gStateStack:push(
                FadeInState({
                    r = 1, g = 1, b = 1,
                }, 1,
    
                function()
                    gStateStack:push(BattleState(self.entity))
                    gStateStack:push(FadeOutState({
                        r = 1, g = 1, b = 1,
                    }, 1,
    
                    function()
                    end))
                end)
            )
    
            self.encounterFound = true
        else
            self.encounterFound = false
        end
    end
    

    Notice that we check if the grass tile at our position is tall grass and if we get a 1 in 10 chance (10% per step). If so, we set the entity to idle, pause the field music and start the battle music, then push a fade in state to white. Once the fade finishes, we push the battle state and a fade out state. This is how we transition from the field to battle while preserving the field state underneath.

The Battle State

  • The battle state is the other main part of the game, the turn-based combat system. We have an opponent with a party of Pokémon. In the actual game, Pokémon trainers can have up to six Pokémon, but for simplicity, we model an opponent with one Pokémon.
  • Consider from pokemon/src/states/game/BattleState.lua:

    function BattleState:init(player)
        self.player = player
        self.bottomPanel = Panel(0, VIRTUAL_HEIGHT - 64, VIRTUAL_WIDTH, 64)
    
        self.opponent = Opponent {
            party = Party {
                pokemon = {
                    Pokemon(Pokemon.getRandomDef(), math.random(2, 6))
                }
            }
        }
    
        -- health bars for pokemon
        self.playerHealthBar = ProgressBar {
            x = VIRTUAL_WIDTH - 160,
            y = VIRTUAL_HEIGHT - 80,
            width = 152,
            height = 6,
            color = {r = 189/255, g = 32/255, b = 32/255},
            value = self.player.party.pokemon[1].currentHP,
            max = self.player.party.pokemon[1].HP
        }
    
        self.opponentHealthBar = ProgressBar {
            x = 8,
            y = 8,
            width = 152,
            height = 6,
            color = {r = 189/255, g = 32/255, b = 32/255},
            value = self.opponent.party.pokemon[1].currentHP,
            max = self.opponent.party.pokemon[1].HP
        }
    end
    

    Notice that we create progress bars for HP. The values aren’t hard coded anymore. They’re based on whatever the current HP is of the Pokémon. value is currentHP which can decrease; max is the full HP which stays static.

  • The battle state triggers dialog messages and eventually pushes the battle menu state:

    gStateStack:push(BattleMessageState('A wild ' .. tostring(self.opponent.party.pokemon[1].name ..
        ' appeared!'),
    
    function()
        gStateStack:push(BattleMessageState('Go, ' .. tostring(self.player.party.pokemon[1].name .. '!'),
    
        function()
            gStateStack:push(BattleMenuState(self))
        end))
    end))
    

    Notice the nested callbacks. This is very common in callback-based GUI programming. After “A wild X appeared!” is dismissed, we show “Go, X!”, and after that’s dismissed, we push the battle menu.

Take Turn State

  • The take turn state is where the actual combat happens. The first thing we do is figure out which Pokémon should be the attacker and which should be the defender. This is determined by a speed stat: Whoever is faster goes first.
  • Consider from pokemon/src/states/game/TakeTurnState.lua:

    function TakeTurnState:init(battleState)
        self.battleState = battleState
        self.playerPokemon = self.battleState.player.party.pokemon[1]
        self.opponentPokemon = self.battleState.opponent.party.pokemon[1]
    
        self.playerSprite = self.battleState.playerSprite
        self.opponentSprite = self.battleState.opponentSprite
    
        -- figure out which pokemon is faster, as they get to attack first
        if self.playerPokemon.speed > self.opponentPokemon.speed then
            self.firstPokemon = self.playerPokemon
            self.secondPokemon = self.opponentPokemon
            self.firstSprite = self.playerSprite
            self.secondSprite = self.opponentSprite
            self.firstBar = self.battleState.playerHealthBar
            self.secondBar = self.battleState.opponentHealthBar
        else
            self.firstPokemon = self.opponentPokemon
            self.secondPokemon = self.playerPokemon
            self.firstSprite = self.opponentSprite
            self.secondSprite = self.playerSprite
            self.firstBar = self.battleState.opponentHealthBar
            self.secondBar = self.battleState.playerHealthBar
        end
    end
    

    Notice that we compare speeds and set pointers to first and second Pokémon. This simplifies how we call code later: We just call attack with first and second.

  • The attack function handles the combat animation and damage calculation:

    function TakeTurnState:attack(attacker, defender, attackerSprite, defenderSprite, attackerkBar, defenderBar, onEnd)
    
        -- first, push a message saying who's attacking, then flash the attacker
        -- this message is not allowed to take input at first, so it stays on the stack
        -- during the animation
        gStateStack:push(BattleMessageState(attacker.name .. ' attacks ' .. defender.name .. '!',
            function() end, false))
    
        -- pause for half a second, then play attack animation
        Timer.after(0.5, function()
    
            -- attack sound
            gSounds['powerup']:stop()
            gSounds['powerup']:play()
    
            -- blink the attacker sprite three times (turn on and off blinking 6 times)
            Timer.every(0.1, function()
                attackerSprite.blinking = not attackerSprite.blinking
            end)
            :limit(6)
            :finish(function()
    
                -- after finishing the blink, play a hit sound and flash the opacity of
                -- the defender a few times
                gSounds['hit']:stop()
                gSounds['hit']:play()
    
                Timer.every(0.1, function()
                    defenderSprite.opacity = defenderSprite.opacity == 64/255 and 1 or 64/255
                end)
                :limit(6)
                :finish(function()
    
                    -- shrink the defender's health bar over half a second, doing at least 1 dmg
                    local dmg = math.max(1, attacker.attack - defender.defense)
    
                    Timer.tween(0.5, {
                        [defenderBar] = {value = defender.currentHP - dmg}
                    })
                    :finish(function()
                        defender.currentHP = defender.currentHP - dmg
                        onEnd()
                    end)
                end)
            end)
        end)
    end
    

    Notice that the attack has two visual phases. First, we toggle the attacker’s blinking six times using Timer.every with :limit(6) to flash the attacker. Then we play a hit sound and toggle the defender’s opacity six times. The damage formula is simple: Attacker’s attack minus defender’s defense, with a minimum of 1 so you always do at least some damage. We tween the health bar down first, then set defender.currentHP once the tween finishes.

  • After both attacks, we check if either Pokémon fainted:

    function TakeTurnState:checkDeaths()
        if self.playerPokemon.currentHP <= 0 then
            self:faint()
            return true
        elseif self.opponentPokemon.currentHP <= 0 then
            self:victory()
            return true
        end
        return false
    end
    

    Notice that Pokémon don’t die; they faint. If neither fainted, we pop back to the battle menu for another round.

Experience and Leveling

  • When you defeat an opponent’s Pokémon, you gain experience points (XP). The XP calculation is based on the opponent’s stats and level:

    -- sum all IVs and multiply by level to get exp amount
    local exp = (self.opponentPokemon.HPIV + self.opponentPokemon.attackIV +
        self.opponentPokemon.defenseIV + self.opponentPokemon.speedIV) * self.opponentPokemon.level
    

    Notice that we sum all the opponent’s IVs and multiply by their level. This gives an XP value scaled to the opponent’s overall power.

  • IVs (Individual Values) are part of Pokémon that represent almost like DNA: They influence how a Pokémon’s stats grow over time. The higher the IV for a stat, the more that stat is inclined to increase per level up. Consider from pokemon/src/Pokemon.lua:

    Pokemon = Class{}
    
    function Pokemon:init(def, level)
        self.name = def.name
        self.baseHP = def.baseHP
        self.baseAttack = def.baseAttack
        self.baseDefense = def.baseDefense
        self.baseSpeed = def.baseSpeed
    
        self.HPIV = def.HPIV
        self.attackIV = def.attackIV
        self.defenseIV = def.defenseIV
        self.speedIV = def.speedIV
    
        self.level = level
        self.currentExp = 0
        self.expToLevel = self.level * self.level * 5 * 0.75
    
        self:calculateStats()
        self.currentHP = self.HP
    end
    
    --[[
        Takes the IV (individual value) for each stat into consideration and rolls
        the dice 3 times to see if that number is less than or equal to the IV (capped at 5).
        The dice is capped at 6 just so no stat ever increases by 3 each time, but
        higher IVs will on average give higher stat increases per level. Returns all of
        the increases so they can be displayed in the TakeTurnState on level up.
    ]]
    function Pokemon:statsLevelUp()
        local HPIncrease = 0
    
        for j = 1, 3 do
            if math.random(6) <= self.HPIV then
                self.HP = self.HP + 1
                HPIncrease = HPIncrease + 1
            end
        end
    
        local attackIncrease = 0
    
        for j = 1, 3 do
            if math.random(6) <= self.attackIV then
                self.attack = self.attack + 1
                attackIncrease = attackIncrease + 1
            end
        end
    
        local defenseIncrease = 0
    
        for j = 1, 3 do
            if math.random(6) <= self.defenseIV then
                self.defense = self.defense + 1
                defenseIncrease = defenseIncrease + 1
            end
        end
    
        local speedIncrease = 0
    
        for j = 1, 3 do
            if math.random(6) <= self.speedIV then
                self.speed = self.speed + 1
                speedIncrease = speedIncrease + 1
            end
        end
    
        return HPIncrease, attackIncrease, defenseIncrease, speedIncrease
    end
    
    function Pokemon:levelUp()
        self.level = self.level + 1
        self.expToLevel = self.level * self.level * 5 * 0.75
    
        return self:statsLevelUp()
    end
    

    Notice that statsLevelUp rolls the dice 3 times for each stat. If the roll (1 through 6) is less than or equal to the IV, that stat increases by 1. Since IVs are capped at 5, no stat can ever increase by 3 each level, but higher IVs will on average give higher stat increases. The levelUp function increments the level, recalculates expToLevel, and delegates to statsLevelUp for the actual stat changes. This is how you get the core RPG loop of fighting, gaining XP, leveling up, and getting stronger.

  • The Pokémon definitions are stored in a data file using data-driven design. Consider from pokemon/src/pokemon_defs.lua:

    POKEMON_DEFS = {
        ['aardart'] = {
            name = 'Aardart',
            battleSpriteFront = 'aardart-front',
            battleSpriteBack = 'aardart-back',
            baseHP = 14,
            baseAttack = 9,
            baseDefense = 5,
            baseSpeed = 6,
            HPIV = 3,
            attackIV = 4,
            defenseIV = 2,
            speedIV = 3
        },
        -- more pokemon...
    }
    

    Notice that all the Pokémon’s characteristics are defined in data, not code. Designers can tweak these values without touching the code. When we instantiate a Pokémon, we look up its definition by name, get its base stats and IVs, and calculate its actual stats based on its level.

Summing Up

  • That ultimately concludes CS50 2D. We ran the gamut of games and genres throughout history, starting with more humble beginnings like Pong to more modern titles, even mobile games. We’ve learned the principles of 2D game development such that you could go develop any of your own types of 2D games and certainly begin to take steps into the world of 3D game development.
  • For Assignment 7, you’ll be extending Pokémon with several features:
    • When you level up, display a menu with all of your stat increases so you know how much attack, defense, etc. you gained.
    • When you’re in the field, press a key to open a menu and see what Pokémon you have, their HP, stats, and level.
    • Have the ability to catch Pokémon. Should you get a Pokémon to be 25% HP or less, you can throw a Pokéball at it and potentially catch it.
    • Make sure you can only have six Pokémon in your party. That’s the max size.
  • In this lesson, you learned how to create a turn-based RPG inspired by Pokémon. Specifically, you learned…
    • How GUI elements like panels, text boxes, selections, and progress bars compose to create complex interfaces.
    • How text wrapping and pagination work using font:getWrap().
    • How selections use callbacks to wire up menu options to game behavior.
    • How progress bars visualize values with tweening for smooth animations.
    • How state stacks differ from state machines: Rendering all states but updating only the topmost.
    • How grid-based movement works with tweening to fixed positions.
    • How random encounters trigger based on tile type and random chance.
    • How turn-based combat uses speed to determine turn order.
    • How damage is calculated from attack minus defense stats.
    • How experience points and IVs drive the leveling system.

This was CS50 Games 2D! Go create amazing games!