Advertisement
  1. Game Development
  2. Platformer
Gamedevelopment

Basic 2D Platformer Physics, Part 2

by
Difficulty:IntermediateLength:LongLanguages:
This post is part of a series called Basic 2D Platformer Physics .
Basic 2D Platformer Physics, Part 1
Basic 2D Platformer Physics, Part 3

Level Geometry

There are two basic approaches to building platformer levels. One of them is to use a grid and place the appropriate tiles in cells, and the other is a more free-form one, in which you can loosely place level geometry however and wherever you want. 

There are pros to cons to both approaches. We'll be using the grid, so let's see what kind of pros it has over the other method:

  • Better performance—collision detection against the grid is cheaper than against loosely placed objects in most cases.
  • Makes it much easier to handle pathfinding.
  • Tiles are more accurate and predictable than loosely placed objects, especially when considering things like destructible terrain.

Building a Map Class

Let's start by creating a Map class. It will hold all of the map specific data.

Now we need to define all the tiles that the map contains, but before we do that, we need to know what tile types exist in our game. For now, we're planning on only three: an empty tile, a solid tile, and a one-way platform.

In the demo, tile types correspond directly to the type of collision we'd like to have with a tile, but in a real game that's not necessarily so. As you have more visually different tiles, it would be better to add new types such as GrassBlock, GrassOneWay and so on, to let the TileType enum define not only the collision type but also the appearance of the tile.

Now in the map class we can add an array of tiles.

Of course, a tilemap that we can't see is not of much use to us, so we also need sprites to back up the tile data. Normally in Unity it is extremely inefficient to have each tile be a separate object, but since we're just using this to test our physics, it's OK to make it this way in the demo.

The map also needs a position in the world space, so that if we need to have more than just a single one, we can move them apart.

Width and height, in tiles.

And the tile size: in the demo we'll be working with a fairly small tile size, which is 16 by 16 pixels.

That would be it. Now we need a couple of helper functions to let us access the map's data easily. Let's start by making a function which will convert world coordinates to the map's tile coordinates.

As you can see, this function takes a Vector2 as a parameter and returns a Vector2i, which is basically a 2D vector operating on integers instead of floats.

Converting the world position to the map position is very straightforward—we simply need to shift the point by mPosition so we return the tile relative to the map's position and then divide the result by the tile size.

Note that we had to shift the point additionally by cTileSize / 2.0f, because the tile's pivot is in its center. Let's also make two additional functions which will return only the X and Y component of the position in the map space. It'll be useful later.

We also should create a complementary function which, given a tile, will return its position in the world space.

Aside from translating positions, we also need to have a couple of functions to see whether a tile at a certain position is empty, is a solid tile or is a one-way platform. Let's start with a very generic GetTile function, which will return a type of a specific tile.

As you can see, before we return the tile type, we check if the given position is out of bounds. If it is, then we want to treat it as a solid block, otherwise we return a true type.

The next in queue is a function to check whether a tile is an obstacle. 

In the same way as before, we check if the tile is out of bounds, and if it is then we return true, so any tile out of bounds is treated as an obstacle.

Now let's check whether the tile is a ground tile. We can stand on both a block and a one-way platform, so we need to return true if the tile is any of these two.

Finally, let's add IsOneWayPlatform and IsEmpty functions in the same manner.

That's all that we need our map class to do. Now we can move on and implement the character collision against it.

Character-Map Collision

Let's go back to the MovingObject class. We need to create a couple of functions which will detect whether the character is colliding with the tilemap.

The method by which we'll know whether the character collides with a tile or not is very simple. We'll be checking all the tiles that exist right outside the moving object's AABB.


The yellow box represents the character's AABB, and we'll be checking the tiles along the red lines. If any of those overlap with a tile, we set a corresponding collision variable to true (such as mOnGround, mPushesLeftWall, mAtCeiling or mPushesRightWall).

Let's start by creating a function HasGround, which will check if the character collides with a ground tile. 

This function returns true if Character overlaps with any of the bottom tiles. It takes the old position, the current position and the current speed as parameters, and also returns the Y position of the top of the tile we are colliding with and whether the collided tile is a one-way platform or not.

The first thing we want to do is to calculate the center of AABB.

Now that we've got that, for the bottom collision check we'll need to calculate the beginning and end of the bottom sensor line. The sensor line is just one pixel below the AABB's bottom contour.

The bottomLeft and bottomRight represent the two ends of the sensor. Now that we've got these, we can calculate which tiles we need to check. Let's start by creating a loop in which we'll be going through the tiles from the left to the right.

Note that there's no condition to exit the loop here—we'll do that at the end of the loop. 

The first thing we should do in the loop is to make sure that the checkedTile.x is not greater than the right end of the sensor. This might be the case because we move the checked point by multiples of the tile size, so for example, if the character is 1.5 tiles wide, we need to check the tile on the left edge of the sensor, then one tile to the right, and then 1.5 tiles to the right instead of 2.

Now we need to get the tile coordinate in the map space to be able to check the tile's type.

First, let's calculate the tile's top position.

Now, if the currently checked tile is an obstacle, we can easily return true.

Finally, let's check whether we already looked through all the tiles that intersect with the sensor. If that's the case, then we can safely exit the loop. After we exit the loop not finding a tile we collided with, we need to return false to let the caller know that there is no ground below the object.

That's the most basic version of the check. Let's try to get it to work now. Back in the UpdatePhysics function, our old ground check looks like this.

Let's replace it using the newly created method. If the character is falling down and we have found an obstacle on our way, then we need to move it out of the collision and also set the mOnGround to true. Let's start with the condition.

If the condition is fulfilled then we need to move the character on the top of the tile we collided with.

As you can see, it's very simple because the function returns the ground level to which we should align the object. After this, we only need to set the vertical speed to zero and set mOnGround to true.

If our vertical speed is greater than zero or we're not touching any ground, we need to set the mOnGround to false.

Now let's see how this works.

As you can see, it works well! The collision detection for the walls on both sides and at the top of the character are still not there, but the character stops each time it meets the ground. We still need to put a little more work into the collision-checking function to make it robust.

One of the issues we need to solve is visible if the character's offset from one frame to the other is too big to detect the collision properly. This is illustrated in the following picture.

This situation does not happen now because we locked the maximum falling speed to a reasonable value and update the physics with 60 FPS frequency, so the differences in positions between the frames are rather small. Let's see what happens if we update the physics only 30 times per second. 

As you can see, in this scenario our ground collision check fails us. To fix this, we can't simply check if the character has ground beneath him at the current position, but we rather need to see whether there were any obstacles along the way from the previous frame's position.

Let's go back to our HasGround function. Here, besides calculating the center, we'll also want to calculate the previous frame's center.

We'll also need to get the previous frame's sensor position.

Now we need to calculate at which tile vertically we are going to start checking whether there is a collision or not, and at which we will stop.

We start the search from the tile at the previous frame's sensor position, and end it at the current frame's sensor position. That's of course because when we check for a ground collision we assume we are falling down, and that means we're moving from the higher position to the lower one.

Finally, we need to have another iteration loop. Now, before we fill the code for this outer loop, let's consider the following scenario.

Here you can see an arrow moving fast. This example shows that we need not only to iterate through all the tiles we would need to pass vertically, but also to interpolate the object's position for each tile we go through to approximate the path from the previous frame's position to the current one. If we simply kept using the current object's position, then in the above case a collision would be detected, even though it shouldn't be.

Let's rename the bottomLeft and bottomRight as newBottomLeft and newBottomRight, so we know that these are the new frame's sensor positions.

Now, within this new loop, let's interpolate the sensor positions, so that at the beginning of the loop we're assuming the sensor to be at the previous frame's position, and at its end it's going to be in the current frame's position.

Note that we interpolate the vectors based on the difference in tiles on the Y axis. When old and new positions are within the same tile, the vertical distance will be zero, so in that case we wouldn't be able to divide by the distance. So to solve this issue, we want the distance to have a minimum value of 1, so that if such a scenario were to happen (and it's going to happen very often), we'll simply be using the new position for collision detection. 

Finally, for each iteration, we need to execute the same code we did already for checking the ground collision along the width of the object. 

That's pretty much it. As you may imagine, if the game's objects move really fast, this way of checking collision can be quite a bit more expensive, but it also reassures us that there will be no weird glitches with objects moving through solid walls.

Summary

Phew, that was more code than we thought we'd need, wasn't it? If you spot any errors or possible shortcuts to take, let me and everyone know in the comments! The collision check should be robust enough so that we don't have to worry about any unfortunate events of objects slipping through the tilemap's blocks. 

A lot of the code was written to make sure that there are no objects passing through the tiles at big speeds, but if that's not a problem for a particular game, we could safely remove the additional code to increase the performance. It might even be a good idea to have a flag for specific fast-moving objects, so that only those use the more expensive versions of the checks.

We still have a lot of things to cover, but we managed to make a reliable collision check for the ground, which can be mirrored pretty straightforwardly to the other three directions. We'll do that in the next part.

Advertisement
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.