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

Turn-Based Loop & Melee Combat

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 →

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.

Part 5 — the @ player adjacent to a green kobold, with the window title bar showing reduced HP after trading blows
Trading blows with a kobold. The title bar tracks your HP; the log records every hit.

What's New in Part 5

FileStatusWhat changed
Entity.hUpdatedAdds power (damage) and name (for the log)
Player.hUpdatedSets the player's power and name
main.cppUpdatedChase AI, bump-to-attack, the turn order, a death screen
Monster.hUnchangedStill has wander from Part 4
Map / GlyphCache / FOVUnchangedExactly 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.hpower = 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.

The framed YOU DIED screen with the prompt to press Space to try again or Escape to quit
The dungeon claims another. Permadeath gives every fight weight.

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.