Every floor so far has been the generators' work: rooms carved by Chapter 2's cuts, or caves grown by Chapter 18's rule. They're different every time, which is the point, but no generator ever makes a room that looks as if someone built it on purpose.
This chapter adds rooms made by hand. A vault is drawn as text in a data file, and read when the game starts. Then it's built into a floor, walls, door and all, as the floor is made. It comes with its treasure, its traps and its guards.
Most of them hold treasure, and about one floor in three has one of those. Three vaults are different. The Amulets of SDL, the three things a run has to find, each lie in a vault of their own, on floors 3, 6 and 9. The last is guarded by a dragon. The next image shows the last of them.

That's the young red dragon's lair, on floor 9, built into the room below the player's. F1 shows the whole floor: the dragon in the middle, the Amulet just below it, and treasure all around. The vault comes from a few lines of text, which you can change, or add to, without compiling.
Chapter 20 in the book's repository holds this chapter's finished project. The chapter carries on in your own project from Chapter 19, or from the book's Chapter 19 folder.In this chapter, we will:
- Let a value in a data file run over several rows, for a vault's map
- Read the vaults from
vaults.txt, and check each one: its size, its symbols, its walls, and its one way in, which must reach every cell - Look at each vault on a new debugging key, F7
- Build a vault into a room, and fill it with traps, treasure, monsters and guards
- Build vaults into caves too, without cutting the cave apart
- Add the three Amulets of SDL, each in a vault of its own, the last guarded by a dragon
- Keep an Amulet from being lost: no trapdoor on its floor, no secret door in its way, and a question before leaving one behind
There are six checkpoints along the way. Let's start with the plan.
Planning the Chapter
This chapter adds two files and a data file, and changes fifteen, along with the tile atlas and two more data files:
| File | Status | What it's for |
|---|---|---|
src/VaultStamp.h, src/VaultStamp.cpp |
New | Builds a vault into the map, and says where its contents go |
data/vaults.txt |
New | The vaults, drawn as text |
src/Records.h, src/Records.cpp |
Changed | A value of several rows |
src/Data.h, src/Data.cpp |
Changed | The vaults read and checked, and two new names |
src/Map.h, src/Map.cpp |
Changed | Where a way can lead, through doors |
src/CaveGen.cpp |
Changed | A cave's flood fill goes through doors |
src/Level.h |
Changed | Where a floor's vault is, while the floor is made |
src/Common.h |
Changed | The Amulets' floors |
src/Item.h, src/Player.h |
Changed | The Amulet, counted rather than carried |
src/Monster.h |
Changed | A trait for monsters found only in vaults |
src/Game.h, src/Game.cpp |
Changed | F7, vaults built and filled, and the Amulets |
src/SaveGame.cpp |
Changed | The Amulets in a save, which becomes version 4 |
data/items.txt, data/monsters.txt |
Changed | The Amulet, and a young red dragon |
assets/tiles.png, assets/tiles.txt |
Changed | Pictures of the Amulet and the dragon |
| The other files | Unchanged |
The next image shows how the files fit together at the end of the chapter. The new VaultStamp builds a vault into a Map, as RoomGen builds rooms. The vault comes from a VaultDef that Data has read. Where a vault goes, and what fills it, is up to Game.

Building is split from filling for a reason. A vault's walls and doors go into the map as the floor is made, before anything else is placed, so that nothing else lands in it. What's inside it, monsters and treasure, goes in last, once the floor has its own.
How a Vault Is Made
A vault starts as a record in vaults.txt, with a map drawn in characters, a row of them to a line. In the map, a # is wall, a . is floor and a + is the door. Other characters stand for what's inside: $ for treasure, ^ for a trap, m for a monster and G for a guard. The next image shows a vault's record, and the vault it makes.

When the game starts, every vault is read and checked: it must be a size that fits on a floor, use only those characters, be walled all the way around, and have exactly one way in, from which every cell inside can be reached. A floor that gets a vault chooses one for its depth, finds a place where it fits, and builds it. On a floor of rooms, the place is inside a room big enough to have floor all around the vault, so the corridors still lead to its door. In a cave, it's anywhere its door opens onto the cave. Last, the vault is filled.
Vaults in a Data File
The first part reads the vaults, and checks them, but doesn't use them yet. The data files have been able to hold one value to a key since Chapter 16. A vault's map is many lines long, so the records learn a new kind of value first.
A Value of Several Rows
A key's value is the rest of its line. But a map needs a value that goes on, a row at a time, on the lines after its key. Each of those lines starts with a |, and the row is whatever follows it, spaces at its start included:
map =
|#######
|#$.^.$#
The | marks the line as a row. So a row can begin with spaces, or with a #, which would otherwise start a comment. Spaces at the end of a row are trimmed, as at the end of any line, but the vaults' checks put them back. A row must come straight after its key, or after another row, with nothing in between. Open Records.h, and in the comment at the top of the file, find these lines:
// anywhere else is part of the line, since a wall is drawn as one. Every
// mistake is reported as "file:line: what's wrong"
Change them to these:
// anywhere else is part of the line, since a wall is drawn as one. A value
// can take several rows, as a vault's map does: each row is a line of its
// own, after the key, starting with a |. Every mistake is reported as
// "file:line: what's wrong"
A record's reader needs a way to ask for such a value, as a list of rows. Add it below glyph's declaration:
// A value of rows, each from a line starting with |, which begin on
// the line after the key's
std::vector<std::string> rows(const std::string& key);
Open Records.cpp. The reading of a file has to remember the last key it saw, and the line it was on, so that a row knows whose value it carries on. In readRecords, add these below int number = 0;:
// The last key given, and the line of its last row, for the rows that
// carry on its value
std::string lastKey;
int lastLine = 0;
Then add the rows' branch below the check that skips comments and blank lines:
// A row carries on the value of the key just above it, or of the
// row just above it. What follows the | is the row, leading spaces too
if (line.front() == '|')
{
if (lastKey.empty() || number != lastLine + 1)
{
errors.push_back(where + "a | row follows its key, or "
"another row");
continue;
}
std::string& value = records.back().fields[lastKey].value;
std::string row = line.substr(1);
value += value.empty() ? row : "\n" + row;
lastLine = number;
continue;
}
A row is added to the value of the last key, with a newline before it if the value has something in it already. The line number in lastLine makes sure the row comes straight after its key or the row before: a blank line or a comment in between, and the row is reported. The key's own line is usually blank after its =, as map = is, so the first row starts the value.
A new record starts with no last key, so a row can't carry on a key from the record before. Below records.push_back(record);, add this:
lastKey.clear();
And each key read becomes the last key. Add these below record.fields[key] = { value, number };:
lastKey = key;
lastLine = number;
Last, the rows are asked for as a list. Add this below color:
std::vector<std::string> FieldReader::rows(const std::string& key)
{
std::vector<std::string> rows;
const Field* field = find(key);
if (field == nullptr)
{
return rows;
}
std::istringstream lines(field->value);
std::string row;
while (std::getline(lines, row))
{
rows.push_back(row);
}
if (rows.empty())
{
report(field->line, "'" + key + "' needs rows, each on a line "
"starting with |");
}
return rows;
}
In the preceding code, the value is split back into its rows at the newlines, as the file itself is split into lines. The splitting uses a std::istringstream and std::getline. A map key with no rows at all is a mistake, and says how rows are written.
The Vaults' Records
A vault is a kind of content, like a kind of monster, so it gets a definition of its own beside theirs. Open Data.h, and add this below #include "Monster.h":
// A vault: a room made by hand, read from vaults.txt. Its map has a row
// of text for each row of cells, all as wide as the widest:
// # wall . floor + door S secret door ^ trap
// $ treasure m a monster from the floor's band G a guardian
// (a space is outside the vault)
struct VaultDef
{
std::string id;
int minFloor = 1; // the floors it can be on
int maxFloor = 1;
const MonsterDef* guardian = nullptr; // what each G is, if any
std::vector<std::string> rows;
int width() const
{
return rows.empty() ? 0 : static_cast<int>(rows.front().size());
}
int height() const
{
return static_cast<int>(rows.size());
}
};
In the preceding code, a vault has an id, the floors it can be on, and its map, as rows of text. A vault with guards names their kind, by its id in monsters.txt. And guardian points at that kind, as an item points at its kind. The width and height come from the rows, once they've been made all as wide as the widest, which the checks do.
The game's data hands out the vaults as it does the monsters and items. Add this below items()'s declaration:
const std::vector<VaultDef>& vaults() const;
Add the vaults' reading below readItems's declaration:
void readVaults(const std::string& folder);
And keep them below items_:
std::vector<VaultDef> vaults_;
Open Data.cpp. Reading is the easy half, and most of the work is in checking. A vault with a gap in its wall, or no way in, would spoil the floor it's built into. The checks need std::max, so add its header below #include "Data.h":
#include <algorithm> // std::max, for the widest of a vault's rows
Then add the checks' numbers to the unnamed namespace, below NEEDED_ITEMS:
// The symbols of a vault's map, a space included, and its sizes, the
// biggest being about as big as rooms ever have room for
const std::string VAULT_SYMBOLS = "#.+S^$mG ";
constexpr int MIN_VAULT_SIZE = 3;
constexpr int MAX_VAULT_W = 15;
constexpr int MAX_VAULT_H = 9;
The biggest vault is 15 cells by 9. It needs a room at least 17 by 11, to have floor all around it, and a floor of rooms has one of those less than one time in twenty. A space is a symbol too, for a cell outside the vault, so a vault needn't be a rectangle. Add a way to tell whether a cell is outside, below them:
// True if a cell of a vault's map is outside the vault: past its rows,
// or a space
bool outsideVault(const std::vector<std::string>& rows, int x, int y)
{
if (y < 0 || y >= static_cast<int>(rows.size()) || x < 0 ||
x >= static_cast<int>(rows[y].size()))
{
return true;
}
return rows[y][x] == ' ';
}
A cell past the edge of the rows is outside, as a space is. So the checks can look at a cell's neighbors without worrying about where the rows end.
Checking a Vault's Map
The map is checked a piece at a time. Add the first part below outsideVault:
// Checks a vault's map, and makes its rows as wide as the widest, with
// spaces. It must be a size rooms have room for, use only the legend's
// symbols, have G only if there's a guardian, and be closed all around:
// a cell inside it that touches the outside, diagonals too, must be
// wall, but for one door, the way in, which must meet the outside along
// a row or a column. Every cell inside but wall must be reachable from
// the way in. The rows begin on the line after the map's key
void checkVaultMap(FieldReader& fields, VaultDef& def)
{
std::vector<std::string>& rows = def.rows;
if (rows.empty())
{
return; // reported already
}
int mapLine = fields.lineOf("map");
size_t widest = 0;
for (const std::string& row : rows)
{
widest = std::max(widest, row.size());
}
for (std::string& row : rows)
{
row.resize(widest, ' ');
}
In the preceding code, the rows are padded with spaces to the widest row's width. Every row is then the same length, so a vault drawn with a ragged right side still reads as a rectangle of cells. The map's key is on line mapLine, and its rows begin on the line after. So, for the mistakes, row y is on line mapLine + 1 + y. Add the size's check below it:
int w = def.width();
int h = def.height();
if (w < MIN_VAULT_SIZE || h < MIN_VAULT_SIZE || w > MAX_VAULT_W ||
h > MAX_VAULT_H)
{
fields.report(mapLine, "a vault is from 3 x 3 to 15 x 9 cells");
return;
}
Then the cells, one at a time. Add this below it, with a blank line in between:
int ways = 0;
Point wayIn = { 0, 0 };
bool guards = false;
bool open = false; // reported once, at the first open cell
for (int y = 0; y < h; ++y)
{
for (int x = 0; x < w; ++x)
{
char c = rows[y][x];
int line = mapLine + 1 + y;
if (VAULT_SYMBOLS.find(c) == std::string::npos)
{
fields.report(line, "'" + std::string(1, c) +
"' isn't in a vault's legend");
continue;
}
guards = guards || c == 'G';
if (c == ' ' || c == '#')
{
continue;
}
In the preceding code, a character that isn't in the legend is reported on the row's own line, so a typo is easy to find. A wall, or a space, needs nothing more, and the loop goes on to the next cell.
Every other cell is part of the vault's inside, and must not touch the outside, or the vault would have a gap. The one exception is its door. Add the rest of the loop directly below it:
// Next to the outside, diagonals too; and straight next to
// it, as a way in must be. DIRECTIONS' even steps are the
// straight ones
bool edge = false;
bool straight = false;
for (int i = 0; i < 8; ++i)
{
Point next = { x + DIRECTIONS[i].x, y + DIRECTIONS[i].y };
bool out = outsideVault(rows, next.x, next.y);
edge = edge || out;
straight = straight || (out && i % 2 == 0);
}
bool door = c == '+' || c == 'S';
if (edge && door && straight)
{
++ways;
wayIn = { x, y };
}
else if (edge && door)
{
fields.report(line, "the door at column " +
std::to_string(x + 1) +
" meets the outside only at a corner");
}
else if (edge && !open)
{
fields.report(line, "the vault is open to the outside, "
"at column " + std::to_string(x + 1));
open = true;
}
}
}
In the preceding code, a cell is on the edge if any of its eight neighbors is outside, the diagonals too, since a step can go that way. The flag straight says whether one of its four straight neighbors is outside. The steps in DIRECTIONS go clockwise from north, so, as Chapter 19 showed, the even ones are the straight ones. That's why the loop counts through the steps by number, rather than taking each in turn.
A door on the edge is the way in, counted, and its place kept in wayIn, but only if it meets the outside straight, along a row or a column. Later in this chapter, VaultStamp::entrance finds the cell just outside the way in, and it looks only along rows and columns. It would never find a door that meets the outside only at a corner, and in a cave, the vault could be built with that door facing rock. So such a door is reported, with its column, rather than counted.
Anything else on the edge is a gap in the wall, and it's reported once. A wall left out of a long side would otherwise be reported at every cell along it. The next image shows a vault's edge, and why its corners matter.

Add the last of the checks below it:
if (ways != 1)
{
fields.report(mapLine, "a vault has one way in: a + or an S "
"on its edge");
}
else
{
checkVaultReach(fields, rows, wayIn, mapLine);
}
if (guards && !fields.has("guardian"))
{
fields.report(mapLine, "the map has G, but there's no "
"'guardian'");
}
if (!guards && def.guardian != nullptr)
{
fields.report(fields.lineOf("guardian"), "a 'guardian' needs "
"a G on the map");
}
}
A vault must have exactly one way in, a door or a secret door. Two ways in would make it a room with a corridor through it, and none would make it a sealed box. The guard checks go both ways: a G with no guardian named is a mistake, and so is a guardian with no G to stand on.
With one way in, a vault is closed, but it could still be closed off inside. A wall drawn across it would leave a part no one can reach, and the treasure there could never be taken. Later in this chapter, an Amulet there would make the run impossible to win. So checkVaultReach walks the vault from its way in, as Chapter 18 walked a cave. Add it above checkVaultMap's comment, leaving a blank line after it:
// Reports the first cell inside a vault that can't be reached from its
// way in, by steps in any of the eight directions over anything but
// wall. An Amulet walled off there could never be taken
void checkVaultReach(FieldReader& fields,
const std::vector<std::string>& rows, Point wayIn,
int mapLine)
{
int w = static_cast<int>(rows.front().size());
int h = static_cast<int>(rows.size());
std::vector<bool> reached(w * h, false);
reached[wayIn.y * w + wayIn.x] = true;
std::vector<Point> toVisit = { wayIn };
while (!toVisit.empty())
{
Point cell = toVisit.back();
toVisit.pop_back();
for (Point step : DIRECTIONS)
{
Point next = { cell.x + step.x, cell.y + step.y };
bool open = !outsideVault(rows, next.x, next.y) &&
rows[next.y][next.x] != '#';
if (open && !reached[next.y * w + next.x])
{
reached[next.y * w + next.x] = true;
toVisit.push_back(next);
}
}
}
for (int y = 0; y < h; ++y)
{
for (int x = 0; x < w; ++x)
{
bool open = rows[y][x] != ' ' && rows[y][x] != '#';
if (open && !reached[y * w + x])
{
fields.report(mapLine + 1 + y, "the cell at column " +
std::to_string(x + 1) + " can't be "
"reached from the way in");
return;
}
}
}
}
The walk steps in all eight directions, as the player can, over anything but wall, and marks every cell it reaches. Any cell inside the vault that's still unmarked is walled off. The first is reported, with its line and its column, and only the first, since a whole part walled off would otherwise fill the list.
The game's load reads the vaults after the monsters and items. In load, add this below items_.clear();:
vaults_.clear();
And this below readItems(folder);:
readVaults(folder);
The order matters. A vault names its guardian by a monster's id, so the monsters have to be read first, or there'd be nothing to find. Add the vaults' list below findMonster:
const std::vector<VaultDef>& GameData::vaults() const
{
return vaults_;
}
Then add the reading, at the end of the file, below readItems:
// Every [vault] record in vaults.txt, checked, and kept if it has no
// mistakes. A guardian is named by its id, so the monsters are read first
void GameData::readVaults(const std::string& folder)
{
const std::string file = "vaults.txt";
std::map<std::string, int> idLines;
for (const Record& record : readDataFile(folder, file, errors_))
{
FieldReader fields(record, file, errors_);
if (record.type != "vault")
{
fields.report(record.line, "unknown record [" + record.type +
"]");
continue;
}
VaultDef def;
def.id = fields.id("id");
std::vector<int> floors = fields.numbers("floors", 2, 1);
def.minFloor = floors[0];
def.maxFloor = floors[1];
if (def.minFloor > def.maxFloor)
{
fields.report(fields.lineOf("floors"), "'floors' runs backward");
}
In the preceding code, each record is read as a monster's is, with a FieldReader: an id, and a pair of floors, from low to high. Add the rest of the function below it:
if (fields.has("guardian"))
{
std::string id = fields.id("guardian");
def.guardian = findMonster(id);
if (def.guardian == nullptr && !id.empty())
{
fields.report(fields.lineOf("guardian"),
"no monster is called '" + id + "'");
}
}
def.rows = fields.rows("map");
checkVaultMap(fields, def);
checkNewId(fields, def.id, idLines);
if (fields.finish())
{
vaults_.push_back(def);
}
}
}
In the preceding code, a guardian is found by its id. A misspelled one is reported as "no monster is called 'gobbo'", and only that. The map's checks ask whether a guardian was named, and it was. The id must be new, as a monster's must, and a vault with no mistakes is kept.
The Vaults
From the book's repository, copy vaults.txt from the Chapter 20 folder's data folder into your project's data folder. It's the finished chapter's, with three vaults at the end that this part can't read yet. Open it, and delete everything from the comment # The Amulets' vaults, one for each of floors 3, 6 and 9 down to the end of the file.
What's left is six treasure vaults, and a long comment at the top that explains every key and every symbol. It explains the Amulets' too, which come later in this chapter. The closet is seven cells wide and five high, with treasure in its four corners, a trap, and a monster. The secret cache's way in is a secret door, so it has to be found before it can be opened. Two have guards: the guardroom, with goblins, and the barracks, with cave orcs.
Design note: Angband's vaults. Angband keeps its vaults in a text file too,
vault.txt. There are well over a hundred of them, from small "interesting rooms" to greater vaults. Each record has a name, a type, a rating, its size, the depths it can be found at, and its map, a line of symbols to a row. Its legend is longer than ours, and many of its symbols are chances: a+is a secret door, a^a trap a quarter of the time, and a&treasure three times in four. An8is a monster up to 40 levels too deep for its floor, with treasure up to 20 levels too deep.
Our vaults are smaller and gentler than Angband's, built for a dungeon nine floors deep rather than over a hundred. A new vault needs nothing but a record: draw it, give it floors, and start the game.
Checkpoint: Press F5, with seed 42. The game is just as it was: the vaults are read, and checked, but nothing builds them yet. Now break one. Close the game, open vaults.txt, and in the closet's map, set the top row's middle # to ., so that the row reads |###.###. Press F5 again. A box says the game can't start, and why: vaults.txt:32: the vault is open to the outside, at column 4. Put the # back.
A Look on F7
Before a vault goes into a floor, it's worth seeing each one as the game has read it. A new debugging key, F7, shows the vaults one at a time, in the middle of the map view, as F6 shows a cave. Open Game.h, and add the key's function below stepCave's declaration:
void stepVault();
Then add its drawing below drawCaveSteps's declaration:
void drawVaultShown() const;
Which vault is showing is kept in a member, as its place in the list of vaults. Add it below cavesShown_, with a blank line in between:
// The debugging aid on F7: which vault in vaults.txt is showing, if any
std::optional<int> vaultShown_;
Open Game.cpp, and add the key to handleKey, below F6's:
if (key.key == SDLK_F7)
{
// A debugging aid: F7 shows the vaults, one at a time
if (!key.repeat)
{
stepVault();
}
return;
}
Each press shows the next vault, and the press after the last one puts the game back. Add the key's function below stepCave:
// F7 shows each vault in vaults.txt in turn, and then the game again
void Game::stepVault()
{
int count = static_cast<int>(data_.vaults().size());
if (!vaultShown_ && count > 0)
{
vaultShown_ = 0;
}
else if (vaultShown_ && *vaultShown_ + 1 < count)
{
++*vaultShown_;
}
else
{
vaultShown_.reset();
}
redraw_ = true;
}
The member vaultShown_ is empty while the game is showing, and holds a vault's place in the list while that vault is. Nothing in the run changes, so F7 can be pressed at any time. The game carries on as if it hadn't been.
While it's showing, a vault has the map view to itself, as F6's cave has. In draw, add this below the cave's branch, if (caveSteps_):
else if (vaultShown_)
{
// And so does F7's vault
drawVaultShown();
}
Then add the vault's drawing below drawCaveSteps:
// The F7 debugging aid: a vault's map, in the middle of the map view.
// The view draws its walls, floors and doors. Each other symbol is drawn
// as it is, in the debugging color, on a plain cell: a secret door's S on
// the walls' background
void Game::drawVaultShown() const
{
const VaultDef& vault = data_.vaults()[*vaultShown_];
Point corner = { (MAP_W - vault.width()) / 2,
(MAP_H - vault.height()) / 2 };
for (int y = 0; y < vault.height(); ++y)
{
for (int x = 0; x < vault.width(); ++x)
{
char symbol = vault.rows[y][x];
Point cell = { corner.x + x, corner.y + y };
if (symbol == '#')
{
view_->drawTerrain(cell, Terrain::Wall, true);
}
else if (symbol == '.')
{
view_->drawTerrain(cell, Terrain::Floor, true);
}
In the preceding code, corner is where the vault's top left corner goes, so that the vault sits in the middle of the map. Its walls and floors are drawn by the view, lit, as the map's own are. Add the rest of the function below it:
else if (symbol == '+')
{
view_->drawTerrain(cell, Terrain::ClosedDoor, true);
}
else if (symbol != ' ')
{
SDL_FRect where = view_->camera().cellRect(cell);
setColor(symbol == 'S' ? Palette::WALL_BACK
: Palette::BACKGROUND);
SDL_RenderFillRect(renderer_, &where);
float left = where.x + (where.w - glyphs_.charWidth()) / 2;
float top = where.y + (where.h - glyphs_.lineHeight()) / 2;
glyphs_.drawText(std::string(1, symbol), left, top,
Palette::DEBUG);
}
}
}
}
A door is drawn as a closed door. Every other symbol is drawn as itself, in the debugging color, on a plain cell. It stands for something the vault will hold, rather than anything the view knows how to draw. A secret door's S sits on the walls' background, so it still looks like part of the wall.
The side panel says which vault is showing. In drawSidePanel, add these lines below F6's:
if (vaultShown_)
{
const VaultDef& vault = data_.vaults()[*vaultShown_];
int count = static_cast<int>(data_.vaults().size());
y += row;
glyphs_.drawText("DEBUG: vault " + std::to_string(*vaultShown_ + 1) +
" of " + std::to_string(count) + " (F7)", x, y,
Palette::DEBUG);
y += row;
glyphs_.drawText(vault.id + ", floors " +
std::to_string(vault.minFloor) + " to " +
std::to_string(vault.maxFloor), x, y,
Palette::DEBUG);
}
The camera needs telling too. The tile view's cells are bigger than the glyph view's, so it shows only part of the map, the part around the player. A vault in the middle of the map could be off the screen. In run, add this below the look cursor's focus:
if (vaultShown_)
{
// F7's vault is in the middle of the map
focus = { MAP_W / 2, MAP_H / 2 };
}
Checkpoint: Press F5 to start the game, and then, with the game's window in front, press F7. The map view shows the closet, in the middle. The side panel says DEBUG: vault 1 of 6 (F7), and below it, closet, floors 1 to 9. Each press of F7 shows the next vault, and the seventh press puts the game back, just as it was. The next image shows all six.

The pink letters are what each vault will hold: $ for treasure, ^ for a trap, m for a monster and G for a guard. Try the tile view, with F2, and then F7: the walls, floors and doors are tiles, and the letters stay letters.
Vaults in Rooms
A vault goes into a floor in two steps. First its walls, floors and doors go into the map, as soon as the map is made, so nothing else lands where the vault will be. Then, once the floor has its own monsters and items, what's inside the vault is filled in. The first step is a module of its own, VaultStamp, since it's about maps and text, as RoomGen is. The second is Game's, since it needs the dice, the data and the floor's lists.
Stamping a Vault
A stamp presses a picture onto a page, and VaultStamp presses a vault's map onto a floor's. Add a Header File (.h) called VaultStamp.h, and type it in:
#pragma once
#include <vector> // std::vector, for the cells of a vault's contents
#include "Common.h"
#include "Data.h"
#include "Map.h"
// Builds a vault from vaults.txt into the map: its walls, floors and doors.
// What's in it, the game fills in, from the cells the stamp hands back
namespace VaultStamp
{
// The cells of a vault's contents, by what they hold, and what its
// guardians are
struct Contents
{
std::vector<Point> traps;
std::vector<Point> treasure;
std::vector<Point> monsters;
std::vector<Point> guardians;
const MonsterDef* guardian = nullptr;
};
// Builds the vault into the map, its top left corner on corner. A
// space leaves the map as it was. Returns the cells of its contents
Contents stamp(Map& map, const VaultDef& vault, Point corner);
}
In the preceding code, stamp builds the vault, and hands back where its contents go, by kind, in a Contents. A Contents holds a list of cells for each kind of thing, and the guardians' kind of monster too, since that comes from the vault. The game fills them in later, when it's ready to.
Add a C++ File (.cpp) called VaultStamp.cpp, and type in its beginning:
#include "VaultStamp.h"
VaultStamp::Contents VaultStamp::stamp(Map& map, const VaultDef& vault,
Point corner)
{
Contents contents;
contents.guardian = vault.guardian;
for (int y = 0; y < vault.height(); ++y)
{
for (int x = 0; x < vault.width(); ++x)
{
char symbol = vault.rows[y][x];
Point cell = { corner.x + x, corner.y + y };
if (symbol == ' ')
{
continue;
}
if (symbol == '#')
{
map.set(cell.x, cell.y, Terrain::Wall);
continue;
}
In the preceding code, the vault's map is read a cell at a time, and each of its cells sets the cell of the floor's map it lands on. A space leaves the floor as it was, which is how a vault that isn't a rectangle keeps the room around its corners. And a wall is a wall. Add the doors below it:
if (symbol == '+')
{
map.set(cell.x, cell.y, Terrain::ClosedDoor);
continue;
}
if (symbol == 'S')
{
map.set(cell.x, cell.y, Terrain::SecretDoor);
continue;
}
A vault's door is always closed, at first, and a secret door is one of Chapter 19's. Add the rest of the function below them:
// Everything else stands on a floor of worked stone, in a cave
// too
map.set(cell.x, cell.y, Terrain::Floor);
if (symbol == '^')
{
contents.traps.push_back(cell);
}
else if (symbol == '$')
{
contents.treasure.push_back(cell);
}
else if (symbol == 'm')
{
contents.monsters.push_back(cell);
}
else if (symbol == 'G')
{
contents.guardians.push_back(cell);
}
}
}
return contents;
}
In the preceding code, every other symbol is a floor, with something to come on it. So its cell goes on the list for its kind. The floor is a room's worked stone even in a cave, so a vault always looks built.
Where a Vault Goes
A vault goes in a room, but not just anywhere in it. With a cell of floor all around it, the room is still a room. The corridors meet it at its doorways, as they always did. The floor around the vault leads from each doorway to the vault's door. The next image shows a vault in a room.

The first room is never used, since that's where the player starts. Open Game.h, and add the new header below #include "TileView.h":
#include "VaultStamp.h"
Then add these below placeTraps's declaration:
TrapKind chooseTrapKind();
std::optional<VaultStamp::Contents> buildVault(
std::vector<SDL_Rect>& areas);
std::optional<Point> roomForVault(const VaultDef& vault,
const std::vector<SDL_Rect>& rooms);
void fillVault(const VaultStamp::Contents& contents);
bool inVault(Point cell) const;
The function buildVault chooses a vault, finds it a room with roomForVault, and stamps it. Then fillVault fills it, and inVault says whether a cell is inside it. The last of them, chooseTrapKind, is the dice for a trap's kind, which the vault's traps need as well as the floor's.
While the floor is made, it has to know where its vault is, so that nothing else is put in it. Open Level.h, and find this line:
#include <vector> // std::vector, for the monsters and the items
Change it to these:
#include <SDL3/SDL.h>
#include <optional> // std::optional, for a vault, which a floor may have
#include <vector> // std::vector, for the monsters and the items
Then add the vault below Point stairs;, with a blank line in between:
// Where the floor's vault is, if it has one, so nothing else is put
// in it while the floor is made. It isn't saved: by then, it's done
// its job
std::optional<SDL_Rect> vault;
The vault's place is a rectangle, an SDL_Rect, as a room's is. It's kept in a std::optional, since most floors don't have one. It isn't saved. Once the floor is made, the vault is walls and doors and things on the floor like any others, and nothing needs to know it was a vault.
Open Game.cpp. Finding a room is a matter of size. Add this below descend:
// Room for a vault on a floor of rooms: a room, but not the first, which
// is the player's, big enough to have floor all around the vault. The
// corridors still meet the room at its doorways, and the floor around the
// vault leads them to its door. Nothing, if no room is big enough
std::optional<Point> Game::roomForVault(const VaultDef& vault,
const std::vector<SDL_Rect>& rooms)
{
std::vector<const SDL_Rect*> roomy;
for (size_t i = 1; i < rooms.size(); ++i)
{
bool fits = rooms[i].w >= vault.width() + 2 &&
rooms[i].h >= vault.height() + 2;
if (fits)
{
roomy.push_back(&rooms[i]);
}
}
Every room after the first that's at least two cells wider and two taller than the vault goes on a list, roomy. Add the rest of the function below it:
if (roomy.empty())
{
return std::nullopt;
}
const SDL_Rect& room =
*roomy[rng_.roll(0, static_cast<int>(roomy.size()) - 1)];
return Point{ room.x + 1 + rng_.roll(0, room.w - 2 - vault.width()),
room.y + 1 + rng_.roll(0, room.h - 2 - vault.height()) };
}
One of the rooms on the list is chosen at random, and then a place in it. The place can be anywhere that leaves at least a cell of floor on every side. With no room on the list, there's no place, and std::nullopt says so.
Building and Filling
The vault's traps need a kind, chosen as the floor's traps' kinds are. That choice is inside placeTraps, so it moves out into a function of its own. In placeTraps, find these lines, and delete them:
int lastKind = static_cast<int>(std::size(TRAP_DEFS)) - 1;
if (level_.depth == LAST_FLOOR)
{
--lastKind;
}
They come back as the start of the new function. Add it below descend:
// A kind of trap, each as likely as the others, but for a trapdoor on the
// last floor, where there's nowhere to fall to. It's last in TRAP_DEFS, so
// the roll leaves it out there
TrapKind Game::chooseTrapKind()
{
int lastKind = static_cast<int>(std::size(TRAP_DEFS)) - 1;
if (level_.depth == LAST_FLOOR)
{
--lastKind;
}
return TRAP_DEFS[rng_.roll(0, lastKind)].kind;
}
Now placeTraps can use it. In placeTraps, change TRAP_DEFS[rng_.roll(0, lastKind)].kind to chooseTrapKind(). The comment above placeTraps says why a trapdoor is left out, and that's now chooseTrapKind's to say. It also needs to say that the vault is left out, which comes next. Replace the comment above placeTraps with this:
// Hides the floor's traps: TRAPS_BASE, and one more for every two floors
// down. Each goes on the ground in an area, but never in the first, where
// the player starts, nor on the stairs, an item, another trap, or in the
// vault, which has traps of its own
Choosing the vault comes next. Add buildVault below chooseTrapKind:
// Builds a treasure vault, if one of those for this floor has room: they
// take their turns in a random order, and the first that fits goes in a
// room. Returns the cells of what's in the vault, for fillVault, once the
// floor has its other monsters and items
std::optional<VaultStamp::Contents> Game::buildVault(
std::vector<SDL_Rect>& areas)
{
std::vector<const VaultDef*> vaults;
for (const VaultDef& vault : data_.vaults())
{
if (vault.minFloor <= level_.depth && level_.depth <= vault.maxFloor)
{
vaults.push_back(&vault);
}
}
for (int i = static_cast<int>(vaults.size()) - 1; i > 0; --i)
{
std::swap(vaults[i], vaults[rng_.roll(0, i)]);
}
The vaults that can be on this floor are gathered into a list, and shuffled, as Chapter 19 shuffled the doors. Add the rest of the function below it, with a blank line in between:
for (const VaultDef* vault : vaults)
{
std::optional<Point> corner = roomForVault(*vault, areas);
if (!corner)
{
continue;
}
VaultStamp::Contents contents =
VaultStamp::stamp(level_.map, *vault, *corner);
level_.vault = SDL_Rect{ corner->x, corner->y, vault->width(),
vault->height() };
return contents;
}
return std::nullopt;
}
Each vault is tried in turn, until one finds a room. The vault's rectangle goes into level_.vault. The areas are passed by reference without const. Later in this chapter, a cave's areas will be worked out again once its vault is built. With no room for any of them, the floor goes without a vault, and std::nullopt says so.
Filling the vault puts something on each of its cells. Add this below roomForVault:
// Fills a vault: a hidden trap on each ^, an item on each $, a monster
// from the floor's band on each m, and its guardian on each G. The
// guardians are a pack of their own, so they wake together, and all start
// asleep or all awake
void Game::fillVault(const VaultStamp::Contents& contents)
{
for (Point cell : contents.traps)
{
level_.traps.push_back({ cell, chooseTrapKind(), false });
}
for (Point cell : contents.treasure)
{
FloorItem treasure;
treasure.pos = cell;
treasure.item.def = chooseItemKind();
level_.items.push_back(treasure);
}
for (Point cell : contents.monsters)
{
const MonsterDef* kind = chooseMonsterKind();
bool awake = kind->has(Trait::NEVER_SLEEPS) ||
rng_.roll(1, AWAKE_ONE_IN) == 1;
addMonster(*kind, cell, 0, awake);
}
In the preceding code, a trap is hidden, as the floor's traps are. The treasure is an item chosen as any item on the floor is. A monster is chosen from the floor's band, and starts awake or asleep as any of them would. Add the rest of the function below it:
if (contents.guardians.empty())
{
return;
}
int packId = 1;
for (const Monster& monster : level_.monsters)
{
packId = SDL_max(packId, monster.packId + 1);
}
const MonsterDef& guardian = *contents.guardian;
bool awake = guardian.has(Trait::NEVER_SLEEPS) ||
rng_.roll(1, AWAKE_ONE_IN) == 1;
for (Point cell : contents.guardians)
{
addMonster(guardian, cell, packId, awake);
}
}
The guardians are a pack of their own, as Chapter 15's packs are. Their pack number is one higher than any on the floor. A pack wakes together, so the noise that wakes one guard wakes them all. And they start all asleep or all awake, from one roll for the lot.
Last, a cell is in the vault if it's in the vault's rectangle. Add this below fillVault:
// True if a cell is inside the floor's vault
bool Game::inVault(Point cell) const
{
return inside(cell, level_.vault);
}
It leaves the test itself to a helper, inside, which the next section adds.
Keeping the Rest Out
The rectangle test is useful outside Game too, for the helpers in the unnamed namespace. Add this to it, below isNextTo:
// True if a cell is inside an area, if there is one
bool inside(Point cell, const std::optional<SDL_Rect>& area)
{
return area && cell.x >= area->x && cell.x < area->x + area->w &&
cell.y >= area->y && cell.y < area->y + area->h;
}
A cell is never inside an area that isn't there, so the floors without a vault need no test of their own.
Two of the helpers choose cells by walking: where the player starts, and where the stairs go. The stairs go on the cell farthest from the player, and a vault's inside is ground, through its door. So the farthest cell could be in the vault. The stairs mustn't be. In the comment above farthestFrom, find these lines:
// The cell of ground farthest from start by walking, where the stairs
// down go. A doorway isn't ground for this
Change them to these:
// The cell of ground farthest from start by walking, where the stairs
// down go. A doorway isn't ground for this, nor a cell in the area to
// avoid, a vault's
Then find this line:
Point farthestFrom(const Map& map, Point start)
Change it to these:
Point farthestFrom(const Map& map, Point start,
const std::optional<SDL_Rect>& avoid = std::nullopt)
Now, in farthestFrom, find this line:
bool ground = map.isWalkable(x, y) && !map.isDoor(x, y);
Change it to these:
bool ground = map.isWalkable(x, y) &&
!map.isDoor(x, y) && !inside({ x, y }, avoid);
The area to avoid is a default argument. So F6's cave, which has no vault, still calls farthestFrom as it did. Then, in placeStairs, change farthestFrom(level_.map, player_.pos) to farthestFrom(level_.map, player_.pos, level_.vault).
The player's start gets the same treatment. Once vaults go into caves, later in this chapter, the first area could hold one. In the comment above startIn, find these lines:
// Where the player starts in an area: the walkable cell nearest its
// middle. In a room, that's the middle itself
Change them to these:
// Where the player starts in an area: the walkable cell nearest its
// middle, but not in the area to avoid, a vault's. In a room, that's
// the middle itself
Then find this line:
Point startIn(const Map& map, const SDL_Rect& area)
Change it to these:
Point startIn(const Map& map, const SDL_Rect& area,
const std::optional<SDL_Rect>& avoid = std::nullopt)
And in startIn, find this line:
if (map.isWalkable(x, y) && nearer)
Change it to these:
bool allowed = map.isWalkable(x, y) &&
!inside({ x, y }, avoid);
if (allowed && nearer)
The rest of the floor's things are placed at random, and each must stay out of the vault, which has things of its own. In placeTraps, find this line:
floorItemAt(pos) == nullptr && trapAt(pos) == nullptr;
Change it to these:
floorItemAt(pos) == nullptr &&
trapAt(pos) == nullptr && !inVault(pos);
Gold and items are placed with the same test as each other, so the same two lines are in both placeGold and placeItems. The lines after them tell the two apart. In placeGold, find these lines:
bool free = level_.map.isWalkable(pos.x, pos.y) &&
pos != player_.pos && floorItemAt(pos) == nullptr;
if (free)
{
FloorItem pile;
Change them to these:
bool free = level_.map.isWalkable(pos.x, pos.y) &&
pos != player_.pos && floorItemAt(pos) == nullptr &&
!inVault(pos);
if (free)
{
FloorItem pile;
That leaves the two lines in placeItems as the only ones of their kind, so in placeItems, find these lines:
bool free = level_.map.isWalkable(pos.x, pos.y) &&
pos != player_.pos && floorItemAt(pos) == nullptr;
And change them to the same three lines:
bool free = level_.map.isWalkable(pos.x, pos.y) &&
pos != player_.pos && floorItemAt(pos) == nullptr &&
!inVault(pos);
Monsters are placed by freeCellIn. Replace the comment above freeCellIn with this:
// A cell of ground in the area, with no monster on it and not in the
// vault, chosen at random, or nothing if a few tries find none
Then, in freeCellIn, find this line:
if (level_.map.isWalkable(cell.x, cell.y) && monsterAt(cell) == nullptr)
Change it to these:
bool free = level_.map.isWalkable(cell.x, cell.y) &&
monsterAt(cell) == nullptr && !inVault(cell);
if (free)
Now the monsters, the items, the gold, the traps, the stairs and the player's start all stay out of the vault.
A Vault on One Floor in Three
How often a floor has a vault is a number to tune. Add it to the unnamed namespace, below CAVE_MONSTERS_PERCENT:
// One floor in VAULT_ONE_IN has a treasure vault (*tune*)
constexpr int VAULT_ONE_IN = 3;
Last, newLevel builds the vault, and fills it. Replace the comment above newLevel with this:
// Makes the floor at a depth, in place of the one before: rooms, or a
// cave, one time in VAULT_ONE_IN a vault, the player in the middle of the
// first area, its monsters, items and gold, what's in the vault, its
// stairs down, unless it's the last floor, and its secrets: secret doors,
// and traps. Both generators give areas to put things in: rooms, or the
// squares of a grid over the cave, which can have walls in them
Then, in newLevel, find this line:
player_.pos = startIn(level_.map, areas.front());
Change it to these:
std::optional<VaultStamp::Contents> inVaultToFill;
if (!isCaveFloor(depth) && rng_.roll(1, VAULT_ONE_IN) == 1)
{
inVaultToFill = buildVault(areas);
}
player_.pos = startIn(level_.map, areas.front(), level_.vault);
And add the filling below placeGold(areas);:
if (inVaultToFill)
{
fillVault(*inVaultToFill);
}
Each floor of rooms rolls for a vault as soon as its rooms are made, and builds one if it rolls a 1 and a vault fits. A cave floor doesn't, yet. The floor's own monsters, items and gold go down next, kept out of the vault by inVault. Then the vault is filled. Filling it after the monsters gives its guards a pack number higher than any of theirs, so the guards never share a pack with the floor's own monsters.
Design note: Rogue's treasure rooms. Rogue has vaults of a sort. One floor in twenty has a treasure room: an ordinary room, chosen at random, with two to nine things lying in it. It also has at least two more monsters than things, all of them from the floor below. Rogue draws nothing by hand, so its treasure room looks like any other room until you're in it, and then it's too late.
Ours give fair warning, since a vault's walls stand in the middle of a room, where no walls should be. What's behind them is the player's choice to make.
Checkpoint: Type 1 into Command Arguments, and press F5. Press F1 to see the whole floor. In the room below and to the right of yours, the closet stands in the middle of the floor, with a jackal inside it, an item in each corner, and its door, closed, in its bottom wall. F1 shows its trap too, between the top two items, but without F1, the trap is hidden until it's found. The next image shows the closet.

The other jackals, around the closet, are the floor's own, kept out of the closet by inVault. Seed 42's first floor has no vault, which is why the seed changed. With one floor in three, two seeds in three have no vault on their first floor.
Vaults in Caves
A cave has no rooms to put a vault in, but it has plenty of room. Almost anywhere in a cave will do for a vault, so long as its door opens onto the cave's floor. The catch is its walls. They're solid. Dropped into a cave, they can cut it in two, or cut off a pocket of it that can no longer be reached.
Chapter 18 already has the cure for pockets: its flood fill keeps the biggest cave, and fills in every pocket with rock. Run again after the vault is built, it fixes whatever the walls cut off. But the fill has to know that the vault is part of the cave, through its door, or it will fill the vault in too.
Through Doors
The flood fill spreads over cells that can be walked on. A closed door can't be, until it's opened. To the fill, a vault behind its closed door is a pocket. It needs a test of its own, for a cell that a way can lead through. Open Map.h, and add this below isDoor's declaration:
// True for terrain a way can lead through, once a door is opened or
// found: the ground, and every kind of door
bool isPassable(int x, int y) const;
Open Map.cpp, and add it below isWalkable:
bool Map::isPassable(int x, int y) const
{
return isWalkable(x, y) || isDoor(x, y) ||
at(x, y) == Terrain::SecretDoor;
}
Cells are passable if they can be walked on, or if they're any kind of door. A secret door is a wall until it's found, but a way still leads through it.
Open CaveGen.cpp. The flood fill uses isWalkable in three places, and all three become isPassable. Replace the comment above keepBiggestCave with this:
// A flood fill from each floor cell that isn't in a cave yet finds the
// cave it's in: the fill spreads to every neighbor a way can lead through,
// the diagonals too, since a step can go that way, and through doors, so
// a vault is part of the cave it opens onto. Every cell it reaches is in
// the same cave. Then every cell outside the biggest cave becomes wall
In keepBiggestCave, change !map.isWalkable(x, y) to !map.isPassable(x, y), where each fill starts. Next, change map.isWalkable(next.x, next.y) to map.isPassable(next.x, next.y), where a fill spreads. Last, change map.isWalkable(x, y) && caveOf to map.isPassable(x, y) && caveOf, where every cell outside the biggest cave becomes wall.
The fourth isWalkable in the file, in areasOf, stays as it is. An area is for putting things on, and things go on the ground, never on a door.
The Way In
In a cave, the vault's door has to open onto the cave's floor. So the game has to know where the door is, and VaultStamp can find it. Open VaultStamp.h, and add this below the Contents struct:
// The cell just outside the vault's way in, from its top left corner.
// It can be outside the vault's map, as (-1, 3) is
Point entrance(const VaultDef& vault);
Open VaultStamp.cpp, and add this below its include, with a blank line in between:
namespace
{
// True if a cell of the vault's map is outside it: past its rows, or a
// space
bool outside(const VaultDef& vault, int x, int y)
{
if (y < 0 || y >= vault.height() || x < 0 || x >= vault.width())
{
return true;
}
return vault.rows[y][x] == ' ';
}
}
The function outside is the same test as outsideVault in Data.cpp. Only that file can see it, so VaultStamp has its own. Add the way in below it:
// The way in is the door on the vault's edge, and the cell outside it is
// the one of its four straight neighbors that's outside the vault. The
// data's checks make sure there's exactly one
Point VaultStamp::entrance(const VaultDef& vault)
{
for (int y = 0; y < vault.height(); ++y)
{
for (int x = 0; x < vault.width(); ++x)
{
char symbol = vault.rows[y][x];
if (symbol != '+' && symbol != 'S')
{
continue;
}
The vault's map is searched for its doors, a + or an S, and every other cell is passed over. Add the rest of the function below it:
for (int i = 0; i < 8; i += 2)
{
// DIRECTIONS' even steps are the straight ones
Point next = { x + DIRECTIONS[i].x, y + DIRECTIONS[i].y };
if (outside(vault, next.x, next.y))
{
return next;
}
}
}
}
return { 0, 0 };
}
Each door is tried, and the first with a straight neighbor outside the vault is the way in. The cell handed back is that neighbor, counted from the vault's top left corner. For a vault with its door in its left wall, that's a column to the left of the corner, outside the vault's own map. That's what the (-1, 3) in the comment means.
The checks in checkVaultMap, earlier in this chapter, count a door as the way in only if it meets the outside in just this way, along a row or a column. So there's exactly one way in to find, and it's the door they counted.
Room in a Cave
Now the game can look for a place in a cave. Open Game.h, and add this below roomForVault's declaration:
std::optional<Point> caveForVault(const VaultDef& vault);
Open Game.cpp, and add the first part of it below roomForVault:
// A place for a vault in a cave: inside the map's edge, with the cell
// outside its way in on the cave's floor. The cave must still hold
// together once the vault is built: the flood fill afterward mustn't take
// the vault away, nor leave the cave too small. So each place is tried out
// on a copy of the map. Nothing, if a few tries find none
std::optional<Point> Game::caveForVault(const VaultDef& vault)
{
Point entrance = VaultStamp::entrance(vault);
for (int tries = 0; tries < 50; ++tries)
{
Point corner = { rng_.roll(1, MAP_W - 1 - vault.width()),
rng_.roll(1, MAP_H - 1 - vault.height()) };
Point outside = { corner.x + entrance.x, corner.y + entrance.y };
if (level_.map.at(outside.x, outside.y) != Terrain::CaveFloor)
{
continue;
}
A place is chosen at random, anywhere the vault fits inside the map's edge. It's no good unless the cell outside the vault's way in is the cave's floor: anywhere else, the door would open onto rock. Add the rest of the function below it:
Map trial = level_.map;
VaultStamp::stamp(trial, vault, corner);
int cells = CaveGen::keepBiggestCave(trial);
bool whole = trial.isWalkable(outside.x, outside.y) &&
cells >= CaveGen::MIN_CAVE_CELLS &&
CaveGen::areasOf(trial).size() >= 2;
if (whole)
{
return corner;
}
}
return std::nullopt;
}
Each place is tried out first, on a copy of the map, trial. The vault is stamped into the copy, and the flood fill is run on it, as it will be for real. Then three things must hold.
The cell outside the door must still be floor, so the vault is part of the biggest cave, and wasn't filled in. The cave must still have at least MIN_CAVE_CELLS, Chapter 18's smallest, and at least two areas, as Chapter 18 asks of every cave. After fifty tries without one, this vault doesn't go in this cave.
The next image shows what the flood fill does once a vault is built. The vault's walls can cut a pocket off from the rest of the cave, and the fill turns the pocket to rock. But it reaches the vault through its door, and keeps it.

Now buildVault chooses between rooms and caves. Replace the comment above buildVault with this:
// Builds a treasure vault, if one of those for this floor has room: they
// take their turns in a random order. On a floor of rooms, it goes in a
// room; in a cave, the cave is flood filled again afterward, in case the
// vault's walls cut part of it off, and its areas are worked out anew.
// Returns the cells of what's in the vault, for fillVault, once the floor
// has its other monsters and items
Then, in buildVault, find these lines:
for (const VaultDef* vault : vaults)
{
std::optional<Point> corner = roomForVault(*vault, areas);
Change them to these:
bool cave = isCaveFloor(level_.depth);
for (const VaultDef* vault : vaults)
{
std::optional<Point> corner = cave ? caveForVault(*vault)
: roomForVault(*vault, areas);
The cave is flood filled again once the vault is built, for real this time. Its areas are worked out again too, since the vault has taken some of their cells. This is why buildVault takes the areas by reference. Add these lines below the two that set level_.vault:
if (cave)
{
CaveGen::keepBiggestCave(level_.map);
areas = CaveGen::areasOf(level_.map);
}
Last, a cave floor can roll for a vault now. In newLevel, change !isCaveFloor(depth) && rng_.roll to rng_.roll.
Checkpoint: Floors 4 and 7 are the caves. To see one without the walk down, set the constructor's newLevel(1) to newLevel(4) for a moment, as in Chapter 18. Type 7 into Command Arguments, press F5, and press F1 to see the whole floor. The guardroom stands in the cave a few steps below you, with its door in its bottom wall, and two goblins inside, its guards. The next image shows it.

That's the tile view, with F2, where the difference shows best. The vault's floor is a room's dark worked stone, in the middle of the cave's brown earth. Set newLevel(4) back to newLevel(1) when you're done.
The Amulets of SDL
The game is called Amulets of SDL, and so far it has none. This part adds them: three Amulets, one on each of floors 3, 6 and 9. Each is in a vault of its own, drawn for it, with guards. Finding all three is the goal of a run. Winning, the moment the third is picked up, comes in Chapter 26, along with the victory screen.
An Amulet's vault isn't a treasure vault that happens to hold an Amulet. It's a different kind, marked in its record, and every Amulet's floor must have one. The last of them is a dragon's lair, and the dragon is a new monster, found nowhere but there.
The Amulet
Which floors have an Amulet is a rule of the game, so it goes with the other rules in Common.h. Open it, and add these below LAST_FLOOR:
// The floors with an Amulet of SDL, each in a vault: the goal of the run
// is all three
constexpr int AMULET_FLOORS[] = { 3, 6, 9 };
constexpr int AMULETS = 3;
The Amulet is an item, of a class of its own. Open Item.h, and add the class below Gold,:
Amulet, // counted, as gold is, not carried in the pack
As its comment says, the Amulet is counted, as gold is, rather than carried. It takes no room in the pack, and can't be dropped, or used, or lost along the way.
Open Data.cpp. The class has a name in items.txt, as the others do, so add it below { "gold", ItemClass::Gold },:
{ "amulet", ItemClass::Amulet },
The game's own code will ask for the Amulet by its id, as it asks for gold, so items.txt must have one. Find these lines:
// The kinds of item the game's own code asks for by id, which
// items.txt must have: the player's first weapon, and gold
const char* const NEEDED_ITEMS[] = { "short_sword", "gold" };
Change them to these:
// The kinds of item the game's own code asks for by id, which
// items.txt must have: the player's first weapon, gold, and the Amulet
const char* const NEEDED_ITEMS[] = { "short_sword", "gold", "amulet" };
Now the record. Open data/items.txt. Its comment at the top lists the classes, and the items the game needs. Both lists grow. Change the first four lines to these:
# The items of Amulets of SDL: one record for each kind. The record's type
# is the item's class: [potion], [scroll], [weapon], [armor], [gold] or
# [amulet]. The game reads this file when it starts, so a change here needs
# no recompiling: save the file, and start the game again.
Change the paragraph that starts # The game needs the short_sword to this:
# The game needs the short_sword, the player's first weapon; gold, which
# lies in piles of its own; and the amulet. Gold and the amulet are never
# chosen as items. There can be up to 10 kinds of potion, one for each
# color.
Then add the Amulet's record at the end of the file, below gold's, with a blank line in between:
# The Amulet of SDL: " in the glyph view. There are three in a run, one in
# a vault on each of floors 3, 6 and 9, and finding all three is the goal
[amulet]
id = amulet
name = Amulet of SDL
tile = amulet
frequency = 0
A frequency of 0 means the Amulet is never chosen as an item, so the only Amulets are the ones the vaults put down. The Amulet and the dragon to come need pictures. From the book's repository, copy tiles.png and tiles.txt from the Chapter 20 folder's assets folder into your project's assets folder, in place of the old ones. They add two tiles: amulet and young-red-dragon.
Counting the Amulets
The player counts the Amulets as they count gold. Open Player.h, and replace the comment above int gold = 0; with this:
// The gold the player has picked up, and the Amulets. Neither is
// carried in the pack
Then add the count below int gold = 0;:
int amulets = 0;
Open Game.cpp. Every Amulet has the same name, and there are only three. So it's "the Amulet of SDL", never "an". In itemName, add this below gold's branch:
if (def.itemClass == ItemClass::Amulet)
{
// There are only three, and each is named
return "the " + def.name;
}
An Amulet in the glyph view is a gold ", the sign for an amulet in many roguelikes. In itemLook, add this below gold's branch:
if (def.itemClass == ItemClass::Amulet)
{
// The tile is silver: gilded with a tint, it's the Amulet of SDL
return { '"', Palette::GOLD, def.tile, true };
}
The last value, true, has the tile tinted with the color, as a potion's tile is. The picture is silver, and the tint gilds it, so it's gold in the tile view too.
Picking an Amulet up counts it. In pickUp, add a branch for it below gold's:
else if (here->item.def->itemClass == ItemClass::Amulet)
{
++player_.amulets;
}
And the log says how many the player has now. Add this below log_.add("You pick up " + itemName(here->item) + ".");:
if (here->item.def->itemClass == ItemClass::Amulet)
{
log_.add(player_.amulets == AMULETS
? "You have all three Amulets!"
: "That's " + std::to_string(player_.amulets) + " of the " +
std::to_string(AMULETS) + " Amulets.");
}
The conditional operator, ? :, chooses between the two messages: "You have all three Amulets!" for the third, and "That's 1 of the 3 Amulets." for the others. The count goes in the side panel too, below the gold. In drawSidePanel, add these lines below the gold's:
glyphs_.drawText("Amulets " + std::to_string(player_.amulets) + " of " +
std::to_string(AMULETS), x, y, Palette::GOLD);
y += row;
The Amulets share gold's color in the panel, as they share the way they're counted.
Monsters Only for Vaults
The dragon is the last Amulet's guardian. It mustn't turn up anywhere else, so it needs a trait that keeps it out of the floors' own monsters. Open Monster.h, and add this below OPENS_DOORS, with a blank line in between:
// It's never found at random, only in the vaults that ask for it
constexpr unsigned int VAULT_ONLY = 1 << 9;
Back in Data.cpp, add the trait's name below { "opens_doors", Trait::OPENS_DOORS },:
{ "vault_only", Trait::VAULT_ONLY },
Chapter 16's check that every floor has a kind of monster must leave out the kinds found only in vaults. A floor whose only kind is a vault's would have no monsters of its own. In readMonsters, find these lines:
// Every floor needs a kind of monster to fill it. A kind with a
// mistake is left out, which could leave a floor empty for no real
// reason, so this waits until the records are all good
Change them to these:
// Every floor needs a kind of monster found at random to fill it. A
// kind with a mistake is left out, which could leave a floor empty for
// no real reason, so this waits until the records are all good
Then find this line:
found = found || (def.minFloor <= floor && floor <= def.maxFloor);
Change it to these:
bool atRandom = !def.has(Trait::VAULT_ONLY);
found = found || (atRandom && def.minFloor <= floor &&
floor <= def.maxFloor);
The game's own choice of monster has to leave them out too. In Game.cpp, in chooseMonsterKind, find this line:
if (def.minFloor <= floor && floor <= def.maxFloor)
Change it to these:
bool here = def.minFloor <= floor && floor <= def.maxFloor;
if (here && !def.has(Trait::VAULT_ONLY))
Without that, the dragon could be one of floor 9's monsters. It could also be one of floor 6's, one time in DEEPER_ONE_IN, from three floors down. Now the dragon itself. Open data/monsters.txt, and add the new trait to the list in the comment at the top, on a line of its own below the one that ends with opens_doors:
# vault_only never found at random, only in vaults
Then add the dragon's record at the end of the file, with a blank line in between:
# The vaults' own: never found at random (vault_only), but guarding a vault
[monster]
id = young_red_dragon
name = young red dragon
glyph = D
color = 230 75 50
tile = young-red-dragon
floors = 9 9
hp = 50
damage = 6 12
defense = 5
sight = 9
traits = never_flees vault_only
The young red dragon has nearly twice an ogre's health, hits harder than anything else in the game, and never flees. It's young, by a dragon's standards. In Chapter 21, it learns to breathe fire.
The Amulets' Vaults
A vault's record says whether it's an Amulet's. Open Data.h, and replace the comment above struct VaultDef with this:
// A vault: a room made by hand, read from vaults.txt. Its map has a row
// of text for each row of cells, all as wide as the widest:
// # wall . floor + door S secret door ^ trap
// $ treasure m a monster from the floor's band G a guardian
// A the Amulet, in an Amulet's vault (a space is outside the vault)
Then add the mark below int maxFloor = 1;:
bool amulet = false; // an Amulet's, not treasure
Open Data.cpp. The map has a new symbol, A, for the Amulet. In the unnamed namespace, change "#.+S^$mG " to "#.+S^$mGA ".
An Amulet's vault must have exactly one A, and a treasure vault none, so the map's checks count them. In checkVaultMap, add this below Point wayIn = { 0, 0 };:
int amulets = 0;
Then add the count below guards = guards || c == 'G';:
if (c == 'A')
{
++amulets;
}
And add the checks at the end of the function, below the two about the guardian:
if (def.amulet && amulets != 1)
{
fields.report(mapLine, "an Amulet's vault has one A");
}
if (!def.amulet && amulets > 0)
{
fields.report(mapLine, "only an Amulet's vault, with 'amulet = "
"yes', has an A");
}
Reading a vault, the mark is a yes or no, left out for no. Replace the comment above readVaults with this:
// Every [vault] record in vaults.txt, checked, and kept if it has no
// mistakes. A guardian is named by its id, so the monsters are read first.
// Each floor with an Amulet needs a vault for it
In readVaults, add this below const std::string file = "vaults.txt";:
size_t errorsBefore = errors_.size();
Then add the mark's reading below the check that floors doesn't run backward:
def.amulet = fields.yesNo("amulet");
Last, an Amulet's floor without an Amulet's vault would leave the run with no way to win, so that's a mistake in the data. Add this at the end of readVaults, below the loop over its records, with a blank line in between:
// Each Amulet's floor needs an Amulet's vault, once the records are
// all good
if (errors_.size() > errorsBefore)
{
return;
}
for (int floor : AMULET_FLOORS)
{
bool found = false;
for (const VaultDef& vault : vaults_)
{
found = found || (vault.amulet && vault.minFloor <= floor &&
floor <= vault.maxFloor);
}
if (!found)
{
errors_.push_back(file + ": floor " + std::to_string(floor) +
" has no Amulet's vault");
}
}
In the preceding code, the check waits until the records are all good, as Chapter 16's check of the monsters' floors does. A vault with a mistake in it is left out, and its floor would be reported as having none. That isn't the real mistake.
From the book's repository, copy vaults.txt from the Chapter 20 folder's data folder into your project's data folder again, the whole file this time. It has the three Amulets' vaults at the end, which you deleted when you first copied it.
The goblin warband's hall is on floor 3: four goblins at the corners of a hall, around an inner room that holds the Amulet. The orc hold, on floor 6, has four cave orcs, all along its top. Each of those has two doors to get through, an outer and an inner. The dragon's lair, on floor 9, is one open room: the dragon in the middle, the Amulet just below it, and treasure all around. The next image shows all three, on F7.

Each has amulet = yes, and floors is the one floor it's for: 3 3, 6 6 and 9 9. A second vault for a floor would be fine, and the game would choose between them, as it does between treasure vaults.
The Stamp's Amulet
The stamp hands back where the Amulet goes, if the vault has one. Open VaultStamp.h, and find this line:
#include <vector> // std::vector, for the cells of a vault's contents
Change it to these:
#include <optional> // std::optional, for the Amulet, if it has one
#include <vector> // std::vector, for the cells of a vault's contents
Then add the Amulet's cell to Contents, below const MonsterDef* guardian = nullptr;:
std::optional<Point> amulet;
Open VaultStamp.cpp, and in stamp, add this below the guardians' branch:
else if (symbol == 'A')
{
contents.amulet = cell;
}
An Amulet's vault has exactly one A, as the checks make sure, so one cell is all Contents needs.
A Vault for Each Amulet
The game needs to know which floors are the Amulets'. Open Game.cpp, and add this to the unnamed namespace, above isCaveFloor, with a blank line in between:
bool isAmuletFloor(int depth)
{
for (int floor : AMULET_FLOORS)
{
if (floor == depth)
{
return true;
}
}
return false;
}
From now on, buildVault builds either kind of vault, and is told which. Open Game.h, and find these lines:
std::optional<VaultStamp::Contents> buildVault(
std::vector<SDL_Rect>& areas);
Change them to these:
std::optional<VaultStamp::Contents> buildVault(
std::vector<SDL_Rect>& areas, bool amulet);
Back in Game.cpp, replace the comment above buildVault with this:
// Builds an Amulet's vault, or a treasure vault, if one of those for this
// floor has room: they take their turns in a random order. On a floor of
// rooms, it goes in a room; in a cave, the cave is flood filled again
// afterward, in case the vault's walls cut part of it off, and its areas
// are worked out anew. Returns the cells of what's in the vault, for
// fillVault, once the floor has its other monsters and items
Then find these lines:
std::optional<VaultStamp::Contents> Game::buildVault(
std::vector<SDL_Rect>& areas)
{
std::vector<const VaultDef*> vaults;
for (const VaultDef& vault : data_.vaults())
{
if (vault.minFloor <= level_.depth && level_.depth <= vault.maxFloor)
Change them to these:
std::optional<VaultStamp::Contents> Game::buildVault(
std::vector<SDL_Rect>& areas, bool amulet)
{
std::vector<const VaultDef*> vaults;
for (const VaultDef& vault : data_.vaults())
{
bool here = vault.minFloor <= level_.depth &&
level_.depth <= vault.maxFloor;
if (here && vault.amulet == amulet)
A vault can be chosen only if it's for this floor, and of the kind asked for. On an Amulet's floor, that's the Amulet's vault; on any other, a treasure vault.
On an Amulet's floor, newLevel asks for the Amulet's vault, every time, and it must get one. A floor of rooms is made again until a room is big enough. The Amulets' vaults are 11 cells by 7, and need a room at least 13 by 9, which about one floor of rooms in three has. Replace the comment above newLevel with this:
// Makes the floor at a depth, in place of the one before: rooms, or a
// cave, an Amulet's vault, or one time in VAULT_ONE_IN a treasure vault,
// the player in the middle of the first area, its monsters, items and
// gold, what's in the vault, its stairs down, unless it's the last floor,
// and its secrets: secret doors, and traps. Both generators give areas to
// put things in: rooms, or the squares of a grid over the cave, which can
// have walls in them
In newLevel, find these lines:
level_ = Level();
level_.depth = depth;
std::vector<SDL_Rect> areas;
if (isCaveFloor(depth))
{
areas = CaveGen::generate(level_.map, rng_);
}
else
{
areas = RoomGen::generate(level_.map, rng_);
}
Change them to these:
// An Amulet's floor has its vault, and if no room has room for it,
// the floor is made again until one does. Other floors have a treasure
// vault one time in VAULT_ONE_IN, if one fits
bool amuletFloor = isAmuletFloor(depth);
std::vector<SDL_Rect> areas;
std::optional<VaultStamp::Contents> inVaultToFill;
do
{
level_ = Level();
level_.depth = depth;
if (isCaveFloor(depth))
{
areas = CaveGen::generate(level_.map, rng_);
}
else
{
areas = RoomGen::generate(level_.map, rng_);
}
Then find these lines, a little further down:
std::optional<VaultStamp::Contents> inVaultToFill;
if (rng_.roll(1, VAULT_ONE_IN) == 1)
{
inVaultToFill = buildVault(areas);
}
Change them to these:
if (amuletFloor)
{
inVaultToFill = buildVault(areas, true);
}
else if (rng_.roll(1, VAULT_ONE_IN) == 1)
{
inVaultToFill = buildVault(areas, false);
}
} while (amuletFloor && !inVaultToFill);
The floor is made inside a do-while loop, which runs its body once before it tests its condition. Every other floor goes once around, since amuletFloor is false. An Amulet's floor goes around until inVaultToFill has the vault's contents in it. Each time around starts over with a fresh Level, and the dice roll on, so each try is a new floor. With a third of floors fitting, it takes three tries on average, and the player never sees the ones that didn't fit.
Last, fillVault puts the Amulet down. Replace the comment above fillVault with this:
// Fills a vault: a hidden trap on each ^, an item on each $, a monster
// from the floor's band on each m, its guardian on each G, and the Amulet
// on the A. The guardians are a pack of their own, so they wake together,
// and all start asleep or all awake
Then, in fillVault, add this above for (Point cell : contents.traps):
if (contents.amulet)
{
FloorItem amulet;
amulet.pos = *contents.amulet;
amulet.item.def = data_.findItem("amulet");
level_.items.push_back(amulet);
}
The Amulet is an item on the floor like any other, of the kind findItem finds by its id, which is why items.txt must have one.
Saving the Amulets
A save has to keep the player's Amulets, so the save changes, and its version goes up to 4. Open SaveGame.cpp, and replace the comment above constexpr int SAVE_VERSION with this:
// The version of the save's format. Whatever changes what's saved
// changes the version, so an old save is never misread. Version 2
// added the caves' floor (Chapter 18), version 3 doors, secret doors
// and traps (Chapter 19), and version 4 the Amulets (Chapter 20)
In SaveGame.cpp, change SAVE_VERSION = 3; to SAVE_VERSION = 4;. Then, in writeSave, add the count below addField(text, "gold", player_.gold);:
addField(text, "amulets", player_.amulets);
Reading it back, a save that claims more than three Amulets has been tampered with, or damaged. Add this below player.gold = fields.number("gold", 0);:
player.amulets = fields.number("amulets", 0);
if (player.amulets > AMULETS)
{
fields.report(fields.lineOf("amulets"), "there are only " +
std::to_string(AMULETS) + " Amulets");
}
Neither gold nor an Amulet belongs in a saved pack. Find these lines:
if (def != nullptr && def->itemClass == ItemClass::Gold)
{
fields.report(fields.lineOf("kind"), "gold goes in the purse");
Change them to these:
bool counted = def != nullptr &&
(def->itemClass == ItemClass::Gold ||
def->itemClass == ItemClass::Amulet);
if (counted)
{
fields.report(fields.lineOf("kind"), "gold and the Amulets "
"aren't carried in the pack");
Checkpoint: Set the constructor's newLevel(1) to newLevel(3) for a moment, type 10 into Command Arguments, and press F5. If you have a save from before, the title screen offers it as before, and C says save.txt: it's version 3, and this game reads version 4. Press N for a new run. The side panel has a new line below the gold: Amulets 0 of 3. Press F1. The big room to the right of yours holds the goblin warband's hall, a goblin in each of its corners, around an inner room with a door of its own. In the middle of the inner room is a ": rest the mouse on it, and the look panel says The Amulet of SDL. The next image shows the hall in the tile view.

Fight your way in and pick it up, if you can, and the side panel counts it. Leave newLevel(3) in place for now: the next checkpoint uses it too.
An Amulet Missed Is a Run Lost
There are no stairs up. That was Chapter 15's choice, so that only the floor the player is on is ever kept. It has a cost now: an Amulet left behind can never be fetched, and the run can never be won. A player who leaves one on purpose has made their choice, but three things could take one away without a choice. This part closes all three.
No Trapdoor on an Amulet's Floor
A trapdoor drops the player to the next floor, with no say in it. On an Amulet's floor, that would lose the Amulet, so an Amulet's floor never has one. Replace the comment above chooseTrapKind with this:
// A kind of trap, each as likely as the others, but for a trapdoor on an
// Amulet's floor, or the last. A fall would leave an Amulet behind, and
// with no way back up, the run could never be won; and the last floor
// has nowhere to fall to. The trapdoor is last in TRAP_DEFS, so the
// roll leaves it out there
In chooseTrapKind, change level_.depth == LAST_FLOOR to level_.depth == LAST_FLOOR || isAmuletFloor(level_.depth). The last floor happens to be an Amulet's floor, so for now the new test alone would cover it. But the old test has a reason of its own, nowhere to fall to, and that holds wherever the Amulets go. If the last Amulet moved up to floor 8, a fall from floor 9 would arrive on a floor that doesn't exist. So both tests stay.
No Secret Door in the Way
Chapter 19 never hides a door that would cut off its own room, or the stairs. The same goes for the Amulet now. Replace the comment above placeSecretDoors with this:
// Hides SECRET_DOORS_MIN to SECRET_DOORS_MAX of the doors as secret doors.
// A door can be hidden only if its room keeps a door that isn't secret,
// and can still be reached from the start without this one, and never
// if the stairs, or the floor's Amulet, could no longer be reached: with
// no way back up, an Amulet missed is a run lost. The rooms beyond it
// can be hidden. The doors are tried in a random order, shuffled as the
// potions' colors are, and each that breaks a rule is put back
Then, in placeSecretDoors, add this below the statement that sets cutOff:
for (const FloorItem& lying : level_.items)
{
bool amulet = lying.item.def->itemClass == ItemClass::Amulet;
if (amulet && fromStart.at(lying.pos) == DijkstraMap::UNREACHED)
{
cutOff = true;
}
}
The door is hidden for a moment, and the distance map from the player's start already shows what can still be reached. So the loop only has to ask it about the Amulet. If an Amulet lying on the floor can't be reached, the door would cut it off, so cutOff is set, and the door is put back as it was. The Amulet is found among the floor's items by its class, since it's an item lying on the floor like any other. It's on the floor by now, since newLevel fills the vault before it hides any doors.
Asked Before Leaving
The third way to lose an Amulet is the simplest: walking down the stairs without it. The game asks first, as it asks before a step onto a known trap. Only a y goes on down. The Confirming state of Chapter 19 does the asking. But it now has two questions to ask, so it has to remember which one it's asking.
Open Game.h. In the GameState enum, change // asking whether to step onto a trap the player knows of to // asking a yes or no, as ConfirmPurpose says. Then add this below the enum, with a blank line in between:
// What a yes in the Confirming state would do
enum class ConfirmPurpose
{
Step, // step onto a trap the player knows of
Descend, // go down the stairs, leaving this floor's Amulet behind
};
After a yes, descend is called again, so it takes a flag. Find this line:
void descend();
Change it to these:
void descend(bool confirmed = false);
bool amuletStillHere() const;
Then find these lines:
// The step waiting for a yes, in the Confirming state
Point pendingStep_;
Change them to these:
// What the Confirming state is asking about, and the step waiting for
// a yes, if it's about a step
ConfirmPurpose confirmPurpose_ = ConfirmPurpose::Step;
Point pendingStep_;
Open Game.cpp, and replace the comment above descend with this:
// > on the stairs goes down to a new floor. The player keeps everything
// they have, and the floor they leave is gone. The run is saved on
// arrival. Leaving the floor's Amulet behind asks first: there's no way
// back up for it
In descend, change void Game::descend() to void Game::descend(bool confirmed). Then add the question above arriveOnFloor("You go down the stairs");:
if (!confirmed && amuletStillHere())
{
confirmPurpose_ = ConfirmPurpose::Descend;
state_ = GameState::Confirming;
log_.add("Leave this floor's Amulet behind for good? (y or n)");
return;
}
The question is asked only if the Amulet still lies on the floor, so a floor whose Amulet has been picked up asks nothing. Add the test below descend:
// True if the floor's Amulet still lies somewhere on it
bool Game::amuletStillHere() const
{
for (const FloorItem& lying : level_.items)
{
if (lying.item.def->itemClass == ItemClass::Amulet)
{
return true;
}
}
return false;
}
The step onto a trap now has to say what it's asking about too. In playerStep, add this below pendingStep_ = step;:
confirmPurpose_ = ConfirmPurpose::Step;
Last, the answer goes where the question came from. Replace the whole of handleConfirmKey with this:
// A yes or no, about stepping onto a trap the player knows of, or about
// going down without this floor's Amulet: y does it, and any other key
// stays put
void Game::handleConfirmKey(const SDL_KeyboardEvent& key)
{
state_ = GameState::Playing;
if (key.key != SDLK_Y)
{
log_.add("You stay where you are.");
}
else if (confirmPurpose_ == ConfirmPurpose::Descend)
{
descend(true);
}
else
{
playerStep(pendingStep_, true);
}
redraw_ = true;
}
In the preceding code, any key but y stays put, whatever the question was. If that's the question, a y goes down the stairs. It calls descend with true, so it doesn't ask again. Otherwise it takes the step, as before.
Checkpoint: With newLevel(3) and seed 10 still in place, add player_.pos = level_.stairs; above updateSight(); in newLevel, for a moment, so you start on the stairs. Press F5, and press >. The log asks Leave this floor's Amulet behind for good? (y or n). Press n, and "You stay where you are." Press > again, and then y, and "You go down the stairs to floor 4." The next image shows the question.

Now take out the line you added, and set newLevel(3) back to newLevel(1).
Playing the Game
A vault is a choice. From outside, it's a walled box in the middle of a room, with a closed door. There's no telling what's inside: treasure, a trap or two, and a monster, or a guardroom's goblins, waiting together.
Open it when you're ready for whatever's behind the door, at full health, with the way back clear. Guards wake as a pack, so a fight with one is a fight with all of them. Draw them into a corridor, if you can, where fewer of them can reach you at once.
The Amulets wait on floors 3, 6 and 9, and each is behind guards. The goblin warband's four goblins are a fair fight for a careful player on floor 3. The orc hold's cave orcs are a harder one, and the young red dragon is the hardest thing in the game. Don't leave an Amulet's floor without its Amulet unless you mean to: the game asks, and it means the question.
Understanding the Code
A vault is a record in vaults.txt, read by readVaults into a VaultDef. Its map is a value of several rows, each from a line starting with |. The function readRecords joins the rows into one value, and FieldReader::rows splits it again. Then checkVaultMap pads the rows to one width, and checks the vault's size, its symbols and its guards, and that it's closed all around but for one way in, from which every cell inside can be reached. F7 shows each vault in turn.
A floor's vault is built as soon as its map is made. Floors 3, 6 and 9 get an Amulet's vault, and the floor is made again until one fits. The others get a treasure vault one time in three. The function that builds it, buildVault, shuffles the vaults for the floor and tries each in turn.
On a floor of rooms, roomForVault finds a room with floor all around the vault. In a cave, caveForVault finds a place whose door opens onto the cave, and tries it on a copy of the map first. The flood fill, which now goes through doors with isPassable, must leave the cave whole. The stamp, VaultStamp::stamp, builds the walls, floors and doors, and hands back the cells of the contents. Until fillVault fills them, last of all, Level::vault keeps everything else out.
The Amulet is an item, counted like gold, and saved in version 4 of the save. Monsters with the vault_only trait, the young red dragon so far, are never chosen at random. Three rules keep an Amulet from being lost: no trapdoor on its floor, no secret door that cuts it off, and a question before the stairs, through the Confirming state and its ConfirmPurpose.
Experimenting
Try each of these, and put each back afterward:
- A vault on every floor. Set
VAULT_ONE_INto1, and try a few seeds, with F1. Nearly every floor has a vault now. Seed 42's first floor still doesn't, since none of its rooms is big enough for one. - A vault of your own. Add the record below to the end of
vaults.txt, and start the game again, without compiling. Press F7 ten times to see it. Then break it, a wall at a time, and see what the checks say. - A dragon in the closet. In the closet's record, change the
min its map to aG, and addguardian = young_red_dragonbelow itsfloors. With seed 1, press F1: the closet on the first floor has a dragon in it.
Here's the vault of your own, a gauntlet of four traps with treasure at the far end:
[vault]
id = gauntlet
floors = 1 9
map =
|###########
|#^.^.^.^.$#
|#+#########
None of these needs a compile, except the first. That's the point of keeping vaults in a file: they're as easy to draw as they are to change.
Common Errors and Fixes
If the game didn't build, or doesn't behave as described, the problem is probably one of these.
C2065: 'VaultStamp': undeclared identifier, in Game.h, followed by C2653 and dozens more. The declarations in Game.h name VaultStamp::Contents, but Game.h doesn't include the header that says what it is. Add #include "VaultStamp.h" below #include "TileView.h".
C2143: syntax error: missing ';' before 'VaultStamp::stamp', in VaultStamp.cpp, followed by C4430 and C2556. The function's first line was typed as Contents VaultStamp::stamp(, as in the header. In the header, the declaration is inside the namespace, so Contents is enough. The .cpp is different: there, the return type comes before VaultStamp::stamp, outside the namespace. So it needs its full name, VaultStamp::Contents.
C2660: 'Game::buildVault': function does not take 2 arguments, twice, in newLevel, followed by C2511 and more. The function itself was given its second parameter, bool amulet, but its declaration in Game.h wasn't. Change the declaration to match.
The game won't start, and a box lists vaults.txt:32: a | row follows its key, or another row, and dozens more. Every row of every vault is reported, because readRecords never remembers the key the rows belong to. Add lastKey = key; and lastLine = number; below record.fields[key] = { value, number };.
The game won't start, and a box lists vaults.txt:107: 'A' isn't in a vault's legend, followed by an Amulet's vault has one A, for each of the three. The Amulet's symbol isn't in VAULT_SYMBOLS, so it's rejected, and then not counted. Add the A, so it reads "#.+S^$mGA ".
The game won't start, and a box says vaults.txt: floor 3 has no Amulet's vault, and the same for floors 6 and 9. The vaults.txt in your project is still the first copy, with the Amulets' vaults deleted from its end. Copy the whole file from the book's repository again.
The title screen says the saved run is damaged, and has been set aside as save.bad: [player] needs 'amulets'. The save's version wasn't changed to 4. So a save made before the Amulets were added, which has no count of them, was read as this game's. Change SAVE_VERSION = 3; to SAVE_VERSION = 4;. The damaged run can't come back, but its file, save.bad, is still there to look at.
In a cave, a vault is solid rock behind its door, and F1 shows its guards and treasure inside the rock. The flood fill still uses isWalkable, so the vault's inside, behind its closed door, is a pocket of its own. It's filled in like any other. Change all three in keepBiggestCave to isPassable, as the chapter does.
FAQ
Q) Why are the Amulets counted, and not carried in the pack?
A) An Amulet in the pack could be dropped, or left behind when the pack was full, or lost to a key pressed by mistake. With no stairs back up, any of those would lose it for good. Counted, as gold is, an Amulet can't be lost once it's picked up.
Q) Why make a whole floor again, rather than a room big enough for the vault?
A) The rooms come from cutting the map in two, and in two again. A bigger room would need different cuts, which make a different floor anyway. Making the floor again is simpler, and the player only ever sees the one that fits.
Q) Why does F7 show letters, rather than the monsters and items themselves?
A) A vault's $ isn't an item yet. It's a place where one will go, chosen when the vault is filled, and different every time. F7 shows the vault as it's drawn, which is what you need to see when you're drawing one.
AI Exercise (Optional)
One drawing could make several vaults, if the game turned or mirrored it at random as it built it. Some roguelikes do this, Dungeon Crawl Stone Soup among them, so a player can't learn a vault by heart. If you'd like to try it, with an AI's help, open your AI chatbot of choice, and try a prompt like this:
"I'm writing a roguelike in C++ with SDL 3. A vault is read from a text file as rows of characters, std::vector<std::string> rows, in struct VaultDef, which has width() and height() from the rows. VaultStamp::stamp(Map& map, const VaultDef& vault, Point corner) builds a vault into the map, and VaultStamp::entrance(const VaultDef& vault) finds the cell outside its one door. Game::buildVault chooses a VaultDef, and finds a corner for it with roomForVault or caveForVault. I'd like each vault to be mirrored, or turned a quarter turn, at random, just before a place is found for it. Show me a function that makes the turned or mirrored copy of a VaultDef, and where to call it, with each curly brace on its own line, and explain it."
Notice what the preceding prompt does. It says where the vault's size is used, so the answer turns the vault before a place is found for it, not after.
When the answer comes back, check the quarter turn. It swaps the width and the height, so a vault 11 cells wide and 7 high becomes 7 wide and 11 high, taller than the checks in checkVaultMap allow. That's fine, since the checks look at the vault as it's drawn. But the turned vault needs a room of a different shape, which roomForVault will look for, given the turned copy.
Summary
A vault is a room drawn by hand in a data file, read and checked when the game starts. It's built into a floor as the floor is made: in a room big enough to have floor all around it, or in a cave, where its door opens onto the cave's floor and the flood fill keeps the cave whole. Treasure vaults turn up one floor in three. An Amulet's vault is on each of floors 3, 6 and 9, guarded by goblins, cave orcs and a young red dragon, and three rules keep an Amulet from being lost. In Chapter 21, the fighting reaches further: scrolls of lightning and fireballs aimed from a distance, bows, arrows, darts and thrown daggers, archers that keep their distance, and the dragon's fiery breath.
