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


Project Starfighter

In his fight back against the ruthless Wade-Ellen Asset Protection Corporation, pilot Chris Bainfield finds himself teaming up with the most unlikely of allies - a sentient starfighter known as Athena.

Click here to learn more and read an extract!

« Back to tutorial listing

— Creating a simple roguelike —
Part 12: Stairs

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

Introduction

Moving between dungeon floors is another common part of roguelike games. In this part, we're going to add stairs, both up and down, so that the player can ascend and descend.

Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./rogue12 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). Walk into the staircase to proceed to the next floor. Upon reaching the next floor, you will start by a down staircase. Play the game as before, moving up and down the stairs case you like. There is a limit of 13 floors. Once you're finished, close the window to exit.

Inspecting the code

Adding in our stairs has required us to make lots of changes, both big and small.

The first thing we've done is updated defs.h, to add in a new ET enum:


enum {
	ET_UKNOWN,
	ET_PLAYER,
	ET_MONSTER,
	ET_ITEM,
	ET_WEAPON,
	ET_ARMOUR,
	ET_MICROCHIP,
	ET_STAIRS
};

ET_STAIRS will represent our define an entity type of Stairs.

Moving onto structs.h, we've made a change to Entity:


struct Entity {
	int id;
	int type;
	char name[MAX_NAME_LENGTH];
	char description[MAX_DESCRIPTION_LENGTH];
	int x;
	int y;
	int dead;
	int solid;
	int facing;
	int alwaysVisible;
	void (*data);
	void (*touch)(Entity *self, Entity *other);
	AtlasImage *texture;
	Entity *next;
};

We've added a field called alwaysVisible. When this is set, it means that an entity on a map square that has been revealed won't be hidden by the player's LOS check. This is useful for things such as stairs; it would look odd if they vanished as we moved around.

We've also introduced a new struct called Stairs:


typedef struct {
	int dir;
} Stairs;

This struct represents a set of stairs and contains just one field - `dir`. `dir` is short for direction and will either be -1 or 1 to represent the direction the stairs will take us. -1 will go down a floor, while 1 will go up a floor.


typedef struct {
	int entityId;
	Entity entityHead, *entityTail;
	Entity deadHead, *deadTail;
	Entity *player, *currentEntity, *attackingEntity;
	MapTile map[MAP_WIDTH][MAP_HEIGHT];
	SDL_Point camera;
	SDL_Point attackDir;
	SDL_Point selectedTile;
	double animationTimer;
	int floor, newFloor;
} Dungeon;

Finally, we've added two new fields to dungeon - `floor` and newFloor. `floor` is the number of the floor we're currently on, while newFloor is the number of the floor we want to move to. We'll see how this works a bit later on.

To handle our stairs, we've introduced a new file called stairs.c. It contains a number of functions, and is laid out in a similar fashion to many other entities. Starting with initStairs:


static void initStairs(Entity *e, int dir)
{
	Stairs *s;

	s = malloc(sizeof(Stairs));
	memset(s, 0, sizeof(Stairs));

	s->dir = dir;

	e->type = ET_STAIRS;
	e->alwaysVisible = 1;
	e->data = s;

	e->touch = touch;
}

Like some other entities (Items, Monsters), this is a helper function for generating our stairs, since both up and down stairs share similar features. It takes two arguments, the Entity and the direction (`dir`) the stairs will take us. We first malloc and memset a Stairs struct (as `s`), then set its `dir` to the value of `dir` we passed into the function. We then set `e`'s type to ET_STAIRS and its alwaysVisible to 1. We set `e`'s `data` field as `s`, and finally assign its `touch` function to `touch`.

`touch` itself is quite simple:


static void touch(Entity *self, Entity *other)
{
	if (other == dungeon.player)
	{
		dungeon.newFloor = dungeon.floor + ((Stairs*) self->data)->dir;
	}
}

We first check that the thing that has walked into the stairs is the player, then set the value of dungeon's newFloor to dungeon's floor plus the value of the Stair's `dir`. This means that if floor was 1 and the the Stair's `dir` was 1, newFloor would become 2. If Stair's dir was -1 and dungeon's floor is 5, newFloor's value will be 4, etc.

initStairsUp is next:


void initStairsUp(Entity *e)
{
	STRCPY(e->name, "Stairs (Up)");
	STRCPY(e->description, "A set of stairs, leading to a higher floor.");
	e->texture = getAtlasImage("gfx/entities/stairsUp.png", 1);

	initStairs(e, 1);
}

This is a standard init function. We're setting the `name` of the entity, as well as the `description`, then calling initStairs, passing over `e` and 1, as the direction the stairs will take us. Since this is an Up staircase, we're passing over 1, to go to a higher floor. Finally, we're returning `e`.

initStairsDown is quite similar:


void initStairsDown(Entity *e)
{
	STRCPY(e->name, "Stairs (Down)");
	STRCPY(e->description, "A set of stairs, leading to a lower floor.");
	e->texture = getAtlasImage("gfx/entities/stairsDown.png", 1);

	initStairs(e, -1);
}

We're setting the `name` and `description` of the down staircase, but passing over -1 to initStairs, since these stairs will be taking us to a lower floor.

Onto addStairs:


void addStairs(int oldFloor)
{
	Entity *up, *down;

	if (dungeon.floor < 13)
	{
		up = initEntity("Stairs (Up)");

		addEntityToDungeon(up, 1);
	}

	if (dungeon.floor > 0)
	{
		down = initEntity("Stairs (Down)");

		addEntityToDungeon(down, 1);
	}

	if (oldFloor > dungeon.floor)
	{
		dungeon.player->x = up->x - 1;
		dungeon.player->y = up->y;
	}
	else if (oldFloor < dungeon.floor)
	{
		dungeon.player->x = down->x + 1;
		dungeon.player->y = down->y;
	}
}

The addStairs function is responsible for adding the staircases to our dungeon floor. It takes one parameter - oldFloor, which is the floor that the player has just come from.

We start by testing whether dungeon's `floor` is less than 13. If so, we'll call initEntity, passing over "Stairs (Up)" to create an up staircase. We'll assign the result of this to a variable called `up`. We're then passing `up` over to addEntityToDungeon. Notice that we've made a change to addEntityToDungeon, as it now takes a extra parameter. This is to tell the function whether to add the entity in a clear space or not. We'll see more on this in a bit.

With our up staircase handled, we're then testing if dungeon's `floor` is greater than 0. If so, we're calling initEntity to this time create a down staircase. We're assigning the result to a variable called `down`, and also passing this over to addEntityToDungeon (again with the requirement that it must be created in clear space).

With our staircases created, we're then going to check where the player has come from, by testing oldFloor against dungeon's `floor`. If oldFloor is greater than dungeon's `floor`, we're going to set the player to the left of the up staircase. This is done by simply setting the player's `x` to `up`'s `x` minus 1. The player's `y` is set as the same as `up`'s `y`. If oldFloor is less than dungeon's `floor`, we're setting dungeon's player `x` to `down`'s `x` plus 1, and their `y` to the same as `down`'s `y`. This will make it appear as though the player has just step off the bottom or top of the stairs, depending on the direction they were going.

That's it for stairs.c. We can now look at all the other changes.

Starting with entities.c, we'll look at the tweak that was made to addEntityToDungeon:


void addEntityToDungeon(Entity *e, int inClearSpace)
{
	int x, y, ok;

	do
	{
		x = 1 + rand() % (MAP_WIDTH - 2);
		y = 1 + rand() % (MAP_HEIGHT - 2);

		ok = dungeon.map[x][y].tile > TILE_HOLE && dungeon.map[x][y].tile < TILE_WALL && !isOccupied(x, y) && (!inClearSpace || (inClearSpace && countWalls(x, y) == 0));
	}
	while (!ok);

	e->x = x;
	e->y = y;
}

As already said, we've added an extra variable called inClearSpace. We're using this in our `ok` variable assignment. We're testing whether inClearSpace is 0, or is 1 and the result of countWalls at the `x` and `y` of our random placement is 0. What this means is that if inClearSpace is 1, we'll require that there are no solid walls adjacent to the position we're placing the entity in. This means that a set of stairs won't be placed in an corridor, which would make them impossible to walk around. If inClearSpace is 0, we're free to add it to any valid dungeon position.

We've also added a new function called clearDungeonEntities:


void clearDungeonEntities(void)
{
	Entity *e;

	while (dungeon.entityHead.next)
	{
		e = dungeon.entityHead.next;

		dungeon.entityHead.next = e->next;

		if (e->type != ET_PLAYER)
		{
			if (e->data != NULL)
			{
				free(e->data);
			}

			free(e);
		}
	}

	while (dungeon.deadHead.next)
	{
		e = dungeon.deadHead.next;

		dungeon.deadHead.next = e->next;

		if (e->data != NULL)
		{
			free(e->data);
		}

		free(e);
	}
}

The idea behind this function is to delete all the entities in the dungeon, both active and dead, with the exception of the player. It is used for when the player moves from one floor to the next. We want to remove all the entities in the game, except the player, who will be moving to the next floor. For all entities, we're deleting their `data` as well as the entity itself, using free.

Moving over to dungeon.c, we'll start looking at how our floor change code is handled. Starting with initDungeon:


void initDungeon(void)
{
	memset(&dungeon, 0, sizeof(Dungeon));

	floorChangeTimer = 0;

	initMap();

	initHud();

	initInventory();

	createDungeon();

	app.delegate.logic = logic;

	app.delegate.draw = draw;
}

We're setting a variable called floorChangeTimer to 0. floorChangeTimer is a new static variable within dungeon.c.

Next, we've updated createDungeon:


static void createDungeon(void)
{
	int oldFloor;
	char text[MAX_DESCRIPTION_LENGTH];

	oldFloor = dungeon.floor;

	dungeon.floor = dungeon.newFloor;

	initEntities();

	if (dungeon.player == NULL)
	{
		initEntity("Player");
	}
	else
	{
		dungeon.player->next = NULL;

		dungeon.entityTail->next = dungeon.player;
		dungeon.entityTail = dungeon.player;
	}

	generateMap();

	if (dungeon.floor > 0)
	{
		addMonsters();

		addItems();

		addWeapons();

		addArmour();

		addMicrochips();
	}

	addStairs(oldFloor);

	updateFogOfWar();

	dungeon.currentEntity = dungeon.player;

	sprintf(text, "Entering floor #%d", dungeon.floor);

	addHudMessage(HUD_MSG_NORMAL, text);
}

We've made quite a few changes here. To begin with, we're storing the value of dungeon's `floor` into a variable called oldFloor. We're then setting dungeon's `floor` to the value of newFloor. This is done because the value of newFloor being different from `floor` is what triggers our floor change, as we'll see shortly. The next change is that we're testing whether dungeon's `player` pointer is NULL. If so, we'll be creating the player as normal. Otherwise, we'll be adding the player back into the dungeon, by adding them to the dungeon's entity list.

The next change is that we're testing if we're on a floor higher than 0. If so, we're adding in the monster, items, etc. Floor 0 is our starting floor and therefore doesn't contain any of those things. After that, we've added in a call to addStairs, passing over oldFloor. Finally, we're adding a HUD message to say which floor we've arrived at.

The changes to `logic` are next:


static void logic(void)
{
	floorChangeTimer = MAX(floorChangeTimer - app.deltaTime, 0);

	if (floorChangeTimer == 0)
	{
		doEntities();

		doHud();

		dungeon.animationTimer = MAX(dungeon.animationTimer - app.deltaTime, 0);

		if (dungeon.animationTimer <= FPS / 5)
		{
			dungeon.attackingEntity = NULL;

			if (dungeon.animationTimer == 0)
			{
				if (dungeon.currentEntity == dungeon.player)
				{
					doPlayer();
				}
				else
				{
					doMonsters();
				}
			}
		}

		doCamera();

		doSelectTile();

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

			initInventoryView();
		}

		if (dungeon.floor != dungeon.newFloor)
		{
			changeDungeonFloor();
		}
	}
}

To begin with, we're decreasing the value of floorChangeTimer and limiting it 0. We've then wrapped the remainder of the function in an if-statement, that requires floorChangeTimer to be 0. We're doing this due to the floor change effect. When moving from one floor to the next, we clear the screen for a short time, before returning. This is to prevent the scene from changing immediately and confusing the player. Since we're pausing for a moment (while floorChangeTimer is greater than 0), we want to stop all processing until we return.

At the bottom of this if-statement, we've added in a check to see if dungeon's `floor` is different from dungeon's newFloor. In other words, if we're moving floors due to walking on a set of stairs. If so, we're calling changeDungeonFloor. As you can see, newFloor acts as a trigger to move from one floor to the next. This is why we set dungeon's newFloor equal to its `floor` in createDungeon, to stop the game from constantly shifting floors.

The changeDungeonFloor function follows:


static void changeDungeonFloor(void)
{
	clearDungeonEntities();

	createDungeon();

	floorChangeTimer = FPS / 2;
}

A very simple function. We're simply calling clearDungeonEntities and createDungeon, and then setting the value of floorChangeTimer to half a second.

The `draw` function follows:


static void draw(void)
{
	if (floorChangeTimer == 0)
	{
		drawMap();

		drawEntities();

		drawHud();
	}
}

Another simple change. We're only rendering our map, entities, and hud if floorChangeTimer is 0. This means that when we change floors the screen will go blank for half a second, before returning.

Those are all the changes to dungeon.c. Only a handful of other changes remain.

Moving onto map.c, we've updated generateMap:


void generateMap(void)
{
	if (dungeon.floor > 0)
	{
		randomWalk();

		tidyWalls();
	}
	else
	{
		generateEmptyMap();
	}
}

We're now repurposing the generateEmptyMap function to only be called if we're at floor 0, our starting floor. Otherwise, we'll be generating a proper maze each time.

Onto hud.c, where we've updated drawTopBar:


static void drawTopBar(void)
{
	// snipped

	sprintf(text, "Level : %d", m->level);
	drawText(text, 900, 0, 255, 255, 255, TEXT_ALIGN_LEFT, 0);

	sprintf(text, "XP : %d / %d", m->xp, m->requiredXP);
	drawText(text, 1200, 0, 255, 255, 255, TEXT_ALIGN_LEFT, 0);

	sprintf(text, "Floor: %d", dungeon.floor);
	drawText(text, SCREEN_WIDTH - 10, 0, 255, 255, 255, TEXT_ALIGN_RIGHT, 0);
}

Again, nothing complex - we've added the floor number to the strings that we're rendering, using sprintf and dungeon's `floor` variable. We're aligning the text to the right of the screen, less 10 pixels.

Finally, we've updated initEntityFactory in entityFactory.c:


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

	addInitFunc("Player", initPlayer);
	addInitFunc("Micro Mouse", initMicroMouse);
	addInitFunc("Key", initKey);
	addInitFunc("Health Pack", initHealthPack);
	addInitFunc("Crowbar", initCrowbar);
	addInitFunc("Biker Jacket", initBikerJacket);
	addInitFunc("Microchip", initMicrochip);
	addInitFunc("Stairs (Up)", initStairsUp);
	addInitFunc("Stairs (Down)", initStairsDown);
}

Adding in initStairsUp and initStairsDown allows us to create the stairs by using initEntity. While you're probably thinking that this isn't necessary (and the code will prove you correct), we will later on be adding the ability to load and save games, which will rely on the entity factory, so adding all our entities to our factory at this point does do us any harm.

And that's it! We can now move up and down floors as we please. But what about the items and monsters? Surely there are more items to be found and different monsters to fight? In the next part, we'll set about adding in more monsters and also new items to be found. More powerful monsters and items will be found the higher up we go in our dungeon, so be ready for some tough fights ahead!

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