Unlearning AAA
I finally ticked off a major item on my bucket list and released a commercial game with my own engine! It was a major effort and I'm happy to see Pommi in the wild. Looking back on development, however, it's obvious that I wasted plenty of time by "writing a game the right way".
My day job was in AAA where I was lucky enough to work with very talented engineers and I soaked up tons of programming knowledge. Naturally, when I started Pommi, I wanted to put all this knowledge to good use and I blindly imitated programming practices from big boy development.
In hindsight, it's obvious that best practices from AAA do not scale down to solo development. The scope and requirements are vastly different and you shouldn't adopt a solution for a problem you do not have!
In this blog post, I want to show three concrete traps I fell into, where digging out of it was a huge distraction and waste of time.
Reflection
Let's start with reflection. In addition to "heavy-data" such as meshes, animations, and textures, the game and tooling also has to deal with "light-weight" data such as level descriptions and resource meta data. Essentially, the light-weight data consists of key-value pairs. Think about placing an enemy in a level: We want to keep track of its type and position. Maybe its health points, or the path it's patrolling. In code, this data could be grouped in a simple structure, but we also need to write it to disk, expose some of it to the editor UI, or maybe even send it over the network.
Reflection let's you interrogate the code, so that you can ask questions like "what are all the variables and their types in this structure?". Obviously that sounds very useful for writing generic code, in particular for handling light-weight data. However, in C and C++ reflection is not part of the language so you have to build it yourself.
There are many different ways of bolting reflection to the language, such as
- Template and macro magic
- Writing a parser
- Integrating clang tooling
- Use a description language to generate both code and reflection info
Each of these approaches is a huge undertaking. For Pommi, which is plain C, I started writing a parser similar to Unreal's header tool. It's a rewarding programming exercise, because the initial results are quick, but then more and more grammar oddities pop up and it becomes apparent how much effort is really involved.
What I ended up shipping in Pommi is much, much simpler: I just dump binary blobs to disk and have bespoke code in the level editor.
Here's part of my editor level file, for example (Pommi calls it rooms). It's for loading back the level in the editor. The game loads a separate file with some information stripped. I'm only showing the editor data because it contains the version field which you would use to have bespoke code for patching old data.
struct EditorHeader {
uint32_t magic;
uint32_t version;
};
struct EditorBlob {
struct EditorHeader header;
uint8_t tiles[eTilemap_COUNT * TILES_PER_ROOM];
uint8_t roomWidth;
uint8_t roomHeight;
uint32_t entitiesOffset;
uint16_t entityCount;
uint32_t decorsOffset;
uint16_t decorCount;
// ...
};
The binary blob is constructed by allocating memory from a linear allocator, and the backing memory can be written to disk with a single fwrite
:
static void WriteEditorBlob(struct Alloc *outputAlloc) {
struct Editor *ed = g_mod->editor;
struct EditorBlob *editorBlob = CJ_ALLOC(outputAlloc, struct EditorBlob);
editorBlob->header.magic = EDITOR_MAGIC;
editorBlob->header.version = EDITOR_VERSION;
// ...
// Entities
{
const uint16_t count = ed->entityCount;
struct EditorEntityBlob *entityBlobs =
CJ_ALLOCA(outputAlloc, struct EditorEntityBlob, count);
editorBlob->entityCount = count;
editorBlob->entitiesOffset =
(uint32_t)((char *)entityBlobs - (char *)editorBlob);
for (uint16_t i = 0; i < count; ++i) {
const struct EditorEntity *entity = ed->entities + i;
struct EditorEntityBlob *entityBlob = entityBlobs + i;
entityBlob->spawnCellX = entity->spawnCellX;
entityBlob->spawnCellY = entity->spawnCellY;
// ...
}
}
}
static void WriteBlobToFile(const char *filePath, void *baseAddress,
size_t size) {
FILE *file = fopen(filePath, "w");
// ...
fwrite(baseAddress, size, 1, file);
fclose(file);
}
Takeaway:
Every reflection system will be an ongoing project in addition to your game.
The problem is that hobbyist projects typically don't reach the
break-even point where the time savings justify the effort.
ECS
Entity-Component-System, or ECS for short, is the current widely recommenended approach for structuring gameplay code.
Similar to other component based systems, the idea is to make up entities of resuable components. An enemy, for example, might have a Transform component for its position in the world, a health component, and a physics component.
On top of that, ECS makes a clear distinction between components which should only contain data, and systems which only should contain logic. In other words, components can be thought of as simple structures and systems are functions acting on these structures.
Entities are supposed to be light-weight handles or IDs with a mechanism to look up components from these entity handles.
The promise of ECS is good performance by tightly packing components together.
There are many ECS libraries and ECS based engines popping up, and there are certainly some success stories.
The problem with any gameplay programming framework, be it ECS or an Actor model, is that you have to express your game in it. It's very necessary for any generic engine to have a gameplay programming framework so that users can plug their game into the engine, but a game does not need a gameplay programming framework.
For Pommi, I implemented an ECS early on, and it slowed down progess quite a bit. Writing systems is often awkward, because of the component lookupcode. Also there's a lot of decision paralysis on how to model code as components. I recall pondering how to fit a tilemap in the ECS world and whether a tile needs to be a component.
The final straw is that I implemented a quick-and-dirty simple entity solution which outperformed by ECS code on the first try. My entity code now looks like this:
struct Entity {
uint8_t type;
void *extra;
struct ModelAsset *modelAsset;
struct PoseContext *poseContext;
hrobj_t hrobj;
uint16_t roomId;
struct Vec3 pos;
uint8_t isDead : 1;
uint8_t isCollider : 1;
// More flags...
struct DLink link;
struct DLink typeLink;
};
A couple of remarks: This entity structure is the variable per type data.
Data that is shared between all entities of a single type is stored in what I call an entity class.
It looks like this:
struct EntityClass {
size_t maxCount;
size_t extraSize;
const char *modelAssetName;
struct Rect collisionRect;
bool isSkinnedMesh;
bool isEnemy;
void (*init)(struct Entity *entity);
void (*deinit)(struct Entity *entity);
void (*physicsTick)(struct Entity *entity);
void (*tick)(struct Entity *entity, float elapsedSeconds);
void (*evaluatePose)(struct Entity *entity, struct PoseContext *context);
// More function pointers...
};
The extra
pointer in the entity structure contains additional
data for entity types. Note that I have pool allocators for entities, and entity types.
In fact, Pommi does not have any calls to malloc
after the game is initialized.
Also, there are many gameplay systems that live outside the entity framework! Bullets, debris, and explosions, for example, are their own thing.
As soon as you do the mental switch away from a gameplay framework it's evident that quite many doodads and gizmos inside the scene can be their own thing. The resulting code is even more data-oriented because it uniquely fits your game.
Takeaway:
Gameplay loops are complicated and incredibly varied. A card game looks unlike a jump-and-run, looks unlike a racing game.
There is no magic bullet to capture the complexity of every game in a single gameplay framework. Start by writing gameplay code
first and see patterns emerge, instead of fitting your gameplay code to a design pattern.
Multi-platform development
Pommi started life on Windows. The very first commit used SDL2 and OpenGL! Of course, none of that code is there anymore, as Pommi eventually released exlusively on the iOS app store.
However, for the longest time I had grand ambitions to release Pommi on multiple platforms right away. As it turns out, releasing on a single platform alone is already a huge undertaking! Plus, I had no experience releasing on console or Steam, so my multi-platform compatible code was really written for fantasy requirements.
This slowed down development in a number of ways. First of all, there is the shader issue. My shaders were written in GLSL and the plan was to cross compile to every platform. This introduced SPIR-V as a dependency and required to code a small shader converter calling the library and an additionally build step in the conversion pipeline.
Even after the move to macOS as a development platform and iOS as the target platform, I was stuck with GLSL to MSL cross compilation for some time. Some time was lost massaging the shader compiler to produce the correct output. It's a big project with a big API, so the necessary tweaks were often painful to get right.
At some point, I decided to commit to fully commit to iOS and treat further platforms as ports. This meant using MSL as the shader source of truth. Luckily, I had the SPIRV generated shaders as a starting point so I could just clean up the sources to be more human friendly.
Takeaway:
Going forward, I will only focus my effort on one platform at a time. Porting a finished product to
additional platforms is much easier than supporting multiple platforms during development, as all the
requirments are set in stone.