Chapter 30  ·  Project

Rogue SDL — Part 3: Polish & Depth

The book formally ended last chapter. Rogue SDL was complete: a turn-based ASCII roguelike with procgen, FOV, A* pathfinding, combat, items, multi-level descent, and a save system. You could play it for hours. This chapter is a bonus victory lap — we're going to take that complete game and add the things that turn a "working roguelike" into one that's genuinely fun. Sound effects, magic scrolls with targeting, weapons you can wield, an inventory popup, and tiny hit-flash animations that make every blow feel landed. Underneath the new content sits one architectural lesson worth the whole chapter: the state stack.

Project folder: SDL3 Projects/Rogue SDL Part 3 — picks up from Chapter 29's codebase. Most files are unchanged.

The Design — State Stack First

So far our Game::handleEvent has been a flat switch — fine when "there's exactly one input mode at all times." The moment you have a modal popup (an inventory) or a transient targeting cursor, that flat switch gets messy fast. A stack of GameState objects fixes it cleanly. Each state owns its input handling and its rendering; pushing a new state on top temporarily takes over input and renders on top of the world; popping it gets you back where you were.

TOP   +---------------------+
      |  TargetingState     |   <-- arrow keys move cursor, Enter casts
      +---------------------+
      |  InventoryState     |   <-- letter keys use items
      +---------------------+
      |  PlayingState       |   <-- arrow keys move player
      +---------------------+
BOT

The Game keeps a std::vector<std::unique_ptr<GameState>>. The state at the top of the stack is the only one that receives input. Rendering walks the whole stack bottom-to-top, so the inventory popup naturally draws over the world, and the targeting cursor draws over both.

This is a textbook State pattern. Real game engines (Unity, Unreal, Godot) all use some variant of it for screens, menus, and modes.

The GameState Base

// GameState.h
#pragma once
#include <SDL3/SDL.h>

class Game;

struct InputResult
{
    bool handled  = false;
    bool tookTurn = false;
};

class GameState
{
public:
    virtual ~GameState() = default;

    GameState(const GameState&)            = delete;
    GameState& operator=(const GameState&) = delete;

    virtual void onEnter(Game&) {}
    virtual void onExit (Game&) {}

    virtual InputResult handleEvent(Game&, const SDL_Event&) = 0;
    virtual void        render     (Game&, SDL_Renderer*)    = 0;

    virtual bool transparent() const { return false; }

protected:
    GameState() = default;
};

The abstract base from Chapter 21 — pure virtual methods, virtual destructor for safe deletion through the base. InputResult is a small two-bool struct: handled says "I recognised this event"; tookTurn says "the world should advance one turn." transparent() declares whether the state below us should still render — Inventory and Targeting return true; PlayingState returns the default false.

The Game, Refactored

Game becomes a world container plus state-stack manager. The old flat handleEvent moves out into the states; what stays is world data and helpers that states call:

// Game.h  (key additions)
class Game
{
public:
    // Stack management
    void pushState(std::unique_ptr<GameState>);
    void popState();
    GameState* top();

    // World actions states call
    void recomputeFov();
    void enemyTurns();
    void descend();
    Enemy* enemyAt(Point p);
    void   playerAttack(Enemy& target);
    void   enemyAttack (Enemy& attacker);
    void   doMagicMapping();
    void   castFireballAt(Point center);

    Sounds&       sounds();
    FlashEffects& flashes();
    Uint64 nowMs() const { return SDL_GetTicks(); }

private:
    std::vector<std::unique_ptr<GameState>> stack_;
    Sounds       sounds_;
    FlashEffects flashes_;
    // ...
};

Push and pop are tiny — the unique_ptr<GameState> does all the ownership work:

void Game::pushState(std::unique_ptr<GameState> s)
{
    GameState* raw = s.get();
    stack_.push_back(std::move(s));
    raw->onEnter(*this);
    dirty_ = true;
}

void Game::popState()
{
    if (stack_.empty()) return;
    stack_.back()->onExit(*this);
    stack_.pop_back();
    dirty_ = true;
}

pop_back() destroys the back unique_ptr, which destroys the GameState, which calls its virtual destructor — Chapter 20 in action.

The render loop is bottom-to-top. PlayingState draws the world. InventoryState draws a dim overlay and a popup over it. TargetingState draws a cursor over both:

void Game::render()
{
    SDL_SetRenderDrawColor(renderer_,
        Palette::BG.r, Palette::BG.g, Palette::BG.b, 255);
    SDL_RenderClear(renderer_);

    for (auto& s : stack_)
        s->render(*this, renderer_);

    SDL_RenderPresent(renderer_);
}

The Main Loop — SDL_WaitEventTimeout

The most subtle change in this chapter is in Game::run. Last chapter used SDL_WaitEvent exclusively. That breaks the moment we have a hit flash that needs to fade — nothing triggers a re-render if no input arrives. The fix: use SDL_WaitEventTimeout only while an animation is in progress:

void Game::run()
{
    while (!quit_)
    {
        if (dirty_) { render(); dirty_ = false; }

        SDL_Event ev;
        const Uint64 t0        = nowMs();
        const bool   animActive = flashes_.any(t0);

        if (animActive)
        {
            if (!SDL_WaitEventTimeout(&ev, ANIMATION_TICK_MS))
            {
                // Timeout — re-render so the flash fade advances
                flashes_.prune(nowMs());
                dirty_ = true;
                continue;
            }
        }
        else
        {
            if (!SDL_WaitEvent(&ev)) break;
        }

        if (GameState* s = top())
        {
            InputResult r = s->handleEvent(*this, ev);
            if (r.tookTurn && !gameOver_)
            {
                if (dynamic_cast<PlayingState*>(top()) != nullptr)
                    enemyTurns();
            }
            if (r.handled || ev.type == SDL_EVENT_WINDOW_EXPOSED)
                dirty_ = true;
        }

        flashes_.prune(nowMs());
        if (flashes_.any(nowMs())) dirty_ = true;
    }
}

When all flashes expire, we drop back to SDL_WaitEvent and idle at zero CPU. Your laptop fan only spins during the brief animation bursts.

The dynamic_cast<PlayingState*>(top()) check ensures enemies only act when we're in pure gameplay — not while an inventory or targeting popup is open.

Player Gets an Inventory

// Player.h  (key additions)
class Player : public Entity
{
public:
    int  effectiveDamage() const { return damage_ + weaponDamageBonus(wielded_); }
    ItemKind wielded() const { return wielded_; }
    void     wield(ItemKind w) { wielded_ = w; }

    const std::vector<ItemKind>& inventory() const { return inventory_; }
    bool addItem(ItemKind k);
    void removeSlot(int index);

    struct UseResult
    {
        bool     used     = false;
        bool     needsAim = false;
        ItemKind kind     = ItemKind::None;
    };
    UseResult useSlot(int index);

private:
    ItemKind              wielded_   = ItemKind::None;
    std::vector<ItemKind> inventory_;
};

UseResult is the glue between the inventory and targeting states: used says the item was consumed; needsAim says a targeting state should be pushed first; kind identifies the item for the caller.

Look at the weapon swap in useSlot: wielding a new weapon puts the previously-wielded weapon back into inventory rather than destroying it. The small touch that makes the system feel like a real RPG:

else if (isWeapon(k))
{
    ItemKind previous = wielded_;
    wielded_ = k;
    removeSlot(index);
    if (previous != ItemKind::None)
        inventory_.push_back(previous);   // swap, not overwrite
    r.used = true;
}

New Item Kinds

enum class ItemKind
{
    None = 0,
    Potion,
    Gold,
    ScrollMagicMap,
    ScrollFireball,
    WeaponDagger,
    WeaponSword,
    WeaponHammer,
};

Each new kind costs about six small lines: one enum entry and one case in each of the lookup functions (itemDisplayName, itemGlyph, itemColor, isWeapon, weaponDamageBonus). That cheapness is why the enum-plus-lookup pattern is so popular for content-heavy games.

The Three Game States

PlayingState

Mostly unchanged from Chapter 29's flat event handler. The only new line is the i key that pushes the inventory:

case SDL_SCANCODE_I:
    game.pushState(std::make_unique<InventoryState>());
    return r;

Pressing i is "push a new mode and forget about it." From this point until InventoryState pops itself, every keypress goes to InventoryState. PlayingState doesn't know or care that there's a popup open.

InventoryState

InputResult InventoryState::handleEvent(Game& game, const SDL_Event& ev)
{
    InputResult r;
    if (ev.type != SDL_EVENT_KEY_DOWN) return r;
    r.handled = true;

    if (ev.key.scancode == SDL_SCANCODE_ESCAPE ||
        ev.key.scancode == SDL_SCANCODE_I)
    {
        game.popState();
        return r;
    }

    int slot = ev.key.scancode - SDL_SCANCODE_A;
    if (slot < 0 || slot >= (int)game.player().inventory().size()) return r;

    Player::UseResult ur = game.player().useSlot(slot);

    if (ur.needsAim)
    {
        game.popState();
        game.pushState(std::make_unique<TargetingState>(
            TargetAction::Fireball, slot, game.player().position()));
        return r;   // no turn yet — wait for targeting to confirm
    }

    game.popState();
    r.tookTurn = true;
    return r;
}

The inventory's whole job: take a letter, ask the Player to use that slot, close itself (taking a turn) or close itself and push a targeting state (no turn yet).

InventoryState::render draws a translucent overlay across the whole window, then a centred popup listing items with letter prefixes (a) Potion of Healing). Twenty lines of rendering code.

TargetingState

InputResult TargetingState::handleEvent(Game& game, const SDL_Event& ev)
{
    InputResult r;
    if (ev.type != SDL_EVENT_KEY_DOWN) return r;
    r.handled = true;

    switch (ev.key.scancode)
    {
        case SDL_SCANCODE_ESCAPE:
            game.hud().addMessage("You stop concentrating.");
            game.popState();
            return r;

        case SDL_SCANCODE_RETURN:
        case SDL_SCANCODE_KP_ENTER:
            if (action_ == TargetAction::Fireball)
            {
                game.castFireballAt(cursor_);
                game.player().removeSlot(consumeSlot_);
            }
            game.popState();
            r.tookTurn = true;
            return r;

        case SDL_SCANCODE_W: case SDL_SCANCODE_UP:    --cursor_.y; break;
        case SDL_SCANCODE_S: case SDL_SCANCODE_DOWN:  ++cursor_.y; break;
        case SDL_SCANCODE_A: case SDL_SCANCODE_LEFT:  --cursor_.x; break;
        case SDL_SCANCODE_D: case SDL_SCANCODE_RIGHT: ++cursor_.x; break;
        default: return r;
    }
    // ... clamp cursor to map bounds ...
    return r;
}

Arrow keys move the cursor; Enter casts and pops; Escape just pops without consuming the scroll. The render method draws the cursor as a yellow X plus a translucent orange overlay showing every tile in the blast radius — the live preview that makes targeting feel like casting a spell:

void TargetingState::render(Game& game, SDL_Renderer* renderer)
{
    SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
    SDL_SetRenderDrawColor(renderer, 255, 80, 0, 60);
    for (int dy = -FIREBALL_RADIUS; dy <= FIREBALL_RADIUS; ++dy)
        for (int dx = -FIREBALL_RADIUS; dx <= FIREBALL_RADIUS; ++dx)
        {
            if (std::abs(dx) + std::abs(dy) > FIREBALL_RADIUS) continue;
            Point p { cursor_.x + dx, cursor_.y + dy };
            // ... bounds check + SDL_RenderFillRect ...
        }
    game.glyphs().draw(renderer, 'X', cursor_.x, cursor_.y, Palette::TARGET_CURSOR);
}

Hit Flashes

The FlashEffects class is a vector of Flash structs, each holding a tile position, colour, and expiry timestamp. Whenever damage is dealt, the corresponding game method adds a flash:

void Game::playerAttack(Enemy& target)
{
    int dmg = player_->effectiveDamage();
    target.takeDamage(dmg);
    sounds_.hit.play();
    flashes_.add(target.position(), Palette::FLASH_RED, FLASH_MS, nowMs());
    // ... message + kill detection ...
}

FlashEffects::render computes each flash's remaining alpha (linear fade from full to zero over its lifetime) and draws a translucent rectangle. Pure SDL alpha blending — no textures, no animation system, just a few rectangles. flashes_.any(nowMs()) reports whether any flash is still alive, which the main loop uses to decide between idle SDL_WaitEvent and animation-ticking SDL_WaitEventTimeout.

Sound — Finally

Audio is one file. The Sound class is a thin RAII wrapper around SDL's audio stream:

bool Sound::load(const char* path)
{
    SDL_AudioSpec spec;
    if (!SDL_LoadWAV(path, &spec, &buf_, &len_))
        return false;
    stream_ = SDL_OpenAudioDeviceStream(
        SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &spec, nullptr, nullptr);
    if (!stream_) { SDL_free(buf_); buf_ = nullptr; return false; }
    SDL_ResumeAudioStreamDevice(stream_);
    return true;
}

void Sound::play() const
{
    if (!stream_ || !buf_) return;
    SDL_ClearAudioStream(stream_);
    SDL_PutAudioStreamData(stream_, buf_, (int)len_);
}

SDL_ClearAudioStream then SDL_PutAudioStreamData — this gives snappy "click-click-click" hits without queue lag. Missing WAV files are silently non-fatal: Sound::load failing means play() is a no-op. The game runs perfectly without audio assets — friendly behaviour for first-time builders.

Sound effects (pickup, hit, kill, quaff, cast, descend) are bundled in a Sounds struct owned by Game. Free, attributable WAV clips are available at freesound.org and opengameart.org — any short clean clip works.

Playing the Game

Rogue SDL Part 3 — inventory popup open over the dimmed dungeon
Inventory popup: the dungeon dims behind it. Press a letter to use an item; Escape or i to close.
Rogue SDL Part 3 — targeting cursor with orange fireball preview
Targeting cursor: yellow X marks the aim point; the orange diamond shows the fireball's blast area. A rat is about to have a bad day.

Build and run. The window opens to a freshly generated dungeon. New touches to try:

Understanding What the State Stack Bought

Worth pausing on what the architecture delivered:

Common Errors and Fixes

Inventory key doesn't open inventory

Make sure PlayingState::handleEvent has the SDL_SCANCODE_I case and that InventoryState has a default constructor (it requires no arguments).

Letter keys do nothing in inventory

Most likely the slot = ev.key.scancode - SDL_SCANCODE_A calculation is wrong, or the inventory is empty so all slots are out of range. Trace with a breakpoint.

Fireball cast but scroll not consumed

The TargetingState's consumeSlot_ index is wrong, or the slot shifted between inventory close and targeting confirm. Nothing should mutate the inventory while TargetingState is on top — verify that's the case.

Hit flash freezes the game

Check that ANIMATION_TICK_MS is ~16, not zero. Also verify that flashes_.any(nowMs()) returns false once all flashes expire — if it always returns true, the game stays at 60 fps permanently.

Compile error — use of undeclared identifier PlayingState

The dynamic_cast<PlayingState*> line in Game.cpp needs #include "PlayingState.h".

Sound doesn't play

Most likely cause: the WAV file isn't next to the .exe. Check: the WAV format is standard 44.1 kHz 16-bit PCM. Sound::load failure is logged to SDL_Log — look in the Output window.

Going Further — Experiment Ideas

The book is ending. The game is yours. Some directions:

AI Exercise (Optional)

A challenge for the AI coding partner that's hopefully now a regular in your workflow:

"I have a C++ SDL 3 roguelike with a state stack architecture: Playing, Inventory, Targeting derived from GameState. Game owns a std::vector<std::unique_ptr<GameState>> and dispatches input to the top of the stack. The Player has an inventory, a wielded weapon, HP, gold. There are weapons, scrolls, and potions. Add a 'Help' screen: pressing ? from PlayingState pushes a new HelpState that shows the full key bindings on a centred popup, dimmed-background style like the inventory. Pressing any key closes it. Use my state-stack pattern. Paste any new or modified files."

A clean answer is a single new HelpState.h/.cpp pair (about 30 lines combined), a single new case in PlayingState::handleEvent, and nothing else changes. That's the architectural purity test — if the AI tries to modify Game or the existing states, the state-stack pattern wasn't followed. Push back: "The point of the state stack is that new modes shouldn't require changes outside their own files. Try again."

Summary — Truly the End

You've finished the book. Rogue SDL has grown from an empty dungeon (Part 1) to a functional roguelike (Part 2) to a polished one (Part 3). The state stack added in this chapter is the architectural pattern that lets you keep growing it — every new mode slots in as its own state without disturbing what's already there. The hit-flash and sound additions are the smallest possible bridge from "turn-based correctness" to "game feel." And the scrolls and weapons are the start of the gameplay-depth rabbit hole that defines the entire roguelike genre.

What's next isn't another chapter. It's your own game. You have C++. You have SDL. You have AI. You have an architecture that works. Go build something.

Thanks for reading.