« Back to tutorial listing

— Making a 2D split screen game —
Part 18: Finishing touches

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

Introduction

Our game is more or less finished now. In this final part, we'll be adding in a handful of little features, as well as sound and music. None of these things are required for our game, and are really just quality of life updates.

Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./versus18 to run the code. Play the game as normal, enjoying the sound and music, the messages when collecting items and scoring points, and the new countdown timer when a match begins. Once you're finished, close the window to exit, or Choose Quit from the main title options.

Inspecting the code

Adding in all our finishing touches requires us to hop into almost every file in our game. We'll be skipping over the sound and music code, as this has been seen many times before. We'll instead concentrate on the things unique to our game.

Starting first with structs.h:


struct InfoText
{
	char       text[MAX_NAME_LENGTH];
	double     health;
	SDL_FPoint position;
	SDL_Color  color;
	InfoText  *next;
};

We've added in a new struct to define our information message when we collect items, etc. InfoText will hold this data. `text` is the message text; `health`, how long the text lives for; `position`, where it is in our zone; `color`, the colour of the text.

Let's now look at infoText.c, where we've added in all our functions for handling the information text. Nothing here will really come as a surprise.

Starting with initInfoText:


void initInfoText(void)
{
	memset(&head, 0, sizeof(InfoText));
	tail = &head;
}

We're setting up a linked list here, using `head` and `tail`, static variables in infoText.c.

Next up, we have addInfoText:


void addInfoText(char *text, SDL_FPoint position, int r, int g, int b)
{
	InfoText *it;

	it = malloc(sizeof(InfoText));
	memset(it, 0, sizeof(InfoText));
	tail->next = it;
	tail = it;

	STRCPY(it->text, text);
	it->position = position;
	it->health = FPS;
	it->color.r = r;
	it->color.g = g;
	it->color.b = b;
	it->color.a = 255;
}

This function creates an InfoText item and adds it to our linked list. `text` is the text we want to display. `position`, where the text should appear, and `r`, `g`, `b` are its colour values. Again, we're simply creating an InfoText object and setting all its values. All our InfoText objects will live for just 1 second (FPS).

Next up, we have doInfoText:


void doInfoText(void)
{
	InfoText *it, *prev;

	prev = &head;

	for (it = head.next; it != NULL; it = it->next)
	{
		it->health -= app.deltaTime;
		it->position.y -= app.deltaTime;

		if (it->health <= 0)
		{
			if (it == tail)
			{
				tail = prev;
			}

			prev->next = it->next;

			free(it);

			it = prev;
		}

		prev = it;
	}
}

We're just processing the InfoText objects here. For each interation, we're decreasing the `health` of the InfoText, and also decreasing its position's `y` value. This will make the text move up the screen while it is still alive, just for aesthetic purposes, of course. Our InfoText will be deleted if its `health` falls to 0 or less.

drawInfoText follows:


void drawInfoText(SDL_FPoint *camera)
{
	InfoText *it;
	double    x, y;

	app.font.scale = 0.5;

	for (it = head.next; it != NULL; it = it->next)
	{
		x = it->position.x - camera->x;
		y = it->position.y - camera->y;

		drawText(it->text, x, y, it->color.r, it->color.g, it->color.b, TEXT_ALIGN_CENTER, 0);
	}

	app.font.scale = 1;
}

This function accepts a camera position as an argument (as an SDL_FPoint). As one might expect, we're adjusting the rendering position of the text based on the camera location. With that known (`x` and `y` as the draw position coordinates), we call drawText, passing over the InfoText's `text` and `color` data. We'll be drawing the text centered horizontally.

Finally, we have clearInfoText:


void clearInfoText(void)
{
	InfoText *it;

	while (head.next != NULL)
	{
		it = head.next;

		head.next = it->next;

		free(it);
	}
}

We're just emptying our linked list here, to delete all the InfoText.

Great! We've defined our InfoText. There was nothing there that was difficult to work with or understand. In summary, we're just drawing some text on screen, that will exist for a second, drawn in the colour of our choosing.

Let's look at some examples of how we're using it. First, over to pod.c, where we've updated the `touch` function:


static void touch(Entity *self, Entity *other)
{
	Pod    *p;
	Player *pl;

	p = (Pod *)self->data;

	if (other->type == ET_PLAYER)
	{
		pl = (Player *)other->data;

		switch (p->type)
		{
			case PT_SCORE:
				pl->score += 25;
				playSound(SND_POD_POINTS, CH_ANY);
				addInfoText("+25pts", self->position, 255, 255, 0);
				break;

			case PT_HEALTH:
				pl->health = MAX_PLAYER_HEALTH;
				playSound(SND_HEALTH_POD, CH_ANY);
				addInfoText("Health", self->position, 0, 255, 0);
				break;

			case PT_SHIELD:
				pl->shield = MAX_PLAYER_SHIELD;
				playSound(SND_SHIELD_POD, CH_ANY);
				addInfoText("Shield", self->position, 192, 192, 255);
				break;

			case PT_AMMO:
				pl->rockets = MAX_ROCKETS;
				playSound(SND_AMMO_POD, CH_ANY);
				addInfoText("Rockets", self->position, 255, 128, 0);
				break;

			default:
				break;
		}

		self->dead = 1;
	}
}

Now, when a Pod is collected by a Player, we'll call addInfoText, to display some information about what the Pod contained (points, health, etc). Notice that we're using different colours, based on the type of Pod that was collected.

Likewise, we've updated takeDamage in aliens.c:


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

	// snipped

	switch (a->type)
	{
		case AT_RED:
			((Player *)attacker->data)->score += 100;
			addInfoText("+100pts", self->position, 255, 255, 0);
			break;

		case AT_ORANGE:
		case AT_PURPLE:
			((Player *)attacker->data)->score += 50;
			addInfoText("+50pts", self->position, 255, 255, 0);
			break;

		default:
			break;
	}
}

We're displaying the number of points that the Player earned for defeating an alien.

Now to zone.c, where we've made some changes to accommodate our InfoText, and also added in some other quality of life updates.

First to initZone:


void initZone(int n)
{
	// snipped

	initHUD();

	initInfoText();

	if (playerViewportTexture == NULL)
	{
		playerViewportTexture = SDL_CreateTexture(app.renderer, SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, SCREEN_WIDTH / 2, SCREEN_HEIGHT);
	}

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

	gameStartTimer = FPS * 2;

	gameOverTimer = FPS * 4;

	showOptions = 0;

	winner = NULL;

	state = ZS_GAME_START;

	doEntities();

	app.transitionTimer = FPS / 2;
	app.delegate.logic = logic;
	app.delegate.draw = draw;
}

First, we've added the call to initInfoText. Next, we've added in a new static variable called gameStartTimer, that we're setting to 2 seconds. This is responsible for displaying our countdown timer when a match first starts. We've also added in a new `state` called ZS_GAME_START, that we're setting here.

If we jump down to `logic` next, we can see this being put to use:


static void logic(void)
{
	double oldDeltaTime, oldGameOverTimer;

	oldDeltaTime = app.deltaTime;

	switch (state)
	{
		case ZS_GAME_START:
			gameStartTimer -= app.deltaTime;

			if (gameStartTimer <= 0)
			{
				state = ZS_IN_PROGRESS;
			}
			break;

		// snipped

		default:
			break;
	}

	if (gameStartTimer <= 0 && gameOverTimer > -FPS * 2)
	{
		// snipped

		doHUD();

		doInfoText();
	}
	else if (gameOverTimer < -FPS * 5)
	{
		nextZone();
	}

	// snipped
}

We've added ZS_GAME_START to our `state` switch statement. All this does is decrease the value of gameStartTimer, switching `state` to ZS_IN_PROGRESS once it falls to 0 or less.

We've also accounted for the existance of gameStartTimer in our main game processing. We want gameStartTimer to be 0 or less, as well as gameOverTimer to be greater than -2 seconds in order for our game to process. What this means is that while gameStartTimer is positive, the game won't process any logic, and everything will be frozen in place. We've also added doInfoText to our game processing clause.

Our logic changes are done. Now we can move over to the rendering changes. Starting with `draw`:


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

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

		drawRect(0, 0, SCREEN_WIDTH / 2, SCREEN_HEIGHT, 0, 0, 24, 255);

		c = &zone.cameras[i];

		drawStars(c);

		// snipped

		flushGeometry();

		drawInfoText(c);

		// snipped
	}

	if (!showOptions)
	{
		drawHUD();

		drawRect(SCREEN_WIDTH / 2, 0, 1, SCREEN_HEIGHT, 128, 128, 128, 255);

		drawGameStart();
	}
}

For each of our players, we're now calling drawStars and drawInfoText, passing over the camera (`c`) to each. This is the reason why we added a camera position to drawStars for our title screen, since we were going to extend the usage into the game itself during these finishing touches.

We've also added in a call to a new function named drawGameStart, that will render our countdown timer. Once again, we're doing this only if we're not displaying the game options, since all that text being drawn over other text would result in an unreadable mess!

Over to drawGameStart:


static void drawGameStart(void)
{
	char text[32];

	if (gameStartTimer > 0)
	{
		drawRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT, 0, 0, 0, 128);

		sprintf(text, "Zone #%d", zoneNum);

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

		drawText("Get Ready ...", SCREEN_WIDTH / 2, (SCREEN_HEIGHT / 2) - 100, 255, 255, 255, TEXT_ALIGN_CENTER, 0);

		sprintf(text, "%.2f", gameStartTimer / 100);

		drawText(text, SCREEN_WIDTH / 2, (SCREEN_HEIGHT / 2) - 50, 255, 255, 255, TEXT_ALIGN_CENTER, 0);
	}
}

This is where we render the "Get Ready ..." text that shows up when the match is starting. We test that gameStartTimer is greater than 0 before darkening the screen, and rendering some text, including the Zone number, and the countdown timer. There's nothing more to add here.

And finally, we just have to do some clean up of our infoText, in clearZone:


void clearZone(void)
{
	clearEntities();

	clearBullets();

	clearParticles();

	clearWorld();

	clearInfoText();

	destroySpatialGrid();
}

The call to clearInfoText ensures we don't have any rogue info text hanging around when we're done with this zone.

Our game is complete! We now have a two player, split screen game that can be configured to be played in a number of different ways, and supports keyboard and joystick controls for both players. We've also learned how to use SDL2's geometry API to create triangles and render them in batches. On top of that, a spatial grid partitioning system was detailed.

Where to do go from here? Well, we could create a number of split screen games based on what we've learned here. Perhaps a 2D split screen platformer, or some sort of real time strategy game. We could even make the game cooperative, instead of competitive. Or maybe the game could have its multiplayer component removed, and instead turned into a single player game, where a spaceship explores an alien world, all built from triangles. Our spatial grid would certainly work well for something like that.

As with all the other tutorials, the possibilities are endless.

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

Desktop site