Tutorial 1 of 15  ·  Roguelikes in C++ with SDL3

Map Generation

Source code on GitHub. The finished project for this part — and all fifteen — is at EliteIntegrity/Roguelike-tutorial-series, one folder per tutorial. Clone it to build along or check your work. View repo →

Roguelikes are a genre defined by a handful of ideas — procedurally generated levels so every run is different, permadeath so failure has real consequences, and turn-based gameplay where thinking beats reflexes. The name comes from Rogue (1980), a dungeon crawler played entirely in ASCII characters where the @ symbol was you, letters were monsters, and when you died, you started over. This series builds something in that spirit from scratch — real C++, real dungeon generation, real game systems, no engine.

By the time you finish Act 1 you will have a turn-based game with a moving player, monsters, melee combat, and a dungeon that changes every run. By the end of Act 3 you will have multiple dungeon styles, magic, character classes, a save system, and sound. Fifteen tutorials, fifteen playable milestones.

Part 1 is the foundation, and it does two things at once. First, a Binary Space Partitioning dungeon generator that carves rooms and corridors out of solid rock. Second — and this is the part most SDL roguelike tutorials get wrong — a renderer that draws the dungeon as crisp ASCII characters from the very first frame: # for walls, . for floors. There is no player yet (he arrives in Part 2), just Space to regenerate and Escape to quit. But the glyph renderer you write here is the same one used in every remaining tutorial. We never throw it away.

New to C++ or need a refresher? This series assumes you are comfortable with classes, templates, smart pointers, and the STL. If any of that sounds unfamiliar, Learning C++ by Building Games will get you there — it's free to read online and covers everything you need, and its final three chapters build a simpler roguelike that this series deliberately goes beyond.
SDL3 setup: If you haven't installed SDL3 and configured Visual Studio yet, Chapter 1 of Learning C++ by Building Games walks through it step by step. This series also uses SDL3_ttf for text — we install that below.
Part 1 dungeon viewer — a BSP-generated dungeon drawn in crisp ASCII, with bright # walls and dim . floors in a 1280x720 SDL3 window
The finished Part 1 program: a BSP dungeon rendered as ASCII glyphs. Press Space for a new layout; Escape to quit.

Why ASCII from the start

It is tempting to draw the first dungeon as coloured rectangles — a filled square for each tile — and add text rendering later. Don't. A roguelike is its glyphs: the moment you add a player, monsters, items, stairs and traps, every one of them is a character, and they all need to line up on the same grid as the walls and floors. If you start with rectangles you build a renderer in Part 1 and then delete it in Part 2. We build the real one now.

Project Structure

Five files, each with one job:

FileResponsibility
Map.hData types — tile grid, room list, map dimensions
Map.cppBSP dungeon generation algorithm
GlyphCache.hThe ASCII renderer's interface
GlyphCache.cppRenders each character once, draws it tinted and centred
main.cppSDL3 window, event loop, drawing the map

Nothing in Map.h or Map.cpp knows SDL exists — the map is pure data. Nothing in GlyphCache knows what a tile is — it just draws characters. That separation costs nothing now and keeps every future tutorial clean: when Part 3 adds fog of war it changes how tiles are coloured, not how they are drawn.

The Data Layer — Map.h

Dimensions and tile size

const int MAP_WIDTH  = 64;
const int MAP_HEIGHT = 36;
const int TILE_SIZE  = 20;

64×36 cells of 20 pixels each is exactly 1280×720 — a clean 16:9 window. The 20-pixel cell is deliberately generous: it gives each glyph room to be drawn at a readable size without crowding its neighbours, which is what makes the dungeon legible at a glance.

TileType and Tile

enum class TileType { Wall, Floor };

struct Tile
{
    TileType type = TileType::Wall;
};

Every tile starts as wall; generation carves rooms and corridors into the rock, so any uncarved tile is guaranteed impassable. Tile is a struct rather than a bare enum because it will grow — Part 3 adds visible and explored flags for field of view. Making it a struct now means no refactor later.

Room

struct Room
{
    int x = 0, y = 0, w = 0, h = 0;
    int centreX() const { return x + w / 2; }
    int centreY() const { return y + h / 2; }
};

Room stores a top-left corner and a size. The centreX / centreY helpers are used by the corridor code, and the rooms vector will be queried in Part 2 to find the player's start and in Part 4 to place monsters.

Map class

class Map
{
public:
    Tile              tiles[MAP_HEIGHT][MAP_WIDTH];
    std::vector<Room> rooms;

    void generate(unsigned int seed = 0);

private:
    std::mt19937 m_rng;

    struct BSPNode
    {
        int x = 0, y = 0, w = 0, h = 0;   // bounds of this partition
        std::unique_ptr<BSPNode> left;     // null at leaf nodes
        std::unique_ptr<BSPNode> right;
        Room room{};
        bool hasRoom = false;
    };

    void split       (BSPNode& node, int depth);
    void buildRooms  (BSPNode& node);
    void connectRooms(BSPNode& node);

    void carveHLine(int x1, int x2, int y);
    void carveVLine(int y1, int y2, int x);
    void carveTile (int x,  int y,  TileType type);

    bool coinFlip() { return (m_rng() & 1) == 0; }
};

tiles is a plain 2D array indexed tiles[y][x] — row first, because C arrays are row-major and that matches a screen where Y increases downward. generate takes an optional seed: pass a fixed value to reproduce a layout, pass nothing and std::random_device picks one. BSPNode is private and nested — it is an implementation detail of the generator, and its unique_ptr children make the whole tree self-destroying when generate returns. coinFlip is a tiny readability helper used throughout the generator.

Binary Space Partitioning — Map.cpp

The simplest dungeon generator places random rooms and rejects overlaps. It works but produces cluttered layouts and can strand rooms with no way in. BSP avoids both: it divides the space before placing rooms, so no two rooms compete for the same area, and it connects partitions as it unwinds, so every room is reachable. The algorithm runs in three phases:

void Map::generate(unsigned int seed)
{
    // Reset the whole grid to solid rock. Assigning a default-constructed Tile
    // clears every field — including the visible/explored flags added in
    // Part 3 — so regenerating mid-game never leaves stale state behind.
    for (auto& row : tiles)
        for (auto& tile : row)
            tile = Tile{};
    rooms.clear();

    if (seed == 0)
    {
        std::random_device rd;
        seed = rd();
    }
    m_rng.seed(seed);

    BSPNode root;
    root.x = 0;          root.y = 0;
    root.w = MAP_WIDTH;  root.h = MAP_HEIGHT;

    split(root, 0);        // phase 1: recursively partition the space
    buildRooms(root);      // phase 2: carve one room into each leaf
    connectRooms(root);    // phase 3: join sibling rooms with corridors
}

Resetting to all-wall first means generate is safe to call at any time, including from the running loop when the player presses Space.

Phase 1 — Splitting

static constexpr int MAX_DEPTH    = 4;   // up to 2^4 = 16 leaf partitions
static constexpr int MIN_LEAF_W   = 12;  // a partition narrower than this won't split vertically
static constexpr int MIN_LEAF_H   = 9;   // a partition shorter than this won't split horizontally
static constexpr int ROOM_PADDING = 1;   // empty border kept inside each partition
static constexpr int ROOM_MIN     = 4;   // smallest room edge length

void Map::split(BSPNode& node, int depth)
{
    if (depth >= MAX_DEPTH) return;

    bool canSplitH = (node.h >= MIN_LEAF_H * 2);
    bool canSplitV = (node.w >= MIN_LEAF_W * 2);
    if (!canSplitH && !canSplitV) return;

    bool horizontal;
    if      (!canSplitV) horizontal = true;
    else if (!canSplitH) horizontal = false;
    else    horizontal = (node.h > node.w) || (node.h == node.w && coinFlip());

    node.left  = std::make_unique<BSPNode>();
    node.right = std::make_unique<BSPNode>();

    if (horizontal)
    {
        int splitY = std::uniform_int_distribution<int>(
            node.y + MIN_LEAF_H, node.y + node.h - MIN_LEAF_H)(m_rng);

        node.left->x  = node.x;  node.left->y  = node.y;
        node.left->w  = node.w;  node.left->h  = splitY - node.y;

        node.right->x = node.x;  node.right->y = splitY;
        node.right->w = node.w;  node.right->h = (node.y + node.h) - splitY;
    }
    else
    {
        int splitX = std::uniform_int_distribution<int>(
            node.x + MIN_LEAF_W, node.x + node.w - MIN_LEAF_W)(m_rng);

        node.left->x  = node.x;          node.left->y  = node.y;
        node.left->w  = splitX - node.x; node.left->h  = node.h;

        node.right->x = splitX;          node.right->y = node.y;
        node.right->w = (node.x + node.w) - splitX;  node.right->h = node.h;
    }

    split(*node.left,  depth + 1);
    split(*node.right, depth + 1);
}

MAX_DEPTH of 4 produces up to 16 leaf partitions across the 64×36 grid — a comfortable dozen-or-so rooms. The split prefers to cut across the longer axis (a tall partition gets a horizontal cut, a wide one a vertical cut), with a coin flip to break ties, which keeps partitions roughly square. The split point is random but never closer than MIN_LEAF_W/MIN_LEAF_H to an edge, guaranteeing both children are big enough to hold a room.

Phase 2 — Carving rooms

void Map::buildRooms(BSPNode& node)
{
    if (node.left)                  // internal node — recurse into children
    {
        buildRooms(*node.left);
        buildRooms(*node.right);
        return;
    }

    // Leaf: the room must fit inside the partition with ROOM_PADDING on
    // every side. The padding guarantees a wall gap between adjacent rooms.
    int maxW = node.w - ROOM_PADDING * 2;
    int maxH = node.h - ROOM_PADDING * 2;
    if (maxW < ROOM_MIN || maxH < ROOM_MIN) return;   // partition too small

    int rw = std::uniform_int_distribution<int>(ROOM_MIN, maxW)(m_rng);
    int rh = std::uniform_int_distribution<int>(ROOM_MIN, maxH)(m_rng);
    int rx = node.x + ROOM_PADDING + std::uniform_int_distribution<int>(0, maxW - rw)(m_rng);
    int ry = node.y + ROOM_PADDING + std::uniform_int_distribution<int>(0, maxH - rh)(m_rng);

    node.room    = { rx, ry, rw, rh };
    node.hasRoom = true;

    for (int y = ry; y < ry + rh; ++y)
        for (int x = rx; x < rx + rw; ++x)
            carveTile(x, y, TileType::Floor);

    rooms.push_back(node.room);
}

Only leaf nodes get rooms; internal nodes just recurse. Inside a leaf the room is given a random width and height between ROOM_MIN and the partition's padded maximum, then placed at a random offset within the leftover space. Picking the size and the position independently is what gives the dungeon its variety — rooms differ in shape and don't all hug the same corner. The single padding tile on each side, combined with its neighbour's padding, leaves a clean wall between adjacent rooms.

Phase 3 — Connecting rooms

void Map::connectRooms(BSPNode& node)
{
    if (!node.left) return;          // leaf — nothing to connect

    connectRooms(*node.left);
    connectRooms(*node.right);

    // Walk randomly down each subtree to reach a leaf that actually has a
    // room. Picking randomly varies which rooms are directly linked, so the
    // corridors don't always follow the same spine through the dungeon.
    BSPNode* l = node.left.get();
    while (l->left) l = coinFlip() ? l->left.get() : l->right.get();

    BSPNode* r = node.right.get();
    while (r->left) r = coinFlip() ? r->left.get() : r->right.get();

    if (!l->hasRoom || !r->hasRoom) return;

    int x1 = l->room.centreX(), y1 = l->room.centreY();
    int x2 = r->room.centreX(), y2 = r->room.centreY();

    if (coinFlip())
    {
        carveHLine(x1, x2, y1);
        carveVLine(y1, y2, x2);
    }
    else
    {
        carveVLine(y1, y2, x1);
        carveHLine(x1, x2, y2);
    }
}

The function works bottom-up: connect each child's subtree first, then join one room from the left subtree to one from the right with an L-shaped corridor. Because both carveHLine and carveVLine normalise their endpoints with std::swap, the order the centres are passed in doesn't matter.

Carving helpers

void Map::carveTile(int x, int y, TileType type)
{
    if (x >= 0 && x < MAP_WIDTH && y >= 0 && y < MAP_HEIGHT)
        tiles[y][x].type = type;
}

void Map::carveHLine(int x1, int x2, int y)
{
    if (x1 > x2) std::swap(x1, x2);
    for (int x = x1; x <= x2; ++x)
        carveTile(x, y, TileType::Floor);
}

void Map::carveVLine(int y1, int y2, int x)
{
    if (y1 > y2) std::swap(y1, y2);
    for (int y = y1; y <= y2; ++y)
        carveTile(x, y, TileType::Floor);
}

Every write goes through carveTile, which bounds-checks first. The guard rarely fires in practice — corridors run between valid room centres — but it makes the generator safe to run with any set of constants without ever writing outside the array.

The ASCII Renderer — GlyphCache

This is the most important class in the series, and the one to get right. SDL3 itself draws no text — that lives in the companion library SDL3_ttf, which loads TrueType fonts and renders characters. Calling it every frame for every one of 2,304 tiles would be wasteful, so we render each character once at startup and reuse it.

The mistake to avoid: stretching

Here is the trap. A glyph from a 20-point font has a natural size — maybe 12 pixels wide by 16 tall. The obvious thing is to stretch it to fill the whole 20×20 cell. Don't. Stretching a 12×16 glyph into a 20×20 box squashes it in one direction and pulls it in the other; every character comes out blurred and slightly wrong-shaped. The fix is simple and is the heart of this renderer: draw each glyph at its natural size, centred in the cell. Sharp every time.

Installing SDL3_ttf

Download SDL3_ttf from the SDL_ttf releases page — grab SDL3_ttf-devel-x.x.x-VC.zip, the Visual C++ development package, and extract it alongside your SDL3 folder. Then, in your project properties:

  1. C/C++ → General → Additional Include Directories — add SDL3_ttf's include folder.
  2. Linker → General → Additional Library Directories — add its lib\x64 folder.
  3. Linker → Input → Additional Dependencies — add SDL3_ttf.lib alongside SDL3.lib.

Copy SDL3_ttf.dll into your build output folder next to SDL3.dll and the .exe, and put RobotoMono-Light.ttf (in the project folder) there too. The font is loaded by filename at runtime, so it must sit beside the executable.

GlyphCache.h

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

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

    bool ok() const { return m_loaded; }

    void drawGlyph(int col, int row, char ch, SDL_Color color) const;

private:
    SDL_Renderer* m_sdl    = nullptr;
    bool          m_loaded = false;

    static constexpr int FIRST_CHAR = 32;    // space
    static constexpr int LAST_CHAR  = 126;   // tilde
    static constexpr int NUM_CHARS  = LAST_CHAR - FIRST_CHAR + 1;

    struct Glyph
    {
        SDL_Texture* tex = nullptr;
        int          w   = 0;     // natural rendered width
        int          h   = 0;     // natural rendered height
    };
    Glyph m_glyphs[NUM_CHARS];
};

The cache stores one Glyph per printable ASCII character (32 through 126), each holding a texture and the glyph's natural pixel size. We keep the size because we need it to centre the glyph at draw time.

Building the cache

#include "GlyphCache.h"
#include "Map.h"                 // for TILE_SIZE
#include <SDL3_ttf/SDL_ttf.h>

GlyphCache::GlyphCache(SDL_Renderer* sdl, const char* fontPath, float ptSize)
    : m_sdl(sdl)
{
    TTF_Font* font = TTF_OpenFont(fontPath, ptSize);
    if (!font)
    {
        SDL_Log("GlyphCache: could not open font '%s': %s", fontPath, SDL_GetError());
        return;
    }

    const SDL_Color white = { 255, 255, 255, 255 };

    for (int c = FIRST_CHAR; c <= LAST_CHAR; ++c)
    {
        char str[2] = { (char)c, '\0' };

        SDL_Surface* surf = TTF_RenderText_Blended(font, str, 0, white);
        if (!surf) continue;

        Glyph& g = m_glyphs[c - FIRST_CHAR];
        g.w   = surf->w;
        g.h   = surf->h;
        g.tex = SDL_CreateTextureFromSurface(m_sdl, surf);
        if (g.tex)
            SDL_SetTextureBlendMode(g.tex, SDL_BLENDMODE_BLEND);

        SDL_DestroySurface(surf);
    }

    TTF_CloseFont(font);
    m_loaded = true;
}

Three details matter here. The glyphs are rendered in white on a transparent background, because SDL_SetTextureColorMod multiplies a tint against the texture — a white base can be tinted to any colour, a coloured base cannot. Each glyph becomes its own texture, created directly from the surface TTF_RenderText_Blended returns. The earlier draft of this series blitted every glyph into one big "atlas" surface first, and that intermediate blit premultiplied the alpha and corrupted the colour — which is exactly why the old renderer looked near-invisible. Creating a texture straight from a fresh TTF surface keeps the alpha correct. And ptSize is a float — SDL3_ttf takes a floating-point point size, unlike SDL2_ttf.

Drawing a glyph — centred, never stretched

GlyphCache::~GlyphCache()
{
    for (Glyph& g : m_glyphs)
        if (g.tex) SDL_DestroyTexture(g.tex);
}

void GlyphCache::drawGlyph(int col, int row, char ch, SDL_Color color) const
{
    if (ch < FIRST_CHAR || ch > LAST_CHAR) ch = '?';

    const Glyph& g = m_glyphs[ch - FIRST_CHAR];
    if (!g.tex) return;

    SDL_SetTextureColorMod(g.tex, color.r, color.g, color.b);
    SDL_SetTextureAlphaMod(g.tex, color.a);

    // Centre the glyph in its cell at natural size. The +0.5 rounding keeps
    // the glyph on whole pixels, which keeps the edges sharp.
    float x = col * TILE_SIZE + (TILE_SIZE - g.w) * 0.5f;
    float y = row * TILE_SIZE + (TILE_SIZE - g.h) * 0.5f;
    SDL_FRect dst = { SDL_floorf(x + 0.5f), SDL_floorf(y + 0.5f),
                      (float)g.w,           (float)g.h };

    SDL_RenderTexture(m_sdl, g.tex, nullptr, &dst);
}

Look closely at dst: its width and height are the glyph's natural g.w and g.h — not TILE_SIZE. The destination is positioned so the glyph sits in the middle of the cell, and that's the whole secret to a crisp ASCII display. Snapping the position to whole pixels with SDL_floorf(x + 0.5f) avoids half-pixel sampling that would soften the edges. SDL_SetTextureColorMod tints the white glyph to whatever colour the caller passes, so the same 95 textures serve walls, floors, the player, monsters — everything.

Putting It Together — main.cpp

If you've used SDL2, a few things have changed in SDL3: SDL_Init and TTF_Init now return bool (true means success), and SDL_CreateWindowAndRenderer builds the window and renderer in a single call.

#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include "Map.h"
#include "GlyphCache.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  = { 130, 120, 150, 255 };
static const SDL_Color FLOOR = {  72,  66,  60, 255 };

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 1: Map Generation",
            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;
    }

    Map map;
    map.generate();

Note the order: TTF_Init must run before any font is opened, and the GlyphCache needs the renderer, so it is built after the window. If the font fails to load, glyphs.ok() is false and we bail out cleanly rather than drawing an empty screen.

Drawing the map and the event loop

    auto draw = [&]()
    {
        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)
            {
                if (map.tiles[row][col].type == TileType::Floor)
                    glyphs.drawGlyph(col, row, '.', FLOOR);
                else
                    glyphs.drawGlyph(col, row, '#', WALL);
            }

        SDL_RenderPresent(sdl);
    };

    draw();   // show the first dungeon immediately

    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;             // uncovered — repaint
                break;

            case SDL_EVENT_KEY_DOWN:
                if (event.key.scancode == SDL_SCANCODE_ESCAPE) running = false;
                if (event.key.scancode == SDL_SCANCODE_SPACE)  { map.generate(); dirty = true; }
                break;
        }

        if (dirty) draw();
    }

    SDL_DestroyRenderer(sdl);
    SDL_DestroyWindow(window);
    TTF_Quit();
    SDL_Quit();
    return 0;
}

The map is static — it only changes when you press Space — so there is no reason to spin a render loop at full speed. SDL_WaitEvent sleeps the thread until an event arrives, dropping CPU use to near zero while idle. That is exactly the behaviour a turn-based game wants, and Part 2 builds player movement directly on top of it. The dirty flag means we only repaint when something actually changed: a new dungeon, or the window being uncovered. Floors are drawn as dim brown . dots and walls as brighter lilac-grey # hashes, so the structure of the dungeon reads clearly even before there's anything in it.

Try It

Build and run. A 1280×720 window should appear showing a dungeon of interconnected rooms in crisp ASCII — bright # walls, dim . floors, dark void where the rock hasn't been carved. Press Space several times: every layout is different, and every one is fully connected because BSP links every partition before generation ends.

A second ASCII dungeon layout — different room positions and corridor paths generated by pressing Space
A different layout from the same generator. No two runs produce the same dungeon.

To reproduce a specific layout, temporarily add SDL_Log("seed %u", seed); just after m_rng.seed(seed), read the value from the Output window, and pass it as a fixed argument — map.generate(42) — to get the same dungeon every run.

Complete Code

Map.h

#pragma once
#include <vector>
#include <memory>
#include <random>

const int MAP_WIDTH  = 64;
const int MAP_HEIGHT = 36;
const int TILE_SIZE  = 20;

enum class TileType { Wall, Floor };

struct Tile
{
    TileType type = TileType::Wall;
};

struct Room
{
    int x = 0, y = 0, w = 0, h = 0;
    int centreX() const { return x + w / 2; }
    int centreY() const { return y + h / 2; }
};

class Map
{
public:
    Tile              tiles[MAP_HEIGHT][MAP_WIDTH];
    std::vector<Room> rooms;

    void generate(unsigned int seed = 0);

private:
    std::mt19937 m_rng;

    struct BSPNode
    {
        int x = 0, y = 0, w = 0, h = 0;
        std::unique_ptr<BSPNode> left;
        std::unique_ptr<BSPNode> right;
        Room room{};
        bool hasRoom = false;
    };

    void split       (BSPNode& node, int depth);
    void buildRooms  (BSPNode& node);
    void connectRooms(BSPNode& node);

    void carveHLine(int x1, int x2, int y);
    void carveVLine(int y1, int y2, int x);
    void carveTile (int x,  int y,  TileType type);

    bool coinFlip() { return (m_rng() & 1) == 0; }
};

Map.cpp

#include "Map.h"
#include <algorithm>

static constexpr int MAX_DEPTH    = 4;
static constexpr int MIN_LEAF_W   = 12;
static constexpr int MIN_LEAF_H   = 9;
static constexpr int ROOM_PADDING = 1;
static constexpr int ROOM_MIN     = 4;

void Map::generate(unsigned int seed)
{
    for (auto& row : tiles)
        for (auto& tile : row)
            tile = Tile{};
    rooms.clear();

    if (seed == 0)
    {
        std::random_device rd;
        seed = rd();
    }
    m_rng.seed(seed);

    BSPNode root;
    root.x = 0;          root.y = 0;
    root.w = MAP_WIDTH;  root.h = MAP_HEIGHT;

    split(root, 0);
    buildRooms(root);
    connectRooms(root);
}

void Map::split(BSPNode& node, int depth)
{
    if (depth >= MAX_DEPTH) return;

    bool canSplitH = (node.h >= MIN_LEAF_H * 2);
    bool canSplitV = (node.w >= MIN_LEAF_W * 2);
    if (!canSplitH && !canSplitV) return;

    bool horizontal;
    if      (!canSplitV) horizontal = true;
    else if (!canSplitH) horizontal = false;
    else    horizontal = (node.h > node.w) || (node.h == node.w && coinFlip());

    node.left  = std::make_unique<BSPNode>();
    node.right = std::make_unique<BSPNode>();

    if (horizontal)
    {
        int splitY = std::uniform_int_distribution<int>(
            node.y + MIN_LEAF_H, node.y + node.h - MIN_LEAF_H)(m_rng);

        node.left->x  = node.x;  node.left->y  = node.y;
        node.left->w  = node.w;  node.left->h  = splitY - node.y;

        node.right->x = node.x;  node.right->y = splitY;
        node.right->w = node.w;  node.right->h = (node.y + node.h) - splitY;
    }
    else
    {
        int splitX = std::uniform_int_distribution<int>(
            node.x + MIN_LEAF_W, node.x + node.w - MIN_LEAF_W)(m_rng);

        node.left->x  = node.x;          node.left->y  = node.y;
        node.left->w  = splitX - node.x; node.left->h  = node.h;

        node.right->x = splitX;          node.right->y = node.y;
        node.right->w = (node.x + node.w) - splitX;  node.right->h = node.h;
    }

    split(*node.left,  depth + 1);
    split(*node.right, depth + 1);
}

void Map::buildRooms(BSPNode& node)
{
    if (node.left)
    {
        buildRooms(*node.left);
        buildRooms(*node.right);
        return;
    }

    int maxW = node.w - ROOM_PADDING * 2;
    int maxH = node.h - ROOM_PADDING * 2;
    if (maxW < ROOM_MIN || maxH < ROOM_MIN) return;

    int rw = std::uniform_int_distribution<int>(ROOM_MIN, maxW)(m_rng);
    int rh = std::uniform_int_distribution<int>(ROOM_MIN, maxH)(m_rng);
    int rx = node.x + ROOM_PADDING + std::uniform_int_distribution<int>(0, maxW - rw)(m_rng);
    int ry = node.y + ROOM_PADDING + std::uniform_int_distribution<int>(0, maxH - rh)(m_rng);

    node.room    = { rx, ry, rw, rh };
    node.hasRoom = true;

    for (int y = ry; y < ry + rh; ++y)
        for (int x = rx; x < rx + rw; ++x)
            carveTile(x, y, TileType::Floor);

    rooms.push_back(node.room);
}

void Map::connectRooms(BSPNode& node)
{
    if (!node.left) return;

    connectRooms(*node.left);
    connectRooms(*node.right);

    BSPNode* l = node.left.get();
    while (l->left) l = coinFlip() ? l->left.get() : l->right.get();

    BSPNode* r = node.right.get();
    while (r->left) r = coinFlip() ? r->left.get() : r->right.get();

    if (!l->hasRoom || !r->hasRoom) return;

    int x1 = l->room.centreX(), y1 = l->room.centreY();
    int x2 = r->room.centreX(), y2 = r->room.centreY();

    if (coinFlip())
    {
        carveHLine(x1, x2, y1);
        carveVLine(y1, y2, x2);
    }
    else
    {
        carveVLine(y1, y2, x1);
        carveHLine(x1, x2, y2);
    }
}

void Map::carveTile(int x, int y, TileType type)
{
    if (x >= 0 && x < MAP_WIDTH && y >= 0 && y < MAP_HEIGHT)
        tiles[y][x].type = type;
}

void Map::carveHLine(int x1, int x2, int y)
{
    if (x1 > x2) std::swap(x1, x2);
    for (int x = x1; x <= x2; ++x)
        carveTile(x, y, TileType::Floor);
}

void Map::carveVLine(int y1, int y2, int x)
{
    if (y1 > y2) std::swap(y1, y2);
    for (int y = y1; y <= y2; ++y)
        carveTile(x, y, TileType::Floor);
}

GlyphCache.h

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

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

    bool ok() const { return m_loaded; }

    void drawGlyph(int col, int row, char ch, SDL_Color color) const;

private:
    SDL_Renderer* m_sdl    = nullptr;
    bool          m_loaded = false;

    static constexpr int FIRST_CHAR = 32;
    static constexpr int LAST_CHAR  = 126;
    static constexpr int NUM_CHARS  = LAST_CHAR - FIRST_CHAR + 1;

    struct Glyph
    {
        SDL_Texture* tex = nullptr;
        int          w   = 0;
        int          h   = 0;
    };
    Glyph m_glyphs[NUM_CHARS];
};

GlyphCache.cpp

#include "GlyphCache.h"
#include "Map.h"
#include <SDL3_ttf/SDL_ttf.h>

GlyphCache::GlyphCache(SDL_Renderer* sdl, const char* fontPath, float ptSize)
    : m_sdl(sdl)
{
    TTF_Font* font = TTF_OpenFont(fontPath, ptSize);
    if (!font)
    {
        SDL_Log("GlyphCache: could not open font '%s': %s", fontPath, SDL_GetError());
        return;
    }

    const SDL_Color white = { 255, 255, 255, 255 };

    for (int c = FIRST_CHAR; c <= LAST_CHAR; ++c)
    {
        char str[2] = { (char)c, '\0' };

        SDL_Surface* surf = TTF_RenderText_Blended(font, str, 0, white);
        if (!surf) continue;

        Glyph& g = m_glyphs[c - FIRST_CHAR];
        g.w   = surf->w;
        g.h   = surf->h;
        g.tex = SDL_CreateTextureFromSurface(m_sdl, surf);
        if (g.tex)
            SDL_SetTextureBlendMode(g.tex, SDL_BLENDMODE_BLEND);

        SDL_DestroySurface(surf);
    }

    TTF_CloseFont(font);
    m_loaded = true;
}

GlyphCache::~GlyphCache()
{
    for (Glyph& g : m_glyphs)
        if (g.tex) SDL_DestroyTexture(g.tex);
}

void GlyphCache::drawGlyph(int col, int row, char ch, SDL_Color color) const
{
    if (ch < FIRST_CHAR || ch > LAST_CHAR) ch = '?';

    const Glyph& g = m_glyphs[ch - FIRST_CHAR];
    if (!g.tex) return;

    SDL_SetTextureColorMod(g.tex, color.r, color.g, color.b);
    SDL_SetTextureAlphaMod(g.tex, color.a);

    float x = col * TILE_SIZE + (TILE_SIZE - g.w) * 0.5f;
    float y = row * TILE_SIZE + (TILE_SIZE - g.h) * 0.5f;
    SDL_FRect dst = { SDL_floorf(x + 0.5f), SDL_floorf(y + 0.5f),
                      (float)g.w,           (float)g.h };

    SDL_RenderTexture(m_sdl, g.tex, nullptr, &dst);
}

main.cpp

#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include "Map.h"
#include "GlyphCache.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  = { 130, 120, 150, 255 };
static const SDL_Color FLOOR = {  72,  66,  60, 255 };

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 1: Map Generation",
            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;
    }

    Map map;
    map.generate();

    auto draw = [&]()
    {
        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)
            {
                if (map.tiles[row][col].type == TileType::Floor)
                    glyphs.drawGlyph(col, row, '.', FLOOR);
                else
                    glyphs.drawGlyph(col, row, '#', WALL);
            }

        SDL_RenderPresent(sdl);
    };

    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 (event.key.scancode == SDL_SCANCODE_ESCAPE) running = false;
                if (event.key.scancode == SDL_SCANCODE_SPACE)  { map.generate(); dirty = true; }
                break;
        }

        if (dirty) draw();
    }

    SDL_DestroyRenderer(sdl);
    SDL_DestroyWindow(window);
    TTF_Quit();
    SDL_Quit();
    return 0;
}

Common Errors

Black window — no characters at all. The font failed to load, so glyphs.ok() returned false. Confirm RobotoMono-Light.ttf sits in the same folder as the .exe (the build output directory, usually x64\Debug), not just the source folder. Check the Output window for the GlyphCache: could not open font message.

Glyphs appear but look blurry or squashed. The destination rectangle is filling the whole cell instead of using the glyph's natural size. In drawGlyph, dst's width and height must be g.w and g.h — not TILE_SIZE. Drawing at natural size centred in the cell is what keeps glyphs sharp.

Linker error — unresolved TTF_Init or TTF_OpenFont. SDL3_ttf.lib is missing from Additional Dependencies, or the path to its lib\x64 folder isn't in Additional Library Directories. Both the lib (for linking) and the include folder (for compilation) are needed.

Crash on launch — SDL3_ttf.dll not found. The DLL must be next to the .exe at runtime. Copy SDL3_ttf.dll into the build output folder alongside SDL3.dll.

Window opens but shows only dark void — no rooms. map.generate() wasn't called before the first draw(), or the BSP constants are too restrictive for the grid. Verify MAX_DEPTH is 4 and the MIN_LEAF values match the listing.

What's Next

Part 2 adds the player. We query Map::rooms for a safe start position, draw the @ with the very same GlyphCache you just wrote, handle four-directional movement with wall collision, and turn the event loop into a proper turn-based one. The map generator and the renderer are untouched — that's the payoff of building the real renderer now.