Two Years on Steam with a C-Based Game
2024-09-12I made the unusual choice to write a game in C. It was a slow and challenging process - but one that was also rewarding. After several years of part-time development, Impaler was released on Steam in late 2022. At release, the game appeared stable and passed quality assurance with relatively few bugs. Still, I was uncertain about how things would go. Would it crash? Would there be surprises? Fast forward two years, and I can now answer this. Ultimately, the things I was most worried about were not an issue. Instead, the challenges came from unexpected areas.
Impaler (2022)
Impaler Gold (2024)
Post-launch Recap
Impaler is a smaller game that launched on Steam at $2.99 USD. It was well received, but it needed more content to warrant a price increase. Thankfully, most of my energy was put into expanding the game versus dealing with technical issues post-launch. You can see the focus on content in the changelog below.
Update | Description | Theme |
---|---|---|
U1 | Quality of life, balance | Balance |
U2 | Difficulty modes | Content |
U3 | New stages | Content |
U3.5 | Leaderboards and GOG Launch | Content |
U4 | New monsters | Content |
U4.5 | Physics and gravity overhaul | “Feel” |
U5 | New weapons and art upgrades | Content |
U5.5 | New achievements and quality of life | Content |
U5.6 | Graphics upgrades and decals | Technical |
U5.7 | New monsters | Content |
U6 | Impaler Gold release and new game mode | Content |
U7 | Leaderboards and achievements | Content |
Did It Crash?
My biggest concern, unexplainable crashing, was not an issue. I owe the “not crashing” to several decisions I made early on - much relating to memory management and my efforts to avoid it. Here are the three main rules I followed.
Rule #1 - Statically allocate game state
Game state and entities are allocated at compile time. Instead of calling malloc
to instantiate something, it lives in static structures-of-arrays or arrays-of-structures. This increases the executable size and puts a cap on the max number of entities. However, that was an acceptable tradeoff for this project. There were other benefits as well.
- the memory footprint of the game state is known
- the data has a simple lifecycle
malloc()
andfree()
are not repeated 100s of times- easier to avoid leaks and some memory-related crashes
Rule #2 - Avoid using NULL
as a parameter
This one isn’t fancy but I made a strong effort to not use NULL
as a parameter. I couldn’t avoid it in all cases, however, the reduction was a quality of life win to avoid redundant null checks.
- if you never pass
NULL
, you don’t need to check forNULL
- instead of
NULL
, pass a reference to an empty struct{}
when absolutely needed - replace
if (thing != NULL)
with something likeif (thing->active)
- pairs well with rule #1
Rule #3 - Limit code paths that modify game state
When the state of an entity or system needs to change, it can only occur in an update loop or event handler. This restriction made debugging far easier. Essentially:
- entities are allowed to modify their own state in update loops
- entities can read an immutable copy of the game state
- when one entity interacts with another, it is done through an event system
- emitted events are processed at the end of each update loop
The Surprising Challenges
While the game was stable, there were technical issues related to graphics drivers and laptops. These were infrequent and had a clear cause and solution. In contrast, gameplay and “feel” issues were less straightforward to address.
1. Driver settings
On a few occasions, players reported seeing white seams along the floors and walls. The cause of the issue turned out to be a setting forced on in the driver control panel. The game’s 2D assets are packed into a texture atlas (one that doesn’t enforce powers of two alignment). As a result, the mip mapping did not behave nicely when anisotropic filtering was forced on. It took some back and forth with the player to diagnose this, but it was addressed by simply reverting the setting. The lesson here is that you can tell OpenGL what to do, but the graphics driver has the final say.
2. Laptops with multiple GPUs
The most frustrating technical issue was related to laptops that had both an integrated AND a discrete GPU. Players reported that their framerates fluctuated drastically while playing the game. This was happening when the laptop switched between the integrated and discrete GPU (which was happening during gameplay). The solution was to configure Windows to only use the discrete GPU when playing the game. Again, some problems live outside of the codebase.
3. “Floatiness”
There was feedback describing the game physics as feeling floaty - especially when the player was airborne. I initially investigated this as a bug but the math showed to be correct. That meant it was not a physics issue but one of perception. Internet research suggested that many games don’t use the standard 9.8 m/s^2
gravity constant. Instead, gravity is implemented in a way that makes the gameplay and movement enjoyable. In Super Mario, gravity is doubled when you are falling making stomp attacks more effective. It turns out that this trick works well in Impaler too. The Mario gravity logic paired with gravity increased to 15 m/s^2
largely fixed the “floatiness” phenomenon. It was only a few lines of code but it took a long time to arrive at a solution.
Comparison between "correct" gravity and what is actually fun
Wrapping Up
I was able to ship something stable by avoiding the pitfalls of memory management and keeping things simple. The most difficult issue, something gameplay related, was solved by borrowing a trick from Super Mario. Overall, I found that gameplay and “feel” issues can be more challenging to solve than technical problems. When in doubt, borrow ideas from other games.