Chapter 28  ·  Project

Rogue SDL — Part 1

This is the beginning of the final project — a three-part series building a complete turn-based ASCII roguelike from scratch using SDL 3 and SDL3_ttf. Part 1 covers the foundation: procedural dungeon generation with BSP trees, rendering every character of the map with a glyph cache, computing what the player can see with a Bresenham field-of-view algorithm, and saving and loading the game state to a plain text file.

Project folder: SDL3 Projects/Rogue SDL Part 1 — the complete source for this chapter.

The Architecture — 11 Files

Rogue SDL is the largest project in the book. It's split across eleven source files to keep each one focused and manageable. Here's the full list:

FileResponsibility
Common.hShared types: Point, Terrain enum, Palette colour constants, constants
Tile.hTile struct: terrain, explored flag, glyph and colour getters
Map.h/.cpp2D tile grid, accessor methods, bounds checking
MapGen.h/.cppBSP dungeon generator, room/corridor placement
GlyphCache.h/.cppSDL3_ttf glyph renderer — caches one SDL_Texture per character
Entity.h/.cppBase class: position, HP, glyph, colour
Player.h/.cppDerives from Entity; movement, score, gold
FOV.h/.cppBresenham line-based field-of-view computation
SaveLoad.h/.cppPlain-text save and load — player position, map explored flags
Game.h/.cppMain game loop, event handling, render, state
main.cppSDL init, window/renderer creation, Game instantiation

Common Types

Everything starts with shared types in Common.h. The Point struct and Terrain enum are used across every file:

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

struct Point
{
    int x = 0, y = 0;
    bool operator==(const Point& o) const { return x == o.x && y == o.y; }
};

enum class Terrain { Wall, Floor, StairsDown };

namespace Palette
{
    inline constexpr SDL_Color BG        = {  12,  12,  18, 255 };
    inline constexpr SDL_Color WALL      = {  60,  60,  80, 255 };
    inline constexpr SDL_Color FLOOR     = {  80,  80, 100, 255 };
    inline constexpr SDL_Color EXPLORED  = {  40,  40,  55, 255 };
    inline constexpr SDL_Color PLAYER    = { 240, 240, 200, 255 };
    inline constexpr SDL_Color STAIRS    = { 180, 160, 100, 255 };
}

static constexpr int MAP_W     = 80;
static constexpr int MAP_H     = 40;
static constexpr int CELL_W    = 14;
static constexpr int CELL_H    = 14;
static constexpr int WIN_W     = MAP_W * CELL_W;
static constexpr int WIN_H     = MAP_H * CELL_H + 60; // + HUD

The Palette namespace keeps all the colours in one place — easy to tweak the look of the entire game by changing one file. The inline constexpr makes each constant available as a value (not just a type) wherever the header is included, without violating the one-definition rule.

The Tile

// Tile.h
#pragma once
#include "Common.h"

struct Tile
{
    Terrain  terrain  = Terrain::Wall;
    bool     explored = false;
    bool     visible  = false;

    char       glyph() const;
    SDL_Color  color() const;
};

The explored flag is set permanently the first time the player can see a tile — it determines whether we draw the tile in dim "memory" colours after the player moves away. The visible flag is recomputed each turn by the FOV algorithm — it determines whether the tile is drawn at full brightness right now.

BSP Dungeon Generation

Binary Space Partitioning (BSP) is a recursive subdivision algorithm. The idea is simple:

  1. Start with the whole map as a rectangular region.
  2. Randomly split it either horizontally or vertically.
  3. Recursively split each half.
  4. When a region is small enough, place a room inside it.
  5. Connect sibling rooms with corridors.

The result is a dungeon with well-separated rooms guaranteed to be connected — no room is ever an island. Here's the recursive split:

// MapGen.cpp
struct BSPNode
{
    SDL_Rect  region;
    SDL_Rect  room   = {0,0,0,0};
    int       left   = -1;   // index into nodes vector
    int       right  = -1;
};

void MapGen::split(int idx, std::vector<BSPNode>& nodes, int depth)
{
    BSPNode& node = nodes[idx];
    if (depth == 0 || node.region.w < 12 || node.region.h < 12)
    {
        placeRoom(node, nodes);
        return;
    }

    bool horizontal = (node.region.w < node.region.h) ||
                      (node.region.w == node.region.h && rand() % 2 == 0);

    int splitPos;
    if (horizontal)
    {
        splitPos = node.region.y + node.region.h / 3
                   + rand() % (node.region.h / 3);
    }
    else
    {
        splitPos = node.region.x + node.region.w / 3
                   + rand() % (node.region.w / 3);
    }

    BSPNode leftNode, rightNode;
    if (horizontal)
    {
        leftNode.region  = { node.region.x, node.region.y,
                             node.region.w, splitPos - node.region.y };
        rightNode.region = { node.region.x, splitPos,
                             node.region.w, node.region.y + node.region.h - splitPos };
    }
    else
    {
        leftNode.region  = { node.region.x, node.region.y,
                             splitPos - node.region.x, node.region.h };
        rightNode.region = { splitPos, node.region.y,
                             node.region.x + node.region.w - splitPos, node.region.h };
    }

    node.left  = (int)nodes.size();
    nodes.push_back(leftNode);
    node.right = (int)nodes.size();
    nodes.push_back(rightNode);

    split(node.left,  nodes, depth - 1);
    split(node.right, nodes, depth - 1);
    connectChildren(idx, nodes);
}

After splitting, the corridor connection walks back up the tree: each internal node connects the centre of its left leaf's room to the centre of its right leaf's room with an L-shaped corridor (horizontal then vertical). The resulting dungeon is always fully connected.

The Glyph Cache

Rendering every cell as a text character using SDL3_ttf is expensive if you re-render every glyph every frame. The GlyphCache pre-renders each character once into a small SDL_Texture and reuses it:

// GlyphCache.h
#pragma once
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <unordered_map>

class GlyphCache
{
public:
    GlyphCache(SDL_Renderer* renderer, const char* fontPath, int ptSize);
    ~GlyphCache();

    void draw(SDL_Renderer* renderer, char glyph,
              int col, int row, SDL_Color color) const;

private:
    TTF_Font*    font_     = nullptr;
    SDL_Texture* atlas_    = nullptr;   // not used in this simple version
    SDL_Renderer* renderer_ = nullptr;

    struct GlyphEntry
    {
        SDL_Texture* tex  = nullptr;
        int          w    = 0;
        int          h    = 0;
    };

    mutable std::unordered_map<char, GlyphEntry> cache_;
    const GlyphEntry& getGlyph(char c) const;
};

The first time draw is called with a character, it creates the texture via TTF_RenderGlyph_Blended and stores it in the map. Every subsequent call for the same character retrieves the cached texture. Since the ASCII set has fewer than 128 characters and the dungeon uses maybe 20 of them, the cache warms up instantly.

The draw method uses SDL colour modulation to tint the white glyph texture to whatever colour is requested — so we can have a bright-white player @, dim-grey explored walls, and yellow stairs without caching separate textures per colour.

void GlyphCache::draw(SDL_Renderer* renderer, char glyph,
                      int col, int row, SDL_Color color) const
{
    const GlyphEntry& g = getGlyph(glyph);
    SDL_SetTextureColorMod(g.tex, color.r, color.g, color.b);
    SDL_SetTextureAlphaMod(g.tex, color.a);

    float x = col * CELL_W + (CELL_W - g.w) * 0.5f;
    float y = row * CELL_H + (CELL_H - g.h) * 0.5f;
    SDL_FRect dst{ x, y, (float)g.w, (float)g.h };
    SDL_RenderTexture(renderer, g.tex, nullptr, &dst);
}

Field of View — Bresenham Raycasting

The player can only see tiles that are in their line of sight — walls block vision. The field-of-view algorithm casts rays in all directions from the player's position and marks tiles visible until a wall is hit.

We use Bresenham's line algorithm to trace each ray — it produces integer grid coordinates along a line from the player to each tile on the map's perimeter:

// FOV.cpp
void FOV::compute(Map& map, Point origin, int radius)
{
    // Clear previous visibility
    for (int y = 0; y < MAP_H; ++y)
        for (int x = 0; x < MAP_W; ++x)
            map.at(x, y).visible = false;

    // Mark origin visible
    map.at(origin.x, origin.y).visible  = true;
    map.at(origin.x, origin.y).explored = true;

    // Cast rays to every cell on the radius perimeter
    for (int angle = 0; angle < 360; ++angle)
    {
        float rad  = angle * 3.14159265f / 180.0f;
        float endX = origin.x + radius * std::cos(rad);
        float endY = origin.y + radius * std::sin(rad);
        castRay(map, origin, { (int)endX, (int)endY });
    }
}

void FOV::castRay(Map& map, Point from, Point to)
{
    int x0 = from.x, y0 = from.y;
    int x1 = to.x,   y1 = to.y;

    int dx = std::abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
    int dy = std::abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
    int err = dx - dy;

    while (true)
    {
        if (x0 < 0 || x0 >= MAP_W || y0 < 0 || y0 >= MAP_H) return;

        Tile& t = map.at(x0, y0);
        t.visible  = true;
        t.explored = true;

        if (t.terrain == Terrain::Wall) return;  // ray blocked
        if (x0 == x1 && y0 == y1) return;        // reached target

        int e2 = 2 * err;
        if (e2 > -dy) { err -= dy; x0 += sx; }
        if (e2 <  dx) { err += dx; y0 += sy; }
    }
}

Casting 360 integer rays per turn is fast enough for a turn-based game with a 80×40 grid. A more precise algorithm (shadow casting) would give fewer "blind spots" in corners, but Bresenham raycasting is simple, correct enough for gameplay, and easy to understand.

The Turn-Based Game Loop

A roguelike is turn-based — nothing happens until the player acts. Instead of SDL_PollEvent (which returns immediately if there's no input, burning CPU), we use SDL_WaitEvent, which blocks until an event arrives:

void Game::run()
{
    render();   // draw initial state

    SDL_Event ev;
    while (!quit_)
    {
        if (!SDL_WaitEvent(&ev)) break;

        if (handleEvent(ev))
        {
            recomputeFov();
            render();
        }
    }
}

bool Game::handleEvent(const SDL_Event& ev)
{
    if (ev.type == SDL_EVENT_QUIT) { quit_ = true; return false; }
    if (ev.type == SDL_EVENT_WINDOW_EXPOSED) return true;  // redraw needed
    if (ev.type != SDL_EVENT_KEY_DOWN) return false;

    int dx = 0, dy = 0;
    switch (ev.key.scancode)
    {
        case SDL_SCANCODE_W: case SDL_SCANCODE_UP:    dy = -1; break;
        case SDL_SCANCODE_S: case SDL_SCANCODE_DOWN:  dy = +1; break;
        case SDL_SCANCODE_A: case SDL_SCANCODE_LEFT:  dx = -1; break;
        case SDL_SCANCODE_D: case SDL_SCANCODE_RIGHT: dx = +1; break;
        case SDL_SCANCODE_F5: save(); return false;
        case SDL_SCANCODE_F9: load(); return true;
        default: return false;
    }

    Point next = { player_->position().x + dx,
                   player_->position().y + dy };

    if (map_.at(next.x, next.y).terrain != Terrain::Wall)
        player_->moveTo(next);

    return true;  // a turn was taken — recompute FOV and render
}

handleEvent returns true only when a turn was actually taken. This is important — we only recompute FOV and re-render when something changed, not on every keypress (e.g., an unrecognised key returns false).

The dirty_ flag is an optional optimisation: set it when something changes, clear it after rendering. If nothing has changed, skip the render call entirely. For a complex map render (80×40 = 3,200 glyph draws per frame), this matters.

Save and Load

The save format is plain text — easy to inspect and debug:

// SaveLoad.cpp  — save
void SaveLoad::save(const Map& map, const Player& player, int depth,
                    const char* path)
{
    std::ofstream f(path);
    f << "v1\n";
    f << depth << "\n";
    f << player.position().x << " " << player.position().y << "\n";
    f << player.hp() << " " << player.maxHp() << "\n";

    // Explored flags as a compact bitmask, one row per line
    for (int y = 0; y < MAP_H; ++y)
    {
        for (int x = 0; x < MAP_W; ++x)
            f << (map.at(x, y).explored ? '1' : '0');
        f << "\n";
    }
}

We save the map seed and re-generate the map geometry on load, then re-apply the explored flags. This keeps the save file small (80 chars per row × 40 rows = 3,200 chars for the explored data) while preserving the player's knowledge of the dungeon. The generated map is deterministic for a given seed, so this is safe.

Playing the Game

Rogue SDL Part 1 — initial dungeon view with player at start
The freshly generated dungeon. The player sees the lit room around them; the rest of the level is in darkness.
Rogue SDL Part 1 — dungeon partially explored, visited rooms shown in dim colours
After exploring several rooms. Visited tiles show in dim "memory" colours; the current FOV radius lights the area around the player in full colour.

Build and run. You should see a dark room with your @ character. WASD or arrow keys move you through the dungeon. Rooms you've visited appear in dim grey even after you leave them. F5 saves; F9 loads.

Common Errors and Fixes

Black Screen, No Glyphs

SDL3_ttf must be initialised before creating the GlyphCache. Call TTF_Init() after SDL_Init and before anything touches fonts. Also ensure the font file path is correct — use an absolute path during development or copy the font file next to the executable.

FOV Shows Entire Map

The visible flag is being set but never cleared. Make sure the FOV compute function resets all tiles to visible = false at the start of each call, then only sets visible = true for tiles the rays reach.

Corridors Don't Connect Rooms

The BSP split is placing rooms correctly but the corridor connection is failing. Check that connectChildren reads from the updated nodes vector (after recursion completes) — if you store node references before recursion and access them after, the vector may have reallocated.

Crash on Load

Most save/load crashes are from mismatched format — the load code expected one value but found another. Add a version string ("v1") at the top of the save file and check it on load, returning an error if it doesn't match rather than reading garbage.

Summary