« Back to tutorial listing

— 2D platformer tutorial —
Part 3: Controlling the character

Introduction

Note: this tutorial series builds upon the ones that came before it. If you aren't familiar with the previous tutorials, or the prior ones of this series, you should read those first.

This tutorial will explain how to add and control a character on a basic 2D map. Extract the archive, run cmake CMakeLists.txt, followed by make to build. Once compiling is finished type ./ppp03 to run the code.

A 1280 x 720 window will open, with a series of coloured squares displayed over a light blue background. Pete, the player character, will drop to the ground from the top-left of the screen. He can be controlled using A and D to move him left and right, and using I to jump. Pressing Space will reset Pete to the starting point. If you're not fond of these controls, they can be easily changed in player.c. Close the window by clicking on the window's close button.

Inspecting the code

In order to add a playable character, one that can interact with the map, we need to make a number of changes and additions to the code. Some are minor, others quite major. Starting with defs.h and structs.h. defs.h sees a one line change:


#define PLAYER_MOVE_SPEED 6

We've updated the player move speed, to better reflect the speed we want Pete to move at. Next, we need to create an Entity struct in structs.h to hold our entity data:


struct Entity {
	float x;
	float y;
	int w;
	int h;
	float dx;
	float dy;
	int isOnGround;
	SDL_Texture *texture;
	long flags;
	Entity *next;
};

The entity struct will hold the the x and y coordinates of our entity, its velocity (dx and dy), and a number of other items that we'll discuss in a bit. We also need to create a linked list to hold the entities in the game. We'll add this to the Stage struct:


typedef struct {
	...
	Entity entityHead, *entityTail;
} Stage;

With those updates made, we need to write all the relevant code to drive our entities. entities.c contains four functions to deal with this. Starting with doEntities:


void doEntities(void)
{
	Entity *e;

	for (e = stage.entityHead.next ; e != NULL ; e = e->next)
	{
		self = e;

		move(e);
	}
}

This is a simple function - it steps through the linked list of entities and calls a function called move, passing the entity over. self is currently reserved for future use. The move function is used to move the entities around:


static void move(Entity *e)
{
	e->dy += 1.5;

	e->dy = MAX(MIN(e->dy, 18), -999);

	e->isOnGround = 0;

	moveToWorld(e, e->dx, 0);

	moveToWorld(e, 0, e->dy);

	e->x = MIN(MAX(e->x, 0), MAP_WIDTH * TILE_SIZE);
	e->y = MIN(MAX(e->y, 0), MAP_HEIGHT * TILE_SIZE);
}

The first thing that happens is the entity's dy is incremented by 1.5. This is to simulate acceleration due to gravity in our world. We then limit the speed of dy, to ensure an entity doesn't move too fast when falling. The entity's isOnGround flag is also reset to 0. A function called moveToWorld is then called, first for the x axis and then for the y axis. The reason these are called separately is due to us wanting to resolve an entity's x movement first, and then it's y movement. Later, we'll want to test the entity moving against other entities, which means resolving x and y movements against the world and entities in order. For now, we'll just consider the world. The final step in the move function is to constrain the entity's x and y to the bounds of the map. This will effectively stop Pete from leaving the world.

Now we come to the moveToWorld function. This is quite a complicated function, so we'll be working through it slowly.


static void moveToWorld(Entity *e, float dx, float dy)
{
	int mx, my, hit, adj;

	if (dx != 0)
	{
		e->x += dx;

		mx = dx > 0 ? (e->x + e->w) : e->x;
		mx /= TILE_SIZE;

		my = (e->y / TILE_SIZE);

		hit = 0;

		if (!isInsideMap(mx, my) || stage.map[mx][my] != 0)
		{
			hit = 1;
		}

		my = (e->y + e->h - 1) / TILE_SIZE;

		if (!isInsideMap(mx, my) || stage.map[mx][my] != 0)
		{
			hit = 1;
		}

		if (hit)
		{
			adj = dx > 0 ? -e->w : TILE_SIZE;

			e->x = (mx * TILE_SIZE) + adj;

			e->dx = 0;
		}
	}

	if (dy != 0)
	{
		e->y += dy;

		my = dy > 0 ? (e->y + e->h) : e->y;
		my /= TILE_SIZE;

		mx = e->x / TILE_SIZE;

		hit = 0;

		if (!isInsideMap(mx, my) || stage.map[mx][my] != 0)
		{
			hit = 1;
		}

		mx = (e->x + e->w - 1) / TILE_SIZE;

		if (!isInsideMap(mx, my) || stage.map[mx][my] != 0)
		{
			hit = 1;
		}

		if (hit)
		{
			adj = dy > 0 ? -e->h : TILE_SIZE;

			e->y = (my * TILE_SIZE) + adj;

			e->dy = 0;

			e->isOnGround = dy > 0;
		}
	}
}

We first check to see if the dx parameter is not 0; remember that as we want to resolve the x and y movements seperately, we do not want to test the entity's own dx and dy. Assuming dx is not 0, we add the dx to the entity's x, to update its position. We then need to work out the map coordinates that the entity is now touching, storing these in mx and my. If we are moving right (dx > 0), we want to test the entity's x plus the entity's width (x + w). If we're going left, we just need the entity x. mx is divided by TILE_SIZE to get the relevant tile index.

A variable called hit is set to 0. This will be used to flag that a hit with the map occurred and that a collision response should be processed.

We then calculate the my value, starting with the top of the entity (e->y / TILE_SIZE).

With the mx and my known for the direction that the entity has now moved, we test to see if we're not inside the map, or if the id of the tile of mx and my is not 0 (in order words, solid). If so, we set hit to 1 to indicate a collision has occurred. We do the same for the bottom of the entity, first calculating my as (e->y + e->h - 1) / TILE_SIZE. Note that we're subtracting 1 from entity's y + h. We need to do this as otherwise walking on the ground would be impossible; my would always enter the tile the entity is standing on, resulting in hit resolving to 1.

Finally, we test hit. If it's 1 then we need to do the collision response. To do this, we need to work out how to adjust the entity position if a collision occurs (storing the value in a variable called adj). If we're moving right, we want this to be the negative of the entity's width. Otherwise, we want it to be TILE_SIZE. This adjustment value will be applied after a colliding entity is aligned with the tile's location. The entity's x position is set to mx * TILE_SIZE, to align with the tile it hit, before the value of adj is added (which might be negative). In effect, the entity will be aligned to either the left or right of the tile it hit, depending on the direction it was moving. Finally, the entity's dx is set to 0.

There's a lot to consider here, and this only deals with the x movement! However, it's actually less complicated than it appears. Re-read the secton above if needed. The y movement is largely the same as the x, only that we're using the y variables instead. We're also trimming 1 from the x + w calculation when working out mx. If we don't do this, the entity can get stuck when moving to the right (and can end up scaling sheer walls!). The only other major difference is that during the collision response, we set the value of the entity's isOnGround to 1 if the entity's dy was > 0 (in other words, it was moving down). This variable is used for things much as testing if the player can jump, as they must be on the ground to do so.

With the map collision sorted out, the only other function to deal with in entities.c is drawEntities, which is very straightforward:


void drawEntities(void)
{
	Entity *e;

	for (e = stage.entityHead.next ; e != NULL ; e = e->next)
	{
		blit(e->texture, e->x - stage.camera.x, e->y - stage.camera.y, 0);
	}
}

We need only loop through our list of entities and blit each one. Note how we're subtracting the camera's position from the entity's, so that it is drawn in the correct place.

With the ability to process, handle, and draw entities, we can consider the player themselves. player.c has been updated to handle the titular Pete, starting with initPlayer:


void initPlayer(void)
{
	player = malloc(sizeof(Entity));
	memset(player, 0, sizeof(Entity));
	stage.entityTail->next = player;
	stage.entityTail = player;

	pete[0] = loadTexture("gfx/pete01.png");
	pete[1] = loadTexture("gfx/pete02.png");

	player->texture = pete[0];

	SDL_QueryTexture(player->texture, NULL, NULL, &player->w, &player->h);
}

We create an Entity (assigned to a variable called player), load two textures to handle Pete facing left and right, assign the player's current texture to the first of these, and finally grab the texture's width and height, to use as the player entity's width and height.

doPlayer is changed to do something other than move the camera around - it will now control Pete himself:


void doPlayer(void)
{
	player->dx = 0;

	if (app.keyboard[SDL_SCANCODE_A])
	{
		player->dx = -PLAYER_MOVE_SPEED;

		player->texture = pete[1];
	}

	if (app.keyboard[SDL_SCANCODE_D])
	{
		player->dx = PLAYER_MOVE_SPEED;

		player->texture = pete[0];
	}

	if (app.keyboard[SDL_SCANCODE_I] && player->isOnGround)
	{
		player->dy = -20;
	}

	if (app.keyboard[SDL_SCANCODE_SPACE])
	{
		player->x = player->y = 0;

		app.keyboard[SDL_SCANCODE_SPACE] = 0;
	}
}

Pete's dx is always zeroed. This stops him from sliding helplessly around; we only want Pete to move when the A and D keys are pressed. Additionally, whenever those keys are pressed, we change Pete's texture to one of him facing left or right. If we detect that the I key is pressed, we will make Pete jump. But this only happens if Pete's isOnGround variable is 1. When the key is pressed, we set Pete's dy value to be -20, to cause him to move rapidly up the screen. Gravity will soon pull him back down. Finally, we check if Space has been pressed. If so, we reset Pete's x and y to 0. This is only to prevent Pete from getting stuck somewhere he can't escape from.

To make use of all of this, we need to make some small changes to stage.c, starting with initStage:


void initStage(void)
{
	...
	stage.entityTail = &stage.entityHead;

	initPlayer();

We set the entity tail to point to entity head, and also call our initPlayer function. Next, we update logic:


static void logic(void)
{
	...
	doEntities();

Only one small addition is needed here - a call to doEntities. Finally, we need to update the draw function:


static void draw(void)
{
	...
	drawEntities();
}

Again, a simple call to drawEntities is all that is needed to ensure Pete is drawn.

With that all done, we now have the start of a very basic platfrom game. Pete can explore the tile map, jump, and interact with it as expected. In the next tutorial, we'll look at how to make Pete interact with solid entities, supporting him so he can walk on them (as though they are bridges), and possibly block his path (like doors or other obstacles).

Purchase

The source code for all parts of this tutorial (including assets) is available for purchase:

From itch.io

It is also available as part of the SDL2 tutorial bundle:

Desktop site