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
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 Attribute of the Strong (Battle for the Solar System, #3)

The Pandoran War is nearing its end... and the Senate's Mistake have all but won. Leaving a galaxy in ruin behind them, they set their sights on Sol and prepare to finish their twelve year Mission. All seems lost. But in the final forty-eight hours, while hunting for the elusive Zackaria, the White Knights make a discovery in the former Mitikas Empire that could herald one last chance at victory.

Click here to learn more and read an extract!

« Back to tutorial listing

— Mission-based 2D shoot 'em up —
Part 4: Simple AI

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

Introduction

It's time to allow for our enemies to give chase and fire back. In this part, we're going to implement some basic AI, that will make the opposing fighters move towards and around the player, opening fire whenever they get a chance. We're not going to allow the player to die at the moment, so you can take as many hits as you like.

Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./shooter3-04 to run the code. You will see a window open like the one above. Use the WASD control scheme to move the fighter around. Hold J to fire your main guns. The enemies will move towards the player, firing when they are in range and in line. Once again, the enemies require 3 shots to die, but the player cannot be killed. There are an unlimited number of enemies. When you're finished, closed the window to exit.

Inspecting the code

Once again, adding in our AI is quite a simple task. It requires just a few modifications to our existing code, and only one new compilation unit (ai.c) to accomplish.

We'll start by looking at the updates to defs.h:


enum
{
	AI_NORMAL
};

We've added a new enum, to hold the AI types. At the moment we only have one type: AI_NORMAL.

Next, we've added another enum:


enum
{
	AI_WPN_NONE,
	AI_WPN_SINGLE
};

This enum will define the type of weapon that the enemy uses (the prefix AI_WPN being short for "AI weapon").

Moving onto structs.h now, we've updated the Fighter struct:


typedef struct
{
	// snipped
	struct
	{
		int     type;
		double  thinkTime;
		int     weaponType;
		int     weaponReload;
		Entity *target;
	} ai;
} Fighter;

We've added an inner struct called `ai`. This will hold the data for the fighter's AI. `type` is the type of ai, `thinkTime is how long the ai will perform its current task before reevaluating, weaponType is the type of weapon the AI uses, weaponReload is the firing rate of the weapon, and `target` is the AI's current target. In our game, the AI won't always attack the player.

Now, let's update our current fighter with the new AI data. Heading over to greebleLightFighter.c, we've updated initGreebleLightFighter:


void initGreebleLightFighter(Entity *e)
{
	Fighter *f;

	f = malloc(sizeof(Fighter));
	memset(f, 0, sizeof(Fighter));
	f->health = f->maxHealth = 3;
	f->ai.weaponType = AI_WPN_SINGLE;
	f->ai.weaponReload = 12;
	f->ai.type = AI_NORMAL;
	f->ai.thinkTime = rand() % FPS;
	f->ai.attackTimer = rand() % FPS;

	// snipped
}

For the fighter's AI struct, we're now setting the various variables. This fighter will use a weaponType of AI_WPN_SINGLE, a weaponReload value of 12, an `ai` type of AI_NORMAL, and a random starting thinkTime and attackTimer. We'll see these in action in a little bit.

Next, we've updated the `tick` function:


static void tick(Entity *self)
{
	Fighter *f;

	f = (Fighter *)self->data;

	fighterTick(self, f);

	doFighterAI(self, f);

	if (stage.engineEffectTimer <= 0)
	{
		addEngineEffect(self->x + (self->facing == FACING_LEFT ? self->texture->rect.w : 0), self->y + 16);
	}

	stage.numActiveEnemies++;
}

We're making a call our to a new function called doFighterAI, passing over the reference to the fighter.

Now that our fighter is configured, we can head over to the new ai.c compilation unit, to see how our AI system works (this is where our doFighterAI function lives). The ai.c contains a good number of functions, as it is here where we'll instruct how fighter how to behave.

So, starting with the aforementioned doFighterAI:


void doFighterAI(Entity *self, Fighter *f)
{
	if (!stage.player->dead)
	{
		if (f->ai.thinkTime <= 0)
		{
			if (f->ai.target == NULL || rand() % 4 == 0)
			{
				f->ai.target = findNearestEnemy(self);
			}

			switch (f->ai.type)
			{
				case AI_NORMAL:
					doNormalAI(self, f);
					break;

				default:
					break;
			}
		}

		if (f->ai.weaponType != AI_WPN_NONE && f->ai.target != NULL)
		{
			f->ai.attackTimer -= app.deltaTime;

			if (f->ai.attackTimer <= 0)
			{
				attackTarget(self, f);

				f->ai.attackTimer = FPS * 0.2;
			}
		}
	}
}

Okay, there's a few things going on here, but nothing too complicated. For a start, we're going to test if the player is actually alive. While it's not possible for the player to be killed just yet, we'll do this check as a pre-empt; we don't want the AI to keep doing things once the player is out of the picture (even if they are not the AI's target). Next, we're checking if the fighter's `ai`'s thinkTime is 0 or less. If so, the AI can start taking a new action.

The first thing we do is check if the AI has a target (or, if they already do, a 1 in 4 chance of choosing a new target). If so, we'll call findNearestEnemy, assigning the result to the fighter's `ai`'s `target` field. After that, we'll check what type of AI the fighter has. We only have one type right now, AI_NORMAL, and so we'll call doNormalAI. This part handles looking for and maneuvering in response to the presence of an enemy.

Attacking comes next. Before attacking, we check to see what kind of weapon the AI has, and also if they have a target. If they don't have a weapon (AI_WPN_NONE) or a target, nothing will happen. If they do, we'll decrease the value of their attackTimer. If it's fallen to 0 or less, we'll allow the AI to attack. We'll call attackTarget for this purpose. Finally, we'll reset the fighter's attackTimer. What all this does is prevent all the fighters from attacking constantly, and all at the same time. Each fighter is given a window in which they are allowed to open fire on their target. If they are outside of this attack window, they won't fire. This is to stop the player from being hailed with enemy fire constantly, which would be somewhat annoying!

Now, let's look at how the functions we're calling in doFighterAI. Starting with doNormalAI:


static void doNormalAI(Entity *self, Fighter *f)
{
	int x, y, speed;

	speed = 0;

	f->dx = f->dy = 0;

	if (f->ai.target != NULL && rand() % 10 <= 5)
	{
		calcSlope(f->ai.target->x, f->ai.target->y, self->x, self->y, &f->dx, &f->dy);

		speed = 5 + rand() % 8;
	}
	else
	{
		if (rand() % 3 == 0)
		{
			x = self->x + (rand() % SCREEN_WIDTH) - (rand() % SCREEN_WIDTH);
			y = self->y + (rand() % 100) - (rand() % 100);

			calcSlope(x, y, self->x, self->y, &f->dx, &f->dy);

			speed = 2 + rand() % 8;
		}
		else
		{
			f->dx = 0;
			f->dy = 0;
		}
	}

	f->dx *= speed;
	f->dy *= speed;

	if (f->dx != 0)
	{
		self->facing = f->dx < 0 ? FACING_LEFT : FACING_RIGHT;
	}

	f->ai.thinkTime = FPS + (FPS * rand() % 3);
}

The above can be thought of as having 2 different parts. Firstly, we're checking how we want to react to an enemy target. If we have one, there's a 60% chance that our AI will fly directly towards it; we'll call calcSlope (util.c), feeding the results of the call into our fighter's `dx` and `dy` variables. We'll also set a variable called `speed` to be between 5 and 12. This, as we'll see, will set how fast the AI moves towards its destination.

If we don't have a target, or our random chance fails, we'll decide to do one of two things. There is a 1 in 3 chance that our AI will decide to dodge, by selecting a position up to the screen's width away from its current position (SCREEN_WIDTH), and up to 100 pixels above or below it. It will decide to move to this position at a speed of between 2 and 9. Otherwise, the AI will decide to hold position and do nothing..!

With that determined, we multiply the fighter's `dx` and `dy` by the `speed` we set, testing the value of `dx` to see which way our fighter should face, and then randomly assign the thinkTime to between 1 and 3 seconds.

So, ultimately our AI fighter will either give chase, evade, or hold position. This logic will stop the AI fighters ganging up on the player (or other target), giving them a chance to fight back, etc. We want the player to have the advantage during play, so it remains fun.

Now for findNearestEnemy:


static Entity *findNearestEnemy(Entity *self)
{
	Entity *e, *rtn;
	int     distance, bestDistance;

	rtn = NULL;
	bestDistance = -1;

	for (e = stage.entityHead.next; e != NULL; e = e->next)
	{
		if (e->side != SIDE_NONE && e->side != self->side)
		{
			distance = getDistance(self->x, self->y, e->x, e->y);

			if (bestDistance == -1 || distance < bestDistance)
			{
				rtn = e;

				bestDistance = distance;
			}
		}
	}

	return rtn;
}

This function does exactly as it is named: it loops through all the entities in the stage, looking for one that is not on the same side as the fighter itself (with the exception of those on SIDE_NONE). When a match is found, it will keep track of both the nearest entity and return that as the target. If none are found, it will return NULL. The distance of the target is unlimited, so it could be anywhere in our playing field.

Lastly, we should look at attackTarget:


static void attackTarget(Entity *self, Fighter *f)
{
	int distX, distY;

	if (f->reload == 0 && ((self->facing == FACING_LEFT && f->ai.target->x < self->x) || (self->facing == FACING_RIGHT && f->ai.target->x > self->x)))
	{
		distX = abs(self->x - f->ai.target->x);
		distY = abs(self->y - f->ai.target->y);

		if (distX < SCREEN_WIDTH / 2 && distY < 25)
		{
			switch (f->ai.weaponType)
			{
				case AI_WPN_SINGLE:
					addStraightBullet(self, 1, self->y + (self->texture->rect.h / 2), self->facing == FACING_RIGHT ? 24 : -24, 0);
					break;

				default:
					break;
			}

			f->reload = f->ai.weaponReload;
		}
	}
}

As the name implies, this function is responsible for having an AI fighter attack its target. The first thing we do is check whether the fighter's `reload` is 0. If so, we then check that the fighter is facing the correct direction; we can't have the fighter firing at a target that is behind them. Next, we set two variables, distX and distY, to hold the horizontal and vertical distance between the attacker and its target. Should distX be less than the screen width and distY be less than 25, we'll consider the target is in range to fire at. We check the type of the weapon the fighter has (we're only using AI_WPN_SINGLE right now), and call addStraightBullet, just as the player does when they fire. With the shots fired, we set the fighter's `reload` to the value of it's `ai`'s weaponReload.

There we go, some very simple AI. Our enemies will chase after us, dodge around a bit, and fire as much as they are able.

We're almost done! We're just missing one crucial change, and that's allowing the enemy shots to hit the player. Doing so is simple. If we head over to player.c, we've updated initPlayer:


void initPlayer(Entity *e)
{
	Fighter *f;

	f = malloc(sizeof(Fighter));
	memset(f, 0, sizeof(Fighter));

	e->side = SIDE_CATS;
	e->facing = FACING_RIGHT;
	e->data = f;
	e->texture = getAtlasImage("gfx/fighters/kit-e.png", 1);

	e->tick = tick;
	e->draw = draw;
	e->takeDamage = fighterTakeDamage;
	e->die = die;

	stage.player = e;
}

We're assigning the player's takeDamage function to fighterTakeDamage, and the `die` function to our own `die` function.

As already stated, the player can't die at the moment, which is why our `die` function appears so:


static void die(Entity *self)
{
}

Our game expects all destroyed fighters to have a valid die function, so we just assign one that is empty. We'll fill it in later.

Another part done, and another milestone accomplished! We already have a working X/Y multi-directional shooter, that would almost be a complete game on its own if we were to make it a one-hit kill, like the previous two games.

But we want bigger and better than that! Our plan is to have missions and objectives, and different enemy types. What would be nice to have is for the enemies to release collectable items once they are destroyed - cash, powerups, that sort of thing. So, in our next part we'll introduce items for the player to collect.

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:

Mobile site