« Back to tutorial listing

— Creating a simple roguelike —
Part 16: Status effects

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

Introduction

Status effects (often also referred to as debuffs) are common in roguelikes, RPGs, and other games. In this part, we're going to look into introducing three different effects - being stunned, being confused, and being poisoned. All these effects will wear off naturally, although poison can be treated by using a new item (an antidote) that we'll be adding.

Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./rogue16 to run the code. You will see a window open displaying the player character in a small room, with just a stair case (leading up). Play the game as normal. It might take a while to reach the mice that can cause these effects, since only the higher level mice (Tough Mouse, Rabid Mouse, and Meta Mouse) can affect the player. When stunned, the player's turn will be skipped. When confused, the player will walk randomly. When poisoned, the player will lose 1 hit point per turn, although it will never fall below 1. It can be cured by using a vial of antivenom. Once you're finished, close the window to exit.

Inspecting the code

Introducing our status effects isn't too difficult a task and only really requires us to set some flags on the player and monsters, and then deal with these flags as necessary. We'll be setting both buffs (that which causes the affliction) and debuffs (the afflicition itself) as bitwise flags.

Starting with defs.h:


#define MF_NONE                   0
#define MF_BUFF_STUNS             (2 << 0)
#define MF_BUFF_CONFUSES          (2 << 1)
#define MF_BUFF_POISONS           (2 << 2)
#define MF_DEBUFF_STUNNED         (2 << 3)
#define MF_DEBUFF_CONFUSED        (2 << 4)
#define MF_DEBUFF_POISONED        (2 << 5)

We've added in the flags themselves. MF stands of "Monster Flag". MF_NONE means no flags. MF_BUFF_STUNS means the Monster has a buff that will stun their target; MF_BUFF_CONFUSES means the Monster can confuse a target; MF_BUFF_POISONS indicates the Monster can poison their target. MF_DEBUFF_STUNNED means that the Monster is stunned, MF_DEBUFF_CONFUSED that they are confused, and MF_DEBUFF_POISONED that they are posioned.

We've also added in a new HUD type:


enum {
	HUD_MSG_NORMAL,
	HUD_MSG_GOOD,
	HUD_MSG_BAD,
	HUD_MSG_WARN
};

HUD_MSG_WARN means that the message is a warning, such as "You're confused!". We'll see this in use later on.

Turning now to structs.h, we've updated the Monster struct:


typedef struct {
	int hp, maxHP;
	int minAttack;
	int maxAttack;
	int defence;
	int alert;
	int visRange;
	int level;
	int xp;
	int requiredXP;
	int savingThrow;
	SDL_Point patrolDest;
	unsigned long flags;
} Monster;

We've added in two new fields - savingThrow and `flags`. savingThrow is a value that will control how quickly any debuffs are set against the Monster (really only the player) will linger. We'll see how we're using this later on. The `flags` field is what we'll set our MF flags to.

The Equipment struct has also been modified:


typedef struct {
	int hp;
	int minAttack;
	int maxAttack;
	int defence;
	int savingThrow;
} Equipment;

A field called savingThrow has been added here too, since we're going to allow equipment such as microchips to alter our savingThrow value.

If we look now at monsters.c, we can see where we're using this new field. Starting with initToughMouse:


void initToughMouse(Entity *e)
{
	Monster *m;

	m = createMonster(e);
	m->hp = m->maxHP = 20 + rand() % 30;
	m->defence = 12;
	m->minAttack = 1;
	m->maxAttack = 16;
	m->visRange = 32;
	m->xp = 50;
	m->flags = MF_BUFF_STUNS;

	STRCPY(e->name, "Tough Mouse");
	STRCPY(e->description, "A mouse that's completely ripped. Apparently spends its life at the gym.");
	e->texture = getAtlasImage("gfx/entities/toughMouse.png", 1);
}

The Tough Mouse has the ability to stun us, and so we've set its Monster's `flags` to MF_BUFF_STUNS.

initRabidMouse is next:


void initRabidMouse(Entity *e)
{
	Monster *m;

	m = createMonster(e);
	m->hp = m->maxHP = 12 + rand() % 35;
	m->defence = 9;
	m->minAttack = 1;
	m->maxAttack = 10;
	m->visRange = 4;
	m->xp = 65;
	m->flags = MF_BUFF_POISONS;

	STRCPY(e->name, "Rabid Mouse");
	STRCPY(e->description, "A mouse that's foaming at the mouth. Either it ate a bath bomb or it has rabies.");
	e->texture = getAtlasImage("gfx/entities/rabidMouse.png", 1);
}

The Rabid Mouse can poison us, so its Monster's `flags` are set to MF_BUFF_POISONS.

initMetaMouse is the last Monster to be updated:


void initMetaMouse(Entity *e)
{
	Monster *m;

	m = createMonster(e);
	m->hp = m->maxHP = 20 + rand() % 35;
	m->defence = 1;
	m->minAttack = 12;
	m->maxAttack = 16;
	m->visRange = 12;
	m->xp = 105;
	m->flags = MF_BUFF_CONFUSES;

	STRCPY(e->name, "Meta Mouse");
	STRCPY(e->description, "A mouse but also not a mouse. One could say it's currently in a superposition.");
	e->texture = getAtlasImage("gfx/entities/metaMouse.png", 1);
}

The Meta Mouse can confuse us, so its Monster's `flags` are set to MF_BUFF_CONFUSES.

So, how are these flags used? If we head over to combat.c, we've tweaked doMeleeAttack:


void doMeleeAttack(Entity *attacker, Entity *target)
{
	// snipped

	if (damage != 0 && target == dungeon.player && rand() % 10 == 0)
	{
		applyDebuffs(atkMonster, tarMonster);
	}

	if (target->dead == 1)
	{
		if (attacker == dungeon.player)
		{
			game.highscore.kills++;

			addPlayerXP(tarMonster->xp);
		}

		if (target == dungeon.player)
		{
			STRCPY(game.highscore.killedBy, attacker->name);
		}
	}
}

Towards the end of the function, we've added in a new if-statement. We're testing if `damage` is greater than 0 and also if `target` is the player. If so, there's a 1 in 10 chance that we'll call a new function named applyDebuffs, to which we'll pass across atkMonster and tarMonster (which will be the attacking Monster and the player's Monster). The reason for doing this here is so that we can add our hud messages in the correct order. We want our status effect message to follow our damage message.

The applyDebuffs function is fairly simple to understand:


static void applyDebuffs(Monster *atkMonster, Monster *tarMonster)
{
	if ((atkMonster->flags & MF_BUFF_STUNS) && (!(tarMonster->flags & MF_DEBUFF_STUNNED)))
	{
		tarMonster->flags |= MF_DEBUFF_STUNNED;

		addHudMessage(HUD_MSG_WARN, "You've been stunned!");
	}

	if ((atkMonster->flags & MF_BUFF_CONFUSES) && (!(tarMonster->flags & MF_DEBUFF_CONFUSED)))
	{
		tarMonster->flags |= MF_DEBUFF_CONFUSED;

		addHudMessage(HUD_MSG_WARN, "You're confused!");
	}

	if ((atkMonster->flags & MF_BUFF_POISONS) && (!(tarMonster->flags & MF_DEBUFF_POISONED)))
	{
		tarMonster->flags |= MF_DEBUFF_POISONED;

		addHudMessage(HUD_MSG_WARN, "You've been poisoned!");
	}
}

The idea behind the function is to test the attacking Monster, to see which buffs they have and apply the relevant debuff to the target if they don't already have it set. We're first testing if atkMonster's `flags` include MF_BUFF_STUNS. If so, and tarMonster's `flags` don't include MF_DEBUFF_STUNNED, we'll set MF_DEBUFF_STUNNED to tarMonster's `flags` and then add a HUD message to say that we've been stunned. When setting this hud message, we're passing over HUD_MSG_WARN, so that this shows up as a warning message.

We're doing the same for the other buffs and debuffs: MF_BUFF_CONFUSES setting MF_DEBUFF_CONFUSED in its target, and MF_BUFF_POISONS setting MF_DEBUFF_POISONED in its target. For each type, we'll set an appropriate HUD message.

Now that our status effects have been set, we'll take a look at how they're being used.

Moving over to player.c, we've updated doPlayer:


void doPlayer(void)
{
	int dx, dy;
	Monster *m;

	moveDelay = MAX(moveDelay - app.deltaTime, 0);

	m = (Monster*) dungeon.player->data;

	if (m->flags & MF_DEBUFF_STUNNED)
	{
		updateFogOfWar();

		updateDebuffs(m);

		nextMonster();
	}
	else if (m->flags & MF_DEBUFF_CONFUSED)
	{
		dx = rand() % 2 - rand() % 2;
		dy = rand() % 2 - rand() % 2;

		moveEntity(dungeon.player, dx, dy);

		updateFogOfWar();

		updateDebuffs(m);

		nextMonster();
	}
	else if (moveDelay == 0)
	{
		dx = dy = 0;

		if (app.mouse.buttons[SDL_BUTTON_LEFT])
		{
			createAStarRoute(dungeon.player, dungeon.selectedTile.x, dungeon.selectedTile.y, &dx, &dy);
		}

		if (app.keyboard[SDL_SCANCODE_TAB])
		{
			app.keyboard[SDL_SCANCODE_TAB] = 0;

			initInventoryView();
		}

		if (app.keyboard[SDL_SCANCODE_W])
		{
			dy = -1;
		}

		if (app.keyboard[SDL_SCANCODE_S])
		{
			dy = 1;
		}

		if (app.keyboard[SDL_SCANCODE_A])
		{
			dx = -1;
		}

		if (app.keyboard[SDL_SCANCODE_D])
		{
			dx = 1;
		}

		if (dx != 0 || dy != 0)
		{
			moveEntity(dungeon.player, dx, dy);

			moveDelay = MOVE_DELAY;

			updateFogOfWar();

			updateDebuffs(m);

			nextMonster();
		}
	}
}

As you can see, we've expanded out the function quite a lot. No longer are we just testing to see if the player has pressed their movement controls. We're now testing the player's Monster's `flags` and reacting accordingly.

We're first checking if MF_DEBUFF_STUNNED has been set. If so, we're simply calling updateFogOfWar, a new function named updateDebuffs (we'll see this in a moment), and then nextMonster. This means that the player will not have any control over the character while they are stunned. They cannot move or attack (or even open the inventory). If MF_DEBUFF_STUNNED isn't set, we're then testing if MF_DEBUFF_CONFUSED is set. If so, we're setting `dx` and `dy` to random values between -1 and 1. We're then calling moveEntity and passing across the player, `dx`, and `dy` values. This means that the player will move around at random (or may even stand still). Again, we're then calling updateFogOfWar, updateDebuffs, and finally nextMonster.

If neither of these flags are set, the player can move as usual. Notice how the test for pressing Tab to open the inventory is now part of this if-block (it has been moved here from dungeon.c). This is because we want to ensure the player cannot access their inventory if they are stunned or confused. If they could, they could change armour, weapons, use items, etc. which we want to disallow. Note also how we're calling updateDebuffs when the player has taken their move. We'll see this next.


static void updateDebuffs(Monster *m)
{
	if (m->flags & MF_DEBUFF_POISONED)
	{
		m->hp = MAX(1, m->hp - 1);

		if (rand() % 100 < m->savingThrow)
		{
			m->flags &= ~MF_DEBUFF_POISONED;

			addHudMessage(HUD_MSG_NORMAL, "You are no longer poisoned.");
		}
	}

	if ((m->flags & MF_DEBUFF_STUNNED) && rand() % 100 < m->savingThrow)
	{
		m->flags &= ~MF_DEBUFF_STUNNED;

		addHudMessage(HUD_MSG_NORMAL, "You are no longer stunned.");
	}

	if ((m->flags & MF_DEBUFF_CONFUSED) && rand() % 100 < m->savingThrow)
	{
		m->flags &= ~MF_DEBUFF_CONFUSED;

		addHudMessage(HUD_MSG_NORMAL, "You are no longer confused.");
	}
}

The idea behind this function is to both apply the effects of a debuff and also to check if they are still affecting the player. The function takes a Monster (`m`) as an argument. We first check `m`'s `flags`, to see if MF_DEBUFF_POISONED is set. If so, we're decreasing `m`'s `hp` by 1 point, although we'll limit it to 1; being poisoned in our game won't kill us, but instead make us extremely vulnerable. We're then picking a random number between 0 and 99, and testing this against `m`'s `savingThrow`. Basically, savingThrow is working as a percentage chance of whether we're able to shake off the effects of our debuff. If both these conditions prove true, we'll remove MF_DEBUFF_POISONED from `m`'s `flags`, and add a HUD message to say that we're no longer poisoned.

We're repeating this flag and saving throw check for the two other debuffs (MF_DEBUFF_STUNNED and MF_DEBUFF_CONFUSED), removing them and adding messages as we do.

That's largely all that's needed for our status effects. However, there are still other little tweaks we've made throughout the code to further enhance the experience. Sticking with player.c, we've updated updatePlayerAttributes:


void updatePlayerAttributes(Monster *m, int ignoreEquipmentSlot)
{
	int i, lvl;
	Equipment *eq;

	m->requiredXP = 0;

	m->level = 0;

	do {
		m->level++;
		m->requiredXP += (m->level * 100) + ((m->level - 1) * 50);
	} while (m->xp >= m->requiredXP);

	lvl = m->level - 1;

	m->maxHP = 25 + (lvl * 10);
	m->defence = 4 + lvl;
	m->minAttack = 1 + lvl;
	m->maxAttack = 4 + (lvl * 1.5);
	m->savingThrow = 10 + (lvl * 1.5);

	for (i = 0 ; i < EQUIP_MAX ; i++)
	{
		if (game.equipment[i] != NULL && i != ignoreEquipmentSlot)
		{
			eq = (Equipment*) game.equipment[i]->data;

			m->maxHP += eq->hp;
			m->minAttack += eq->minAttack;
			m->maxAttack += eq->maxAttack;
			m->defence += eq->defence;
			m->savingThrow += eq->savingThrow;
		}
	}

	m->maxHP = MAX(m->maxHP, 1);
	m->hp = MIN(m->hp, m->maxHP);
	m->minAttack = MAX(1, m->minAttack);
	m->maxAttack = MAX(1, m->maxAttack);
	m->defence = MAX(m->defence, 0);
	m->savingThrow = MAX(m->savingThrow, 0);
}

Since savingThrow is part of the player's attributes, we're going to raise the value as the player's level increases. savingThrow has a base value of 10, and will raise 1.5 points for each level the player gains, making it easier to shake off debuff as they grow more powerful. Also, as we saw earlier, we updated the Equipment struct, to include a savingThrow field. We're incorporating that value when adding up the equipment's values, as with the other fields. Finally, we're ensuring our savingThrow cannot drop below 0, by making use of MAX.

If we turn to microchip.c, we can see where we're allowing for the microchip to alter the value of our saving throw, in initMicrochip:


void initMicrochip(Entity *e)
{
	Equipment *eq;
	int i, n, amount;

	eq = createMicrochip(e);

	STRCPY(e->name, "Microchip");
	STRCPY(e->description, "A tiny wafer of semiconducting material. Randomly affects stats.");
	e->texture = getAtlasImage("gfx/entities/microchip.png", 1);

	n = dungeon.floor * 2;

	for (i = 0 ; i < n ; i++)
	{
		amount = rand() % 2 == 0 ? -1 : 1;

		switch (rand() % 5)
		{
			case 0:
				eq->hp += amount;
				break;

			case 1:
				eq->minAttack += amount;
				break;

			case 2:
				eq->maxAttack += amount;
				break;

			case 3:
				eq->defence += amount;
				break;

			case 4:
				eq->savingThrow += amount;
				break;

			default:
				break;
		}
	}

	SDL_LogMessage(SDL_LOG_CATEGORY_APPLICATION, SDL_LOG_PRIORITY_DEBUG, "%s: hp=%d, minAttack=%d, maxAttack=%d, defence=%d, savingThrow=%d", e->name, eq->hp, eq->minAttack, eq->maxAttack, eq->defence, eq->savingThrow);
}

Our switch statement has been updated to support 5 case statements, the 5th one changing the value of `eq`'s (Equipment) savingThrow. Simple.

Additionally, we've updated hud.c, to make use of the new message type we added, via drawMessages:


static void drawMessages(void)
{
	int i, y;
	SDL_Color c;

	y = SCREEN_HEIGHT - 35;

	for (i = 0 ; i < NUM_HUD_MESSAGES; i++)
	{
		switch (game.messages[i].type)
		{
			case HUD_MSG_GOOD:
				c.r = 40;
				c.g = 200;
				c.b = 255;
				break;

			case HUD_MSG_BAD:
				c.r = 255;
				c.g = c.b = 64;
				break;

			case HUD_MSG_WARN:
				c.r = 255;
				c.g = 160;
				c.b = 0;
				break;

			default:
				c.r = c.g = c.b = 255;
				break;
		}

		drawText(game.messages[i].text, 10, y, c.r, c.g, c.b, TEXT_ALIGN_LEFT, 0);

		y -= 30;
	}
}

We've added in a new case to handle HUD_MSG_WARN, setting the RGB values of `c` (SDL_Color) to orange.

Next, we've made a small change to inventory.c, in the drawStats function:


static void drawStats(void)
{
	// snipped

	if (compare)
	{
		updatePlayerAttributes(&m2, selectedEquipmentSlot);

		if (eq != NULL)
		{
			m2.maxHP += eq->hp;
			m2.minAttack += eq->minAttack;
			m2.maxAttack += eq->maxAttack;
			m2.defence += eq->defence;
			m2.savingThrow += eq->savingThrow;

			m2.maxHP = MAX(1, m2.maxHP);
			m2.hp = MIN(m2.hp, m2.maxHP);
			m2.minAttack = MAX(1, m2.minAttack);
			m2.maxAttack = MAX(1, m2.maxAttack);
			m2.defence = MAX(0, m2.defence);
			m2.savingThrow = MAX(0, m2.savingThrow);
		}
	}

	app.fontScale = 1.8;

	sprintf(text, "HP: %d / %d", m2.hp, m2.maxHP);
	drawDiffValue(text, 1100, 100, m2.maxHP, m1->maxHP);

	sprintf(text, "Min attack: %d", m2.minAttack);
	drawDiffValue(text, 1100, 150, m2.minAttack, m1->minAttack);

	sprintf(text, "Max attack: %d", m2.maxAttack);
	drawDiffValue(text, 1100, 200, m2.maxAttack, m1->maxAttack);

	sprintf(text, "Defence: %d", m2.defence);
	drawDiffValue(text, 1100, 250, m2.defence, m1->defence);

	sprintf(text, "Saving throw: %d", m2.savingThrow);
	drawDiffValue(text, 1100, 300, m2.savingThrow, m1->savingThrow);

	app.fontScale = 1.0;
}

When comparing stats, we're now taking the value of `eq`'s (Equipment) and `m2`'s (Monster) savingThrow into consideration. We're also rendering the Saving throw value along with the `hp`, attack, and `defence` stats.

We're almost done. The last thing we need to do is look at how we're adding in the antivenom item. Luckily, we've built quite a flexible framework, so adding the item is very easy. All this is done in items.c. Starting with createRandomItem:


static Entity *createRandomItem(void)
{
	switch (rand() % 7)
	{
		case 0:
			return initEntity("Health Pack");

		case 1:
			return initEntity("Crowbar");

		case 2:
			return initEntity("Stun Baton");

		case 3:
			return initEntity("Biker Jacket");

		case 4:
			return initEntity("Bulletproof Vest");

		case 5:
			return initEntity("Microchip");

		case 6:
			return initEntity("Antidote");

		default:
			break;
	}

	return NULL;
}

We've added in a 7th case to our switch statement, which will call initEntity with an argument of "Antidote" if selected (and, of course, we're using a random of 0-6 now).

For the antivenom itself, we've added in a new function called initAntidote:


void initAntidote(Entity *e)
{
	Item *i;

	i = createItem(e);

	STRCPY(e->name, "Vial of Antivenom");
	STRCPY(e->description, "Antidote. Cures poison when used.");
	e->texture = getAtlasImage("gfx/entities/antidote.png", 1);

	i->use = useAntidote;
}

This is very standard item creation function. We start by calling createItem and assigning the result to a variable named `i`. We're then setting the entity's `name`, `description`, and `texture`, and finally setting `i`'s `use` function to useAntidote. There's really nothing else to it..!

The useAntidote function is quite similar to useHealthPack:


static int useAntidote(void)
{
	Monster *m;

	m = (Monster*) dungeon.player->data;

	if (m->flags & MF_DEBUFF_POISONED)
	{
		m->flags &= ~MF_DEBUFF_POISONED;

		addHudMessage(HUD_MSG_NORMAL, "You are no longer poisoned.");

		return 1;
	}

	return 0;
}

We're first extracting the Monster `data` from the player, and checking if the player has the MF_DEBUFF_POISONED set on their `flags`. If so, we're removing MF_DEBUFF_POISONED from their `flags`, setting a HUD message to say we're no longer poisoned, and then returning 1, to say that the item was used. Otherwise, we're returning 0.

The very last thing that we need to do is update entityFactory.c, to allow the antidote to be created:


void initEntityFactory(void)
{
	memset(&head, 0, sizeof(InitFunc));
	tail = &head;

	addInitFunc("Player", initPlayer);
	addInitFunc("Micro Mouse", initMicroMouse);
	addInitFunc("Neo Mouse", initNeoMouse);
	addInitFunc("Tough Mouse", initToughMouse);
	addInitFunc("Rabid Mouse", initRabidMouse);
	addInitFunc("Meta Mouse", initMetaMouse);
	addInitFunc("Key", initKey);
	addInitFunc("Health Pack", initHealthPack);
	addInitFunc("Crowbar", initCrowbar);
	addInitFunc("Stun Baton", initStunBaton);
	addInitFunc("Biker Jacket", initBikerJacket);
	addInitFunc("Bulletproof Vest", initBulletproofVest);
	addInitFunc("Microchip", initMicrochip);
	addInitFunc("Stairs (Up)", initStairsUp);
	addInitFunc("Stairs (Down)", initStairsDown);
	addInitFunc("Door", initDoorNormal);
	addInitFunc("Door (Locked)", initDoorLocked);
	addInitFunc("Antidote", initAntidote);
}

A single line adding in addInitFunc is all that's needed here, and our antidote is ready to save us from the harmful affects of poison.

And that's it for our status effects. We only have a few things left to do in this tutorial - saving and loading, introducing the Mouse King, and adding the finishing touches. We'll be looking at how to save our games next, as we shouldn't expect the player to finish the game in one sitting.

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:

Desktop site