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
| File | What's New |
|---|---|
Enemy.h/.cpp | Enemy class deriving from Entity; MonsterKind enum |
Item.h/.cpp | Item class; ItemKind enum (Potion, Gold) |
Pathfind.h/.cpp | A* pathfinding returning next-step Point |
HUD.h/.cpp | HP 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
! and gold as $.
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
- A* pathfinding with a Manhattan heuristic finds enemy routes in O((W×H) log(W×H)) — fast enough for real-time play on small grids.
- Bump-to-attack: if the player or enemy tries to move into an occupied tile, it attacks instead. Simple, elegant, genre-defining.
std::deque<std::string>gives an efficient push-back / pop-front scrolling message log.- The erase-remove idiom cleans up dead enemies and picked-up items in one pass with no index juggling.
- The save format version header (
v2) ensures old saves fail cleanly rather than corrupting the game state. - Depth scaling (more enemies, harder types, better loot) is the simplest form of progression — and it works.