transmutrix

Wizwag - Lazy Save Files in C

Over the years I've used different strategies for handling game save files. Much of my professional experience comes from web games, which most often means localStorage, but even with that API there are still decisions to make:

These days, most of the time, if I'm in a sufficiently high level language I usually default to JSON based save files.

They are easy to inspect, simple to read and write, not too bulky, etc. and as mentioned above, versioning them and writing migrations is trivial. This is what I did for years in web games and I never had a serious problem, even when the time came to migrate people's localStorage saves to a cloud save API tied to an account!

Format Woes

Wizwag is written in C11 now, and I didn't feel like wrangling a parser for this single use case. Granted, I could also use JSON for things like localization, but I'll cross that bridge when I come to it. I also wanted to be able to iterate on the save file frequently during dev without needing to write migrations all the time.

I looked at some text formats. I looked at some binary formats. I considered making the save files based on IFF.

The fastest and most reliable parser is no parser at all, so I set about structuring my game saves as a well laid out struct that I could just fread and fwrite with no extra work.

I spent some time thinking about this and sketching different ideas, and overthinking the problem a lot. Eventually I settled on the following -

The Simplest Possible Thing

typedef struct {
  i32 values[4096];
} wiz_save_t;

And that's the "save file" in Wizwag today. I was inspired by RMXP, which IIRC gives you a bank of like 5000 "switches" by default for setting up story flags and things of that nature.

"What the Hell is wrong with you?" you may ask.

But I don't answer, I just keep on yo-ing.

So here's the other major piece of this puzzle -

Labeling Slots

"Oh man, a flat list of numbers. For your whole save file? That seems like a bad idea, are you just gonna index those and track which slots are for what?"

This isn't the 80s, we don't have a spreadsheet printed out in the office that says "[3456] -> how many times the player save scummed a life potion" or something like that. Don't be ridiculous.

No, we track that stuff like this, with a hip and modern C enum:

typedef enum {
  WIZ_SAVE_LAST_ENTRANCE,
  WIZ_SAVE_LAST_ENTRANCE_ROOM_ID,
  WIZ_SAVE_LAST_ENTRANCE_FACING,
  WIZ_SAVE_LAST_ENTRANCE_X,
  WIZ_SAVE_LAST_ENTRANCE_Y,
  WIZ_SAVE_LAST_ENTRANCE_Z,

  // ... many many things ...

  WIZ_SAVE_KEY_MAX,
} wiz_save_key_t;

#ifndef EMSCRIPTEN
static_assert(WIZ_SAVE_KEY_MAX <= 4096, "save key cap exceeded");
#endif

Now it's not so bad, right? You can represent ints and bools, and when you need to refer to a specific value, you can do so like this:

// Giving the player an item.
rt->game.save.values[ WIZ_SAVE_HAS_BOW ] = true;

// Checking if a boss is dead.
bool boss_defeated = (
  boss_defeated_save_key &&
  rt->game.save.values[ boss_defeated_save_key ] != 0
);

// Marking a dungeon room as visited, for the map.
// (this one is a bit obscure because bit packing is going on)
i32 row_save_key = WIZ_SAVE_DMAP_VISITED_ROW(dmap->dungeon_index, row);
rt->game.save.values[ row_save_key ] |= (1 << col);

// Giving the player a small key (extraneous code removed)
wiz_save_key_t key_counter_id = ctx->area_meta.key_counter_id;
int keys = rt->game.save.values[ key_counter_id ];
int new_keys = pq_max(0, keys + net_keys_change);
rt->game.save.values[ key_counter_id ] = new_keys;

It's also easy to do things like "iterate over the inventory slots and look for something" or "iterate over the minigame slots and zero them out".

If I ever exceed 4096 save values, I'll need to handle that case. But so far, not even close, and I've reserved many slots for many things.

Reserving Slots

You could use a setup like this and label each entry as-needed, like a bump allocator, when it comes up. Making a new chest? Add a save entry for whether it is opened wherever the next free slot is!

Doing this across everything would fragment your save massively, and if you ever wanted to iterate over related items, you would need a list of e.g. every save key that's for that kind of entry.

That's gross and sounds like a nightmare, so we split it up into categories, like breaking a large memory arena into bespoke chunks.

Reserving slots to keep things organized is easy: I go to my Chrome browser window and press F12 to open the JavaScript console, and I type something like this in 5 seconds (generally without the line breaks):

let s='';
for (let i=0; i<128; ++i) {
  s += `  WIZ_SAVE_CHEST_OPENED_${i}_UNUSED,\n`;
}
console.log(s);

And then I press "Enter" and the browser says:

  WIZ_SAVE_CHEST_OPENED_0_UNUSED,
  WIZ_SAVE_CHEST_OPENED_1_UNUSED,
  WIZ_SAVE_CHEST_OPENED_2_UNUSED,
  // ... you get it ...
  WIZ_SAVE_CHEST_OPENED_125_UNUSED,
  WIZ_SAVE_CHEST_OPENED_126_UNUSED,
  WIZ_SAVE_CHEST_OPENED_127_UNUSED,

And I select that string output and I copy it and then I paste it into my C enum, reserving 128 slots for opened chests. Yes, those could be packed 8 chests per byte, 32 chests per save entry.

In some cases I've done things like that (like tracking which dungeon rooms have been visited), but in most cases I haven't because it's way less cumbersome to just read and assign 4-byte values.

Running Out of Slots

So far, I've never run out of slots in a way that mattered, even for a sub- category of values. I've given myself ample headroom for most things. I could put a treasure chest in every room in the game and it would be fine, etc.

It goes without saying that until the demo is out, I'm happy to occasionally throw away all the old game saves, if I did want to substantially reorder something in there, but even that hasn't come up so far.

When the game ships, I'll have added some extra headroom to each existing category to accomodate future updates without needing to migrate the save in any way.

If the game ever needed to grow the total number of slots in a large post-launch update, to do that and migrate values to the new shape correctly would be an afternoon of work at most, and I would only need to do it once, ever.

Save Robustness

This isn't really related to the my goofy setup, but it's related to save games in general so I wanted to at least mention it, for completeness sake.

Writing saves is done with fwrite and reading them is done with fread. There's a bit of other stuff in the file on disk besides our struct of ints: A magic number header we look for, a save file version number, and a checksum, followed by the actual save data.

Each save file in the game has a backup of the previous save, and we use simple CRC32 checksums to try to detect save file corruption.

In the case that both the save and the backup fail their checksums, we assume they're unrecoverable, and currently we just delete them. Before release, I'll need to make a nice UX around this unfortunate case.

And that's it!

Altogether I spent almost no time thinking about this game's save files, they just work and it's easy. Things may get a bit dicier when I set about adding the randomizer to the game, depending on how it works in the end.

For now, the workflow for most "fings wot need savin" (that's an academic term) is simple:

  1. I want to add new chest in a dungeon.
  2. I go rename one of the WIZ_SAVE_CHEST_OPENED_NN_UNUSED entries to include where the chest is and which one it is.
  3. I assign that as the save_key of the chest when I place it in the world.
  4. Done.

The only real win above this, in my mind, would be if my level editor was a bit nicer and we had a "project-wide" entity id ticker that was stable over the whole development of the game, so each entity could have an identifying GUID across the game world and across sessions, and we could just generate save keys and link things up automagically for things that are "in the world" as opposed to like a designer-named story flag or item slot, etc.

Maybe on my next game!