The dungeon is atmospheric but empty. Part 4 fills it with monsters — and in doing so introduces the single most important piece of structure in the whole series: the Entity. Up to now the player has been a one-off struct. But the player and a monster have almost everything in common — a position, some health, a glyph to draw, the ability to try to move — so we lift all of that into a shared base class that both will derive from. Write it once, and a monster is suddenly 90% built.
On top of that we give monsters the simplest possible behaviour — wandering — and wire them into the turn structure so they act when you act. And because we built field of view in Part 3, monsters get something atmospheric for free: they're only drawn when you can actually see them. A goblin shuffling around in an unexplored room is invisible until you round the corner and the torchlight finds it.
What's New in Part 4
| File | Status | What it does |
|---|---|---|
Entity.h | New | Shared base: position, health, glyph, colour, tryMove |
Monster.h | New | Monster : Entity with wander AI |
Player.h | Rewritten | Now just Player : Entity |
main.cpp | Updated | Spawns monsters, runs their turns, draws them when visible |
Map / GlyphCache / FOV | Unchanged | Exactly as in Part 3 |
The Entity Base — Entity.h
Everything that lives on the map and acts in turns is an Entity. It holds the fields the player and monsters share, and the map-only movement routine we first wrote for the player back in Part 2 — which now lives here, written once, for everyone:
#pragma once
#include <SDL3/SDL.h>
#include "Map.h"
struct Entity
{
int x = 0;
int y = 0;
int hp = 1;
int maxHp = 1;
char glyph = '?';
SDL_Color color = { 255, 255, 255, 255 };
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;
}
};
Note tryMove still only knows about the map — it checks bounds and floor, nothing else. Being blocked by another creature, and bumping one to attack it, is deliberately left out; that's the heart of Part 5. Keeping the base class ignorant of combat keeps it simple and reusable.
The Player, Slimmed Down — Player.h
The Player struct shrinks to almost nothing. Everything it used to do is inherited; all that's left is to stamp on the player's own glyph, colour and health:
#pragma once
#include "Entity.h"
struct Player : Entity
{
Player()
{
glyph = '@';
color = { 255, 230, 150, 255 }; // bright gold
hp = maxHp = 30;
}
};
That's the payoff of a good base class: the player keeps every line of behaviour it had, but the code that is the player is now three assignments. The movement code from Part 2 didn't change — it just moved up into Entity.
The Monster — Monster.h
A monster is an Entity with behaviour. In Part 4 the behaviour is the simplest thing that still feels alive: wander. Each turn the monster steps in a random cardinal direction, or stands still:
#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); // 4 = wait
if (choice < 4)
tryMove(dx[choice], dy[choice], map);
}
};
The distribution runs 0..4 — five outcomes, four of them a move and one a wait, so a monster pauses roughly one turn in five, which reads as more natural than relentless pacing. Walking into a wall is harmless: tryMove simply returns false and the monster stays put. We pass in the std::mt19937 from main rather than letting each monster own a generator, so the whole game shares one well-seeded random stream.
Spawning and Turns — main.cpp
Describing monster kinds
Rather than hard-coding each monster, we describe a few kinds — a glyph, a colour, and a starting health — and stamp one onto each monster as it spawns:
struct MonsterKind { char glyph; SDL_Color color; int hp; };
static const MonsterKind KINDS[] = {
{ 'r', { 150, 150, 160, 255 }, 3 }, // rat
{ 'k', { 110, 200, 120, 255 }, 5 }, // kobold
{ 'g', { 120, 170, 230, 255 }, 8 }, // goblin
};
static const int NUM_KINDS = (int)(sizeof(KINDS) / sizeof(KINDS[0]));
static const int MONSTER_COUNT = 8;
Placing them
Inside newGame we drop one monster into each room, starting at rooms[1] so none share the player's spawn in room 0. Each gets a random kind:
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;
monsters.push_back(m);
}
The turn structure
This is the important idea, and it's tiny: when the player takes a turn, every monster takes one too. We already had "a successful player move is a turn" from Part 2; we just add the monsters' turns right after it:
if (player.tryMove(dx, dy, map))
{
FOV::compute(map, player.x, player.y);
// The player took a turn, so now every monster takes one.
for (Monster& m : monsters)
if (m.isAlive())
m.wander(map, rng);
dirty = true;
}
Because the whole loop is still driven by SDL_WaitEvent, the monsters only move when you move. Stand still and the dungeon stands still with you — the defining rhythm of a turn-based roguelike, and the thing that makes it a game of thought rather than reflex.
Drawing only what's seen
Monsters are drawn after the tiles and before the player, and only when the player can currently see their tile:
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);
That one map.tiles[m.y][m.x].visible check is the entire reason the field-of-view work in Part 3 pays off here. Unlike floors and walls, monsters are not drawn from memory — you see them only while they're lit, so a creature can slip out of view and you lose track of it. That uncertainty is exactly the tension a roguelike wants.
Try It
Build and run. Explore, and coloured letters appear as your torchlight reaches them — grey r rats, green k kobolds, blue g goblins — each drifting a step at a time whenever you move. Walk away and they vanish back into the dark. They can't hurt you yet, and you can't hurt them; walking into one just stops you, because tryMove doesn't know what an entity is. We fix that next.
Complete Code
Only the new and changed files are listed. Map.h, Map.cpp, GlyphCache.h, GlyphCache.cpp, FOV.h and FOV.cpp are unchanged from Part 3.
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;
char glyph = '?';
SDL_Color color = { 255, 255, 255, 255 };
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 };
hp = maxHp = 30;
}
};
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 "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 };
struct MonsterKind { char glyph; SDL_Color color; int hp; };
static const MonsterKind KINDS[] = {
{ 'r', { 150, 150, 160, 255 }, 3 },
{ 'k', { 110, 200, 120, 255 }, 5 },
{ 'g', { 120, 170, 230, 255 }, 8 },
};
static const int NUM_KINDS = (int)(sizeof(KINDS) / sizeof(KINDS[0]));
static const int MONSTER_COUNT = 8;
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 4: Monsters & AI",
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;
auto newGame = [&]()
{
map.generate();
player.x = map.rooms[0].centreX();
player.y = map.rooms[0].centreY();
player.hp = player.maxHp;
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;
monsters.push_back(m);
}
};
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 = [&]()
{
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:
{
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)
{
if (player.tryMove(dx, dy, map))
{
FOV::compute(map, player.x, player.y);
for (Monster& m : monsters)
if (m.isAlive())
m.wander(map, rng);
dirty = true;
}
}
break;
}
}
if (dirty) draw();
}
SDL_DestroyRenderer(sdl);
SDL_DestroyWindow(window);
TTF_Quit();
SDL_Quit();
return 0;
}
Common Errors
Compiler error: Player has no member tryMove / x. The inheritance is missing — it must be struct Player : Entity (and struct Monster : Entity), and both must #include "Entity.h".
Monsters never appear. Either none spawned (check map.rooms.size() is more than 1) or they're being drawn without the visibility gate. Remember they're only shown when map.tiles[m.y][m.x].visible — explore toward them.
Monsters move continuously / on their own. Their turn loop is outside the "player actually moved" branch. The for (Monster& m : monsters) m.wander(...) must run only after player.tryMove(...) returns true, so the world advances one tick per player action.
Monsters stay visible after you leave the room. You're drawing them from the explored flag like tiles. Monsters should use visible only — they move, so a remembered position would be a lie.
The same monster layout every run. The std::mt19937 isn't seeded. Construct it as std::mt19937 rng{ std::random_device{}() } so each launch differs.
What's Next
Part 5 makes the dungeon dangerous. Monsters gain the sense to chase you when they can see you, and we add melee combat: walk into a monster to attack it, and it strikes back. Entities gain attack power, the tryMove call grows a bump-to-attack check, HP starts mattering, and a "You Died" screen waits for the careless. The Entity base you built here is what makes all of it fall into place.