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

Android Games

DDDDD
Number Blocks
Match 3 Warriors

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
SDL 1 tutorials (outdated)

Latest Updates

SDL2 Rogue tutorial
Wed, 29th September 2021

SDL2 Gunner tutorial
Thu, 26th August 2021

SDL2 Shooter 2 tutorial
Tue, 13th July 2021

SDL2 Widget tutorial
Fri, 18th June 2021

SDL2 Adventure tutorial
Tue, 8th June 2021

All Updates »

Tags

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

Books

« Back to tutorial listing

— Creating a simple roguelike —
Part 15: Death and Highscores

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

Introduction

Up until now, the player has been immortal. They can be hurt, but never killed. In this part, we'll change that and also add in a highscore table.

Extract the archive, run make, and then use ./rogue15 to run the code. You will see a window open displaying the player character in a small room, with just a stair case (leading up). Play the game as normal. When / if you are killed, the game will begin fading to red before cutting to the highscore table. If you've made it onto the table, your entry will be highlighted in green. Press Space (or Escape or Return) to play again. Highscores are saved to a file called highscores.json. Once you're finished, close the window to exit.

Inspecting the code

Handling death in our game isn't too tricky. We could've left it there, with the fatal blow fading to red and then restarting the game at floor 0. Adding the player's progress to a highscore table makes things more fun, however.

To begin with, we've updated structs.h:


typedef struct {
	int kills;
	int xp;
	int floor;
	int defeatedMouseKing;
	char killedBy[MAX_NAME_LENGTH];
	unsigned long datetime;
} Highscore;

We've added in a struct called Highscore, to represent a highscore. `kills` is the number of kills we had achieved. `xp` is the amount of xp we earned. `floor` is the highest floor we reached. defeatedMouseKing is a flag to say whether we defeated the Mouse King (currently not possible), while killedBy is the name of the Monster that killed us. Finally, `datetime` is the time at which our death occurred.

We've also updated Game:


typedef struct {
	HudMessage messages[NUM_HUD_MESSAGES];
	Entity inventoryHead, *inventoryTail;
	Entity *equipment[EQUIP_MAX];
	Highscore highscore;
} Game;

We've addd in a field called `highscore`, that will hold the highscore data for our current game.

As expected, to handle our highscores, we've added in a file called highscores.c. Also as expected, there are a number of functions that we'll work through from top to bottom. Starting with initHighscores:


void initHighscores(void)
{
	int i;
	Highscore *h;

	memset(highscores, 0, sizeof(Highscore) * NUM_HIGHSCORES);

	for (i = 0 ; i < NUM_HIGHSCORES ; i++)
	{
		h = &highscores[i];

		h->xp = i;
		h->kills = i;
		h->floor = 1;
		STRCPY(h->killedBy, "Micro Mouse");
	}

	loadHighscores();
}

We're first clearing all our highscores by memsetting an array of Highscores, called `highscores`. The array is of length NUM_HIGHSCORES (defined as 16 in highscores.h). Next, we're setting up some default values, so our table isn't blank. We're looping through our array of highscores, going from 0 to NUM_HIGHSCORES. For each iteration of the loop, we're assigning the highscore at array index `i` to a variable called `h` (just for the sake of brevity) and then setting its `xp`, `kills`, and `floor` to the value of `i`. We're also setting the killedBy field to "Micro Mouse". With that done, we're calling loadHighscores. We'll get to this function later.

Next, we have a function called initHighscore view:


void initHighscoreView(void)
{
	displayTimer = FPS / 2;

	app.delegate.logic = logic;

	app.delegate.draw = draw;

	clearInput();
}

This function is called whenever we want to display our highscores. We're setting a variable called displayTimer to half a second, assigning the app delegate's `logic` and `draw` functions, and then clearing the current input (via a call to clearInput). displayTimer is a variable used to control when to display the highscore table. Like when we change dungeon floors in the main game, we want to introduce a brief pause before displaying content and allowing user input. The displayTimer variable handles this for us.

Moving onto `logic`, we can see how displayTimer is used:


static void logic(void)
{
	displayTimer = MAX(displayTimer - app.deltaTime, 0);

	if (displayTimer == 0)
	{
		if (app.keyboard[SDL_SCANCODE_SPACE] || app.keyboard[SDL_SCANCODE_RETURN] || app.keyboard[SDL_SCANCODE_ESCAPE])
		{
			clearInput();

			initDungeon();
		}
	}
}

We're decreasing the value of displayTimer and limiting it to 0. We're then testing to see if displayTimer is 0. If so, we're checking if the user has pressed Space, Return, or Escape and then calling clearInput, followed by initDungeon. This basically means that when the highscore table is shown, we're allowing the user to press one of three keys to start a new game.

The `draw` function is next and is where we're rendering our highscores:


static void draw(void)
{
	int i;
	Highscore *h;
	char text[MAX_NAME_LENGTH];
	SDL_Color c;

	if (displayTimer == 0)
	{
		app.fontScale = 2;

		drawText("Top Technicians", SCREEN_WIDTH / 2, 40, 255, 255, 255, TEXT_ALIGN_CENTER, 0);

		app.fontScale = 1;

		drawText("XP", 350, 140, 160, 220, 255, TEXT_ALIGN_LEFT, 0);
		drawText("Kills", 500, 140, 160, 220, 255, TEXT_ALIGN_LEFT, 0);
		drawText("Floor", 650, 140, 160, 220, 255, TEXT_ALIGN_LEFT, 0);
		drawText("Killed By", 800, 140, 160, 220, 255, TEXT_ALIGN_LEFT, 0);
		drawText("Date", 1050, 140, 160, 220, 255, TEXT_ALIGN_LEFT, 0);

		for (i = 0 ; i < NUM_HIGHSCORES - 1 ; i++)
		{
			h = &highscores[i];

			c.r = c.g = c.b = 255;

			if (h == latest)
			{
				c.r = 0;
				c.g = 255;
				c.b = 0;
			}
			else if (h->defeatedMouseKing)
			{
				c.r = 255;
				c.g = 240;
				c.b = 0;
			}

			sprintf(text, "%d", h->xp);
			drawText(text, 350, 180 + (i * 40), c.r, c.g, c.b, TEXT_ALIGN_LEFT, 0);

			sprintf(text, "%d", h->kills);
			drawText(text, 500, 180 + (i * 40), c.r, c.g, c.b, TEXT_ALIGN_LEFT, 0);

			sprintf(text, "%d", h->floor);
			drawText(text, 650, 180 + (i * 40), c.r, c.g, c.b, TEXT_ALIGN_LEFT, 0);

			drawText(h->killedBy, 800, 180 + (i * 40), c.r, c.g, c.b, TEXT_ALIGN_LEFT, 0);

			drawText(timeToDate(h->datetime), 1050, 180 + (i * 40), c.r, c.g, c.b, TEXT_ALIGN_LEFT, 0);
		}

		app.fontScale = 1.5;

		drawText("PRESS SPACE TO CONTINUE", SCREEN_WIDTH / 2, SCREEN_HEIGHT - 80, 160, 190, 255, TEXT_ALIGN_CENTER, 0);

		app.fontScale = 1;
	}
}

As with `logic`, we're first checking that displayTimer is 0 before doing anything more. We're then rendering the "Top Technicians" text, scaled up by 2. The headers of our table columns comes next, as we output "XP", "Kills", "Floor", "Killed By", and "Date". We then setup a for-loop to iterate through all our highscores. We're taking a reference to the highscore at index `i` in the highscores array, and assigning it a variable called `h`. Notice how we're only rendering the first 15 scores, despite our array being 16 in length. This is because the score in position 16 is always overwritten by the most recently score, as we'll see when we cover addHighscore.

We're then setting the RGB values of an SDL_Color struct called `c` to 255, so it is completely white. We're next checking if `h` is the same highscore as one named `latest`. `latest` is a static Highscore pointer within highscores.c, and is set when we add in a new highscore (we'll see more on this in a bit). If it is, we're setting `c`'s RGB values to green. If it's not, we're testing to see if the highscore's defeatedMouseKing flag is set. If so, we'll see the RGB values to a near-yellow colour.

With our colour decided, we're using sprintf and a variable called `text` to draw each highscore attribute string. Each highscore is rendered at a different vertical position using 180 plus `i` multiplied by 40. When it comes to rendering the highscore's `datetime`, we're calling a function named timeToDate and passing over the value.

Lastly, we're displaying the prompt to press Space to continue.

Onto addHighscore:


void addHighscore(void)
{
	int i;
	time_t now;

	memcpy(&highscores[NUM_HIGHSCORES - 1], &game.highscore, sizeof(Highscore));

	time(&now);

	highscores[NUM_HIGHSCORES - 1].datetime = now;

	qsort(highscores, NUM_HIGHSCORES, sizeof(Highscore), comparator);

	latest = NULL;

	for (i = 0 ; i < NUM_HIGHSCORES - 1 ; i++)
	{
		if (highscores[i].datetime == now)
		{
			latest = &highscores[i];
		}
	}

	saveHighscores();
}

This function is responsible for adding a highscore to our highscore table and also saving the highscores. We start by copying game's `highscore` into the last Highscore index (NUM_HIGHSCORES - 1) in our `highscores` array. Next, we use the time function (from time.h) to get the current time, passing over a reference to a time_t variable called `now`. `now` will then contain the number of milliseconds that have passed since the epoch. We're then setting the `datetime` field of the last Highscore in our array to the value of `now`.

Next, we're using qsort to sort our array of highscores, using a comparator function called `comparator`. Again, we'll see this last. Our highscore array is now sorted the way we want it. What we want to do now is to identify the most recent score. We do this by looping through our highscores (only the first 15), searching for one with a `datetime` matching `now`. When found, we'll assign it to latest. If we don't find one, `latest` will remain NULL, as it was set before the loop. Note that we can't simply use a pointer for this, because the pointer will always reference the final position and not the new one, due to the nature of how arrays work.

With all that done, we call saveHighscores.

The loadHighscores function is next:


static void loadHighscores(void)
{
	Highscore *h;
	cJSON *root, *node;
	char *text;

	if (fileExists(HIGHSCORES_FILENAME))
	{
		text = readFile(HIGHSCORES_FILENAME);

		root = cJSON_Parse(text);

		h = &highscores[0];

		for (node = root->child ; node != NULL ; node = node->next)
		{
			h->xp = cJSON_GetObjectItem(node, "xp")->valueint;
			h->defeatedMouseKing = cJSON_GetObjectItem(node, "defeatedMouseKing")->valueint;
			h->datetime = cJSON_GetObjectItem(node, "datetime")->valueint;
			h->kills = cJSON_GetObjectItem(node, "kills")->valueint;
			h->floor = cJSON_GetObjectItem(node, "floor")->valueint;
			STRCPY(h->killedBy, cJSON_GetObjectItem(node, "killedBy")->valuestring);

			h++;
		}

		cJSON_Delete(root);

		free(text);
	}
}

As we've seen this in two tutorials before (SDL2 Shooter and SDL2 Shooter 2), we're going to be brief. We're first testing to see if the highscores file exists by calling fileExists and passing in the name of the file (HIGHSCORES_FILENAME, defined as "highscores.json"). If so, we'll open the file, read it, convert it into JSON, and then set each of the scores, by walking through the JSON's object linked list. We're using a pointer called `h` for the highscores, assigning it to the first element in our highscores array, and then incrementing it at the end of each JSON loop. Note that we're not checking for an overflow of the array here, and are assuming we will never have more than 16 elements in our JSON array.

saveHighscores follows:


static void saveHighscores(void)
{
	int i;
	cJSON *root, *highscore;
	char *out;

	root = cJSON_CreateArray();

	for (i = 0 ; i < NUM_HIGHSCORES ; i++)
	{
		highscore = cJSON_CreateObject();

		cJSON_AddNumberToObject(highscore, "xp", highscores[i].xp);
		cJSON_AddNumberToObject(highscore, "kills", highscores[i].kills);
		cJSON_AddNumberToObject(highscore, "floor", highscores[i].floor);
		cJSON_AddNumberToObject(highscore, "defeatedMouseKing", highscores[i].defeatedMouseKing);
		cJSON_AddNumberToObject(highscore, "datetime", highscores[i].datetime);
		cJSON_AddStringToObject(highscore, "killedBy", highscores[i].killedBy);

		cJSON_AddItemToArray(root, highscore);
	}

	out = cJSON_Print(root);

	writeFile(HIGHSCORES_FILENAME, out);

	cJSON_Delete(root);

	free(out);
}

Another JSON saving function. Again, we've seen this sort of things before in the Shooter tutorials. We're creating a JSON array, then adding each of our highscores to it, as a JSON object, containing all the releveant values. The array is then converted into a text string and saved.

Now, let's look at the timeToDate function:


static char *timeToDate(unsigned long millis)
{
	static char date[MAX_NAME_LENGTH];

	time_t time;

	time = millis;

	strftime(date, MAX_NAME_LENGTH, "%d %b %Y, %H:%M%p", localtime(&time));

	return date;
}

The purpose of this function is to convert milliseconds in a pretty date string, to display in our highscore table. This function takes an unsigned long as an argument, called `millis`. This is the number of milliseconds that has elapsed since the epoch. We're first assigning a variable called `time` (a time_t type) the value of our `millis`. We're then calling strftime, passing over `date` (a static char array within this function), the length of the array, the string format we wish to use, and the result of the call to localtime (into which we've passed `time`). With that done, we're returning date, which will contain our string. The strftime function is quite complex and out of scope of this tutorial, so, for those interested, it's best to read more about it here: https://man7.org/linux/man-pages/man3/strftime.3.html.

Finally we come to our `comparator` function:


static int comparator(const void *a, const void *b)
{
	Highscore *h1, *h2;
	int result;

	h1 = (Highscore*) a;
	h2 = (Highscore*) b;

	result = h2->defeatedMouseKing - h1->defeatedMouseKing;

	if (result == 0)
	{
		result = h2->xp - h1->xp;

		if (result == 0)
		{
			result = h2->kills - h1->kills;

			if (result == 0)
			{
				result = h1->datetime - h2->datetime;
			}
		}
	}

	return result;
}

This is the function we pass to qsort, when sorting our highscores. We want our highscores to be displayed in the following order: those that have defeated the Mouse King, those with higher `xp`, those with higher `kills`, and finally those with an earlier `datetime`. Our two comparing highscores are assigned to variables named `h1` and `h2`. For each test, we're subtracting the value of one field in each highscore and assigning it to a variable called `result`. If `result` is 0, it means that the value of the fields in `h1` and `h2` are equal. We'll therefore test a different field, to try and sort on that next. So, if both highscores haven't defeated the Mouse King, we'll test to see which of the two highscores has the higher `xp`. If those are equal, we'll test `kills`, and if those are equal, we'll test the `datetime` the highscore was submitted (note, that we're sorting by the lower datetime here). Since defeating the Mouse King is the highest honour, we'll display those that have beaten the game first.

That's our highscores covered. We'll now look at how we're actually handling the player's death.

Turning to combat.c, we've made a couple of small changes to doMeleeAttack:


void doMeleeAttack(Entity *attacker, Entity *target)
{
	int damage, attack, type;
	Monster *atkMonster, *tarMonster;

	dungeon.attackDir.x = (target->x - attacker->x);
	dungeon.attackDir.y = (target->y - attacker->y);
	dungeon.animationTimer = FPS / 3;

	dungeon.attackingEntity = attacker;

	atkMonster = (Monster*) attacker->data;

	tarMonster = (Monster*) target->data;

	attack = atkMonster->minAttack + (rand() % ((atkMonster->maxAttack + 1) - atkMonster->minAttack));

	damage = attack * attack / (attack + tarMonster->defence);

	if (damage != 0)
	{
		tarMonster->hp -= damage;

		if (tarMonster->hp <= 0)
		{
			target->dead = 1;
		}

		setBloodSplat(target->x, target->y);
	}

	buildAttackMessage(attacker, target, damage);

	type = HUD_MSG_NORMAL;

	if (target == dungeon.player && damage > 0)
	{
		type = HUD_MSG_BAD;
	}

	addHudMessage(type, combatMessage);

	if (target->dead == 1)
	{
		if (attacker == dungeon.player)
		{
			game.highscore.kills++;

			addPlayerXP(tarMonster->xp);
		}

		if (target == dungeon.player)
		{
			STRCPY(game.highscore.killedBy, attacker->name);
		}
	}
}

Where before, when updating `target`'s `dead` flag to 1 we were first checking that `target` wasn't the player, we're now simply checking if tarMonster's hp is less than or equal to 0. This now means that player can be killed. Additionally, when checking if `target`'s `dead` flag is set, we're testing if the target was the player. If so, we're copying the name of the attacker into game's highscore's killedBy field. This allows us to record what killed the player for the highscore table.

Moving over to dungeon.c now, we've made several other small updates. Starting with initDungeon:


void initDungeon(void)
{
	memset(&dungeon, 0, sizeof(Dungeon));

	floorChangeTimer = FPS / 2;

	playerDeathAlpha = -FPS / 2;

	initMap();

	initHud();

	initInventory();

	createDungeon();

	app.delegate.logic = logic;

	app.delegate.draw = draw;
}

We've introduced a new static variable called playerDeathAlpha, that we're setting to half a second. This variable is used to control the red fade that occurs when the player is killed.

createDungeon has also been tweaked:


static void createDungeon(void)
{
	int oldFloor;
	char text[MAX_DESCRIPTION_LENGTH];

	oldFloor = dungeon.floor;

	dungeon.floor = dungeon.newFloor;

	initEntities();

	if (dungeon.player == NULL)
	{
		initEntity("Player");
	}
	else
	{
		dungeon.player->next = NULL;

		dungeon.entityTail->next = dungeon.player;
		dungeon.entityTail = dungeon.player;
	}

	generateMap();

	if (dungeon.floor > 0 && dungeon.floor < MAX_FLOORS)
	{
		addMonsters();

		addItems();
	}
	else if (dungeon.floor == 0)
	{
		addHelperItems();
	}

	addStairs(oldFloor);

	addDoors();

	updateFogOfWar();

	dungeon.currentEntity = dungeon.player;

	sprintf(text, "Entering floor #%d", dungeon.floor);

	game.highscore.floor = MAX(dungeon.floor, game.highscore.floor);

	addHudMessage(HUD_MSG_NORMAL, text);
}

We're now testing to see if the player is on floor 0, and if so calling a function named addHelperItems (in items.c - we'll see this in a little bit). Another minor update is to update game's highscore's `floor` variable, setting it to the higher floor the player has reached, via use of the MAX function, passing in dungeon's floor and game's highscore's `floor` value.

Moving to the `logic` function:


static void logic(void)
{
	floorChangeTimer = MAX(floorChangeTimer - app.deltaTime, 0);

	if (!dungeon.player->dead && floorChangeTimer == 0)
	{
		// snipped
	}
	else if (dungeon.player->dead)
	{
		playerDeathAlpha += 0.5 * app.deltaTime;

		if (playerDeathAlpha >= 64)
		{
			initGameOver();
		}
	}
}

We've added to the floorChangeTimer value check if-statement, now also checking to see if the player is not dead. The game will proceed as normal if both these conditions are true. If not, we've added in an additional else-if check, to see if the player is dead. If they are, we're increasing the value of playerDeathAlpha by 0.5. If playerDeathAlpha is 64 or more, we're then calling initGameOver. This means that when the player is killed, it will take a little over two seconds before initGameOver is called.

The `draw` function has seen a minor tweak, too:


static void draw(void)
{
	if (floorChangeTimer == 0)
	{
		drawMap();

		drawEntities();

		drawHud();
	}

	if (dungeon.player->dead && playerDeathAlpha > 0)
	{
		drawRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT, 255, 0, 0, playerDeathAlpha);
	}
}

At the end of the function, after having drawn everything, we're testing to see if the player is dead and also if playerDeathAlpha is greater than 0. If so, we're calling drawRect, with a rectangle to cover the whole screen, and an RGB red colour. The alpha (final parameter) is set as playerDeathAlpha. As this value increases from 0, the screen will gradually turn red. However, as we've seen, it never gets there fully.

We're almost done. A handful of other updates and the part is finished.

Turning to game.c, we've added in a new function called initGameOver:


void initGameOver(void)
{
	addHighscore();

	clearDungeonEntities();

	free(dungeon.player->data);

	free(dungeon.player);

	destroyGame();

	initHighscoreView();
}

This function is resposible for adding our highscores, clearing down our dungeon, and displaying our scores. We start by calling addHighscore, then clearDungeonEntities. As we saw in a previous part, clearDungeonEntities excludes the player, so we must free the player's data manually here (remembering to free player's `data` field, as well as the player entity itself, to prevent a memory leak). We then call destroyGame, a function we'll come to next, and finally initHighscoreView, to display our highscores.

destroyGame is a function responsible for freeing up all the game data:


static void destroyGame(void)
{
	int i;
	Entity *e;

	for (i = 0 ; i < EQUIP_MAX ; i++)
	{
		if (game.equipment[i] != NULL)
		{
			if (game.equipment[i]->data != NULL)
			{
				free(game.equipment[i]->data);
			}

			free(game.equipment[i]);
		}
	}

	while (game.inventoryHead.next != NULL)
	{
		e = game.inventoryHead.next;

		game.inventoryHead.next = e->next;

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

		free(e);
	}

	memset(&game, 0, sizeof(Game));
}

Our equipped items and inventory contains entities that are detatched from the dungeon, so we need to clear these all down. Freeing the equipment data is simply a matter of setting up a for-loop, from 0 to EQUIP_MAX, testing if an entity is set at game's equipment index `i`, and freeing the entity data there, as needed. For the inventory, we're using a while-loop to clear down the linked list, as we've seen a few times before. Finally, we're calling memset on the `game` object itself, to reset everything there (including the highscore and HUD messages).

We can now be confident that when the game ends, the dungeon data is completely cleared down, and we're not leaking memory all over the place.

Finally, we've added in one more function to items.c, called addHelperItems:


void addHelperItems(void)
{
	if (dungeon.floor == 0 && getInventoryItem("Health Pack") == NULL)
	{
		addEntityToDungeon(initEntity("Health Pack"), 0);
	}
}

As the game is a bit tougher now, we're going to help the player out a bit. We're testing if the player is on floor 0 and also if they have a Health Pack in their inventory. If they don't, we'll add one to the dungeon floor. There are no shops or vending machines to be found in our dungeon, so supplying the player with a health pack on floor 0 if they don't have one can help them out when getting started.

Wow, this part ended up being quite a lot longer than anticipated. But that was mainly due to introducing the highscore table. It would be less the half the length otherwise. Our game is almost complete, and there is just one more gameplay feature we want to introduce - status effects. In the next part, we'll look at allowing some of the more powerful mice to inflict debuffs on the player, to stun, confuse, or even poison them!

Purchase

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

It is also available as part of the SDL2 tutorial bundle (with on-going updates):

If you do not wish to create an itch.io account, you can also purchase the tutorial bundle using PayPal. This method will be slower, however, as it will require manual verification of the transaction.

Comments

Share your comments and thoughts below. All comments are anonymous and cannot be edited.

 

Mobile site