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
Medals (Achievements)
2D turn-based strategy game
SDL 1 tutorials (outdated)

Latest Updates

SDL2 turn-based strategy tutorial
Thu, 14th April 2022

Water Closet ported to PlayStation Vita
Tue, 4th January 2022

The Legend of Edgar 1.35
Sat, 1st January 2022

Achievements tutorial
Thu, 2nd December 2021

SDL2 Rogue tutorial
Thu, 30th September 2021

All Updates »

Tags

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

Books


The Red Road

For Joe Crosthwaite, surviving school was about to become more than just a case of passing his exams ...

Click here to learn more and read an extract!

« Back to tutorial listing

— Creating an in-game achievement system —
Part 4: A full game!

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

Introduction

Our medal system is working very well now - we can earn medals, and our progress can be saved. What we should look at next is how we can integrate it into a full game. In this part, we'll be looking at how Medals are used in UFO Rescue! a small game involving the rescue of stranded aliens.

Extract the archive, run make, and then use ./medals04 to run the code. You will see a window open, displaying a title screen. Use the arrow keys to navigate the menu, Return or Space to select a menu option, and Escape to back out of a menu. To start the game, select Start and then choose a level to play. The goal of the game is to rescue all the stranded aliens, using your tractor beam, and return them to the green portal. Use the WASD control scheme to pilot your UFO around. Hold J to activate the tractor beam. Pressing Escape pauses the game and brings up the in-game menu.

Fly over to an alien and pick him up with your tractor beam, then return him to the portal. Watch your energy level, as this will decrease as you fly and use your tractor beam. Batteries restore your energy level. If you fly into a wall at high speed, you will take damage. If you lose all your health, your ship will explode. However you have unlimited lives. Don't drop aliens - they will die upon impact with the ground!

Close the window to exit (note - your game will only be saved at the end of stage).

Inspecting the code

Being a full game, there is quite a lot happening in UFO Rescue! However, we are only interested in the parts of the code that interact with our Medals system, as everything is out of scope.

Starting with defs.h:


enum {
	STAT_ALIENS_CAUGHT,
	STAT_ALIENS_KILLED,
	STAT_ALIENS_RESCUED,
	STAT_BATTERIES_COLLECTED,
	STAT_DAMAGE_TAKEN,
	STAT_ENERGY_USED,
	STAT_DEATHS,
	STAT_TIME_PLAYED,
	STAT_MAX
};

We have an enum for our stats, including rescuing aliens, collecting batteries, the time we played, etc.

We also have an enum for the level ranks:


enum {
	RANK_NONE,
	RANK_C,
	RANK_B,
	RANK_A,
	RANK_MAX
};

These are used to determine how well we played a level. Rescuing all the aliens within the time limit will award us with an A rank, outside of the time limit will grant us a B rank, while losing an alien will result in a C rank. We'll see more on all of these in a little bit.

Moving on to structs.h now, we can see how our Game struct is tweaked:


typedef struct {
	int soundVolume;
	int musicVolume;
	double stats[STAT_MAX];
	int stageRanks[NUM_STAGES];
	Medal medalsHead;
} Game;

`stats` is now an array of doubles, rather than an array of ints. This is to support floating point timers, such as the amount of energy used. stageRanks is an array of ints, to hold the ranks for each stage in the game (for a total of 5).

If we now move over to medals.c, we can see we've added in a few new functions, to help with displaying the medal information. Starting with initMedalsDisplay:


void initMedalsDisplay(void (*_postMedalDisplay)(void))
{
	postMedalDisplay = _postMedalDisplay;

	startMedalIndex = 0;

	app.activeWidget = getWidget("back", "medals");
}

The only relevant part of this function that is in scope for this tutorial is startMedalIndex, that we're setting to 0. startMedalIndex is a variable that will determine where in the medal display we start from when listing our medals.

Moving onto doMedalsDisplay, we can see this variable in use:


void doMedalsDisplay(void)
{
	if (app.keyboard[SDL_SCANCODE_UP])
	{
		startMedalIndex = MAX(startMedalIndex - 1, 0);

		app.keyboard[SDL_SCANCODE_UP] = 0;
	}

	if (app.keyboard[SDL_SCANCODE_DOWN])
	{
		startMedalIndex = MIN(startMedalIndex + 1, numMedals - 1);

		app.keyboard[SDL_SCANCODE_DOWN] = 0;
	}

	if (app.keyboard[SDL_SCANCODE_ESCAPE])
	{
		back();

		app.keyboard[SDL_SCANCODE_ESCAPE] = 0;
	}

	doWidgets("medals");
}

doMedalDisplay is responsible for letting us navigate our list of medals, using the Up and Down arrow keys to scroll through the list. We first check if Up has been pressed. If so, we're decreasing the value of startMedalIndex, limiting it to 0. We're also clearing the Up key, so we don't scroll through the list too fast. Next, we're testing if Down has been pressed. If it has, we're increasing the value of startMedalIndex. We're ensuring that this doesn't exceed the value of numMedals (less 1), so we don't go past the end of our list. We're also clearing the Down key.

For the remainder of the function, we're checking if Escape has been pressed to exit the medal display. We're also calling doWidgets, to process our medal display widgets.

drawMedalsDisplay is the next function we're interested in. There's quite a lot to it:


void drawMedalsDisplay(void)
{
	Medal *m;
	int i, h, y;

	drawRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT, 0, 0, 0, 168);

	app.fontScale = 3;

	drawText("Medals", SCREEN_WIDTH / 2, 35, 255, 255, 255, TEXT_ALIGN_CENTER, 0);

	app.fontScale = 0.7;

	drawText("Use [Up] and [Down] to scroll", SCREEN_WIDTH / 2, 155, 192, 192, 192, TEXT_ALIGN_CENTER, 0);

	app.fontScale = 1.0;

	y = 225;

	i = 0;

	for (m = game.medalsHead.next ; m != NULL ; m = m->next)
	{
		if (i >= startMedalIndex)
		{
			h = getWrappedTextHeight(m->description, 700);

			if (m->awardDate != 0)
			{
				blitAtlasImage(medalTextures[m->type], SCREEN_WIDTH / 2, y, SDL_FLIP_NONE, 1);

				drawText(m->title, (SCREEN_WIDTH / 2) - 35, y, 255, 255, 255, TEXT_ALIGN_RIGHT, 0);

				drawText(m->description, (SCREEN_WIDTH / 2) + 60, y, 255, 255, 255, TEXT_ALIGN_LEFT, 700);

				drawText(timeToISODate(m->awardDate), (SCREEN_WIDTH / 2) + 60, y + h, 225, 225, 255, TEXT_ALIGN_LEFT, 700);

				y += 40;
			}
			else if (!m->hidden)
			{
				blitAtlasImage(unearnedMedalTexture, SCREEN_WIDTH / 2, y, SDL_FLIP_NONE, 1);

				drawText(m->title, (SCREEN_WIDTH / 2) - 35, y, 128, 128, 128, TEXT_ALIGN_RIGHT, 0);

				drawText(m->description, (SCREEN_WIDTH / 2) + 60, y, 128, 128, 128, TEXT_ALIGN_LEFT, 700);
			}
			else
			{
				blitAtlasImage(unearnedMedalTexture, SCREEN_WIDTH / 2, y, SDL_FLIP_NONE, 1);

				drawText("?????", (SCREEN_WIDTH / 2) - 35, y, 128, 128, 128, TEXT_ALIGN_RIGHT, 0);

				drawText("?????", (SCREEN_WIDTH / 2) + 60, y, 128, 128, 128, TEXT_ALIGN_LEFT, 0);
			}

			y += h + 16;

			if (y > 650)
			{
				break;
			}
		}

		i++;
	}

	drawWidgets("medals");
}

This function is used to draw our medals list, that is displayed whenever we select "Medals" from the game menus.

We start by calling drawRect, passing in the screen dimensions and a transparent black RGBA, to darken the screen. Next, we're scaling our font up to 3, before drawing the "Medals" text. We're then scaling it back down to 0.7 before rendering the control method hints, and then returning the scale to normal (1.0).

Next, we set the value of a variable called `y` to 255. `y` is the vertical location that we will begin rendering our medals list from. We're next setting a variable called `i` to 0. This variable is being used to track the medal number in our current list. We then begin looping through our medals linked list. For each one, we're testing to see if `i` is greater or equal to startMedalIndex. As we will incrementing the value of `i` during each iteration of our loop, this means if startMedalIndex is 0, we will start handing our medals immediately. If it's 1, we'll skip the first medal. If 2, the first 2, etc.

Once we begin processing our medals, the first thing we do is call getWrappedTextHeight using the medal's `description`, to find out the height of the description text when constrained to 700 pixels (assigning the result to `h`). Next, we test the medal's awardDate. If it's not 0 (meaning it's been awarded), we will call blitAtlasImage to draw the medal's image, according to its `type`, then render its title, description, and award date. As our description can be long, we're adding on the value of `h` before drawing the award date, so that is correctly positioned below. Finally, we increase the value of `y` by 40, to add extra padding before we come to the next medal.

If the medal hasn't been awarded yet, we test to see if it's not `hidden`. If not, we call blitAtlasImage, passing over a texture to show a dull medal (unearnedMedalTexture). We're then drawing the title and description as we did with the earned medal.

Finally, if the medal is `hidden`, we're drawing the unearned medal texture, and a series of question marks (?????) in place of the title and description. This prevents the player from seeing what the medals are awarded for; this is a good way to stop players from having elements of a story, etc. spoiled for them if they happen to look at the in-game medals list before reaching those points in a game.

With that all done, we add the value of `h` (our wrapped description height) to `y`, plus an additional 16 pixels for padding. We then test to see if `y` is greater than 650. If so, we'll break out of our medals for-loop. This reason for this is because we don't want to render any more items if they pass a certain vertical point on the screen; it will look messy, and so we're testing here that we've not passed that point. This isn't quite the best way to do this, but in this instance it meets our requirements. Finally, we're calling drawWidgets and passing over "medals".

That's our medals display handled. We can now move onto how the medals are actually awarded and processed in our game. Moving across to stage.c ...

Medal unlocking works a little different in UFO Rescue! compared to the previous examples; we're only awarding medals once the player has finished a stage, whether they pass or fail it. The reason for this is because we don't want the medal notification to get in the way of gameplay. Once a stage is done, we call doPostStageMedals, to award any medals the player unlocked during play:


static void doPostStageMedals(void)
{
	int i, ranks[RANK_MAX];

	for (i = 0 ; i < STAT_MAX ; i++)
	{
		game.stats[i] += stage.stats[i];
	}

	if (game.stats[STAT_ALIENS_RESCUED] >= 10)
	{
		awardMedal("rescue10");
	}

	if (game.stats[STAT_ALIENS_RESCUED] >= 25)
	{
		awardMedal("rescue25");
	}

	if (game.stats[STAT_ALIENS_RESCUED] >= 100)
	{
		awardMedal("rescue100");
	}

	if (game.stats[STAT_BATTERIES_COLLECTED] >= 50)
	{
		awardMedal("batteries50");
	}

	// used energy for 5 minutes
	if (game.stats[STAT_ENERGY_USED] >= FPS * 300)
	{
		awardMedal("energy300");
	}

	// used energy for 10 minutes
	if (game.stats[STAT_ENERGY_USED] >= FPS * 600)
	{
		awardMedal("energy600");
	}

	if (game.stats[STAT_ALIENS_CAUGHT] >= 5)
	{
		awardMedal("catch");
	}

	if (game.stats[STAT_DAMAGE_TAKEN] >= 35)
	{
		awardMedal("paintwork");
	}

	if (stage.stats[STAT_DEATHS] >= 5)
	{
		awardMedal("sundayDriver");
	}

	if (stage.stats[STAT_ALIENS_KILLED] == stage.numAliens)
	{
		awardMedal("p45");
	}

	if (stage.status == SS_COMPLETE)
	{
		memset(ranks, 0, sizeof(int) * RANK_MAX);

		for (i = 0 ; i < NUM_STAGES ; i++)
		{
			ranks[game.stageRanks[i]]++;
		}

		if (ranks[RANK_A] > 0)
		{
			awardMedal("rankA");

			awardMedal("rankB");
		}

		if (ranks[RANK_B] > 0)
		{
			awardMedal("rankB");
		}

		if (ranks[RANK_A] + ranks[RANK_B] >= 3)
		{
			awardMedal("3RankB");
		}

		if (ranks[RANK_A] >= 3)
		{
			awardMedal("3RankA");
		}

		if (ranks[RANK_A] + ranks[RANK_B] + ranks[RANK_C] == NUM_STAGES)
		{
			awardMedal("finishAll");

			if (ranks[RANK_A] + ranks[RANK_B] == NUM_STAGES)
			{
				awardMedal("allB");
			}

			if (ranks[RANK_A] == NUM_STAGES)
			{
				awardMedal("allA");
			}
		}

		if (stage.id == 0)
		{
			awardMedal("stage1");

			if (stage.timeLimit / FPS >= 5 && stage.stats[STAT_ALIENS_KILLED] == 0 && stage.stats[STAT_DAMAGE_TAKEN] == 0 && stage.stats[STAT_BATTERIES_COLLECTED] == 0)
			{
				awardMedal("perfect");
			}
		}
	}
}

Both our `stage` and `game` have their own `stats` arrays, so that the stats can be handled and tested separately. The first thing we therefore do is add all of `stage`'s `stats` to `game`'s `stats`, using a for-loop. We then begin testing a number of stats, to see if they have met the requirements for unlocking medals. For example, STAT_ALIENS_RESCUED with a value of 10 or more will unlock "rescue10". STAT_BATTERIES_COLLECTED with a value of 50 or more will unlock "batteries50", etc.

Once we've handled all our stats medals, we test to the value of `stage`'s `status`. If it's SS_COMPLETE (meaning the player achieved at least a rank of C), we're going to begin testing the rank medals. We start by memsetting a variable called `ranks`, an int array that will store the total number of A, B, and C ranks the player has earned. We use a for-loop to set the value of the appropriate index to the rank of the stage in `game`'s stageRanks array. We then test the values of these ranks. If we have one or more A rank (ranks[RANK_A] > 0), we call awardMedal, passing over "rankA" and "rankB". In our game, there is a medal awarded for completing a stage with Rank A and one for Rank B or better. Our medal unlocking therefore has to award both these medals if a ranking of A is acheived; it would be no good to expect the player to earn exactly a Rank A and Rank B.

The rest of the rank unlocking follows a similar pattern of testing the value of the ranks and unlocking the appropriate medals. In some cases, such as "3RankB" (complete 3 stages of rank B or better), we're adding the number of Rank As and Rank Bs together, to see if the threshold has been met. This is also the case for "finishAll", where we're testing if the total number of ranks equals the total number of stages in the game.

Finally, there is a special check for two medals that only apply to the first stage (zero indexed). We award "stage1" for completing the stage, and then also test that the player has finished the stage within a particular set of constraints, before awarding them the "perfect" medal. You may have encountered games in which trophies and achievements are awarded for performing a task in a certain way, such as to complete level #4 in under 8 minutes, while also defeating 40 enemies, and without using magic - such trophies and achievements would be tested in a similar way.

That's largely all there is to the integration. Before we finish up, we'll take a quick look at how the stats are handled in a few places.

Starting with portal.c. The `touch` function bumps a stat:


static void touch(Entity *self, Entity *other)
{
	int x, y;

	if (other != NULL && other->type == ET_STRANDED_ALIEN)
	{
		x = self->x + self->texture->rect.w / 2;
		y = self->y + self->texture->rect.h / 2;

		if (getDistance(x, y, other->x, other->y) <= 48)
		{
			stage.stats[STAT_ALIENS_RESCUED]++;

			other->dead = 1;

			addAlienRescuedEffect(other->x + (other->texture->rect.w / 2), other->y + (other->texture->rect.h / 2));

			playSound(SND_ALIEN_RESCUED, CH_ALIEN_RESCUED);
		}
	}
}

The function checks to see if the thing that has made contact with it is a stranded alien (`other`'s `type` is ET_STRANDED_ALIEN). If so, and the alien is close to the center of the portal (48 pixel radius), we're bumping the stage's STAT_ALIENS_RESCUED stat, and processing the rest of the rescue logic.

Turning to strandedAlien.c, the takeDamage function also bumps a stat:


static void takeDamage(Entity *self, int amount)
{
	addAlienSplatEffect(self->x + (self->texture->rect.w / 2), self->y + self->texture->rect.h);

	self->dead = 1;

	stage.stats[STAT_ALIENS_KILLED]++;

	playSound(SND_ALIEN_DIE, CH_ALIEN_DIE);

	playSound(SND_ALIEN_SPLAT, CH_ALIEN_SPLAT);
}

Whenever a stranded alien takes damage (either from striking the ground at a high speed or by being hit by a ball on stages 4 and 5), the alien will be killed and the stage's STAT_ALIENS_KILLED stat will be incremented by 1.

Finally, turning to player.c, the takeDamage function also bumps some stats:


static void takeDamage(Entity *self, int amount)
{
	Alien *a;

	a = (Alien*) self->data;

	if (a->immuneTimer == 0)
	{
		if (--a->life <= 0)
		{
			self->dead = 1;
		}

		a->immuneTimer = FPS;

		stage.stats[STAT_DAMAGE_TAKEN]++;

		playSound(SND_PLAYER_HURT, -1);

		if (self->dead)
		{
			playSound(SND_PLAYER_DIE, -1);

			addUFOExplosionEffect(self->x + self->texture->rect.w / 2, self->y + self->texture->rect.h / 2);

			((Beam*) beam->data)->active = 0;

			stage.stats[STAT_DEATHS]++;
		}
	}
}

If the player isn't currently immune, we'll be bumping `stage`'s STAT_DAMAGE_TAKEN stat. Subsequently, if the player's `dead` flag has been set (they have lost all their `life`), the stage's STAT_DEATHS stat is incremented.

These are just three examples of where we're updating stats throughout our game logic, so that they can be processed once the stage is finished.

Hopefully now you've got a good idea of how to create and implement an achievement / trophy system into a game. What would be fun to do next is to connect these medals online, so players can optionally view their medals and friends' medals online. In the next part, we'll look at how to use SDL Net to achieve this.

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:

Directly

If you do not wish to create an itch.io account, you can also purchase the tutorial bundle using PayPal, and then download the tutorials directly from the main tutorials page.

SDL2_Tutorials.tar.gz 56.76MB 23rd April 2022

Click here to see the list of files in the archive

Mobile site