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:
| File | Responsibility |
|---|---|
Common.h | Shared types: Point, Terrain enum, Palette colour constants, constants |
Tile.h | Tile struct: terrain, explored flag, glyph and colour getters |
Map.h/.cpp | 2D tile grid, accessor methods, bounds checking |
MapGen.h/.cpp | BSP dungeon generator, room/corridor placement |
GlyphCache.h/.cpp | SDL3_ttf glyph renderer — caches one SDL_Texture per character |
Entity.h/.cpp | Base class: position, HP, glyph, colour |
Player.h/.cpp | Derives from Entity; movement, score, gold |
FOV.h/.cpp | Bresenham line-based field-of-view computation |
SaveLoad.h/.cpp | Plain-text save and load — player position, map explored flags |
Game.h/.cpp | Main game loop, event handling, render, state |
main.cpp | SDL 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:
- Start with the whole map as a rectangular region.
- Randomly split it either horizontally or vertically.
- Recursively split each half.
- When a region is small enough, place a room inside it.
- 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
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
- The project spans 11 files. Each file has one responsibility — the
Mapdoesn't know about the player; theFOVdoesn't know about saving. - BSP dungeon generation produces connected, well-separated rooms by recursively splitting a rectangular region and connecting sibling leaves with L-shaped corridors.
- The
GlyphCachepre-renders each ASCII character as an SDL_Texture and tints it withSDL_SetTextureColorModfor zero-copy colour changes. - Bresenham raycasting computes FOV by tracing 360 integer-grid rays from the player, stopping at walls.
SDL_WaitEventgives a turn-based loop that uses zero CPU while waiting for input.- The plain-text save format stores only the player state and explored flags — the map geometry is regenerated from a seed.