2D Rogue-Lite Game Dev Log #01 :: 6 Months of Progress Summary
Posted on
Project Introduction
Hiya, over the last 6 months I’ve been intensely working on my current game project. It’s a twin-stick rogue-lite inspired by both the recent swarm of idle/auto-shooter games inspired by Vampire Survivors and 20 Minutes Till Dawn, to more action focused rogue-lites such as Hades, Risk of Rain (+ its sequel) and Enter the Gungeon. I have a stronger focus on more direct player-involvement, such as manual aiming and allowing for more varied play-styles via activated abilities and emergent behaviors, driven by systemic design.
A truly epic short-montage of the current state of the high-octane gameplay
Rewinding back to June 2023, I hadn’t touched the project since February, as I was busy with uni and worked on other ideas. Facing performance issues with the high number of enemies and other moving entities I was hoping to achieve, I then lost interest in the project once more. But, after spending some time reflecting, I made a conscious decision to accept the challenge (also to learn a lot about modern performance techniques) and actually see this through till the end!
The few milestones I reached before abandoning the project in February 2023, till late June 2023, when I picked this project back up again
New gameplay mechanics
Improving the gameplay was the first thing I settled to focus my efforts towards. Until then, it felt lackluster and too simplistic compared to what I personally and I believe others would enjoy. The major changes are all about creating more variety and depth:
Enemy rework
My main issue with the gameplay was the lack of enemy variety and their behaviors — they’d merely chase the player and deal damage on contact, with movement and turn speeds being the only differences between enemy types. So I settled on creating a modular set of components to create far more varied behaviors:
Weapons
Enemies using the player's "Pistol" weapon
Added support for weapons (the same ones the player uses), allowing enemies to attack from a distance in a variety of ways.
Target distance + attraction
Enemies can optionally try to maintain a target range distance from the player. They also try to avoid other enemies unless it means getting closer to the player
This led to the addition of having options to maintain a target distance from the player instead of always chasing them. This helps to break up the large persistent clusters of enemies that would form, with many now only sticking to more short-lived ranged attacks. Implemented as an attraction system with negative forces (aka repulsion), it causes enemies trying to sustain a target distance from one another as well. The system responds dynamically to enemies being both knocked back and defeated, creating a stronger swarm-like feel for the enemies’ movement.
Pets
"Senty" enemies that solely spawn timed-pets that fire ranged attacks at the player
I also added "pet" spawning so that enemies could spawn other kinds of enemies for various scenarios, such as fast moving, low health attack sponges, or stationary ranged "sentries".
Stats
"Leader" enemy that buffs other enemies and de-buffs the player's movement speed whilst inside it's effect radius
I also extended the stat modifier system to allow affecting either individual, specific type(s) or all enemies at once, so that enemies can receive stat changes just like the player can. This led me to adding a leader enemy type, one which buffs up nearby enemies with increased damage and movement speed, whilst also slowing the player down if they get too close.
Ability system
Whilst working on the enemy improvements, I wanted a way to design more unique and individual behaviors for specific types of enemies, as well as for the player’s abilities and upgrades. The player’s current "ability system" was actually only a timed upgrade, limited to changing the player’s stat values. I also devised a reworked system allowing for both passively and manually activated abilities, with arbitrary logic for a variety of end results. Some of the current abilities include:
Splash damage
Splash damage applied in an area with a falloff depending on distance from the source point. It’s in use by some weapons and specific enemies on events such as death
Spawning interactive world elements
Spawning interactive world elements such as bombs, and unique ranged attacks
Elemental types
Elemental types: Fire is causing the target to take continuous damage, but also to move faster. Ice freezes movement entirely, and Warp is pulling nearby targets closer in at regular intervals
Grid level layouts + destruction
Demo of the iteration speed to create a level layout via Godot's TileMap tooling
The other major pain point: The play space itself being massive yet entirely empty, with the only reason for the player to go in any direction is to run away from the enemies, leading to the movement as a whole being un-engaging. This led me to try using Godot’s "TileMap" system for prototyping levels. The terrain sets are allowing me to create seamlessly connected walls with a much quicker iteration speed. This was already a tremendous step up in creating gameplay variety, especially with the addition of destructible tiles that take damage before breaking, allowing for forming new passages by both the player and enemies.
Debug visualization of the navigation mesh being updated asynchronously whenever a destructible tile is added or removed, leading to enemies using the new paths nearly instantly
With this working, enemies’ movements & navigation needed a proper understanding of the environment, rather than moving blindly towards the player. To achieve this, I used Godot’s navigation server, generating an updated navigation mesh at runtime whenever destructible tiles are broken, and so allowing enemies to discover and use any freshly formed pathways. In the future, I would like to add layers to the navigation mesh, so that enemies can intentionally form new passages whenever it would be faster than just moving on existing routes. There are still additional improvements to be made, particularly regarding enemies maintaining their distance, as they often encounter obstacles while prioritizing to avoid the player. Also, narrow, single-tile wide paths are causing enemies to push into one another at entrances, resulting in many enemies getting stuck. At present, the player can abuse this with destructible tiles.
Visual style explorations
Along with the gameplay, I’ve also invested time into iterating the visual design and aesthetics of the game. At the beginning, I hadn’t decided yet if the choice of using Sci-Fi UI (sometimes called FUI) from films and other games as an inspiration would work well here. After having added these elements, I’m feeling far more confident with this visual direction.
Screen layering
One of the key elements I tried incorporating was a sense of depth and visual hierarchy via the use of layering. I tried out 2 different approaches:
Post Processing Shader
Old screenshot of post-processing based screen layering effect. It provides a unique effect but ultimately felt too visually disruptive
This would selectively mask the screen texture based on a luminance threshold, similar to how many bloom filter implementations work. Then, the algorithm samples the screen texture with a scaled down UV per layer and mixes it in based on the depth of the layer. This works well but isn’t directly controllable for specific elements, unless I’d use a mask or material ID from a custom render texture. But without easily usable render textures in Godot (at the time — this is improving thankfully!), I tried out a different approach...
Custom Node for Layering other Visual Nodes
Showcase of the layered visual node applied to the tilemap to provide depth to the visuals
Since sprite rendering is very cheap, I tried creating a custom node to create duplicates of an input node with different scaling and position offsets for each layer. Positioning these relative to the camera’s current position creates a convincing pseudo-3D parallax effect. With this approach, I can control which elements are layered and can have unique settings for each instance. And since I had only wanted the layers for certain elements anyway, this also ended up being faster than the post-processing approach and avoids making the visuals harder to interpret for the player! Win, win!
Pixel Quantization
Quantize shader being applied to the player's sprite with the "progress" factor being modulated
Lower resolutions elements are another feature I found in a lot of retro-styled FUI designs. These are resulting in far more chunky pixels, which I am admittedly a big fan of. This led to the idea of using this kind of effect for glitches, such as when enemies or the player are taking damage. It works by modifying the UV from being continuous to stepping at constant, discrete intervals. By modifying the UV from being continuous to stepping at constant, discrete intervals, the effect allows for smooth transitions between regular texture sampling and a uniformly quantized, blocky appearance.
Retro Post-FX (CRT + Bloom)
Comparison of the "raw" custom bloom with the CRT-only custom bloom
I also experimented with bloom and CRT effects to further drive the retro aesthetic but also to sell the idea that you’re piloting the character via a terminal of some kind. The first few attempts of the CRT effects felt a bit too cliche and also disruptive to the legibility of text and general silhouettes of enemies within the world, even when lightly mixed in. I tried working around this by adding some custom bloom, since I didn’t want to use Godot’s built-in bloom, as it requires a HDR framebuffer, using much more VRAM + bandwidth for a single visual effect. Instead, I sample the screen texture at a lower mipmap value as a faster alternative to blurring and use the luminance as a mask. This worked well enough, but it also made the text and visuals harder to read as the bloom would bleed the edges. I tried tweaking this with a custom curve for mixing and after some messing around + applying the CRT to only the bloom gave an unexpectedly awesome visual effect that doesn’t hurt the visual clarity but also creates a more unique aesthetic.
Splash damage/explosion shader
Realtime + slow-motion demo of the splash damage effect
Splash damage affects targets within a radius, but that radius wasn’t being displayed at all yet. Initially, I just made some debug drawing script for different shapes and used a circle to visualize the radius, but felt it needed to have more impact. So I made a "refraction" shader that modulates the UV for the underlying screen texture with a "bloated ripple" effect. The red and blue channels are UV shifted slightly to give the refraction effect. I added some tweens for the effect via a script to manipulate the quad’s scale and various shader uniforms for a more juicy feel.
Motion blur
Comparison between no motion-blur and motion-blur being applied to projectiles
Whilst doing some play-testing, I realized that projectiles with upgraded speeds became increasingly harder to follow, with pretty choppy motion due to their velocity being far higher than what the refresh/render rate could properly display. My first approach to improve the perception of the motion was simply to stretch the vertices of the projectiles based on their velocity via a shader, which would also stretch out the texture, but the results weren’t great and felt pretty unpolished. Then I had the idea of applying a directional blur in the fragment shader to emulate motion blur. Typically, post-processing is used to achieve motion blur, either by incorporating camera-based motion blur (utilizing the camera’s movement and rotation velocity) or per-object motion blur (using a render texture that stores the object’s XY velocity in screen space). Godot does produce a motion vector texture for 3D rendering since it’s useful for various render passes, such as temporal anti-aliasing and certain upscaling approaches (eg. AMD’s FSR2). Sadly, it’s currently inaccessible to custom shaders (though, this might be changing with the eventual addition of render hooks, maybe?) and it also isn’t being generated for 2D viewports, anyway. Since I only wanted it for projectiles and perhaps also pickups, it was far easier to just modify their shaders directly, than creating a custom render texture for the motion vectors and apply the blur in a post processing pass.
The amount of iterations required for the blur is based on the node’s velocity (passed as a uniform via code) and I still stretch the vertices so the texture can be extended beyond the initial quad bounds when needed, although I adjust the UVs to cancel out the vertices from getting stretched out. This worked out great and vastly improves the presentation for fast moving projectiles and pickups! Eventually, I’ll pre-calculate the blur for various velocities into a texture atlas to improve performance, since there isn’t any need to re-calculate this each frame.
General game features
I also worked on some less exciting, but still necessary features to elevate the game from a prototype to a more polished, user-friendly experience.
Loadout menu redesign + Save data
Usage of the loadout menu to view, unlock and equip weapons + abilities for the next run attempt
To allow for meta-progression and gradually introduce new content to the player, I added a currency that you earn depending on how far you proceed into a run. Players can exchange this currency in the new loadout menu to preview and unlock new options: weapons and two equipable abilities. To make the progress persistent across launches of the game, this data is now saved into a binary save file.
Settings menu
"Controls" settings panel with fully re-bindable inputs for the gameplay actions
Another important addition is the settings menu, where players can customize the experience to their liking with fully re-mappable controls for both keyboard/mouse & gamepads, audio mixing, visual settings. I’d like to add many more features, specifically to tune the gameplay and difficulty to be more accessible, but also add visual accessibility features such as custom color themes for both the UI and game world, high-contrast modes (enemy outlines/fill) and any others that are suggested (and feasible...)!
Licenses menu
Preview of the list of licenses available to view in the dedicated menu
I also added a license menu to view all the licenses used by the Godot engine and the game directly as part of the main menu. I feel it’s important to make that sort of information easily accessible as a small gesture for all the hard work from the huge amount of people that indirectly contributed to my game, rather than just being tucked into a licenses text file along with the game executable.
Various UI experiments
A showcase of some UI animations I've created, mostly with this project in mind (the second one is a re-make of Street Fighter 6's "multi-menu")
Besides the loadout menu redesign, I also created some experimental UI experiments outside of the core gameplay. One idea was to create a fake windowing environment for a fictional operating system that you’d use throughout the game, so I tried to tackle the idea of creating a gamepad friendly pie-wheel for switching between windows (which I now use for the redesigned upgrade menu). I also experimented with some UI animations, since they’re a personal joy of mine in other games, but I still need to come back to these all once the time is right.
Gameplay code port/refactor
So that all seems like a decent amount of progress, but not exactly what I’d consider 6 months of work worthy, so what else did I end up doing... Well, I was hitting very major performance issues before I picked up this project again, and it only got worse as I added more features, despite my best efforts, so I spent around 3 months exclusively addressing the performance issues with various levels of success. Here’s a brief summary of what I did to fix it:
Identifying bottlenecks & issues
Profiling results before I refactored the gameplay code, gathered from the Godot Editor’s profiler tool. Whilst useful for seeing a high-level view of performance, it wasn’t providing enough info about why certain functions were slow
The first step to fixing performance is identifying what is causing performance issues. Godot provides a profiler for scripting code and another for rendering, but unfortunately the script profiler gives far too limited information for what I’d consider useful, so I created a custom profiler script, that allowed me to easily profile sections of code by marking the start and end manually with a label. This data would then be exported to a JSON file following Google’s Profiling format on runtime exit, so that I could view it in an external profile inspector tool like spall or perfetto. This was invaluable for my project to better identify performance issues, especially since profiling threading code in Godot’s profiler wasn’t really supported (thankfully Godot’s script profiler is getting large improvements soon!). With my profiler findings, I found most of the performance issues came down to the enemies, projectiles and physics interpolation taking the most amount of time.
Profiling results before I refactored the gameplay code, visualized in Perfetto. Data gathered from my custom profiler script
Disabling physics
The biggest contribution to the frame time was the physics tick, which surprised me since I only had 600 enemies running at 30Hz, something I could achieve in Unity and even in my own game engine using Box2D. I tried disabling physics between enemies and still strangely hit an issue with thousands of collision pairs occurring (btw. seems this was a bug that’s been fixed in new versions of Godot 4, good to know!). So I disabled collisions entirely for enemies, opting for a custom collision detection solution I made using hash grids (based on Ten Minute Physics...), which was much faster but suffered from enemies not interacting with the level anymore.
Object pooling
Some people mistakenly believe object pooling is unnecessary in Godot because GDScript lacks garbage collection, while Unity's C# runtime garbage collector is notorious for causing frame-spikes. This is certainly true, but object pooling isn’t only used to reduce/eliminate garbage collection allocations, as it pre-allocates and prepares memory in advance, so that the memory can be re-used instead of re-allocating it. This becomes especially crucial in cases where complex and/or large amounts of instances need to be instantiated during runtime. In my case, adding object pools for enemies, projectiles, pickups and stat labels significantly helped a ton with reducing the time to add and remove these entities (a single enemy could take up to 0.4ms, which adds up quickly with other tasks to perform in a frame).
Using Godot’s worker thread pool
To better utilize the CPU’s multi-core architecture, I tried to optimize enemies and projectiles by splitting the workload over multiple threads. Achieving this was easy with Godot’s Worker Thread Pool, and while it certainly helped, the efficiency of the thread pool itself left much to be desired.
Migrating hot code paths to GDExtension
So I tried porting the hot code paths such as the enemy attraction, enemy movement and fixed timestep visual interpolation code into a custom GDExtension, which I wrote in C++. This took a while to figure out, as I had to read a bunch of code in Godot’s C++ codebase to understand how I could port certain aspects, mostly related to how Godot handles memory allocations. Because of the "one-way interop" between GDExtension and GDScript (i.e. GDScript can use custom GDExtension types, but the inverse is unsupported), I had to figure out how to use custom GDScript classes without porting them entirely to GDExtension as well. I solved this by opting to port the common data needed in both GDScript and GDExtension as a custom type in GDExtension, to avoid relying on too many get()
and set()
function calls as they’re quite slow due to their interaction with the GDScript virtual machine (VM).
Reworking the gameplay with Flecs
Whilst the above helped a ton, unfortunately, it still wasn’t enough for the 600 or so enemies I wanted to have as an upper limit. I did some experiments in a separate prototype project trying to use data oriented design and used Godot’s Servers instead of using Scene Nodes and saw a significant boost in performance. This led me to write a mini ECS in GDScript based on a 2-part blog series by the creator of Flecs, an excellent C++ ECS that I’m using in my own custom engine. Once I had it working in the prototype quite well, I had an Eureka moment that I could just simply use Flecs itself inside my custom GDExtension for a far more enjoyable dev experience. This led me to take the opportunity to redesign essentially all the enemy, projectile, pickups, stat labels and stat modifier code whilst moving it to Flecs, which I feel will really help the project in the longer term! The performance is now far, far better, and finally feel that I can move on from optimizations to actually start working on gameplay & content again! This part especially took me quite some time as I faced multiple bugs that would cause crashes along the way with only little info to go by. This was a result of failing to figure out how to attach a C++ debugger to Godot on MacOS, since it loads the library during runtime (if anyone knows how to solve this, I’d greatly appreciate it if you could share how — I’ve tried multiple different ways without success...). As a result, I was stuck with printing info to the console with various extra utility functions I wrote and using Flecs’ excellent web-based inspector. On top of that, the interop between the ECS and GDScript was hard to design for at first, until I adopted a "handle" approach with various functions that GDScript could call easily.
Profiling results after I refactored the gameplay code, visualized in Perfetto. Data gathered from my custom profiler script. The end result is the gameplay code runs a whopping ~18x faster on average than my original code, despite also having many more features!
Next steps
So with the performance issues out of the way, I want to focus again on gameplay and content for a demo release, so heres what I have planned for the next few weeks:
Add stage objectives + stage progression
Currently, there isn’t much of a game play loop beyond of defeating a pre-made, fixed set of enemy waves to survive for 10 minutes. Depending on both how many enemies you defeat and how long you can survive, you earn a currency which is used to unlock new loadout options for future attempts. This leaves very little incentive to do future attempts other than trying different loadouts, but they’re quite limited themselves in their current form. So how do I hope to improve this situation? Well, for most of the development, I’ve been planning to have some sort of stage progression during the run with different maps & enemy types. This alone will already help to add more variety to the gameplay, but I’ve also thought about having different randomized objectives required for each stage to progress further, to create almost "mini game-modes" besides the general goal of completing a set series of stages to beat a run.
Finish first content pack for first demo release
Once stage progression and objectives are in place, I’ll set my focus on creating the initial content for the game in preparation of the first demo I’d like to release (finally!). I’ve long held off on doing a demo, largely because the performance issues were really bad and simply weren’t acceptable to me for what I’d like to release, even as a demo. And if I would’ve, it’d have been the largest criticism by far. Now that the performance isn't as bad, I hope that when I release a demo, the main points of feedback will be about the actual gameplay loop, mechanics, and content, which I'm sure will require multiple rounds of iteration.
Outro
So that has been a "brief" summary of the work that I’ve put into the project over the past 6 months. If you have any feedback or want to see more regular updates and/or just generally chat, I’m readily available on Mastodon! And thank you very much for reading!