Tutorial 11 of 17  ·  Act 2 — Systems

Multiple Floors & Depth Scaling

Source code on GitHub. The finished project for this part — and all seventeen — is at EliteIntegrity/Roguelike-tutorial-series, one folder per tutorial. The full Game.cpp lives there. View repo →

A roguelike is a descent. Part 11 makes the dungeon deep: stairs to climb down, floors that get steadily nastier, and the structural change that makes it all clean — the Level class we earmarked back at the Architecture interlude. This is the second of our two planned refactors, and like the first it earns its place by arriving exactly when a feature demands it: you cannot have a second floor while the map, monsters and items are single global things owned by the game.

The Level Extraction

Until now Game held the map, the monster list and the item list directly. A floor, though, is precisely those three things bundled together — so we bundle them:

class Level
{
public:
    Map                  map;
    std::vector<Monster> monsters;
    std::vector<Item>    items;
    int                  stairsX = 0;
    int                  stairsY = 0;

    void generate(int depth, std::mt19937& rng);
};

And Game trades its three loose members for a stack of levels plus the current depth:

std::vector<Level> m_levels;     // one per floor
int                m_depth = 0;

Level& cur() { return m_levels[m_depth]; }   // the floor we're on

That one accessor, cur(), is the hinge of the whole refactor. Everywhere the old code said m_map, m_monsters or m_items, it now says cur().map, cur().monsters, cur().items. It's a mechanical change — but it only stayed mechanical because Part 6 had already gathered all that logic into one Game class. Imagine making this change against the Part 5 pile of lambdas in main(); the refactor we did then is what makes the refactor we're doing now a half-hour job instead of a nightmare. Because each Level carries its own map — and the map carries its own explored/visible flags — a floor you leave and come back to is exactly as you left it.

Generating a Floor — Scaled to Depth

Level::generate owns floor population now (it's the natural home for the monster table and item rolls, moved out of Game). It takes the depth and makes deeper floors meaner — more monsters, each tougher:

void Level::generate(int depth, std::mt19937& rng)
{
    map.generate();
    monsters.clear();
    items.clear();

    int n = (int)map.rooms.size();

    // Down-stairs in the last room — far from where the player enters (room 0).
    const Room& last = map.rooms[n - 1];
    stairsX = last.centreX();
    stairsY = last.centreY();

    int monsterCount = 8 + depth * 2;             // more, the deeper you go
    for (int i = 1; i < n && (int)monsters.size() < monsterCount; ++i)
    {
        // ...pick a random kind...
        m.hp = m.maxHp = k.hp + depth;            // hardier with depth
        m.power = k.power + depth / 2;            // and hits harder
        monsters.push_back(m);
    }

    // ...scatter items on random floor tiles...
}

Putting the stairs in the last room — the player always enters in room 0 — guarantees a real journey across each floor to reach the way down.

Descending

The player carries their stats, inventory and equipment between floors (those live on Game, not on the Level), so descending just swaps which level is current — generating the next one the first time it's reached:

void Game::descend()
{
    Level& here = cur();
    if (m_player.x != here.stairsX || m_player.y != here.stairsY)
    {
        SDL_Log("There are no stairs here.");
        return;
    }

    m_depth++;
    if (m_depth >= (int)m_levels.size())          // first visit — build it, scaled to depth
    {
        m_levels.emplace_back();
        m_levels.back().generate(m_depth, m_rng);
    }

    Level& L = cur();
    m_player.x = L.map.rooms[0].centreX();
    m_player.y = L.map.rooms[0].centreY();
    FOV::compute(L.map, m_player.x, m_player.y);
    SDL_Log("You descend to depth %d.", m_depth + 1);
}

The if (m_depth >= m_levels.size()) guard is what makes floors persistent: a level is generated once, the first time you set foot on it, and then kept in the stack. Add an "ascend" with < later and you'd walk back up into the exact floor you left — same layout, same explored map, same survivors. Descending doesn't cost a turn; you're stepping off the floor, so the monsters you leave behind simply freeze in place until you return.

Stairs and Input

The down-stairs draw as a bright yellow >, obeying the fog of war like everything else, and pressing the > key (the period/greater-than key) descends when you're standing on them:

if (sc == SDL_SCANCODE_PERIOD)   // the '>' key
{
    descend();
    dirty = true;
    break;
}

The title bar now leads with Depth, so you always know how far down you've pushed your luck.

Try It

Build and run. Cross the floor to the glowing >, press >, and drop into a fresh dungeon — and notice the welcome party is bigger and hits harder than the one above. Keep descending: by depth 4 or 5 those rats are no longer a joke, and the equipment and potions you hoarded on the easy floors start to matter. The further you go, the better the run — and the worse the death.

Part 11 — the player standing on a bright yellow > down-stairs, title bar reading Depth 3
The down-stairs glow yellow. Each > takes you to a deeper, deadlier floor — the title bar keeps the count.

Notes on the Code

New files: Level.h / Level.cpp. Game.h and Game.cpp change throughout (the cur() swap, descend, stairs, depth in the title). The full Game.cpp is in the repo. Map, GlyphCache, FOV, Entity, Item, Monster, Player and DijkstraMap are unchanged from Part 10.

Common Errors

Crash the instant you descend. A dangling reference. After m_levels.emplace_back() the vector may reallocate, invalidating any Level& you grabbed earlier — fetch cur() after the new level is added, as the code does.

Pressing > anywhere descends. The on-stairs check is missing. Compare the player's position to cur().stairsX/Y first and bail out with a message otherwise.

The new floor is pitch black, or the player spawns in a wall. You didn't recompute FOV (or place the player in rooms[0]) for the level just switched to. Do both against the new cur().map.

Inventory or HP resets on descent. Player state is being stored on the Level instead of the Game. Only the floor's contents (map, monsters, items) belong to Level; the player and inventory belong to Game and travel with you.

What's Next

Act 2 is complete — you have a deep, scaling dungeon full of systems. Act 3 opens with Part 12 — Cellular Automata Cave Generation: a second, completely different dungeon style. Instead of neat BSP rooms we'll grow organic, twisting caverns by seeding the map with random rock and smoothing it over a few generations of a simple cellular automaton — then drop it into the same Level we just built, so the rest of the game neither knows nor cares which generator made the floor.