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: theGlyphCachedoes 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 samedrawGlyphcall.
What's New in Part 3
| File | Status | What changed |
|---|---|---|
FOV.h / .cpp | New | Recursive shadowcasting |
Map.h | Updated | Tile gains visible and explored |
main.cpp | Updated | FOV after each move; three-state tile colouring |
Map.cpp | Unchanged | BSP generator from Part 1 |
GlyphCache.h / .cpp | Unchanged | The ASCII renderer from Part 1 |
Player.h | Unchanged | Player 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:
- The slopes.
lSlopeandrSlopeare the left and right edges of a cell, expressed as column-offset over row-distance. A cell is skipped if it begins to the right of where the light ends, and the row stops once a cell ends to the left of where the light begins. - The recursion. Only the transition into shadow — lit cell, then wall — launches a recursive call, to cover the lit arc that precedes the wall. A second wall in the same row finds
blockedalready true and just narrows the shadow, so multiple walls are handled without extra recursion. - The circular clamp.
dx*dx + dy*dy <= RADIUS*RADIUStrims the lit region to a disc; without it the algorithm naturally lights a square. The check uses the octant-localdxanddy, not the final screen coordinates. - Why
dyis negative. The row distance runs up the octant asdy = -distance, so the slope denominators are(dy + 0.5)and(dy - 0.5). Getting that sign wrong makes every row break on its first cell and lights nothing but the origin — the classic "only my current tile is lit" bug.
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.
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.