Chapter 29  ·  Project

Rogue SDL — Part 2

Part 1 gave us a dungeon you can walk around. Part 2 makes it a game. We add enemies that hunt the player using A* pathfinding, bump-to-attack melee combat, potions and gold scattered through the dungeon, a scrolling message log in the HUD, multiple dungeon levels with increasing difficulty, game-over and restart, and a save format that preserves the full game state including enemies and items.

Project folder: SDL3 Projects/Rogue SDL Part 2 — picks up from the Chapter 28 codebase. Most of the Part 1 files are unchanged.

New Files in Part 2

FileWhat's New
Enemy.h/.cppEnemy class deriving from Entity; MonsterKind enum
Item.h/.cppItem class; ItemKind enum (Potion, Gold)
Pathfind.h/.cppA* pathfinding returning next-step Point
HUD.h/.cppHP bar, depth indicator, scrolling message log

The existing files (Map, MapGen, FOV, GlyphCache, SaveLoad) are modified to handle the new content. Game is extended with enemy-turn logic, combat, and item pickup.

Enemies and the MonsterKind Enum

The Enemy class derives from Entity and carries a MonsterKind that determines its stats and appearance:

// Enemy.h
#pragma once
#include "Entity.h"

enum class MonsterKind { Rat, Goblin, Orc, Troll };

const char* monsterName  (MonsterKind k);
int         monsterMaxHp (MonsterKind k);
int         monsterDamage(MonsterKind k);
char        monsterGlyph (MonsterKind k);
SDL_Color   monsterColor (MonsterKind k);

class Enemy : public Entity
{
public:
    explicit Enemy(MonsterKind kind, Point pos);
    MonsterKind kind()   const { return kind_; }
    int         damage() const { return damage_; }

private:
    MonsterKind kind_;
    int         damage_;
};

The lookup functions (monsterName, monsterMaxHp, etc.) are free functions in Enemy.cpp — each is a switch on the enum. This is the same data-as-code pattern we used in Chapter 14 for item tables: define the enumeration, then add a lookup function for each property. Adding a new monster type costs one enum entry and one case in each switch.

A* Pathfinding

A* is the standard algorithm for finding the shortest path on a grid. It extends Dijkstra's algorithm with a heuristic — an estimate of the remaining distance to the goal — to focus the search toward the destination.

Our implementation returns only the first step of the path, not the full path. This is all enemies need: "which direction should I move right now?"

// Pathfind.cpp
struct AStarNode
{
    Point  pos;
    int    g    = 0;   // cost so far
    int    h    = 0;   // heuristic (Manhattan distance)
    int    f() const { return g + h; }
    Point  parent;
    bool   operator>(const AStarNode& o) const { return f() > o.f(); }
};

Point Pathfind::nextStep(const Map& map, Point from, Point goal,
                         const std::vector<Point>& blocked)
{
    // Min-heap: smallest f() on top
    std::priority_queue<AStarNode,
                        std::vector<AStarNode>,
                        std::greater<AStarNode>> open;

    std::unordered_map<Point, Point,  PointHash> cameFrom;
    std::unordered_map<Point, int,    PointHash> gScore;

    auto heuristic = [&](Point p) {
        return std::abs(p.x - goal.x) + std::abs(p.y - goal.y);
    };

    AStarNode start;
    start.pos = from;
    start.g   = 0;
    start.h   = heuristic(from);
    open.push(start);
    gScore[from] = 0;

    static const int DX[] = { 0, 0,-1, 1 };
    static const int DY[] = {-1, 1, 0, 0 };

    while (!open.empty())
    {
        AStarNode cur = open.top(); open.pop();
        if (cur.pos == goal)
        {
            // Reconstruct path and return first step
            Point step = goal;
            while (cameFrom.count(step) && cameFrom[step] != from)
                step = cameFrom[step];
            return step;
        }

        for (int i = 0; i < 4; ++i)
        {
            Point nb{ cur.pos.x + DX[i], cur.pos.y + DY[i] };
            if (nb.x < 0 || nb.x >= MAP_W || nb.y < 0 || nb.y >= MAP_H)
                continue;
            if (map.at(nb.x, nb.y).terrain == Terrain::Wall)
                continue;
            // Check blocked list (other enemies occupy their tiles)
            bool isBlocked = std::any_of(blocked.begin(), blocked.end(),
                [&](const Point& p){ return p == nb; });
            if (isBlocked && nb != goal) continue;

            int tentativeG = gScore[cur.pos] + 1;
            if (!gScore.count(nb) || tentativeG < gScore[nb])
            {
                gScore[nb]    = tentativeG;
                cameFrom[nb]  = cur.pos;
                AStarNode next;
                next.pos    = nb;
                next.g      = tentativeG;
                next.h      = heuristic(nb);
                next.parent = cur.pos;
                open.push(next);
            }
        }
    }

    return from;  // No path found — stay put
}

The std::priority_queue with std::greater<AStarNode> gives a min-heap (lowest f() first). The PointHash struct is a custom hash specialisation for std::unordered_map<Point, ...>:

struct PointHash
{
    std::size_t operator()(const Point& p) const
    {
        return std::hash<int>{}(p.x) ^ (std::hash<int>{}(p.y) << 16);
    }
};

The XOR-with-shift approach is simple and works for grids up to 65,535 cells wide. For a production game you'd use a better hash (multiply by large primes), but this is fine for our 80-column map.

Bump-to-Attack Combat

In roguelikes, "bump-to-attack" means that moving into a tile occupied by an enemy attacks it instead of moving. This is handled entirely in Game::handleEvent:

Point next = { player_->position().x + dx,
               player_->position().y + dy };

// Check for enemy on the target tile
Enemy* target = enemyAt(next);
if (target)
{
    playerAttack(*target);
    return true;  // turn taken
}

// Normal movement
if (map_.at(next.x, next.y).terrain != Terrain::Wall)
{
    player_->moveTo(next);
    checkItemPickup();
    checkStairs();
}
return true;

The playerAttack function applies damage, logs a message, and removes the enemy if it dies. The enemyAt helper scans the enemies vector for an enemy at the target position. Linear scan over a small vector (typically 5–15 enemies per floor) — fast enough.

Enemy turns work the same way in reverse: if the A* next step is the player's position, the enemy attacks instead of moving:

void Game::enemyTurns()
{
    // Build blocked list: current enemy positions
    std::vector<Point> occupied;
    for (auto& e : enemies_)
        occupied.push_back(e->position());

    for (auto& e : enemies_)
    {
        if (!e->isAlive()) continue;

        Point step = Pathfind::nextStep(map_,
            e->position(), player_->position(), occupied);

        if (step == player_->position())
        {
            enemyAttack(*e);
        }
        else if (step != e->position())
        {
            // Update occupied list so subsequent enemies see this move
            auto it = std::find(occupied.begin(), occupied.end(), e->position());
            if (it != occupied.end()) *it = step;
            e->moveTo(step);
        }
    }

    // Remove dead enemies
    enemies_.erase(
        std::remove_if(enemies_.begin(), enemies_.end(),
            [](const auto& e){ return !e->isAlive(); }),
        enemies_.end());
}

The occupied list is updated as each enemy moves, so two enemies don't try to occupy the same tile. The erase-remove idiom from Chapter 14 cleans up dead enemies at the end of all enemy turns.

Items — Potions and Gold

Items are simpler than enemies — they don't move or think. The Item class holds a position and a kind:

enum class ItemKind { Potion, Gold };

class Item
{
public:
    Item(ItemKind kind, Point pos) : kind_(kind), pos_(pos) {}
    ItemKind kind()     const { return kind_; }
    Point    position() const { return pos_;  }
private:
    ItemKind kind_;
    Point    pos_;
};

The MapGen scatters items into room interiors at generation time. The Game::checkItemPickup method, called whenever the player moves, scans the items vector for one at the player's new position and applies its effect (heal HP for a potion, add score for gold), then removes it.

The HUD — Message Log

The HUD area below the map shows HP, depth, and a scrolling message log. The log is a std::deque<std::string> with a maximum of five visible messages. New messages are pushed to the back; if the deque exceeds the limit, the front is popped:

void HUD::addMessage(const std::string& msg)
{
    messages_.push_back(msg);
    if (messages_.size() > MAX_MESSAGES)
        messages_.pop_front();
}

void HUD::render(SDL_Renderer* r, GlyphCache& glyphs) const
{
    int row = MAP_H;  // first row below the map
    // Draw HP bar, depth...

    for (const auto& msg : messages_)
    {
        int col = 1;
        for (char c : msg)
        {
            glyphs.draw(r, c, col, row, Palette::MESSAGE);
            ++col;
        }
        ++row;
    }
}

std::deque<std::string> is the right choice here: push to back is O(1), pop from front is O(1). A vector would require shifting all elements on every pop_front — O(n) for a data structure we're modifying every turn.

Multi-Level Depth and Scaling

Stepping onto a StairsDown tile descends one level. The game generates a fresh dungeon, resets enemy and item vectors, and spawns the next level's contents with depth-scaled parameters:

void Game::descend()
{
    ++depth_;
    map_ = Map{};
    MapGen::generate(map_, depth_);
    enemies_.clear();
    items_.clear();

    // Spawn enemies — more and harder at deeper levels
    int numEnemies = 4 + depth_ * 2;
    for (int i = 0; i < numEnemies; ++i)
    {
        MonsterKind k = MonsterKind::Rat;
        if (depth_ >= 3) k = (rand() % 2 == 0) ? MonsterKind::Goblin : MonsterKind::Rat;
        if (depth_ >= 5) k = static_cast<MonsterKind>(rand() % 3);
        // ... place at random floor tile away from player ...
    }
    hud_.addMessage("You descend deeper into the dungeon.");
    recomputeFov();
}

Game Over and Restart

When the player's HP reaches zero, gameOver_ is set to true. The render method draws a game-over overlay. The event handler ignores all movement keys while game-over is active; pressing R calls newGame():

void Game::newGame()
{
    depth_    = 1;
    gameOver_ = false;
    enemies_.clear();
    items_.clear();
    map_ = Map{};
    MapGen::generate(map_, depth_);
    player_ = std::make_unique<Player>(findFloor());
    hud_.clear();
    hud_.addMessage("Welcome to Rogue SDL.");
    recomputeFov();
    dirty_ = true;
}

Save Format v2

The save file now stores enemies and items in addition to the map and player. The version header changes from v1 to v2 so loading a v1 save gracefully fails rather than reading garbage:

// v2 save format:
// v2
// depth
// player x y hp maxhp gold
// num_enemies
//   for each: kind x y hp
// num_items
//   for each: kind x y
// map explored flags (80 chars per row, 40 rows)

Playing Part 2

Rogue SDL Part 2 — dungeon with enemies and items visible
Level 1 with enemies. Rats and goblins appear as coloured glyphs; potions show as ! and gold as $.
Rogue SDL Part 2 — combat messages in the HUD log
Combat in progress. The message log shows attack and damage results; the HP bar updates with each hit.
Rogue SDL Part 2 — game over screen
Game over. Press R to start a new game with a fresh dungeon.

The game is now playable end-to-end. Explore rooms, fight monsters, collect potions, descend stairs, and survive as long as you can. The game gets meaningfully harder each level.

Common Errors and Fixes

Compile Error — unordered_map with Point

std::unordered_map<Point, ...> requires a hash specialisation. Define PointHash and pass it as the third template argument, or specialize std::hash<Point> in a header. Missing this gives a confusing template error about a deleted function.

Enemies Walk Through Each Other

The blocked list passed to A* must be updated as enemies move within the same turn. If you rebuild it from scratch for each enemy using only the original positions, later enemies in the loop don't see where earlier ones moved to and two will try to occupy the same tile.

Items Persist After Being Picked Up

The erase-remove idiom is needed: use items_.erase(std::remove_if(...)), not just calling erase on a found iterator and then continuing to use that iterator.

Game Crashes on Descend

Dangling pointers. If anything holds a raw pointer to an enemy or item that gets cleared by enemies_.clear(), it becomes invalid. Review everything that stores a pointer to an enemy or item and make sure those pointers aren't used after descending.

Summary