I discovered the game Dome Keeper in October of 2022. It’s a Godot game, and it had a cool premise—tower defense mixed with resource mining in a sci-fi atmosphere—so I bought it. Since then, I’ve played Dome Keeper on a near-daily basis, and I haven’t gotten tired of it yet.
Why do I like it so much? I decided to analyze the gameplay and try to figure that out. If I can understand why I like playing a particular game, that will help me figure out how to better design my own games.
I decided to focus on two categories: the gameplay of Dome Keeper itself, and the “environment” surrounding how and when I play Dome Keeper. Both impact my overall feeling for the game and why it’s so much fun for me to play.
I totally didn’t get distracted playing Dome Keeper while writing the blog post, I was doing field research!
The Gameplay
There are three parts to Dome Keeper’s gameplay: mine digging, resource management, and monster fighting. Additionally, there are a number of “rush moments” where these three systems come together to create awesome moments.
All runs start with carving out your initial tunnel.
Mine Digging
Digging a mine is simple: move down below the dome and start drilling into the nearest block (if you’re the Engineer character, anyway). This feels like digging mines in Minecraft (another activity I’ve done a lot). Why do I like it? I think it has to do with being able to structure a plan for what an “ideal” mine shaft looks like and then imprint that pattern onto the pixelated rock tiles.
I’ll be ignoring the Assessor character for this post because I almost solely play as Engineer; it just fits my preferred style of play better.
I’m not content with just digging tunnels, though; I need a goal in doing so. That objective is finding new resources: iron, water, and cobalt. These resources all contribute to improving my capabilities (more on that in a bit), so they have endogenous value within the game. Because of that value, they’re worth finding, so it gives me a small burst of dopamine when I find them. The way I dig my tunnels allows me to ensure I don’t miss finding anything.
I don’t know why digging optimal patterns to find resources is so soothing to me…but it is.
Finding iron, water, and cobalt is only part of the mining story; I also have to carry those resources back through the mine shafts to my dome, where they are processed for use. At the beginning, the game limits how many resource units I can carry, resulting in a cap to how quickly I can mine. Over time, I can upgrade this carry limit, so the efficiency improves. That visible sense of progress feels good as I play, and it further feeds into the value resources have.
There’s other things which can be found underground, too. The other main discoverable items are gadgets, which you have to dig out and carry back to your dome. Once there, they provide some beneficial functionality, including the ability to teleport between your base and a movable portal (Teleporter), a gravity lift which pulls resources in it up to the dome (Lift), and high-powered explosives you can drop to destroy lots of rocks at once (Blast). There are also other minor things to discover under the rocks which aren’t directly part of the dome, like a small creature that ferries one resource at a time, a seed which you can plant on a resource to create a “tree” growing that resource, and a device that, when activated with two iron cubes, gives you X-ray vision to see through two layers of rock. These things add a touch of surprise when you come across them in your digging, and the variety which can be found keeps things interesting.
I’m not sure what this called, but picking it up gives you a massive speed boost. That’s fun to randomly find!
Finding resources and helpful items is only one facet of Dome Keeper’s gameplay. Once you bring those things back to your base, you need to do something with them.
Once your resources have been brought back for processing, you get to choose where to spend them. There are multiple things you can upgrade, and making the right choices about which upgrades to get when is important.
Resource Management
You are underpowered when you start a new run of Dome Keeper; it takes quite a few hits for your drill to break apart the rock, and even longer when that rock contains a resource. The first thing I always do is to mine enough iron to increase my drill’s power so I can dig faster. Immediately this provides visible feedback in the form of faster digging, and passive feedback in that I can get future upgrades sooner. This kind of tangible feedback feels great, and it keeps building up with each upgrade you purchase.
The upgrade UI is nice and clean, so it’s obvious what you’re getting and what it leads to in the progression tree.
I didn’t have to choose upgrading the drill first, though; I could also have chosen to upgrade my mining speed, or the power of the laser weapon on my dome, or even the ability to see how much time I have before monsters come within firing range of the dome. There’s a plethora of choices to make, and strategizing to buy upgrades at the right time in order to maximize my chances of survival is enthralling. Even choices which feel worthless (like buying an indicator that tells me how many waves I’ve survived) feed into the thrill of strategic decision making; by having some options that are obviously worthless, it gives me the thrill of knowing I’m making a correct choice by avoiding them.
The dome and the keeper aren’t the only things to upgrade, either. The gadgets you find buried under the dome also have their own small upgrade tree, which makes them even more fun to use. Some of the upgrades improve efficiency, like adding additional orbs to the Lift to bring more resources back faster. Others change gameplay entirely, like the Teleporter gaining the ability to teleport resources and providing an alternate means of getting resources back to your base. As I play, I also develop partiality towards certain gadgets based on how I like to play (teleporter and lift are go-to gadgets) while others have less value (like the probe and the Drillbert robot). If I bring back a gadget and it turns out to be something I don’t want, I get to choose to shred it for cobalt, which helps prevent me from feeling like I brought back something worthless.
Spending resources on upgrades is important for improving my ability to mine more effectively. Just as important, however, is improving the ability to defend my dome.
Turns out we weren’t alone, after all.
Monster Fighting
The final element of Dome Keeper’s gameplay trifecta is base defense. Every so often, monsters will approach from across the surface of the planet you’ve landed on and attempt to destroy your dome. Losing your dome means losing the game, so you have to prevent this from happening at all costs. Fortunately, this part of gameplay feels fun, and the combat introduces further opportunities for strategic thinking.
Firing a laser feels so darn fun! It’s a straightforward beam of light that melts the health of any monster it hits for as long as you keep it trained on said monster. The effects feel nice and juicy, from the wavy effects of the beam to the small particles that shoot from along the beam trail, from the satisfying shoom sound of the laser to the small controller rumble that happens while I’m opening fire.
Over time, bigger and bigger monsters approach, in greater numbers, so you must upgrade in order to survive. The laser can be made more powerful, and the dome can be improved with greater health and resistance to damage. These upgrades cost the same resources that you use to build up your mining efficacy, so there is a constant tug and pull of when to upgrade your mining to get resource faster and when to upgrade your defense so you can continue to survive.
Time to upgrade my laser. That means it’ll be awhile before I can afford upgrading other things, but I need to start killing monsters faster to reduce time to kill, and with it base damage.
There’s even strategic gameplay in the moment to moment of battle. You can’t move your laser instantaneously, so where you move it to impacts how much damage you take. If you move it to the side with fewer monsters, then that’s more damage coming your way before you can shift the laser back to the other side. If you take enough damage, you need to pop out of laser mode to make repairs (done simply with a payment of cobalt), so you need to make sure you stay on top of how much health you have.
A recent update to Dome Keeper added an upgrade that automatically fixes your dome when it runs out of health, if you have the resources to afford it. It’s something I always get because of how convenient it is, but having it definitely removes a lot of that tension which previously came from managing health in battle.
Overall, fighting the monsters feels good, and figuring out the optimal times to upgrade and the best battlefield tactics for minimizing damage feels continuously interesting. The best moments of Dome Keeper, however, come from when these three tenets of gameplay interact with one another to create thrilling results.
I like to keep my laser in the center of the dome prior to starting the battle, so I can pivot quicker to whichever side spews monsters first.
Rush Moments
I’ve already explored some of the ways Dome Keeper’s systems come together to create good gameplay, but there are some more subtle thrills to be had.
While you’re mining underground, you have to keep a constant eye on the monster proximity countdown. Come back too early, and you’ve wasted precious time you could’ve spent doing more digging; come back too late, and you’re going to take some hits and get behind in your defense. If you get things exactly right and dash back into the base just as an attack wave starts, it creates an incredible rush.
Even in failure to calculate things correctly, it still results in powerful feelings, from the panicked adrenaline burst as you scramble back to the tune of the proximity alarm’s beeping to the relief you feel as you exterminate the last monster in a wave with a sliver of health remaining, able to fight another day. Importantly, you aren’t left with the taste of defeat from these moments; you feel as though some bad decision you made was your near downfall, and formulate solutions to prevent yourself from falling into the same bad situation again.
Whoops, didn’t play that one right. I’ll do better on the next one.
Finally, reaching an endgame moment provides its own thrill. Whether it’s the relic from Relic Hunt mode, to making the decision that you have enough score to trigger the sendoff in Prestige mode, reaching the end goal feels powerful in that the end is in sight, if only you can hold out and play well just a few moments longer. It can even turn into a bit of mastery, where you know you can win right now, but choose to try and play as long as you can, betting on being able to squeak out a win just before becoming overwhelmed.
My Environment
The gameplay itself is enjoyable, but just as important for me is how well it fits my lifestyle. I don’t get much time to play games, I don’t enjoy spending lots of time learning how to play them, and short and sweet games usually don’t feel cool enough for me to enjoy playing them. Dome Keeper manages to hit a sweet spot for all three of those metrics, and I’ll explain how.
Hmm…find resources more easily, or improve my base defense? Or, perhaps, take the cobalt to repair my base? Choices, choices.
Time Spent
I don’t have much spare time. In addition to spending 40 hours a week doing my day job, I spend at least 16 hours a week on developing my own games, and at least half of the remaining time in various family activities. I simply don’t have time anymore for epic games that cost me dozens of hours of gameplay, like I did as a teenager and in my early twenties.
The gameplay of Dome Keeper is such that I can easily fit a meaningful gameplay session in roughly 20 minutes. It doesn’t feel like I’m hard pressed to end the session, either; a full cycle takes roughly 2-3 minutes, so it’s not hard to find a natural stopping point.
I do confess to sometimes finding it hard to stop because of that “just one more thing” mentality, though. Gotta find out what that gadget is before I quit my session, right?
Completing a full round of Dome Keeper takes, at most, a couple of hours. That’s enough time for me to finish a run at least once each week. Game completion gives me a great sense of resolution, even if said run ends in defeat. Comparatively, games with a massive story and lots of content can easily take me months to reach a completion point, and playing the same game that long without resolution leaves me feeling frustrated. Thus, Dome Keeper’s short run time is something I enjoy a lot.
As someone who has a life to live and games to develop, it’s easier for me to enjoy games that have short core sessions and modest run times; in those regards, Dome Keeper’s timing fits my sweet spot almost perfectly.
Don’t mind me, I’m just hauling some iron.
Simplicity
Similarly to how I have less time to play games, I have less time to understand the complexity of a game’s loop. If something takes a lot of time for me to learn, I have a lot less fun playing it; at the same time, if the gameplay is too simple, then it’s boring. Once again, Dome Keeper manages to hit a perfect balance for my tastes.
Having only a small number of resources to manage helps keep the complexity down. The fewer compounding interactions I have to keep track of, the easier it is for me to understand those resource interactions. I think the number three, in particular, is just right. Having only two resources would make for not much challenge at all, and having more than three, while not impossibly complex, would add more information for me to comprehend, and make the game slightly more difficult to get into.
Relic found! Time to dig it out and bring it back to the base…
Speaking of complex interactions, Dome Keeper has a lot of them. It may not be obvious at first, though, because those complexities arise from the combination of simple interactions. Buying a drill upgrade? Easy to understand the value. Buying a more powerful laser? Also easy to understand. But what if you only have the resources to buy one upgrade or the other? Less powerful drill means it’ll take longer to get more resources for upgrading, but less powerful laser means you might not have enough firepower to prevent the next wave from doing a lot of damage. Those two simple interactions (upgrading the drill and the laser) combine to form a complex strategic decision, one that changes from run to run depending on the situation I’m in. I love simple ideas that combine to form deep strategies, and Dome Keeper is full of such interactions.
Playing the game almost feels like routine to me at this point. That may sound like a weird thing to speak of in a positive light, but it makes sense for someone in my position. A routine is something I can measure myself against easily to see how well I’m doing. Following the patterns I’ve established are good feels rewarding when I carry them out with maximal efficiency. Finally, that comfy feeling of following a routine is a pleasant break from the stressful challenges I face in my day job, game development efforts, and family life.
Dome Keeper may seem like a simple game to play, with not much time spent doing it. For me, that’s a good combination, and everything feels just complex enough that I don’t get bored of playing it the way that I do.
One sec, trying to survive the final fight.
Fun Atmosphere
Finally, the style Dome Keeper is presented in appeals to me. The pixel art is crisp, and easy to read, so it doesn’t take me much effort to read the information I need; it also conveys a good sci-fi atmosphere without needing too much detail. The sound design is great, from the tings of the drill to the crumbling of the rocks to the screams of the monsters as they die. And the music is chill enough to fit well in the background and not pull me out of my mine-digging reverie.
Conclusion
I like Dome Keeper a lot. In studying the game, I’ve realized that its gameplay is a great fit for my interests, and the way it fits into my busy lifestyle makes it easy for me to make small amounts of time to play it.
As a game designer, still working on crafting an enjoyable game, these insights are valuable to keep in mind. I don’t want to design another Dome Keeper, but understanding why I enjoy that game so much aids my own efforts to create games that, hopefully, are just as enjoyable.
The aftermath of the final battle. I survive, victorious once again.
Rebecca and I have spent the last half year working on the same game: Dice Tower (the full release). We hit a milestone of getting the game ready enough to show at a local playtest livestream, and I really wanted to take a break from Dice Tower development. The two of us decided to participate in another game jam; it would let us try making something different, and possibly something that would be worth continuing after Dice Tower was finished.
Participating in more game jams was something we’d intended to do after GMTK Game Jam last year, but we spent so much time focused on Dice Tower that we never made time for it.
After some research into game jams happening at the time, we chose to enter Godot Wild Jam 54. It’s a game jam where only Godot engine-made games were allowed, so we’d get a chance to see other games made in that engine. It was a smaller size game jam than others we’d entered in the past, so the odds that people would play our game (and leave feedback) was higher. Finally, the length of the jam was nine days, and we thought that would allow us to better fit jam work in alongside our normal day job and family responsibilities without taking too much time off.
The game we created was Belle Bubb’s Sand-Witch Shop, and it was about working in a sandwich shop making “cursed sandwiches” (fitting the jam’s theme of “curses”) through 2D drag-and-drop gameplay. Sadly, we were unable to finish the game due to a convergence of multiple major issues; that said, those issues provided great insight into how to get better at making games, so I don’t think our efforts were wasted.
I’ll identify what those issues were, explain why they were problematic, and explain how I’m going to learn from them to become a better game developer.
Even though our game was incomplete, we still uploaded what we had to the jam anyway, so we could get feedback.
Issue: Isolated Tests
The gameplay for Belle Bubb’s Sand-Witch Shop involved doing new kinds of things that I was less familiar with, like drag and drop mechanics, evaluating what constituted a “sandwich”, and having ingredients that could interact with one another both during sandwich assembly and during sandwich consumption (aka “eating”). I chose to try and build each system in isolation and test that each worked well enough before moving on to others.
Creating all the systems in isolation meant there was no gameplay loop for the majority of the game’s development, so there couldn’t be any testing around whether the game was actually fun. Sure, dragging ingredients around and having them interact with one another was cool, but without evaluating them it was just an aimless sandbox experience. Testing the systems independently also meant I couldn’t see how well they’d interface with one another. This had catastrophic consequences when I encountered fatal issues with those systems once they were brought together.
Lesson Learned: No More Isolated Tests
I like making isolated tests because it’s simpler for me to wrap my head around them; I also don’t have to immediately worry about making them work as part of a whole. However, this is twice now that waiting to integrate systems until later in the project has resulted in slower progress. The jam version of Dice Tower was able to recover from this; Belle Bubb’s Sand-Witch Shop was not. Therefore, for games I’m working on the future, I need to build all the systems together at once. That should help me identify integration problems much sooner, and come up with better fixes for them.
Gameplay was to drag ingredients from their containers onto the sandwich being assembled on the countertop. The taste profile on the right would update as each ingredient was added to the sandwich.
Issue: Badly-Designed Resource System
The foundation of evaluating the sandwich structure was a system that made heavy use of Godot’s customizable Resource nodes to store data in complex ways. This was somewhat similar to resource-based work that I’d created for Dice Tower, so I thought it wouldn’t be too much work to do the same kind of thing here. The architecture I came up with involved having the Ingredient resource store references to its UI model, and then the UI model also referencing its own Ingredient resource.
This dual-reference approach led to horrible issues with resource uniqueness. I was treating each Ingredient resource as a unique entity, but Godot treats resources as shared entities; anything holding a reference to a resource refers to the same instance of that resource. Since each ingredient, in design, could have its own unique set of curses and effects, I had to do a lot of duplication when creating new Ingredients and assigning them to models. This created all kinds of bugs where the stored data on a model or resource didn’t match what the game systems expected, and the massive amount of nesting took hours for me to debug.
All that stemmed from one complex resource system. I built multiple such architectures, and then had them all reference each other… The bugs stemming from that hell were the reason we wound up not being able to fully complete the game.
Lesson Learned: Don’t Abuse Resources
Working with Godot’s resources allows for creating powerful customizable systems, but it is incredibly easy to abuse that power to the point of making things insanely difficult to troubleshoot. I need to not use resources so much for the data solutions. At the very least, I need to minimize how much nested resource storage I’m doing, and make sure the architectures I’m coming up with aren’t fatally flawed before building on top of them.
Bringing in a cursed ingredient had an effect on ingredients around it. For instance, the tomatoes were cursed to repel lettuce.
Issue: Too Much Undetected Complexity
I had no idea how complex the game we were trying to make actually was. To illustrate, these are the features which we managed to get into the game. At the time of planning, I thought they were simple:
Sandwiches have ingredients
Ingredients have curses
Curses have effects
Ingredients can also have effects
The same effects can be assigned to both curses and ingredients
Ingredients have individual tastes
Sandwich ingredients are combined together to form a recipe
They also come together to form the actual sandwich
Both of these have a “taste profile” that is the amalgamation of all ingredient tastes
They also have an “effects profile” that is the amalgamation of all curse and ingredient effects
Recipe and food need to be compared to each other to determine if they are “close enough”
All of the above needs to be represented in-game and in UI
That wasn’t even the full scope of what we had originally planned to put into the game. Other ideas include:
Randomizing which ingredients have what curses
Getting paid for making sandwiches
Using earned money to choose what ingredients to buy
Having a story about how you sold your soul to keep your sandwich shop alive
Post-sandwich order review that included descriptions of what effects your curses had
That was a lot of complexity. It didn’t feel that way at the beginning because I didn’t think things through. The further we got into making the game, the more I realized the amount of effort needed to make all these systems and interactions work was insane.
And, because I waited so long to bring systems together, I didn’t experience this revelation until it became too late to pivot to something simpler.
Lesson Learned: Work Through Systems Implications in Advance
I need to have much better insight ahead of time where potential complexities will arise. The best way I can think about doing that is diagramming them somewhere, on paper or in a whitescreen board app, so I can visualize what systems need to interact with one another. If there’s too much complexity, then it’s a clear sign that I need to scale things back. I’ve been worried in the past that this will make the process take longer, but the consequence of not trying to do so is having things take longer anyway and not realize it until it’s too late.
Other ingredients had effects that could cancel out curses. The cheese, in this instance, cancelled out any Repulsive curse on any other ingredient.
Issue: There Was No Planned Endgame
We came up with an idea for interesting gameplay, but had no concept of what the goal of the game was going to be. I kept putting a pin in it to “come back to” after the other systems were developed. Since none of the other systems were ever fully completed, we never got a chance to figure out what happens at the end.
Lacking this conceptualization of the player’s goal contributed to the underestimation of how much work was needed. There was no way to test the player could play through a core gameplay loop because we didn’t know what said loop was beyond “make a sandwich”. Without that guiding star, it was too easy to fall into a pattern of perfecting the systems with no plan for whether those systems would work well together to create a satisfying resolution.
Lesson Learned: Define the Goal at the Outset
Knowing what the player’s in-game goal is from the beginning not only gives a development target to shoot for in terms of minimum viability, but it also makes it clear what systems really need to be kept to make a fun gameplay experience. That doesn’t mean the goal has to be immutable; in fact, the goal will likely need to be simplified further as development roadblocks cut into available development time. If the goal is known in advance, though, then figuring out how to juggle all the necessary concerns should become much simpler.
To build a sandwich, you would have to account for the curses each ingredient had. You would then have to be careful to make sure the taste profile of those combined ingredients fit close enough to the profile of the sandwich recipe.
Issue: I Was Overworked
As mentioned previously, we’d spent a long time before this working on Dice Tower. In that entire time, I hadn’t really taken a break, and this caught up with me during the jam.
I’m no stranger to pushing through burnout—I don’t think I’ve ever fully recovered from burning myself out nearly a decade ago—but I underestimated how much easier it is to work through burnout when on a consistent, slow grind. When I needed to work fast and come up with good ideas quickly, my brain refused to cooperate. That’s one of the reasons I didn’t have a good grasp on how complex the ideas were: I couldn’t think far enough ahead to evaluate the consequences of those ideas, so I instead ignored that part of the process to work on the next thing necessary. The consequence of working through major burnout was significantly slowed progress, much too slow for a game jam. Subsequently, since I fell behind pace, I didn’t give myself time to rest during the jam, which only further exacerbated the mental problems I was dealing with. It was a nasty negative feedback loop.
Lesson Learned: Take a Break!
I’m bad at taking time off to actually take time off. I also know that it’s easier for me to push through tiredness and make progress if I do it consistently. But there’s a limit to how much grind I can endure, and I need to respect that. At the very least, I should take some time off from game development and other project work prior to starting a new game jam, to give myself time to recharge properly. That should hopefully make it easier for me to work and plan properly, and thus also make it easier to not feel like I’m falling behind, resulting in pacing my work schedule appropriately.
There were lots of possibilities to work with, both for ingredients and for effects.
Conclusion
I entered a game jam with the intent of working on a different project which might become future inspiration. Instead, I endured an experience that revealed multiple flaws with how I approach game development. Though the experience was painful, the lessons I learned from it are valuable, and that makes me glad for having gone through it.
Lest this postmortem post make it seem like the entire project was a disaster, let me highlight some of the things that did go right:
People liked messing around with making the sandwiches, so there is an idea there which might be worth future exploration.
While the resource system was buggy, it did allow for relatively easy creation of ingredients, curses, and effects.
Of all the things that got cut, at least sound and music weren’t among them this time!
Our focus is going back to Dice Tower, but we definitely intend to enter game jams more frequently in the future. Even when the projects don’t succeed in being good games, they succeed in being great teachers.
This July, Rebecca and I took part in Game Maker’s Toolkit Game Jam 2022 (an annual game jam hosted by the creator of YouTube channel Game Maker’s Toolkit, Mark Brown), making a game called Dice Tower in only fifty hours. It was our first game jam in a long time, having spent the last three years focusing (and failing) on making our “first big release”. Those fifty hours proved to be an intense, fulfilling experience, and I want to examine how things went. I’ll start with the goals that Rebecca and I made, continue into what happened during the jam, and conclude with whether I felt our goals were met and what we’ll do in the future.
Our Goals
First and foremost, Rebecca and I wanted to actually release the game! Our last jam effort, Rabbit Trails, never saw the light of day because we didn’t get the build submitted in time, and we were determined to avoid making that mistake again.
To that end, we explicitly wanted to scope our game small enough that it had a realistic chance of being completed by jam’s end. Further supporting that goal, we also decided that we would avoid trying to come up with something “clever”; we would be fine with coming up with feasible and fun ideas, even if they might be ideas that other people were likely to come up with. Finally, we determined that we would specifically make a 2D side-scrolling platformer, because that was the genre we were most familiar with; we didn’t want to waste time figuring out how to do a genre that we lacked experience in making.
Based on what had happened in previous jams, we had a few other goals we wanted to meet. Given that our previous games felt bland, we wanted to make sure whatever we made felt more polished and juicy, thereby increasing how good it felt to play. Previous jams had left us feeling burnt out and exhausted, and, while that is a traditional hallmark of game jams, we strongly wanted to avoid feeling that way at this jam’s end, so we made plans to limit how many hours we’d work per day so that we’d have time to relax and get our normal amount of sleep.
Separate from the in-jam goals, we had one ulterior objective: we wanted to see if the idea we came up with was good enough to develop further into a small release. The game we’d been working on, code-named “Squirrel Project”, was still very much in the early prototyping stage; we’d built and tried a lot of things, but made little progress in actually putting together a full game experience. Both of us felt concerned with how much time we were taking in making a “small game”, so we were interested in seeing if doing game jams might give us smaller concepts that would be easier to develop and release quickly. This jam would serve as a proof of concept for this theory.
Day One (Friday, 7/15/22)
The day of the jam arrived. Our son was off spending the weekend with relatives, we’d prepared our meal plan for the next couple of days, and I’d taken time off from my day job. We sat around my laptop, awaiting Mark Brown’s announcement of the theme. At noon, the video was released, and we saw the theme emblazoned on the screen:
Initial Planning
I hated the theme at first impression. One of my takeaways from working on Sanity Wars Reimagined (our first-ever full release) was that working with randomness in game design was hard, and now we had a game theme which strongly implied designing a game focusing around randomness. In my mind, it would be harder to design a game around dice that wasn’t random in some way. Concerned now with whether we would have enough time to make a good game, I started brainstorming ideas with Rebecca.
We spent that first hour working out an idea; the first idea we hit upon wound up dominating the rest of our brainstorming session, to the point that we didn’t seriously entertain anything else. The game would be a rogue-lite platformer, where you moved to various stations and rolled dice to determine which ability you got to use for the next section of gameplay. Throughout the level would be enemies that you could defeat to collect more dice, which would then give you a better chance to roll higher at the ability checkpoints, thereby increasing your odds of getting the “good” abilities.
I intentionally wanted to minimize the randomness of our game so that players felt they had some level of control over the outcomes of dice rolls, and I liked the idea of using dice quantity to achieve this outcome. The more dice you add to a roll, the more likely you are to get certain number totals. This is known as a probability distribution.
At the end of that hour, we formally determined that this was the idea we wanted to work with. Rebecca started crunching out pixel art, and I got to work making a prototype for our envisioned mechanics.
That Old, Familiar Foe
The next few hours of my life were spent working on the various elements that would serve as a foundation for our mechanics. I threw together some simple dice code, along with dice containers that could roll all the dice they were given. I also pilfered my player character and enemy AI code from Squirrel Project, to jumpstart development in those areas.
But something happened as I started trying to work out how I was going to make the player’s abilities work: my brain began to freeze up. This was a frighteningly familiar sensation: I’d felt this way near the end of developing the original Sanity Wars (for Ludum Dare 43). Dark thoughts started clouding my mind:
There’s not enough time to make this game.
You’re going to fail to finish it, just like your last game jam.
Is what you’re feeling proof that you’re not really good enough to be a game developer?
Slowly, I forced myself to work through this debilitating state of mind. After several more hours, I came up with a janky prototype for firing projectiles, and a broken prototype for imparting status effects on the player (like a shield). It occurred to me that if I was having this much trouble making something as simple (cough cough) as player abilities, then how was I going to have time to fix my glitchy enemy AI, and develop the core mechanic of rolling dice to gain one of multiple abilities, and make enough content to make this game feel adequate, let alone good. Oh, and then there was still bugfixing, sound and music creation, playtesting…
It finally hit me: This idea wasn’t going to work. There was simply too much complexity that was not yet done, and I was struggling with the foundational aspects that needed to be built just to try out our idea. There was no way I would be able to finish this vision of our game on time.
My mind in shambles and my body exhausted, I shared my feelings and concerns with Rebecca, and she agreed that we needed to pivot to a new idea. We tried to come up with something, but we were both too tired to think clearly. Therefore, we decided to be done for the evening, get some supper, and head to bed.
Note the lack of planned relaxation time.
As I lay in bed, I fretted about whether we could actually come up with a new idea. That first idea was by far the best of what few ideas we’d been able to come up with during our brainstorming session; how were we going to suddenly come up with an idea that was good enough and simpler? With these worries exhausting my mind, I fell asleep.
This is the first thing I made, a simple test with a dice container and N number of dice. It felt oddly satisfying to keep pressing the “Roll Dice” button.
Day Two (Saturday, 7/16/22)
The next morning, at 6am, I woke up, took a shower, and played a video game briefly. This was my normal morning routine during the workday, and I was determined to keep to it. At 7am, I grabbed a cup of coffee and sprawled out on the couch, pad of paper and pen resting upon an adjacent TV tray, prepared to try and come up with a new idea.
Rebecca joined me at 7:30am, after she did her own waking routines.
The New Idea
I wrote down the elements we already had: Rebecca’s character art and tileset from her previous day’s work, some dice and dice containers, and a player entity that was decently functional as a platformer character. If our new idea could incorporate those elements, then at least not all of yesterday’s work would go to waste.
After thinking about it for a long while, I hit upon an idea: what if you rolled dice to determine how much time you had to complete a level? As you moved through each level, you could collect dice and spend them at the end-of-level checkpoints to increase your odds of getting more time to complete the next level. If that were combined with a scoring system involving finding treasure collectibles that were also scattered throughout each level, then there’d be potentially interesting gameplay around gambling how many dice you’d need to collect to guarantee that you had enough time in each level to collect enough treasure to get a high score.
I pitched the idea to Rebecca, and after some discussion about that and a few other ideas, we decided this was the simplest idea, and therefore had the best chance of being completed before the deadline. Fortunately, this idea also did successfully incorporate most of the elements that we’d worked on yesterday, so we didn’t have to waste time recreating assets. On the other hand, this idea needed to work; there was likely not going to be any time to come up with another plan if we spent time on this one and it failed.
Our idea and stakes in mind, Rebecca and I once more commenced our work.
Slogging Through the Day
I created various test scenes in Godot. Slowly, I began to amass the individual systems and entities that I’d eventually put together to form the gameplay. Throughout the day, I felt very sluggish and lethargic mentally; this was likely a side effect of the burnout I’d put myself through yesterday. I kept reminding myself that some amount of progress was better than none at all, but it still didn’t feel great.
By the start of that evening, I had a bunch of systems, but nothing that fully integrated them. Taking a break, I went outside for a walk and some breaths of fresh air. As I trod the trails in our neighborhood, I worked out the game’s next steps. I reasoned that if I continued to put each system and entity together in isolation and test them, I stood a realistic chance of running out of time to put everything together into a cohesive game. Therefore, I decided to forgo this approach, and simply put everything together now, and build the remaining systems as I went along. This flew in the face of how I traditionally prefer to program things, but I resolved to set aside my clean code concerns and focus simply on getting the game working.
This might look like a game level, but it was really just a single test scene that I was throwing my creations into. There was no logic connecting any of these things together to form a gameplay loop.
The Late Evening Dash
Not long after I returned from my walk, it became 7pm. This was the twelve-hour mark, and in our pre-jam plans I’d determined that I wouldn’t work more than twelve hours on Saturday. Yet I still didn’t have much that was actually put together. I was now faced with a conundrum: should I commit to stopping work now, and risk not having enough time tomorrow to finish the minimum viable product?
Ultimately, I decided that I would try to get an MVP done tonight, or at least keep working until I felt ready to stop. Three hours later, while that MVP was still incomplete, I had put together the vast majority of what was needed to make the game playable: a level loading system, the dice-rolling checkpoints, dice collection (though said dice weren’t yet connected to the checkpoints), the level timer and having it set from rolling the checkpoint dice, rudimentary menu UI, and player death. At this point, I felt that continuing to work would just cut into my sleeping time, and I was still determined to get a proper amount of sleep. At the least, those few hours had produced enough progress that I felt confident that I could finish things up tomorrow morning.
I quickly prepared for bed, and after watching some YouTube to wind down I fell asleep.
One of three tutorial levels I’d created that night. This one is to teach the player that they can pick up dice; when they advance to the next checkpoint, they’ll see that newly-collected die in their dice pool, and hopefully make the connection between the two actions without having to explicitly spell it out.
Day Three (Sunday, 7/17/22)
That morning, I woke up at 6am, as usual, but this time I skipped all of my morning routine save the shower. By 6:30am, I was at my computer and raring to go.
Blazing Speed and Fury
The first thing I did was export the game as it currently was. I’d been burned enough times by last-minute exports that I was determined to make sure that didn’t bite me in the butt this time. Fortunately, this time there were no export-specific crashes or bugs, so I resumed work on the game proper.
For the next several hours, my mind singularly focused on getting this game done. I finally made the checkpoints accept the dice you collected, thus completing the core loop of rolling dice to determine the time you had to make it through the level. I added game restart logic, added transition logic for when the player was moving between levels, added endgame conditions and win/loss logic, and fix various bugs encountered along the way. I also realized that I wouldn’t have time to implement a scoring system, so I unceremoniously cut it from the MVP features.
Cutting the scoring system also meant cutting the third tutorial level, which would have taught the player about the treasure collectible. Additionally, it would’ve served as a prelude to the intended tension of collecting treasure to increase your score and collecting enough dice to have more time in the next level.
Around noon, I finally had the game fully working. I could boot the application, start a new game from the main menu, play through all the designated levels, and successfully reach the victory level to win the game (or run out of time and lose). If nothing else, we at least had a playable game!
Final Push
The game jam started at noon on Friday, and as it was now noon on Sunday that meant 48 hours had passed. Thankfully, the jam had a two-hour grace period for uploading and submitting games to Itch.io. That meant I had less than two hours to jam as much content and polish into the game as I could before release!
I blazed my way through creating six levels, spending less than an hour to do so. Of course, that meant I had little time to balance the levels properly, beyond ensuring they could be completed in an average amount of time. One thing I did spend time on was adding the various pieces of decorative art Rebecca made to each level. It might seem frivolous to add decorations, but I think having a nicely-decorated level goes a long way towards breaking up level monotony and sameness, and honestly it didn’t take that long for me to add those things.
After a few trials, I hit upon a solution for balancing the randomness for the dice checkpoints: I’d give the player six free seconds at the start of each level, and a single die at each checkpoint (in addition to the ones collected through gameplay). As I playtested the level, that at least felt long enough that, using probability curves, the average player could have a decent chance of finishing each level.
Intentionally, I chose to not focus at all on adding sound effects and music. My reasoning went thusly: players would probably prefer to have more content to play through than have sound and music with very little content. It made me remorseful, because sound can make an okay game feel great, but there just wasn’t enough time to do a good job of it, so I decided it was better to just cut audio entirely.
Finally, at 1:30pm, I looked at what we had and determined it was good enough. Rebecca had been working on setting up our Itch.io page, and I gave her the final game build export to upload to the store page. We submitted the game just in the nick of time; shortly after our submission was processed, Itch.io crashed under the weight of thousands of last-minute uploads. While this server strain prevented us from adding images for our game page (we got them in later), at least we already had the game uploaded and submitted, so we weren’t in danger of missing the deadline.
Rebecca and I looked at each other. We’d done it! We’d successfully made a game and submitted it! We spent the rest of the afternoon out and about on a date, taking advantage of our last bits of free time before returning to parenthood the next day.
Feedback
Over the next week, our game was played and rated by people. We received multiple comments about how people enjoyed the core idea, which was encouraging.
A couple of our friends from the IGDA Twin Cities community took it upon themselves to speedrun our game. This surprised me, because I thought the inherently random nature of our game would be a discouragement from speedrunning. They told me they enjoyed it a lot, however, and over the course of that week they posted videos of ridiculously speedy runs and provided good feedback.
By happenstance, the Twin Cities Playtest session for July was that same week, so Rebecca and I submitted Dice Tower to be played as part of that stream. Mark LaCroix, Lane Davis, and Patrick Yang all enjoyed the game’s core concept, and gave us tremendous feedback on where they felt improvements could be made, as well as different directions we could go to further expand and elaborate on the core mechanic. It was a great session, and we are greatly indebted to them for their awesome feedback.
After the play-and-review week had passed, Mark Brown made his video announcing the winners of the jam, and we got to see our final results online (we did not make it into the video, which only featured the top one-hundred games).
For a game jam with over six thousand entries, we did surprisingly well, placing in the top 50% in overall score. Our creativity score was in the top third, which pleased me greatly; it felt like further validation that our core concept was good enough to build on.
Reflection
I want to circle back to those goals Rebecca and I set before the jam, and see how we did in meeting them. There were a couple of other takeaways I had as well, which I’ll present after looking at the goals.
Did We Meet Our Goals?
First and foremost, we wanted to release a game, and we did! That was huge for us, given our last effort died in development. In particular, to release this game after the mental fracturing I endured on Friday night, and having to pivot to a new idea, shows that, perhaps, we’re decent game developers, after all.
Did we scope our game properly? At the beginning, no, definitely not. Fortunately, we recognized this (albeit after spending a day on it), and decided to change to a more feasible idea. Even though we had to cut scope from the new idea as well, the core was small enough that we were able to make it in the time allotted. It gives us a new baseline for how much work we can fit in a given amount of time, which should serve us well in future endeavors.
It’s a similar story for our theme interpretation. Our first idea seemed simple, but turned out to be complex under the hood, as it’s a lot of work to not only add lots of different player abilities, but game entities (such as enemies) to use those abilities on. In hindsight, I laugh at how we thought that kind of game was feasible in 48 hours, with not nearly enough foundation in place beforehand. That said, once again, we pivoted from the complex to the simple, and the new theme interpretation was simple enough to be doable.
Ironically, I thought this would be a common enough idea that plenty of other people would do it, but I actually never encountered a mechanic similar to this during my plays of other jam games, and multiple people commented on the novelty of the idea. Perhaps our focus on coming up with a good idea instead of a unique one managed to get us the best of both worlds!
We stuck to our guns and made a 2D side-scrolling platformer, even though at times I felt like that made it more difficult to find a good theme interpretation. Because we knew how to work in that genre, it made our mid-development pivot possible; I don’t think we could have been successful in doing that if we’d tried to make something unfamiliar to us. Additionally, I think using a genre where dice are less commonly used led to coming up with something more unique than we might’ve if we’d done an RPG or top-down game (two genres I thought would be easier fits for the theme).
One area we did miss on was making this a polished release. The missing audio was sorely felt. That said, I really like the art that Rebecca came up with, especially the decorations, and I don’t regret the decision to cut sound in favor of making levels and placing her doodads. Honestly, despite the missing audio, the fact that this game was fun and playable made this jam release feel more polished than our previous jam efforts.
That dice banner and the dice family portraits were particularly fun to place throughout the levels.
A big success we had was in self-care management. Even though we didn’t stick to the maximum hours per day we set before the jam, we took care to make sure we got enough sleep each night. The end result is that this is the first jam we’ve done that both of us didn’t feel utterly spent at the end of it. That allowed us to enjoy a lovely afternoon and evening together as a couple, and we didn’t feel totally shot for days thereafter. I think that also helped us have the energy needed to push us through to the finish line.
One final goal remains to be evaluated: was our game good enough to base a future release upon? The answer is “yes”. Based on the enthusiastic feedback we received, plus our own thoughts about where the concept could go, we’ve decided to put Squirrel Project aside and focus on making a full, but small, release out of Dice Tower. With some more features and polish, and maybe a dollop of background story to tie things together, we think Dice Tower could make a good starting point for our first paid release.
Additionally, we plan to take part in future game jams, and further explore the concept of making quick games to find small ideas with good potential.
Other Takeaways
There were some additional things we learned from the jam. These emerged as byproducts of the things we tried during the jam.
Our initial thoughts were that we’d spend our brainstorming session coming up with multiple ideas, and then make small prototypes for each of them and decide which of them was the one we wanted to make. It didn’t turn out that way; both our first and second attempts settled early on a single idea, to the point where we didn’t really have many other ideas that we seriously considered. I think that perhaps that’s just how Rebecca and I work; it’s easier for us to jump into an idea and try it rather than piece a bunch of ideas together and flesh all of them out on paper. Perhaps in our next jam, then, we’ll deliberately settle on an idea right away and just go for it, making explicit allowance for pivoting if it starts feeling like too big an idea to work with.
Personally, I also learned that I need to let go of trying to test every little thing in isolation. That may be a good approach for building long-lived, stable systems, but for something as volatile as game prototyping it just slows the process down too much, with no game to show for it. By putting things together as early as possible, it makes things feel more real because it is the real game! I’ll keep this in mind for future projects, to just make a go of it right off the bat, and save my efforts to make things nice and tidy for when the ideas are codified and made official.
Conclusion
Ultimately, I’m glad Rebecca and I decided to participate in GMTK Game Jam 2022. It feels exhilarating to not only finish a game in that short amount of time, but to defeat some of my internal demons from previous jams in the process. We feel more confident in our ability to develop and release games, and we’re excited to tackle making the full release of Dice Tower.
It’ll be interesting to compare this postmortem with the postmortem for whatever that game becomes!
In the meantime, though, feel free to try out our jam release of Dice Tower and let us know what you think!
Over the last three days, PixelLunatic and I worked on a game for Game Maker’s Tool Kit Game Jam 2022. Our entry is Dice Tower, and you can play it here!
Keep in mind that this is a game jam game, so this is far from perfect (for example, there is no audio), but we think the end result would up being pretty fun to play! We might decide to develop this further in the future, though we haven’t officially committed to doing so yet.
After seven months of development, Rebecca (PixelLunatic) and I have released Sanity Wars Reimagined! Along the way, we learned a lot, lessons we hope to apply to future games we develop. In this article, I want to dig a little bit into what we learned, from what we were initially trying to do to where we wound up, and the various lessons learned and mistakes made along the way.
This project started with a discussion Rebecca and I had about where things were from a long-term standpoint. For the past few years, we’d attempted, and abandoned, various prototypes, and it seemed like we were still far off from actually releasing a product. At the time, I had just come off of a month-long project exploring the creation of game AI; my intent was to start another project aimed at making improvements to the dialogue system. During our discussion, however, Rebecca pointed out that, while our ultimate goal was to make games and sell them, we had a bad habit of not committing to anything long enough to get it released. What’s more, she was concerned that this pattern of starting and abandoning projects wasn’t good for our morale.
She was right to be concerned about this; I’d felt that making an actual release was still a point far off in the distance, and this was making it easier for me to accept the idea of working on non-release projects, to “gain experience”. How would we ever learn how to improve, though, if we never got our projects to a releasable, playable state? Only two of our game jam games and one of our prototypes had ever been given to other people to play, so we had very little feedback on where we needed to improve. I realized that we couldn’t keep waiting to release something until “we got better”; we needed to make something and release it, however bad it may be.
We decided that we would start with a small project, so that it would take less time to get it to a releasable state. To that end, we determined that the project should be a remake of our first jam game, Sanity Wars. By remaking a game that was already released, we thought, we could focus on actually building the parts needed to make the game work; since I’d made those things work previously, we would hopefully avoid the pitfall of trying to create mechanics that were beyond our current skill to implement, or would take too much time to build. Why Sanity Wars? Out of all the previous jam games we’d made, it seemed like the most successful one, so we thought we could just add some polish, redo the art (since Rebecca did not do the original’s art), and it would be fine.
With that, our next project was set: Sanity Wars Reimagined. We would stay faithful to the mechanics of the original, aiming only to remake them in Godot, as this would be quicker than trying to iterate and make new mechanics. I would also take the opportunity to try and make systems that would be reusable in future games; ideally, we would treat Sanity Wars Reimagined as a foundation that we would directly expand upon for the next game. Since the original Sanity Wars was done in three full days, I thought this project wouldn’t take long to complete. Accounting for our busy adult schedules, I estimated the work would take two weeks to complete; at most, a month.
It didn’t take two weeks. It didn’t take a month. It took seven months before we finally released Sanity Wars Reimagined on Itch.io. Along the way, we made significant modifications to the core mechanics, removing multiple parts that were considered essential to the original Sanity Wars; even with those changes, the end result was still not that fun (in our minds). There were many times during the development period where it felt like it was going to drag on and on, with no end in sight. All that said, I still think Sanity Wars Reimagined was a successful release.
Why do I think that? To answer that question, I want to examine what technologies we developed during the project, what mistakes we made, and what we plan to do to improve things for our next project.
Technologies Developed
A lot of what I made from a code standpoint was able to be imported back into my boilerplate Godot project, which will then be available from the start when I clone the Genesis boilerplate to make any future game project. In doing so, I’ve hopefully decreased the amount of development time needed for those projects.
Genesis is name of a tool I created in NodeJS that lets me keep a centralized Godot boilerplate template and create new projects from that boilerplate using simple commands in a command line interface. To give a non-technical summary, it allows me to quickly create and update new Godot projects that include common code that I want to reuse from project to project.
Here are some of the things that I’ll be able to make use of as a result of the work done for Sanity Wars Reimagined:
Resolution Management
There is a lot to consider when supporting multiple resolutions for a game, especially one that uses a pixel art aesthetic. I was already aware of this before committing to figuring out a solution for Sanity Wars Reimagined, but I underestimated just how much more there was to learn. The good news is that I created a solution that not only works for Sanity Wars Reimagined, but is generalized enough that I can use it as the starting point for future games.
I’ll talk in brief about some of the struggles I had to contend with and what I did to solve them.
For starters, when working with pixel art, scaling is a non-trivial issue. Because you are literally placing pixels in precise positions to render your art aesthetic, your scaling must always keep the same aspect ratio as your initial render; on top of that, it must specifically be increased in whole integer factors. This means you can only support a limited number of window sizes without messing up the pixel art. That plays a huge factor in determining what your base size is going to be; since most monitors use a 1920px by 1080px resolution size, your base needs to be a whole integer scale of 1920×1080, or else the game view is not going to actually fill the whole screen when it is maximized to fill the screen (aka fullscreen).
The way fullscreen modes are typically handled for pixel art games, when they attempt to handle it at all, is to set the game view to one of those specific ratios, and letterbox the surrounding area that doesn’t fit cleanly into that ratio. That is the approach I chose for my fullscreen management as well.
Godot does give you the means to scale your game window such that it renders the pixel art cleanly, and you can also write logic to limit supported resolution sizes to only those that scale in whole integers from the base resolution. However, there is a catch with the native way Godot handles this: any UI text that isn’t pixel-perfect becomes blurry, which isn’t a great look to have. I could have switched to only using pixel-perfect fonts, but that wasn’t the look I wanted the game UI to have. After spending a lot of time experimenting with ways to handle this in Godot’s settings, I determined that I would have to create a custom solution to achieve the effect that I wanted.
I wound up talking to Noel Berry (of Extremely OK Games) about how Celeste handled this problem, as its crisp UI over gameplay pixel art was similar to what I was hoping to achieve. He told me that they actually made the UI render at 1920×1080 at default, and then scaled the UI up or down depending on what the game’s current resolution was. This inspired me to create a version of that solution for Sanity Wars Reimagined. I created a UI scaling node that accepts a base resolution, and then changes its scale (and subsequently the scale of its child and grandchild nodes) in response to what the game’s current resolution is. It took a lot of effort, but in the end I was able to get this working correctly, with some minor caveats*.
* I’ll be coming back to these caveats later on in the article, when I discuss mistakes that were made.
Overall, I’m very pleased with the solution I developed for resolution management in Sanity Wars Reimagined, and ideally this is something that should just work for future pixel art-based games.
Screen Management
Another system I developed for Sanity Wars Reimagined is a screen management system that supports using shaders for screen transitions. Although my boilerplate code already included a basic screen manager that was responsible for changing what screens were being currently portrayed (MainMenuScreen, GameScreen, etc.), a significant flaw it had was that it didn’t provide support for doing screen transitions. This was going to be a problem for Sanity Wars Reimagined, as the original game had fade transitions between the different screen elements. I thus set out to refactor my screen management to support doing transitions.
In the original Sanity Wars, the way I accomplished the fade was through manipulating the drawn image in the browser’s canvas element (as the original game was built using HTML/JavaScript, the technologies I was most familiar with at the time). It was hardcoded to the custom engine I’d built, however, and there wasn’t a direct way to achieve the same effect in Godot. It’s possible I could have made the fade transition, specifically, work by manipulating the screen node’s modulation (visibility), but I didn’t feel comfortable making direct changes to node properties for the sake of screen effects, especially if I wanted to have the ability to do other kinds of transitions in the future, such as screen slides or flips; anything more complex than that would be outright impossible through mere node property manipulation.
My focus turned towards experimenting with a different approach, one based on using Godot’s Viewport node to get the actual render textures, and then manipulating the raw pixels by applying shaders to the render images of the outgoing screen and the incoming screen. Viewports were something I hadn’t had much experience with, however, so I wasn’t certain if the idea I had would actually work. To prove the concept, I spent a weekend creating a prototype specifically to test manipulating viewport and their render textures. The approach did, in fact, work as I envisioned (after a lot of research, trial, and error), so I proceeded to refactor the screen management system in Sanity Wars Reimagined to use this new approach.
When referring to screens here, I’m not talking about physical monitor screens; it’s a term I use to refer to a whole collection of elements comprising a section of the game experience. For instance, the Main Menu Screen is what you see on booting up the game, and Game Screen is where the gameplay takes place.
Overall, the refactor was an immense success. The fade effect worked precisely the way it did for the original Sanity Wars, and the system is flexible enough that I feel it should be easy enough to design new screen transition effects (in fact, I did create one as part of making the LoadingScreen, transitioning to an in-between screen that handled providing feedback to the user while the incoming GameScreen prepared the gameplay). Should I want to create different visual effects for future transitions, it should be as simple as writing a shader to handle it. (Not that shaders are simple, but it is far easier to do complex visual effects with shaders than with node property manipulation!)
Automated Export Script
After realizing that I needed to export game builds frequently (more on that later), I quickly found that it was tedious to have to work through Godot’s export interface every time I wanted to make a build. On top of that, Godot doesn’t have native build versioning (at least, not that I’ve found), so I have to manually name each exported build, and keep track of what the version number is. Needless to say, I wondered if there was a way I could automate this process, possibly through augmenting the Genesis scripting tools to include a simple command to export a project.
I took a few days to work through this, and in the end I managed to create functionality in my Genesis scripting tool that did what I wanted. With a simple command, godot-genesis export-project "Sanity Wars Reimagined" "Name Of Export Template", Genesis would handle grabbing a Godot executable and running a shell command to make Godot export the project using the provided export template, and then create a ZIP archive of the resulting export. The name of the export was the project name, followed by a build number using the semantics I chose (major.minor.patch.build). By providing a -b flag, I could also specify what kind of build this was (and thus which build number to increment). It works really well, and now that exports are so easy to do I am more willing to make them frequently, which allows me to quickly make sure my development changes work in the release builds.
Other Features and Improvements
There are many other features that were created for Sanity Wars Reimagined; to save time, I’ll simply give brief summaries of these. Some of these were not generalized enough to be ported back into the Genesis boilerplate, but the experience gained from creating them remains valuable nonetheless.
Generators
These nodes handle spawning entities, and I made the system flexible enough that I can pretty much generate any kind of object I want (though, in this case, I only used it to spawn Eyeballs and Tomes).
RectZone
This is a node which let me specify a rectangular area that other nodes (like Generators) could use to make spatial calculations against (aka figure out where to spawn things).
PixelPerfectCamera
This is a Camera node that was customized to support smoothing behavior rounded to pixel-perfect values. This helps reduce the amount of visual jitter that results from when a camera is positioned between whole integer values.
The reason this happens is because pixels can’t be rendered at fractional, non-integer values, so when a pixel art game asset is placed such that the underlying pixels don’t line up to a whole integer, the game engine’s renderer “guesses” what the actual pixel color values should be. This is barely noticeable for high-resolution assets because they consist of a huge number of pixels, but for something as low-resolution as pixel art, this results in visual artifacts that look terrible.
UI Theming
I finally took a dive into trying to understand how Godot’s theme system works, and as a result I was able to create themes for my UI that made it much simpler to create new UI elements that already worked with the visual design of the interface. I plan to build on my experience with UI themes for future projects, and ultimately want to make a base theme for the Genesis boilerplate, so I don’t have to create new themes from scratch.
State Machine Movement
I converted my previous Player character movement code to be based on a state machine instead of part of the node’s script, and this resulted in movement logic that was far simpler to control and modify.
As you can see, there were a lot of features I developed for Sanity Wars Reimagined, independent of gameplay aspects. A large part of what I created was generalized and reusable, so I can put the code in future projects without having to make modifications to remove Sanity Wars-specific functionality.
Complications
No human endeavor is perfect, and that is certainly true for Sanity Wars Reimagined. In fact, I made a lot of mistakes on this project. Fortunately, all of these mistakes are things I can learn from to make future projects better. I’ll highlight some of these issues and mistakes now.
Both Rebecca and I learned a lot from the mistakes we made developing this project, but I’m specifically focusing on my mistakes in this article.
New Systems Introduced New Complexities
The big systems I added, like Resolution Management and Screen Management, added lots of functionality to the game. With that, however, came gameplay issues that arose as the result of the requirements integrating with these systems introduced.
Take ScreenManager, for example. The system included the ability to have screens running visual updates during the transition, so the screen’s render texture didn’t look like it was frozen while fading from one screen to the next. By creating this capability, however, I needed to modify the existing game logic to take into account the idea that it could be running as part of a screen transition; for instance, the player character needed to be visible on the screen during the transition, but with input disabled so the player couldn’t move while the transition was running.
Another issue the ScreenManager refactor created had to do with resetting the game when the player chose to restart. Before, screens were loaded from file when being switched to, and being unloaded when switched from, so restart logic was as simple as using node _ready() methods to set up the game logic. After the refactor, this was no longer true; to avoid the loading penalty (and subsequent screen freeze) of dealing with loading scenes from file, ScreenManager instead kept inactive screens around in memory, adding them to the scene tree when being transitioned to and removing them from the scene tree when being transitioned from. Since _ready() only runs once, the first time a node enters the scene tree, it was no longer usable as a way to reset game logic. I had to fix this by explicitly creating reset functions that could be called in response to a reset signal emitted by the controlling screen, and throughout the remaining development I encountered bugs stemming from this change in game reset logic.
ResolutionManager, while allowing for crisp-looking UI, created its own problems as well. While the UI could be scaled down as much as I wanted, at smaller resolutions elements would render slightly differently from how they looked at 1920×1080. The reason for this was, ironically, similar to the issues with scaling pixel art: by scaling the UI down, any UI element whose size dimensions did not result in whole-number integers would force Godot’s renderer to have to guess what to render for a particular pixel location on the monitor. Subsequently, some of the UI looked bad at smaller resolutions (such as the outlines around the spell selection indicators). I suspect I could have addressed this issue by tweaking the UI design sizes to scale cleanly, but my attempts to change those values resulted in breaking the UI in ways I couldn’t figure out how to fix (largely due to my continued troubles understanding how to create good UI in Godot). In the end, I decided that, with my limited amount of time, trying to fix all the UI issues was less important than fixing the other issues I was working on, and ultimately the task was cut during the finishing stages of development.
I’m guessing most people won’t notice, anyway, since most people likely have the game at fullscreen, anyway.
Complexities arising from implementing new systems happened in other ways throughout the project as well, although the ones stemming from ScreenManager and ResolutionManager caused some of the bigger headaches. Fixing said issues contributed to extending development time.
Designing for Randomization
One of the core mechanics of Sanity Wars (original and Reimagined) is that all the entities in the game spawn at random locations on an unchanging set of maps. At the time I created the mechanic, my thought was that this was a way to achieve some amount of replayability, by having each run create different placements for the tomes, eyeballs, portals, and player.
Playtesters, however, pointed out that the fully random nature of where things spawned resulted in wide swings of gameplay experience. Sometimes, you got spawns that made runs trivially easy to complete; other times, the spawns made runs much more difficult. This had a negative impact on gameplay experience.
The way to solve this is through creating the means of controlling just how random the processes are. For example, I could add specific locations where portals were allowed to spawn, or add logic to ensure tomes didn’t spawn too close to portals. Adding controlled randomness isn’t easy, however, because by definition it means having to add special conditions to the spawning logic beyond simply picking a location within the map.
The biggest impact of controlled randomness wasn’t directly felt with Sanity Wars Reimagined, however; it was felt in our plans to expand directly off of this project for our next game. Given that random generation was a core element of gameplay, that meant adding additional elements would also need to employ controlled randomness, and that would likely result in a lot of work. On top of that, designing maps with randomness in mind is hard. It would likely take months just to prototype ideas, let alone flesh them out into complete mechanics.
This aspect, more than anything else, was a huge influence in our decision to not expand on Sanity Wars Reimagined for the next project, but to concentrate on a more linear experience. (More on that later.)
Clean Code
If you’re a programmer, you might be surprised at seeing “clean code” as a heading under complications. If you’re not a programmer, let me explain, very roughly, what clean code is: a mindset for writing code in such a way that it is easy to understand, has specific responsibilities, and avoids creating the same lines of code in different files; through these principles one’s code should be easier to comprehend and use throughout your codebase.
Under most circumstances, writing clean code is essential for making code not only easier to work with, but faster to develop. So how did writing clean code make Sanity Wars Reimagined more complex?
Simply put, the issue wasn’t strictly with adhering to clean code principles in and of themselves; the issue was when I spent a lot of time and effort coming up with clean code for flawed systems. Clean code doesn’t mean the things you create with it are perfect. In fact, in Sanity Wars Reimagined, some of the things I created wound up being harmful to the resulting gameplay.
A prime example of how this impacted development is the way I implemented movement for the Eyeball entity. I had the thought of creating a steering behavior-based movement system; rather than giving the entity a point to navigate to and allowing it to move straight there, I wanted to have the entity behave more like real things might move (to put it in very simple terms). I then spent a long time creating a locomotion system that used steering behaviors, trying to make it as clean as possible.
In the end, my efforts to integrate steering behavior movement were successful. There was a huge flaw, however; the movement hampered gameplay. Steering behaviors, by design, are intended to give less-predictable behavior to the human eye, which makes it harder to predict how the eyeball is going to move when it isn’t going in a straight line. This style of movement also meant the Eyeballs could easily get in a position where it was difficult for the player to hit them with the straight-line spirit bullet projectile, which was specifically intended for destroying Eyeballs. Since steering behaviors work by applying forces, rather than explicitly providing movement direction, there wasn’t an easy, feasible way for me to tweak the movement to make it easier for the player to engage with Eyeballs.
In addition to making Eyeballs less fun to play against, the steering behaviors also made it hard to make Eyeballs move in very specific ways. When I was trying to create a dive attack for the Eyeball, I literally had to hack in movement behavior that circumvented the steering behaviors to try and get the attack to visually look the way I wanted to; even then, I still had a lot of trouble getting the movement to look how I felt it should.
How did clean code contribute to this, precisely? Well, I’d spent a lot of time creating the locomotor system and making it as clean an implementation as I possibly could, before throwing it into the gameplay arena to be tested out and refined. If there had been time to do so, I likely would have needed to go back and refactor the Eyeball movement to not use steering behaviors; all that work I’d spent making the steering behavior implementation nice and clean would’ve gone to waste.
Don’t get me wrong; writing clean code is very important, and there is definitely a place for it in game development. The time for that, however, is not while figuring out if the gameplay works; there’s no sense in making something clean if you’re going to end up throwing it out shortly thereafter.
Playtesting
I didn’t let other people playtest the game until way too late in development. Not only did that result in not detecting crashes in release builds, it also meant I ran out of time to properly take feedback from the playtests and incorporate it back into the game.
For the first three months of development, I never created a single release build of Sanity Wars Reimagined. I kept plugging away in development builds. The first time I attempted exporting the project was the day, no, the evening I was scheduled to bring the game to a live-streamed playtest session with IGDA Twin Cities. As a result, it wasn’t until two hours before showtime that I found out that my release builds crashed on load. I spent a frantic hour hack-fixing the things causing the crashes, but even with that, the release build still had a major, game-breaking bug in it: the testers couldn’t complete the game because no portals spawned. Without being able to complete the game, the testers couldn’t give me good feedback on how the game felt to them. From that point onward, I made a point of testing exports regularly so that something like that wouldn’t catch me off-guard again.
The next time I brought the game out for playtesting was in the middle of January 2022, three months after the first playtest. At that point, I’d resigned myself to the fact that Sanity Wars Reimagined didn’t feel fun, and was likely going to be released that way; my intent with attending the playtest was to have people play the game and help me make sure I didn’t have any showstopping bugs I’d need to fix before release. What I wasn’t expecting (and, in retrospect, that was silly of me) was that people had a lot to say about the game design and ways it could be made more fun.
To be honest, I think I’d been stuck so long on the idea that I wanted to faithfully recreate the original game’s mechanics that I didn’t even think about making changes to them. After hearing the feedback, however, I decided that it would be more important to make the game as fun as I could before release, rather than sticking to the original mechanics.
That playtest, however, was one and a half weeks before the planned release date, meaning that I had very little time to attempt making changes of any significance. I did what I could, however. Some of the things I changed included:
Removing the sacrifice aspect of using spells. Players could now access spells right away, without having to sacrifice their maximum sanity.
Giving both the spells dedicated hotkeys, to make them easier (and thus more likely) to be used.
Adding a countdown timer, in the form of reducing the player’s maximum sanity every so often, until the amount was reduced to zero, killing the player. This gave players a sense of urgency that they needed to resolve, which was more interesting than simply exploring the maps with no time constraints.
Changing the Eyeball’s attack to something that clearly telegraphed it was attacking the player, which also made them more fun to interact with.
Adding a scoring system, to give the player something more interesting to do than simply collecting tomes and finding the exit portal.
Various small elements to add juice to the game and make it feel more fun.
Although we did wind up extending the release date by a week (because of dealing with being sick on the intended release week), I was surprised with just how much positive change I was able to introduce in essentially two and a half weeks’ worth of time. I had to sacrifice a lot of clean code principles to do it (feeding into my observation about how doing clean code too early was a problem), but the end result was an experience that was far more fun than it was prior to that play test.
I can only imagine how much more fun the game could’ve been if I’d had people involved with playtesting in the early stages of development, when it would’ve been easier to change core mechanics in response to suggestions.
Thanks to the IGDA Twin Cities playtest group, and specifically Mark LaCroix, Dale LaCroix, and Lane Davis, for offering many of the suggested changes that made it into the final game. Thanks also to Mark, Lane, Patrick Grout, and Peter Shimeall for offering their time to playtest these changes prior to the game’s release.
Lack of a Schedule
I mentioned previously that I’d thought the entire Sanity Wars Reimagined project wouldn’t take more than a month, but I hadn’t actually established a firm deadline for when the project needed to be done. I tried to implement an approach where we’d work on the project “until it felt ready”. I knew deadlines were a way that crunch could be introduced, and I wanted to avoid putting ourselves in a situation where we felt we needed to crunch to make a deadline.
The downside, however, was that there wasn’t any target to shoot for. Frequently, while working on mind-numbing, boring sections of code, I had the dread fear that we could wind up spending many more months on this project before it would be finished. This fear grew significantly the longer I spent working on the project, my initial month-long estimate flying by the wayside like mile markers on a highway.
Finally, out of exasperation, I made the decision to set a release date. Originally, the target was the middle of December 2021, but the game wasn’t anywhere near bug-free enough by that point, so we pivoted to the end of January 2022, instead. As that deadline approached, there were still dozens upon dozens of tasks that had yet to be started. Instead of pushing the deadline out again, however, I went through the list to determine what was truly essential for the game’s release, cancelling every task that failed to meet that criteria.
Things that hit the cutting room floor include:
Adding a second enemy to the game, which would’ve been some form of ground unit.
Refactoring the player’s jump to feel better.
Fixing a bug that caused the jump sound to sometimes not play when it should.
Adding keyboard navigation to menus (meaning you had to use the mouse to click on buttons and such).
Create maps specifically for the release (the ones in the final build are the same as the ones made for the second playtest).
It’s not that these things wouldn’t have improved the game experience; it’s just that they weren’t essential to the game experience, or at least not enough to make it worth extending the release date to incorporate them. By this point, my goal was to finish the game and move on to the next project, where, hopefully, I could do a better job and learn from my mistakes.
These are far from the only complexities that we had to deal with during Sanity Wars Reimagined, but they should serve to prove that a lot of issues were encountered, and a lot of mistakes were made. All of these things, however, are learning opportunities, and we’re excited to improve on the next project.
Improvements for Next Time
There’s a lot of things that I want to try for the next project; many of them serve as attempts to address issues that arose during the development of Sanity Wars Reimagined.
Have A Planned Release Date
I don’t want to feel like there’s no end in sight to the next project, so I fully intend to set a release date target. Will we hit that target? Probably not; I’m not a great estimator, and life tends to throw plenty of curveballs that wreak havoc on plans. By setting an end goal, however, I expect that it will force us to more carefully plan what features we want to try and make for the next game.
In tandem with that, I want to try and establish something closer to a traditional game development pipeline (or, at least, what I understand of one), with multiple clearly-defined phases: prototyping, MVP, alpha, beta, and release. This will hopefully result in lots of experimentation up front that settles into a set of core mechanics, upon which we build lots of content that is rigorously tested prior to release.
Prototype Quickly Instead of Cleanly
Admittedly, the idea of not focusing on making my code clean rankles me a bit, as a developer, but it’s clear that development moves faster when I spend less time being picky about how my code is written. Plus, if I’m going to write something, find out it doesn’t work, and throw it away, I want to figure that out as quickly as possible so I can move on to trying the next idea.
Thus, during the prototyping phase of the next project, I’ll try to not put an emphasis on making the code clean. I won’t try to write messy code, of course, but I’m not going to spend hours figuring out the most ideal way to structure something. That can wait until the core mechanics have been settled on, having been playtested to confirm that said mechanics are fun.
Playtest Sooner Rather Than Later
The feedback I received from the final big playtesting session of Sanity Wars Reimagined was crucial in determining how to make the game more fun before release. For the next project, I don’t want to wait that long to find out what’s working, what’s not, and what I could add to make things even more fun.
I don’t think I’ll take it to public playtesting right away, but I’ll for sure reach out to friends and interested parties and ask them to try out prototype and MVP builds. It should hopefully be much easier to make suggested changes during those early stages, versus the week before release. With more frequent feedback, I can also iterate on things more often, and get the mechanics to be fun before locking them down and creating content for them.
Make a Linear Experience
After realizing how much work it would be to try and craft a good random experience, I’ve decided that I’m going to purposely make the next game a linear experience. In other words, each playthrough of the game won’t have randomness factoring into the gameplay experience. This may be a little more “boring”, but I think doing it this way will make it easier for me to not only practice making good game design, but make good code and good content for as well.
Will it be significantly less fun than something that introduces random elements to the design? Maybe, maybe not. We’ll find out after I attempt it!
Those are just a few of the things I intend to try on the next project. I don’t know if all of the ideas will prove useful in the long run, but they at least make sense to me in the moment. That’s good enough, for now. Whatever we get wrong, we can always iterate on!
Conclusion
That’s the story of Sanity Wars Reimagined. We started the project as an attempt to make a quick release to gain experience creating games, and despite taking significantly longer than planned, and the numerous mistakes made along the way, we still wound up releasing the game. Along the way, we developed numerous technologies, and learned lots of lessons, that should prove immensely useful for our next project. Because of that, despite the resulting game not being as fun as I wish it could’ve been, I consider Sanity Wars Reimagined a success.
What’s next for Rebecca and I? It’ll for sure be another platformer, as that will allow us to make good use of the technologies and processes we’ve already developed for making such games. I fully expect there will be new challenges and complications to tackle over the course of this next project, and I can’t wait to create solutions for them, and learn from whatever mistakes we make!
The game PixelLunatic and I have been working on for the past seven months, Sanity Wars Reimagined, has been officially released! Download it here.
It’s been a long seven months, working on this game, and while there’s still plenty of things that could be improved, overall I’m pleased with how this game turned out. Most importantly, we learned many things that will prove useful for our future game development endeavors.
Note: If you implemented this pattern and are now experiencing issues with get_tree() calls, see my Issues with get_tree() section at the end of the article.
Oftentimes, in code, you need a way to have different parts of the codebase communicate with each other. One way to do this is have those components directly call methods from another component. While that works, it means you directly couple those components together. If you want to reuse one component in another project, you either have to take all the directly-coupled components with it or you have to refactor the direct couplings out of the component you want to reuse, neither of which is desirable from a clean code standpoint.
A way to solve this problem is to use the signal pattern. This is where each component can emit a named signal, and other components can then be connected to that signal. From that point on, whenever that signal is emitted by the component, anything that is listening for that signal can run code in response to that emission. It’s generally a great pattern, allowing for code to indicate when some event, or signal, happens, and for other parts of code to respond to that event accordingly (without code directly relying on calling methods from one another).
There is a third way to have decoupled components communicate to one another: the messenger pattern. At surface level, it’s very similar to the signal pattern: a part of your code dispatches a named message, and any code that is listening for that particular message can respond to it. Those different parts of your code aren’t connected to one another, however; instead, they interact through a Messenger node. Code that wants to listen for a message registers a message listener to the Messenger, and when another part of code dispatches a message with that name, the Messenger loops through all the registered listeners for that message name and invokes their callback functions.
Both the signal pattern and the messenger pattern can be considered subsets of the Observer pattern. The key difference is that the signal pattern has many objects connecting to one (the object emitting the signal), while the messenger pattern has a mediator object through which messages are dispatched and listened for by other objects. Which is better? It depends on what you are trying to accomplish architecturally, and there’s no reason you can’t use both.
Let’s discuss specifics, with relation to what Godot uses. Godot has the signal pattern baked into it at the core. Nodes can define signals through use of the signal keyword. Any node that wants to listen for another node’s signal can connect() to that node’s signal and associate a callback function to it. It looks like this, at a simplified level:
# OrdinaryNode
extends Node
signal some_cool_thing
# DifferentNode
extends Node
func _ready():
# Assuming both OrdinaryNode and DifferentNode are children of a hypothetical parent node.
get_parent().get_node('OrdinaryNode').connect('some_cool_thing', self, '_do_something_awesome')
func _do_something_awesome():
print("This is awesome!")
From then on, whenever OrdinaryNode emits the some_cool_thing signal, the _do_something_awesome() function in DifferentNode will run, printing “This is awesome!”
While this is a good implementation of signals, the nature of how the signal pattern works implies some shortcomings. For instance, all signals must be explicitly defined in code. You can’t have OrdinaryNode, as written above, emit a coffee_break signal because the code didn’t explicitly define that such a signal exists. This is by design, as it means you have to plan what your node can and can’t emit. Sometimes, though, you do want to have a more flexible way to communicate with other nodes, and at that point signals can’t help you. This is one thing the messenger pattern can help with, by not requiring you to explicitly define what messages can or can’t be sent.
Another aspect of the signal pattern is that it requires you to have nodes define a connection to the node emitting the signal if you want those nodes to react to the signal. That means those nodes must, by definition, couple themselves to the node emitting the signal (though the emitter node doesn’t know, or care, about those couplings). This isn’t necessarily bad, but it limits how you can architect your code; you have to make sure nodes that need to listen for a specific signal are able to connect to the node emitting said signal. Conversely, using the messenger pattern, you can have nodes connect only to a single Messenger node, which can be simpler to implement.
Godot does not natively implement such a messenger node, however. If we want to use this messenger pattern, we’ll need to make something ourselves. That’s what this tutorial will be about.
Note: What I’m calling the Messenger Pattern is more commonly known as the Mediator Pattern. I came up with the name Messenger before I learned what it is called, and I’ll continue to use it in this tutorial because I think it communicates more clearly what I’m using it for.
Setting Up
There is a sample project, if you want to refer to the finished product.
If you want to code alongside the tutorial, start by creating a new Godot project, then create a GDScript file named Messenger.gd. We’ll make this as the base file that other implementations of messengers can extend to provide their own functionality.
Adding and Removing Listeners
The first thing we want to do is provide a way to add and remove message listeners. Let’s begin with adding listeners.
var _message_listeners := {} # Stores nodes that are listening for messages.
# Add object as a listener for the specified message.
func add_listener(message_name: String, object: Object, method_name: String) -> void:
var listener = { 'object': object, 'object_id': object.get_instance_id(), 'method_name': method_name }
if _message_listeners.has(message_name) == false:
_message_listeners[message_name] = {}
_message_listeners[message_name][object.get_instance_id()] = listener
This is fairly straightforward. We take the name of the message, the object that has the callback function, and the name of the callback. We store all that in a listener dictionary (defined as a class property outside of the function) and store it in _message_listeners in the dictionary stored at the key matching the message name (creating a dictionary for that key if it doesn’t already exist). We key this listener in the message_name dictionary to the object’s instance id, which is guaranteed to be unique.
Since Godot implements signals at the object level (Node inherits from Object), I’ll be typing these as Objects rather than Nodes, which allows for any node inheriting from Object to be used as a listener (including Resources).
Next, the ability to remove a registered listener.
# Remove object from listening for the specified message.
func remove_listener(message_name: String, object: Object) -> void:
if not _message_listeners.has(message_name):
return
if _message_listeners[message_name].has(object.get_instance_id()):
_message_listeners[message_name].erase(object.get_instance_id())
if _message_listeners[message_name].empty():
_message_listeners.erase(message_name)
Again, fairly straightforward. We run existence checks to see if a listener exists at that message_name key, and erase it from the dictionary if so. Additionally, if no more listeners exist for that message_name, we erase the dictionary for listeners of that message name.
Sending Messages
Now that we can add and remove message listeners, it’s time to add the ability to send those messages.
# Sends a message and triggers _callbacks on its listeners.
func dispatch_message(message_name: String, data := {}) -> void:
var message = { 'name': message_name, 'data': data }
_process_message_listeners(message)
We take a message_name string and a data dictionary (which defaults to be an empty dictionary), store it to a message variable, and pass that variable into _process_message_listeners.
# Invoke all listener callbacks for specified message.
func _process_message_listeners(message: Dictionary) -> void:
var message_name = message.name
# If there aren't any listeners for this message name, we can return early.
if not _message_listeners.has(message_name):
return
# Loop through all listeners of the message and invoke their callback.
var listeners = _message_listeners[message_name]
for listener in listeners.values():
# Invoke the callback.
listener.object.call(listener.method_name, message.data)
This is where we handle triggering the callbacks for a message listener. If there aren’t any listeners for that message name, we return early to avoid doing further processing. If there are listeners for that message name, then we loop through each one and trigger the stored method callback, passing in the message’s data dictionary.
That’s it, as far as the basic implementation goes. But there are a couple of caveats that need to be dealt with.
Dealing with Nonexistent Listeners
One such case happens when a listener’s object is freed, making the stored reference in the listener dictionary invalid. If you try to operate on it, Godot will crash, so we need to provide a way to scan for dead listeners and remove them from storage.
Let’s start with a function to perform both the check and the purge:
# Removes a listener if it no longer exists, and returns whether the listener was removed.
func _purge_listener(listeners: Dictionary, listener: Dictionary) -> bool:
var object_exists = !!weakref(listener.node).get_ref() and is_instance_valid(listener.node)
if !object_exists or listener.node.get_instance_id() != listener.node_id:
listeners.erase(listener.node_id)
return true
return false
Multiple checks are used to see if the object exists (I’ve found in practice that I’ve needed both of these, not just one or the other). We also check to see if the instance id of the stored listener matches the id of the listener object we passed in; honestly, I can’t recall when or why that particular scenario occurs (I sadly forgot to write a comment about it in my code), but I know I’ve encountered it in the past, so I continue to include it as part of my check. If the object doesn’t exist, or the ids don’t match, we conclude the listener’s object no longer exists, and thus remove the listener from storage. Finally, we return a boolean value indicating whether the purge was performed or not.
Now we need to modify our existing code to use this function.
func _process_message_listeners(message: Dictionary) -> void:
# ...existing logic
for listener in listeners.values():
# If the listener has been freed, remove it
if _purge_listener(listeners, listener):
# Check if there are any remaining listeners, and erase the message_name from listeners if so.
if not _message_listeners.has(message_name):
_message_listeners.erase(message_name)
return
else:
continue
# ...existing logic
The difference is we call _purge_listener before we try to invoke the callback. If the listener was purged, we perform an additional check to see if there are any other listeners of message_name, and erase the dictionary keyed to message_name if there aren’t; otherwise, we proceed to the next listener in the for loop.
That takes care of dead listeners. There’s one more problem we need to address.
Dispatching Messages Too Early
Right now, if we try to send and listen for messages during the ready process (when Godot’s nodes all run their _ready callbacks), then we’ll likely run into issues where messages are dispatched before the listeners of those messages are registered (because their ready callbacks run later than when the messages are sent). To solve this, we’re going to add a message queue. If a message is being dispatched before the root node of the scene tree is ready, we’ll add the message onto this queue, and once the root node emits its ready signal we’ll process all the messages in the queue.
Let’s start with setting up the message queue, and modifying our dispatch_message function.
var _message_queue := [] # Stores messages that are being deferred until the next physics process tick.
var _messenger_ready := false # Is set to true once the root node is ready, indicating the messenger is ready to process messages.
# Sends a message and triggers _callbacks on its listeners.
func dispatch_message(message_name: String, data := {}) -> void:
var message = { 'name': message_name, 'data': data }
if _messenger_ready:
_process_message_listeners(message)
else:
_message_queue.push_back(message)
We’ve added two new class properties, one to house the message queue and the other to mark when the messenger node considers itself ready. dispatch_message has been modified to first check _messenger_ready, and if so it runs the code the same as before. If the messenger node is not ready, then the message is pushed onto the message queue.
Now let’s set up the ability to process the message queue.
func _ready() -> void:
get_tree().get_root().connect('ready', self, '_on_Root_ready')
# Is called when the root node of the main scene tree emits the ready signal.
func _on_Root_ready() -> void:
_process_message_queue()
# Process all messages in the message queue and reset the queue to an empty array.
func _process_message_queue() -> void:
for message in _message_queue:
_process_message_listeners(message)
_message_queue = []
In Messenger’s own _ready callback, we register a listener to the scene tree root’s ready signal. The callback then calls a function, _process_message_queue(), which loops through each message in the queue and calls _process_message_listeners() on them. At the send, we clear the message queue, since we don’t need (or want) to process these messages again.
Creating a GlobalMessenger
At this point, we have a base Messenger class that can be used anytime we want to implement the messenger pattern in our code. Let’s demonstrate this by creating a global singleton, GlobalMessenger, that we can interact with from anywhere in our codebase.
Start by creating a new file, global_messenger.gd, and have it extend our Messenger class. If Godot claims the Messenger class doesn’t exist, then you’ll need to reload the project to force Godot to update itself and recognize the Messenger class we added in Messenger.gd.
# Creates a global messenger that can be accessed from anywhere in the program.
extends Messenger
The reason I made this file name snake_case is because my personal convention is to name files that are solely used as singletons with this format, to distinguish them from files containing extensible classes. This is my personal preference only, and is not required to make this code work.
That’s all that needs to be done from a code standpoint. To make this a globally-available singleton, we need to go to Project -> Settings in the editor menu, navigate to the AutoLoad tab, and add global_messenger.gd to the list of autoloaded files.
And…that’s it! We now have a global singleton that we can use from anywhere in our codebase to dispatch messages!
Deferring Messages
Let’s add some additional functionality to our global messenger. For instance, right now, once the messenger is ready, we immediately run listener callbacks upon receipt of the message. What if we wanted to defer message dispatches until the next process tick? It might prove useful to ensure all game data is updated by the time your message callbacks are being run.
We already have a message queue that is used to make sure messages are deferred until the messenger is ready. We can build on that to add functionality to intentionally defer message dispatching until the next physics process tick.
func _ready() -> void:
set_physics_process(false)
func _physics_process(_delta) -> void:
._process_message_queue()
set_physics_process(false) # We don't need to keep updating once messages are processed.
# Queues a message to be dispatched on the next physics processing tick.
func dispatch_message_deferred(message_name: String, data := {}) -> void:
_message_queue.push_back({ 'name': message_name, 'data': data })
set_physics_process(true)
First, we use _ready() to disable physics processing. That’s because, whenever _physics_process() is defined in a script file, Godot automatically enables processing. We only want to process when there are messages in queue, so we just disable physics processing right off the bat.
I use _physics_process instead of _process to ensure messages are processed at a consistent rate. physics_process is run a consistent amount of times per second, whereas _process is run as often as possible, and I’ve found that having messages processed as fast as possible can result in unexpected complexity when sent from code that is expecting a consistent frame rate.
Next, in the _physics_process() callback, we call _process_message_queue(), then disable physics processing again (basically, only running the update step a single time).
Finally, we create a new function, dispatch_message_deferred, making it obvious that calling this will be different from a regular message dispatch. We add the message straight onto the message queue. Afterwards, we set the physics processing step to be true. This way, the next time _physics_process() callbacks are run in the code, the global messenger’s _physics_process() callback will be run, too. And since it is a global singleton, it will be run before other nodes in the root scene.
That’s it!
Testing our Implementation
Now that we have a Messenger node, and a GlobalMessenger implementation of it, let’s set up a test scene in our project to test their functionality and make sure they work as intended.
Create a new scene, TestScene, then structure it thusly:
LocalMessenger is a node which is extended from Messenger; we will use this to test that a locally-built implementation of our messenger node works.
The other two nodes, OrdinaryNode and DifferentNode, should contain the following code:
At this point, if you run the scene, you should see the two messages printed to console. If you do, then everything was set up correctly!
Issues with get_tree()
Recently (in May 2023), I encountered a strange bug where a message callback that invoked get_tree() was not returning the scene tree, despite the node housing the callback function being in the scene tree. After some investigation, I realized that I was calling the add_listener() function from the node’s _ready() callback; when I switched to adding the listener in _enter_tree() and removing it in _exit_tree() the get_tree() call worked as expected.
I admittedly am not entirely sure why this works, but my theory is that adding the listener during _ready() is either storing the reference to the function call when the tree is not yet defined or subsequent tree exits and enters is causing the reference to be lost. In any case, I wanted to add this addendum in case anyone else chose to implement this pattern and ran into the same problem.
If you happen to know more info about why this might have happened, please let me know!
Conclusion
We now have a base Messenger node, as well as a GlobalMessenger singleton that extends it and adds defer functionality to it. When should it be used? Personally, I use the messenger pattern in cases where I want to enable node communication, but for whatever reason it doesn’t benefit me to define the specific signals ahead of time, which is when the messenger’s dynamism comes into play.
Of course, that dynamism leads to the risk of making messy code. One advantage to explicitly forcing signals to be defined is that it forces you to think about how you are architecting your code, by making you think clearly about how your signals are going to be used. Since Messenger lets any node send whatever message it wants, it falls on you to make sure that power isn’t abused to send messages when the situation doesn’t call for it. For instance, if you have one node which you want other nearby nodes to listen for a specific event from, you don’t need the dynamic nature of Messenger; signals work perfectly fine, and are a cleaner way to get the job done.
As with all things, in life and code, consider carefully how you do things, and use whatever tools and patterns best fit your needs.
I’ve been playing Metroid Dread recently (with one finished playthrough under my belt). It’s the first Metroid game I’ve played (though I’ve played other Metroidvanias), and I absolutely enjoyed it. There are many mechanics that I found fun: the combat is simple, yet engaging; the various abilities you unlock are interesting and lead to great gameplay moments; the level design both helps you avoid getting too lost and still lets you feel like you can explore and discover new areas; and the boss battles are tough, yet fair.
The flagship mechanic, the one that Nintendo advertised heavily, are the EMMI zones. These are designated sections of a map patrolled by a powerful EMMI robot, whose sole purpose is to hunt you down. Your normal weapons can’t hurt the EMMI, so you have to avoid encountering them while moving through their territory. If an EMMI catches you, it kills you unless you succeed on a low-probability counter move. There’s only one way to stop an EMMI for good: finding the energy to power your Omega Cannon, the one weapon you have that can damage them.
EMMI Zones present an action-oriented take on stealth gameplay, and I think they pull the job off well. They’re designed to give you a challenge that you can’t blast your way through, and are crafted such that they don’t put you through too much of their gameplay. In this blog post, I want to examine EMMI zones in two ways: how the game teaches you about the zones, and what mechanics are used to make the zones work.
Note: There will be discussion of some of the abilities you earn throughout the game, though there won’t be any spoilers about the game’s story.
How the Game Teaches You About EMMI Zones
Looking at EMMI zones as a whole, there’s a lot to consider, and it can be a little complex. Metroid Dread spends a good deal of time in the early parts of the game guiding you through the gameplay, using a combination of in-game cutscenes, scripted gameplay, and overt tutorialization. Through these approaches, the game allows you to get used to how EMMI zones work, while still having fun along the way.
The First EMMI Zone
When you first encounter an EMMI, you are shown a cutscene, after which you are given a moment to try and respond to it with what limited abilities you have. The door behind you doesn’t open, and your normal beam weapon and missiles cause no damage to the robot. You are powerless to stop the EMMI from capturing you. At the moment of capture, however, the game pauses, explaining that you have a chance to try and parry the EMMI before it lands a killing blow, and waits to resume until you press the melee counter button. The EMMI freezes in response to this parry, and you are told that you can slide beneath a stunned EMMI. This sequence teaches you what happens when an EMMI catches you, and that you are not powerless to respond to it.
The EMMI recovers from the stun, and you are left to figure out how to run from it. In the next room, the level is designed with a tall wall and platform that you can bounce up and escape. The EMMI is damaged, so even though it tries to follow you, it cannot. The fact that it tries, however, suggests that this is what the other EMMI robots will try to do.
In the very next room is the Omega Cannon, the weapon you need to destroy the EMMI. First, you get a tutorial that teaches you how the Omega Cannon’s charge blast works, by forcing you to use it to open an exit out of the room. Afterwards, you drop back down to face the EMMI, and you have to use the cannon to destroy it and continue on your way.
The Second EMMI Zone
The next EMMI zone is right next to the first one, so you get a chance to try a less hand-held version of EMMI zone gameplay. It starts with a cutscene that reinforces the fact that your normal weapons don’t work against the EMMI (in case you hadn’t realized that, yet). You then have to escape from the EMMI; however, unlike the first one, this EMMI isn’t damaged, so simply climbing up a wall is not enough. You have to evade the EMMI through multiple rooms long until it loses sight of you. This causes it to leave pursuit mode, allowing the EMMI zone doors to unlock and giving you the chance to get out.
From that point, the game leaves you alone, letting you explore more of the world, discovering some new powers and augmentations along the way. The level design forces you to cross back through the EMMI zone multiple times to get to the places you need to go, giving you plenty of experience avoiding the EMMI.
Eventually, you encounter the first Central Unit room, the place where an entity coursing with Omega Energy (the resource that powers your Omega Cannon) is housed. You have to battle this Central Unit, dodging two attacks while blasting the Central Unit with missiles to take down its armored shell and squishy body. Finally, the entity dies, allowing you to siphon its Omega Energy to power the cannon. This time, you are given a second facet to the Omega Cannon: a rapid-fire blast.
As with the first room, the game locks you into the Central Unit’s room and forces you to use your Omega Cannon’s abilities to break out of it. You have to use the rapid-fire to melt the exit door’s heat shield, exposing it to a charge blast which blows it open. This same combination is what you use to defeat the EMMI. This time, when the EMMI dies, you acquire an ability from it, hinting that future EMMI will do the same.
The last major aspect of EMMI zone gameplay comes after you defeat the game’s first boss, where you acquire the Phantom Cloak ability. This turns you invisible for a short period of time. A short briefing explains how the Phantom Cloak can be used to hide yourself from EMMI detection, which is reinforced by a short cutscene when you enter the next EMMI zone, where Samus goes undetected by the next EMMI when she employs the Phantom Cloak.
At this point, you’ve been given the abilities you need to handle any challenge the EMMI zones can throw at you. Along the way, the game gave you a tour of how gameplay within EMMI zones will work, both teaching you through tutorial and allowing you to experiment and try things for yourself.
How do those abilities come together with the rest of the game design to give great gameplay? That’s what I want to examine next.
The Design of EMMI Zone Gameplay
The bulk of gameplay in Metroid Dread consists of exploration, small enemy encounters, and boss fights. Lots of action, lots of things to discover, lots of abilities to unlock progression to other parts of the world. Compared to this run-and-gun style of gameplay, EMMI zones feel tense. You can’t blast your way out of the problem; you have to plan your next moves carefully, with the occasional rapid, improvised escape. This is a good change-up in gameplay, giving you a different style of experience; at the same time, it doesn’t last too long, so you don’t spend too much time under tension, helping you avoid becoming fatigued.
Let’s take a look at the various components which power this experience: level design, evasion, and encounters.
Level Design
There is plenty of great map design in Metroid Dread. Seemingly impossible paths are unlocked by new abilities you discover, clever use of one-way gating keeps you from getting too lost, and many skillful sections conceal power-ups to reward conquering the challenge.
Likewise, the EMMI zones are carefully designed, not just in and of themselves, but also with respect to supplementing the rest of the map design. Each zone is placed on the map such that you are forced to cross through multiple times to accomplish your other goals. At the same time, the EMMI zones are only one part of a larger world, so you aren’t constantly forced to stay long in these high-tension areas. It helps keep the gameplay novel and interesting, without putting too much emphasis on having to play stealthy.
How about the level design within each EMMI zone? By and large, there are two kinds of map design within EMMI zones: large, open spaces where you get plenty of space to move around, and small, narrow corridors that require specific strategies to traverse through safely. While there are rooms within the EMMI zones which are one or the other of those types, often you’ll encounter rooms which mix both types of design together, providing variety in how you plan and execute your evasion. There are also some special areas to help or hinder your approach, including spider magnet walls and ceilings to cling to, pressure plate doors requiring fast maneuvering to blast through, and waterlogged floors that inhibit your movement.
An important aspect of the level design is that it gives you ways to evade the EMMI. When an EMMI has detected your sound, it will pursue your sounds until it finds you or you get far enough away that it stops investigating. The level design supports many strategies for getting out of this pursuit. For instance, you can lead the EMMI off into an isolated part of the map, then double back down a different path to go around it to try and quickly reach your objective. Another way is to take advantage of your mobility to get to areas of the map the EMMI can’t immediately follow you to, forcing it to take another path to get there and increasing the time you have to get out of pursuit range entirely. You just have to look at the map and figure out how best to make your way through safely.
Finally, the visual look of the levels themselves contribute to how the player should feel inside an EMMI zone. While the EMMI is still alive and patrolling, the entire area is grainy and gray, and haunting tones play in the background. Once the EMMI is defeated, the entire area brightens up, the grainy filter is gone, and you hear no more spooky music. Additionally, the world map changes the EMMI zone area to have green backgrounds, indicating you’ve cleared that zone.
Evasion
You can’t hurt the EMMI without the Omega Cannon, and the level design forces you to move in and out of EMMI zones several times before you gain access to the room housing the energy which powers that cannon. That leaves you with two options for dealing with the EMMI while on their turf: running and hiding.
Running is the most obvious solution, and there are various ways the game encourages you to do so and not move too slowly. Your Aeion energy, the ability which powers your Phantom Cloak (and certain other moves) recharges more quickly when you move, so you have to move around at some point to regain your ability to hide. A lot of the map design encourages hiding in a safe spot to plot your next move, then darting over to the next safe spot to repeat the process all over again. The more quickly and carelessly you move, however, the more “noise” Samus makes, increasing the odds that the EMMI will hear you and come to investigate your position. You can risk running for an exit, but if you’re spotted, the EMMI zone exits close until you break the pursuit.
The Phantom Cloak, once you’ve acquired it, allows you to find a spot safe from EMMI wandering and hide from visual detection. While active, it drains your Aeion energy resource, but standing still makes the resource drain significantly less than if you are moving while cloaked. This, along with Aeion energy recharging faster when you move (if you aren’t using an Aeion ability) encourages you to plan your next moves while cloaked, uncloak to execute that plan, then activate the cloak again in your next safe spot to make the next step of your plan. Of course, things don’t always work the way you planned them, and a quick activation of the Phantom Cloak gives you a quick way out of being spotted, hopefully allowing you time to get away from the EMMI threat.
Another aspect of Phantom Cloak is that the game allows it to drain life energy if you run out of Aeion energy while cloaked, and this functions as a risky way to prolong your hidden state. It makes for an interesting player decision: do you continue using the Phantom Cloak and risk losing too much life to deal with enemies beyond the EMMI zone, or do you uncloak and risk the threat of EMMI detection? It presents yet another interesting facet to EMMI zone gameplay.
All the decisions you need to make while in the EMMI zone are aided by the information the game chooses to give you while in the zone. If the EMMI is outside of the screen, but close to you to be within the minimap, a red dot appears on the minimap to indicate its whereabouts. Once an EMMI gets close enough to detect the noises you make, a big yellow circle appears on screen to illuminate the precise area within which it can hear you, and while in this area your sound-making movements are highlighted by smaller yellow circles, letting you know exactly what you’re doing to attract the EMMI’s attention. A blue cone is projected from the EMMI’s face, representing the area in which the EMMI can see you, allowing you to react to its sight and do your best to stay out of it. Finally, if it sees you, the cone turns red during the short moment of time the EMMI spends transitioning to pursuit mode, giving you an opportunity to get a head start on your escape.
Engaging the EMMI
Much of the time you spend in an EMMI zone revolves around avoiding EMMI interaction, because to do so risks death. You can’t run from them forever, though. Let’s take a look at the two ways you can directly interact with the EMMI: attacking it with the Omega Cannon, and attempting to escape its grasp when you are caught.
The Omega Cannon has two forms: one gives you a rapid-fire stream of energy bullets, or the Omega Stream, the other a blast with a charging period, or the Omega Blaster. You need both abilities (after the tutorial zone) to defeat an EMMI: Omega Stream is required to take down the shielding on the EMMI’s head, and Omega Blast is needed to take out the core once the shielding is down.
It takes a lot of rapid-fire shots to take the shielding down, so you’ll need to maneuver through the environment to find long stretches of corridor to give you enough time to land enough shots to take down the shield. Oftentimes, the corridor you’re in isn’t quite long enough to completely destroy the shield, so you have to judge when to disengage and move away to avoid getting caught, and subsequently where you’ll move to next to renew the assault. The shield also regenerates if you haven’t taken it down and you stop damaging it, so it’s not as simple as firing a few shots, moving away, and firing a few more shots; you inherently have to take some risky positioning to make sure you do enough damage in one encounter to not allow the shielding to fully recharge by the next time you position yourself to strike.
Once the shield is taken down, the EMMI is stunned for a brief period of time. If you’re not too close, you can start charging your Omega Blaster during this period. Otherwise, you once again need to move far enough away that you give yourself enough time to charge up the blast and aim for the head. This can lead to some tense, thrilling moments where you hold your ground and manage to get your shot off just before the EMMI catches you.
(Yeah. That was close.)
Something you’ll need to adapt to is the varying speeds EMMI will use when approaching you. Sometimes, they’ll come at you slowly, giving you plenty of time to damage them; other times, they’ll come at you rapidly, and you may find yourself needing to risk your position to get a few more hits in before you move away. The more you engage with an EMMI, the more of a feel you’ll get for the speeds it’ll employ and when it’ll employ them, giving you more information to use in planning your attack.
What if you are caught? When the game launches you into an EMMI capture sequence, you are given two chances to counter the EMMI’s capture strike: once when it grabs at you, and once more when it goes for the killing blow. In both cases, the timing of when the move happens is randomized, so you essentially have to guess when it’s going to come and hope you guessed correctly. You are told that it is almost impossible to counter these blows, which makes it all the more thrilling when you do succeed in landing the parry and are able to escape the EMMI’s clutches. Most of the time, the counter will fail, but by giving you a slim chance of escape, despite failing most of the time you will still feel as though you had some measure of control over your fate.
As someone who specifically dislikes gameplay where failing at stealth immediately results in total failure, I really appreciated this approach to giving you a chance to get out of trouble, even though most of the time I failed to do so.
Even when the EMMI lands the killing blow and you have to reload, the game spawns you just outside of whichever EMMI zone entrance you came through, instead of forcing you to start from wherever you last saved. This allows you to immediately go back in and try again without having to fight your way back; alternatively, if you want to take a break from EMMI zone gameplay, you can wander off and do something else for awhile before diving back in. This is a great approach to minimize player frustration with failure, by making the consequence of it less severe.
There is also something to be said to having an enemy that constant hunts for you, that you can do nothing about but to evade. The feeling you get when your efforts finally lead to you gaining the power to destroy that hunter once and for all is exhilarating, leaving you fired up and raring to take on the next EMMI.
As shown, there are many different mechanics that come together to make up the EMMI zone experience. From the level design, to the various ways you are given to evade the EMMI, to how you engage the EMMI directly, these mechanics come together to provide plenty of experiences for you within an EMMI zone.
Conclusion
Metroid Dread is an awesome game, in my opinion, and a large contributor to that great gameplay are the EMMI zones. You are taught how handle EMMI zones through a combination of guided experiences, cutscenes, and gameplay. Afterwards, the various game mechanics specific to EMMI zones come together to form a tense, challenging experience that straddles the line between being fun and frustrating.
I think the game would be more boring without EMMI zones, and that is a testament to how effectively they are designed. If ever I want to implement action-based stealth mechanics into my own games, I’ll be taking cues from Metroid Dread’s EMMI zones as an example of how to do it right.
Welcome to Part 2 of my tutorial for creating a debugging interface in Godot! In Part 1, we created the base for our debugging system. If you haven’t read that part, you should do so now, because the rest of the tutorial series will be building atop it. Alternatively, if you just want the code from the end of Part 1, you can check out the tutorial-part-1 branch in the Github repo.
At this point, we have the base of a debugging system, but that’s all it is: a base. We need to add things to it that will render the debugging information we want to show, as well as an API to DebugLayer that is responsible for communicating this information.
We’ll do this through “debug widgets”. What’s a debug widget? It’s a self-contained node that accepts a set of data, then displays it in a way specific to that individual widget. We’ll make a base DebugWidget node, to provide common functionalities, then make other debug widgets extend that base that implement their custom functionalities on top of the base node.
Alright, enough high-level architecture talk. Let’s dive in and make these changes!
Creating the Base DebugWidget
To get started, we want a place to store our debug widgets. To that end, make a new directory in _debug, called widgets. In this new widgets directory, create a new script called DebugWidget.gd, extending MarginContainer.
# Base class for nodes that are meant to be used with the DebugLayer system.
class_name DebugWidget
extends MarginContainer
Note the custom class_name. This is important, because later on we’ll be using it to check whether a given node is a debug widget.
You may need to reload your Godot project to ensure that the custom class_name gets registered.
Next, we’re going to add something called “widget keywords”:
# Abstract method which must be overridden by the inheriting debug widget.
# Returns the list of widget keywords. Responses to multiple keywords should be provided in _callback.
func get_widget_keywords() -> Array:
push_error("DebugWidget.get_widget_keywords(): No widget keywords have been defined. Did you override the base DebugWidget.get_widget_keywords() method?")
return []
This function will be responsible for returning a debug widget’s widget keywords. What are widget keywords, though?
To give a brief explanation, widget keywords are the way we’re going to expose what functionalities this debug widget provides to the debugging system. When we want to send data to a widget, the debugging system will search through a list of stored widget keywords, and if it finds one matching the one we supply in the data-sending function, it will run a callback associated with that widget keyword.
If that doesn’t make much sense right now, don’t worry. As you implement the rest of the flow, it should become clearer what widget keywords do.
One thing to note about the code is that we’re requiring inheriting classes to override the method. This is essentially an implementation of the interface pattern (since GDScript doesn’t provide an official way to do interfaces).
Let’s add a couple more functions to DebugWidget.gd:
# Abstract method which must be overridden by the inheriting debug widget.
# Handles the widget's response when one of its keywords has been invoked.
func _callback(widget_keyword, data) -> void:
push_error('DebugWidget._callback(): No callback has been defined. (' + widget_keyword + ', ' + data + ')')
# Called by DebugContainer when one of its widget keywords has been invoked.
func handle_callback(widget_keyword: String, data) -> void:
_callback(widget_keyword, data)
handle_callback() is responsible for calling the _callback() function. Right now, that’s all it does. We’ll eventually also do some pre-callback validation in this function, but we won’t get into that just yet.
_callback() is another method that we explicitly want the inheriting class to extend. Essentially, this is what will be run whenever something uses one of the debug widget’s keywords. Nothing is happening there right now; all the action is going to be in the inheriting debug widgets.
That’s it for the base DebugWidget. Time to extend that base!
Creating the DebugTextList DebugWidget
Remember that DebugLabel that was discussed at the beginning of the article? Having a text label that you can update as needed is a useful thing for a debugging system to have. Why stop with a single label, though? Why not create a debug widget that is a list of labels, which you can update with multiple bits of data?
That’s the debug widget we’re going to create. I call it the DebugTextList.
I prefix debug widget nodes with Debug, to indicate that they are only meant to be used for debugging purposes. It also makes it easy to find them when searching for scenes to instance.
Create a directory in widgets called TextList, then create a DebugTextListscene (not script). If you’ve registered the DebugWidget class, you can extend the scene from that; otherwise, this is the point where you’ll need to reload the project in order to get access to that custom class.
Why create it as a scene, and not as another custom node? Really, it’s simply so that we can create the node tree for our debug widget using the editor’s graphical interface, making it simpler to understand. It’s possible to add the same child nodes through a script, and thereby make it possible to make the DebugTextList a custom node. For this tutorial, however, I’m going to keep using the scene-based way, for simplicity.
Alright, let’s get back on with the tutorial.
Add a VBoxContainer child node to the DebugTextList root node. Afterwards, attach a new script to the DebugTextList scene, naming it DebugTextList.gd, and have it extend DebugWidget. Replace the default script text with the following code:
listNode is a reference to the VBoxContainer. We also have defined a const, WIDGET_KEYWORDS, which will define the widget keywords this debug widget supports. Technically, you could just use the keyword’s strings where needed, rather than define a const, but using the const is easier, as you can see below.
# Handles the widget's response when one of its keywords has been invoked.
func _callback(widget_keyword: String, data) -> void:
match widget_keyword:
WIDGET_KEYWORDS.ADD_LABEL:
add_label(data.name, str(data.value))
WIDGET_KEYWORDS.REMOVE_LABEL:
remove_label(data.name)
_:
push_error('DebugTextList._callback(): widget_keyword not found. (' + widget_keyword + '", "' + name + '", "' + str(WIDGET_KEYWORDS) + '")')
# Returns the list of widget keywords.
func get_widget_keywords() -> Array:
return [
WIDGET_KEYWORDS.ADD_LABEL,
WIDGET_KEYWORDS.REMOVE_LABEL
]
Notice that we’re overriding both _callback() and get_widget_keywords(). The latter returns the two widget keywords we defined in the const, while the former performs a match check against the widget_keyword argument to see if it matches one of our two defined keywords, running a corresponding function if so. By using the const to define our widget keywords, we’ve made it easier to ensure that the same values get used in all the places needed in our code.
match is Godot’s version of implementing the switch/case pattern used in other languages (well, it’s slightly different, but most of the time you can treat it as such). You can read more about it here. The underscore in the match declaration represents the default case, or what happens if widget_keyword doesn’t match our widget keywords.
Let’s go ahead and add the two response functions now: add_label() and remove_label(). We’ll also add a helper function that is used by both, _find_child_by_name().
# Returns a child node named child_name, or null if no child by that name is found.
func _find_child_by_name(child_name: String) -> Node:
for child in listNode.get_children():
if 'name' in child and child.name == child_name:
return child
return null
# Adds a label to the list, or updates label text if label_name matches an existing label's name.
func add_label(label_name: String, text_content: String) -> void:
var existingLabel = _find_child_by_name(label_name)
if existingLabel:
existingLabel.text = text_content
return
var labelNode = Label.new()
labelNode.name = label_name
labelNode.text = text_content
listNode.add_child(labelNode)
func remove_label(label_name) -> void:
var labelNode = _find_child_by_name(label_name)
if labelNode:
listNode.remove_child(labelNode)
_find_child_by_name() takes a given child_name, loops through the children of listNode to see if any share that name, and returns that child if so. Otherwise, it returns null.
add_label() uses that function to see if a label with that name already exists. If the label exists, then it is updated with text_content. If it doesn’t exist, then a new label is created, given the name label_name and text text_content, and added as a child of listNode.
remove_label() looks for an existing child label, and removes it if found.
With this code, we now have a brand-new debug widget to use for our debugging purposes. It’s not quite ready for use to use, yet. We’re going to have to make changes to DebugLayer in order to make use of these debug widgets.
Modifying DebugLayer
Back in Part 1 of this tutorial, we made the DebugLayer scene a global AutoLoad, to make it accessible from any part of our code. Now, we need to add an API to allow game code to send information through DebugLayer to the debug widgets it contains.
Let’s start by adding a dictionary for keywords that DebugLayer will be responsible for keeping track of.
# The list of widget keywords associated with the DebugLayer.
var _widget_keywords = {}
Next, we’ll add in the ability to “register” debug widgets to the DebugLayer.
func _ready():
# ...existing code
_register_debug_widgets(self)
# Go through all children of provided node and register any DebugWidgets found.
func _register_debug_widgets(node) -> void:
for child in node.get_children():
if child is DebugWidget:
register_debug_widget(child)
elif child.get_child_count() > 0:
_register_debug_widgets(child)
# Register a single DebugWidget to the DebugLayer.
func register_debug_widget(widgetNode) -> void:
for widget_keyword in widgetNode.get_widget_keywords():
_add_widget_keyword(widget_keyword, widgetNode)
In our _ready() function, we’ll call _register_debug_widgets() on the DebugLayer root node. _register_debug_widgets() loops through the children of the passed-in node (which, during the ready function execution, is DebugLayer). If any children with the DebugWidget class are found, it’ll call register_debug_widget() to register it. Otherwise, if that child has children, then _register_debug_widgets() is called on that child, so that ultimately all the nodes in DebugLayer will be processed to ensure all debug widgets are found.
register_debug_widget(), meanwhile, is responsible for looping through the debug widget’s keywords (acquired from calling get_widget_keywords()) and adding them to the keywords dictionary via _add_widget_keyword(). Note that this function I chose to not mark as “private” (by leaving off the underscore prefix). There may be reason to allow external code to register a debug widget manually. Though I personally haven’t encountered this scenario yet, the possibility seems plausible enough that I decided to not indicate the function as private.
Let’s add the _add_widget_keyword() function now:
# Adds a widget keyword to the registry.
func _add_widget_keyword(widget_keyword: String, widget_node: Node) -> void:
var widget_node_name = widget_node.name if 'name' in widget_node else str(widget_node)
if not _widget_keywords.has(widget_node_name):
_widget_keywords[widget_node_name] = {}
if not _widget_keywords[widget_node_name].has(widget_keyword):
_widget_keywords[widget_node_name][widget_keyword] = widget_node
else:
var widget = _widget_keywords[widget_node_name][widget_keyword]
var widget_name = widget.name if 'name' in widget else str(widget)
push_error('DebugLayer._add_widget_keyword(): Widget keyword "' + widget_node_name + '.' + widget_keyword + '" already exists (' + widget_name + ')')
return
That looks like a lot of code, but if you examine it closely, you’ll see that most of that code is just validating that the widget data we’re working with was set up correctly. First, we get the name of widget_node (aka the name as entered in the Godot editor). If that node’s name isn’t already a key in our _widget_keywords dictionary, we add it. Next, we check to see if the widget_keyword already exists in the dictionary. If it doesn’t, then we add it, setting the value equal to the widget node. If it does exist, we push an error to Godot’s error console (after some string construction to make a developer-friendly message).
Interacting with Debug Widgets
At this point, we can register debug widgets so that our debugging system is aware of them, but we still don’t have a means of communicating with the debug widgets. Let’s take care of that now.
# Sends data to the widget with widget_name, triggering the callback for widget_keyword.
func update_widget(widget_path: String, data) -> void:
var split_widget_path = widget_path.split('.')
if split_widget_path.size() == 1 or split_widget_path.size() > 2:
push_error('DebugContainer.update_widget(): widget_path formatted incorrectly. ("' + widget_path + '")')
var widget_name = split_widget_path[0]
var widget_keyword = split_widget_path[1]
if _widget_keywords.has(widget_name) and _widget_keywords[widget_name].has(widget_keyword):
_widget_keywords[widget_name][widget_keyword].handle_callback(widget_keyword, data)
else:
push_error('DebugContainer.update_widget(): Widget name and keyword "' + widget_name + '.' + widget_keyword + '" not found (' + str(_widget_keywords) + ')')
Our API to interact with debug widgets will work like this: we’ll pass in a widget_path string to update_widget(), split with a . delimiter. The first half of the widget_path string is the name of the widget we want to send data to; the second half is the widget keyword we want to invoke (and thereby tell the widget what code to run).
update_widget() performs string magic on our widget_path, makes sure that we sent in a properly-formatted string and that the widget and widget keyword is part of _widget_keywords. If things were sent correctly, the widget node reference we stored during registration is accessed, and the handle_callback() method called, passing in whatever data the widget node expects. If something’s not done correctly, we alert the developer with error messages and return without invoking anything.
That’s all we need to talk to debug widgets. Let’s make a test to verify that everything works!
Currently, our TestScene scene doesn’t have an attached script. Go ahead and attach one now (calling it TestScene.gd) and add the following code to it:
This is just a simple counter functionality, where test_ct is incremented by 1 each process step. Between 0-899, Debug.update_widget() will be called, targeting a debug widget named “TextList1” and the add_widget widget keyword. For the data we’re passing the widget, we send the name of the label we want to update, and the value to update to (which is a string version of test_ct). Once test_ct hits 900, however, we want to remove the label from the debug widget, which we accomplish through another Debug.update_widget() call to TextList1, but this time using the remove_label widget keyword. Finally, once test_ct hits 1000, we reset it to 0 so it can begin counting up anew.
If you run the test scene right now, though, nothing happens. Why? We haven’t added TextList1 yet! To do that, go to the DebugLayer scene, remove the existing test label (that we created during Part 1), and instance a DebugTextList child, naming it TextList1. Now, if you run the test scene and open up the debugging interface (with Shift + `, which we set up in the previous part), you should be able to see our debug widget, faithfully reporting the value of test_ct each process step.
If that’s what you see, congratulations! If not, review the tutorial code samples and try to figure out what might’ve been missed.
One More Thing
There’s an issue that we’re not going to run into as part of this tutorial series, but that I’ve encountered during my own personal use of this debugging system. To save future pain and misery, we’re going to take care of that now.
Currently, our code for debug widgets always assumes that we’re going to pass in some form of data for it to process. But what if we want a debug widget that doesn’t need additional data? As things stand, because debug widgets assume that there will be data, the code will crash if you don’t pass in any data.
To fix that, we’ll need to add a couple of things to the base DebugWidget class:
# Controls if the widget should allow invocation without data.
export(bool) var allow_null_data = false
# Called by DebugContainer when one of its widget keywords has been invoked.
func handle_callback(widget_keyword: String, data = null) -> void:
if data == null and not allow_null_data:
push_error('DebugWidget.handle_callback(): data is null. (' + widget_keyword + ')')
return
_callback(widget_keyword, data)
We’ve added an exported property, allow_null_data, defaulting it to false. If a debug widget implementation wants to allow null data, it needs to set this value to true.
handle_callback() has also been modified. Before it runs _callback(), it first checks to see if data is null (which it will be if the second argument isn’t provided, because we changed the argument to default to null). If data is null, and we didn’t allow that, we push an error and return without running callback(). That prevents the game code crashing because of null data, and it also provides helpful information to the developer. If there is data, or the debug widget explicitly wants to allow null data, then we run _callback(), as normal.
That should take care of the null data issue. At this point, we’re golden!
Congratulations!
Our debugging system now supports adding debug widgets, and through extending the base DebugWidget class we can create whatever data displays we want. DebugTextList was the first one we added, and hopefully it should be easy to see how simple it is to add other debug widgets that show our debugging information in whatever ways we want. If we want to show more than one debug widget, no problem, just instance another debug widget!
Even though all this is pretty good, there are some flaws that might not be immediately apparent. For instance, what happens if we want to implement debug widgets that we don’t want to be shown at the same time, such as information about different entities in our game? Or what if we want to keep track of so much debugging information that we clutter the screen, making it that much harder to process what’s going on?
Wouldn’t it be nice if we could have multiple debug scenes that we could switch between at will when the debug interface is active? Maybe we’d call these scenes “containers”. Or, even better, a DebugContainer.
That’s what we’ll be building in the next part of this tutorial!
If you want to see the complete results from this part of the tutorial, check the tutorial-part-2 branch of the Github repo.
At some point during the development of a game, you need to be able to show information that helps you debug issues in your game. What kind of information? That really depends on your game and what your needs are. It could be as simple as printing some text that shows the result of an internal calculation, or it could be as fancy as a chart showing the ratio of decisions being made by the game’s artificial intelligence.
There are different kinds of debugging needed as well. Sometimes, you need something temporary to help you figure out why that function you just wrote isn’t behaving the way you expect it to. Other times, you want an “official” debugging instrument that lives on as a permanent display in your game’s debugging interface.
How does one go about building a debugging system, however? In this blog tutorial, we’ll build a debugging system in the Godot game engine, one that is flexible, yet powerful. It’s useful both for temporary debugging and a long-term debugging solution. I use this system to provide a debugging interface for my own games, and I want to share how to make one like it in the hopes that it helps you in your own game development efforts.
This will be a multiple-part series. At the end of it, you’ll have the root implementation of the debugging system, and knowledge on how to extend it to suit your debugging purposes.
Godot comes with a number of functionalities that are useful for debugging. The most obvious of these is the trusty print() function. Feed it a string, and that string will get printed out to the debugging console during game runtime. Even when you have a debugging system in place, print() is still useful as part of your toolset for temporary debugging solutions. That said, nothing you show with print() is exposed to an in-game interface, so it’s not very useful if you want to show debugging information on a more permanent basis. Also, if you need to see information that updates on every frame step, the debugging console will quickly be overwhelmed with a flood of printed messages, to the point where Godot will bark at you about printing too many messages. Thus, while print() definitely has its uses, we are still in need of something more robust for long-term debugging solutions.
One way I solved this problem in the past is by creating a DebugLabel node, based on a simple Label. This node would listen for a signal, and when said signal was received it would set its text value to whatever string was sent to it. The code looked something like this:
This solution also depended on a separate GlobalMessenger system that functions as a global way of passing information. But that system is a tale for another day.
This gave me a solution for printing debugging information that updated on every process step, without overloading the debugging console. While this little component was useful, it had its drawbacks. Every call to print a message to the DebugLabel would overwrite the previous value, so if I needed to show more than one piece of updating information, I would have to create multiple DebugLabel nodes. It wouldn’t take long for my scenes to be cluttered with DebugLabel nodes. Also, this still wasn’t part of a debugging system. If there was a DebugLabel, it’d show, regardless of whether you needed to view debugging information or not. Thus, while this node also served a valuable purpose, it was not enough for a proper debugging solution.
So what does a debugging solution need? It needs a way to conditionally show and hide debugging information, depending on whether such information needs to be viewed. It also needs to expose a method for game code to interact with it to pass in debugging information. There are many possible kinds of information that we’d want to see, so this interaction method must support being able to accept multiple kinds of information. Finally, there should be an easy way of creating debugging scenes to organize the information in whatever ways make sense to those that view the debugging information.
With that high-level information, let’s start by tackling the first part of that paragraph: conditionally showing and hiding the debugging information.
Creating a Test Scene
But before we start working on the debugging system proper, we should have a test scene that exists to help us test that what we’re creating actually works. It doesn’t need to be anything fancy.
While this part of the tutorial is optional, the tutorial series will be assuming the existence of this test scene. If you choose not to make it, then you’ll have to figure out how to test the debugging system’s code in a different way.
Create a scene, and have it extend Node. Let’s call it “TestScene”. In TestScene, add a Line2D node, make it whatever color you want (I chose red), and set the points to make it some easily-visible size (I set mine to [-25, 0], [25, 0] to make a 50px-long horizontal line). Move the Line2D somewhere near the center of the scene; it doesn’t have to be exact, as long as it isn’t too close to the top or edge of the game window. Finally, click the triangle button to run Godot’s main scene; since we don’t have one defined, Godot will pop up an interface that will allow you to make TestScene the default scene, which you should do.
You can alternatively just run this individual scene without making it the main scene; I have chosen to make it the main scene in this tutorial purely out of convenience.
This is what my version of the test scene looks like after doing these things:
Now that we have a test scene, let’s get to building the debugging system proper.
Creating the DebugLayer Global
We need a way to interact with the debugging interface from anywhere in our game code. The most effective way to do this is to create a global scene that it loaded as part of the AutoLoads. This way, any time our game or a scene in our game is run, the debugging system will always be available.
Start by creating a new scene, called DebugLayer, and have it extend the CanvasLayer node. Once the scene is created, go to the CanvasLayer node properties and set the layer property to 128.
That layer property tells Godot what order it should render CanvasLayer nodes in. The highest value allowed for that property is 128, and since we want debugging information to be rendered atop all other information, that’s what we’ll set our DebugLayer to.
Now, create a script for our node, DebugLayer.gd. For now, we’re not going to add anything to it, we just want the script to be created. Make sure it, as well as the DebugLayer scene, are saved to the directory _debug (which doesn’t exist yet, so you’ll need to create it).
Finally, go to Project -> Project Settings -> AutoLoad, and add the DebugLayerscene (not the DebugLayer.gd script) as an AutoLoad, shortening its name to Debug in the process. This is how we’ll make our debugging interface accessible from all parts of our game.
Yes, you can add scenes to AutoLoad, not just scripts. I actually discovered that thanks to a GDQuest tutorial on their Quest system, and have since used that pattern for a wide variety of purposes, including my debugging system.
To verify that our DebugLayer shows in our game, add a Label child to the DebugLayer scene, then run the game. You should be able to see that Label when you run the game, proving that DebugLayer is being rendered over our TestScene.
Toggle Debug Visibility
This isn’t particularly useful yet, though. We want to control when we show the debugging information. A good way to do this is to designate a key (or combination of keys) that, when pressed, toggles the visibility of DebugLayer and any nodes it contains.
Open up the project settings again, and go to Input Map. In the textbox beside Action:, type “toggle_debug_interface” and press the Add button. Scrolling down to the bottom of the Input Map list will reveal our newly-added action at the bottom.
Now we need to assign some kind of input that will dispatch this toggle_debug_interface action. Clicking the + button will allow you to do this. For this tutorial, I’ve chosen to use Shift + ` as the combination of keys to press (Godot will show ` as QuoteLeft). Once this is done, go ahead and close the project settings window.
It’s time to start adding some code. Let’s go to DebugLayer.gd and add this code:
Right away, the editor will show an error on the visible = boolean line. You can confirm the error is valid by running the project and seeing the game crash on that line, with the error The identifier "visible" isn't declared in the current scope. That’s because CanvasLayer doesn’t inherit from the CanvasItem node, so it doesn’t contain a visible property. Therefore, we’ll need to add a node based on Control that acts as a UI container, and it is this node that we’ll toggle visibility for.
CanvasItem is the node all 2D and Control (aka UI) nodes inherit from.
Add a MarginContainer node to DebugLayer, calling it DebugUIContainer. Then, move the test label we created earlier to be a child of the DebugUIContainer. Finally, in DebugLayer.gd, change the visibility target to our new UI container:
onready var _uiContainer = $DebugUIContainer
func _set_ui_container_visibility(boolean):
_uiContainer.visible = boolean
You may notice that I’m prefixing _uiContainer with an underscore. This is a generally-accepted Godot best practice for identifying class members that are intended to be private, and thus should not be accessed by code outside of that class. I also use camelCase to indicate that this particular variable represents a node. Both are my personal preferences, based on other best practices I’ve observed, and you do not need to adhere to this style of nomenclature for the code to work.
At this point, if you run the test scene, the test label that we’ve added should no longer be visible (because we’ve defaulted visibility to false). That’s only half the battle, of course; we still need to add the actual visibility toggling functionality. Let’s do so now:
func _input(_event):
if Input.is_action_just_pressed('toggle_debug_interface'):
show_debug_interface = !show_debug_interface
_set_ui_container_visibility(show_debug_interface)
_input() is a function Godot runs whenever it detects an input action being dispatched. We’re using it to check if the input action is our toggle_debug_interface action (run in response to our debug key combination we defined earlier). If it is our toggle_debug_interface action, then we invert the value of show_debug_interface and call _set_ui_container_visibility with the new value.
Technically, we could just call the visibility function with the inverted value, but setting a variable exposes to outside code when the debug interface is being shown. While this tutorial is not going to show external code making use of that, it seems a useful enough functionality that we’re going to include it nonetheless.
Run the test scene again, and press Shift + `. This should now reveal our test label within DebugLayer, and prove that we can toggle the debug interface’s visibility. If that’s what happens, congratulations! If not, review the tutorial to try and identify what your implementation did incorrectly.
Congratulations!
We now have the basics of a debugging interface. Namely, we have a DebugLayer scene that will house our debugging information, one that we can make visible or invisible at the press of a couple of keys.
That said, we still don’t have a way of actually adding debugging information. As outlined earlier, we want to be able to implement debugging displays that we can easily reuse, with a simple API for our game code to send debugging information to the debugging system.
To accomplish these objectives, we’ll create something that I call “debug widgets”. How does a debug widget work? Find out in the next part of this tutorial!
You can review the end state of Part 1 in the Github repo by checking out the tutorial-part-1 branch.