Podule
// Devlog

Building a game in C

I wrote a short post a while back about why I chose C. This is the longer one, about what it’s actually like building a game this size in it.

How big is it?

For the Common Wealth is about 175,000 lines of C, there are 658 source files. There’s no engine as such. There’s little separation between “engine code” and “game code”, which was… a choice. In my defence, what exists as an “engine” would work for unrelated 2.5D games, but it’s not a reusable engine for any random game, at all.

Dependencies

SDL3, plus SDL3_image to load the PNGs and SDL3_mixer for sound. That’s the lot. The Steam integration is loaded at runtime if Steam is there, so the same exe runs with or without it. The whole game is plain old C99.

I wanted to keep dependencies down to an absolute minimum, mostly because I saw myself releasing this game on retro platforms like Amiga, but that is looking doubtful unless SDL3 is fully GPU accelerated on an Amiga, and memory usage now means it would be PowerPC Amigas only.

Memory

Most of what appears during play lives in fixed-size pools that are set up once: sprites, explosions, particles, bullets, fires. A level asks for what it needs from those pools, and the pools are reset when the level restarts.

Levels, and the level builder (BIABuilder)

BIABuilder with mission_beach open
BIABuilder with mission_beach open. Each red box is an NPC.

All 25 levels are built in BIABuilder, my level editor, which is C# and Avalonia rather than C. The file format is JSON, and on export BIABuilder writes each level out as plain binary, field after field, and the game reads it back in exactly the same order into its C structs. There’s no parser and no schema, just magic numbers at the end of records so I quickly know if I’ve got the export wrong.

BIABuilder is about 25,000 lines of C#.

Below is the same level in-game.

A minute of mission_beach: moving through the jungle, then the camp.

Sprites

This is a 2.5D game… and that’s a stretch. Let’s call it a 2.1D game. It’s 2D through and through, but multiple sprite layers and isometric art make it look 3D. Ish. Some of the time.

Sprites are based on PNG images. There is no tiling system, you can put images anywhere you like, using BIABuilder. This can then be edited with transforms, hooks and events in BIABuilder and in-game, each sprite becomes a struct with 138 members.

The main sprite layer is called “Ordering” and orders sprites by the Y baseline every frame to allow player sprites to move in front of, and behind other sprites.

Hooks

C has no reflection, so the level builder can’t just call a function by name. Instead, each level registers the functions it wants to expose under their own names, using a macro that turns a function’s name into a string. When BIABuilder says “when the player walks in here, call npc_escaped”, the game looks that name up when the level loads and keeps the function pointer. Most of the level-specific behaviour in the game, like a door opening or a car driving off, is one of these. These hooks are the main way in which behaviours and functions can get assigned in BIABuilder and run in-game. An example would be “player proximity” hook. Where any sprite can have this hook assigned, and the hook will be called in-game when the player approaches the sprite. This is ideal for opening doors and things like that.

NPCs

NPCs run their “AI” in a thread pool. They patrol around location waypoints, but on becoming alerted or suspicious, they can break away from patrol and then start finding their own paths on an A* pathfinding system. They can attack, panic, run away, and talk amongst themselves.

Collision

There are several types of collision, Physical, can’t walk there, like a wall or a hole. Bullet, stops a bullet. Player Vision can computer controlled allies see there. NPC Vision, can NPCs see there. There is Patrol Collision which helps guide NPCs through sensible patrol routes, i.e. they can walk through a swamp if they want, but they’re not going to patrol that swamp. Finally, there is Hint Only collision, which is for things like tents. You can shoot straight through a tent, it won’t stop a bullet, but we do want sound effects of a bullet hitting cloth.

Named Areas

Weird name from back when this game was going to be a cozy RPG and a named area would be something like “Mr. Cozymans’s Orchard” or something. Now they are arbitrary polygons to which we apply effects like moving water, or clouds, or interact with other feature like rain. When there is a level with rain, we can make Named Areas for Rain Rejection so while rain can fall anywhere on the level, it can be stopped from falling in certain places, like indoors, or the side of a wall or something. The same applies to falling snow.

Runtime

On starting a level, the BIABuilder exports are loaded from disk and a level is composed from sprites, named area, NPCs, locations, collision, mission objective, audio regions and hooks. Hooks are resolved from string at this point and during gameplay they are function pointers. NPCs start to think in their own thread pool, animations resolve into timelines which are simple structs which can advance an animation or transform based on the current ticks.

Timelines handle movement of just about everything in game, from walking to a bullet in flight. A bullet in flight is a discrete sprite that checks its own position every frame. It may hit collision, or a player, or an NPC, then the next event begins, like NPC death.

Mutterings

All characters in For the Common Wealth can emit “mutterings” which may be a scream on death, or it may be two NPCs talking with each other about their girlfriends back home. As well as enemy NPCs, there are “hostages” and “civilians”, with their own voices and motives.

Would I do it again?

100% Yes.

C and SDL3 has suited me very well for making this game. I’ve made innumerable mistakes in making it, but none of them were caused by C. My mistakes came from never having made a game before, and learning as I went. C on the other hand has been great, and SDL has been rock solid throughout.