Rogue C# – Hidden Doorways

Hidden doorways and passages are one of the challenges in many games and Rogue is no exception. Every so often, you find yourself in a room with no doors and you have to run around searching for one before you could get out by pressing the ‘s’ key. It makes the game a little more challenging and stretches out the game experience, too. In this chapter, we’ll see how to add hidden doorways to our own roguelike by adapting existing classes and adding a new key command.

This post is part of a series on creating a roguelike game in C#. For the latest chapters and more information, please visit the Official Project Page on ComeauSoftware.com. The current code for this project is also available on Github.

The Algorithm

In the original requirements we established that 75% of the exits should be visible. I tried 50% but this seemed like a little much. We’ll assume that our rogue has reasonably good vision and is smart enough to find the exits at least 75% of the time. Many games provide some kind of visual cue for a player like a slight difference in color or cracks on the wall to hint at a hidden passage. Rogue’s basic graphics don’t provide as much of an opportunity for that.

The basic algorithm I came around to is this:

  1. When placing exits on the doors during the map generation, 25% of the time the program will instead set the MapSpace.SearchRequired property to True.
  2. The MapText() function, which assembles the map content string that is output to the main form, will refer to the SearchRequired property and decide what character to print.
  3. When the user presses the ‘s’ key, the program will look for any surrounding characters in which SearchRequired is True and change the settings so the doorway is shown.

Sounds like a great plan, doesn’t it? Let’s see how it worked out.

The Code

First, the MapLevel class gets a new constant.

private const int HIDDEN_EXIT_PCT = 25; 

The MapLevel.RoomGeneration() method is called for of the map’s nine regions while the map is being constructed. It randomly generates a room and then randomly replaces at least one of the wall characters with a doorway. That’s where the next step needs to happen. Let’s look at the original code for the north-facing doorways.

if (regionNumber >= 4 && rand.Next(101) <= ROOM_EXIT_PCT)  // North doorways
{
	doorway = rand.Next(westWallX + 1, eastWallX);
	levelMap[doorway, northWallY] = new MapSpace(ROOM_DOOR, false, false, doorway, northWallY);
	levelMap[doorway, northWallY - 1] = new MapSpace(HALLWAY, false, false, doorway, northWallY - 1);
	deadEnds.Add(levelMap[doorway, northWallY - 1], Direction.North);
	doorCount++;
}

In regions 4 through 9, this code selects a random space on the wall to replace and then creates the start of a hallway leading off of it and stores that space so the hallway generation procedure can pick it up later. Let’s make the first changes.

if (regionNumber >= 4 && rand.Next(1, 101) <= ROOM_EXIT_PCT)  // North doorways
{
    doorway = rand.Next(westWallX + 1, eastWallX);
    searchRequired = rand.Next(1, 101) <= HIDDEN_EXIT_PCT;
    levelMap[doorway, northWallY] = new MapSpace(ROOM_DOOR, false, searchRequired, doorway, northWallY);
    levelMap[doorway, northWallY - 1] = new MapSpace(HALLWAY, false, false, doorway, northWallY - 1);
    deadEnds.Add(levelMap[doorway, northWallY - 1], Direction.North);
    doorCount++;
}

This code repeats for south, east and west doorways so a similar line is needed in each of those blocks. I’ll talk about the change to the random function a bit later.

The SearchRequired property is set so now I started looking at the MapText() function and thinking about what it would reference to know what character to replace the doorway character with while the door was hidden. MapText() would need to know what characters were on either side of the door way to judge between horizontal and vertical and then do some swapping out of characters which starts to get unwieldy. That’s when I started questioning the algorithm.

The next idea was to have the RoomGeneration() method simply put the normal wall character in the MapCharacter property if the search is required and let MapText() insert the door. That’s easier but then it messes up the hallway generation which looks for a doorway on the room to decide what to do next.

Finally, I could just add an AltMapCharacter property to the MapSpace class and have the RoomGeneration() function populate it with with the character to display if a search is required. MapText() could then add it to the character priority decision.

I’ve said previously that I’m trying not to inflate the MapSpace class too much but it comes down to this question – would I rather add another property that can be automatically defined by constructors and changed as needed or do I want to add a lot of new code elsewhere that is more likely to introduce bugs?

Hiding the Doorway

So, MapSpace gets a new property. Code is code, after all, regardless where it’s placed. If the class can do it more efficiently, that’s where it goes.

public char? AltMapCharacter { get; set; }

Each section of the door placement code gets a new statement:

if (regionNumber >= 4 && rand.Next(1, 101) <= ROOM_EXIT_PCT)  // North doorways
{

    doorway = rand.Next(westWallX + 1, eastWallX);
    searchRequired = rand.Next(1, 101) <= HIDDEN_EXIT_PCT;
    levelMap[doorway, northWallY] = new MapSpace(ROOM_DOOR, false, searchRequired, doorway, northWallY);
    levelMap[doorway, northWallY].AltMapCharacter = searchRequired ? HORIZONTAL : null;
    levelMap[doorway, northWallY - 1] = new MapSpace(HALLWAY, false, false, doorway, northWallY - 1);
    deadEnds.Add(levelMap[doorway, northWallY - 1], Direction.North);
    doorCount++;

}

The MapCharacter property is set to the doorway and, if it needs to be searched for, the AltMapCharacter property is defined as a wall character.

Then we go back to MapText to decide which character should be prioritized. The IF statement now has four possible conditions.

// Standard priority - DisplayCharacter, ItemCharacter and then MapCharacter.
if (levelMap[x, y].DisplayCharacter != null)
    priorityChar = levelMap[x, y].DisplayCharacter;
else if (levelMap[x, y].ItemCharacter != null)
    priorityChar = levelMap[x, y].ItemCharacter;
else if (levelMap[x, y].AltMapCharacter != null)
    priorityChar = levelMap[x, y].AltMapCharacter;
else
    priorityChar = levelMap[x, y].MapCharacter;

Then the function decides what character to print to the form and we need an adjustment for when the space is visible:

if (levelMap[x, y].Visible)
{
    // If the player is in the room, or the space represents the player,
    // show the standard priority character. Otherwise, just show the map     \   
    // character.
    if (inRoom || levelMap[x, y] == playerSpace)
        sbReturn.Append(priorityChar);
    else
    { 
        if(levelMap[x, y].SearchRequired)
            sbReturn.Append(levelMap[x, y].AltMapCharacter);
        else
            sbReturn.Append(levelMap[x, y].MapCharacter);
    }
}

If the SearchRequired property for a space is True, it will now print the AltMapCharacter. Otherwise, it will output the MapCharacter.

During testing, I found an extra bit of code that was needed in the MoveCharacter() method of the Game class. This method looked at the MapCharacter property to decide if the player could move onto a space but that’s not the only place to look anymore. The method now has another decision to make.

visibleCharacter = 
    adjacent[direct].SearchRequired ? 
    (char)adjacent[direct].AltMapCharacter! : 
    adjacent[direct].MapCharacter;

The method now defines the visibleCharacter char variable depending on the SearchRequired property of the space and uses that instead of looking at the MapCharacter itself.

// If the map character in the chosen direction 
// is habitable and if there's no monster there,
// move the character there.

if (charsAllowed.Contains(visibleCharacter) && 
    adjacent[direct].DisplayCharacter == null)
        player.Location = CurrentMap.MoveDisplayItem(player.Location, adjacent[direct]);

Finding the Doorway

So far, so good. Now we need a way out of the room.

The lowercase ‘s’ is used to search in the game. (capital ‘S’ is used to save the game) so we go back to the KeyHandler() method in the Game class. First, we need a new constant at class level.

private const int KEY_S = 83;

Incidentally, I’m getting these key values because I have the following line in the KeyDown event of the form and it’s sending them to the output window every time a key is pressed.

Debug.WriteLine(e.KeyValue);

The event then sends the KeyValue to the Game.KeyHandler() method. Remember that it has a number of Switch statements that handle the keys depending on whether the SHIFT key is needed and whether it’s pressed.

public void KeyHandler(int KeyVal, bool Shift)
{
    // Process whatever key is sent by the form.

    // Basics
    switch (KeyVal)
    {
        ...
    }

    // Shift combinations
    if (Shift)
    {
        switch (KeyVal)
        {
            ...
        }
    }
    else
    {
        switch (KeyVal)
        {
            case KEY_S:
                SearchForHidden();
                break;
            default:
                break;
        }

    }
}

The new SearchForHidden() method referenced above is actually pretty straightforward.

First, the Game class gets a new constant.

private const int SEARCH_PCT = 20;
private void SearchForHidden()
{
    List<MapSpace> spaces;

    if (rand.Next(1, 101) <= SEARCH_PCT)
    {
        cStatus = "Searching ...";
        spaces = CurrentMap.GetSurrounding
            (CurrentPlayer.Location!.X, CurrentPlayer.Location.Y);

        foreach (MapSpace space in spaces)
        {
            if (space.SearchRequired)
            {
                space.SearchRequired = false;
                space.AltMapCharacter = null;
            }
        }

    }
    else
        cStatus = "";
}

The MapLevel.GetSurrounding() function is called to get a list of the spaces all around the player in eight directions and, for each one, the SearchRequired property is set to false and AltMapCharacter to null so it can no longer be seen by the MapText() function. This is all dependent on the Random class returning an integer within the limits of the SEARCH_PCT constant. The original game did this all silently but I thought a status update would be helpful, at least during testing.

Earlier I mentioned a slight change in the call to the Random class. Previously, for simple percentage checks, I was using something like this:

if (rand.Next(101) <= SEARCH_PCT)

That generates an integer up to 100, excluding the specified value. As I was coding up this feature, however, I realized that meant I was including 0 as the minimum possible which throws off the percentage of chance, if only by one. So, I decided to start specifying the minimum of 1.

if (rand.Next(1, 101) <= SEARCH_PCT)

See it in action …

You can see the hidden door feature in action in my newest YouTube video which is also an overview of the changes you’ve seen in this chapter.