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

Field of View & Fog of War

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 →

Field of view and fog of war are the two features that turn a dungeon viewer into a roguelike. Without them you see the whole map the instant you spawn — no surprises, no tension, no reason to explore. With them the dungeon exists in three states: what you can see right now, what you remember from earlier, and what is still unknown.

Part 3 adds all three. We compute what the player can see with recursive shadowcasting — the field-of-view algorithm used by NetHack, Angband, DCSS and most serious roguelikes. After every move the view is recomputed: tiles in sight are drawn at full brightness, tiles seen before but no longer visible are drawn dim, and everything else stays dark. The Tile struct gains the two flags we reserved for it back in Part 1, and that's the only change to the data layer.

A note on the renderer: the GlyphCache does not change in this tutorial. Because Part 1 built it correctly — one texture per glyph, drawn centred at natural size — it already renders crisp characters at any colour we ask for. All three of Part 3's visibility states are just different tint colours passed to the same drawGlyph call.
Part 3 — player in the starting room surrounded by darkness, only the immediate area lit by field of view
First spawn: the player sees their starting room and a little of the corridor beyond. Everything else is unknown.

What's New in Part 3

FileStatusWhat changed
FOV.h / .cppNewRecursive shadowcasting
Map.hUpdatedTile gains visible and explored
main.cppUpdatedFOV after each move; three-state tile colouring
Map.cppUnchangedBSP generator from Part 1
GlyphCache.h / .cppUnchangedThe ASCII renderer from Part 1
Player.hUnchangedPlayer struct from Part 2

The Tile Gains Two Flags — Map.h

struct Tile
{
    TileType type     = TileType::Wall;
    bool     visible  = false;   // in the player's current field of view
    bool     explored = false;   // seen at least once — drawn dim from memory
};

visible is recomputed every turn: cleared to false across the whole map, then set true for each tile the shadowcaster reaches. explored is set true the first time a tile is ever seen and then never cleared — it is the player's permanent memory of the dungeon. This is the entire change to Map.h, and Map.cpp needs no change at all: its reset loop already assigns a default-constructed Tile, so both flags return to false whenever a new dungeon is generated.

Recursive Shadowcasting — FOV.h / FOV.cpp

Why not raycasting?

The simplest field of view casts a ray in every direction — say 360 of them, one per degree — and marches each outward until it hits a wall. It's easy, and it's what the roguelike in the back of Learning C++ by Building Games uses to keep things approachable. But it has a real flaw: at close range, one ray per degree is too sparse, so adjacent rays skip over tiles and leave little blind spots near walls. Adding more rays fixes the holes but multiplies the work.

Recursive shadowcasting has neither problem. Instead of rays it tracks arcs of light, row by row: a wall casts a shadow, and the algorithm recurses to handle whatever arc remains lit beside it. One sweep per octant, no missed tiles, no duplicated effort. It is the algorithm serious roguelikes actually ship, and stepping up from raycasting to shadowcasting is one of the ways this series goes beyond the book.

The octant model

Shadowcasting works in a single 45° wedge — one octant. Full 360° vision comes from running the same routine eight times with different coordinate transforms, one per octant:

#pragma once
#include "Map.h"

class FOV
{
public:
    static constexpr int RADIUS = 11;   // how far the player can see, in tiles

    static void compute(Map& map, int originX, int originY);

private:
    static const int MULT[8][4];        // per-octant coordinate transforms

    static void castLight(Map& map, int cx, int cy,
                          int row, float startSlope, float endSlope,
                          int xx, int xy, int yx, int yy);
};

RADIUS of 11 tiles is roughly a room plus a corridor — far enough to navigate, close enough to keep the dungeon tense. The eight rows of MULT are the per-octant transforms; compute clears visibility, lights the origin, then fires one castLight per octant.

#include "FOV.h"

const int FOV::MULT[8][4] = {
    {  1,  0,  0,  1 },
    {  0,  1,  1,  0 },
    {  0, -1,  1,  0 },
    { -1,  0,  0,  1 },
    { -1,  0,  0, -1 },
    {  0, -1, -1,  0 },
    {  0,  1, -1,  0 },
    {  1,  0,  0, -1 },
};

void FOV::compute(Map& map, int originX, int originY)
{
    for (int y = 0; y < MAP_HEIGHT; ++y)
        for (int x = 0; x < MAP_WIDTH; ++x)
            map.tiles[y][x].visible = false;

    map.tiles[originY][originX].visible  = true;
    map.tiles[originY][originX].explored = true;

    for (int oct = 0; oct < 8; ++oct)
        castLight(map, originX, originY,
                  1, 1.0f, 0.0f,
                  MULT[oct][0], MULT[oct][1], MULT[oct][2], MULT[oct][3]);
}

The casting loop

castLight scans the octant one row at a time. Its state is the arc of unblocked slopes [startSlope, endSlope]. For each cell it computes the cell's left and right slopes; cells outside the lit arc are skipped or end the row, cells inside are marked visible. When it crosses from lit space into a wall it recurses to handle the still-lit arc above the wall, then keeps narrowing the arc for the rest of the row.

void FOV::castLight(Map& map, int cx, int cy,
                    int row, float startSlope, float endSlope,
                    int xx, int xy, int yx, int yy)
{
    if (startSlope < endSlope) return;

    float nextStartSlope = startSlope;
    bool  blocked        = false;

    for (int distance = row; distance <= RADIUS && !blocked; ++distance)
    {
        int dy = -distance;

        for (int dx = -distance; dx <= 0; ++dx)
        {
            // Slopes of this cell's left and right edges. dy is negative, so
            // the denominators are (dy + 0.5) and (dy - 0.5).
            float lSlope = (dx - 0.5f) / (dy + 0.5f);
            float rSlope = (dx + 0.5f) / (dy - 0.5f);

            if (startSlope < rSlope) continue;   // not in the lit arc yet
            if (endSlope   > lSlope) break;       // past the far edge of the arc

            int x = cx + dx * xx + dy * xy;
            int y = cy + dx * yx + dy * yy;

            if (x < 0 || x >= MAP_WIDTH || y < 0 || y >= MAP_HEIGHT)
                continue;

            // Circular clamp — without it the lit area would be a square.
            if (dx * dx + dy * dy <= RADIUS * RADIUS)
            {
                map.tiles[y][x].visible  = true;
                map.tiles[y][x].explored = true;
            }

            bool isWall = (map.tiles[y][x].type == TileType::Wall);

            if (blocked)
            {
                if (isWall)
                {
                    nextStartSlope = rSlope;       // still in shadow
                }
                else
                {
                    blocked    = false;            // shadow ended
                    startSlope = nextStartSlope;
                }
            }
            else if (isWall && distance < RADIUS)
            {
                // Entering shadow: recurse on the lit arc above this wall,
                // then keep scanning with a tightened start slope.
                blocked = true;
                castLight(map, cx, cy, distance + 1, startSlope, lSlope, xx, xy, yx, yy);
                nextStartSlope = rSlope;
            }
        }
    }
}

A few details worth understanding:

Wiring It Together — main.cpp

Two things change in main.cpp: the view is recomputed whenever the world changes, and tiles are coloured by their visibility state.

Recomputing the view

auto newGame = [&]()
{
    map.generate();
    player.x  = map.rooms[0].centreX();
    player.y  = map.rooms[0].centreY();
    player.hp = player.maxHp;
    FOV::compute(map, player.x, player.y);   // light the starting area
};

// ...and after a successful move, inside the key handler:
if (player.tryMove(dx, dy, map))
{
    FOV::compute(map, player.x, player.y);   // recompute after a real move
    dirty = true;
}

The view is computed once when the dungeon is built and again after every move that actually happens. Because tryMove returns false when blocked, bumping a wall recomputes nothing — exactly the turn-based discipline established in Part 2.

Three colour states

static const SDL_Color WALL_LIT  = { 150, 140, 175, 255 };
static const SDL_Color FLOOR_LIT = { 100,  92,  80, 255 };
static const SDL_Color WALL_MEM  = {  58,  54,  72, 255 };
static const SDL_Color FLOOR_MEM = {  40,  37,  33, 255 };

auto drawTile = [&](int col, int row, const Tile& t)
{
    if (!t.explored) return;                 // never seen — leave as void

    if (t.visible)
    {
        if (t.type == TileType::Floor) glyphs.drawGlyph(col, row, '.', FLOOR_LIT);
        else                           glyphs.drawGlyph(col, row, '#', WALL_LIT);
    }
    else
    {
        if (t.type == TileType::Floor) glyphs.drawGlyph(col, row, '.', FLOOR_MEM);
        else                           glyphs.drawGlyph(col, row, '#', WALL_MEM);
    }
};

Unexplored tiles return immediately, leaving the dark background showing through. Visible tiles use the bright "lit" colours; explored-but-unseen tiles use the dim "memory" colours at roughly a quarter of the brightness — still readable as a map, but clearly not what you are looking at right now. The player @ is drawn after the whole grid, so it always sits on top.

The draw 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)
            drawTile(col, row, map.tiles[row][col]);

    glyphs.drawGlyph(player.x, player.y, '@', PLAYER);

    SDL_RenderPresent(sdl);
};

Try It

Build and run. Your starting room is lit, with the corridor beyond it just coming into view, and the rest of the level is black. Walk around: rooms reveal themselves as you approach, and fade to dim memory colours once you leave. You can still read where you have been, but it's clearly behind you in the dark.

Part 3 — several rooms explored, visited areas shown dim, the player's current surroundings brightly lit
After exploring. Visited tiles show in dim memory colours; the current view is brightly lit; the unexplored dungeon stays hidden.

Press Space for a fresh dungeon — it starts completely dark again, because regenerating resets every tile, including the explored flag.

Complete Code

Only the new and changed files are listed. Map.cpp, GlyphCache.h, GlyphCache.cpp and Player.h are unchanged from earlier parts.

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;
    bool     visible  = false;
    bool     explored = false;
};

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; }
};

FOV.h

#pragma once
#include "Map.h"

class FOV
{
public:
    static constexpr int RADIUS = 11;

    static void compute(Map& map, int originX, int originY);

private:
    static const int MULT[8][4];

    static void castLight(Map& map, int cx, int cy,
                          int row, float startSlope, float endSlope,
                          int xx, int xy, int yx, int yy);
};

FOV.cpp

#include "FOV.h"

const int FOV::MULT[8][4] = {
    {  1,  0,  0,  1 },
    {  0,  1,  1,  0 },
    {  0, -1,  1,  0 },
    { -1,  0,  0,  1 },
    { -1,  0,  0, -1 },
    {  0, -1, -1,  0 },
    {  0,  1, -1,  0 },
    {  1,  0,  0, -1 },
};

void FOV::compute(Map& map, int originX, int originY)
{
    for (int y = 0; y < MAP_HEIGHT; ++y)
        for (int x = 0; x < MAP_WIDTH; ++x)
            map.tiles[y][x].visible = false;

    map.tiles[originY][originX].visible  = true;
    map.tiles[originY][originX].explored = true;

    for (int oct = 0; oct < 8; ++oct)
        castLight(map, originX, originY,
                  1, 1.0f, 0.0f,
                  MULT[oct][0], MULT[oct][1], MULT[oct][2], MULT[oct][3]);
}

void FOV::castLight(Map& map, int cx, int cy,
                    int row, float startSlope, float endSlope,
                    int xx, int xy, int yx, int yy)
{
    if (startSlope < endSlope) return;

    float nextStartSlope = startSlope;
    bool  blocked        = false;

    for (int distance = row; distance <= RADIUS && !blocked; ++distance)
    {
        int dy = -distance;

        for (int dx = -distance; dx <= 0; ++dx)
        {
            float lSlope = (dx - 0.5f) / (dy + 0.5f);
            float rSlope = (dx + 0.5f) / (dy - 0.5f);

            if (startSlope < rSlope) continue;
            if (endSlope   > lSlope) break;

            int x = cx + dx * xx + dy * xy;
            int y = cy + dx * yx + dy * yy;

            if (x < 0 || x >= MAP_WIDTH || y < 0 || y >= MAP_HEIGHT)
                continue;

            if (dx * dx + dy * dy <= RADIUS * RADIUS)
            {
                map.tiles[y][x].visible  = true;
                map.tiles[y][x].explored = true;
            }

            bool isWall = (map.tiles[y][x].type == TileType::Wall);

            if (blocked)
            {
                if (isWall)
                {
                    nextStartSlope = rSlope;
                }
                else
                {
                    blocked    = false;
                    startSlope = nextStartSlope;
                }
            }
            else if (isWall && distance < RADIUS)
            {
                blocked = true;
                castLight(map, cx, cy, distance + 1, startSlope, lSlope, xx, xy, yx, yy);
                nextStartSlope = rSlope;
            }
        }
    }
}

main.cpp

#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include "Map.h"
#include "FOV.h"
#include "GlyphCache.h"
#include "Player.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 PLAYER = { 255, 230, 150, 255 };

static const SDL_Color WALL_LIT  = { 150, 140, 175, 255 };
static const SDL_Color FLOOR_LIT = { 100,  92,  80, 255 };
static const SDL_Color WALL_MEM  = {  58,  54,  72, 255 };
static const SDL_Color FLOOR_MEM = {  40,  37,  33, 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 3: Field of View",
            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;
    Player player;

    auto newGame = [&]()
    {
        map.generate();
        player.x  = map.rooms[0].centreX();
        player.y  = map.rooms[0].centreY();
        player.hp = player.maxHp;
        FOV::compute(map, player.x, player.y);
    };

    auto drawTile = [&](int col, int row, const Tile& t)
    {
        if (!t.explored) return;

        if (t.visible)
        {
            if (t.type == TileType::Floor) glyphs.drawGlyph(col, row, '.', FLOOR_LIT);
            else                           glyphs.drawGlyph(col, row, '#', WALL_LIT);
        }
        else
        {
            if (t.type == TileType::Floor) glyphs.drawGlyph(col, row, '.', FLOOR_MEM);
            else                           glyphs.drawGlyph(col, row, '#', WALL_MEM);
        }
    };

    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)
                drawTile(col, row, map.tiles[row][col]);

        glyphs.drawGlyph(player.x, player.y, '@', PLAYER);

        SDL_RenderPresent(sdl);
    };

    newGame();
    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:
            {
                int dx = 0, dy = 0;
                switch (event.key.scancode)
                {
                    case SDL_SCANCODE_UP:    case SDL_SCANCODE_W: dy = -1; break;
                    case SDL_SCANCODE_DOWN:  case SDL_SCANCODE_S: dy = +1; break;
                    case SDL_SCANCODE_LEFT:  case SDL_SCANCODE_A: dx = -1; break;
                    case SDL_SCANCODE_RIGHT: case SDL_SCANCODE_D: dx = +1; break;
                    case SDL_SCANCODE_SPACE:  newGame(); dirty = true; break;
                    case SDL_SCANCODE_ESCAPE: running = false;         break;
                    default: break;
                }

                if (dx != 0 || dy != 0)
                {
                    if (player.tryMove(dx, dy, map))
                    {
                        FOV::compute(map, player.x, player.y);
                        dirty = true;
                    }
                }
                break;
            }
        }

        if (dirty) draw();
    }

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

Common Errors

The whole map is visible from spawn. The visible or explored flag is being set true somewhere it shouldn't, or the draw routine ignores them. Confirm both default to false in Tile and that drawTile returns early when !t.explored.

Rooms stay fully lit after you leave them. FOV::compute isn't clearing visible at the start of each call, or it's only called once. Every call must clear all visible flags before re-lighting — explored stays, visible resets.

Only the tile you're standing on is lit — visited floors show as faint dots and no walls appear. The slope math has the row distance sign wrong, so every row breaks on its first cell and nothing past the origin is ever marked. dy must be -distance (negative), making the denominators (dy + 0.5) and (dy - 0.5). With the sign flipped you get exactly this symptom.

The lit area is a square, not a disc. The circular clamp dx*dx + dy*dy <= RADIUS*RADIUS is missing or is using the wrong variables. It must use the octant-local dx and dy, not the final x and y.

Walls are visible but the floor in front of them is not — blind spots. That's the raycasting artefact this algorithm exists to avoid, which means a stray ray-based routine is still in play. Make sure FOV runs entirely through castLight.

Stack overflow on a very large radius. Shadowcasting recurses once per wall per octant per row; on a wide-open map with a huge radius that can go deep. Keep RADIUS at 15 or below for this grid, or rewrite the recursion with an explicit stack if you need more.

What's Next

Part 4 adds monsters. We introduce an Entity base that both Player and Monster share, scatter a few enemies through the rooms, give them simple wandering behaviour, and draw them as coloured letters — but only when the player can actually see them. The FOV system you built here drives that directly: if a monster's tile has visible == false, the monster isn't drawn. The map, the renderer and the FOV code are all untouched.