The game has been narrating itself into the console this whole time, and squeezing its stats into the window title bar. Part 16 fixes both: a proper HUD and a scrolling message log drawn right in the window, plus sound — short blips for hits, pickups, descents and death, synthesised on the fly with SDL3's audio so there are no asset files to ship.
Room for a HUD
The window has been exactly the size of the map. We give it a few extra rows underneath for the interface:
const int HUD_ROWS = 5;
SDL_CreateWindowAndRenderer(
"Roguelike - Part 16: UI & Sound",
MAP_WIDTH * TILE_SIZE, (MAP_HEIGHT + HUD_ROWS) * TILE_SIZE, 0,
&window, &sdl);
Because the GlyphCache places a glyph at row * TILE_SIZE, drawing into rows MAP_HEIGHT and beyond simply lands in the new space below the dungeon — no special coordinate handling required.
The Message Log
This is the small MessageLog extraction we earmarked at the Interlude — a list of lines the game appends to and the HUD shows the tail of:
class MessageLog
{
public:
void add(const std::string& line)
{
m_lines.push_back(line);
if (m_lines.size() > 200) m_lines.erase(m_lines.begin());
}
const std::vector<std::string>& lines() const { return m_lines; }
private:
std::vector<std::string> m_lines;
};
Every SDL_Log("...") that used to narrate combat is replaced by a log(...) helper that formats its arguments and pushes the result into m_log — so the same one-liners now appear in the game:
void Game::log(const char* fmt, ...)
{
char buf[256];
va_list ap; va_start(ap, fmt);
SDL_vsnprintf(buf, sizeof(buf), fmt, ap);
va_end(ap);
m_log.add(buf);
}
The HUD then draws a status line and the last few messages, fading the older ones:
void Game::drawHud()
{
int base = MAP_HEIGHT; // first row below the map
// ...build "Fighter HP 30/36 POW 10 DEF 3 Depth 2 POISON(4)" and draw it...
const std::vector<std::string>& lines = m_log.lines();
int count = std::min((int)lines.size(), HUD_ROWS - 1);
for (int i = 0; i < count; ++i)
drawLeft(1, base + 1 + i, lines[lines.size() - count + i].c_str(),
(i == count - 1) ? MSG_NEW : MSG_OLD);
}
Save and load confirmations now flow through the same log, so the title bar is free to just say the game's name.
Sound From Nothing
Real games ship audio files, but we can make satisfying feedback with no assets at all by synthesising tones and feeding them to an SDL3 audio stream. Open the device once:
SDL_AudioSpec spec; SDL_zero(spec);
spec.format = SDL_AUDIO_F32; spec.channels = 1; spec.freq = m_rate;
m_stream = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &spec, nullptr, nullptr);
if (m_stream) SDL_ResumeAudioStreamDevice(m_stream); // else run silent
Then a beep fills a buffer with a sine wave at a given pitch and pushes it to the stream. The little fade in and out is the important detail — without it you'd hear a click at each end of the tone:
void Sound::beep(float freqHz, int ms, float volume)
{
if (!m_stream) return;
int samples = m_rate * ms / 1000;
std::vector<float> buf(samples);
int fade = m_rate / 200;
for (int i = 0; i < samples; ++i)
{
float t = (float)i / (float)m_rate;
float env = 1.0f;
if (i < fade) env = (float)i / fade;
else if (i > samples - fade) env = (float)(samples - i) / fade;
buf[i] = SDL_sinf(2.0f * 3.14159265f * freqHz * t) * volume * env;
}
SDL_PutAudioStreamData(m_stream, buf.data(), (int)(buf.size() * sizeof(float)));
}
Each event is just a pitch: a low thud for a hit(), a high blip for a pickup(), a falling two-note descend(), a sparkly zap() for magic, a long low die(). The game calls them from the places those things happen — attack, pickUpHere, descend, readScroll, and on death. And if there's no audio device, init logs a note and the game plays on in silence — sound is a bonus, never a requirement.
Try It
Build and run — note SDL now initialises with SDL_INIT_VIDEO | SDL_INIT_AUDIO. You'll see the dungeon sitting above a status line and a running narration of everything that happens, and you'll hear your blows land, loot get scooped up, and the stairs drop you deeper. The console is finally quiet; the game speaks for itself.
Notes on the Code
New files: MessageLog.h, Sound.h / Sound.cpp. Game.h / Game.cpp route messages through log, draw the HUD, and play sounds; main.cpp grows the window and adds SDL_INIT_AUDIO. Full Game.cpp in the repo. No new external libraries — the audio is core SDL3.
Common Errors
No sound at all. Did you add SDL_INIT_AUDIO to SDL_Init? Without it SDL_OpenAudioDeviceStream fails and the game (correctly) runs silent.
A click or pop on every blip. The fade in/out is missing or too short. Ramp the amplitude up over the first few milliseconds and back down at the end.
The HUD is cut off or overlaps the map. The window wasn't enlarged. Its height must be (MAP_HEIGHT + HUD_ROWS) * TILE_SIZE, and the HUD draws starting at row MAP_HEIGHT.
The log is empty. Messages are still going to SDL_Log instead of log. Route gameplay narration through the log helper so it reaches m_log.
What's Next
One thing remains: a reason to descend. Part 17 — the Win Condition places the legendary Amulets of SDL deep in the dungeon; recover all three and escape with your life, and the dungeon yields to a victory screen. It's the finale that turns this engine into a game you can beat.