Screenshot Option 1

The Making of The 13th Floor (JS13k 2024)

Intro

This game builds upon my 3d game engine I’ve used the last 2 years, for more background on the more foundational parts of the game engine, read about last years entry and my entry from two years ago.

Pre-Comp Engine Improvements

Collision Detection

After many experiments with different collision detection methods, I decided to switch my collision logic to use sphere/triangle collision. While performance is slightly worse than my old cylinder/triangle collision logic, it’s still plenty fast for a narrow phase collision algorithm. It also handles corners much better; automatically handles stairs and slopes, including making the player slide down steep slopes; and is less code than the cylinder approach.

Most game engines use a capsule rather than a sphere to better fit the shape of the game character, and I could do this too if needed. Going from sphere to capsule collision simply involves finding the right point on a line segment the height of the character to test the sphere from. For this year though, a sphere was fine. In fact, this year AABBs would have been fine, as I ended up making a game in a hotel where everything is at a right angle. But I didn’t know that before I started so I left in my collision code. You can see an example of some collision experiments here:

Audio

I’ve used ZZFx for the last two years. It’s an amazing tool, and if you’re doing a 2d/pixel art style game, I definitely recommend it. I really stretched it to it’s limits the last couple years trying to make sounds for my 3d games, but ultimately I just can’t make it make sounds that suit more “realistic” (very heavily quotation marked there) 3d games. With that in mind, I set out to make my own audio player, based VERY loosely on a JavaScript midi player I found online. Ultimately all that remains of the midi player is it’s enveloping function and algorithmically created musical notes. This can be improved more still to be more powerful and smaller, but this served as a good starting point.

Combine this with a little extra WebAudio magic, and I think the sound effects really turned out. For instance, check out the sound of the elevator doors. This was probably the most difficult sound to synthesize.

The footsteps, while less complicated, also came out really nicely:

Making the Game

Game Design

When the competition started and the theme Triskaidekaphobia was announced, it just made sense to go back to the horror genre. This year though, I wanted the enemy to be able to stalk you around the environment and chase you. This meant path finding and some form of enemy AI. I also wanted to step up my lighting game. Lighting is insanely important for a horror game, so this year rather than simply a directional light and directional shadow, we have a spot light, a point light, and point light shadows.

Lighting also played an important role in the games stealth-based gameplay. Many stealth games use some sort of HUD to display the enemy’s line of sight, allowing the player to see when they are safely out of view versus in danger of being seen. This isn’t very viable in 13kb, but if the enemy itself is emitting a light, the player can see that light shining down a hallway and see that the enemy is there. This lets you know that hallway isn’t safe and that you should stay away until the light disappears. This is also why the doors don’t go all the way to the floor, there’s a gap left for light to shine under the doorway to let the player know the enemy is passing by. The light following the enemy even moves to a lower position if the player is in a room with the door closed, allowing the maximum amount of light to shine under the door.

The enemy light design along with the footsteps let the player get a good idea of where the enemy is located without the enemy seeing them. This design helps the player stay hidden, while also encouraging the player to never actually look at the enemy. If you’re staying away from the enemy’s light and away from the sound of it’s footsteps, you’ll never really get a good look at it. This is great for a horror game as your imagination is always scarier than some computer animated monster. For the same reasons, the viewpoints from the hiding spots are setup so that you can see a portion of the enemy, but never get a great look at it.

Enemy Behavior

Pathfinding

Above is a (crudely drawn) representation of the nodes and their connections that are used for enemy pathfinding. When spawned in, the enemy will simply patrol the area, moving from node to node, but not entering any doors, and never going to the previous node. Nodes that lead into rooms get doors assigned to them, so later when the enemy chases you, it can detect if the door is closed at that node and open it before it moves on to the next node.

These nodes also ended up serving other useful purposes. All items and hiding spots are stored relative to each node. Since the rooms repeat themselves, this let me just offset these locations for each room, and the player always only ever has access to the items and hiding spots at the node they are closest to. This saves having to do any ray casting for hiding place/item discovery, and instead I simply check the distance and angle of the player to the item, and if close enough and looking at the thing, they can interact with it.

The pathfinding routine is simply BFS, this is quite small and with this limited number of nodes is still plenty fast.

Enemy Vision

The other nice thing about these nodes is that due to the fact that the game features only straight hallways with no other lines of sight, the node paths can be used for enemy vision. Simply start at whatever node the enemy is on, and follow it’s north/south/east/west siblings in straight lines until they end, and if the player is at one of those nodes, the player is potentially in the enemies line of sight.

I say potentially in the enemies line of sight for two reasons. The simplest of which is the enemies vision distance is limited. It felt cheap to have the enemy see you if you couldn’t hear it’s footsteps, so it’s vision is roughly limited to the edge of the sound of the footsteps.

In addition, even if the player is potentially in the light of sight and close enough to the enemy, they might still not be visible. Both the enemy’s and player’s North/South or East/West distance from their nearest node also impacts if the player is visible to the enemy. The opposite distance from the sight path is always checked. So when looking East/West the enemy and player North/South distance is checked, and vice versa.

Above, we are testing the enemy’s vision to the east. We check it’s first sibling straight east, this node is not the player’s nearest node. So we check that nodes east sibling. This is the player’s closest node, so now we check the player’s North/South distance from their closest node. In the image above, the player is too far south from their closest node to be seen, so the vision check fails and the player is not seen.

You can tell by looking at the image above that the player is around the corner and should not be seen, but if they were a little farther North, they should be visible. Checking the distance allows for this vision check. Much like item collection, doors, and hiding spots, you could use ray casting for this, but given the very simple broad phase collision of a 2d grid, this isn’t very efficient, and since the nodes and the ability to traverse their siblings already exists, this is a small and fast way to build in enemy vision.

Enemy States and Difficulty Balance

The enemy fluctuates between a number of states: Despawned, Patrol, Chase, Search, Kill, and Flee. The enemy starts despawned, where it simply does nothing. It then spawns in at the node farthest away from the player’s current position. The enemy also has an aggression setting, which starts at 0 when spawned in, and can go up to a maximum of 1. This acts a percentage to how aggressive the enemy should behave.

Every time you pick up a key, the enemy’s aggression increases by 0.1 (effectively 10%). If the enemy chases you and you escape it, it’s aggression increases by 0.1. If the enemy catches you and damages you, it’s aggression decreases 40%. This creates a rough difficulty adjustment that tries to keep the game exciting when you are doing well, and a bit easier if you’ve lost a lot of health. The aggression level effects the enemy’s movement speed, and the chance that it will find you when you are hiding. It also has various other effects described in each enemy state below.

The enemy spawns in in the Patrol state. In this state, generally the enemy will patrol the hallways, and generally will never choose the node it came from as it’s next node to go to. This stops the enemy from just turning around or going back and forth. The direction is chosen at random, but the node it came from is removed from it’s choices, and it won’t open a door from the hallway. However, when arriving at each node, there is an aggression/3.0 chance that the enemy will go to the neighboring node that is on the closest path to the player. This means if you are doing well, the enemy is more aggressive and will move more towards you, including then opening doors of rooms you are in. This is quite a low chance, 1/3 at maximum, but this decision is made at each node.

In chase mode, the enemy simply chases after you. After 600 frames + 600 frames * aggression the enemy will give up chasing you. The game runs at a fixed 60fps, so that’s 10 seconds + a maximum of another 10 seconds before it gives up. Alternatively, if you enter a room and hide before the enemy enters the room, it will enter search mode. If it doesn’t find you, it will also give up.

In search mode, the enemy will move from the room bathroom node to the room bedroom node and “look around” for you, rotating in place. Once the enemy has searched both spots, there is a 75% chance it will leave after each search. So generally the enemy will only search each spot once, but it could search 2, 3, 4, 5 etc times, it’s just very unlikely. Each time it searches a node, the probability of the enemy finding you is Math.min(aggression, 0.3). Effectively, a 0-30% chance of finding you at each search, but based on aggression.

If the enemy’s chase fails, it enters into Flee mode. It picks the farthest node from the player and makes it’s way there. Once it reaches that node, it goes back to patrol mode. This gives the player a chance to get to the next room.

Finally, in kill mode the enemy simply stands in front of you and damages you. The player’s flashlight is automatically turned off and the enemy’s light is moved directly behind it to keep the enemy in the shadow. The player takes damage, and then the enemy respawns at the farthest node from the player.

Point Light Shadows

This took me five days:

I’ve done shadows before, you can see my tutorials on them here and here. Directional shadows are truly not that difficult once you understand writing data to textures. Unfortunately point light shadows, while conceptually not that dissimilar, are quite difficult in WebGL due to some strange behavior.

For directional shadows, I was simply able to render the scene from the point of view of the light and store those depth values in a 2d texture. That texture could then be sampled during regular rendering to determine if a given pixel was lit or not. Point light shadows should be the same, except you use a texture cube instead of a 2d texture. If you have the angle and distance from a given pixel to a light, you can use the angle to find on the cube where to write the value, and then write the distance value. You now have stored the distance of all pixels from the light for sampling.

While I could simply write depth values to a 2d texture for directional shadows, this simply would not work on a texture cube. Only after including the following WebGL plugins did this work: EXT_color_buffer_float and OES_texture_float_linear. Now, in some ways this does make sense, as these enable writing floating point values to textures. However…I did not need these to write depth values to 2d textures.

In addition, even with these extensions, I could not get the texture to work with a samplerCubeShadow, only with a regular samplerCube. I still do not know why this is. If anyone can show me a working example of writing to a texture cube and then sampling it from a samplerCubeShadow in WebGL2 I will pay you. With a samplerCubeShadow, in Render Doc I can see the texture cube being written to correctly, but when it is sent in to the main fragment shader it is just 1 empty pixel on each side. Only with regular sampler cube will it work. It took a lot of debugging to find these issues, and while I still don’t fully understand why I couldn’t use a shadow sampler cube, I was able to make shadowing work properly with a regular sampler cube.

The previous point light video above probably doesn’t look that impressive, since there are no actual lighting effects involved (to keep the shadow debugging as simple as possible). However, mix in actual point and spot light lighting techniques, and things start looking really cool:

With those tests complete, the code was moved into the game, first tested as the elevator light, which gave the elevator a great look, and gave a great effect when the doors open and close.

In addition, you get a flashlight in the game (see the earlier video on sound effects seeing it being turned on and off). Again, by comparison to point light shadows, this was trivial to implement, but gives a really nice effect.

Summary

As always, a ton more work went into making this game. As you can possibly tell, a lot of 3d modeling (all with code of course); as well as a lot of testing and tweaking the balance and feel of the game. I love the lighting effects and feel, I can’t believe I was able to synthesize the elevator door sound, and the enemy pathfinding and vision “hacks” are super cool.

There was more I wanted to do, like different little scares in each room, the number 13 chasing you into the elevator at the end, and the enemy’s light changing color based on it’s state. However, the nature of 13kb game development is, you have to focus in on the core mechanics and make those work. I think given the theme and the limitation, I did that quite well. After two horror games though…I’m really hoping for some sort of fun bright happy platformer or arcade game type theme next year!

You can watch the trailer for the game here:

Please play the game!! You can find it here: https://dev.js13kgames.com/2024/games/13th-floor

I also made a developer playthrough where I play through the game and comment on how some of it works:

Finally, you can find the source code here: https://github.com/roblouie/js13k-2024