Until now the monsters have been scenery — drifting about, ignoring you, harmless. Part 5 gives the dungeon teeth. Monsters learn to chase you when they can see you, melee combat arrives as the classic roguelike bump-to-attack, health finally matters, and stepping into the wrong room can end your run on a "You Died" screen.
None of it needs much code, because Part 4 did the structural work. Combat is just two entities reducing each other's hp; chasing is just stepping toward the player instead of randomly; the turn order — you act, then every monster acts — is the same rhythm from Part 4 with attacks slotted in. This is where the engine becomes a game you can lose.
What's New in Part 5
| File | Status | What changed |
|---|---|---|
Entity.h | Updated | Adds power (damage) and name (for the log) |
Player.h | Updated | Sets the player's power and name |
main.cpp | Updated | Chase AI, bump-to-attack, the turn order, a death screen |
Monster.h | Unchanged | Still has wander from Part 4 |
Map / GlyphCache / FOV | Unchanged | Exactly as before |
Entities Learn to Hit — Entity.h
Combat needs two new fields on the shared base: how hard a creature hits, and what to call it in the log. Two lines:
int power = 1; // damage dealt per hit
const char* name = "thing"; // for the combat log
The player sets its own in Player.h — power = 5, name = "You" — and each monster kind carries its own, stamped on at spawn. Everything else about Entity, including tryMove, is exactly as it was in Part 4. Notice tryMove still doesn't know about combat: bumping a monster is handled by the game loop, the only place that can see every entity at once.
The Attack — main.cpp
An attack is deliberately the simplest thing that works: subtract the attacker's power from the defender's health, log it, and mark the loser dead. Damage formulas (defence, criticals, dice) can come later — the mechanism is what matters now:
auto attack = [&](Entity& atk, Entity& def)
{
def.hp -= atk.power;
SDL_Log("%s hits %s for %d. (%s: %d hp)", atk.name, def.name, atk.power,
def.name, def.hp > 0 ? def.hp : 0);
if (def.hp <= 0)
{
def.hp = 0;
def.alive = false;
SDL_Log("%s dies.", def.name);
}
};
For now the "combat log" is just SDL_Log to the console — a real on-screen message log is a job for the UI polish in Part 15. It's plenty to follow a fight while you build.
Bump to Attack
The roguelike convention is beautifully simple: there's no separate attack key. You walk into a monster, and that is the attack. So before the player moves, we check whether a living monster occupies the destination. If so, we strike it instead of moving:
Monster* target = monsterAt(player.x + dx, player.y + dy);
if (target)
{
attack(player, *target);
tookTurn = true;
}
else if (player.tryMove(dx, dy, map))
{
FOV::compute(map, player.x, player.y);
tookTurn = true;
}
The little monsterAt helper just scans for a living monster on a tile:
auto monsterAt = [&](int x, int y) -> Monster*
{
for (Monster& m : monsters)
if (m.isAlive() && m.x == x && m.y == y) return &m;
return nullptr;
};
Attacking counts as your turn just as moving does — both set tookTurn, and only a turn that was actually taken advances the monsters. (Attacking doesn't recompute field of view, because you didn't move.)
Monsters That Hunt
In Part 4 a monster always wandered. Now it checks first whether it can see you — and we reuse the fog-of-war flag to decide: if the monster is standing on a lit tile, then you can see it, which means it can see you. Seen monsters hunt; unseen ones wander in the dark as before:
if (!map.tiles[m.y][m.x].visible)
{
m.wander(map, rng);
continue;
}
int ddx = player.x - m.x;
int ddy = player.y - m.y;
// Adjacent to the player? Strike.
if (std::abs(ddx) + std::abs(ddy) == 1)
{
attack(m, player);
continue;
}
// Otherwise close the distance: step along the longer axis first,
// fall back to the other axis if that way is blocked.
int sx = (std::abs(ddx) >= std::abs(ddy)) ? sgn(ddx) : 0;
int sy = (sx == 0) ? sgn(ddy) : 0;
auto stepIfFree = [&](int dx, int dy) -> bool
{
if (dx == 0 && dy == 0) return false;
int nx = m.x + dx, ny = m.y + dy;
if (nx < 0 || nx >= MAP_WIDTH || ny < 0 || ny >= MAP_HEIGHT) return false;
if (map.tiles[ny][nx].type != TileType::Floor) return false;
if (nx == player.x && ny == player.y) return false; // never stack on player
if (monsterAt(nx, ny)) return false; // or on each other
m.x = nx; m.y = ny; return true;
};
if (!stepIfFree(sx, sy))
stepIfFree(sgn(ddx) - sx, sgn(ddy) - sy); // try the other axis
This is "greedy" chasing — always step toward the player on whichever axis is further, and if a wall blocks that, try the other axis. It's not perfect pathfinding (a clever monster could be led in circles around a pillar), and that's fine: smart, map-aware pursuit is exactly what the Dijkstra maps in Part 6 are for. The stepIfFree checks keep monsters from walking through walls, stacking on each other, or standing on you — they stop one tile away and attack instead. The sgn helper just returns -1, 0, or +1.
The Turn Order
The shape of a turn is now explicit and is the spine of the whole game: the player acts; then, if a turn was actually taken, every monster acts; then we check whether the player survived.
if (tookTurn)
{
monstersAct();
updateTitle();
if (player.hp <= 0) dead = true;
dirty = true;
}
monstersAct simply runs the chase-or-wander logic above for every living monster. updateTitle writes the player's HP into the title bar — our stand-in HUD until Part 15. And the moment the player's HP hits zero, we flip into the dead state.
You Died
Death mirrors the win state from the bonus finale: a flag the renderer and the input loop both fork on. When dead is true, draw shows a framed message instead of the dungeon, and the only keys that do anything are Space (try again) and Escape (quit):
if (dead)
{
if (event.key.scancode == SDL_SCANCODE_SPACE) { newGame(); dirty = true; }
if (event.key.scancode == SDL_SCANCODE_ESCAPE) running = false;
break;
}
That same playing / dead fork is a real game-state machine in miniature — the pattern that will later hold a title screen, an inventory, a pause menu. One boolean today; an enum class GameState tomorrow.
Try It
Build and run. Explore until a monster spots you and watch it turn and come — rats are weak, goblins hurt. Walk into one to attack; it hits back, and the title bar counts your HP down. Clear a room and the survivors of the dungeon are yours to hunt. Get greedy against three at once and you'll meet the death screen; press Space to try a fresh dungeon. Watch the Output window to read the blow-by-blow.
Complete Code
Only the changed files are listed. Monster.h is unchanged from Part 4, as are Map, GlyphCache and FOV.
Entity.h
#pragma once
#include <SDL3/SDL.h>
#include "Map.h"
struct Entity
{
int x = 0;
int y = 0;
int hp = 1;
int maxHp = 1;
int power = 1;
char glyph = '?';
SDL_Color color = { 255, 255, 255, 255 };
const char* name = "thing";
bool alive = true;
bool isAlive() const { return alive && hp > 0; }
bool tryMove(int dx, int dy, const Map& map)
{
int nx = x + dx;
int ny = y + dy;
if (nx < 0 || nx >= MAP_WIDTH) return false;
if (ny < 0 || ny >= MAP_HEIGHT) return false;
if (map.tiles[ny][nx].type != TileType::Floor) return false;
x = nx;
y = ny;
return true;
}
};
Player.h
#pragma once
#include "Entity.h"
struct Player : Entity
{
Player()
{
glyph = '@';
color = { 255, 230, 150, 255 };
name = "You";
hp = maxHp = 30;
power = 5;
}
};
Monster.h
#pragma once
#include <random>
#include "Entity.h"
struct Monster : Entity
{
void wander(const Map& map, std::mt19937& rng)
{
static const int dx[4] = { 0, 0, -1, +1 };
static const int dy[4] = { -1, +1, 0, 0 };
int choice = std::uniform_int_distribution<int>(0, 4)(rng);
if (choice < 4)
tryMove(dx[choice], dy[choice], map);
}
};
main.cpp
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <vector>
#include <random>
#include <cstdlib>
#include "Map.h"
#include "FOV.h"
#include "GlyphCache.h"
#include "Player.h"
#include "Monster.h"
static const char* FONT_PATH = "RobotoMono-Light.ttf";
static const float FONT_PT = 20.0f;
static const SDL_Color BG = { 12, 12, 16, 255 };
static const SDL_Color WALL_LIT = { 150, 140, 175, 255 };
static const SDL_Color FLOOR_LIT = { 100, 92, 80, 255 };
static const SDL_Color WALL_MEM = { 58, 54, 72, 255 };
static const SDL_Color FLOOR_MEM = { 40, 37, 33, 255 };
static const SDL_Color DEAD_FRAME = { 150, 60, 60, 255 };
static const SDL_Color DEAD_TITLE = { 220, 90, 90, 255 };
static const SDL_Color DEAD_TEXT = { 210, 200, 205, 255 };
static const SDL_Color DEAD_HINT = { 120, 112, 112, 255 };
struct MonsterKind { char glyph; SDL_Color color; int hp; int power; const char* name; };
static const MonsterKind KINDS[] = {
{ 'r', { 150, 150, 160, 255 }, 3, 1, "the rat" },
{ 'k', { 110, 200, 120, 255 }, 5, 2, "the kobold" },
{ 'g', { 120, 170, 230, 255 }, 8, 3, "the goblin" },
};
static const int NUM_KINDS = (int)(sizeof(KINDS) / sizeof(KINDS[0]));
static const int MONSTER_COUNT = 8;
static int sgn(int v) { return (v > 0) - (v < 0); }
int main(int argc, char* argv[])
{
if (!SDL_Init(SDL_INIT_VIDEO)) { SDL_Log("SDL_Init: %s", SDL_GetError()); return 1; }
if (!TTF_Init()) { SDL_Log("TTF_Init: %s", SDL_GetError()); SDL_Quit(); return 1; }
SDL_Window* window = nullptr;
SDL_Renderer* sdl = nullptr;
if (!SDL_CreateWindowAndRenderer(
"Roguelike - Part 5: Melee Combat",
MAP_WIDTH * TILE_SIZE, MAP_HEIGHT * TILE_SIZE, 0,
&window, &sdl))
{
SDL_Log("CreateWindowAndRenderer: %s", SDL_GetError());
TTF_Quit(); SDL_Quit();
return 1;
}
GlyphCache glyphs(sdl, FONT_PATH, FONT_PT);
if (!glyphs.ok())
{
SDL_DestroyRenderer(sdl); SDL_DestroyWindow(window);
TTF_Quit(); SDL_Quit();
return 1;
}
std::mt19937 rng{ std::random_device{}() };
Map map;
Player player;
std::vector<Monster> monsters;
bool dead = false;
auto updateTitle = [&]()
{
char buf[96];
SDL_snprintf(buf, sizeof(buf), "Roguelike - Part 5 | HP: %d / %d",
player.hp, player.maxHp);
SDL_SetWindowTitle(window, buf);
};
auto newGame = [&]()
{
map.generate();
player.x = map.rooms[0].centreX();
player.y = map.rooms[0].centreY();
player.hp = player.maxHp;
player.alive = true;
dead = false;
FOV::compute(map, player.x, player.y);
monsters.clear();
int n = (int)map.rooms.size();
for (int i = 1; i < n && (int)monsters.size() < MONSTER_COUNT; ++i)
{
const MonsterKind& k = KINDS[std::uniform_int_distribution<int>(0, NUM_KINDS - 1)(rng)];
Monster m;
m.x = map.rooms[i].centreX();
m.y = map.rooms[i].centreY();
m.glyph = k.glyph; m.color = k.color;
m.hp = m.maxHp = k.hp;
m.power = k.power; m.name = k.name;
monsters.push_back(m);
}
updateTitle();
};
auto monsterAt = [&](int x, int y) -> Monster*
{
for (Monster& m : monsters)
if (m.isAlive() && m.x == x && m.y == y) return &m;
return nullptr;
};
auto attack = [&](Entity& atk, Entity& def)
{
def.hp -= atk.power;
SDL_Log("%s hits %s for %d. (%s: %d hp)", atk.name, def.name, atk.power,
def.name, def.hp > 0 ? def.hp : 0);
if (def.hp <= 0)
{
def.hp = 0;
def.alive = false;
SDL_Log("%s dies.", def.name);
}
};
auto monstersAct = [&]()
{
for (Monster& m : monsters)
{
if (!m.isAlive()) continue;
if (!map.tiles[m.y][m.x].visible)
{
m.wander(map, rng);
continue;
}
int ddx = player.x - m.x;
int ddy = player.y - m.y;
if (std::abs(ddx) + std::abs(ddy) == 1)
{
attack(m, player);
continue;
}
int sx = (std::abs(ddx) >= std::abs(ddy)) ? sgn(ddx) : 0;
int sy = (sx == 0) ? sgn(ddy) : 0;
auto stepIfFree = [&](int dx, int dy) -> bool
{
if (dx == 0 && dy == 0) return false;
int nx = m.x + dx, ny = m.y + dy;
if (nx < 0 || nx >= MAP_WIDTH || ny < 0 || ny >= MAP_HEIGHT) return false;
if (map.tiles[ny][nx].type != TileType::Floor) return false;
if (nx == player.x && ny == player.y) return false;
if (monsterAt(nx, ny)) return false;
m.x = nx; m.y = ny; return true;
};
if (!stepIfFree(sx, sy))
stepIfFree(sgn(ddx) - sx, sgn(ddy) - sy);
}
};
auto drawCentred = [&](int row, const char* text, SDL_Color color)
{
int len = (int)SDL_strlen(text);
int startCol = (MAP_WIDTH - len) / 2;
for (int i = 0; i < len; ++i)
glyphs.drawGlyph(startCol + i, row, text[i], color);
};
auto drawDeathScreen = [&]()
{
SDL_SetRenderDrawColor(sdl, BG.r, BG.g, BG.b, BG.a);
SDL_RenderClear(sdl);
const int boxW = 40, boxH = 9;
const int x0 = (MAP_WIDTH - boxW) / 2;
const int y0 = (MAP_HEIGHT - boxH) / 2;
for (int i = 0; i < boxW; ++i)
{
glyphs.drawGlyph(x0 + i, y0, '=', DEAD_FRAME);
glyphs.drawGlyph(x0 + i, y0 + boxH - 1, '=', DEAD_FRAME);
}
for (int j = 0; j < boxH; ++j)
{
glyphs.drawGlyph(x0, y0 + j, '|', DEAD_FRAME);
glyphs.drawGlyph(x0 + boxW - 1, y0 + j, '|', DEAD_FRAME);
}
drawCentred(y0 + 2, "Y O U D I E D", DEAD_TITLE);
drawCentred(y0 + 4, "The dungeon claims another.", DEAD_TEXT);
drawCentred(y0 + 6, "SPACE try again ESC quit", DEAD_HINT);
SDL_RenderPresent(sdl);
};
auto drawTile = [&](int col, int row, const Tile& t)
{
if (!t.explored) return;
if (t.visible)
{
if (t.type == TileType::Floor) glyphs.drawGlyph(col, row, '.', FLOOR_LIT);
else glyphs.drawGlyph(col, row, '#', WALL_LIT);
}
else
{
if (t.type == TileType::Floor) glyphs.drawGlyph(col, row, '.', FLOOR_MEM);
else glyphs.drawGlyph(col, row, '#', WALL_MEM);
}
};
auto draw = [&]()
{
if (dead) { drawDeathScreen(); return; }
SDL_SetRenderDrawColor(sdl, BG.r, BG.g, BG.b, BG.a);
SDL_RenderClear(sdl);
for (int row = 0; row < MAP_HEIGHT; ++row)
for (int col = 0; col < MAP_WIDTH; ++col)
drawTile(col, row, map.tiles[row][col]);
for (const Monster& m : monsters)
if (m.isAlive() && map.tiles[m.y][m.x].visible)
glyphs.drawGlyph(m.x, m.y, m.glyph, m.color);
glyphs.drawGlyph(player.x, player.y, player.glyph, player.color);
SDL_RenderPresent(sdl);
};
newGame();
draw();
bool running = true;
SDL_Event event;
while (running)
{
SDL_WaitEvent(&event);
bool dirty = false;
switch (event.type)
{
case SDL_EVENT_QUIT:
running = false;
break;
case SDL_EVENT_WINDOW_EXPOSED:
dirty = true;
break;
case SDL_EVENT_KEY_DOWN:
{
if (dead)
{
if (event.key.scancode == SDL_SCANCODE_SPACE) { newGame(); dirty = true; }
if (event.key.scancode == SDL_SCANCODE_ESCAPE) running = false;
break;
}
int dx = 0, dy = 0;
switch (event.key.scancode)
{
case SDL_SCANCODE_UP: case SDL_SCANCODE_W: dy = -1; break;
case SDL_SCANCODE_DOWN: case SDL_SCANCODE_S: dy = +1; break;
case SDL_SCANCODE_LEFT: case SDL_SCANCODE_A: dx = -1; break;
case SDL_SCANCODE_RIGHT: case SDL_SCANCODE_D: dx = +1; break;
case SDL_SCANCODE_SPACE: newGame(); dirty = true; break;
case SDL_SCANCODE_ESCAPE: running = false; break;
default: break;
}
if (dx != 0 || dy != 0)
{
bool tookTurn = false;
Monster* target = monsterAt(player.x + dx, player.y + dy);
if (target)
{
attack(player, *target);
tookTurn = true;
}
else if (player.tryMove(dx, dy, map))
{
FOV::compute(map, player.x, player.y);
tookTurn = true;
}
if (tookTurn)
{
monstersAct();
updateTitle();
if (player.hp <= 0) dead = true;
dirty = true;
}
}
break;
}
}
if (dirty) draw();
}
SDL_DestroyRenderer(sdl);
SDL_DestroyWindow(window);
TTF_Quit();
SDL_Quit();
return 0;
}
Common Errors
Walking into a monster moves you onto it instead of attacking. The monsterAt check is missing or runs after tryMove. Test the destination tile for a monster first; only call tryMove when it's clear.
Monsters attack you from across the room. The adjacency test is wrong. A four-directional melee hit requires std::abs(ddx) + std::abs(ddy) == 1 — exactly one tile away, orthogonally.
Dead monsters still fight or block. You're not checking isAlive(). monstersAct, monsterAt and the draw loop must all skip monsters whose alive is false (or hp <= 0).
Monsters chase you through solid rock. stepIfFree isn't checking the tile type. Every monster step must verify the destination is TileType::Floor and unoccupied before committing.
HP hits zero but the game continues. The death check runs in the wrong place. Test player.hp <= 0 after monstersAct(), where the monsters' blows have actually landed, and set dead = true there.
The whole pack moves the instant the game starts. monstersAct() is being called outside the tookTurn guard. Monsters act only after a turn the player actually took.
What's Next
You now have a complete, losable game loop: explore, fight, survive or die. The greedy chase works, but it's easily fooled — lead a monster around a pillar and it loses the plot. Part 6 replaces it with Dijkstra maps, the elegant roguelike technique that floods the dungeon with distance values so every monster always knows the true shortest path to you — and the same map, run in reverse, makes them flee when wounded. From here the series moves into Act 2: items, equipment, status effects and depth.