« Back to tutorial listing

— Making a 2D split screen game —
Part 9: Particles and effects

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

Introduction

It's time to make our game a little nicer to look at. We have a lot of the core gameplay elements in place, but there are things missing, such as explosions, impact effects, and other things. In our game, these will all be displayed using a particle system, created from triangles. Once again, there won't be an textures in our game.

Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./versus09 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. There should now be a load of particle effects visible on the screen, while flying your ship, firing rockets, dying / respawning, and when bullets impact the environment. Once you're finished, close the window to exit.

Inspecting the code

Adding in a Particle system is something we've done many times, in many of our tutorials. The process here won't differ very much from those. Once again, the major difference is that we'll be rendering our Particles using triangles, instead of textures.

Starting first with structs.h:


struct Particle
{
	SDL_FPoint position;
	SDL_FPoint dir;
	int        size;
	SDL_Color  color;
	double     health;
	double     angle;
	double     rotationSpeed;
	Particle  *next;
};

We've added in a Particle struct. This struct, unsurprisingly, represents a particle. We're storing the `position`, `dir` (velocity), `size`, `color`, `health`, adn `angle` of the particle. rotationSpeed is the speed at which the partcle spins. Our Particles are part of a linked list.

With that setup, we can move over to particles.c, where the bulk of this update has taken place. Starting first with initParticles:


void initParticles(void)
{
	memset(&head, 0, sizeof(Particle));
	tail = &head;

	playerEngineTimer = 0;
	rocketEngineTimer = 0;
}

Our Particles are stored in a linked list, so we start by setting up the `head` and `tail`. Next, we set two variables to 0. playerEngineTimer is used to control how often we'll produce a particle for the players' engines. The function addPlayerEngineParticles is called every frame by a Player's `tick` function, but we don't want the trail to be bound to our frame rate. Therefore, this timer will help us to control how often the trail is produced. A second variable, rocketEngineTimer, controls how often we'll produce the trail for the rockets.

We'll see these two variables in action in a little while. Next up, we have the doParticles function:


void doParticles(void)
{
	Particle *p, *prev;

	prev = &head;

	for (p = head.next; p != NULL; p = p->next)
	{
		p->position.x += p->dir.x * app.deltaTime;
		p->position.y += p->dir.y * app.deltaTime;

		p->health -= app.deltaTime;

		p->angle += p->rotationSpeed * app.deltaTime;

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

			prev->next = p->next;

			free(p);

			p = prev;
		}

		prev = p;
	}

	if (playerEngineTimer < 0)
	{
		playerEngineTimer = 2;
	}

	if (rocketEngineTimer < 0)
	{
		rocketEngineTimer = 0.25;
	}

	playerEngineTimer -= app.deltaTime;

	rocketEngineTimer -= app.deltaTime;
}

Nothing out of the ordinary here; it's like many particle processing functions that we've seen before. The particles are moved by their `dir`, and their `health` is reduced. Particles are deleted once their `health` hits 0 or less. Of note here is that we're updating the particle's `angle` by its rotationSpeed. This is to make the particle rotate, if the rotationSpeed is not 0.

Also of note is that we're processing our playerEngineTimer and rocketEngineTimer variables here. We're decreasing their values upon each call to doParticles, and if they fall below 0, we're resetting them to 2 and 0.25, respectively. Note that the order in which we're decreasing and resetting these variables is important, as we'll see in a bit.

Again, nothing special. Now let's look at drawParticles:


void drawParticles(SDL_FPoint *camera)
{
	int        n;
	double     rot;
	SDL_Vertex v;
	SDL_FPoint points[3];
	Particle  *p;

	SDL_SetRenderDrawBlendMode(app.renderer, SDL_BLENDMODE_ADD);

	for (p = head.next; p != NULL; p = p->next)
	{
		points[0].x = 0;
		points[0].y = -p->size;

		points[1].x = -p->size;
		points[1].y = p->size;

		points[2].x = p->size;
		points[2].y = p->size;

		rot = TO_RAIDANS(p->angle);

		for (n = 0; n < 3; n++)
		{
			memset(&v, 0, sizeof(SDL_Vertex));

			v.position.x = points[n].x * cos(rot) - points[n].y * sin(rot);
			v.position.y = points[n].x * sin(rot) + points[n].y * cos(rot);
			v.color = p->color;
			v.color.a = p->health * 3;

			v.position.x += p->position.x;
			v.position.y += p->position.y;

			v.position.x -= camera->x;
			v.position.y -= camera->y;

			drawVertex(&v);
		}
	}

	SDL_SetRenderDrawBlendMode(app.renderer, SDL_BLENDMODE_BLEND);
}

Once again, there's nothing difficult here to understand, as our particles are just a bunch of triangles, of variable sizes. Our particles support additive alpha blending, as in our other games, so we're first setting SDL_SetRenderDrawBlendMode to render that way. We're then looping through all our particles, creating an equilateral triangle of the required size (stored in 3 SDL_Points called `points`), and then using those points to create 3 vertices. The vertices are rotated by the Particle's `angle`, and its `color` set. Since we want our Particles to fade as they age, we're adjusting the alpha value of the colour, according to the Particle's `health`. As with the other things in the game we render, we take the camera position into account. Finally, we pass the SDL_Vertex (`v`) to drawVertex, before resetting our blending mode at the end of the loop.

So far, pretty standard. The remaining functions won't pose much of a challenge, either, so we'll only take a high level look at them. Starting first with addPlayerEngineParticles:


void addPlayerEngineParticles(Entity *e)
{
	int       i;
	Particle *p;

	if (playerEngineTimer <= 0)
	{
		for (i = 0; i < 5; i++)
		{
			p = spawnParticle();
			p->size = 2 + rand() % 3;
			p->angle = ((int)e->angle + 180) % 360;

			p->dir.x = -2 * sin(TO_RAIDANS(e->angle));
			p->dir.y = -2 * -cos(TO_RAIDANS(e->angle));
			p->dir.x += (1.0 * (rand() % 20) - rand() % 20) * 0.01;
			p->dir.y += (1.0 * (rand() % 20) - rand() % 20) * 0.01;

			p->position = e->position;
			p->position.x += p->dir.x * 8;
			p->position.y += p->dir.y * 8;

			p->health = FPS / 2;
			p->color.r = 128;
			p->color.g = 168;
			p->color.b = 255;
		}
	}
}

Here, we can see the playerEngineTimer put to use. This function won't do anything if the value is greater than 0. This is why in our doParticles function we need to reset the value BEFORE decreasing it. If we do it the other way around (as one would expect), this function won't work, since playerEngineTimer will never be 0 when it is called. As for the rest of the function, we're using a for-loop to create 5 particles. When it comes to calculating the direction of the Particle, we're taking the Player's (`e`) current `angle`, and issuing the Particles in the opposite direction; we want our Particles to appear at the back of the ship, after all. With that done, we add some randomness to the `dir` vector, and set the `color` to a light blue.

So, our particles will be created at the back of the player's ships, with a velocity moving away from it. Just as you would expect an engine to behave.

Over next to addRocketEngineParticles:


void addRocketEngineParticles(Bullet *b)
{
	int       i;
	Particle *p;

	if (rocketEngineTimer <= 0)
	{
		for (i = 0; i < 5; i++)
		{
			p = spawnParticle();
			p->size = 2 + rand() % 4;
			p->angle = ((int)b->angle + 180) % 360;

			p->dir.x = -2 * sin(TO_RAIDANS(b->angle));
			p->dir.y = -2 * -cos(TO_RAIDANS(b->angle));
			p->dir.x += (1.0 * (rand() % 20) - rand() % 20) * 0.01;
			p->dir.y += (1.0 * (rand() % 20) - rand() % 20) * 0.01;

			p->position = b->position;
			p->position.x += p->dir.x * 8;
			p->position.y += p->dir.y * 8;

			p->health = FPS / 2;
			p->color.r = 255;
			p->color.g = rand() % 255;
			p->color.b = 0;
		}
	}
}

This is very much like the function for adding our player engine particles. Here, we're making use of rocketEngineTimer, and giving the Particles a random red-yellow hue.

As stated before, the next few functions are all very similar in nature, so we won't linger on them. First, we have addBulletHitParticles:


void addBulletHitParticles(Bullet *b)
{
	int       i;
	Particle *p;

	for (i = 0; i < 3; i++)
	{
		p = spawnParticle();
		p->position = b->position;
		p->size = 2 + rand() % 3;
		p->angle = rand() % 360;

		p->dir.x = -0.5 * sin(TO_RAIDANS(p->angle));
		p->dir.y = -0.5 * -cos(TO_RAIDANS(p->angle));
		p->dir.x += (1.0 * (rand() % 20) - rand() % 20) * 0.01;
		p->dir.y += (1.0 * (rand() % 20) - rand() % 20) * 0.01;

		p->health = (FPS / 2) + rand() % 16;
		p->color.r = p->color.g = p->color.b = 255;
	}
}

This creates a handful of Particles about the bullet's (`b`) `position` (where it would've hit the environment / entity). Their various attributes are randomly set.

addRocketHitParticles comes next:


void addRocketHitParticles(Bullet *b)
{
	int       i;
	Particle *p;

	for (i = 0; i < 50; i++)
	{
		p = spawnParticle();
		p->position = b->position;
		p->size = 4 + rand() % 8;
		p->angle = rand() % 360;

		p->dir.x = -0.5 * sin(TO_RAIDANS(p->angle));
		p->dir.y = -0.5 * -cos(TO_RAIDANS(p->angle));
		p->dir.x += (1.0 * (rand() % 100) - rand() % 100) * 0.1;
		p->dir.y += (1.0 * (rand() % 100) - rand() % 100) * 0.1;

		p->health = FPS + rand() % 16;
		p->color.r = 255;
		p->color.g = rand() % 255;
		p->color.b = 0;
	}
}

Here, we're creating a large number of Particles, about the point of the impact of a rocket. The `dir` of the rocket is much greater than the other functions, to emphasise the blast. The `size` of the Particles is also much larger (between 4 and 11). Once again, they are given a random red-yellow hue.

addDeathParticles comes next:


void addDeathParticles(SDL_FPoint position, int r, int g, int b)
{
	int       i, c;
	Particle *p;

	for (i = 0; i < 50; i++)
	{
		p = spawnParticle();
		p->position = position;
		p->size = 4 + rand() % 8;
		p->angle = rand() % 360;
		p->rotationSpeed = 2 + rand() % 8;

		p->dir.x = -0.5 * sin(TO_RAIDANS(p->angle));
		p->dir.y = -0.5 * -cos(TO_RAIDANS(p->angle));
		p->dir.x += (1.0 * (rand() % 25) - rand() % 25) * 0.1;
		p->dir.y += (1.0 * (rand() % 25) - rand() % 25) * 0.1;

		c = rand() % 64 - rand() % 64;

		p->health = FPS + rand() % (int)FPS;
		p->color.r = MAX(MIN(r + c, 255), 0);
		p->color.g = MAX(MIN(g + c, 255), 0);
		p->color.b = MAX(MIN(b + c, 255), 0);
	}
}

This creates a large number of Particles at the given `position`, with the Particles being coloured using the RGB values passed into the function. The colour is randomly adjusted by a small amount, to add some variation.

The last creation function is addRespawnParticles:


void addRespawnParticles(SDL_FPoint position)
{
	int       i;
	Particle *p;

	for (i = 0; i < 50; i++)
	{
		p = spawnParticle();
		p->position = position;
		p->size = 2 + rand() % 4;
		p->angle = rand() % 360;

		p->dir.x = -0.5 * sin(TO_RAIDANS(p->angle));
		p->dir.y = -0.5 * -cos(TO_RAIDANS(p->angle));
		p->dir.x += (1.0 * (rand() % 25) - rand() % 25) * 0.1;
		p->dir.y += (1.0 * (rand() % 25) - rand() % 25) * 0.1;

		p->health = FPS + rand() % 16;
		p->color.r = p->color.g = p->color.b = 192 + rand() % 64;
	}
}

Once again, this creates some random Particles at the given `position`.

The final function is spawnParticle:


static Particle *spawnParticle(void)
{
	Particle *p;

	p = malloc(sizeof(Particle));
	memset(p, 0, sizeof(Particle));
	tail->next = p;
	tail = p;

	return p;
}

This doesn't need much explaining - it just creates a Particle and add it to our linked list.

So, particles.c is pretty much par for the course! We've seen it all before, and other than the rendering of the Particles, there isn't much else that needs detailing. Let's move on to see how all this is used in the rest of the code. I'm certain it won't bring up any surprises.

First, over to zone.c, where we've first updated initZone:


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

	initEntities();

	initBullets();

	initParticles();

	// snipped
}

Here, we're calling initParticles. Next up, we have `logic`:


static void logic(void)
{
	doBullets();

	doEntities();

	doParticles();

	// snipped
}

We've added in a call to doParticles. Finally, we have `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];

		drawEntities(c);

		drawBullets(c);

		drawWorld(c);

		drawParticles(c);

		// snipped
	}

	// snipped
}

As expected, we're calling drawParticles here, passing over the camera (`c`) we wish to work with.

That's zone.c done. We can now briefly look at where we've added in our various Particle function calls. First, over to player.c, where we've updated `steer`:


static void steer(Entity *self, Player *p)
{
	// snipped

	if (isControl(CONTROL_THRUST, p->num))
	{
		rot = TO_RAIDANS(self->angle);

		self->dir.x += 0.15 * sin(rot) * app.deltaTime;
		self->dir.y += 0.15 * -cos(rot) * app.deltaTime;

		addPlayerEngineParticles(self);
	}

	// snipped
}

We're calling addPlayerEngineParticles here, whenever the player is flying. Notice that we only call the function when the player is applying the thrust control, rather than all the time. We want to give the impression that the engines are active.

Next up, we have the `die` function updates:


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

	p = (Player *)self->data;

	if (p->num == 0)
	{
		addDeathParticles(self->position, 160, 192, 255);
	}
	else
	{
		addDeathParticles(self->position, 96, 255, 96);
	}
}

As we saw earlier, the addDeathParticles function takes RGB values. We're making use of that here, producing some light blue particles when player 1 dies, and light green ones when player 2 dies. This reflects the colour of their ships.

That's player.c done, so we can move over to entities.c, where we've made a tweak to doDead:


static void doDead(Entity *e)
{
	e->respawnTimer -= app.deltaTime;

	if (e->respawnTimer <= 0)
	{
		e->dead = 0;

		e->position = e->respawnPosition;

		e->respawn(e);

		addRespawnParticles(e->position);
	}
}

Whenever an entity respawns, we're calling addRespawnParticles, passing over the `position` of the entity. Right now, this will only affect the two players.

The changes to bullets.c comes next. First, we have doBullets:


void doBullets(void)
{
	Bullet *b, *prev;

	prev = &head;

	for (b = head.next; b != NULL; b = b->next)
	{
		// snipped

		if (b->type == BT_ROCKET)
		{
			addRocketEngineParticles(b);
		}

		// snipped
	}
}

While processing our bullets, we'll test if the current bullet is a rocket (its `type` is BT_ROCKET). If so, we'll call addRocketEngineParticles. Again, the rocketEngineTimer will control how often these particles are produced, so we are not bound to our frame rate.

bulletDie comes next:


static void bulletDie(Bullet *self)
{
	addBulletHitParticles(self);

	self->health = 0;
}

Here, we're now calling addBulletHitParticles, in addition to setting the bullet's `health` to 0. Finally, we have rocketDie:


static void rocketDie(Bullet *self)
{
	Entity *e;
	Player *p;
	double  distance, force, dx, dy;
	int     damage;

	addRocketHitParticles(self);

	for (e = zone.entityHead.next; e != NULL; e = e->next)
	{
		// snipped
	}

	self->health = 0;
}

We're calling the addRocketHitParticles function.

Our particle system is done. There wasn't anything here that taxed the mind; the more interesting parts were the timers for the engines and rockets, and also the rendering. Everything else was very much as we've seen before. It has made our game a lot prettier, though, and adds in some missing impact.

You're likely feeling there is something else missing our game, though. How about some power ups or items to collect? Perhaps some points pods, that the players can hunt down. I agree, this would be a good thing. So, in our next part we'll be introducing items and other collectables. This will expand the gameplay of our game, and add in some interesting elements.

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