Game: Pumpkin Push

Game: Pumpkin Push #

This page is a work in progress.

Pumpkin Push will be a block pushing puzzle game based on the Japanese hit Sokoban. First released in 1982, the game is still available today on a variety of platforms.

In Pumpkin Push the player is a ghost trying to organize their pumpkin patch. The environment is a farm field divided into a grid, and the player is tasked with pushing all of the pumpkins into their righful places marked with vines. Pumpkins can only be pushed, not pulled, and only one pumpkin can be pushed at a time… multiple pumpkins in a row are just too heavy, can’t be done.

Create Game Files #

First we need to set up a new game directory and a few files to start with.

If you want, you can just make a copy of the hello-love2d directory and rename it to pumpkin-push. You can start with the copies of main.lua and run.bat that come with it.

Create a new directory for the project called pumpkin-push inside C:\learn-lua\source\, and create the following files:

run.bat to easily launch the game:

@"C:\Program Files\LOVE\love.exe" --console .

… or …

@..\..\bin\love\love.exe --console .

…depending on where you installed love.exe.

main.lua which can start with a simple love.draw() to clear the background colour:

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

In addition to the main.lua script, Love2D games usually have a script used to configure the game engine named conf.lua.

conf.lua is run before when the game is loaded, before main.lua and before the window is created by the engine, allowing you to customize the appearance of the window, as well as a variety of other engine related settings:

function love.conf(t)
    t.window.title = "Pumpkin Push"
end

Love calls the function love.conf and passes in a table t which contains several sub-tables, one for each of the various aspects of the engine, such as window, mouse, keyboard, etc. We’ll start with just setting the window title to something more interesting than Untitled.

Running the game should display our blank – but appropriately titled – game window:

Game window titled “Pumpkin Push”, filled in with a solid light green.

Drawing Images #

Image Assets #

First things first, if we want to draw some images on the screen, we’ll need some image files.

In your pumpkin-push game folder (the same folder that contains main.lua), create a directory called images, and save the images below into that new images folder, either right click and choose Save Image As…, or drag and drop each image file into the folder.

a simple cartoon ghost wearing a straw hat and carrying a shovel ghost.png

a dark brown tile with some dark and light patches. It’s supposed to be dirt, ok?! ground.png

I created these files using the free programs Inkscape and Paint.NET. You can use these images as-is, edit them, or create your own using whatever software you want. Just make sure to save them as 64 x 64 pixel PNG files to stay consistent with the rest of the tutorial, otherwise you’ll need to update the code accordingly to match the size of the images.

Loading And Drawing #

Before we can draw an image file, we’ll need to load it and store the result in a variable. A good place to do this kind of task is in the love.load() callback, which as you might guess, is called once at the start of your game to allow you to load the assets that you’ll need to render the game.

We’ll call love.graphics.newImage() to load the image from the file "images/ghost.png" (note that the path to this file is relative to the game directory, where the file main.lua is) and store the result in a global for now called img_player

Then when love.draw() is called to render each frame, we call love.graphics.draw() to draw the loaded image.

function love.load()
    img_player = love.graphics.newImage("images/ghost.png")
end

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

    love.graphics.draw(img_player, 0, 0)
end

Game window filled in with light green, image of the ghost farmer drawn in the top left

When calling love.graphics.draw() we’re passing in the coordinates 0, 0 which are the x, y coordinates of the top left corner of the window.

Even though the image we are drawing is a rectangle, we can see the background colour around the coloured pixels of the character because the .png we’re using has an alpha channel which marks the background pixels of the image as being transparent.

Also note that the image has been drawn with its upper left corner positioned at the coordinates (0, 0) we specified, which will be important to take into consideration whenever we need to decide what coordinates to use in order to position images around the screen.

We can load and draw a ground tile in the same way:

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

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

    love.graphics.draw(img_ground, 0, 0)
    love.graphics.draw(img_player, 0, 0)
end

The order we draw the images in matters. Since we want the player to show up on top of the ground image, we need to draw the ground image first, and then draw the player image over top of it.

Game window with the ghost farmer in the top left, drawn on top of a single dirt tile

One thing you never want to do is load any images or other assets inside callbacks like love.draw(). love.draw() runs every time a new frame is drawn for the game, eg. 60 times per second. While it’s technically possible on modern computers to load one or two small images from disk every single frame, this is a terrible waste of resources, and will quickly become impractical as the number and size of data increases.

This is what love.load() is for, it’s called once when the game starts giving us a chance to load all of the files we need.

Tiling Images #

We going to want more a single ground tile to give the player some room to move around and position other objects in the world.

Let’s start by drawing a row of 10 tiles. The image we’re using is 64 x 64 pixels, so to find the x coordinate for each tile, we can multiply the column number by 64. Each tile we draw will be 64 pixels farther to the right than the previous one.

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

    for column = 1, 10 do
        local tile_x = column * 64
        local tile_y = 0
        love.graphics.draw(img_ground, tile_x, tile_y)
    end

    love.graphics.draw(img_player, 0, 0)
end

Game window with the ghost in the top corner, and a row of dirt images tiled to the right of them

This isn’t quite right though… our current calculation will give us the x coordinates 64, 128, 192, 256, … but we want the first coordinate to be 0 to draw in the same position as the player.

To make the math work out, we can subtract 1 from the column number when calculating the x coordinate. That way, when we multiply by the image width, the first result will be 0, then 64, and so on.

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

    for column = 1, 10 do
        local tile_x = (column - 1) * 64
        local tile_y = 0
        love.graphics.draw(img_ground, tile_x, tile_y)
    end

    love.graphics.draw(img_player, 0, 0)
end

Game window with the ghost in the top corner, the row of tiled dirt images has been shifted one space to the left to include the first tile occupied by the ghost

Now that we’ve got one row drawn, we can easily expand this to the full 10 rows by nesting the current for loop inside another one.

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

    for row = 1, 10 do
        for column = 1, 10 do
            local tile_x = (column - 1) * 64
            local tile_y = (row - 1) * 64
            love.graphics.draw(img_ground, tile_x, tile_y)
        end
    end

    love.graphics.draw(img_player, 0, 0)
end

We loop over the 10 rows top to bottom, and for each row we loop over 10 columns left to right.

In addition to calculating the x coordinate from the column index, we are now also calculating the y coordinate from the row index using the same formula, this time representing a distance from the top of the screen.

Ghost in the top left corner, and a 10 by 10 grid of ground tiles, cut off at the bottom

Changing The Window Size #

The default Love2D window size is 800 x 600 pixels and our grid of ground tiles doesn’t quite fit, it’s getting cut off at the bottom. Let’s increase the size of our window a bit by going over to conf.lua and setting the window width and height to 1024 x 768 pixels.

function love.conf(t)
    t.window.title = "Pumpkin Push"
    t.window.width = 1024
    t.window.height = 768
end

Ghost in the top left corner, and a 10 by 10 grid of ground tiles with a bit of extra space below the bottom row

Now we’ve got a bit more breathing room to work with. That extra space will be useful later on to display level information, the player’s score and whatnot.

If you’re wondering where these specific numbers came from, they are based on the SVGA (800 x 600) and XGA (1024 x 768) graphics resolutions respectively. Since our game is running in a window, it doesn’t actually need to fit any particular resolution and we can pretty much just set the width and height to any number you want (within reason); however, since our tiles are 64 x 64 pixels, using a resolution like 1024 x 768, lets those tiles fit evenly into the window, 1024 / 64 is 16 tiles across, and 768 / 64 is 12 tiles high.

Player Movement #

The player character will be controlled with the keyboard. We’ll need a couple of variables to keep track of the position of the player on in the game world, and then handle the keyboard events that let us know a key has been pressed.

Screen Coordinates #

To start with, let’s declare a couple of variables to keep track of the player’s position on the grid, and start the player off in the top left corner (column 1, row 1).

local player_col = 1
local player_row = 1

But when we want to draw the player we use love.graphics.draw() which takes screen coordinates in pixels. We can use the same formula we used for drawing all of the ground tiles.

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

    for row = 1, 10 do
        for column = 1, 10 do
            local tile_x = (column - 1) * 64
            local tile_y = (row - 1) * 64
            love.graphics.draw(img_ground, tile_x, tile_y)
        end
    end

    local player_x = (player_col - 1) * 64
    local player_y = (player_row - 1) * 64
    love.graphics.draw(img_player, player_x, player_y)
end

To quickly check this is working as expected, try changing the position that the player starts in, and see where it draws on screen:

local player_col = 3
local player_row = 5

Ghost character is now standing 3 tiles to the right, and 5 tiles down from the top

At this point, we can see that we’re repeating the same calculation several times over, when we convert from tile rows and columns to screen coordinates to draw the tiles, and to convert from the player position to screen coordinates. There will be other places we’ll need to repeat this same pattern, so let’s move this code into a helper function that we can use whenever we need it.

function tile_to_screen(column, row)
    local screen_x = (column - 1) * 64
    local screen_y = (row - 1) * 64
    return screen_x, screen_y
end

We pass a column and row into tile_to_screen(), and it returns both the x and y pixel coordinates where we want to draw that tile on the screen. Now we can change our drawing code in love.draw() to use this helper function, instead of repeating the same formula over and over.

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

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

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

Event Handling #

Now let’s handle the key pressed event so that we can move the player character in response. To do this, we add a new event handler, a function called love.keypressed(). As with love.draw(), love.keypressed() is a predefined function name that the Love2D engine knows about which the engine will call for us when the player presses a key on their keyboard.

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

After defining this function, when you run the game and press some keys, you should see the messages we’re printing to the Love2D console window.

Console window behind the game window showing a list of logged key pressed messages

Looking at the console output we can see that each key press is represented by its lowercase letter, or a string name in the case of “left”, “right”, “space”, “escape”, etc…

love.keypressed a, a, false
love.keypressed s, s, false
love.keypressed d, d, false
love.keypressed left, left, false
love.keypressed up, up, false
love.keypressed right, right, false
love.keypressed down, down, false
love.keypressed space, space, false
love.keypressed escape, escape, false

Chances are the key and scancode parameters will have the same value for each key press, but if you’re using a non-English keyboard, or have a different keyboard layout configured for your OS, then key and scancode might not match.

We’ll let the player move the character using either the arrow keys, or the common WASD movement keys. So if the key pressed is "a" or "left" we’ll want to move the character one spot to the left, "d" or "right" will move to the right and so on.

To double check that the code is running when we expect, and doing the things we expect, we can print out messages when we try to move the player right or left, and then print out the final player position after each key press.

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

    if key == "a" or key == "left" then
        print(" move left")
        player_col = player_col - 1
    elseif key == "d" or key == "right" then
        print(" move right")
        player_col = player_col + 1
    end

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

Console key pressed messages now say things like “move right”, and the player position. In the game window, the ghost character is off the right side of the dirt tile grid and is standing in empty green filled space

We’re able to move the character left and right, but we’re also able to keep moving past the edges of the board, or entirely off the screen. We’ll need to add some checks to make sure that the player’s position doesn’t exceed the number of tiles on the board.

Level Boundaries #

First, let’s create some variables to hold the number of columns and rows in the game:

local NUM_COLS = 10
local NUM_ROWS = 10

Then before we move the player in response to a key press event, we’ll check whether the result would stay within the bounds of the board before actually moving the player, otherwise we ignore the key press.

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
        end
    elseif key == "d" or key == "right" then
        if player_col < NUM_COLS then
            print(" move right")
            player_col = player_col + 1
        end
    end

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

While we’re at it, now might be a good time to update the love.draw() function to use the same NUM_COLS and NUM_ROWS variables, instead of hard coding the values of 10.

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)
        end
    end

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

Now it’s possible to change the number of rows and columns in the game in one place, and the rest of the code will continue to work accordingly.

And let’s finish up by adding up and down movement as well…

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
        end
    elseif key == "d" or key == "right" then
        if player_col < NUM_COLS then
            print(" move right")
            player_col = player_col + 1
        end
    elseif key == "w" or key == "up" then
        if player_row > 1 then
            print(" move up")
            player_row = player_row - 1
        end
    elseif key == "s" or key == "down" then
        if player_row < NUM_ROWS then
            print(" move down")
            player_row = player_row + 1
        end
    end

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

The player should now be able to move around the entire board, but not outside of it.

The Pumpkin Push game window so far. A 10 by 10 grid of dirt tiles, the ghost player character is standing slightly up and to the right of center.

Code So far

conf.lua

function love.conf(t)
    t.window.title = "Pumpkin Push"
    t.window.width = 1024
    t.window.height = 768
end

main.lua

local NUM_COLS = 10
local NUM_ROWS = 10

local player_col = 3
local player_row = 5

function love.load()
    img_player = love.graphics.newImage("images/ghost.png")
    img_ground = love.graphics.newImage("images/ground.png")
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)
        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
        end
    elseif key == "d" or key == "right" then
        if player_col < NUM_COLS then
            print(" move right")
            player_col = player_col + 1
        end
    elseif key == "w" or key == "up" then
        if player_row > 1 then
            print(" move up")
            player_row = player_row - 1
        end
    elseif key == "s" or key == "down" then
        if player_row < NUM_ROWS then
            print(" move down")
            player_row = player_row + 1
        end
    end

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

function tile_to_screen(column, row)
    local screen_x = (column - 1) * 64
    local screen_y = (row - 1) * 64
    return screen_x, screen_y
end

2024-10-03

This page is a work in progress. The following sections are coming soon.

Sound Effects #

Level Map #

Walls #

Pushing Pumpkins #

Win Condition #