« Back to tutorial listing

— 2D Shoot 'Em Up Tutorial —
Part 13: Highscore table (Part 1)

Introduction

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

We must now collect score pods to earn points, making the game a little more tricky. Wouldn't it be fun if we had a highscore table? This part of the tutorial will do just that.Extract the archive, run cmake CMakeLists.txt, followed by make to build. Once compiling is finished type ./shooter13 to run the code.

A 1280 x 720 window will open, with a colorful background. A highscore table is displayed, as in the screenshot above. Press the left control key to start. A spaceship sprite will also be shown. The ship can now be moved using the arrow keys. Up, down, left, and right will move the ship in the respective directions. You can also fire by holding down the left control key. Enemies (basically red versions of the player's ship) will spawn from the right and move to the left. Shoot enemies to destroy them. Enemies can fire back, so you should avoid their shots. Score points by collect points pods released from destroyed enemy ships. The highscore table is shown upon the player's death and the game can be played again. Close the window by clicking on the window's close button.

Inspecting the code

We've added a several new pieces of code, including a new compilation unit. We'll start with defs.h and structs.h. Note - there's been some refactoring done to the code, but we'll cover all this at the end. For now, let's start with everything related to the highscore table.

defs.h has a simple update:


#define NUM_HIGHSCORES 8

Our highscores will be a fixed sized array, so we're creating a define for the purpose. Next, we've added two new structs to structs.h:


typedef struct {
	int recent;
	int score;
} Highscore;

typedef struct {
	Highscore highscore[NUM_HIGHSCORES];
} Highscores;

The Highscore struct will hold details of the score. We've added a score variable and a recent variable. The recent variable will simply be used for highlighting the score, as we'll see later. We also have a Highscores struct, that will hold our array of highscores.

Now we come to the new highscores.c compilation unit. It contains several functions, including its own logic and draw routines. We'll work our way through it, starting with initHighscoreTable:


void initHighscoreTable(void)
{
	int i;

	memset(&highscores, 0, sizeof(Highscores));

	for (i = 0 ; i < NUM_HIGHSCORES ; i++)
	{
		highscores.highscore[i].score = NUM_HIGHSCORES - i;
	}
}

The above function is called while we're setting up the game (in much the same way as we do with fonts and sounds). This code memsets our highscores object (declared to highscores.h) and then loops through each highscore object, assigning them a value of NUM_HIGHSCORES minus their index. This means that our scores will go from 8 to 1.

Our initHighscores function is next:


void initHighscores(void)
{
	app.delegate.logic = logic;
	app.delegate.draw = draw;

	memset(app.keyboard, 0, sizeof(int) * MAX_KEYBOARD_KEYS);
}

This function will assign our delegate's logic and draw function pointers to the ones declared locally and also clears the keyboard entry. We clear the keyboard entry to prevent the game from jumping between the highscore table and the game itself instantaneously, and causing the player confusion. We can see why we needed this next:


static void logic(void)
{
	doBackground();

	doStarfield();

	if (app.keyboard[SDL_SCANCODE_LCTRL])
	{
		initStage();
	}
}

The logic function drives the background and starfield, and also looks to see if the left control key is pushed down (indicating that the player has pressed the fire control to start playing). If we didn't clear the keyboard during the initHighscore function, this could potentially become true right away and lead to the highscore table moving immediately onto the game, which we don't want. When the fire key is pressed, the initStage function is called to start the game.

Moving onto the draw routine, we are calling three functions: drawBackground, drawStarfield, and drawHighscores:


static void draw(void)
{
	drawBackground();

	drawStarfield();

	drawHighscores();
}

We've seen the first two in previous tutorials (and will be touched upon during our refactoring discussion later), so let's look at drawHighscores:


static void drawHighscores(void)
{
	int i, y;

	y = 150;

	drawText(425, 70, 255, 255, 255, "HIGHSCORES");

	for (i = 0 ; i < NUM_HIGHSCORES ; i++)
	{
		if (highscores.highscore[i].recent)
		{
			drawText(425, y, 255, 255, 0, "#%d ............. %03d", (i + 1), highscores.highscore[i].score);
		}
		else
		{
			drawText(425, y, 255, 255, 255, "#%d ............. %03d", (i + 1), highscores.highscore[i].score);
		}

		y += 50;
	}

	drawText(425, 600, 255, 255, 255, "PRESS FIRE TO PLAY!");
}

We start by drawing the text "HIGHSCORES" and then looping through all our highscore objects, drawing the position and score as we go along, via our drawText function. One thing we're doing is checking to see if the recent variable of the highscore is true (1). If so, we draw the text in yellow. Otherwise, scores are drawn in white. This serves to highlight the most recent entry in the highscore table and better inform the player of where their last attempt ended up. Finally, we draw the text PRESS FIRE TO PLAY! to tell the player what they need to do to start the game.

Another (very important) function in this file is addHighscore:


void addHighscore(int score)
{
	Highscore newHighscores[NUM_HIGHSCORES + 1];
	int i;

	for (i = 0 ; i < NUM_HIGHSCORES ; i++)
	{
		newHighscores[i] = highscores.highscore[i];
		newHighscores[i].recent = 0;
	}

	newHighscores[NUM_HIGHSCORES].score = score;
	newHighscores[NUM_HIGHSCORES].recent = 1;

	qsort(newHighscores, NUM_HIGHSCORES + 1, sizeof(Highscore), highscoreComparator);

	for (i = 0 ; i < NUM_HIGHSCORES ; i++)
	{
		highscores.highscore[i] = newHighscores[i];
	}
}

As one might expect, this function is called when we want to add a score to the highscore table. We can call this at any time, without needing to check to see if a highscore has been achieved; the function will take care of all of that for us, as we'll see. We start by allocating an array of Highscore objects. We declare the size of this to be one more than the number of highscores we will show (NUM_HIGHSCORES). We then copy all the existing highscore objects into our new array, at the same time setting recent of each to 0. With this done, we set the values of the last highscore object in the array to the score value that we passed into the function and tell recent to be 1. We then sort the array by calling a function called qsort, passing over our score array, the number of items in the array, and a pointer to a function called highscoreComparator (more on this in a bit). With the scores sorted, we copy each item of newHighscores into the existing highscores array.

What this all means is that we'll let a sorting algorithm do the work of deciding where the most recent score should be in the table (even if it exists outside of it!). Finally, let's look at the highscoreComparator function:


static int highscoreComparator(const void *a, const void *b)
{
	Highscore *h1 = ((Highscore*)a);
	Highscore *h2 = ((Highscore*)b);

	return h2->score - h1->score;
}

This function return an integer (positive or negative) to tell the qsort function how it should position the two candidate items (a and b). In our case, we want higher scores to be closer to the start of the array and lower ones to be moved to the end. You can read more about the qsort function here: https://www.cplusplus.com/reference/cstdlib/qsort/

With all our highscore functions in place, we can look at integrating it. We've made one such call in stage.c:


static void logic(void)
{
	...
	if (player == NULL && --stageResetTimer <= 0)
	{
		addHighscore(stage.score);

		initHighscores();
	}

In our logic function, instead of resetting the stage when the player is killed, we add their score to the highscore table (by calling addHighscore), and then returning to the highscore table by calling initHighscores. We also update main.c to display the highscore table right away:


int main(int argc, char *argv[])
{
	...
	initHighscores();

We used to call initStage here, but have decided not to jump straight into the game.

Now, let's look at all that refactoring, just in case there's some confusion (note that due to the nature of how tutorials tend to run, there could be more of this in the future).

We've created a new function in init.c called initGame:


void initGame(void)
{
	initBackground();

	initStarfield();

	initSounds();

	initFonts();

	initHighscoreTable();

	loadMusic("music/Mercury.ogg");

	playMusic(1);
}

This function will setup all our essentials and start playing the music. Of course, we'll want to call this in main.c:


int main(int argc, char *argv[])
{
	...
	initGame();

We've also added a new macro and define to defs.h:


#define STRNCPY(dest, src, n) strncpy(dest, src, n); dest[n - 1] = '\0'

#define MAX_NAME_LENGTH 32

The macro will be used to limit the amount of text that can be copied into a char array, as well as adding a null terminator. The define is used in conjunction with this. We also want to make sure that we don't load textures more than once. initStage, for example, attempts to load in textures whenever it's called. If we cache these texture and look them up when calling the loadTexture function, we can ensure we don't waste memory:


SDL_Texture *loadTexture(char *filename)
{
	SDL_Texture *texture;

	texture = getTexture(filename);

	if (texture == NULL)
	{
		SDL_LogMessage(SDL_LOG_CATEGORY_APPLICATION, SDL_LOG_PRIORITY_INFO, "Loading %s", filename);
		texture = IMG_LoadTexture(app.renderer, filename);
		addTextureToCache(filename, texture);
	}

	return texture;
}

Whenever there is a request to loadTexture we first check to see if we've loaded it previously, by calling getTexture. If the result of getTexture is NULL then we load the texture as usual and then cache it:


static SDL_Texture *getTexture(char *name)
{
	Texture *t;

	for (t = app.textureHead.next ; t != NULL ; t = t->next)
	{
		if (strcmp(t->name, name) == 0)
		{
			return t->texture;
		}
	}

	return NULL;
}

You'll see we've added a linked list our App struct to hold texture information. When looking for a texture we'll step through the entries and compare the names. If we get a match, we'll return the texture. Whenever we want to cache a texture, we call the addTextureToCache function:


static void addTextureToCache(char *name, SDL_Texture *sdlTexture)
{
	Texture *texture;

	texture = malloc(sizeof(Texture));
	memset(texture, 0, sizeof(Texture));
	app.textureTail->next = texture;
	app.textureTail = texture;

	STRNCPY(texture->name, name, MAX_NAME_LENGTH);
	texture->texture = sdlTexture;
}

Nothing we've not seen before: we're mallocing a Texture object and adding it our linked list. We're also using our STRNCPY macro to copy the name of the texture and truncating it if needed. We're allowing 32 characters for the name, plenty in our case, but could easily extend that in defs.h if needed. Finally, let's look at the changes in structs.h:


struct Texture {
	char name[MAX_NAME_LENGTH];
	SDL_Texture *texture;
	Texture *next;
};

typedef struct {
	...
	Texture textureHead, *textureTail;
} App;

The Texture struct is nothing unexpected - it holds a name, a reference to the SDL_Texture, and a pointer to the next item in the linked list.

There are also a number of other little changes, such as the addition of background.c where our background and starfield handling functions now live. We've done this because the functions are shared by stage.c and highscores.c, and we don't want to duplicate code all over the place.

That's it for our highscore table. Our little tutorial is looking more and more like a proper game all the time. One thing that probably haven't escaped your attention is that we're unable to input our name when we get a highscore. What if you're challenging friends? How will you know who got the best score? In the second part of this tutorial, we'll handle name input. Another thing is that the text isn't very well aligned. What we should do is support positioning of text. We'll look into this, too.

Exercises

  • Randomize the initial scores in the table.
  • Add more than 8 highscores, keeping in mind the amount of screen real estate available.

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