PC Games

Orb
Lasagne Monsters
Three Guys Apocalypse
Water Closet
Blob Wars : Attrition
The Legend of Edgar
TBFTSS: The Pandoran War
Three Guys
Blob Wars : Blob and Conquer
Blob Wars : Metal Blob Solid
Project: Starfighter
TANX Squadron

Tutorials

2D shoot 'em up
2D top-down shooter
2D platform game
Sprite atlas tutorial
Working with TTF fonts
2D adventure game
Widget tutorial
2D shoot 'em up sequel
2D run and gun
Roguelike
Medals (Achievements)
2D turn-based strategy game
2D isometric game
2D map editor
2D mission-based shoot 'em up
2D Santa game
2D split screen game
SDL 1 tutorials (outdated)

Latest Updates

SDL2 Versus game tutorial
Wed, 20th March 2024

Download keys for SDL2 tutorials on itch.io
Sat, 16th March 2024

The Legend of Edgar 1.37
Mon, 1st January 2024

SDL2 Santa game tutorial 🎅
Thu, 23rd November 2023

SDL2 Shooter 3 tutorial
Wed, 15th February 2023

All Updates »

Tags

android (3)
battle-for-the-solar-system (10)
blob-wars (10)
brexit (1)
code (6)
edgar (9)
games (43)
lasagne-monsters (1)
making-of (5)
match3 (1)
numberblocksonline (1)
orb (2)
site (1)
tanx (4)
three-guys (3)
three-guys-apocalypse (3)
tutorials (17)
water-closet (4)

Books


H1NZ

Arriving on the back of a meteorite, an alien pathogen has spread rapidly around the world, infecting all living humans and animals, and killing off all insect life. Only a handful are immune, and these survivors cling desperately to life, searching for food, fresh water, and a means of escape, find rescue, and discover a way to rebuild.

Click here to learn more and read an extract!

« Back to tutorial listing

— Making a 2D split screen game —
Part 6: Live. Die. Repeat.

Note: this tutorial assumes knowledge of C, as well as prior tutorials.

Introduction

Now that we have our split screen in place, we should start looking into implementing our gameplay loop. As there is quite a lot to this, we'll be doing it over several steps. This step will involve handling the player's ship being destroyed, and the subsequent respawning. Up until now, our players have been completely invulnerable; being shot, or colliding with the environment or other player does nothing but results in the ship bouncing away. Now, the player's ship can be destroyed.

Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./versus06 to run the code. You will see a window open like the one above, with each player on either side of our zone. Use the default controls (or, at your option, copy the config.json file from a previous tutorial, to use that - remember to exit the game before copying the replacement file). Play the game as normal. Notice how constantly flying into the ground or against the other player will eventually lead to one (or both) of the craft being destroyed. After a short time, the player will reappear in their starting position, with a protective shield. Once you're finished, close the window to exit.

Inspecting the code

Adding our in respawning requires us to make a number of updates throughout the code, but isn't a difficult task to accomplish overall.

Starting first with defs.h:


#define MAX_PLAYER_HEALTH 100.0

We've added in a define called MAX_PLAYER_HEALTH, that will be the maximum value of the players' health. Notice that it is a double, rather than an int. This is because we want collisions with the environment to cause damage over time, so we aren't tied to our frame rate.

Next, we turn to structs.h, where we've updated Entity:


struct Entity
{
	uint8_t    type;
	SDL_FPoint position;
	SDL_FPoint dir;
	Model     *model;
	double     angle;
	int        radius;
	uint8_t    dead;
	int        respawnInterval;
	double     respawnTimer;
	SDL_FPoint respawnPosition;
	void(*data);
	void (*tick)(Entity *self);
	void (*draw)(Entity *self, SDL_FPoint *camera);
	void (*touch)(Entity *self, Entity *other);
	void (*takeDamage)(Entity *self, double amount, Entity *attacker);
	void (*respawn)(Entity *self);
	Entity *next;
};

We've made a bunch of additions and changes to this struct. The new `dead` flag defines whether an entity is alive or dead. respawnInterval, respawnTimer, and respawnPosition all govern this entity's respawning state. respawnInterval will determine how long before the entity is re-created upon death. respawnTimer is used to control this countdown until it is respawned, while respawnPosition holds the position where the entity is to reappear. We've also added in function pointers called takeDamage and `respawn`. takeDamage will be invoked whenever this entity takes damage, `respawn` when it is re-created. We'll see these in use when we come to the updates to player.c.

Next up, we've made changes to Player:


typedef struct
{
	uint8_t num;
	double  health;
	double  shield;
	double  reload;
} Player;

We've added in `health` and `shield` variables. As their names suggest, these variables represent the Player's current health and shield amounts.

Finally, we've updated Zone:


typedef struct
{
	Entity     entityHead, *entityTail;
	SDL_FPoint cameras[NUM_PLAYERS];
	SDL_Rect   bounds;
	Entity     WORLD;
} Zone;

We've added in an entity here called WORLD. This will be used whenever an entity takes damage from the environment. As we'll see, this helps to keep our takeDamage function more fluid, since we can avoid a NULL check.

Now for the changes to player.c, starting with initPlayer:


void initPlayer(int num, int x, int y)
{
	Entity *e;
	Player *p;

	// snipped

	e = spawnEntity(ET_PLAYER);
	e->position.x = x;
	e->position.y = y;
	e->respawnInterval = FPS * 2;
	e->respawnPosition = e->position;
	e->radius = 15;
	e->model = models[num];
	e->data = p;

	e->tick = tick;
	e->draw = draw;
	e->touch = touch;
	e->takeDamage = takeDamage;
	e->respawn = respawn;

	respawn(e);

	p->shield = 0;
}

When we create the player, we're now setting the entity's respawnInterval to two seconds, and its respawnPosition to the same position as where the player is first located. We're also setting its takeDamage and `respawn` function pointers (to function with the same names). With that done, we're calling the `respawn` (we'll see this in a bit), and setting the Player's `shield` to 0. The reason for this is because our `respawn` function sets the player's `shield` to 5 seconds, but we don't want our player to have a shield when the game first starts.

Next, we can look at the changes to `tick`:


static void tick(Entity *self)
{
	Player     *p;
	SDL_FPoint *c;

	p = (Player *)self->data;

	p->shield = MAX(p->shield - app.deltaTime, 0);

	steer(self, p);

	// snipped
}

A one line addition - we're decreasing the value of the player's `shield`, limiting it to 0. This will, of course, mean that our shield will expire after a period of time.

Now, over to `draw`, where we've made a larger update:


static void draw(Entity *self, SDL_FPoint *camera)
{
	int        i, s, shield;
	Player    *p;
	SDL_Point  points[NUM_SHIELD_PLOT_POINTS];
	SDL_FPoint drawPosition;

	drawPosition = self->position;
	drawPosition.x -= camera->x;
	drawPosition.y -= camera->y;

	drawModel(self->model, drawPosition, self->angle);

	p = (Player *)self->data;

	if (p->shield > 0)
	{
		shield = ceil(p->shield / (FPS * 5));

		for (s = 0; s < shield; s++)
		{
			SDL_SetRenderDrawColor(app.renderer, 64 + (s * 64), 32, 255, 255);

			for (i = 0; i < NUM_SHIELD_PLOT_POINTS; i++)
			{
				points[i].x = (32 + (s * 5)) * cos(i * 2 * PI / 6);
				points[i].y = (32 + (s * 5)) * sin(i * 2 * PI / 6);

				points[i].x += self->position.x - camera->x;
				points[i].y += self->position.y - camera->y;
			}

			SDL_RenderDrawLines(app.renderer, points, NUM_SHIELD_PLOT_POINTS);
		}
	}
}

Here, we're drawing the player's model as usual, but after that we're testing to see if the player has a shield. If so, we're going to render it as a hexagon. To achieve this, we're using a series of SDL_Points, along with the SDL_RenderDrawLines function. The calculated points are positioned around the player, with the location of the camera also taken into account. There is scope for optimisation here, since we're calculating the corners of the hexagon all the time, when we could do this once, and just adjust the position. This would be a micro optimisation in our game, however, and likely wouldn't result in any noticeable impact to performance.

Now over to `touch`, and here you'll notice we have a much larger update:


static void touch(Entity *self, Entity *other)
{
	double  damage;

	switch (other->type)
	{
		case ET_NONE:
			damage = sqrt((self->dir.x * self->dir.x) + (self->dir.y * self->dir.y)) * 5;
			takeDamage(self, damage, self);
			bounce(self);
			break;

		case ET_PLAYER:
			damage = sqrt((self->dir.x * self->dir.x) + (self->dir.y * self->dir.y)) * 2.5;
			takeDamage(self, damage, self);
			takeDamage(other, damage, self);

			other->dir.x += self->dir.x * 0.5;
			other->dir.y += self->dir.y * 0.5;

			bounce(self);
			break;

		default:
			break;
	}
}

Here, you can see why we have a "WORLD" entity in our Zone struct - we're expecting `other` never to be NULL. We first test the `type` of the entity. If the `type` is "ET_NONE", we'll consider it to be the world / environment. We'll first calculate how fast the player was moving when they made contact with the environment (according their `dir`), and multiply that by 5, to determine the amount of damage to apply (as `damage`). We'll then call takeDamage, passing over the entity, the amount of damage to apply, and the entity itself (`self`) as the attacker (3rd argument). Finally, we'll call `bounce`, to push the player back. In effect, this means that flying into the ground will harm the player. And the faster they go, the greater the damage will be.

If `other` is another player, we'll once again calculate how much damage to cause (but this time multiply by 2.5). We'll then damage BOTH the current player and the other player, with the current player being the attacker. We'll then adjust the other player's `dir` by half the current player's `dir`, in effect pushing the opponent. Finally, we'll once again call `bounce`, to push the current player back. This means the players can shove each other about a bit.

That's it for our updates to `touch`. Let's now look at our new takeDamage function:


static void takeDamage(Entity *self, double amount, Entity *attacker)
{
	Player *p;

	p = (Player *)self->data;

	if (p->shield == 0)
	{
		p->health = MAX(p->health - amount, 0);

		self->dead = p->health == 0;
	}
}

This function takes three arguments - the current entity, the amount of damage to apply, and the attacker. The attacker isn't actually used at the moment; it's here now so that we don't need to make changes to the function signature later. The function itself is rather basic. We test if the player has a shield, and if not we're subtracting `amount` from the player's `health` (limiting to 0). The player's `dead` flag is set to 0 if their `health` has hit 0.

So, a standard damage function. Finally, we come to the `respawn` function:


static void respawn(Entity *self)
{
	Player *p;

	self->angle = 0;
	self->dir.x = self->dir.y = 0;

	p = (Player *)self->data;

	p->shield = FPS * 5;
	p->health = MAX_PLAYER_HEALTH;
}

This function basically resets the player's state. We set their `angle` to 0, and reset their `dir` (to stop them moving). The player's `health` is set to MAX_PLAYER_HEALTH, and we give them a shield that last 5 seconds. This is why in the initPlayer function we're setting this value back to 0.

That's it for player.c. We can now turn our attention to entities.c, to see how our respawning logic works. It's not difficult to understand.

Let's start with initEntities:


void initEntities(void)
{
	memset(&zone.entityHead, 0, sizeof(Entity));
	zone.entityTail = &zone.entityHead;

	memset(&respawnHead, 0, sizeof(Entity));
	respawnTail = &respawnHead;
}

We're setting up a new linked list here, to handle our respawnable entities (as respawnHead / respawnTail). The reason we're doing this is to separate our living entities (that we'll want to render, collide with, etc.) from the ones that are waiting to return to the game. Doing this helps to do away with micromanagement (testing if an entity is dead before drawing it, etc) and strange bugs that could occur elsewhere.

With that done, we can implement it in doEntities:


void doEntities(void)
{
	Entity *e, *prev;

	prev = &zone.entityHead;

	for (e = zone.entityHead.next; e != NULL; e = e->next)
	{
		// snipped

		if (e->dead)
		{
			if (e == zone.entityTail)
			{
				zone.entityTail = prev;
			}

			prev->next = e->next;

			e->next = NULL;

			if (e->respawnInterval > 0)
			{
				e->respawnTimer = e->respawnInterval;

				respawnTail->next = e;
				respawnTail = e;
			}
			else
			{
				if (e->data != NULL)
				{
					free(e->data);
				}

				free(e);
			}

			e = prev;
		}

		prev = e;
	}
}

The major change here is that we're now testing the entity's `dead` flag. If the entity is dead, we'll want to either remove it from the game completely, or add it to the respawn list. In both cases, we start by removing it from Zone's entity list. Next, we check if the entity's respawnInternal is greater than 0 (meaning it will respawn). If so, we'll set its respawnTimer to the value of its respawnInterval, and then add it to the respawn list (setting respawnTail's `next` to the current entity). If the entity isn't flagged for respawnnig, we'll simply delete it from our game.

That should all be quite clear. In summary, if a respawnable entity is killed, we'll move it to the respawn list, rather the remove it completely.

A minor update to touchWorld follows:


static void touchWorld(Entity *e)
{
	int       n;
	Triangle *t;

	for (t = getWorldTriangles(); t != NULL; t = t->next)
	{
		for (n = 0; n < 3; n++)
		{
			if (lineCircleCollision(t->points[n].x, t->points[n].y, t->points[(n + 1) % 3].x, t->points[(n + 1) % 3].y, e->position.x, e->position.y, e->radius))
			{
				e->touch(e, &zone.WORLD);
				return;
			}
		}
	}
}

As stated before, our `touch` functions expect a non-NULL entity to be passed over, so the `type` can be tested. Here, we're changing our once NULL parameter to be Zone's WORLD.

Finally, we've added in a new function for handling our respawnable entities - respawnEntities:


void respawnEntities(void)
{
	Entity *e, *prev;

	prev = &respawnHead;

	for (e = respawnHead.next; e != NULL; e = e->next)
	{
		e->respawnTimer -= app.deltaTime;

		if (e->respawnTimer <= 0)
		{
			if (e == respawnTail)
			{
				respawnTail = prev;
			}

			e->dead = 0;

			e->position = e->respawnPosition;

			if (e->respawn != NULL)
			{
				e->respawn(e);
			}

			prev->next = e->next;

			e->next = NULL;

			zone.entityTail->next = e;
			zone.entityTail = e;

			e = prev;
		}

		prev = e;
	}
}

This function is responsible for bringing our entities "back to life", and returning them to the game world. We simply loop through all the entities in the respawn list, and reduce their respawnTimer value. If it hits 0 or less, we'll remove it from the respawn list, set its `dead` flag to 0, return it to its starting position (respawnPosition), call its `respawn` function if one is set (as it is, for our Players), and finally return the entity to Zone's entity list.

So, almost the reverse of when an entity dies, except that we're moving it back into the world, and resetting its various attributes. There are, of course, a number of different ways we could've approached this, but this one saves us from a bit of micromanagement.

Finally, we just need a call this new function, so it's over to zone.c, where we've updated `logic`:


static void logic(void)
{
	doBullets();

	doEntities();

	respawnEntities();

	// snipped
}

We're now calling the respawnEntities function.

We're finished! We can now cause damage to our players, they can die, and be reborn. That's just what we need in our game.

However, it's not too useful that we can't see how much health, etc. the players have. In fact, we can't see much of anything; there is no status display to be found anywhere in our game. So, in our next part we'll look at introducing our good old friend, the HUD.

Purchase

The source code for all parts of this tutorial (including assets) is available for purchase, as part of the SDL2 tutorials bundle:

From itch.io

Mobile site