In this chapter … While adding a few more scrolls, I adjust player and monster movement for blind and confused states. I also demonstrate the value of code generalization and the “Don’t Repeat Yourself” principle. Read more of the Rogue C# series here.
Not all scrolls and potions are helpful in a roguelike but that’s part of the fun … more or less. In this update, I add a mix of scrolls including Remove Curse, a frustratingly rare item that you’ll want after putting on the wrong armor and Aggravate Monsters which wil make your life on the level a lot more exciting. As before, every new scroll leads to a re-examination of the code.

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 AndrewComeau.com. The current code for this project is also available on Github.
Don’t miss the next chapter! Subscribe for free on Substack to be notified when new chapters are posted.
Let’s see ’em dance …
Now that I have two scrolls that cause confusion, Teleportation and Monster Confusion, I decided it was time to affect movement if either the player or a monster was confused. At first, I just tried reversing all intended movements but that made the monster run away and it was kind of sad so I decided to add some randomness by adding a new DEGREE_CONFUSION constant and using it to essentially roll the dice as to whether any move will go the right way if the monster or player is confused.
In the Game class:
/// <summary>
/// Degree of confusion caused by various items.
/// </summary>
private const int DEGREE_CONFUSION = 50;
In Game.MoveMonster():
// Get relative directions to monster's choice. Chance to reverse movement if the monster is confused.
wrongMove = (monster.Confused > 0 && rand.Next(100) > DEGREE_CONFUSION);
direct = (MapLevel.Direction)monster.Direction!;
if(wrongMove) { direct = CurrentMap.GetDirection180(direct); }
direct90 = CurrentMap.GetDirection90(direct);
direct270 = CurrentMap.GetDirection270(direct);
In Game.MovePlayer():
// If player is confused, there's a chance of reversed movement.
if (player.Confused > 0 && rand.Next(100) > DEGREE_CONFUSION)
direct = CurrentMap.GetDirection180(direct);
I decided to handle blindness on the part of the player while I was at it. This required an update to DiscoverSurrounding() in the MapLevel class. It was actually handling two tasks – making spaces visible and detecting an obstruction – so I decided to split it into two.
public void ShowSurrounding(int xPos, int yPos)
{
foreach (MapSpace space in GetSurrounding(xPos, yPos))
{
// Mark the space as discovered.
if (!space.Discovered)
space.Discovered = true;
// If this is a wall, stairway or anything else
// that should remain visible, mark it as lighted.
if (!space.Lighted)
{
if (MapDiscovery.Contains(space.MapCharacter.DisplayChar))
space.Lighted = true;
}
}
}
public bool DetectObstruction(int xPos, int yPos)
{
bool retValue = false;
foreach (MapSpace space in GetSurrounding(xPos, yPos))
{
// If there's something one of the spaces, return True.
// Ignore player's space.
if (!retValue)
if (space.X != xPos || space.Y != yPos)
retValue = (!GlideSpaces.Contains(PriorityChar(space, false).DisplayChar));
}
return retValue;
}
Then, the Game.MovePlayer() method can take the necessary individual actions based on the conditions.
// Show the surrounding spaces if the player can see.
if (CurrentPlayer.Blind == 0)
CurrentMap.ShowSurrounding(player.Location.X, player.Location.Y);
// Discover the spaces surrounding the player and note if something is found.
stopMoving = CurrentMap.DetectObstruction(player.Location.X, player.Location.Y);
Don’t Repeat Yourself
I take the Don’t Repeat Yourself principle to heart and try not to duplicate code whenever possible. A small example of that was how after programming a few scrolls and wondering why the inventory screen wasn’t clearing, I remembered that I had to set the ReturnFunction property to null if I wanted the ReadScroll() method to restore the screen after calling the scroll’s delegate function. I was doing that in each delegate and knew I’d forget it again so I decided to have a single line in ReadScroll() instead, like I should have before.
// Find and invoke the delegate
if (InventoryActions.TryGetValue((Inventory.InvCategory.Scroll, items[0].RealName), out var taskInfo))
{
// Remove the item from the player's inventory and invoke delegate.
CurrentPlayer.PlayerInventory.Remove(items[0]);
ReturnFunction = null;
readScroll = taskInfo.Invoke();
}
Then, if a scroll needs to set the ReturnFunction for a second task, it can and everything will be fine. Otherwise, it’s already taken care of.
Calling up monsters
The Scroll of Create Monster spawns a new monster within two spaces of the player. There’s already a MapLevel.GetSurrounding() function but it only gets spaces within one space of the player. I decided to add some flexibility there which meant having it accept an argument for the number of spaces and updating the four references to it.
public List<MapSpace> GetSurrounding(int x, int y, int spaces)
{
List<MapSpace> surrounding = (from MapSpace space in levelMap
where Math.Abs(space.X - x) <= spaces
&& Math.Abs(space.Y - y) <= spaces
select space).ToList();
return surrounding;
}
The next step was to consider MapLevel.GetOpenSpace():
I wanted a way to get an available space specifically within a group of spaces. I thought about doing an overload of this function that would accept a list to check against but didn’t want to duplicate the list definition with all the conditions shown above. So, I decided to beef up this function with a parameter to accept an optional list of spaces to check against and a couple of other changes.
public MapSpace? GetOpenSpace(bool hallways, List<MapSpace>? limitTo = null)
{
// List of map characters that qualify as open for this list.
string charList = hallways ? (HALLWAY.DisplayChar.ToString() + ROOM_INT.DisplayChar.ToString()) :
ROOM_INT.DisplayChar.ToString();
// Get qualifying open spaces with no inventory or monsters or the current player.
List<MapSpace> spaces = (from MapSpace space in levelMap
where charList.Contains(space.MapCharacter.DisplayChar)
&& DetectInventory(space) == null
&& DetectMonster(space) == null
&& space != CurrentPlayer.Location
select space).ToList();
// Limit further based on any list passed in.
if (limitTo != null)
spaces = (from MapSpace space in spaces
where limitTo.Contains(space)
select space).ToList();
// Return random space in remaining list or null if there are none.
return (spaces.Count > 0) ? spaces[rand.Next(0, spaces.Count)] : null;
}
I know there’s a way to do the selection and filter in one statement instead of the two shown here but I like to keep things easily decipherable, too, and there shouldn’t be any performance hit. With the extra limitation on the selected spaces, it’s now important to account for the possibility that there are no open spaces left, especially if the player is in a smaller room.
That required checking the nine references to this function for any issues with a null but there weren’t too many since they weren’t passing in a filter list. Keeping the new list parameter optional also kept the change from breaking any of the references. It did, however, lead to a couple of extra precautions in the code.
Since GetOpenSpace() could now return a null, MapLevel.MapGeneration() and VerifyMap() needed to be a little more careful when placing the stairway and amulet.
// Verify there's a stairway
if(retValue)
retValue = (from MapSpace space in levelMap
where space.MapCharacter.DisplayChar == STAIRWAY.DisplayChar
select space).ToList().Count > 0;
// On the game's final level, verify the amulet is there.
if (retValue && CurrentLevel == Game.MAX_LEVEL)
retValue = (from MapSpace space in levelMap
where space.MapCharacter.DisplayChar == AMULET.DisplayChar
select space).ToList().Count > 0;
The actual risk of these two tasks coming up with a null space on the map should be non-existent but I decided to put the precautions in there anyway on principle. I also spent way too much time fiddling with AddMonsters() and AddInventory() to get the code as solid as possible but I did pick up another way of limiting WHILE loops by having the loop decrement a variable when I asked Google Gemini to do a code review.
public void AddMonsters(int Number, List<MapSpace>? spaces = null)
{
Monster? spawned;
MapSpace? itemSpace;
MapSpace? playerSpace = CurrentPlayer.Location;
int startingCount = ActiveMonsters.Count();
int maxMonster = 50, maxSpace = 50;
// Pick random monsters until its probability of appearing is
// within the random limit generated.
while (ActiveMonsters.Count < startingCount + Number && --maxMonster > 0)
{
do
{
spawned = Monster.SpawnMonster(CurrentLevel);
} while (spawned != null && rand.Next(1, 101) <= spawned.AppearancePct);
// Place monster on map. If the spaces are specified, then make sure
// it's not in the same region as the player.
do
{
itemSpace = GetOpenSpace(true, spaces);
if (itemSpace != null)
{
if (spaces == null && playerSpace != null &&
GetRegionNumber(itemSpace.X, itemSpace.Y) == GetRegionNumber(playerSpace.X, playerSpace.Y))
itemSpace = null;
}
} while (itemSpace == null & --maxSpace > 0);
if (spawned != null && itemSpace != null)
{
spawned.Location = itemSpace;
ActiveMonsters.Add(spawned);
}
}
}
AddMonster() now has to allow that it might be called by the current map when adding monsters during map generation or by the Create Monster scroll. If the scroll is calling it, it optionally accepts a list of spaces as a filter that it can pass on to GetOpenSpace() and doesn’t prevent a new monster in the player’s region. Just to be safe, I put decrement limiters on the WHILE loops for the number of monster searches and space searches.
What were we doing again …?
After all that, we come back to the new Scroll of Create Monster delegate.
private bool ScrollOfCreateMonster()
{
List<MapSpace> spaces = CurrentMap.GetSurrounding(CurrentPlayer.Location.X, CurrentPlayer.Location.Y, 2);
// Create a new monster near the player.
CurrentMap.AddMonsters(1, spaces);
UpdateStatus("The room suddenly got a bit more crowded.", false);
return true;
}
Bingeing Scrolls
With this update, I’ve added the following scrolls:
- Remove Curse – Removes any curses from both the current armor and weapon.
- Sleep – Really inconvenient when you were hoping for a teleport away from a monster.
- Teleportation – It can be a life saver when fighting trolls.
- Aggravate Monsters – Every monster on the level suddenly knows your name and where to find you.
- Create Monster – Meet your new best friend!
Just one more and then I’ll call it a night. The Scroll of Gold Detection is one I’ve never seen but it’s in the documentation so, by gosh, I’m doing it.
It’s really simple, too. MapLevel.DiscoverFood() gets renamed DiscoverInventoryByCat() and now accepts a member of the Inventory.InvCategory enumeration.
public bool DiscoverInventoryByCat(Inventory.InvCategory Category)
{
// Set all the food on the map to discovered and visible.
bool retValue = false;
List<Inventory> mapInventory = (from Inventory inv in MapInventory
where inv.ItemCategory == Category
select inv).ToList();
mapInventory.ForEach(inv => {
inv.Location.Discovered = true; inv.Location.Lighted = true;
inv.Location.RemoteSight = true;
retValue = true;
});
return retValue;
}
private bool ScrollOfGoldDetection()
{
bool retValue = false;
// Reveal all the gold on the map.
retValue = CurrentMap.DiscoverInventoryByCat(InvCategory.Gold);
if (retValue)
UpdateStatus("You hear the jingle of coins somewhere on this level.", false);
else
UpdateStatus("'Check out the Dungeon,' they said. 'There's PLENTY of gold down there!' they said...", false);
return retValue;
}
If only getting gold in real life was this easy.

