Special Halloween 2022 - Monster Maze
39 years have passed since the unsettling events in that creepy sort of place known as “Ghost Maze”. The yells and shouts haven’t ceased ever since. But it has only been recently when villagers have started to claim that something new has nested and dwells inside those walls.
The place is continously visited by fresh cohorts of reckless youngsters looking for ways to prove themselves and adrenaline junkies with a morbid fascination for anything supernatural. Most of them only make it to the door like playing the knick knack knock game. The few who have set foot on that unholy ground come out changed, having lost a part of themselves in the experience, eaten by fear and muttering under their breaths about a noise… a dragging of feet accompanied by a muffled sound, a yell coming from a soul in misery that cannot become airborne and dries in the throat.
When questioned for details, the people cover their eyes with their hands in an attempt to block the memory as it was just before them. Apparently this muted sound is more dreadful than the eerie wails that fill the desecrated rooms and corridors because, even if inaudible, it follows you around and carries a promise…“you will never leave”.
Since the Autumn equinox the paranormal activity around the place is spiraling upwards. Many believe of this to be an omen of something evil occurring in Halloween and others only see a business opportunity to siphon money out from the most gullible. However, an increasing number of fanatics, seized by horror and hysteria, presage that the Hellmouth is about to open right there to set the damned upon us.
I do not give myself much credit to such fantasies, but I cannot ignore the agitated state of mind of these people, completely unsuited to carry out any tasks on the terrain. So, I have decided to send you word of my whereabouts and plans in case these weirdos get me in trouble, because I am planning to get in tomorrow, take some samples from the basement and get out. Quick and clean :D
I am determined to prove once and for all that all the phenomena (screams, disorientation, visual and audible hallucinations…) are easily explained by a puncture in a natural gas reservoir very close to the surface beneath that horrible property.
Measures must be taken immediately to secure the area before we have to regret human losses again.
On my return I will share the creepy details with you over a pint or two.
Cheers
Seeking inspiration for a Halloween post I came across Peter Pravos’ Celebrate Halloween with Creepy Computer Games in R.
Following his example I decided to translate into R one of the other creepy games in the book Creepy Computer Games (Usborne, London). This book and many other wonders are available in the previous link. The book collection has been a completely unexpected discovery, I am really thankful to Peter for such an interesting series in his blog.
The books are richly coloured with beautiful illustrations everywhere. There is a big contrast between the illustrations and the simple graphics of the games in ZX Spectrum and BBC Micro. I was considering if the book was trying to compensate somehow, but now I just think that the same imagination that was powering the illustrations was also behind the graphics, so the contrast is due to the different degree of development of the the tools.
For this occasion I have selected “Ghost Maze” written by Colin Reynolds.
I have never programmed on a Spectrum and spent a good time going through the codelines, I absolutely admire the efficiency and elegance of the code, the subroutines, the string manipulation to create and navigate the maze. I started translating the lines but half way I decided to do things differently, times have changed after all.
So, this game is not an accurate translation but it is strongly inspired by Ghost Maze.
How to run it
The code is publicly available in Github:
monster-maze-console DownloadThe game requires RStudio or Rscript.
Make sure to choose a MONOSPACE font (like 'Courier', 'Monaco', 'Andale Mono', 'Lucida Console' or any other 'Mono' font) in your terminal/console.
Option 1: RStudio Console
- Download and unzip the repo
- Open the project in RStudio double clicking on monster-maze-console.Rproj
- Source the file R/monster-maze-all.R
- The game runs on the RStudio console
- Move to the console to enter the input
Option 2: RStudio Console
- In Github, copy the code from R/monster-maze-all.R
- Open a new file in RStudio
- Paste the contents
- Source the new file
- The game runs on the RStudio console
- Move to the console to enter the input
Option 3: Terminal
- Download and unzip the repo
- Open a terminal
- Run the command Rscript <path_to_the_unzipped_repo>/R/monster-maze-all.R
Game Basics
Next I explain some of the implementation decisions behind the game.
Mazes
First thing I opted for a matrix of integers to represent the maze rather than strings where:
- 0: represents the walls.
- 1: represents the corridors and rooms.
- 9: represents the exit.
The following code snippet shows the syntax used, the reason for this verbose approach is simply to have a matrix-like representation in the code that I could easily check while coding.
1maze1_data <- c(0,0,0,0,0,0,0,0,0,0)
2maze1_data <- c(maze1_data,0,1,1,1,1,1,0,1,1,0)
3maze1_data <- c(maze1_data,0,0,1,0,0,1,1,1,0,0)
4maze1_data <- c(maze1_data,0,0,1,1,0,1,0,1,1,0)
5maze1_data <- c(maze1_data,0,1,1,0,1,0,0,1,0,0)
6maze1_data <- c(maze1_data,0,1,1,1,1,1,1,1,0,0)
7maze1_data <- c(maze1_data,0,0,0,0,0,0,0,9,0,0)
8maze1 = matrix(maze1_data,nrow=7,ncol=10,byrow=TRUE);
Extract the rooms and corridors positions from the maze
1require(invctr)
2CORRIDOR <- 1
3corridor_positions <- CORRIDOR %ai% maze1 # gets the indices for all CORRIDOR places in the maze
nv | row | col |
---|---|---|
1 | 2 | 2 |
1 | 5 | 2 |
1 | 6 | 2 |
1 | 2 | 3 |
1 | 3 | 3 |
Extract the exit position from the maze 🏆
1require(invctr)
2EXIT <- 9
3exit_position <- EXIT %ai% maze1 # gets the indeces for all CORRIDOR places in the maze
The steps so far has brought us from the plain matrix to the maze depicted in the figure 2. We have:
- the underlying lattice in a matrix.
- the corridors and rooms positions in a dataframe.
- the exit position in a dataframe.
Where the player starts 👤
After a few tests I decided to assign the player position randomly to any free corridor cell that is at least 4 cells away from the exit.
The distance between two position in the matrix is calculated accordingly to the following function.
1calc_distance <- function(position_1, position_2) {
2 row_distance <- abs(position_1$row - position_2$row)
3 col_distance <- abs(position_1$col - position_2$col)
4 distance <- row_distance + col_distance
5}
The figure 3 shows the distance of each cell in a corridor or room to the exit.
As an example, in figure 4 the player has been placed in the position (row=4, col=3).
The player is simply tracked as a position and a direction (North, South, East, West). The latter will become clearer when I explain what the player sees.
Next is determining the positions of the other characters of the game, their initial positions are randomly selected from free cells, corridors and rooms that are not occupied by other characters and that are at least 2 cells away from the player (Figure 5) so that the player stands a chance of reaching the exit.
Ghosts 👻
- appear and disappear at a constant speed.
- only appear on corridor or rooms but they can walk through walls, the position must be a free position but does not need to be connected through a corridor to the previous.
- do not pursue the player.
- if the player looks aside and back, the ghost may disappear.
- if a ghost materialises in a position at distance 1 of the player (front, back or either side) or the player bumps into it, then the player is whisked away to a new random position at least 4 cells from the exit, disoriented not knowing which direction is facing. In practice the game is reset but no life is taken.
- From 0 to as many as you can fit in considering the player and the distance limit.
In the figure 6 2 ghosts are placed in positions (row=2, col=4) and (row=4,col=8).
The ghost positions are tracked in a list of positions.
Zombies 🧟
- move at a constant speed, usually slower than the player. However, zombies move awkwardly, they do not turn and can move in diagonal.
- cannot walk through walls or other characters.
- do pursue the player, getting closer and closer.
- if a zombie grabs a player, it hurts the player, taking away 1 life before whisking her and resetting he game.
- From 0 to as many as you can fit in considering the player, the ghosts and the distance limit.
In the figure 7 2 zombies are placed in positions (row=1, col=6) and (row=1,col=9).
The zombie positions are tracked in a list of positions.
So, let’s recap the different elements we have so far.
- the underlying lattice in a matrix.
- the corridors and rooms positions in a dataframe.
- the exit position in a dataframe.
- the player as a position and a direction (North, South, East, West).
- the ghosts as a list of positions, a constant speed, range of effect (distance=1).
- the zombies as a list of positions, a constant speed, range of effect (distance=0).
What the player sees
What the player sees is determined by the maze (walls, corridors and exit), player position, player direction, ghost positions, zombie positions, forward vision, lateral vision (defined as half of the forward vision each side) and rear vision.
The steps followed in the game to render the player’s view are as follows:
- a new matrix (meta_maze) is created with extra rows and columns around the maze to support the depth of the vision even if the player is on the border of the maze, the padding is made with walls.
- the maze is copied in the meta_maze.
- the ghosts are placed in the meta_maze using the list of ghost positions.
- the zombies are placed in the met_maze using the list of zombie positions.
- based on the player position, direction, and the depth of the vision (forward, rear, lateral) the area that is visible is determined. The figure shows the different areas according to the direction for (forward vision = 4, lateral vision = 1 and rear vision = 1).
- the direction is also used to determine how many times the view has to be rotated clockwise so that it is always facing North when presented.
- the view is rotated using the following function
1rotate_clockwise <- function(x) {t( apply(x, 2, rev))}
- the player position is set in the view.
Game loop (engine)
The game loop or engine is implemented simply as an infinite loop “while(T)” and certain conditions that break the loop. The diagram 11 shows the loop and the main activities (collisions, render, characters movements …) as well as the exit conditions (exit, quit, game over).
Console / Terminal
Below I mention some of the features in R I have stumbled upon and consider worth outlining.
Print / cat
Print doesn’t work on the terminal, the code uses only cat.
Clear Screen
The game is rendered by cleaning the terminal and re-printing it. Surprisingly the character to clear the screen in the terminal and the console are different. For that reason I used the following function (Code 2022).
1clear_screen <- function() {
2 if (interactive()) {
3 cat("\014") #or cat("\f")
4 } else {
5 cat("\33[2J")
6 }
7}
User Input
I had to resort to different code to capture the user input.
According to the documentation readLine in non-interactive use the result is as if the response was RETURN and the value is ““. I followed the recommended solution in stackoverflow: Make readline wait for input in R
1user_input <- function(prompt) {
2 if (interactive()) {
3 return(readline(prompt))
4 } else {
5 cat(prompt)
6 return(readLines("stdin", n=1))
7 }
8}
Emojis
I decided to use emojis rather the plain characters. And I think the result is very positive. However, emojis are not generally supported and the behaviour may vary from OS to OS. I have only tested the game in my personal Mac. And even in such a narrow scenario the terminal and the console behave differently, not printing the emojis as expectedly. I have partially overcome this issue but introducing a graph_sep, currently a combination of invisible characters that forces the emojis to be displayed correctly on the screen. Each invisible character works either on the console or the terminal.
An easier way to fix this is resorting to whitespace as graph_sep. The extra space between the emojis makes the maze less pleasant to the sight, at least so my taste is.
1# Graph map
2# https://invisible-characters.com/
3graph_sep="\U17B5\U2063" # each invisible char works on a different terminal
4graph_map <- dict()
5# https://www.w3schools.com/charsets/ref_emoji.asp
6graph_map$set(WALL, list("block"=paste0("🏾",graph_sep),"desc"="wall"))
7graph_map$set(CORRIDOR, list("block"=paste0("🏻",graph_sep),"desc"="corridor"))
8graph_map$set(GHOST, list("block"=paste0("👻",graph_sep), "desc"="ghost" ))
9graph_map$set(EXIT, list("block"=paste0("🏆",graph_sep) ,"desc"="exit"))
10graph_map$set(PLAYER, list("block"=paste0("👤",graph_sep),"desc"="player"))
11graph_map$set(ZOMBIE, list("block"=paste0("🧟",graph_sep),"desc"="zombie"))