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


The Battle for the Solar System (Complete)

The Pandoran war machine ravaged the galaxy, driving the human race to the brink of destruction. Seven men and women stood in its way. This is their story.

Click here to learn more and read an extract!

« Back to tutorial listing

— Making a 2D split screen game —
Part 11: Gooooooooooooooal(s)

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

Introduction

Our game is missing something for the players to aim towards; there is no real point to it, other than to play around, collect pods, and shoot down the other player. This part changes all that, with goals being randomly set (for now).

Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./versus11 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. Goals will be randomly set, to aim towards. Either reaching a target score, wiping out all the opponent's lives, or earning the most points within a set time limit (or a combination of all of the above!). Once the match is concluded there is no way to continue, so once you're finished, close the window to exit.

Inspecting the code

As stated, this part will randomly produce goals for the players to aim towards. In our full game, the players will be able to choose what the match should consist of (there won't be an option to make things truly random, as it can be too wild and unpredictable).

Adding in our logic for goals is very easy, as all we need do is check who has reached the target score first, for example.

Over, as ever, to defs.h to start with:


#define ZONE_UNLIMITED_TARGET -1

The new define ZONE_UNLIMITED_TARGET is used to specify that the goal doesn't have a limit. So, if we're applying this to our lives, we'll have unlimited lives. We'll see this more in use in zone.c, later on.

Speaking of lives, let's move over to structs.h, where we've updated Player:


typedef struct
{
	// snipped

	double  spinTimer;
	int     lives;
	Entity *spinInflictor;
} Player;

We've now introduced a variable called `lives`, that will hold the number of lives the player has remaining (not including their current life).

Zone has also been updated:


typedef struct
{
	// snipped

	double     timeRemaining;
	Entity     WORLD;
} Zone;

We've added in a variable called timeRemaining. This will hold the remaining time in our game.

A long while back, we introduced the Game struct, that contains lots of information about our game state. This was so that we could create a config.json file that could be ported across all the tutorial parts, so that one need not constantly fiddle with the settings. We'll have a look at this struct now:


typedef struct
{
	int      zoneNum;
	int      livesLimit;
	int      scoreLimit;
	int      timeLimit;
	uint8_t  aliens;
	Controls controls[NUM_PLAYERS];
	uint8_t  soundVolume;
	uint8_t  musicVolume;
	int      wins[NUM_PLAYERS];
} Game;

zoneNum is the number of the zone we're in (reserved for later use). livesLimit the number of lives the Players have. scoreLimit is the target score. If this score is reached by either player, the match will end. timeLimit is our time limit for the match. After the time has expired, the player with the highest score will win. `aliens` is whether the zone will include alien creatures, that can get in the way of things (also for future use). Finally, `wins` in the total number of wins the Player has achieved so far.

Now, over to zone.c, where the bulk of the changes for this part have taken place. Starting with initZone:


void initZone(void)
{
	memset(&zone, 0, sizeof(Zone));

	game.livesLimit = ZONE_UNLIMITED_TARGET;
	game.scoreLimit = ZONE_UNLIMITED_TARGET;
	game.timeLimit = ZONE_UNLIMITED_TARGET;

	if (rand() % 2 == 0)
	{
		game.livesLimit = 1 + rand() % 10;
	}

	if (rand() % 2 == 0)
	{
		game.scoreLimit = 250 * (1 + rand() % 10);
	}

	if (rand() % 2 == 0)
	{
		game.timeLimit = 1 + rand() % 5;
	}

	if (game.livesLimit == ZONE_UNLIMITED_TARGET && game.scoreLimit == ZONE_UNLIMITED_TARGET && game.timeLimit == ZONE_UNLIMITED_TARGET)
	{
		game.scoreLimit = 1000;
	}

	// snipped

	zone.timeRemaining = game.timeLimit * 60 * FPS;

	gameOverTimer = FPS * 4;

	showOptions = 0;

	winner = NULL;

	state = ZS_IN_PROGRESS;

	app.delegate.logic = logic;
	app.delegate.draw = draw;
}

We currently have no way of configuring our game setup, so for now we're randomly creating the win conditions. We start by setting Game's livesLimit, scoreLimit, and timeLimit to be unlimited, and then giving each a 50/50 chance of having an actual value. At the end, if everything is still unlimited, we're setting to scoreLimit to be 1000, so that the players don't buzz around without anything to do (in our full game, this will actually be a valid choice by the player!).

With that done, we set Zone's timeRemaining according to Game's timeLimit (which is in minutes). We then set a variable called gameOverTimer to 4 seconds. This is a special counter that will kick in once the game's target is met, and the game is over. As we'll see, it adds in a small delay after victory, so the match doesn't end immediately. We then set a variable called `winner` to NULL. `winner` is a pointer to the Player that won the match. Finally, we set a control variable called `state` to ZS_IN_PROGRESS, to say that our game in currently in progress.

Our ZS (Zone State) enums are stored in zone.c itself, since they are only used there. We currently have just two: ZS_IN_PROGRESS and ZS_GAME_OVER, to represent a game in progress, and the game being over.

Now over to `logic`, where we've made some larger changes:


static void logic(void)
{
	double oldDeltaTime;

	oldDeltaTime = app.deltaTime;

	switch (state)
	{
		case ZS_IN_PROGRESS:
			respawnEntities();
			checkGoal();
			break;

		case ZS_GAME_OVER:
			gameOverTimer -= app.deltaTime;

			if (gameOverTimer > FPS)
			{
				app.deltaTime *= 0.25;
			}
			break;

		default:
			break;
	}

	if (gameOverTimer > -FPS * 2)
	{
		doEntities();

		doPods();

		doBullets();

		doParticles();

		doHUD();
	}

	app.deltaTime = oldDeltaTime;

	// snipped
}

Before, we were simply calling all our doEntities, doPods, etc. functions. Now, we're doing a bit more. To begin with, we're storing the value of our App's deltaTime, since we may be manipulating it. Next, we test the current `state`. If we're in ZS_IN_PROGRESS, we'll want to call respawnEntities and checkGoal, to see if any of our goals have been met. We'll see checkGoals in a bit. We don't want to do either of these two things if the game is over. Next, if we're in ZS_GAME_OVER (the game is over), we'll decrease the value of gameOverTime. Notice the next part - we're testing if gameOverTimer is greater than 1 second, and if so we're quartering App's deltaTime. This leads to a nice "slow down" effect when the game is won in certain ways. We need to store the value of the delta so that we can restore it later on.

With that done, we're testing if gameOverTimer is greater than negative than two seconds, and calling all our game processing functions. As you'll see later, we're using 0 as gameOverTimer's base value for various other things. Finally, we restore App's deltaTime to the stored value (oldDeltaTime). Notice that we do this after all our game processing is complete, so that we don't mess up anything that relies on the delta time elsewhere.

Now on to checkGoal:


static void checkGoal(void)
{
	Player *p1, *p2;

	p1 = (Player *)zone.players[0]->data;
	p2 = (Player *)zone.players[1]->data;

	if (game.timeLimit != ZONE_UNLIMITED_TARGET)
	{
		zone.timeRemaining = MAX(zone.timeRemaining - app.deltaTime, 0);

		if (zone.timeRemaining == 0)
		{
			gameOverTimer = 0;

			gameOver();
		}
	}

	if (game.livesLimit != ZONE_UNLIMITED_TARGET && (p1->lives < 0 || p2->lives < 0))
	{
		gameOver();
	}

	if (game.scoreLimit != ZONE_UNLIMITED_TARGET && (p1->score >= game.scoreLimit || p2->score >= game.scoreLimit))
	{
		gameOver();
	}
}

As we saw, this is invoked only if the game is in ZS_IN_PROGRESS state. This is a very simple function to understand. We test each of our limits, to see if they're not in unlimited state, and react accordingly. For our time limit, we decrease the value of Zone's timeRemaining, and call a function named gameOver if it hits 0 (notice that we're also setting gameOverTimer to 0 immediately, to avoid the slowdown effect, and end things sooner).

For the others, we're checking if either player has run out of lives, or if they have hit the score limit.

Let's look at the gameOver function itself now:


static void gameOver(void)
{
	Player *p1, *p2;

	if (state == ZS_IN_PROGRESS)
	{
		p1 = (Player *)zone.players[0]->data;
		p2 = (Player *)zone.players[1]->data;

		if (p1->lives != -1 && p2->lives == -1)
		{
			winner = zone.players[0];
		}
		else if (p1->lives == -1 && p2->lives != -1)
		{
			winner = zone.players[1];
		}
		else if (p1->score != p2->score)
		{
			winner = p1->score > p2->score ? zone.players[0] : zone.players[1];
		}
		else if (p1->lives != p2->lives)
		{
			winner = p1->lives > p2->lives ? zone.players[0] : zone.players[1];
		}

		if (winner != NULL)
		{
			game.wins[((Player *)winner->data)->num]++;
		}

		state = ZS_GAME_OVER;
	}
}

We're taking a bit of care here, to ensure that we're still in ZS_IN_PROGRESS state, before determining the winner. There are a number of steps here we're using to determine our winner. First, if we determine one player has run out of lives, the opponent wins. If this isn't the case, we check which player has the higher score. If this is a tie, we finally check which player has more lives remaining. If, after this, we have a winner, we'll increase the number of wins for that player, in Game. Finally, we set state to ZS_GAME_OVER, so we only determine the winner once. The match is considered a draw is `winner` is NULL.

That's all our logic handled, so now we can look at the rendering changes. To start with, we've updated `draw`:


static void draw(void)
{
	int         i;
	SDL_FPoint *c;

	for (i = 0; i < NUM_PLAYERS; i++)
	{
		SDL_SetRenderTarget(app.renderer, playerViewportTexture);

		// snipped

		drawGameOver(i);

		SDL_SetRenderTarget(app.renderer, NULL);

		blit(playerViewportTexture, (SCREEN_WIDTH / 2) * i, 0, 0, SDL_FLIP_NONE);
	}

	// snipped
}

We're calling a new function here named drawGameOver, passing over the number of the player (`i`).

drawGameOver is another easy to understand function:


static void drawGameOver(int i)
{
	char text[16];

	if (gameOverTimer <= 0)
	{
		if (zone.players[i] == winner)
		{
			STRCPY(text, "WINNER!");

			app.font.rainbow = 1;
		}
		else if (winner != NULL)
		{
			drawRect(0, 0, SCREEN_WIDTH / 2, SCREEN_HEIGHT, 0, 0, 0, 168);

			STRCPY(text, "LOSER!");
		}
		else
		{
			drawRect(0, 0, SCREEN_WIDTH / 2, SCREEN_HEIGHT, 0, 0, 0, 168);

			STRCPY(text, "DRAW!");
		}

		app.font.scale = 2.5;

		drawText(text, (SCREEN_WIDTH / 4), (SCREEN_HEIGHT / 2) + 50, 255, 255, 255, TEXT_ALIGN_CENTER, 0);

		app.font.rainbow = 0;

		app.font.scale = 0.75;

		sprintf(text, "%d win%c", game.wins[i], game.wins[i] == 1 ? '\0' : 's');

		drawText(text, (SCREEN_WIDTH / 4), (SCREEN_HEIGHT / 2) + 175, 192, 192, 192, TEXT_ALIGN_CENTER, 0);
	}
}

This function is basically responsible for drawing the "WINNER" / "LOSER / "DRAW" text that comes up on the screen when the game ends. We test first if gameOverTimer is 0 or less, and then determine what text to draw. If the current player (indexed by `i` in Zone's `players`) is the winner, we'll prepare to print WINNER. If this player isn't the winner (and `winner` isn't NULL), then we'll darken the screen and prepare to print LOSER!. If winner is NULL, we'll consider the match a draw, and prepare to print DRAW. We'll then print the text we prepared, as well as the total number of wins, just beneath it.

Note that when displaying our WINNER text, we're doing so with a rainbow effect. This is handled in text.c and draw.c.

That's all for zone.c. As I said earlier, this is where the bulk of the changes have been made. We've made some misc. changes elsewhere, that we'll look at next.

First, over to player.c, where we've updated initPlayer:


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

	// snipped

	p->num = num;

	if (game.livesLimit != ZONE_UNLIMITED_TARGET)
	{
		p->lives = game.livesLimit - 1;
	}

	// snipped
}

Now, when setting up a Player, if we're not playing a match with unlimited lives, we'll set their lives to the number of lives in Game's livesLimit, less one (consumed upon creation)

A change to takeDamage follows:


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);

		if (attacker->type == ET_PLAYER && attacker != self && p->health == 0 && !self->dead)
		{
			((Player *)attacker->data)->score += 100;
		}

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

Now, as well as flagging the player as `dead` if their `health` hits 0, we're testing if the `attacker` is the other player. If so, we're increasing the opponent's `score` by 100 points.

Next, over to `die`:


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

	// snipped

	if (game.livesLimit != ZONE_UNLIMITED_TARGET)
	{
		p->lives--;
	}
}

Whenever the `die` function is called for the player, we'll deducted one of their remaining lives, if, again, this isn't a match with unlimited lives.

That's it for player.c. To finish off, we'll jump over to hud.c, where we've made some changes to display the game limits. To begin with, we've updated drawHUD:


void drawHUD(void)
{
	drawLimits();

	drawBottomBars();
}

We're calling a new function named drawLimits:


static void drawLimits(void)
{
	int       m, s, scoreY;
	char      text[16];
	SDL_Color c;

	scoreY = 30;

	if (game.timeLimit != ZONE_UNLIMITED_TARGET)
	{
		s = (int)ceil(zone.timeRemaining / FPS);
		m = s / 60;

		s %= 60;

		sprintf(text, "%02d:%02d", m, s);

		app.font.scale = 0.5;

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

		if (m == 0 && s <= 10)
		{
			app.font.scale = 1;

			if (s > 0 && ((int)zone.timeRemaining % 30 < 15))
			{
				c.g = c.b = 0;
			}

			scoreY = 55;
		}

		drawText(text, SCREEN_WIDTH / 2, 5, c.r, c.g, c.b, TEXT_ALIGN_CENTER, 0);
	}

	if (game.scoreLimit != ZONE_UNLIMITED_TARGET)
	{
		sprintf(text, "Score Limit: %d", game.scoreLimit);

		app.font.scale = 0.5;

		drawText(text, SCREEN_WIDTH / 2, scoreY, 255, 255, 255, TEXT_ALIGN_CENTER, 0);
	}

	app.font.scale = 1;
}

This function is responsible for drawing our time limit and our score limit. We check first if there is a time limit, and then prepare the minutes (`m`) and seconds (`s`) remaining, using sprintf to format them into a char buffer called `text`. By default, we'll be rendering the time limit in white, at a small text size. If there is only 10 seconds left on the clock, however, we'll increase the size of the text, and render it in red every half a second. This will draw the player's attention to the fact that time is running out.

We render the score limit just below this (always in white, always at the same size, and vertically adjusted based on the size of the time limit text).

Lastly, we've made an update to drawBottomBars:


static void drawBottomBars(void)
{
	int     i;
	Player *p;
	char    text[16];

	drawRect(0, SCREEN_HEIGHT - 25, SCREEN_WIDTH, 30, 0, 0, 0, 200);

	app.font.scale = 0.5;

	for (i = 0; i < NUM_PLAYERS; i++)
	{
		// snipped

		if (game.livesLimit != ZONE_UNLIMITED_TARGET)
		{
			sprintf(text, "Lives: %d", MAX(p->lives, 0));
			drawText(text, (SCREEN_WIDTH / 2) * (i + 1) - 165 - PADDING, SCREEN_HEIGHT - 28, 255, 255, 255, TEXT_ALIGN_RIGHT, 0);
		}

		sprintf(text, "Score: %05d", p->score);
		drawText(text, (SCREEN_WIDTH / 2) * (i + 1) - PADDING, SCREEN_HEIGHT - 28, 255, 255, 255, TEXT_ALIGN_RIGHT, 0);
	}

	app.font.scale = 1;
}

For both players, we're now rendering their `lives` (if applicable to the match), and their `score`.

There we go! We now have a match that can be played and completed by the players. We still need to establish a proper game loop, so the next match can start after this one is concluded, but otherwise all the pieces are in place.

Now, a little while back I made a comment about some readers might've become aware of "trouble brewing". Ths is due to the fact that we're doing many, many more collision checks than we need. For example, when bullets are fired, they are testing every triangle of the Zone, as well as every entity. The entities are doing likewise. While this is a simple game and most modern computers won't be stretched by it, the number of collision checks can be greatly reduced.

So, in the next part, we're going to look at adding in a spatial grid, to solve this problem.

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