Chapter 19 · ~52 min read

Doors, Secrets and Traps

Since Chapter 2, every room has had doorways, gaps in its walls where the corridors come in. There's been nothing to shut them with. A room stands open to anything that wanders past. From inside it, the player can see out of every doorway, down every corridor that leads away. This chapter hangs doors in the doorways, and the dungeon starts keeping secrets.

A closed door blocks sight, so a room behind one can't be seen until the door is opened. And some monsters can't get through one at all. Some doors are hidden, and look like wall until the player searches beside them. And traps lie hidden in the floor, waiting for a step: darts, poison needles, alarms, teleporters and trapdoors. The next image shows a floor at the end of the chapter.

The end of Chapter 19
Figure 19.1 — The end of Chapter 19

The player has just searched, and found a trap in the floor below them: a poison needle, drawn now, tinted green. Before they step onto it, the log asks. Around them, the doorways have doors in them, standing open. The rest of the floor is still dark. None of this needs a new generator: the doors go into the doorways the rooms already have, and the traps go into the same areas as the monsters and items.

Project folder: Chapter 19 in the book's repository holds this chapter's finished project. The chapter carries on in your own project from Chapter 18, or from the book's Chapter 18 folder.

In this chapter, we will:

  • Put doors in the rooms' doorways, closed or open, and draw them in both views
  • Open a door by walking into it, and close one with c
  • Let some monsters open doors, while the rest wait behind them, and let the distance maps count a door as more than one step
  • Hide some of the doors as secret doors, and find them by searching with s
  • Hide traps of five kinds, which spring when they're stepped on, and ask before the player steps onto one they know of
  • Keep doors, secret doors and traps in the save, as its version 3

There are five checkpoints along the way. Let's start with the plan.

Planning the Chapter

This chapter adds one file, and changes seventeen, along with the tile atlas and the monsters' data:

File Status What it's for
src/Trap.h New The kinds of trap, and a trap on a floor
src/Map.h, src/Map.cpp Changed Doors, closed, open and secret: which can be walked through, and which block sight
src/RoomGen.cpp Changed Doors in the rooms' doorways
src/DijkstraMap.h, src/DijkstraMap.cpp Changed A closed door counted as more than one step
src/Monster.h, src/Data.cpp Changed The opens doors trait
data/monsters.txt Changed The monsters that open doors
src/Common.h Changed The doors' color
src/View.h Changed A trap's drawing, which every view has
src/GlyphView.h, src/GlyphView.cpp Changed Doors and traps, drawn in letters
src/TileView.h, src/TileView.cpp Changed Doors and traps, drawn in tiles
src/Level.h Changed A floor's traps
src/Game.h, src/Game.cpp Changed Opening and closing doors, monsters at doors, secret doors and searching, and traps
src/SaveGame.cpp Changed Doors, secret doors and traps in a save, which becomes version 3
assets/tiles.png, assets/tiles.txt Changed Pictures of a door, closed and open, and of a trap
The other files Unchanged

The next image shows how the files fit together at the end of the chapter. The one new file, Trap.h, is small: the kinds of trap, what each is called, and what a trap on a floor is. A Level keeps its floor's traps beside its monsters and items, so Game and the save reach them the same way.

The files at the end of Chapter 19
Figure 19.2 — The files at the end of Chapter 19

Most of the work is in Game, as it has been for every chapter since the game took shape there. The doors themselves are terrain, in Map, and the rest of the game finds out about them by asking the map what a cell is made of.

Doors, Secret Doors and Traps

The three new things are built in two different ways, and the difference matters for the code.

A door is terrain. It's what a cell of the map is made of, just as a wall or a floor is, so it goes in Terrain, as ClosedDoor and OpenDoor. A closed door is a wall that can be opened: it blocks sight, and nothing walks through it. An open door is walked through like floor. Opening or closing one is a change to the map, from one terrain to the other.

A secret door is terrain too, SecretDoor, but terrain that pretends to be a wall. It's drawn as a wall, it blocks sight as a wall does, and the look panel calls it a wall. Searching beside it finds it, and it becomes a closed door, an ordinary one from then on.

A trap isn't terrain. It lies on the floor, as an item does, with the floor still under it. And it has something a cell of the map doesn't: whether the player knows it's there. So each floor keeps its traps in a list, as it keeps its items. A trap is drawn only once it's known, and then it stays drawn, so the player can step around it, or be asked before stepping on it.

Doors

The first part puts doors in the rooms' doorways, draws them, and lets the player open them by walking into them. For now, a closed door stops every monster, since none of them knows how to open one. The next part teaches some of them.

A Door Is Terrain

A door is a new kind of terrain, and it comes in two states, closed and open. Open Map.h, and add them to Terrain, below CaveFloor,:

ClosedDoor,
OpenDoor,

An open door is walked through like floor, and a closed one has to be opened first. Replace the comment above bool isWalkable with this:

// True for terrain that can be walked on: floor, cave floor, the
// stairs, and an open door. A closed door has to be opened first

Some of the code will want to know whether a cell is a door at all, closed or open. Add a function for that below isWalkable's declaration:

// True for a door, open or closed
bool isDoor(int x, int y) const;

A closed door stops sight, as a wall does. Replace the comment above bool blocksSight with this:

// Sight: what stops it (walls, and doors that aren't open), which
// cells the player can see right now, and which they've ever seen

Open Map.cpp. In isWalkable, change terrain == Terrain::StairsDown; to terrain == Terrain::StairsDown || terrain == Terrain::OpenDoor;. Then add isDoor below isWalkable:

bool Map::isDoor(int x, int y) const
{
    Terrain terrain = at(x, y);
    return terrain == Terrain::ClosedDoor || terrain == Terrain::OpenDoor;
}

Last, replace the whole of blocksSight with this:

bool Map::blocksSight(int x, int y) const
{
    Terrain terrain = at(x, y);
    return terrain == Terrain::Wall || terrain == Terrain::ClosedDoor;
}

That's all the field of view needs. Chapter 4's shadowcasting asks blocksSight about every cell it reaches. So a closed door casts a shadow just as a wall does, and an open one lets the light through.

Where Doors Go

A corridor reaches a room through a gap in its wall. The cell just outside the room, where the wall would be, is corridor instead, and that cell is the doorway. A door goes there, but only where the doorway is a real gap, with wall on both sides of it along the room's edge.

When a corridor runs along a room's side, instead of meeting it, it leaves a line of floor outside the room. That's floor beside floor, and none of those cells is a doorway. The next image shows the rule at work on two rooms.

Doorways, and where doors go
Figure 19.3 — Doorways, and where doors go

Where two rooms stand back to back, with only their walls between them, a corridor from one to the other makes two doorways side by side. There's one for each room, as in the image. Two doors in a row would look like a broken wall, so a door goes only where no door is next to it already. Of the doors, six in ten are closed, and the rest stand open. Open RoomGen.cpp, and add the share of closed doors to the unnamed namespace, below PADDING:

// Of the doors into rooms, this many in a hundred are closed, and the
// rest stand open (*tune*)
constexpr int CLOSED_DOOR_PERCENT = 60;

Then add the test for one cell below split, at the end of the unnamed namespace:

// A door goes in a doorway: a cell of corridor just outside a room,
// with wall on both sides of it along the room's edge
void tryDoor(Map& map, Point cell, Point along, Rng& rng)
{
    Point before = { cell.x - along.x, cell.y - along.y };
    Point after = { cell.x + along.x, cell.y + along.y };
    bool doorway = map.at(cell.x, cell.y) == Terrain::Floor &&
                   map.at(before.x, before.y) == Terrain::Wall &&
                   map.at(after.x, after.y) == Terrain::Wall;

    // No door beside another, as where two rooms' doorways meet.
    // DIRECTIONS' even steps are the straight ones
    for (int i = 0; i < 8; i += 2)
    {
        Point next = { cell.x + DIRECTIONS[i].x,
                       cell.y + DIRECTIONS[i].y };
        doorway = doorway && !map.isDoor(next.x, next.y);
    }
    if (doorway)
    {
        bool closed = rng.roll(1, 100) <= CLOSED_DOOR_PERCENT;
        map.set(cell.x, cell.y,
                closed ? Terrain::ClosedDoor : Terrain::OpenDoor);
    }
}

In the preceding code, along is the step along the room's edge, { 1, 0 } for the top and the bottom, and { 0, 1 } for the sides. The cells before and after the doorway, one step each way along the edge, must both be wall, and the doorway itself floor.

The steps in DIRECTIONS go clockwise around the compass, starting from north. So its even entries, 0, 2, 4 and 6, are the four straight ones. The loop checks that none of those neighbors is a door already. A doorway that passes both tests gets a door, closed six times in ten.

The test runs on cells along each room's four sides, just outside the room. Add that below tryDoor:

// Looks for doorways all around each room, along its four sides just
// outside it. The corners can't be doorways: a corridor meets a room
// square on
void addDoors(Map& map, const std::vector<SDL_Rect>& rooms, Rng& rng)
{
    for (const SDL_Rect& room : rooms)
    {
        for (int x = room.x; x < room.x + room.w; ++x)
        {
            tryDoor(map, { x, room.y - 1 }, { 1, 0 }, rng);
            tryDoor(map, { x, room.y + room.h }, { 1, 0 }, rng);
        }
        for (int y = room.y; y < room.y + room.h; ++y)
        {
            tryDoor(map, { room.x - 1, y }, { 0, 1 }, rng);
            tryDoor(map, { room.x + room.w, y }, { 0, 1 }, rng);
        }
    }
}

The loops go along the room's own width and height, so the cells at the room's outside corners are never tried. A corridor can't come in at a corner anyway: Chapter 2's corridors are made of straight lines, which meet a room's side square on. In generate, add this below split(map, inside, MAX_CUTS, rng, rooms);:

addDoors(map, rooms, rng);

The doors come last, once the rooms and corridors are all dug, so every floor of rooms has the same rooms and corridors as before. The doors' rolls come before everything else that's rolled for the floor, though. So the monsters, the items and the gold of a seed are all different from Chapter 18's. Caves don't call addDoors, and have no doors.

Drawing Doors

A closed door is drawn as a +, and an open one as a ', both in a wooden brown. Open Common.h, and add the color to the palette, below CAVE_FLOOR:

constexpr SDL_Color DOOR = { 180, 130, 80, 255 };

Open GlyphView.cpp, and in drawTerrain, add the doors' branches below the stairs':

else if (terrain == Terrain::ClosedDoor)
{
    // A closed door fills its gap in the wall, so it has the wall's
    // background
    SDL_Color color = lit ? Palette::DOOR : Palette::MEMORY_WALL;
    SDL_Color back = lit ? Palette::WALL_BACK : Palette::MEMORY_WALL_BACK;
    drawCell(cell, '+', color, back);
}
else if (terrain == Terrain::OpenDoor)
{
    SDL_Color color = lit ? Palette::DOOR : Palette::MEMORY_WALL;
    drawCell(cell, '\'', color);
}

A closed door has the wall's dark background, since it stands in the line of the wall, and fills the gap in it. An open door is drawn on the floor's black, since it stands open. The character ' is written '\'' in C++, with a backslash, since a single quote on its own would end the character. Out of sight, a door is drawn in the memory's wall color, as the stairs are.

The tile view needs pictures of doors. From the book's repository, copy tiles.png and tiles.txt from the Chapter 19 folder's assets folder into your project's assets folder, in place of the old ones. They add three tiles: door and open-door, which this part uses, and trap, for later in the chapter.

The doors' tiles are see-through around their edges, as the stairs' tile is, so a floor tile goes under each of them first. Open TileView.cpp, and replace the comment above drawTerrain with this:

// Remembered cells are tinted, which darkens and cools them, as the glyph
// view's memory colors do. The stairs' and the doors' tiles are see-through
// around their edges, so a floor is drawn under them first

Then, in drawTerrain, find these lines:

else if (terrain == Terrain::StairsDown)
{
    drawTile(cell, "floor", lit ? NO_TINT : remembered(NO_TINT));
    name = "stairs";
}
drawTile(cell, name, lit ? tint : remembered(tint));

Change them to these:

else if (terrain == Terrain::StairsDown)
{
    name = "stairs";
}
else if (terrain == Terrain::ClosedDoor)
{
    name = "door";
}
else if (terrain == Terrain::OpenDoor)
{
    name = "open-door";
}
bool onFloor = terrain == Terrain::StairsDown ||
               terrain == Terrain::ClosedDoor ||
               terrain == Terrain::OpenDoor;
if (onFloor)
{
    drawTile(cell, "floor", lit ? NO_TINT : remembered(NO_TINT));
}
drawTile(cell, name, lit ? tint : remembered(tint));

In the preceding code, each terrain chooses its tile's name first, and onFloor then says whether it needs a floor under it. Before, the stairs' branch drew the floor itself. But with three terrains needing one, it's tidier to decide that once, below all the branches.

The look panel names the ground under the mouse, and it would call a door a wall, its name for anything it doesn't know. Open Game.cpp, and in drawLookPanel, add these below the stairs' branch:

else if (terrain == Terrain::ClosedDoor)
{
    ground = "A closed door";
}
else if (terrain == Terrain::OpenDoor)
{
    ground = "An open door";
}

Doors are on the map now, and drawn, but the player can't get through a closed one yet.

Opening a Door

Walking into a closed door opens it. It takes the turn, as a step would, and the player stays where they are, with the open door in front of them. Opening a door makes a little noise, as a fight does. And the player's sight has to be worked out again, since there may be a room to see beyond it.

Open Game.cpp, and add the door's loudness to the unnamed namespace, below WAKE_LOUDNESS:

// Doors make noises too: opening one is this loud (*tune*)
constexpr int DOOR_OPEN_LOUDNESS = 5;

Then add the function that opens a door below continueRun:

// Opens a closed door, which makes a noise, and lets sight through
void Game::openDoor(Point door)
{
    level_.map.set(door.x, door.y, Terrain::OpenDoor);
    makeNoise(door, DOOR_OPEN_LOUDNESS);
    updateSight();
}

The door becomes an open door, and a noise goes out from it. As Chapter 11's noises do, it wakes a monster sleeping a step or two away, and draws a wandering one from a little farther. Then updateSight works out what the player can see, now that the door no longer blocks it. Open Game.h, and declare it below handleMenuKey's declaration:

void openDoor(Point door);

A step into a cell that holds a monster attacks it, and a step into a closed door should open it. Back in Game.cpp, in playerStep, find these lines:

    playerAttacks(*monster);
    removeDeadMonsters();
    finishTurn();
}
else if (level_.map.isWalkable(to.x, to.y))

Change them to these:

    playerAttacks(*monster);
    removeDeadMonsters();
    finishTurn();
}
else if (level_.map.at(to.x, to.y) == Terrain::ClosedDoor)
{
    // Bumping a closed door opens it, which is the turn
    openDoor(to);
    log_.add("You open the door.");
    finishTurn();
}
else if (level_.map.isWalkable(to.x, to.y))

The closed door is checked before the ground, since a closed door isn't walkable. Otherwise it would end up in the last branch, as a wall to bump into.

One more thing has to know about doors: the stairs. They go on the cell farthest from the player by walking, and that cell could be a doorway, where the stairs would replace the door. Replace the comment above farthestFrom with this:

// The cell of ground farthest from start by walking, where the stairs
// down go. A doorway isn't ground for this

Then, in farthestFrom, find these lines:

int distance = distances.at({ x, y });
if (distance != DijkstraMap::UNREACHED &&
    distance > farthestDistance)

Change them to these:

int distance = distances.at({ x, y });
bool ground = map.isWalkable(x, y) && !map.isDoor(x, y);
if (ground && distance != DijkstraMap::UNREACHED &&
    distance > farthestDistance)

A doorway can still be on the way to the stairs, but never the place they're on.

Doors in a Save

A save writes each cell's terrain as a letter, and a door needs one. So the save's version goes up to 3. In a save, a closed door is +, as it's drawn. An open door is /, since ' would be easy to miss in a line of dots. 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), and version 3 doors (Chapter 19)

In SaveGame.cpp, change SAVE_VERSION = 2; to SAVE_VERSION = 3;. Then add the doors' letters below { Terrain::CaveFloor, ':' },:

{ Terrain::ClosedDoor, '+' },
{ Terrain::OpenDoor, '/' },

The rest of the chapter adds more to the save, but it all goes into version 3. None of it is released between here and the end of the chapter. A save from Chapter 18 is turned away by its version, just as Chapter 17's was in Chapter 18.

Checkpoint: Copy the assets, if you haven't, and press F5, with seed 42 in Command Arguments. Your room has two doors in its bottom wall: an open one, ', to the left, and a closed one, +, straight below you. Walk down with j: three steps take you to the wall, and the fourth bumps the closed door, which opens. "You open the door." Your sight goes through the doorway and down the corridor beyond, to a giant white rat. The next image shows the door, closed and then open.

The door below the player, closed and then open
Figure 19.4 — The door below the player, closed and then open

Press F2 for the tile view, and the doors are drawn there too, on their floor tiles. Then press F1, and look around the floor: every room has its doors now, some closed and some open. Notice that a monster in a room whose doors are all closed stays there, for now, since nothing can open a door but you.

Closing Doors, and Monsters at Doors

The second part lets the player close a door again, with c and a direction. Then it lets some of the monsters open doors as the player does, while the rest are stopped by them. Last, it teaches the distance maps that a closed door is a way through, at a cost. And it teaches a noise that a door muffles it.

Closing a Door

Closing a door takes two keys: c, and then a moving key that says which way. Between the two, the game is in a state of its own. It's asking, as it asks which item when the inventory is open. Open Game.h, and add the new state to GameState, below Menu:

Closing,     // asking which way to close a door

Then declare the state's key handler, below handleMenuKey's declaration:

void handleClosingKey(const SDL_KeyboardEvent& key);

And declare the closing itself, below openDoor's:

void closeDoor(Point door);

Closing a door makes a noise too, if a quieter one. Open Game.cpp, and replace the comment above constexpr int DOOR_OPEN_LOUDNESS = 5; with this:

// Doors make noises too, opening and closing. A closed door also
// muffles a noise: passing one counts as DOOR_MUFFLE cells, three more
// than a step (all *tune*)

Then add the two new numbers below DOOR_OPEN_LOUDNESS:

constexpr int DOOR_CLOSE_LOUDNESS = 3;
constexpr int DOOR_MUFFLE = 4;

The first is the closing's noise. The second, DOOR_MUFFLE, is for noises that have a closed door in their way, at the end of this part.

The state needs its case in handleKey, or no key would do anything while the game asks. In handleKey, add this below the case for GameState::Menu:

case GameState::Closing:
    handleClosingKey(key);
    break;

Replace the comment above handlePlayingKey with this:

// Esc opens the menu, > goes down the stairs, g picks up, i shows the
// inventory, d drops, x starts looking around, c closes a door, and the
// moving keys move the player

Then, in handlePlayingKey, add the new key below the block for x:

if (key.key == SDLK_C)
{
    log_.markRead();
    log_.add("Close a door: which way? (Esc to stop)");
    state_ = GameState::Closing;
    redraw_ = true;
    return;
}

The old messages are marked read first, so the question stands out, bright, as a new message. The key changes nothing on the map, and takes no turn: it only asks.

The answer comes to its own handler. Add it below continueRun, above openDoor:

// Closing a door: a moving key says which way, and Esc changes the
// player's mind. Either way, the game goes back to Playing
void Game::handleClosingKey(const SDL_KeyboardEvent& key)
{
    Point step;
    if (key.key == SDLK_ESCAPE)
    {
        state_ = GameState::Playing;
    }
    else if (stepFor(key, step) && step != Point{ 0, 0 })
    {
        state_ = GameState::Playing;
        closeDoor({ player_.pos.x + step.x, player_.pos.y + step.y });
    }
    redraw_ = true;
}

Esc goes back to playing with nothing done, and so does a moving key, once it has said which way. The period key is a moving key, but one that goes nowhere. A door can't be closed on the player's own cell, so the key is ignored. Any other key is ignored too, and the question stays until it's answered.

The closing itself checks what's there first. Add it below openDoor:

// Closes the open door on a cell, if the doorway is empty: no one in it,
// and nothing lying in it. That takes a turn, and makes a little noise
void Game::closeDoor(Point door)
{
    if (level_.map.at(door.x, door.y) != Terrain::OpenDoor)
    {
        log_.add("There's no open door there.");
        return;
    }
    if (monsterAt(door) != nullptr || floorItemAt(door) != nullptr)
    {
        log_.add("Something is in the way.");
        return;
    }
    level_.map.set(door.x, door.y, Terrain::ClosedDoor);
    log_.add("You close the door.");
    makeNoise(door, DOOR_CLOSE_LOUDNESS);
    updateSight();
    finishTurn();
}

In the preceding code, a cell that isn't an open door is turned down with a message. So is a doorway with something in it, since a door can't swing shut through a monster or an item. Neither takes a turn, since nothing happened. A door that does close makes a noise, changes what the player can see, and ends the turn.

Monsters That Open Doors

A closed door stops every monster at the moment, which makes it a perfect defense: close the door behind you, and nothing follows. That's too easy. In most roguelikes, some monsters open doors and some don't, by what they are. A kobold or an orc has hands and the sense to use them, and a rat or a spider has neither.

Design note: NetHack's doors. In NetHack, walking into a closed door opens it too, with its autoopen option, which is on from the start. Its doors do more than ours. A door can be locked, and nobody steps diagonally into or out of a doorway that has a door in it, open or shut. Whether a monster gets past a closed door depends on its body. One with hands that isn't very small opens it, one carrying a key unlocks a locked one, a giant smashes it down, and a fog cloud flows under it.

This game gives the choice to the data, as a trait, opens doors. Open Monster.h, and add the trait to Trait, below DRAINS:

// It can open a closed door. Without it, a closed door stops it
constexpr unsigned int OPENS_DOORS = 1 << 8;

The data file names the trait, so the code that reads the file needs its name. Open Data.cpp, and add it to the traits' names, below { "drains", Trait::DRAINS },:

{ "opens_doors", Trait::OPENS_DOORS },

Now the monsters can be given it. Open data/monsters.txt, and first add the new trait to the list in the comment at the top, after drains, so that the line reads # never_flees regenerates drains opens_doors.

Five kinds open doors: the kobold, the goblin and the cave orc in the shallower bands, and the ogre and the vampire in the deep. The kobold, the goblin and the ogre have no traits yet, so add this line to each of their records, below its sight:

traits  = opens_doors

The cave orc and the vampire have traits already, so add the word to theirs. The cave orc's line becomes traits = opens_doors pack:2, and the vampire's traits = drains opens_doors. Save the file. Since Chapter 16, a change to the data needs no compiling, but this one needs the new Data.cpp, which knows the trait's name.

Every monster's step goes through two functions: stepDown, which picks the next cell on a distance map, and wanderStep, which picks one at random. Both ask isFree whether a cell can be stepped onto, and a closed door isn't free. A monster that opens doors needs a different question. Back in Game.cpp, add it below isFree:

// True if a monster could step onto the cell, or, being one that opens
// doors, open the closed door there
bool Game::canEnter(const Monster& monster, Point cell)
{
    bool door = level_.map.at(cell.x, cell.y) == Terrain::ClosedDoor;
    return isFree(cell) || (door && monster.def->has(Trait::OPENS_DOORS));
}

Then, in stepDown, change if (!isFree(to)) to if (!canEnter(monster, to)). And in wanderStep, change if (isFree(to)) to if (canEnter(monster, to)).

A monster that's chosen a closed door as its next cell can't stand in it. It opens the door instead, and that's its move, just as it is for the player. That happens in one place, so add it below canEnter:

// Moves a monster onto a cell next to it. If that's a closed door, it
// opens the door instead, and that's its move
void Game::moveMonster(Monster& monster, Point to)
{
    if (level_.map.at(to.x, to.y) == Terrain::ClosedDoor)
    {
        openDoor(to);
        announce(monster, "opens a door.");
        return;
    }
    monster.pos = to;
}

The function announce tells the player, if they can see the monster: "The kobold opens a door." The door was in the way of their sight until a moment ago, so openDoor's updateSight is what lets them see it.

Five places in the monsters' code move a monster by setting its pos, and each should go through moveMonster now. In actHunting, change monster.pos = stepDown(monster, toPlayer_); to moveMonster(monster, stepDown(monster, toPlayer_));.

Further down the same function, in the search after a lost player, there's a second. In actHunting, change monster.pos = next; to moveMonster(monster, next);.

Then, in actFleeing, change monster.pos = next; to moveMonster(monster, next);, and in walkToward, change monster.pos = next; to moveMonster(monster, next); as well. Last, in movesErratically, change monster.pos = wanderStep(monster); to moveMonster(monster, wanderStep(monster));.

Monsters open doors with openDoor now, as the player does. Open Game.h, and declare the two new functions below isFree's declaration:

bool canEnter(const Monster& monster, Point cell);
void moveMonster(Monster& monster, Point to);

A monster without the trait is stopped by a closed door, as every monster was before.

The Way Through a Door

There's one more piece. A hunting monster finds its way by the distance map from the player, toPlayer_. The distance map knows only ground: a closed door is a wall to it. Even a kobold, which could open the door, is never sent that way by the map. It goes the long way around, or doesn't come at all.

So the distance map learns that a closed door is a way through, at a cost: two steps, one to open the door and one to step in. Chapter 10 chose a priority queue for the map, over a plain queue, for steps like these, which cost more than one.

Every monster follows the same maps, though, and twelve of the seventeen kinds can't open doors. When the shortest way to you runs through a closed door, a rat walks up to the door and stops there. It waits at the door while it can see you, and gives up the chase once it can't. That much is what a rat would do.

Where it falls short is the way around. The rat stops at the door even when there's an open way around to you, and a real rat would take it. A fleeing monster can be caught the same way. If its way out runs through a closed door, it has nowhere lower to go, and turns at bay there, when it could have run another way. The AI exercise at the end of the chapter offers a fix.

Open DijkstraMap.h, and replace the comment above class DijkstraMap with this:

// For every cell of the map, how many steps it is to the nearest of some
// goal cells, walking in eight directions over the ground, and through
// closed doors, which count as more than one step. A monster that steps
// to its lowest neighbor, again and again, reaches a goal by the shortest
// way around the walls. Roguelike programmers call this a Dijkstra map,
// after the computer scientist whose algorithm fills it in

Add the door's cost below UNREACHED:

// What a closed door counts as, unless asked otherwise: two steps, one
// to open it and one to step in
static constexpr int DOOR_STEPS = 2;

Then find these lines:

// Works out every cell's distance from the nearest of the goals
void compute(const Map& map, const std::vector<Point>& goals);

Change them to these:

// Works out every cell's distance from the nearest of the goals. A
// closed door counts as doorCost steps
void compute(const Map& map, const std::vector<Point>& goals,
             int doorCost = DOOR_STEPS);

The parameter doorCost has a default, DOOR_STEPS. So every call that already exists counts a door as two steps, without a change. A call that wants something else, as the noises will, passes its own. Add the cost to the class's members, below std::vector<int> distance_;:

int doorCost_ = DOOR_STEPS;

The cost is kept in a member, rather than passed along, because two functions need it: compute, and the spreading that the flee map does again, from a copy, long after compute has returned. Open DijkstraMap.cpp, and find these lines:

void DijkstraMap::compute(const Map& map, const std::vector<Point>& goals)
{
    distance_.assign(distance_.size(), UNREACHED);

Change them to these:

void DijkstraMap::compute(const Map& map, const std::vector<Point>& goals,
                          int doorCost)
{
    doorCost_ = doorCost;
    distance_.assign(distance_.size(), UNREACHED);

Then, in spread, find these lines:

for (Point step : DIRECTIONS)
{
    Point next = { cell.x + step.x, cell.y + step.y };
    if (!map.isWalkable(next.x, next.y))
    {
        continue;
    }
    int nextIndex = next.y * MAP_W + next.x;
    if (distance + 1 < distance_[nextIndex])
    {
        distance_[nextIndex] = distance + 1;
        queue.push({ distance + 1, nextIndex });
    }
}

Change them to these:

for (Point step : DIRECTIONS)
{
    // A step onto the ground costs 1, and onto a closed door,
    // doorCost_. Nothing else can be stepped onto
    Point next = { cell.x + step.x, cell.y + step.y };
    int cost = 1;
    if (map.at(next.x, next.y) == Terrain::ClosedDoor)
    {
        cost = doorCost_;
    }
    else if (!map.isWalkable(next.x, next.y))
    {
        continue;
    }
    int nextIndex = next.y * MAP_W + next.x;
    if (distance + cost < distance_[nextIndex])
    {
        distance_[nextIndex] = distance + cost;
        queue.push({ distance + cost, nextIndex });
    }
}

In the preceding code, a step costs 1, as before, unless the cell is a closed door, when it costs doorCost_. Anything else that can't be walked on is skipped, as before. The next image shows the difference on the player's room, with F3's distances.

Distances through an open door and a closed one
Figure 19.5 — Distances through an open door and a closed one

The noises spread by a distance map too, as Chapter 11 made them. A closed door should muffle a noise, not stop it. With DOOR_MUFFLE, a noise that passes a closed door loses four cells of its reach there, three more than a step. Back in Game.cpp, in makeNoise, change spread.compute(level_.map, { origin }); to spread.compute(level_.map, { origin }, DOOR_MUFFLE);.

A fight behind a closed door is still heard, but only close by. The other maps, the hunters', the fleers' and the stairs', keep the default of two steps.

Checkpoint: Press F5, with seed 42, and press F1 to watch the whole floor. In the room in the middle of the map, a kobold, a green k, is awake. Wait, pressing the period key, and watch it: it leaves its room by the door in the room's bottom wall, and walks down the corridor below. On the seventh turn, it opens the closed door at the corridor's end, into the room of jackals. The next image shows the moment, before and after.

A kobold opens a door
Figure 19.6 — A kobold opens a door

Press F1 again, walk down to your own door, and open it, as at the last checkpoint. Then close it: press c, and the log asks "Close a door: which way? (Esc to stop)". Press j, and "You close the door." Try c and then h, toward the floor beside you, and "There's no open door there."

Press F1 once more, and look for the stairs, >. They've moved since the last checkpoint, from the corridor below the top right room into the room itself. At the first checkpoint, the distance map couldn't pass a closed door. So the stairs were placed on the farthest cell it could reach, and the room behind the door was out of reach.

Secret Doors

The third part hides one or two of each floor's doors as secret doors. A secret door looks like wall, and is one, as far as anything in the game can tell. That lasts until the player searches next to it with s and finds it. Then it's an ordinary closed door.

A Wall That Isn't

A secret door is a terrain of its own. Open Map.h, and add it to Terrain, below OpenDoor,:

SecretDoor,   // looks like a wall, and is one, until it's found

It blocks sight, as the wall it pretends to be does. The comment above blocksSight says so already, since a secret door is a door that isn't open. Open Map.cpp, and replace the whole of blocksSight with this:

bool Map::blocksSight(int x, int y) const
{
    Terrain terrain = at(x, y);
    return terrain == Terrain::Wall || terrain == Terrain::ClosedDoor ||
           terrain == Terrain::SecretDoor;
}

Nothing else in Map needs to know. A secret door can't be walked on, since isWalkable names everything that can. And it isn't one of the doors that isDoor names either. That matters in a moment: as far as the rest of the game is concerned, it's wall.

The views need no change at all. The glyph view draws anything it doesn't know as a wall, in its last branch. The tile view starts every cell as "wall" before it looks at the terrain. The look panel calls anything it doesn't know "A wall." too. So a secret door looks like a wall everywhere, without a line of code to make it so.

The save needs a letter for it. Open SaveGame.cpp, and add it below the open door's:

{ Terrain::SecretDoor, 'S' },

A secret door can be written into a save now, even though there are none on the map yet.

Hiding Doors

The secret doors are chosen once the floor is finished, stairs and all. They start as ordinary doors, closed or open. Then one or two of them, chosen at random, become secret. Not every door can be hidden, though, and two rules say which:

  • A door can be hidden only if its room keeps a door that isn't secret, and can still be reached from where the player starts without it. Otherwise the room would be a sealed box, and the player would never know it was there. A second door isn't enough on its own, since it might lead only to rooms that are cut off too.
  • A door can be hidden only if the stairs can still be reached from where the player starts, without it. Secret doors may hide rooms, but never the way down.

What lies beyond a secret door is another matter. If the door was the only way to the rooms on its far side, they're hidden with it, and only a search finds them. That's what secret doors are for. The next image shows the rules at work on a small map.

Which doors can be hidden
Figure 19.7 — Which doors can be hidden

Whether a place can be reached is checked the plain way. Hide the door, make a distance map from the player's start, and see whether the door's room and the stairs have a distance. If either doesn't, the door goes back as it was. Open Game.cpp, and add the number of secret doors, and the chance of finding one, to the unnamed namespace, below DOOR_MUFFLE:

// Each floor of rooms has SECRET_DOORS_MIN to SECRET_DOORS_MAX secret
// doors. Searching finds each one next to the player with a chance of
// 1 in FIND_ONE_IN (all *tune*)
constexpr int SECRET_DOORS_MIN = 1;
constexpr int SECRET_DOORS_MAX = 2;
constexpr int FIND_ONE_IN = 3;

The first rule also needs a room's doors, to count the ones that aren't secret. A room's doors are on the cells just outside it, where RoomGen looked for doorways. So they can be found the same way, by looking along its four sides. Add this to the unnamed namespace, below isCaveFloor:

// The doors into a room: the door cells along its four sides, just
// outside it, where RoomGen put them
std::vector<Point> doorsInto(const Map& map, const SDL_Rect& room)
{
    std::vector<Point> doors;
    for (int x = room.x; x < room.x + room.w; ++x)
    {
        for (int y : { room.y - 1, room.y + room.h })
        {
            if (map.isDoor(x, y))
            {
                doors.push_back({ x, y });
            }
        }
    }

The inner loop, for (int y : { room.y - 1, room.y + room.h }), is a range-for over a list of two numbers, written in the braces: the row above the room, and the row below it. It saves writing the loop's body out twice. Add the rest of the function below it:

    for (int y = room.y; y < room.y + room.h; ++y)
    {
        for (int x : { room.x - 1, room.x + room.w })
        {
            if (map.isDoor(x, y))
            {
                doors.push_back({ x, y });
            }
        }
    }
    return doors;
}

The second pair of loops does the same for the columns to the left and to the right of the room. Since isDoor doesn't count a secret door, the list holds only the doors that aren't hidden.

The doors are hidden in a random order, so that it isn't always the first room's that go. Add <utility>, for std::swap, below #include <sstream>:

#include <utility>    // std::swap, to shuffle the doors

Then add the first part of the function below newLevel:

// 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 could no longer be reached. 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
void Game::placeSecretDoors(const std::vector<SDL_Rect>& rooms)
{
    // Every door, with the room it leads into
    struct Doorway
    {
        Point door;
        const SDL_Rect* room;
    };
    std::vector<Doorway> doorways;
    for (const SDL_Rect& room : rooms)
    {
        for (Point door : doorsInto(level_.map, room))
        {
            doorways.push_back({ door, &room });
        }
    }
    for (int i = static_cast<int>(doorways.size()) - 1; i > 0; --i)
    {
        std::swap(doorways[i], doorways[rng_.roll(0, i)]);
    }

In the preceding code, Doorway is a struct that only this function uses, so it's declared inside it. It holds a door, and a pointer to the room it leads into, which is one of the rooms that the function was given. The pointer stays good, since the rooms aren't changed while the function runs. The loop at the end is Chapter 12's shuffle, the one that gave each potion its color. It works back from the last doorway, and swaps each with one chosen at random from those up to it, itself included.

Add the next part below it, with a blank line in between:

int wanted = rng_.roll(SECRET_DOORS_MIN, SECRET_DOORS_MAX);
int hidden = 0;
for (const Doorway& doorway : doorways)
{
    if (hidden == wanted)
    {
        return;
    }
    if (doorsInto(level_.map, *doorway.room).size() < 2)
    {
        continue;   // it's the room's only door that isn't secret
    }
    Point door = doorway.door;
    Terrain before = level_.map.at(door.x, door.y);
    level_.map.set(door.x, door.y, Terrain::SecretDoor);

The loop stops once enough doors are hidden. By the first rule, a door whose room has fewer than two doors that aren't secret is left as it is. That's this door alone, since it isn't secret yet. Otherwise the door is hidden, for now, and what it was before is kept, in case it has to go back. Add the rest of the function below:

        // Hidden, the door mustn't cut off its own room, tested from the
        // cell just inside it, nor the stairs
        const SDL_Rect& room = *doorway.room;
        Point inside = { SDL_clamp(door.x, room.x, room.x + room.w - 1),
                         SDL_clamp(door.y, room.y, room.y + room.h - 1) };
        DijkstraMap fromStart;
        fromStart.compute(level_.map, { player_.pos });
        bool cutOff = fromStart.at(inside) == DijkstraMap::UNREACHED ||
                      (level_.depth < LAST_FLOOR &&
                       fromStart.at(level_.stairs) == DijkstraMap::UNREACHED);
        if (cutOff)
        {
            level_.map.set(door.x, door.y, before);
            continue;
        }
        ++hidden;
    }
}

The room is tested from the cell just inside the door. That's the door's own cell, pulled into the room by Chapter 6's SDL_clamp. A door in the top wall is a row above the room, so it gets the room's top row, and keeps its column.

The distance map is made from the player's start with the door hidden. A secret door isn't walkable, and isn't a closed door either, so the map can't go through it. If the cell inside the room is out of reach, or the stairs are, cutOff is true. Then the door goes back to what it was, and the next one is tried. The last floor has no stairs, so they're checked only above it.

The function is called once the stairs are down, on floors of rooms only, since caves have no doors. In newLevel, add this below the block that places the stairs:

if (!isCaveFloor(depth))
{
    placeSecretDoors(areas);
}

Open Game.h, and declare it below placeGold's declaration:

void placeSecretDoors(const std::vector<SDL_Rect>& rooms);

The floors have secret doors now, and nothing can find them.

Searching

Searching looks at the eight cells around the player, and finds each secret door there with a chance of one in three. It takes a turn, whether it finds anything or not. So searching a whole corridor's walls is slow, and a monster can come along while you're at it.

Design note: Rogue's searching. In Rogue, s searched the eight squares around you too, and its version 5.4.4 gives each kind of secret its own odds. A secret door was found one search in five, a trap one in two, and a hidden passage one in three. Rogue had eight traps: a trapdoor, an arrow trap, a sleeping gas trap, a beartrap, a teleport trap, a poison dart trap, a rust trap and a mysterious trap. This game keeps one chance for everything, FIND_ONE_IN, which is easier to remember, and a single number to tune.

Open Game.cpp, and replace the comment above handlePlayingKey with this:

// Esc opens the menu, > goes down the stairs, g picks up, i shows the
// inventory, d drops, x starts looking around, c closes a door, s
// searches, and the moving keys move the player

Then add the key below the block for c:

if (key.key == SDLK_S)
{
    search();
    redraw_ = true;
    return;
}

Add the search itself below closeDoor:

// Searches the eight cells around the player, which takes a turn. Each
// secret door there is found with a chance of 1 in FIND_ONE_IN, and
// becomes a closed door
void Game::search()
{
    log_.markRead();
    bool found = false;
    for (Point step : DIRECTIONS)
    {
        Point cell = { player_.pos.x + step.x, player_.pos.y + step.y };
        bool secret = level_.map.at(cell.x, cell.y) == Terrain::SecretDoor;
        if (secret && rng_.roll(1, FIND_ONE_IN) == 1)
        {
            level_.map.set(cell.x, cell.y, Terrain::ClosedDoor);
            log_.add("You find a secret door!");
            found = true;
        }
    }
    if (!found)
    {
        log_.add("You search, and find nothing.");
    }
    finishTurn();
}

In the preceding code, every secret door around the player gets its own roll, so one search can find two. A door that's found becomes a closed door, not an open one, whatever it was before it was hidden. It's been shut all this time. "You search, and find nothing." tells the player the turn went somewhere. Open Game.h, and declare it below closeDoor's declaration:

void search();

Last, F1's debugging aid should show secret doors for what they are, or testing them would mean searching every wall. Back in Game.cpp, in drawMap, find this line:

Terrain terrain = level_.map.at(x, y);

Change it to these:

// The debugging aid shows secret doors for what they are
Terrain terrain = level_.map.at(x, y);
if (revealMap_ && terrain == Terrain::SecretDoor)
{
    terrain = Terrain::ClosedDoor;
}

This change is only to what's drawn. The map still holds a secret door, so F1 changes nothing else, and searching still has to find it.

Checkpoint: Press F5, with seed 42. Your room's bottom wall has only one door now, the closed one below you. Where the open door was, to its left, there's wall. Press F1, and the door is back, drawn as a closed door, since F1 shows a secret door for what it is.

With F1 off again, walk down and to the left three times, with b, which puts you just above the hidden door. Search, with s, and "You search, and find nothing." Keep searching: with seed 42, the fourth search finds it. "You find a secret door!" The next image shows the wall before, and the door after.

A secret door, before and after a search
Figure 19.8 — A secret door, before and after a search

The door is an ordinary closed door now: walk into it, and it opens. On another seed, or after other steps, it might take more searches or fewer, since each has its chance of one in three.

Seed 42 has a second secret door, which F1 shows too: the door in the right wall of the room in the bottom left corner. That room keeps its top door, so it can still be reached, as the first rule says. But the corridor behind the secret door is the only way to the room to its right. So that room is hidden too, until a search finds the door. A zombified kobold and a shrieker wait in it, with two items and some gold.

Traps

The fourth part hides traps on every floor, of five kinds, and springs one when the player steps on it. A trap is hidden until it's sprung. From then on, it's known, and drawn as a ^ in its kind's color. For now, the only way to find one is the hard way. The last part adds searching for traps, and a question before stepping onto one you know of.

Five Kinds of Trap

A trap is a thing on the floor, not a terrain. So it gets a header of its own, as the items and the monsters have. Add a Header File (.h) called Trap.h, and type in its start:

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

// The kinds of trap, and what each one does
enum class TrapKind
{
    Dart,           // 2 to 6 damage, less the player's defense
    PoisonNeedle,   // poisons the player
    Alarm,          // a noise loud enough to wake much of the floor
    Teleport,       // takes the player somewhere else on the floor
    Trapdoor,       // drops the player to the next floor, with a fall
};

Each kind does something different. The code that springs a trap, later in this part, does each one in turn. The comments say what each does: a dart hurts, a poison needle poisons, an alarm makes a noise, a teleport trap moves you, and a trapdoor drops you to the floor below.

Below the kinds, add what every trap of a kind has in common, and the table of the five:

// What every trap of a kind has: its name in a save, its name in messages,
// and the color of its ^, and of its tile
struct TrapDef
{
    TrapKind kind;
    const char* id;
    const char* name;
    SDL_Color color;
};

inline constexpr TrapDef TRAP_DEFS[] = {
    { TrapKind::Dart, "dart", "dart trap", { 200, 200, 215, 255 } },
    { TrapKind::PoisonNeedle, "poison_needle", "poison needle",
      { 130, 205, 70, 255 } },
    { TrapKind::Alarm, "alarm", "alarm trap", { 240, 205, 70, 255 } },
    { TrapKind::Teleport, "teleport", "teleport trap",
      { 175, 120, 240, 255 } },
    { TrapKind::Trapdoor, "trapdoor", "trapdoor", { 180, 125, 75, 255 } },
};

The struct TrapDef holds what a kind has besides its place in the enum. Its id is its name in a save, its name is its name in messages, and its color is the color it's drawn in. The table is inline constexpr, as the monsters' and the items' tables were before Chapter 16 moved them into data files. So every file that includes Trap.h shares one copy. The names are const char*, plain C strings, since a constexpr table can't hold a std::string.

The traps stay in the code, not in a data file. There are only five, and each does something of its own, which the code has to do anyway. So a file that could add a sixth kind couldn't make it do anything. Add a way to find a kind's row of the table, below it:

// The kind's row of TRAP_DEFS
inline const TrapDef& trapDefOf(TrapKind kind)
{
    for (const TrapDef& def : TRAP_DEFS)
    {
        if (def.kind == kind)
        {
            return def;
        }
    }
    return TRAP_DEFS[0];
}

The function is inline, since it's defined in a header that several files include. It returns a reference into the table, so nothing is copied. Every kind has a row, so the loop always finds one, and the last line never runs. The compiler can't know that, though, and every way out of a function that returns something must return something.

Last, add a trap itself, below trapDefOf:

// A trap on a floor: where it is, its kind, and whether the player knows
// it's there. A hidden trap is found by searching, or by stepping on it
struct Trap
{
    Point pos;
    TrapKind kind = TrapKind::Dart;
    bool known = false;
};

A trap is where it is, what kind it is, and whether the player knows it's there. Each floor keeps its traps as it keeps its monsters and items. Open Level.h, and add the new header below #include "Monster.h":

#include "Trap.h"

Then replace the comment above struct Level with this:

// One floor of the dungeon: how deep it is, its map, the monsters, the
// items and the traps on it, and where its stairs down are. Only the floor
// the player is on is kept, since the way down is one way

And add the traps below items:

std::vector<Trap> traps;

A new floor starts with a new Level, in newLevel. So a floor's traps go when the floor does, with its monsters and items.

Hiding Traps

Every floor gets two traps, and one more for every two floors down, so the ninth floor has six. They go where monsters and items go, in the areas, but never in the first, where the player starts. A trap must be on the ground, and never on the stairs, an item or another trap. Each kind is as likely as the others, with one exception: the last floor has no floor below it, so it has no trapdoors.

Open Game.cpp, and add the traps' numbers to the unnamed namespace, below FIND_ONE_IN:

// Each floor hides TRAPS_BASE traps, and one more for every two floors
// down. What they do: a dart's damage, an alarm's loudness, and the
// damage of a fall through a trapdoor (all *tune*)
constexpr int TRAPS_BASE = 2;
constexpr int DART_MIN = 2;
constexpr int DART_MAX = 6;
constexpr int ALARM_LOUDNESS = 30;
constexpr int FALL_MIN = 2;
constexpr int FALL_MAX = 5;

The table's size will be needed, and std::size gives the size of an array. Add its header below #include "Game.h":

#include <iterator>   // std::size, for the kinds of trap

Each trap's cell is chosen at random, with tries, as the items' cells are. Add the function that hides them below descend:

// 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 or another trap. Every
// kind is as likely as the others, but a trapdoor can't be on the last
// floor, where there's nowhere to fall to: it's last in TRAP_DEFS, so the
// roll leaves it out there
void Game::placeTraps(const std::vector<SDL_Rect>& areas)
{
    int lastArea = static_cast<int>(areas.size()) - 1;
    if (lastArea < 1)
    {
        return;
    }
    int lastKind = static_cast<int>(std::size(TRAP_DEFS)) - 1;
    if (level_.depth == LAST_FLOOR)
    {
        --lastKind;
    }

A floor with fewer than two areas gets no traps, since the first area is the player's. The kinds are chosen by their row in the table, from 0 to lastKind. The trapdoor is the last row, on purpose, so leaving it out on the last floor only takes one less from lastKind. Add the rest of the function below it, with a blank line in between:

    int wanted = TRAPS_BASE + level_.depth / 2;
    for (int i = 0; i < wanted; ++i)
    {
        for (int tries = 0; tries < 20; ++tries)
        {
            const SDL_Rect& area = areas[rng_.roll(1, lastArea)];
            Point pos = { area.x + rng_.roll(0, area.w - 1),
                          area.y + rng_.roll(0, area.h - 1) };
            bool free = level_.map.isWalkable(pos.x, pos.y) &&
                        level_.map.at(pos.x, pos.y) != Terrain::StairsDown &&
                        floorItemAt(pos) == nullptr && trapAt(pos) == nullptr;
            if (free)
            {
                Trap trap;
                trap.pos = pos;
                trap.kind = TRAP_DEFS[rng_.roll(0, lastKind)].kind;
                level_.traps.push_back(trap);
                break;
            }
        }
    }
}

The tries work as the gold's do since Chapter 18: up to twenty cells are tried for each trap, and the first one that's free gets it.

The check uses trapAt, which finds the trap on a cell, as monsterAt finds a monster. Add it below moveMonster:

// The trap on a cell, or nullptr if there's none
Trap* Game::trapAt(Point pos)
{
    for (Trap& trap : level_.traps)
    {
        if (trap.pos == pos)
        {
            return &trap;
        }
    }
    return nullptr;
}

The trap is returned by pointer, so the caller can change it, to mark it known. And nullptr says there's no trap there. Every floor has traps, so newLevel should hide them. Replace the comment above newLevel with this:

// Makes the floor at a depth, in place of the one before: rooms, or a
// cave, the player in the middle of the first area, its monsters, items
// and gold, 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, add this below the block that places the secret doors:

placeTraps(areas);

The traps come after the secret doors, and after everything else on the floor, so that they can keep off the stairs and the items. Caves get them too, since placeTraps works on areas, whichever generator made them. Open Game.h, and declare placeTraps below placeSecretDoors's declaration:

void placeTraps(const std::vector<SDL_Rect>& areas);

Then declare trapAt below monsterAt's declaration:

Trap* trapAt(Point pos);

The traps are on the floor now, hidden, and nothing springs them yet.

Drawing Traps

A known trap is drawn as a ^ in its kind's color in the glyph view, and as a tile in the tile view, tinted with the same color. Both views get a function for it, so the view's interface gets one more. Open View.h, and add it below drawItem's declaration:

// A trap, in its kind's color: lit if the player can see it, and
// otherwise as the player remembers it
virtual void drawTrap(Point cell, SDL_Color color, bool lit) const = 0;

Since the function is = 0, every view must have one, and the compiler won't make a GlyphView or a TileView until it does. Open GlyphView.h, and declare it below drawItem's declaration:

void drawTrap(Point cell, SDL_Color color, bool lit) const override;

Open GlyphView.cpp, and add it at the end of the file, below drawCell:

// A trap is a ^ in its kind's color, or in the memory's color
void GlyphView::drawTrap(Point cell, SDL_Color color, bool lit) const
{
    drawCell(cell, '^', lit ? color : Palette::MEMORY_WALL);
}

Open TileView.h, and declare it below drawItem's declaration:

void drawTrap(Point cell, SDL_Color color, bool lit) const override;

Open TileView.cpp, and add it below drawItem:

// Every trap has the same tile, tinted with its kind's color
void TileView::drawTrap(Point cell, SDL_Color color, bool lit) const
{
    drawTile(cell, "trap", lit ? color : remembered(color));
}

All five kinds share the one tile, trap, from the atlas you copied earlier. The tint tells them apart, as it tells the potions apart. A remembered trap is darkened by remembered, as a remembered item is. The next image shows the five kinds in both views.

The five kinds of trap
Figure 19.9 — The five kinds of trap

Which traps to draw is up to Game. Open Game.cpp, and add this below drawItems:

// The traps the player knows of, in sight or remembered. With revealMap_,
// a debugging aid, the hidden ones too
void Game::drawTraps() const
{
    for (const Trap& trap : level_.traps)
    {
        Point cell = trap.pos;
        bool visible = level_.map.isVisible(cell.x, cell.y);
        bool seen = visible || level_.map.isExplored(cell.x, cell.y);
        if ((trap.known && seen) || revealMap_)
        {
            view_->drawTrap(cell, trapDefOf(trap.kind).color, visible);
        }
    }
}

A trap is drawn if the player knows of it and can see its cell or remember it. With F1 on, every trap is, hidden or not. The look panel needs the same question about a single cell. Add a function that answers it below drawTraps:

// The trap on a cell, if the player knows of it, or nullptr. With
// revealMap_, any trap
const Trap* Game::knownTrapAt(Point cell) const
{
    for (const Trap& trap : level_.traps)
    {
        if (trap.pos == cell && (trap.known || revealMap_))
        {
            return &trap;
        }
    }
    return nullptr;
}

The answer is the trap there, if the player knows of it, or nullptr. With F1 on, it's any trap. In draw, add this above drawItems();:

drawTraps();

The traps are drawn before the items and the monsters, so anything standing on a trap is drawn over it. Then, in drawLookPanel, add this below the open door's branch:

const Trap* trap = knownTrapAt(*cell);
if (trap != nullptr)
{
    ground = capitalized(withArticle(trapDefOf(trap->kind).name));
}

A known trap takes the place of the ground's name, "A dart trap." rather than "Floor.", since it's what's worth knowing about the cell. Open Game.h, and declare the two new functions below visibleMonsterAt's declaration:

const Trap* knownTrapAt(Point cell) const;
void drawTraps() const;

The const at the end of both matters. Drawing changes nothing, so drawLookPanel and draw are const. And a const function can only call other const functions of its own object.

Springing a Trap

A trap springs when the player steps onto it, and becomes known. Monsters never spring them. It's their dungeon, and they know where the traps are. The trapdoor needs one more thing first: it drops the player to the floor below, just as the stairs take them there. The two should share the code that arrives on a new floor.

Open Game.cpp, and replace the whole of 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
void Game::descend()
{
    log_.markRead();
    if (level_.map.at(player_.pos.x, player_.pos.y) != Terrain::StairsDown)
    {
        log_.add("There are no stairs down here.");
        return;
    }
    arriveOnFloor("You go down the stairs");
}

The check for the stairs stays in descend, and everything after it moves into a function of its own. Add it below descend, above placeTraps:

// The player arrives on the next floor down, by the stairs or by a fall.
// It takes no turn there; the turn count still goes up
void Game::arriveOnFloor(const std::string& how)
{
    newLevel(level_.depth + 1);
    ++turn_;
    log_.add(how + " to floor " + std::to_string(level_.depth) + ".");
    if (level_.depth == LAST_FLOOR)
    {
        log_.add("This is the bottom of the dungeon.");
    }

    // Each new floor is a checkpoint: if the game stops without saving,
    // as in a crash, the run goes on from here
    if (!writeSave())
    {
        log_.add("The run couldn't be saved: " +
                 std::string(SDL_GetError()));
    }
}

In the preceding code, the lines that came after the check in descend are all here, with the message built from how: "You go down the stairs to floor 2." or "You fall through to floor 2." The checkpoint save is here too, so a fall writes one, as the stairs do. Open Game.h, and declare it below playerStep's declaration:

void arriveOnFloor(const std::string& how);

Now the trap itself. Back in Game.cpp, add the springing below playerStep:

// The player has stepped onto a trap: it's known from now on, and it does
// what it does. Returns true if the player fell through a trapdoor to the
// floor below, which ends the turn there
bool Game::springTrap(Trap& trap)
{
    trap.known = true;
    switch (trap.kind)
    {
    case TrapKind::Dart:
        log_.add("A dart flies out of the wall, and hits you!");
        player_.hp -= rollDamage(rng_, DART_MIN, DART_MAX, 0,
                                 player_.defense);
        if (!player_.isAlive())
        {
            player_.killedBy = "a dart trap";
        }
        break;
    case TrapKind::PoisonNeedle:
        log_.add("A needle pricks you. You feel very sick!");
        player_.poisoned = SDL_max(player_.poisoned, POISON_TURNS);
        break;
    case TrapKind::Alarm:

In the preceding code, the trap is known from the moment it springs. A dart does 2 to 6 damage, less the player's defense, with rollDamage, as a monster's blow does. And a poison needle poisons for six turns, as a potion of poison does. If the dart kills, killedBy gives the death screen its cause. Add the rest of the function below it:

        log_.add("An alarm rings out across the floor!");
        makeNoise(trap.pos, ALARM_LOUDNESS);
        break;
    case TrapKind::Teleport:
        log_.add("The floor flashes, and you're somewhere else!");
        teleportPlayer();
        break;
    case TrapKind::Trapdoor:
        log_.add("A trapdoor opens under you!");
        player_.hp -= rng_.roll(FALL_MIN, FALL_MAX);
        if (!player_.isAlive())
        {
            player_.killedBy = "a fall through a trapdoor";
            break;
        }
        arriveOnFloor("You fall through");
        return true;
    }
    return false;
}

In the preceding code, an alarm is a noise of 30 from the trap, loud enough to wake most of the floor, and send whatever is wandering to see what it was. A teleport trap does what Chapter 12's scroll of teleportation does. And a trapdoor does 2 to 5 damage, from the fall, and then the player arrives on the next floor. The function returns true only then, since the player's turn ended on another floor. The rest of it must not go on with the floor that's gone.

A fall that kills happens before the player arrives anywhere. So they die on the floor the trapdoor was on, and the death screen says "Killed by a fall through a trapdoor". Open Game.h, and declare springTrap below playerStep's declaration:

bool springTrap(Trap& trap);

The step that springs a trap is in playerStep. Back in Game.cpp, replace the comment above playerStep with this:

// Stepping onto a monster attacks it. An attack or a step takes a turn, and
// then the rest of the turn plays out. Bumping into a wall takes no turn,
// unless the player staggered into it. Stepping onto a trap springs it

The trap on the cell the player steps to is looked up first, before the player moves. In playerStep, find these lines:

else if (level_.map.isWalkable(to.x, to.y))
{
    player_.pos = to;

Change them to these:

else if (level_.map.isWalkable(to.x, to.y))
{
    Trap* trap = trapAt(to);
    player_.pos = to;

Then, further down in playerStep, find these lines:

const FloorItem* here = floorItemAt(player_.pos);
if (moved && here != nullptr)
{
    log_.add("You see " + itemName(here->item) + " here.");
}
finishTurn();

Change them to these:

const FloorItem* here = floorItemAt(player_.pos);
if (moved && here != nullptr)
{
    log_.add("You see " + itemName(here->item) + " here.");
}

// A trapdoor ends the turn on the floor below, as the stairs do
if (moved && trap != nullptr && springTrap(*trap))
{
    return;
}
finishTurn();

Traps spring only on a real step, moved, and not when the player waits on a trap with the period key. A trapdoor has already finished the turn on the floor below, so playerStep returns at once. It skips finishTurn, which would play out a turn of the new floor's monsters before the player has seen them.

Checkpoint: Type 23 into Command Arguments, and press F5. Press F1 to see the floor: two traps lie in the room just below yours. There's a green one at the top, and a yellow one at the bottom, beside a giant white rat.

Turn F1 off, and walk: down and to the right three times with n, down once with j, and down and to the right once more with n. The green trap is just below you now, though you can't see it. Step onto it with j: "A needle pricks you. You feel very sick!" The side panel says you're poisoned, and the trap is drawn now, a green ^, since you know where it is.

Step off it with j, and back onto it with k, and it pricks you again: nothing asks first, yet. Hover the mouse over it, and the look panel says "A poison needle." A save doesn't keep the traps yet either. Save and quit with Esc and S, continue, and press F1. The traps are gone, hidden and known alike, until the last part adds them to the save.

Known Traps

The last part makes traps fair. Searching finds them, as it finds secret doors. A trap the player knows of is never walked onto by accident, since the game asks first. And the save keeps the traps, so a floor is the same after a break as before it.

Searching for Traps

A hidden trap next to the player is found with the same chance as a secret door, one in three for each search. Open Game.cpp, and replace the comment above search with this:

// Searches the eight cells around the player, which takes a turn. Each
// secret door or hidden trap there is found with a chance of 1 in
// FIND_ONE_IN. A secret door found becomes a closed door, and a trap found
// is known, and drawn

Then, in search, add these below the block that finds a secret door:

Trap* trap = trapAt(cell);
bool hidden = trap != nullptr && !trap->known;
if (hidden && rng_.roll(1, FIND_ONE_IN) == 1)
{
    trap->known = true;
    log_.add("You find " +
             withArticle(trapDefOf(trap->kind).name) + "!");
    found = true;
}

A hidden trap is one that isn't known yet. Finding it makes it known, which is all it takes for drawTraps to draw it. The log names it, with withArticle, as the look panel does: "You find a poison needle!"

Asking First

A step onto a trap the player knows of is almost always a mistake, a key pressed once too often, or in the wrong direction. So the game asks: "Step onto the dart trap? (y or n)". Only y takes the step, and any other key stays put. Asking needs a state of its own, as closing a door does, and the step has to be kept until the answer comes.

Open Game.h, and add the new state to GameState, below Closing:

Confirming,  // asking whether to step onto a trap the player knows of

In Game.h, change void playerStep(Point step); to void playerStep(Point step, bool confirmed = false);. A step that's been answered with y comes back to playerStep with confirmed set, and every other step leaves it out. Then declare the state's key handler below playerStep's declaration:

void handleConfirmKey(const SDL_KeyboardEvent& key);

The step waiting for its answer is kept in a member. Add it below lookCursor_:

// The step waiting for a yes, in the Confirming state
Point pendingStep_;

Open Game.cpp, and in handleKey, add the state's case below the case for GameState::Closing:

case GameState::Confirming:
    handleConfirmKey(key);
    break;

Replace the comment above playerStep with this:

// Stepping onto a monster attacks it. An attack or a step takes a turn, and
// then the rest of the turn plays out. Bumping into a wall takes no turn,
// unless the player staggered into it. Stepping onto a trap springs it,
// and a step onto a trap the player knows of is asked about first, unless
// it's confirmed already

In Game.cpp, change void Game::playerStep(Point step) to void Game::playerStep(Point step, bool confirmed). The confusion of Chapter 14 needs a thought. A confused player's step can stagger off in a direction of its own, and Chapter 14's staggered says when it has.

A stagger isn't chosen, so it isn't asked about: you can stagger onto a trap you know of. But a step that's been answered with y has had its roll for a stagger already, before the question, so it mustn't roll again. In playerStep, find these lines:

// Confused, half of the player's steps go in a direction of their own
bool moving = step.x != 0 || step.y != 0;
bool staggered = false;
if (moving && player_.confused > 0 && rng_.roll(0, 1) == 0)

Change them to these:

// Confused, half of the player's steps go in a direction of their own.
// A confirmed step has had its roll
bool moving = step.x != 0 || step.y != 0;
bool staggered = false;
if (!confirmed && moving && player_.confused > 0 &&
    rng_.roll(0, 1) == 0)

A confirmed step never rolls, since the test of confirmed comes first, and && stops at the first part that's false.

Now the question itself, before the player moves. Further down in playerStep, find these lines:

Trap* trap = trapAt(to);
player_.pos = to;

Change them to these:

// A trap the player knows of isn't walked onto by accident, but
// can't be avoided in a stagger
Trap* trap = trapAt(to);
bool knownTrap = trap != nullptr && trap->known && moving;
if (knownTrap && !staggered && !confirmed)
{
    std::string name = trapDefOf(trap->kind).name;
    pendingStep_ = step;
    state_ = GameState::Confirming;
    log_.add("Step onto the " + name + "? (y or n)");
    return;
}

player_.pos = to;

If a step onto a known trap wasn't a stagger, and hasn't been confirmed, it's kept in pendingStep_. Then the function asks, and returns, with no turn taken. The step kept is the one asked about, after any stagger, so y takes exactly that step. A wait on a known trap, with the period key, isn't a step onto it, and isn't asked about.

The answer comes to its own handler. Add it below playerStep:

// A yes or no about stepping onto a trap the player knows of: y takes
// the step, and any other key stays put
void Game::handleConfirmKey(const SDL_KeyboardEvent& key)
{
    state_ = GameState::Playing;
    if (key.key == SDLK_Y)
    {
        playerStep(pendingStep_, true);
    }
    else
    {
        log_.add("You stay where you are.");
    }
    redraw_ = true;
}

The state goes back to Playing first. That way, the step, if it's taken, happens in the ordinary way. And a trapdoor or a death can change the state again after it. Anything but y is a no, Esc included.

Traps in a Save

A save is a picture of the run, and the traps are part of the floor. Each trap gets a record of its own, [trap], with its kind by its id, its cell, and whether the player knows of it:

[trap]
kind = poison_needle
pos = 14 11
known = yes

Open SaveGame.cpp. In writeSave, add these below the loop that writes the items, with a blank line in between:

for (const Trap& trap : level_.traps)
{
    text += "\n[trap]\n";
    addField(text, "kind", trapDefOf(trap.kind).id);
    addPoint(text, "pos", trap.pos);
    addField(text, "known", trap.known ? "yes" : "no");
}

Reading a trap back is like reading a monster: the kind is named by its id, and a name that isn't any kind's is reported. Add this to the unnamed namespace, below readMonster:

// A trap, as it was: its kind, by its name, where it is, and whether
// the player knows of it
void readTrap(FieldReader& fields, std::vector<Trap>& traps)
{
    Trap trap;
    std::string kind = fields.text("kind");
    bool named = false;
    for (const TrapDef& def : TRAP_DEFS)
    {
        if (kind == def.id)
        {
            trap.kind = def.kind;
            named = true;
        }
    }
    if (!named && !kind.empty())
    {
        fields.report(fields.lineOf("kind"),
                      "no kind of trap is called '" + kind + "'");
    }
    trap.pos = readPoint(fields, "pos");
    trap.known = fields.yesNo("known");
    traps.push_back(trap);
}

In the preceding code, a kind is looked for in the table, row by row. A record with no kind at all, or an empty one, is reported by fields.text already. So only a name that's there but wrong needs a report of its own. Then, in loadSave, add this below the branch for item records:

else if (record.type == "trap")
{
    readTrap(fields, level.traps);
}

The whole save's checks, from Chapter 17, look at everything together once it's all read. A trap must be on the map, on the ground, one to a cell, and a trapdoor can't be on the last floor. Add the first part of the traps' checks below the items':

for (size_t i = 0; i < level.traps.size(); ++i)
{
    const Trap& trap = level.traps[i];
    std::string what = std::string("the ") + trapDefOf(trap.kind).id;
    if (!checkPlace(level.map, trap.pos, what, errors))
    {
        continue;
    }
    bool shared = false;
    for (size_t j = 0; j < i; ++j)
    {
        shared = shared || level.traps[j].pos == trap.pos;
    }
    if (shared)
    {
        errors.push_back(SAVE_FILE + ": " + what + " at " +
                         std::to_string(trap.pos.x) + ", " +
                         std::to_string(trap.pos.y) +
                         " shares its cell");
    }

In the preceding code, the checks are the items' own, from Chapter 17, for a trap. One thing differs: std::string("the ") turns the first piece into a std::string before anything is added to it. A kind's id is a const char*, and "the " is one too. So "the " + id would ask C++ to add two pointers, which it refuses to do. Add the rest below it:

    if (trap.kind == TrapKind::Trapdoor && level.depth == LAST_FLOOR)
    {
        errors.push_back(SAVE_FILE + ": a trapdoor can't be on the "
                         "last floor");
    }
}

The save's version stays 3. Doors changed it, earlier in the chapter, and nothing between then and now was ever released. So a save without traps never needs to be told apart from one with them.

Checkpoint: Press F5, with seed 23. Walk as at the last checkpoint, down and to the right three times with n, down once with j, and down and to the right once with n, which puts the poison needle just below you. Search, with s: "You search, and find nothing." Search again, and "You find a poison needle!" The trap is drawn now, a green ^. Step toward it with j, and the game asks "Step onto the poison needle? (y or n)". Press n, and "You stay where you are." The next image shows the question.

Asked before a known trap
Figure 19.10 — Asked before a known trap

Press j again, and this time y: "A needle pricks you. You feel very sick!" Then save and quit, with Esc and S, and continue. The trap is still there, still known, in the save as known = yes, and the floor is as you left it.

Playing the Game

The dungeon has started to hide things. A floor of rooms is a run of closed doors now, and every one is a choice: open it, and see what's there, or leave it shut. Behind you, a closed door is the best defense the game has had. Rats and spiders can't follow you through it, and even the kobolds and the orcs lose a move opening it.

A dead end isn't always one. Any corridor that stops for no reason, or a room that should have a door on the side facing the rest of the floor, is worth a few searches. And searching has a second use: before a long walk through rooms you haven't seen, a search here and there finds the traps beside you before you find them the hard way.

Understanding the Code

Doors are terrain, put in the doorways by RoomGen, closed or open. They go where a corridor meets a room's side with wall on both sides of it, and never beside another door. A closed door blocks sight and movement. The player opens one by walking into it, or closes one with c, through the Closing state.

Monsters with the opens doors trait open doors through moveMonster, and canEnter lets them choose one as their next step. The distance maps count a closed door as two steps, so hunters find the way through, and a noise counts one as four cells.

A secret door is terrain that's drawn, and blocks, and is named, as a wall. On each floor of rooms, placeSecretDoors hides one or two doors, but never one that would cut off its own room, or the stairs, from the player's start. Searching, with s, finds each secret door and hidden trap next to the player with a chance of one in three.

A trap is a thing on the floor, kept in the Level, with a kind from TRAP_DEFS and whether it's known. Every area but the first can get them, from placeTraps, and springTrap does what each kind does when the player steps on one. The trapdoor drops the player through arriveOnFloor, which the stairs share. Once known, a trap is drawn, and a step onto it asks first, through the Confirming state. The save writes a [trap] for each, and checks them with everything else.

Experimenting

Try each of these, and put each back afterward:

  • Every door shut. Set CLOSED_DOOR_PERCENT, in RoomGen.cpp, to 100. Each room is closed off, and the monsters that can't open doors stay in the rooms they started in, until you let them out.
  • Sharp eyes. Set FIND_ONE_IN to 1, and every search finds every secret door and trap around you at once. It's dull, which is why it's three.
  • A minefield. Set TRAPS_BASE to 20, and press F1 on a new floor. Most of the floor's areas have traps in them, and walking anywhere is a gamble until you've searched.
  • Thick doors. Set DOOR_MUFFLE to 100. A noise never gets past a closed door now, so a fight behind one wakes nothing on the other side.
  • Rats with hands. Add traits = opens_doors to the giant white rat's record in monsters.txt, below its sight, and start the game again, without compiling. Shutting a door on a rat no longer helps.

Each of these changes how the floors play, which is why these numbers are marked *tune*.

Common Errors and Fixes

If the game didn't build, or doesn't behave as described, the problem is probably one of these.

C2259: 'GlyphView': cannot instantiate abstract class, in Game.h. The views' interface, View, has a new function, drawTrap, with = 0. So every view must have one of its own before the compiler will make one, and Game has a GlyphView as a member. Declare drawTrap in GlyphView.h, with override, and add it to GlyphView.cpp. The same message names TileView, if that's the view without one.

C2662: 'const Trap *Game::knownTrapAt(Point)': cannot convert 'this' pointer from 'const Game' to 'Game &', in drawLookPanel. The function was declared without const at the end. A const function, as drawLookPanel is, can only call const ones. Add const after knownTrapAt's parameters, in Game.h and in Game.cpp.

C2110: '+': cannot add two pointers, in SaveGame.cpp. The traps' checks were typed as "the " + trapDefOf(trap.kind).id, and both sides of that + are const char*. Make the first a std::string, as the chapter does: std::string("the ") + trapDefOf(trap.kind).id.

C2065: 'Trap': undeclared identifier, in Level.h, followed by C2923 and dozens more, in Game.h and Game.cpp. The floor's header, Level.h, doesn't include Trap.h. Add #include "Trap.h" below #include "Monster.h".

LNK2019: unresolved external symbol "public: bool __cdecl Map::isDoor(int,int)const ", referenced in function farthestFrom, followed by LNK2001 and LNK1120. The declaration of isDoor is in Map.h, but its body was never added to Map.cpp. Add it below isWalkable, in Map.cpp.

The game won't start, and a box says its data files have mistakes, starting with monsters.txt:76: unknown trait 'opens_doors'. The monsters' file names the new trait, four more times after that line, and Data.cpp doesn't know its name. Add { "opens_doors", Trait::OPENS_DOORS }, to the traits' names in Data.cpp, below the one for drains.

After c, the keys stop working, Esc included, though the debugging keys still do. The game is in the Closing state, and handleKey has no case for it. So no handler ever sees a key. Add the case to handleKey's switch, and stop the stuck game with Shift+F5.

You open a door, and the corridor beyond it stays dark until you take a step. The new openDoor doesn't call updateSight, so what you can see isn't worked out again until your next step does it. Add updateSight(); at the end of openDoor.

From your first room, you can see down the corridor behind the closed door. The closed door doesn't block sight, since blocksSight still names only the wall. Replace the whole of blocksSight, in Map.cpp, as the first part of the chapter does.

FAQ

Q) Why don't monsters spring traps?

A) It's their dungeon, and they know where the traps are. A trap that a monster could spring would also need its own messages, its own damage to monsters, and rules for which monsters notice. All of that would be for something the player mostly wouldn't see. The traps in this game are a hazard for the player, and the code stays small because of it.

Q) Why does a closed door count as two steps, and not one?

A) Because getting through one takes two moves: one to open it, and one to step into the doorway. A monster that counted it as one would think the way through a door as short as an open corridor beside it, and choose it when the corridor was really quicker.

Q) Why are there no doors in the caves?

A) A door needs a doorway, a gap between two walls where a corridor meets a room. A cave has no rooms and no corridors, only open ground and narrow throats. And a door in a throat would look like a door in the middle of nowhere. The caves have their traps, though, since placeTraps works on areas, whichever kind of floor they're on.

AI Exercise (Optional)

Every monster follows the same distance maps, so one that can't open doors waits at a closed door even when there's an open way around, as The Way Through a Door said. The fix is a second pair of maps, made for those monsters. 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. Monsters find their way on Dijkstra maps. DijkstraMap::compute(const Map& map, const std::vector<Point>& goals, int doorCost = DOOR_STEPS) counts a closed door, Terrain::ClosedDoor, as doorCost steps, and keeps the cost for DijkstraMap::fleeMap(const Map& map), which makes a map for running away. Each turn, Game makes toPlayer_ from the player's position, and fromPlayer_, its flee map. A hunting monster steps with Game::stepDown(monster, toPlayer_), and a fleeing one with stepDown(monster, fromPlayer_), which skips any cell that Game::canEnter(monster, cell) turns down. Only monsters with Trait::OPENS_DOORS can enter a closed door. I'd like the monsters that can't open doors to take an open way around when there is one. Give them a second toPlayer_, with a door cost so high that any open way is cheaper, and a flee map of their own, in which a closed door counts as a wall. Show me every change, with each curly brace on its own line, and explain it."

Notice what the preceding prompt does. It asks for a different door cost in each of the two new maps, and the difference matters. With a high cost, a door that's the only way to you still leads a monster to it, to wait there, as now. But a flee map made from that map would make the cells behind a closed door look farther from you than anywhere else. It would send a fleeing monster straight to the door it can't open.

When the answer comes back, check that the new maps are made where toPlayer_ and fromPlayer_ are, every turn, and that every step these monsters choose uses them. Check walkToward too, which makes a map of its own for the walk to a noise, or to where a monster last saw you. It needs the high cost for these monsters as well. Then try it with F1 on: shut a door between you and a rat that's after you, with an open way around, and watch which way it comes.

Summary

Doors are terrain that the player and some monsters can open, and that blocks sight while it's shut. Secret doors are doors that look like wall until a search finds them. Traps are hidden things on the floor, kept like items, that spring when they're stepped on and are asked about once they're known. In Chapter 20, the dungeon gets rooms made by hand: vaults, read from a data file and set into the generated floors, where the Amulets wait.