I've been using Roblox since 2019, so it's the engine that I currently have the most experience with, and at this point I have little to no complications in what I want to achieve using it. Although I don't consider Roblox my main engine anymore, I still usually go back to it for trying new ideas that I'm unsure will be successful, or just simply experimenting with new mechanics.
After Kane Pixel's Backrooms series went viral on Youtube, I decided to ask some of my friends who have some modelling experience if they'd want to create a game inspired by it. At the time, I'd worked on a few other projects with that team that were linear, single-player narrative based games. In contrast, this project would be multiplayer, and focus on completely procedural systems rather than hand-made levels.
Considering the complexity that this project could end up posing for me, I decided to use the design thinking process in order to plan out the game beforehand, and that's how I ended up implementing mechanics, the narrative, and also visual and audio design. And soon enough, a few weeks after I started I had a finished prototype.
My initial "design doc":
Considering all the systems that are currently in the game, I'll only be focusing on a few in this page:
Level generation
This includes randomly placed props, and items.
Enemy AI
Visual design [WIP]
Audio design [WIP]
Level generation is the focus and most important element of the game. Unsurprisingly, it was also the most challenging.
In order to start working out how to do level generation, I used reference images of old creepy office spaces, which helped define the visual look of the game off of liminal spaces.
Considering the lore behind the structure of the Backrooms, a few elements would be required for the game:
Infinite level generation
(Think Minecraft, but instead of a beautiful world it's randomly segmented creepy office rooms)
Randomly loaded props and items
Like my Love2D project, this system uses perlin noise in order to create randomly generated chunks. A chunk generation module first processes the heights for each chunk, which are rounded to binary from a perlin noise function, and based on this value that chunk will be marked as either a wall or ground tile.
Each level includes a collection of 3D tiles to represent Wall, Ground, and Ceiling tiles. A random tile for each type is then loaded in from the collections shown below.
So now that the main level generation part is out of the way, how would it be possible to make this infinite without using a ton of processing power? A method that's commonly implemented in some games to solve this problem is only rendering the chunks that are within a certain radius of the player's position. Doing so creates the illusion that the world is infinite, even though only a portion of it is actually visible.
My implementation of this renders the nearest 13 chunks surrounding the player, storing them within an array along with it's position divided by the grid size of each chunk (making each chunk's position a unit of one away from the chunk next to it). Every 200 milliseconds, the system then finds the new position of the player, and clears any chunks out of the array that are not within the new render distance.
Furniture and props like chairs, tables, desks, and plants are all parts of office spaces. Replicating these objects in a procedural environment in a videogame though is a bit trivial, as simply placing props randomly around each level could break immersion and believability. As a result, the system that places and positions these objects went through a lot of trial and error to get to the point it is now. So how does adding props procedurally work?
I decided to create an algorithm that's executed for every ground chunk. I initially just gave each chunk a random 1 out of 3 chance of spawning a prop from a collection of props.
The issue with this method though is that in real life office spaces, furniture is usually placed against walls or dividers of some sort. In real life you wouldn't see a bunch of desks, couches, or shelves scattered randomly throughout a room, which is pretty much what I was doing up to this point.
While there are probably other algorithms for realistic prop/furniture placement, I decided to create an algorithm that...
Checks the four surrounding chunks of that ground chunk.
If one of those chunks is a wall, grab the direction vector between the current ground chunk and the wall.
Orient the prop to face the wall relative to the direction vector.
Position the prop to the edge of the wall by:
Offsetting the position of the prop relative to the direction vector by half the size of the prop and half the size of the wall itself.
Finally, placing items on top of props, such as cassette tapes and batteries required an extension of this algorithm. Here are the steps:
Check for a random 1 out of 6 chance of an item spawning.
Check if the current chunk has a prop on it.
Check if that prop is a desk.
Pick and load a random item out of a collection.
Position the item randomly relative to the prop.
Offset the height based on half the size of the prop, and half the size of the item. (So the item rests comfortably on top of the desk)
Subtract the final position from the size of the item. (Just to make sure the item doesn't spawn over the edge of the desk)
Give the item a random orientation.
And here's the result of the entire level generation system!
The enemy AI was another complicated web of algorithms, so I knew I had to start simple and plan out how the enemy was going to behave:
Step 1.) When the enemy isn't chasing a player, it walks around randomly. This was done by simply picking a random position to walk to away from the enemy. In order to prevent the enemy walking into walls, I used Roblox's native pathfinding implementation.
Step 2.) The enemy would have to constantly search for players. This was done by checking for any nearby players by a defined distance. Another mechanic that was necessary for finding players was detecting whether the player was visible by being in light, or not.
This was done by having every client shoot a ray from the player toward all nearby light sources during every update call. If an object intersects the ray, then the player is in shadow.
the distance from each player to all nearby light sources is also checked. if the distance is further than the radius of the light, then the player is in shadow.
Another interesting mechanic that was implemented into the AI is the ability to "hear" noises from players, such as footsteps. This encouraged players to stay away from enemies and try to sneak around them. I planned to have the AI also detect where the player is relative to the enemy, and in response to this, have an animation of the enemy tilting it's head toward the player to signify that it can hear them. Creepy!
Step 3.) Have the enemy AI "listen" to footsteps from players:
Check if the player is within the listening range of the enemy.
Check if the player is walking.
Grab and normalize the direction vector between the enemy and the player.
Find the dot product of the direction vector to the direction/look vector of the enemy.
The dot product value returns a number from 0 to 1, which basically gives the angle of the player relative to any given direction and position (which in this case is the direction the enemy is facing). The dot product also comes in use when checking the field of view of an enemy, meaning that a player could be checked as either being visible or not based on the dot product.
Here's the finished "listening" system:
Since I already mentioned step 4 previously (field of view), I'll move on to the next step.
Step 5.) Attack a player:
First, check if all the previous conditions for detecting if a player is visible or not apply.
if so, find the nearest player and start chasing them:
During every update call...
Continue checking if the player is within a range of 30 units from the enemy.
If so, continue chasing them. If not, stop chasing them since they're out of sight.
If the player is still chase-able, generate a path to the player and have the enemy follow it.
if the enemy is within a range of 3 units of the player, stop creating a path and simply follow the player in a straight line.
(This improves processing power and overall gameplay fluidity, since if the player is close enough to the enemy, there's no need to generate a path to avoid obstacles).
If the enemy is within a range of 2 units of the player, take away all of their health.
(This is pretty much the death state of the game at the moment).
And with all of that covered, the enemy AI is complete!
Here's some footage of the game in it's current state: