Game: Pumpkin Push #
This chapter 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 thehello-love2d
directory and rename it topumpkin-push
. You can start with the copies ofmain.lua
andrun.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:
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.
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
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.
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
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
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.
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
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
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.
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
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.
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
Sound Effects #
Now that we’ve got our little ghost walking around, let’s get a couple of basic sound effects in, and we can add more later as we go.
We’ll start off with a footstep sound when the player moves, and a crunch sound if the player tries to move somewhere that they can’t, such as off the edge of the board.
Wait… this ghost doesn’t appear to have any feet? Look, don’t worry about it… artistic license and all that.
In your pumpkin-push
game folder (the same folder that contains the images
folder), create another directory called sounds
, and save the files below into that new sounds
folder (eg. by using right click and choose Save Link As…)
These sound files come from Kenny Game Assets “Retro Sounds 2” and “RPG Audio” packs, though I’ve renamed them to match how they’re used in this game.
Like with images, we need to load the sound files first by calling love.audio.newSource()
to create a new audio source that we can play. love.load()
is a good place to do that.
function love.load()
img_player = love.graphics.newImage("images/ghost.png")
img_ground = love.graphics.newImage("images/ground.png")
snd_footstep = love.audio.newSource("sounds/footstep1.ogg", "static")
snd_blocked = love.audio.newSource("sounds/blocked1.ogg", "static")
end
Then we can play the appropriate sound (or audio source) when the player tries to move:
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
love.audio.play(snd_footstep)
else
love.audio.play(snd_blocked)
end
elseif key == "d" or key == "right" then
if player_col < NUM_COLS then
print(" move right")
player_col = player_col + 1
love.audio.play(snd_footstep)
else
love.audio.play(snd_blocked)
end
elseif key == "w" or key == "up" then
if player_row > 1 then
print(" move up")
player_row = player_row - 1
love.audio.play(snd_footstep)
else
love.audio.play(snd_blocked)
end
elseif key == "s" or key == "down" then
if player_row < NUM_ROWS then
print(" move down")
player_row = player_row + 1
love.audio.play(snd_footstep)
else
love.audio.play(snd_blocked)
end
end
print(string.format(" player pos %d, %d", player_col, player_row))
end
Try that out by moving around the board, and trying to move off the edges which should play the blocked sound.
That’s all there is to basic sound effects!
Or is it…
Organizing Sounds #
We’ve got some basic sounds going, but there are a few things that could use improvement.
Right now we only have a single variation of each sound, which can get monotonous while the player is playing, hearing just the same sound over and over, so it would be nice to add a couple of different versions of each sound that we can choose to play at random, to add a bit of variety.
If we write some logic to play random sounds each time the player moves, we’re not going to want to duplicate that code for each of the directions that the player is going to move, so we’ll move it into a helper function, instead of directly calling love.audio.play
on a specific sound source.
To start with, we can create a table to hold all of our sound effects, and the lists of sound files for each one:
local sounds = {
footstep = {
files = {
"sounds/footstep1.ogg",
"sounds/footstep2.ogg",
}
},
blocked = {
files = {
"sounds/blocked1.ogg",
"sounds/blocked2.ogg",
"sounds/blocked3.ogg",
}
}
}
The sounds
table contains two keys for now, one for each sound effect, each effect containing its own table for data related to that specific sound effect. To start with, each sound effect contains a files
array which holds the file names of the different variations of that sound effect.
Next we loop through the sound tables in love.load()
and load each of the sound files.
function love.load()
img_player = love.graphics.newImage("images/ghost.png")
img_ground = love.graphics.newImage("images/ground.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
end
First we use pairs()
to loop through the top level table.
We’ll add a new empty table called sources
to each of the sound tables, and then use ipairs()
to loop through each of the sound files listed in the sound’s files
array, load it with love.audio.newSource
, and insert the newly loaded sound source at the end of the sources
array.
Now we need an easy way to play each sound effect, so let’s create a helper function:
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
Our play_sound()
function takes the sound effect name and looks it up from the sounds
table, then chooses one of the audio sources from the sources
table, and plays that source using love.audio.play()
.
Finally, we can call our helper function from each of the locations we need to play a sound effect:
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
We can easily add as many variations of each sound effect as we’d like, just by adding a new entry into that sound’s files
array. It’s also okay to have only a single entry, it will just “randomly” choose from the one available sound source each time.
By organizing our sounds this way, it also makes it easy to add other kinds of variations as well. A common case is different sets of footsteps depending on what sort of surface your character is walking on, wood, metal, grass, etc…
The calling code simply needs to say “play me a footstep!”, and the helper function could choose from different sets of footstep audio sources based on the ground surface.
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
local sounds = {
footstep = {
files = {
"sounds/footstep1.ogg",
"sounds/footstep2.ogg",
}
},
blocked = {
files = {
"sounds/blocked1.ogg",
"sounds/blocked2.ogg",
"sounds/blocked3.ogg",
}
}
}
function love.load()
img_player = love.graphics.newImage("images/ghost.png")
img_ground = love.graphics.newImage("images/ground.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
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
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 screen_x = (column - 1) * 64
local screen_y = (row - 1) * 64
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
To Be Continued… #
2024-10-23
This chapter is a work in progress. The following sections are coming soon…er or later.