Love2d Platformer Tutorial: Part 1 - The Basics

Posted in Tutorials

If you're new to programming, I highly suggest checking out my first tutorial Your First Love2d Game in 200 Lines. This tutorial will assume some programming knowledge. We'll start with moving that knowledge to game development in Love2d.

In part 1 we're going to make a small proof of concept. We'll build a little platformer with a player who can interact with platforms, move, and jump. In part 2 we'll abstract out that code and prepare to make our little protagonist into the big protagonist of a real game. We'll have plenty of time to talk about gamestates, building levels, animation, and more once our super simple platformer is done.

Let's start by making our basic Love engine functions:

function love.load() end function love.update(dt) end function love.keypressed(key) if key == "escape" then love.event.push("quit") end end function love.draw(dt) end

One of the first things I do when starting a new Love project is to add that love.keypressed function that listens for "escape." I don't have time to be exiting out in other ways. When our game is done we'll change that to be a pause button.

Next, let's think about our minimal platformer game. What is the least amount of work we can do to get a working demo? We call this the minimal viable product in the more professional development industry. You might be thinking, "well we need bosses and levels and score keeping and bad guys." Think simpler! A platformer needs a player character and platforms. That's it. Everything else, bosses and enemies, is just an expansion of that. We'll make a plan for dealing with all of that in the next part.

Let's go ahead and define a stub player above those functions.

-- Setup a player object to hold an image and attach a collision object player = { x = 16, y = 16, -- Here are some incidental storage areas img = nil -- store the sprite we'll be drawing }

So far, we've given the player a location, but we know from playing platformers that there are certain ways the player character interacts with the world. The player should be able to move backwards and forwards. Unlike my side-scrolling tutorial, the player doesn't move in set intervals. The player character needs to accelerate in a direction up to a given maximum and slow-down when the player stops giving the command. In other words, the player needs a velocity and friction with the ground. Let's store these values. Replace the player code block with:

player = { x = 16, y = 16, -- The first set of values are for our rudimentary physics system xVelocity = 0, -- current velocity on x, y axes yVelocity = 0, acc = 100, -- the acceleration of our player maxSpeed = 600, -- the top speed friction = 20, -- slow our player down - we could toggle this situationally to create icy or slick platforms -- Here are some incidental storage areas img = nil -- store the sprite we'll be drawing }

We'll also need to jump. This is a more complex version of above. We'll set a jump acceleration as well as some maximums on how our character jumps. Finally, if we're jumping, we'll need gravity.

-- The finished player object player = { x = 16, y = 16, -- The first set of values are for our rudimentary physics system xVelocity = 0, -- current velocity on x, y axes yVelocity = 0, acc = 100, -- the acceleration of our player maxSpeed = 600, -- the top speed friction = 20, -- slow our player down - we could toggle this situationally to create icy or slick platforms gravity = 80, -- we will accelerate towards the bottom -- These are values applying specifically to jumping isJumping = false, -- are we in the process of jumping? isGrounded = false, -- are we on the ground? hasReachedMax = false, -- is this as high as we can go? jumpAcc = 500, -- how fast do we accelerate towards the top jumpMaxSpeed = 9.5, -- our speed limit while jumping -- Here are some incidental storage areas img = nil -- store the sprite we'll be drawing }

Go ahead and finish our player out by adding player.img = love.graphics.newImage('assets/character_block.png') to the love.load function and love.graphics.draw(player.img, player.x, player.y) to love.draw.

If everything went as planned you should see a little rectangle on a black screen when you run the application.

The Player Character has a lot of variables to take in, but they'll make more sense as we use them. First, let's go ahead and write in our movement system. It's quite a bit more complex than the code in our scrollling shooter. We're rolling in some very rudimentary physics. The player will have a velocity and be acted on by gravity and friction.

Replace love.update with this code block:

function love.update(dt) player.x = player.x + player.xVelocity player.y = player.y + player.yVelocity -- Apply Friction player.xVelocity = player.xVelocity * (1 - math.min(dt * player.friction, 1)) player.yVelocity = player.yVelocity * (1 - math.min(dt * player.friction, 1)) -- Apply gravity player.yVelocity = player.yVelocity + player.gravity * dt if love.keyboard.isDown("left", "a") and player.xVelocity > -player.maxSpeed then player.xVelocity = player.xVelocity - player.acc * dt elseif love.keyboard.isDown("right", "d") and player.xVelocity < player.maxSpeed then player.xVelocity = player.xVelocity + player.acc * dt end -- The Jump code gets a lttle bit crazy. Bare with me. if love.keyboard.isDown("up", "w") then if -player.yVelocity < player.jumpMaxSpeed and not player.hasReachedMax then player.yVelocity = player.yVelocity - player.jumpAcc * dt elseif math.abs(player.yVelocity) > player.jumpMaxSpeed then player.hasReachedMax = true end player.isGrounded = false -- we are no longer in contact with the ground end end

Let's walk through the code. There is a lot to see here so brace yourself. First, we move the player x, y position to their old position plus their velocity on the given axis.

player.x = player.x + player.xVelocity player.y = player.y + player.yVelocity

It's odd that we're doing this first. Normally we'd process the input first then add the velocity results. However, when we write the code for collision handling we'll want collisions handled immediately to avoid shudders.

Next we add a small amount of decceleration and call it friction.

player.xVelocity = player.xVelocity * (1 - math.min(dt * player.friction, 1)) player.yVelocity = player.yVelocity * (1 - math.min(dt * player.friction, 1))

friction was set in our player variables. All we are doing here is multiplying the current velocity by a number between 0 and 1. math.min just chooses the smaller of dt * player.friction and 1. In the real-world the y axis doesn't have a whole lot of friction, but it ends up feeling better than turning the gravity down.

Gravity is just a constant downward acceleration. It will work the same way as our left / right movement code which looks like this:

if love.keyboard.isDown("left", "a") and player.xVelocity > -player.maxSpeed then player.xVelocity = player.xVelocity - player.acc * dt elseif love.keyboard.isDown("right", "d") and player.xVelocity < player.maxSpeed then player.xVelocity = player.xVelocity + player.acc * dt end

Pay attention to the signs here. For left we subtract and for right we add.

One of the fundamental elements of a platformer game is the player characters ability to interact with platforms. Obviously.

We have a few options here. We could use Love2d's built in Box2D physics engine. We could use a very simple rectangular collision check function like we did in the side scrolling tutorial. Or we could find a library mid-way inbetween.

Box2D feels like overkill. We don't need realistic physics. We need "game-y" physics. And a simple function isn't going to do enough when we have many collisions happening at once.

Instead, let's load up a library called bump.lua. You'll want to download the entire project then unzip it into /libs. It should look like this:

We include the library by adding bump = require 'libs.bump.bump' to the very top of the file. Next, we'll stub out a world and a couple of ground objects just below the require. The top of our file should now look like this:

world = nil -- storage place for bump ground_0 = {} ground_1 = {}

In bump our collidables are stored in a "world" which we create in our load event. Objects that have collisions are then inserted into the world. When we want to move an object we move it relative to the world we created so that bump can process our collisions for us.

We declare this world right at the top of love.load. Our tiles will be 16 pixels wide and tall so we pass that into bump.newWorld which defaults to 64. After we load in our player object we'll add objects to our world so that our final love.load looks like this:

function love.load() -- Setup bump world = bump.newWorld(16) -- 16 is our tile size -- Create our player. player.img = love.graphics.newImage('assets/character_block.png') world:add(player, player.x, player.y, player.img:getWidth(), player.img:getHeight()) -- Draw a level world:add(ground_0, 120, 360, 640, 16) world:add(ground_1, 0, 448, 640, 32) end

Let's add our ground to love.draw so we can see it. Our final love.draw function will look like this:

function love.draw(dt) love.graphics.draw(player.img, player.x, player.y) love.graphics.rectangle('fill', world:getRect(ground_0)) love.graphics.rectangle('fill', world:getRect(ground_1)) end

We only need a couple more lines to start processing collisions. Our process will be to calculate where we want to go, a goalX and goalY, then attempt to move there. bump.lua will process our collisions and return the actual x and y of what was moved.

Let's change:

player.x = player.x + player.xVelocity player.y = player.y + player.yVelocity

to

local goalX = player.x + player.xVelocity local goalY = player.y + player.yVelocity

Then add our movement to the player object. world:move returns the actual x and y coordinates after stopping our moving objects and a list of collisions that occurred.

player.x, player.y, collisions = world:move(player, goalX, goalY)

If we run the code now we'll see our little player character fall from the sky and stop when it hits the white platform. It should look a little like this:

This is actually complete and ready to go, but we will eventually need some collision filtering. For instance, we want our character to pass through the bottom of platforms but get stopped by landing on them. So we'll add a filter that looks like this:

player.filter = function(item, other) local x, y, w, h = world:getRect(other) local px, py, pw, ph = world:getRect(item) local playerBottom = py + ph local otherBottom = y + h if playerBottom <= y then return 'slide' end end

This function takes the item colliding, the player in this case, and what it is colliding with. From there we use a bump.lua world function world:getRect(item) to return the coordinates of the object at the time of collision. If the bottom of our player is above (less than) the top of the platform we are colliding with the platform from above. We return 'slide' as the type of collision we'd like to see. You can read about collision types in bump.lua here. If we aren't above the platform we return an implicit nil value that means no collision is being processed.

Next we run our world:move passing in player.filter as our last argument. player.x, player.y, collisions, len = world:move(player, goalX, goalY, player.filter)

Finally, we'll loop through the returned collisions and react appropriately:

for i, coll in ipairs(collisions) do if coll.touch.y > goalY then player.hasReachedMax = true player.isGrounded = false elseif coll.normal.y < 0 then player.hasReachedMax = false player.isGrounded = true end end

All we're doing is allowing the player to jump again once the character touches the ground.

That's all there is for this bit. By now main.lua is looking a little cluttered. In part two we'll do some behind the scenes plumbing to make our game development quicker and cleaner.

In this series:

  1. Part 1 - The Basics
  2. Part 2 - Plumbing a Game
  3. Part 3 - Creating and Loading Levels (Coming Soon!)

Source Code

In this series:

  1. Part 1 - The Basics
  2. Part 2 - Plumbing a Game
  3. Part 3 - Creating and Loading Levels (Coming Soon!)