Game: Pumpkin Push

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

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.

Level Map #

Walls #

Pushing Pumpkins #

Win Condition #