Game: Pumpkin Push Part II#

Level Map#

Now that we’ve got a player character walking around an empty world, it’s time to add walls to turn that empty world into a game level.

Simple grey brick wall tile. wall.png

First, let’s make sure we can load and draw a single wall. Add a line in love.load() to load the wall image:

function love.load()
    img_player = love.graphics.newImage("images/ghost.png")
    img_ground = love.graphics.newImage("images/ground.png")
    img_wall = love.graphics.newImage("images/wall.png")

    -- ...
end

And a couple of lines in love.draw() to draw it:

function love.draw()
    -- ...

    local wall_x, wall_y = tile_to_screen(1, 1)
    love.graphics.draw(img_wall, wall_x, wall_y)
end

A single wall tile drawn in the top left of the screen.

Loading Walls#

So far so good, but we want more than a single wall tile. It would be handy to have some kind of level map that lets us easily place wall tiles anywhere on the playing field.

One of the simplest ways to represent a level map is using an array table, containing a number of items equal to the width times the height of the level. Our NUM_ROWS and NUM_COLS are both 10, so 10 x 10 equals an array with 100 entries.

We’ll add an array level_map at the top of our file to contain the level map, using spaces ' ' to indicate empty tiles and 'w' to mark wall tiles.

local level_map = { 
    'w', 'w', 'w', 'w', 'w', 'w', 'w', 'w', 'w', 'w', -- row 1
    'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', -- row 2
    'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', -- etc...
    'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', 
    'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', 
    'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', 
    'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', 
    'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', 
    'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', 
    'w', 'w', 'w', 'w', 'w', 'w', 'w', 'w', 'w', 'w', 
}

The lines are wrapped at the same number of items as the width of the level, and as many lines as the height of the level, so that it’s easy to see where the tiles should go, but this is a convenience for the person editing the map. It would mean the same thing if it was all on one line – or one entry per line – it would just be more confusing to read and edit.

What matters is the order of the items in the array… all of the tiles for the first row, followed by all of the tiles for the second row, the third row, and so on.

We need to to able to map the column and row for a tile into a single index into the array.

We can use the calculation local tile_index = column + (row - 1) * NUM_COLS to convert our tile coordinates into an array index. This comes up all the time when dealing with grid based data stored in an array, whether they are tiles in a level map, or the pixels in an image or texture.

As the value of column increases it simply gives us values 1 through 10. But as row increases, (row - 1) * NUM_COLS will start off as 0, then 10, then 20, 30, up to 90. By adding these two values together, we can reach any value between 1 and 100 in the array. Tiles (1, 1) through (10, 1) become index 1 through 10; tiles (1, 2) through (10, 2) become array index 11 through 20 and so on until we reach the last tile (10, 10) which gives us the index 100, the final entry in the array.

Now we can write a short helper function to check if a particular tile on the map is a wall. We look up that index and check if it contains 'w':

function tile_is_wall(column, row)
    local tile_index = column + (row - 1) * NUM_COLS
    return level_map[tile_index] == 'w'
end

And then in love.draw() we’ll draw the wall graphic on each tile that is a wall:

function love.draw()
    love.graphics.clear(156/255, 172/255, 141/255)

    for row = 1, NUM_ROWS do
        for column = 1, NUM_COLS do
            local tile_x, tile_y = tile_to_screen(column, row)
            love.graphics.draw(img_ground, tile_x, tile_y)

            if tile_is_wall(column, row) then
                love.graphics.draw(img_wall, tile_x, tile_y)
            end
        end
    end

    local player_x, player_y = tile_to_screen(player_col, player_row)
    love.graphics.draw(img_player, player_x, player_y)

    -- removed the test tile draw call
end

Wall tiles form a border all around the outside of the playing area.

And we can make the level more interesting by re-arranging the walls in level_map:

local level_map = { 
    ' ', 'w', 'w', 'w', 'w', 'w', ' ', ' ', ' ', ' ', 
    'w', 'w', ' ', ' ', ' ', 'w', 'w', 'w', 'w', 'w', 
    'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', 
    'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', 'w', 
    'w', ' ', ' ', ' ', 'w', 'w', ' ', ' ', 'w', ' ', 
    'w', ' ', ' ', ' ', ' ', 'w', 'w', ' ', 'w', 'w', 
    'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', 
    'w', 'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', 
    ' ', 'w', 'w', ' ', ' ', 'w', 'w', 'w', ' ', 'w', 
    ' ', ' ', 'w', 'w', 'w', 'w', ' ', 'w', 'w', 'w', 
}

Some of the border walls have been staggered or inset to make a more interesting outline, and a few placed inside as an interior wall that needs to be navigated around.

Player Start Position#

The position our player starts in is currently hard-coded, but now that we have a level map, we can mark the start position there instead.

Let’s change the defaults back to something simple:

local player_col = 1
local player_row = 1

And then mark the position we actually want the player to start on the map with a 'g' (for ghost, 'p' will be for pumpkin):

local level_map = { 
    ' ', 'w', 'w', 'w', 'w', 'w', ' ', ' ', ' ', ' ', 
    'w', 'w', ' ', ' ', ' ', 'w', 'w', 'w', 'w', 'w', 
    'w', ' ', ' ', 'g', ' ', ' ', ' ', ' ', ' ', 'w', -- added a 'g' here
    'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', 'w', 
    'w', ' ', ' ', ' ', 'w', 'w', ' ', ' ', 'w', ' ', 
    'w', ' ', ' ', ' ', ' ', 'w', 'w', ' ', 'w', 'w', 
    'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', 
    'w', 'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', 
    ' ', 'w', 'w', ' ', ' ', 'w', 'w', 'w', ' ', 'w', 
    ' ', ' ', 'w', 'w', 'w', 'w', ' ', 'w', 'w', 'w', 
}

When the game loads, we can search for the 'g' in the map, and set the player’s starting coordinates based on that.

Finding a specific value in the map sounds like a useful thing you might want to do in general, so let’s write a new function to do that. We’ll pass in the character that we’re looking for, and it will return the coordinates of the first instance of that character in the map.

function find_map_coords(character)
    for row = 1, NUM_ROWS do
        for column = 1, NUM_COLS do
            local tile_index = column + (row - 1) * NUM_COLS
            if level_map[tile_index] == character then
                return column, row
            end
        end
    end
end

Then when the game loads we can call find_map_coords to find the player’s starting position.

function love.load()
    -- ...

    player_col, player_row = find_map_coords('g')
end

The ghost’s position has been loaded from the map at position (4, 3).

If the level doesn’t contain a 'g' at all then find_map_coords will return nil and the game will crash. (Try and see!)

This isn’t necessarily a problem… you’re the developer so it’s your responsibility to make sure your levels are valid. But if you wanted to handle this situation more gracefully, you could check the return value first before using the values, and print a warning message to the console.

function love.load()
    -- ...

    local start_col, start_row = find_map_coords('g')
    if start_col and start_row then
        player_col, player_row = start_col, start_row
    else
        print("could not find player start position in level map!")
    end
end

Centering The Map#

So far we’ve been drawing the game level aligned to the top left corner of the window, which is the (0, 0) pixel coordinate in Love2D.

It would be more aesthetically pleasing to have the game centered in our window, so let’s do that now. We’ll update tile_to_screen() to calculate an offset that we can use to center the map.

We’ll do this in two steps which will hopefully make it more obvious what each step is doing.

First, we’ll call love.graphics.getDimensions() to get the width and height of the game in pixels, which in our case will be the area inside our window (that we specified in conf.lua). We want to center the map so lets take half of each dimension and add that to the screen position we draw our game tiles at.

function tile_to_screen(column, row)
    local graphics_width, graphics_height = love.graphics.getDimensions()

    local offset_x = graphics_width/2
    local offset_y = graphics_height/2

    local screen_x = offset_x + (column - 1) * TILE_SIZE
    local screen_y = offset_y + (row - 1) * TILE_SIZE

    return screen_x, screen_y
end

The map has been aligned so it’s top left corner is in the center of the window.

That gets us part of the way, it’s certainly centered by one definition: we’ve moved the top left corner of the map from the top left corner of the window into the center of the window.

But we really want the center of the map to align to the center of the window, not the corner. We can accomplish this by calculating the width of the map in pixels, and then subtracting half of that amount from our offset, shifting the map left and up by half of its size.

function tile_to_screen(column, row)
    local graphics_width, graphics_height = love.graphics.getDimensions()

    local map_width = NUM_COLS * TILE_SIZE
    local map_height = NUM_ROWS * TILE_SIZE

    local offset_x = graphics_width/2 - map_width/2
    local offset_y = graphics_height/2 - map_height/2

    local screen_x = offset_x + (column - 1) * TILE_SIZE
    local screen_y = offset_y + (row - 1) * TILE_SIZE

    return screen_x, screen_y
end

The map properly centered in the window.

Ta daa!

Code So Far
local TILE_SIZE = 64
local NUM_COLS = 10
local NUM_ROWS = 10

local player_col = 1
local player_row = 1

local sounds = {
    footstep = {
        files = {
            "sounds/footstep1.ogg", 
            "sounds/footstep2.ogg", 
        }
    },
    blocked = {
        files = {
            "sounds/blocked1.ogg", 
            "sounds/blocked2.ogg", 
            "sounds/blocked3.ogg", 
        }
    },
}

local level_map = { 
    ' ', 'w', 'w', 'w', 'w', 'w', ' ', ' ', ' ', ' ', 
    'w', 'w', ' ', ' ', ' ', 'w', 'w', 'w', 'w', 'w', 
    'w', ' ', ' ', 'g', ' ', ' ', ' ', ' ', ' ', 'w',
    'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', 'w', 
    'w', ' ', ' ', ' ', 'w', 'w', ' ', ' ', 'w', ' ', 
    'w', ' ', ' ', ' ', ' ', 'w', 'w', ' ', 'w', 'w', 
    'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', 
    'w', 'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', 
    ' ', 'w', 'w', ' ', ' ', 'w', 'w', 'w', ' ', 'w', 
    ' ', ' ', 'w', 'w', 'w', 'w', ' ', 'w', 'w', 'w', 
}

function love.load()
    img_player = love.graphics.newImage("images/ghost.png")
    img_ground = love.graphics.newImage("images/ground.png")
    img_wall = love.graphics.newImage("images/wall.png")

    for name, sound in pairs(sounds) do
        sound.sources = {}
        for i, filename in ipairs(sound.files) do
            local source = love.audio.newSource(filename, "static")
            table.insert(sound.sources, source)
        end
    end

    local start_col, start_row = find_map_coords('g')
    if start_col and start_row then
        player_col, player_row = start_col, start_row
    else
        print("could not find player start position in level map!")
    end
end

function love.draw()
    love.graphics.clear(156/255, 172/255, 141/255)

    for row = 1, NUM_ROWS do
        for column = 1, NUM_COLS do
            local tile_x, tile_y = tile_to_screen(column, row)
            love.graphics.draw(img_ground, tile_x, tile_y)

            if tile_is_wall(column, row) then
                love.graphics.draw(img_wall, tile_x, tile_y)
            end
        end
    end

    local player_x, player_y = tile_to_screen(player_col, player_row)
    love.graphics.draw(img_player, player_x, player_y)
end

function love.keypressed( key, scancode, isrepeat )
    print(string.format("love.keypressed %s, %s, %s", key, scancode, isrepeat))

    if key == "a" or key == "left" then
        if player_col > 1 then
            print(" move left")
            player_col = player_col - 1
            play_sound("footstep")
        else
            play_sound("blocked")
        end
    elseif key == "d" or key == "right" then
        if player_col < NUM_COLS then
            print(" move right")
            player_col = player_col + 1
            play_sound("footstep")
        else
            play_sound("blocked")
        end
    elseif key == "w" or key == "up" then
        if player_row > 1 then
            print(" move up")
            player_row = player_row - 1
            play_sound("footstep")
        else
            play_sound("blocked")
        end
    elseif key == "s" or key == "down" then
        if player_row < NUM_ROWS then
            print(" move down")
            player_row = player_row + 1
            play_sound("footstep")
        else
            play_sound("blocked")
        end
    end

    print(string.format(" player pos %d, %d", player_col, player_row))
end

function tile_to_screen(column, row)
    local graphics_width, graphics_height = love.graphics.getDimensions()

    local map_width = NUM_COLS * TILE_SIZE
    local map_height = NUM_ROWS * TILE_SIZE

    local offset_x = graphics_width/2 - map_width/2
    local offset_y = graphics_height/2 - map_height/2

    local screen_x = offset_x + (column - 1) * TILE_SIZE
    local screen_y = offset_y + (row - 1) * TILE_SIZE

    return screen_x, screen_y
end


function play_sound(name)
    local effect = sounds[name]
    local rnd_index = math.random(#effect.sources)
    local source = effect.sources[rnd_index]
    love.audio.play(source)
end

function tile_is_wall(column, row)
    local tile_index = column + (row - 1) * NUM_COLS
    return level_map[tile_index] == 'w'
end

function find_map_coords(character)
    for row = 1, NUM_ROWS do
        for column = 1, NUM_COLS do
            local tile_index = column + (row - 1) * NUM_COLS
            if level_map[tile_index] == character then
                return column, row
            end
        end
    end
end

Hitting A Wall#

Now that we’ve got our walls loaded, we want to block the player from being able to walk through them.

Wait… if the player is a ghost so shouldn’t they be able to walk through walls…? It’s a game, it doesn’t need to obey the laws of ghost physics.

We’re already checking to see if the player is trying to move beyond the bounds of the level and preventing them from doing so. Now we’ll extend that functionality to also check for walls, and prevent them from walking into them.

We’ll also take this opportunity to refactor the movement code in love.keypressed() to reduce the amount of duplicate code that’s already there, and avoid adding more. Instead of handling each direction separately, we can write some functions to handle moving in any direction.

The first thing we want to do is figure out which direction the player wants to move (if any). We can write a function key_to_direction() to convert the key they pressed into two direction values:

function key_to_direction(key)
    if key == "a" or key == "left" then
        return -1, 0
    elseif key == "d" or key == "right" then
        return 1, 0
    elseif key == "w" or key == "up" then
        return 0, -1
    elseif key == "s" or key == "down" then
        return 0, 1
    end
end

…and replace all of the key checks we had in love.keypressed() with a single call to key_to_direction():

function love.keypressed( key, scancode, isrepeat )
    print(string.format("love.keypressed %s, %s, %s", key, scancode, isrepeat))

    local move_x, move_y = key_to_direction(key)
    if move_x and move_y then
        print(string.format(" player wants to move %d, %d", move_x, move_y))
    end

    print(string.format(" player pos %d, %d", player_col, player_row))
end

At this point the player won’t actually be able to move, but we can see what they’re trying to do in the console output:

love.keypressed w, w, false
 player wants to move 0, -1
 player pos 4, 3
love.keypressed a, a, false
 player wants to move -1, 0
 player pos 4, 3
love.keypressed s, s, false
 player wants to move 0, 1
 player pos 4, 3
love.keypressed d, d, false
 player wants to move 1, 0
 player pos 4, 3

Next we want to check if the player is allowed to move in the direction they requested. player_can_move() will add the desired movement to the player’s current position, and check if that position is still within the level bounds.

function player_can_move(move_x, move_y)
    local new_col = player_col + move_x
    local new_row = player_row + move_y

    if new_col < 1 or new_col > NUM_COLS then
        return false
    end

    if new_row < 1 or new_row > NUM_ROWS then
        return false
    end

    return true
end

If the move is allowed then we update the player’s real position and play a footstep, and if not we play the blocked sound like we were doing before, except that now we only need to do it in one place instead of four.

We’ll also move the player position debug line up so it only prints if the player has pressed a valid movement key.

function love.keypressed( key, scancode, isrepeat )
    print(string.format("love.keypressed %s, %s, %s", key, scancode, isrepeat))

    local move_x, move_y = key_to_direction(key)
    if move_x and move_y then
        print(string.format(" player wants to move %d, %d", move_x, move_y))
        if player_can_move(move_x, move_y) then
            play_sound("footstep")
            player_col = player_col + move_x
            player_row = player_row + move_y
        else
            play_sound("blocked")
            print(" blocked!")
        end
        print(string.format(" player pos %d, %d", player_col, player_row))
    end
end

The player can only move on one axis (X or Y) at a time. Since key_to_direction() returns a 1 or -1 for either the X or Y value with the other being 0, we can add both the move_x and move_y values to the player’s current position to find their new desired position, and whichever value contains 0 will have no effect.

Now it’s a simple matter to add a check if the new position contains a wall by calling tile_is_wall, and prevent movement in that case as well.

function player_can_move(move_x, move_y)
    local new_col = player_col + move_x
    local new_row = player_row + move_y

    if new_col < 1 or new_col > NUM_COLS then
        return false
    end

    if new_row < 1 or new_row > NUM_ROWS then
        return false
    end

    if tile_is_wall(new_col, new_row) then
        return false
    end

    return true
end

Try moving the ghost all around the level… they shouldn’t be able to pass a wall in any direction.

Code So Far
local TILE_SIZE = 64
local NUM_COLS = 10
local NUM_ROWS = 10

local player_col = 1
local player_row = 1

local sounds = {
    footstep = {
        files = {
            "sounds/footstep1.ogg", 
            "sounds/footstep2.ogg", 
        }
    },
    blocked = {
        files = {
            "sounds/blocked1.ogg", 
            "sounds/blocked2.ogg", 
            "sounds/blocked3.ogg", 
        }
    },
}

local level_map = { 
    ' ', 'w', 'w', 'w', 'w', 'w', ' ', ' ', ' ', ' ', 
    'w', 'w', ' ', ' ', ' ', 'w', 'w', 'w', 'w', 'w', 
    'w', ' ', ' ', 'g', ' ', ' ', ' ', ' ', ' ', 'w',
    'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', 'w', 
    'w', ' ', ' ', ' ', 'w', 'w', ' ', ' ', 'w', ' ', 
    'w', ' ', ' ', ' ', ' ', 'w', 'w', ' ', 'w', 'w', 
    'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', 
    'w', 'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', 
    ' ', 'w', 'w', ' ', ' ', 'w', 'w', 'w', ' ', 'w', 
    ' ', ' ', 'w', 'w', 'w', 'w', ' ', 'w', 'w', 'w', 
}

function love.load()
    img_player = love.graphics.newImage("images/ghost.png")
    img_ground = love.graphics.newImage("images/ground.png")
    img_wall = love.graphics.newImage("images/wall.png")

    for name, sound in pairs(sounds) do
        sound.sources = {}
        for i, filename in ipairs(sound.files) do
            local source = love.audio.newSource(filename, "static")
            table.insert(sound.sources, source)
        end
    end

    local start_col, start_row = find_map_coords('g')
    if start_col and start_row then
        player_col, player_row = start_col, start_row
    else
        print("could not find player start position in level map!")
    end
end

function love.draw()
    love.graphics.clear(156/255, 172/255, 141/255)

    for row = 1, NUM_ROWS do
        for column = 1, NUM_COLS do
            local tile_x, tile_y = tile_to_screen(column, row)
            love.graphics.draw(img_ground, tile_x, tile_y)

            if tile_is_wall(column, row) then
                love.graphics.draw(img_wall, tile_x, tile_y)
            end
        end
    end

    local player_x, player_y = tile_to_screen(player_col, player_row)
    love.graphics.draw(img_player, player_x, player_y)
end

function love.keypressed( key, scancode, isrepeat )
    print(string.format("love.keypressed %s, %s, %s", key, scancode, isrepeat))

    local move_x, move_y = key_to_direction(key)
    if move_x and move_y then
        print(string.format(" player wants to move %d, %d", move_x, move_y))
        if player_can_move(move_x, move_y) then
            play_sound("footstep")
            player_col = player_col + move_x
            player_row = player_row + move_y
        else
            play_sound("blocked")
            print(" blocked!")
        end
        print(string.format(" player pos %d, %d", player_col, player_row))
    end
end

function tile_to_screen(column, row)
    local graphics_width, graphics_height = love.graphics.getDimensions()

    local map_width = NUM_COLS * TILE_SIZE
    local map_height = NUM_ROWS * TILE_SIZE

    local offset_x = graphics_width/2 - map_width/2
    local offset_y = graphics_height/2 - map_height/2

    local screen_x = offset_x + (column - 1) * TILE_SIZE
    local screen_y = offset_y + (row - 1) * TILE_SIZE

    return screen_x, screen_y
end


function play_sound(name)
    local effect = sounds[name]
    local rnd_index = math.random(#effect.sources)
    local source = effect.sources[rnd_index]
    love.audio.play(source)
end

function tile_is_wall(column, row)
    local tile_index = column + (row - 1) * NUM_COLS
    return level_map[tile_index] == 'w'
end

function find_map_coords(character)
    for row = 1, NUM_ROWS do
        for column = 1, NUM_COLS do
            local tile_index = column + (row - 1) * NUM_COLS
            if level_map[tile_index] == character then
                return column, row
            end
        end
    end
end

function key_to_direction(key)
    if key == "a" or key == "left" then
        return -1, 0
    elseif key == "d" or key == "right" then
        return 1, 0
    elseif key == "w" or key == "up" then
        return 0, -1
    elseif key == "s" or key == "down" then
        return 0, 1
    end
end

function player_can_move(move_x, move_y)
    local new_col = player_col + move_x
    local new_row = player_row + move_y

    if new_col < 1 or new_col > NUM_COLS then
        return false
    end

    if new_row < 1 or new_row > NUM_ROWS then
        return false
    end

    if tile_is_wall(new_col, new_row) then
        return false
    end

    return true
end

Completing The Map#

Time to get the final image assets loaded into the game, and marked on the map.

FIXME pumpkin_sad.png

FIXME pumpkin_happy.png

FIXME vines.png

Place a few pumpkins and vines on the map, marked with 'p' and 'v' respectively:

local level_map = { 
    ' ', 'w', 'w', 'w', 'w', 'w', ' ', ' ', ' ', ' ', 
    'w', 'w', ' ', ' ', 'v', 'w', 'w', 'w', 'w', 'w', 
    'w', ' ', ' ', 'g', ' ', ' ', ' ', ' ', ' ', 'w',
    'w', ' ', ' ', ' ', ' ', 'p', ' ', 'p', 'w', 'w', 
    'w', ' ', ' ', ' ', 'w', 'w', ' ', ' ', 'w', ' ', 
    'w', 'v', ' ', ' ', ' ', 'w', 'w', ' ', 'w', 'w', 
    'w', ' ', 'p', ' ', ' ', ' ', ' ', ' ', 'v', 'w', 
    'w', 'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', 
    ' ', 'w', 'w', ' ', ' ', 'w', 'w', 'w', ' ', 'w', 
    ' ', ' ', 'w', 'w', 'w', 'w', ' ', 'w', 'w', 'w', 
}

Load the new images in love.load():

function love.load()
    img_player = love.graphics.newImage("images/ghost.png")
    img_ground = love.graphics.newImage("images/ground.png")
    img_wall = love.graphics.newImage("images/wall.png")
    img_pumpkin_sad = love.graphics.newImage("images/pumpkin_sad.png")
    img_pumpkin_happy = love.graphics.newImage("images/pumpkin_happy.png")
    img_vines = love.graphics.newImage("images/vines.png")

    -- ...
end    

Add new tile_is_pumpkin() and tile_is_vine() helpers like the existing tile_is_wall():

function tile_is_wall(column, row)
    local tile_index = column + (row - 1) * NUM_COLS
    return level_map[tile_index] == 'w'
end

function tile_is_pumpkin(column, row)
    local tile_index = column + (row - 1) * NUM_COLS
    return level_map[tile_index] == 'p'
end

function tile_is_vine(column, row)
    local tile_index = column + (row - 1) * NUM_COLS
    return level_map[tile_index] == 'v'
end

And draw them in love.draw().

function love.draw()
    love.graphics.clear(156/255, 172/255, 141/255)

    for row = 1, NUM_ROWS do
        for column = 1, NUM_COLS do
            local tile_x, tile_y = tile_to_screen(column, row)
            love.graphics.draw(img_ground, tile_x, tile_y)

            if tile_is_wall(column, row) then
                love.graphics.draw(img_wall, tile_x, tile_y)
            end

            if tile_is_vine(column, row) then
                love.graphics.draw(img_vines, tile_x, tile_y)
            end

            if tile_is_pumpkin(column, row) then
                love.graphics.draw(img_pumpkin_sad, tile_x, tile_y)
            end 
        end
    end

    local player_x, player_y = tile_to_screen(player_col, player_row)
    love.graphics.draw(img_player, player_x, player_y)
end

We’re only drawing sad pumpkins for now, because that’s how they start off. Once they are pushed onto a tile with vines they’ll become happy.

FIXME

You may have noticed that we’re repeating the math in tile_is_wall(), tile_is_pumpkin(), and tile_is_vine(). This is a good indication that it’s time to separate that code into it’s own utility function, so let’s do that now. get_tile_at() will do the math to look up the tile at a particular column and row, and return the character stored in that map location.

function get_tile_at(column, row)
    local tile_index = column + (row - 1) * NUM_COLS
    return level_map[tile_index]
end

Now we can use get_tile_at instead of copy and pasting the same formula:

function tile_is_wall(column, row)
    return get_tile_at(column, row) == 'w'
end

function tile_is_pumpkin(column, row)
    return get_tile_at(column, row) == 'p'
end

function tile_is_vine(column, row)
    return get_tile_at(column, row) == 'v'
end

We can also update find_map_coords() to use get_tile_at() as well:

function find_map_coords(character)
    for row = 1, NUM_ROWS do
        for column = 1, NUM_COLS do
            if get_tile_at(column, row) == character then
                return column, row
            end
        end
    end
end
Code So Far
local TILE_SIZE = 64
local NUM_COLS = 10
local NUM_ROWS = 10

local player_col = 1
local player_row = 1

local sounds = {
    footstep = {
        files = {
            "sounds/footstep1.ogg", 
            "sounds/footstep2.ogg", 
        }
    },
    blocked = {
        files = {
            "sounds/blocked1.ogg", 
            "sounds/blocked2.ogg", 
            "sounds/blocked3.ogg", 
        }
    },
}

local level_map = { 
    ' ', 'w', 'w', 'w', 'w', 'w', ' ', ' ', ' ', ' ', 
    'w', 'w', ' ', ' ', 'v', 'w', 'w', 'w', 'w', 'w', 
    'w', ' ', ' ', 'g', ' ', ' ', ' ', ' ', ' ', 'w',
    'w', ' ', ' ', ' ', ' ', 'p', ' ', 'p', 'w', 'w', 
    'w', ' ', ' ', ' ', 'w', 'w', ' ', ' ', 'w', ' ', 
    'w', 'v', ' ', ' ', ' ', 'w', 'w', ' ', 'w', 'w', 
    'w', ' ', 'p', ' ', ' ', ' ', ' ', ' ', 'v', 'w', 
    'w', 'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', 
    ' ', 'w', 'w', ' ', ' ', 'w', 'w', 'w', ' ', 'w', 
    ' ', ' ', 'w', 'w', 'w', 'w', ' ', 'w', 'w', 'w', 
}

function love.load()
    img_player = love.graphics.newImage("images/ghost.png")
    img_ground = love.graphics.newImage("images/ground.png")
    img_wall = love.graphics.newImage("images/wall.png")
    img_pumpkin_sad = love.graphics.newImage("images/pumpkin_sad.png")
    img_pumpkin_happy = love.graphics.newImage("images/pumpkin_happy.png")
    img_vines = love.graphics.newImage("images/vines.png")

    for name, sound in pairs(sounds) do
        sound.sources = {}
        for i, filename in ipairs(sound.files) do
            local source = love.audio.newSource(filename, "static")
            table.insert(sound.sources, source)
        end
    end

    local start_col, start_row = find_map_coords('g')
    if start_col and start_row then
        player_col, player_row = start_col, start_row
    else
        print("could not find player start position in level map!")
    end
end

function love.draw()
    love.graphics.clear(156/255, 172/255, 141/255)

    for row = 1, NUM_ROWS do
        for column = 1, NUM_COLS do
            local tile_x, tile_y = tile_to_screen(column, row)
            love.graphics.draw(img_ground, tile_x, tile_y)

            if tile_is_wall(column, row) then
                love.graphics.draw(img_wall, tile_x, tile_y)
            end

            if tile_is_vine(column, row) then
                love.graphics.draw(img_vines, tile_x, tile_y)
            end

            if tile_is_pumpkin(column, row) then
                love.graphics.draw(img_pumpkin_sad, tile_x, tile_y)
            end
        end
    end

    local player_x, player_y = tile_to_screen(player_col, player_row)
    love.graphics.draw(img_player, player_x, player_y)
end

function love.keypressed( key, scancode, isrepeat )
    print(string.format("love.keypressed %s, %s, %s", key, scancode, isrepeat))

    local move_x, move_y = key_to_direction(key)
    if move_x and move_y then
        print(string.format(" player wants to move %d, %d", move_x, move_y))
        if player_can_move(move_x, move_y) then
            play_sound("footstep")
            player_col = player_col + move_x
            player_row = player_row + move_y
        else
            play_sound("blocked")
            print(" blocked!")
        end
        print(string.format(" player pos %d, %d", player_col, player_row))
    end
end

function tile_to_screen(column, row)
    local graphics_width, graphics_height = love.graphics.getDimensions()

    local map_width = NUM_COLS * TILE_SIZE
    local map_height = NUM_ROWS * TILE_SIZE

    local offset_x = graphics_width/2 - map_width/2
    local offset_y = graphics_height/2 - map_height/2

    local screen_x = offset_x + (column - 1) * TILE_SIZE
    local screen_y = offset_y + (row - 1) * TILE_SIZE

    return screen_x, screen_y
end


function play_sound(name)
    local effect = sounds[name]
    local rnd_index = math.random(#effect.sources)
    local source = effect.sources[rnd_index]
    love.audio.play(source)
end

function tile_is_wall(column, row)
    return get_tile_at(column, row) == 'w'
end

function tile_is_pumpkin(column, row)
    return get_tile_at(column, row) == 'p'
end

function tile_is_vine(column, row)
    return get_tile_at(column, row) == 'v'
end

function find_map_coords(character)
    for row = 1, NUM_ROWS do
        for column = 1, NUM_COLS do
            if get_tile_at(column, row) == character then
                return column, row
            end
        end
    end
end

function key_to_direction(key)
    if key == "a" or key == "left" then
        return -1, 0
    elseif key == "d" or key == "right" then
        return 1, 0
    elseif key == "w" or key == "up" then
        return 0, -1
    elseif key == "s" or key == "down" then
        return 0, 1
    end
end

function player_can_move(move_x, move_y)
    local new_col = player_col + move_x
    local new_row = player_row + move_y

    if new_col < 1 or new_col > NUM_COLS then
        return false
    end

    if new_row < 1 or new_row > NUM_ROWS then
        return false
    end

    if tile_is_wall(new_col, new_row) then
        return false
    end

    return true
end

function get_tile_at(column, row)
    local tile_index = column + (row - 1) * NUM_COLS
    return level_map[tile_index]
end

Completing Gameplay#

Now that we have pumpkins loaded into the game world, it’s time to make them interactive.

How do we allow pumpkins to be moved around in the game level? The player already moves, and their current position is stored in the variables player_col and player_row, but currently the position of the pumpkins is fixed in the level map.

One option would be to modify the level_map array itself. We could, for example, write a ' ' (space) character into level_map where the pumpkin’s old position was, and then write a 'p' at the new position. On the next frame, the pumpkin would draw in it’s new location.

-- example only
level_map[pumpkin_old_index] = ' '
level_map[pumpkin_new_index] = 'p'

One issue with doing this is we want to be able to push pumpkins on top of tiles with vines, indicated with 'v'. If we overwrite this index in the array with a 'p' then the 'v' would be erased.

So instead of modifying the map while the player is playing, we’ll create a new array to hold the positions of all of the pumpkins. When the game loads, we’ll read the map to populate the pumpkin list with all of the starting positions. If the player pushes a pumpkin we’ll find it in the pumpkin list and update its position there.

A Table Full Of Pumpkins#

First we’ll declare a new table near the top of our file to store the positions of all of the pumpkins. It will start off empty.

local player_col = 1
local player_row = 1

local pumpkins = {}

In love.load(), we’ll loop over the tiles in the map, and for each 'p' we find, we’ll create a new table storing the position, and insert that table into the pumpkins array.

function love.load()
    -- ...

    for row = 1, NUM_ROWS do
        for column = 1, NUM_COLS do
            if get_tile_at(column, row) == 'p' then
                local pumpkin = {}
                pumpkin.column = column
                pumpkin.row = row
                print(string.format("loading pumpkin at (%d, %d)", pumpkin.column, pumpkin.row))
                table.insert(pumpkins, pumpkin)
            end
        end
    end
end

Running the game should display the list of loaded pumpkins in the debug console.

loading pumpkin at (6, 4)
loading pumpkin at (8, 4)
loading pumpkin at (3, 7)

We’ll also need to update love.draw so that we are drawing each pumpkin in the pumpkins list, which will change during gameplay, instead of from the level_map which only has the fixed starting positions.

After looping over the map to draw the fixed position ground, walls and vines, we’ll do a separate loop using ipairs over the dynamic pumpkins array to draw all of the pumpkins in their current positions.

function love.draw()
    love.graphics.clear(156/255, 172/255, 141/255)

    for row = 1, NUM_ROWS do
        for column = 1, NUM_COLS do
            local tile_x, tile_y = tile_to_screen(column, row)
            love.graphics.draw(img_ground, tile_x, tile_y)

            if tile_is_wall(column, row) then
                love.graphics.draw(img_wall, tile_x, tile_y)
            end

            if tile_is_vine(column, row) then
                love.graphics.draw(img_vines, tile_x, tile_y)
            end

            -- removed tile based pumpkin drawing
        end
    end

    for i, pumpkin in ipairs(pumpkins) do
        local tile_x, tile_y = tile_to_screen(pumpkin.column, pumpkin.row)
        love.graphics.draw(img_pumpkin_sad, tile_x, tile_y)
    end

    local player_x, player_y = tile_to_screen(player_col, player_row)
    love.graphics.draw(img_player, player_x, player_y)
end

Running the game at this point should look the same as in the last section, but now we’re drawing the pumpkins from the positions in the pumpkins array, instead of from the level_map.

Pushing Pumpkins#

The rules for pushing pumpkins are simple enough: a player can move onto a tile occupied by a pumpkin, which pushes the pumpkin into the next tile over, but only if the next tile over is not a wall, not off the map, and not already occupied by a pumpkin. Pushing multiple pumpkins in a line is not allowed.

Let’s modify player_can_move() to include a check to see if there is a pumpkin occupying the tile the player intends to move into. If there’s a pumpkin in the destination tile, we need to test the next tile over as well, to see if it is vacant.

First let’s split the out of bounds checks from player_can_move() into its own function out_of_bounds(), since we’ll want to use this same logic from multiple places.

function out_of_bounds(column, row)
    if column < 1 or column > NUM_COLS then
        return true
    end

    if row < 1 or row > NUM_ROWS then
        return true
    end

    return false
end
function player_can_move(move_x, move_y)
    local new_col = player_col + move_x
    local new_row = player_row + move_y

    if out_of_bounds(new_col, new_row) then
        return false
    end

    if tile_is_wall(new_col, new_row) then
        return false
    end

    return true
end

Now that the current positions of pumpkins are stored in pumpkins instead of the level map, we’ll need a new way to look them up. Let’s replace tile_is_pumpkin() with a new function get_pumpkin_at() which will return the individual pumpkin table if there is one found at the requested coordinates, or nil if there is no pumpkin there.

function get_pumpkin_at(column, row)
    for i, pumpkin in ipairs(pumpkins) do
        if pumpkin.column == column and pumpkin.row == row then
            return pumpkin
        end
    end
end

Next we write a function tile_blocks_pumpkin() to tell us if a pumpkin could move into a particular column and row. If that tile would be out of bounds, if it’s a wall, or if there is already a pumpkin in that location then it would block pumpkins from being pushed onto it.

function tile_blocks_pumpkin(column, row)
    if out_of_bounds(column, row) then
        return true
    end

    if tile_is_wall(column, row) then
        return true
    end

    if get_pumpkin_at(column, row) then
        return true
    end

    return false
end

And then we incorporate these new checks into player_can_move() …

After determining there’s a pumpkin in the next tile over, we add move_x and move_y again to the new position, to get the next tile over, and call tile_blocks_pumpkin() to determine if we could push a pumpkin into it. If we can’t, then the player also can’t move in that direction.

function player_can_move(move_x, move_y)
    local new_col = player_col + move_x
    local new_row = player_row + move_y

    if out_of_bounds(new_col, new_row) then
        return false
    end

    if tile_is_wall(new_col, new_row) then
        return false
    end

    if get_pumpkin_at(new_col, new_row) then
        local next_col = new_col + move_x
        local next_row = new_row + move_y
        if tile_blocks_pumpkin(next_col, next_row) then
            return false
        end
    end

    return true
end

Finally, we need to actually update the position of any pumpkin that we want to push.

In love.keypressed() once we’ve determined that the player can move into a particular tile and updated the player position accordingly, we’ll look up the pumpkin occupying that tile and, if there is one, push it over in the same direction.

Previously we’ve been treating the return value of get_pumpkin_at() as through it were a true/false value (either it contains a table, or nil) because all we cared about was if there was a pumpkin there or not. This time we’ll store the return value, which is the table containing the coordinates of the pumpkin, so that we can update its position.

Note that we aren’t checking if there is space to push the pumpkin over at this point, since we’ve already performed that check inside player_can_move(), so we’re relying on the result of that call being correct.

function love.keypressed( key, scancode, isrepeat )
    print(string.format("love.keypressed %s, %s, %s", key, scancode, isrepeat))

    local move_x, move_y = key_to_direction(key)
    if move_x and move_y then
        print(string.format(" player wants to move %d, %d", move_x, move_y))
        if player_can_move(move_x, move_y) then
            play_sound("footstep")
            player_col = player_col + move_x
            player_row = player_row + move_y

            local pumpkin = get_pumpkin_at(player_col, player_row)
            if pumpkin then
                pumpkin.column = pumpkin.column + move_x
                pumpkin.row = pumpkin.row + move_y
                print(string.format(" pushed pumpkin to %d, %d", pumpkin.column, pumpkin.row))
            end
        else
            play_sound("blocked")
            print(" blocked!")
        end
        print(string.format(" player pos %d, %d", player_col, player_row))
    end
end

Now that the player can push pumpkins, we want to draw any pumpkin that is on a vine tile using the happy image instead of the sad one.

When drawing each pumpkin in love.draw() we can check tile_is_vine() to decide which image to draw:

function love.draw()
    --- ...

    for i, pumpkin in ipairs(pumpkins) do
        local tile_x, tile_y = tile_to_screen(pumpkin.column, pumpkin.row)
        local pumpkin_img = img_pumpkin_sad
        if tile_is_vine(pumpkin.column, pumpkin.row) then
            pumpkin_img = img_pumpkin_happy
        end
        love.graphics.draw(pumpkin_img, tile_x, tile_y)
    end

    --- ...
end

The player has pushed two of the pumpkins onto vine tiles, and they are drawn smiling. The final pumpkin is on an empty dirt tile and still sad.

Win Condition#

Let’s recognize the player’s achievement if they manage to push all of the pumpkins onto a vine, winning the game.

Add this new sound to the sounds table:

local sounds = {
    footstep = {
        files = {
            "sounds/footstep1.ogg", 
            "sounds/footstep2.ogg", 
        }
    },
    blocked = {
        files = {
            "sounds/blocked1.ogg", 
            "sounds/blocked2.ogg", 
            "sounds/blocked3.ogg", 
        }
    },
    win = {
        files = {
            "sounds/congratulations.ogg", 
        }
    },
}

We’ll write all_pumpkins_happy() which will loop through all of the pumpkins and check if all of them are on vine tiles. If we find one that isn’t, the player still has some work to do.

function all_pumpkins_happy()
    for i, pumpkin in ipairs(pumpkins) do
        if not tile_is_vine(pumpkin.column, pumpkin.row) then
            return false
        end
    end
    return true
end

After processing a key press, check if all of the pumpkins are happy, and if they are then the player has won, time to play the win sound!

function love.keypressed( key, scancode, isrepeat )
    -- ...

    if all_pumpkins_happy() then
        print(" player wins!")
        play_sound("win")
    end
end

The player has pushed all three pumpkins onto vine tiles to win the game. Congratulations!

Congratulations!

Code So Far
local TILE_SIZE = 64
local NUM_COLS = 10
local NUM_ROWS = 10

local player_col = 1
local player_row = 1

local pumpkins = {}

local sounds = {
    footstep = {
        files = {
            "sounds/footstep1.ogg", 
            "sounds/footstep2.ogg", 
        }
    },
    blocked = {
        files = {
            "sounds/blocked1.ogg", 
            "sounds/blocked2.ogg", 
            "sounds/blocked3.ogg", 
        }
    },
    win = {
        files = {
            "sounds/congratulations.ogg", 
        }
    },
}

local level_map = { 
    ' ', 'w', 'w', 'w', 'w', 'w', ' ', ' ', ' ', ' ', 
    'w', 'w', ' ', ' ', 'v', 'w', 'w', 'w', 'w', 'w', 
    'w', ' ', ' ', 'g', ' ', ' ', ' ', ' ', ' ', 'w',
    'w', ' ', ' ', ' ', ' ', 'p', ' ', 'p', 'w', 'w', 
    'w', ' ', ' ', ' ', 'w', 'w', ' ', ' ', 'w', ' ', 
    'w', 'v', ' ', ' ', ' ', 'w', 'w', ' ', 'w', 'w', 
    'w', ' ', 'p', ' ', ' ', ' ', ' ', ' ', 'v', 'w', 
    'w', 'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', 
    ' ', 'w', 'w', ' ', ' ', 'w', 'w', 'w', ' ', 'w', 
    ' ', ' ', 'w', 'w', 'w', 'w', ' ', 'w', 'w', 'w', 
}

function love.load()
    img_player = love.graphics.newImage("images/ghost.png")
    img_ground = love.graphics.newImage("images/ground.png")
    img_wall = love.graphics.newImage("images/wall.png")
    img_pumpkin_sad = love.graphics.newImage("images/pumpkin_sad.png")
    img_pumpkin_happy = love.graphics.newImage("images/pumpkin_happy.png")
    img_vines = love.graphics.newImage("images/vines.png")

    for name, sound in pairs(sounds) do
        sound.sources = {}
        for i, filename in ipairs(sound.files) do
            local source = love.audio.newSource(filename, "static")
            table.insert(sound.sources, source)
        end
    end

    local start_col, start_row = find_map_coords('g')
    if start_col and start_row then
        player_col, player_row = start_col, start_row
    else
        print("could not find player start position in level map!")
    end

    for row = 1, NUM_ROWS do
        for column = 1, NUM_COLS do
            if get_tile_at(column, row) == 'p' then
                local pumpkin = {}
                pumpkin.column = column
                pumpkin.row = row
                print(string.format("loading pumpkin at (%d, %d)", pumpkin.column, pumpkin.row))
                table.insert(pumpkins, pumpkin)
            end
        end
    end
end

function love.draw()
    love.graphics.clear(156/255, 172/255, 141/255)

    for row = 1, NUM_ROWS do
        for column = 1, NUM_COLS do
            local tile_x, tile_y = tile_to_screen(column, row)
            love.graphics.draw(img_ground, tile_x, tile_y)

            if tile_is_wall(column, row) then
                love.graphics.draw(img_wall, tile_x, tile_y)
            end

            if tile_is_vine(column, row) then
                love.graphics.draw(img_vines, tile_x, tile_y)
            end
        end
    end

    for i, pumpkin in ipairs(pumpkins) do
        local tile_x, tile_y = tile_to_screen(pumpkin.column, pumpkin.row)
        local pumpkin_img = img_pumpkin_sad
        if tile_is_vine(pumpkin.column, pumpkin.row) then
            pumpkin_img = img_pumpkin_happy
        end
        love.graphics.draw(pumpkin_img, tile_x, tile_y)
    end

    local player_x, player_y = tile_to_screen(player_col, player_row)
    love.graphics.draw(img_player, player_x, player_y)
end

function love.keypressed( key, scancode, isrepeat )
    print(string.format("love.keypressed %s, %s, %s", key, scancode, isrepeat))

    local move_x, move_y = key_to_direction(key)
    if move_x and move_y then
        print(string.format(" player wants to move %d, %d", move_x, move_y))
        if player_can_move(move_x, move_y) then
            play_sound("footstep")
            player_col = player_col + move_x
            player_row = player_row + move_y

            local pumpkin = get_pumpkin_at(player_col, player_row)
            if pumpkin then
                pumpkin.column = pumpkin.column + move_x
                pumpkin.row = pumpkin.row + move_y
                print(string.format(" pushed pumpkin to %d, %d", pumpkin.column, pumpkin.row))
            end
        else
            play_sound("blocked")
            print(" blocked!")
        end
        print(string.format(" player pos %d, %d", player_col, player_row))
    end

    if all_pumpkins_happy() then
        print(" player wins!")
        play_sound("win")
    end    
end

function tile_to_screen(column, row)
    local graphics_width, graphics_height = love.graphics.getDimensions()

    local map_width = NUM_COLS * TILE_SIZE
    local map_height = NUM_ROWS * TILE_SIZE

    local offset_x = graphics_width/2 - map_width/2
    local offset_y = graphics_height/2 - map_height/2

    local screen_x = offset_x + (column - 1) * TILE_SIZE
    local screen_y = offset_y + (row - 1) * TILE_SIZE

    return screen_x, screen_y
end


function play_sound(name)
    local effect = sounds[name]
    local rnd_index = math.random(#effect.sources)
    local source = effect.sources[rnd_index]
    love.audio.play(source)
end

function tile_is_wall(column, row)
    return get_tile_at(column, row) == 'w'
end

function get_pumpkin_at(column, row)
    for i, pumpkin in ipairs(pumpkins) do
        if pumpkin.column == column and pumpkin.row == row then
            return pumpkin
        end
    end
end

function tile_is_vine(column, row)
    return get_tile_at(column, row) == 'v'
end

function find_map_coords(character)
    for row = 1, NUM_ROWS do
        for column = 1, NUM_COLS do
            if get_tile_at(column, row) == character then
                return column, row
            end
        end
    end
end

function key_to_direction(key)
    if key == "a" or key == "left" then
        return -1, 0
    elseif key == "d" or key == "right" then
        return 1, 0
    elseif key == "w" or key == "up" then
        return 0, -1
    elseif key == "s" or key == "down" then
        return 0, 1
    end
end

function player_can_move(move_x, move_y)
    local new_col = player_col + move_x
    local new_row = player_row + move_y

    if out_of_bounds(new_col, new_row) then
        return false
    end

    if tile_is_wall(new_col, new_row) then
        return false
    end

    if get_pumpkin_at(new_col, new_row) then
        local next_col = new_col + move_x
        local next_row = new_row + move_y
        if tile_blocks_pumpkin(next_col, next_row) then
            return false
        end
    end

    return true
end

function get_tile_at(column, row)
    local tile_index = column + (row - 1) * NUM_COLS
    return level_map[tile_index]
end

function out_of_bounds(column, row)
    if column < 1 or column > NUM_COLS then
        return true
    end

    if row < 1 or row > NUM_ROWS then
        return true
    end

    return false
end

function tile_blocks_pumpkin(column, row)
    if out_of_bounds(column, row) then
        return true
    end

    if tile_is_wall(column, row) then
        return true
    end

    if get_pumpkin_at(column, row) then
        return true
    end

    return false
end

function all_pumpkins_happy()
    for i, pumpkin in ipairs(pumpkins) do
        if not tile_is_vine(pumpkin.column, pumpkin.row) then
            return false
        end
    end
    return true
end

Polish#

Of course there are a lot of things that we could add from this point to make the game more complete and like a “real game”, but let’s take a look at a couple of simple things to polish up.

Game Over#

First off, as it stands, the player can keep interacting with the game even after they’ve won. Keep pressing keys and you’ll keep being congratulated, which is nice but a bit unusual. You can also push the pumpkins off their vines and un-win the game.

We can prevent this by keeping track of whether the game is over and preventing any further moves.

Create a variable called game_over at the top of the file to track whether the game has ended or not.

local game_over = false

And make a couple of changes to love.keypressed().

First check if game_over is true at the top of the function before we handle the keyboard input, so if the game is over, we just return without doing anything else.

Then when we determine the player has won, we can set game_over to true, ending the game.

function love.keypressed( key, scancode, isrepeat )
    print(string.format("love.keypressed %s, %s, %s", key, scancode, isrepeat))

    if game_over then
        return
    end

    -- ...

    if all_pumpkins_happy() then
        print(" player wins!")
        game_over = true
        play_sound("win")
    end
end

Restarting#

If the player wants to play again, or if they get into an unwinnable situation such as pushing a pumpkin into a corner, it would be nice to allow them to start over without fully exiting and re-launching the game.

We’ll take the code we had in love.load() that sets the position of the player and the pumpkins, and move that out into a separate function reset_game(). Then we can call reset_game() when the game loads, and also when the player wants to play again to put everything back the way it started.

This is another reason it’s useful to not modify the level_map directly, we still have all of the original map data to work with.

function love.load()
    img_player = love.graphics.newImage("images/ghost.png")
    img_ground = love.graphics.newImage("images/ground.png")
    img_wall = love.graphics.newImage("images/wall.png")
    img_pumpkin_sad = love.graphics.newImage("images/pumpkin_sad.png")
    img_pumpkin_happy = love.graphics.newImage("images/pumpkin_happy.png")
    img_vines = love.graphics.newImage("images/vines.png")

    for name, sound in pairs(sounds) do
        sound.sources = {}
        for i, filename in ipairs(sound.files) do
            local source = love.audio.newSource(filename, "static")
            table.insert(sound.sources, source)
        end
    end

    reset_game()
end
function reset_game()
    print("resetting game state")

    game_over = false
    pumpkins = {}

    local start_col, start_row = find_map_coords('g')
    if start_col and start_row then
        player_col, player_row = start_col, start_row
    else
        print("could not find player start position in level map!")
    end

    for row = 1, NUM_ROWS do
        for column = 1, NUM_COLS do
            if get_tile_at(column, row) == 'p' then
                local pumpkin = {}
                pumpkin.column = column
                pumpkin.row = row
                print(string.format("loading pumpkin at (%d, %d)", pumpkin.column, pumpkin.row))
                table.insert(pumpkins, pumpkin)
            end
        end
    end
end

Note that in addition to the code we moved from love.load(), we also set game_over to false in case the player had already won, and we’ll clear the pumpkins table by assigning it to a new empty table, which we then re-populate from the map.

Now we can also call reset_game() when the player presses the R key at any time to replay the level from the start.

function love.keypressed( key, scancode, isrepeat )
    print(string.format("love.keypressed %s, %s, %s", key, scancode, isrepeat))

    if key == "r" then
        reset_game()
        return
    end

    -- ...
end

Exiting#

We can also allow the player to exit the game at any time by pressing the Esc key, in addition to using the mouse to close the window (or using a system hotkey like Alt-F4).

function love.keypressed( key, scancode, isrepeat )
    print(string.format("love.keypressed %s, %s, %s", key, scancode, isrepeat))

    if key == "escape" then
        print("quitting!")
        love.event.quit()
        return
    end

    -- ...
end

And there we have it, a pretty functional Sokoban clone!

Code So Far
local TILE_SIZE = 64
local NUM_COLS = 10
local NUM_ROWS = 10

local player_col = 1
local player_row = 1

local game_over = false

local pumpkins = {}

local sounds = {
    footstep = {
        files = {
            "sounds/footstep1.ogg", 
            "sounds/footstep2.ogg", 
        }
    },
    blocked = {
        files = {
            "sounds/blocked1.ogg", 
            "sounds/blocked2.ogg", 
            "sounds/blocked3.ogg", 
        }
    },
    win = {
        files = {
            "sounds/congratulations.ogg", 
        }
    },
}

local level_map = { 
    ' ', 'w', 'w', 'w', 'w', 'w', ' ', ' ', ' ', ' ', 
    'w', 'w', ' ', ' ', 'v', 'w', 'w', 'w', 'w', 'w', 
    'w', ' ', ' ', 'g', ' ', ' ', ' ', ' ', ' ', 'w',
    'w', ' ', ' ', ' ', ' ', 'p', ' ', 'p', 'w', 'w', 
    'w', ' ', ' ', ' ', 'w', 'w', ' ', ' ', 'w', ' ', 
    'w', 'v', ' ', ' ', ' ', 'w', 'w', ' ', 'w', 'w', 
    'w', ' ', 'p', ' ', ' ', ' ', ' ', ' ', 'v', 'w', 
    'w', 'w', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'w', 
    ' ', 'w', 'w', ' ', ' ', 'w', 'w', 'w', ' ', 'w', 
    ' ', ' ', 'w', 'w', 'w', 'w', ' ', 'w', 'w', 'w', 
}

function love.load()
    img_player = love.graphics.newImage("images/ghost.png")
    img_ground = love.graphics.newImage("images/ground.png")
    img_wall = love.graphics.newImage("images/wall.png")
    img_pumpkin_sad = love.graphics.newImage("images/pumpkin_sad.png")
    img_pumpkin_happy = love.graphics.newImage("images/pumpkin_happy.png")
    img_vines = love.graphics.newImage("images/vines.png")

    for name, sound in pairs(sounds) do
        sound.sources = {}
        for i, filename in ipairs(sound.files) do
            local source = love.audio.newSource(filename, "static")
            table.insert(sound.sources, source)
        end
    end

    reset_game()
end

function reset_game()
    print("resetting game state")

    game_over = false
    pumpkins = {}

    local start_col, start_row = find_map_coords('g')
    if start_col and start_row then
        player_col, player_row = start_col, start_row
    else
        print("could not find player start position in level map!")
    end

    for row = 1, NUM_ROWS do
        for column = 1, NUM_COLS do
            if get_tile_at(column, row) == 'p' then
                local pumpkin = {}
                pumpkin.column = column
                pumpkin.row = row
                print(string.format("loading pumpkin at (%d, %d)", pumpkin.column, pumpkin.row))
                table.insert(pumpkins, pumpkin)
            end
        end
    end
end

function love.draw()
    love.graphics.clear(156/255, 172/255, 141/255)

    for row = 1, NUM_ROWS do
        for column = 1, NUM_COLS do
            local tile_x, tile_y = tile_to_screen(column, row)
            love.graphics.draw(img_ground, tile_x, tile_y)

            if tile_is_wall(column, row) then
                love.graphics.draw(img_wall, tile_x, tile_y)
            end

            if tile_is_vine(column, row) then
                love.graphics.draw(img_vines, tile_x, tile_y)
            end
        end
    end

    for i, pumpkin in ipairs(pumpkins) do
        local tile_x, tile_y = tile_to_screen(pumpkin.column, pumpkin.row)
        local pumpkin_img = img_pumpkin_sad
        if tile_is_vine(pumpkin.column, pumpkin.row) then
            pumpkin_img = img_pumpkin_happy
        end
        love.graphics.draw(pumpkin_img, tile_x, tile_y)
    end

    local player_x, player_y = tile_to_screen(player_col, player_row)
    love.graphics.draw(img_player, player_x, player_y)
end

function love.keypressed( key, scancode, isrepeat )
    print(string.format("love.keypressed %s, %s, %s", key, scancode, isrepeat))

    if key == "escape" then
        print("quitting!")
        love.event.quit()
        return
    end

    if key == "r" then
        reset_game()
        return
    end

    if game_over then
        return
    end

    local move_x, move_y = key_to_direction(key)
    if move_x and move_y then
        print(string.format(" player wants to move %d, %d", move_x, move_y))
        if player_can_move(move_x, move_y) then
            play_sound("footstep")
            player_col = player_col + move_x
            player_row = player_row + move_y

            local pumpkin = get_pumpkin_at(player_col, player_row)
            if pumpkin then
                pumpkin.column = pumpkin.column + move_x
                pumpkin.row = pumpkin.row + move_y
                print(string.format(" pushed pumpkin to %d, %d", pumpkin.column, pumpkin.row))
            end
        else
            play_sound("blocked")
            print(" blocked!")
        end
        print(string.format(" player pos %d, %d", player_col, player_row))
    end

    if all_pumpkins_happy() then
        print(" player wins!")
        game_over = true
        play_sound("win")
    end    
end

function tile_to_screen(column, row)
    local graphics_width, graphics_height = love.graphics.getDimensions()

    local map_width = NUM_COLS * TILE_SIZE
    local map_height = NUM_ROWS * TILE_SIZE

    local offset_x = graphics_width/2 - map_width/2
    local offset_y = graphics_height/2 - map_height/2

    local screen_x = offset_x + (column - 1) * TILE_SIZE
    local screen_y = offset_y + (row - 1) * TILE_SIZE

    return screen_x, screen_y
end


function play_sound(name)
    local effect = sounds[name]
    local rnd_index = math.random(#effect.sources)
    local source = effect.sources[rnd_index]
    love.audio.play(source)
end

function tile_is_wall(column, row)
    return get_tile_at(column, row) == 'w'
end

function get_pumpkin_at(column, row)
    for i, pumpkin in ipairs(pumpkins) do
        if pumpkin.column == column and pumpkin.row == row then
            return pumpkin
        end
    end
end

function tile_is_vine(column, row)
    return get_tile_at(column, row) == 'v'
end

function find_map_coords(character)
    for row = 1, NUM_ROWS do
        for column = 1, NUM_COLS do
            if get_tile_at(column, row) == character then
                return column, row
            end
        end
    end
end

function key_to_direction(key)
    if key == "a" or key == "left" then
        return -1, 0
    elseif key == "d" or key == "right" then
        return 1, 0
    elseif key == "w" or key == "up" then
        return 0, -1
    elseif key == "s" or key == "down" then
        return 0, 1
    end
end

function player_can_move(move_x, move_y)
    local new_col = player_col + move_x
    local new_row = player_row + move_y

    if out_of_bounds(new_col, new_row) then
        return false
    end

    if tile_is_wall(new_col, new_row) then
        return false
    end

    if get_pumpkin_at(new_col, new_row) then
        local next_col = new_col + move_x
        local next_row = new_row + move_y
        if tile_blocks_pumpkin(next_col, next_row) then
            return false
        end
    end

    return true
end

function get_tile_at(column, row)
    local tile_index = column + (row - 1) * NUM_COLS
    return level_map[tile_index]
end

function out_of_bounds(column, row)
    if column < 1 or column > NUM_COLS then
        return true
    end

    if row < 1 or row > NUM_ROWS then
        return true
    end

    return false
end

function tile_blocks_pumpkin(column, row)
    if out_of_bounds(column, row) then
        return true
    end

    if tile_is_wall(column, row) then
        return true
    end

    if get_pumpkin_at(column, row) then
        return true
    end

    return false
end

function all_pumpkins_happy()
    for i, pumpkin in ipairs(pumpkins) do
        if not tile_is_vine(pumpkin.column, pumpkin.row) then
            return false
        end
    end
    return true
end