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
Build and run. The window opens to a freshly generated dungeon. New touches to try:
- Walk over a potion — the inventory adds a slot. Press i — the popup appears:
a) Potion of Healing. Press a — you drink it. HUD HP jumps up. You hear a gulp. - Find a sword —
c) Sword (+5). Press c to wield it. The HUD showsWielded: Sword (+5)and your effective damage jumps from 4 to 9. - Find a Scroll of Magic Mapping — read it from the inventory. The whole level is suddenly visible.
- Find a Scroll of Fireball — read it. The world dims slightly, a yellow
Xappears at your feet. Move it with arrow keys. Aim at a cluster of goblins. Press Enter. They flash red. Most die. - Get hit by a goblin — your
@flashes red briefly before fading back to white. You hear the impact sound.
Understanding What the State Stack Bought
Worth pausing on what the architecture delivered:
- The
Gameclass no longer has a long, branchyhandleEvent. It has a tinyrun()that dispatches to whatever state is on top. New input modes are additions, not modifications. - Each state is small with a clear name and single job. PlayingState handles gameplay. InventoryState handles the inventory popup. TargetingState handles aiming. No state knows another state's internals — they all talk only to
Game. - Rendering layers automatically. Bottom-to-top render order means modal states draw on top without per-state coordination.
- The
dynamic_castcheck stops enemies from acting when a popup is open. One line, one bug avoided forever.
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:
- More scrolls. Scroll of Teleport (random new position), Scroll of Detect Monsters, Scroll of Identify.
- Armor. A third equipment slot. Wear armor to reduce incoming damage. Same
UseResultflow as wielding a weapon. - Damage numbers. Floating text rising from a damaged tile and fading.
FlashEffectsextends naturally —DamageNumbersis the same time-based manager pattern. - Screen shake. When the player takes damage, offset the entire render by ±2 pixels for ~120 ms. Cheap and dramatic.
- A new GameState. A help screen pressed with
?. About 30 lines of new code — one new state and one new case in PlayingState. Everything else stays the same. That's the architectural purity test. - A boss. Every 5 levels, one big enemy with unique behaviour — a Wizard, a Necromancer, a Dragon. All the machinery is already there.
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,Targetingderived fromGameState.Gameowns astd::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 newHelpStatethat 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.
- The state-stack pattern separates input handling and rendering into small, focused state objects. Each new mode is an addition, not a modification to existing code.
SDL_WaitEventTimeoutonly while animations are active keeps the roguelike's zero-CPU idle property while supporting frame-level animation.- Hit flashes are the cheapest possible animation — a vector of expiry-timestamped translucent rectangles, rendered with alpha blending.
- SDL audio is one file:
SDL_LoadWAV,SDL_OpenAudioDeviceStream,SDL_PutAudioStreamData. Missing assets are silently non-fatal. - The weapon swap pattern (wield new → old goes back to inventory) is the detail that makes an equipment system feel like a real RPG.
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.