-
Notifications
You must be signed in to change notification settings - Fork 0
Home
Welcome to the Cave Run technical overview!
I've said it before, I'll say it again: Cave Run, at its core, is nothing more than a huge state machine.
Here are the main states of the system:
- SPLASH: The greeting screen
- MENU: The main menu, with its options:
- MENU_SETTINGS: The settings submenu
- MENU_HIGHSCORES: Highscores submenu
- MENU_ABOUT: About screen
- GAME: The game itself
- GAME_END: The game end screen, which waits for user interaction before switching back to MENU, thus completing the game interaction circle.
All the main arduino loop() function does is check the current global state and run its corresponding loop.
All the main state loops do the following:
- Get user input
- If input, parse it and do action
- If action results in having to redraw things, redraw what is needed depending on the state (lcd, matrix, etc)
As an example, the menuLoop()
:
- Reads joystick Y axis input
- If there is input, compute action logic
- Determine if the action is legal; in this case, if moving the joystick up/down through the menu items, update the selected item index, if not going out of bounds
- If the internal state changed, signal that we need to redraw data, by toggling
shouldRedrawMenu
to true
- If the state did something that toggled
shouldRedrawMenu
, we update the components so that the user gets to see the effects of their action.
All the submenus function similarly.
As for the main gameLoop()
, while obviously more complicated internally, it boils down to:
- Holding an internal game state:
- when first entering the game screen, the GAME_SETUP state is put into place, which sets up the game by resetting values and drawing LCD labels
- afterwards, we switch to the GAME_LOOP state which loops through checking if there is any update through the various game elements:
-
updateTime()
: Computes remaining time and displays it on the LCD when necessary -
updateScore()
: Redraws score if shouldRedrawScore was toggled by another event -
updatePlayer()
: Handles player movement by keeping track of the player's current and previous move. Computes when the player goes out of the current region bound and toggles map redrawing when necessary. Keeps track of player blinking animation. -
updateBombRadar()
: Computes distance between player and bombs and finds the nearest bomb. If it is near enough, light up the radar. If player has stepped on bomb, make the radar LED flash intermittently for a few seconds. -
updateKeys()
: Checks if player has stepped on any of the keys. If they have, remove the key from the game, and toggle redrawing of the map and updating of the key counter. -
updateBomb()
: Checks if player has stepped on any bomb. If they have, remove the bomb from the game, and toggle bomb specific actions (lose a life, radar goes off) -
updateDoor()
: Computes whether to unlock the door, when no keys are left, unlock the door to the next level. -
updatePowerup()
: Shows bombs visible on the current map region.
Additionally, there are the following draw functions:
-
drawMap()
: ifshouldRedrawMap
is toggled, clear and redraw the current map segment, otherwise do nothing -
drawPlayer()
: ifshouldRedrawPlayer
is toggled, redraw player and clear player from previous position, otherwise do nothing
Finally, we also checkEndConditions()
to see if any of the end conditions have been satisfied (timeout, no lives), and if so, we end the game, by changing the overall system state to GAME_END
.
The score is computed on-demand , that is, whenever there is an action taken that triggers a scoreable event, we trigger score calculation and update the display accordingly.
State changing is handled with interrupts in the StateChanger.h
module. It's a big switch statement (you will notice that I love using switch statements - i feel they work well in this environment) which depending on the current global state (system state + other variables depending on the state) switches the global state and marks redraws to be done where necessary.
! Please note: i tried to keep the interrupt function as simple logically as possible, as per Arduino design guidelines, you're only supposed to do fast operations in interrupts, so I tried to only change variable states and other operations that are done in linear time.
The rooms are procedurally generated using a customizable algorithm which renders a number of rooms of certain sizes based on the current difficulty. The default configuration is as follows:
- for Easy, 3 small rooms, 3 medium rooms, then large rooms until the time runs out
- for Medium, 1 small room, 2 medium rooms, then large rooms until the time runs out
- for Hard, 2 medium rooms, then large rooms until time runs out
The difficulty setting also increases the amount of items that you have to collect/avoid per room.
When generating objects for the level, I make sure they don't generate on top of one another or block the door through a brute-force algorithm. I simply check if they overlap anything that already exists, and if not, the newly-generated item is good to go. Otherwise, I generate a new pair of coordinates and try again.
You may call it rudimentary, I call it good enough for the job, due to the small map and item count. It's fast enough to be insesisable when playing, so i deem it ✨appropiate✨ for this purpose.
The map is sized a multiple of 4 - as the matrix is 8x8, i figured this would flow most naturally. I keep the map in memory as a simple bool 2d array, with 1 representing the walls and 0 the floor through which the player can move.
The items are kept separately in arrays of Coordinates
- one array for the keys, one for the bombs. Ah, yes, the Coordinate
is a simple struct I created because I don't like the C++ pair. I wrote this entire game using C-style code, it'd be weird to add C++ elements like that anyway.
One of the most interesting aspects to program in this project was the panning camera - or at least I thought so, until I figured out i could use a bit of arithmetics to get the job done.
If you imagine a map as a bunch of 8x8 quadrants, you can take your current position x/y coordinates, divide them by 8, and get the current region segment.
For example:
My coordinates are x=2,y=5 so i am in region {0,0}
My coordinates are x=10,y=5 so i am in region {1,0} (divide x and y by 8)
When changing region (when stepping from x=7 to x=8 for instance), I should redraw both the map and player. By doing the opposite operation (multiply region segment data by 8) you get the coordinates of the upper leftmost corner of the region, and you can use that index to draw the map section where the player is located.
I also draw the player as (x%8, y%8) -> once you step out of the current region you wrap around on the other side. This works out nicely as the map has no extra walls on the inside.
Cool trick.
As I'm writing this I just figured out a possible optimisation: I think you can get away without the 2d map entirely, as I only use it for drawing sections of it on the LED matrix and for collision detection.
It would complicate the code significantly, but it'd also free up a huge chunk of the memory which... I'm not sure what I'd do with. This approach would also sacrifice extensibility: With a 2d map, I can add obstacles such as extra walls in the future, whereas using formulas would force me to keep the map as a square.
Overall, I think i made the right call going for the 2d bitmap route.
- I enjoyed making this game - the most challenging aspect was actually being flexible and adapting to the restrictions of the hardware.
- I believe that polish is subjective - this is my vision of how the final version of the game should look. I found it enjoyable to see other people's versions through these projects.
- Don't write your technical docs in the github wiki panel - i just spent an hour applying forencsics techniques to my Firefox cache in order to recover this page. I succeeded, but still.