Tutorial 4 of 17  ·  Roguelikes in C++ with SDL3

Monster Entities & Basic AI

Source code on GitHub. The finished project for this part — and all sixteen — is at EliteIntegrity/Roguelike-tutorial-series, one folder per tutorial. Clone it to build along or check your work. View repo →

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.

Part 4 — the @ player in a lit room with a green k kobold and a grey r rat nearby, other monsters hidden in darkness
Monsters drawn as coloured letters — only the ones in your field of view. The rest wait in the dark.

What's New in Part 4

FileStatusWhat it does
Entity.hNewShared base: position, health, glyph, colour, tryMove
Monster.hNewMonster : Entity with wander AI
Player.hRewrittenNow just Player : Entity
main.cppUpdatedSpawns monsters, runs their turns, draws them when visible
Map / GlyphCache / FOVUnchangedExactly 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.

The same dungeon a few turns later — monsters have shuffled to new positions as the player moved
A few turns on. The monsters have wandered — they move only when you do.

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.