So you want to handle save games ?

Most games when they reach a certain size, they will need to persist data between game sessions. In our game dev jargon, we call this data a Save Game.

Your first reflex would be to think: oh this is easy, we only need to find the user’s documents or data folder and use simple file read and write operations to handle the save data.

Assumming we are using the Zig programming language, our favorite here at Cold Bytes Games, and the library known-folders, a naive save game serialization code could look like this (this code has not been tested).

 1const known_folders = @import("known-folders");
 2const std = @import("std");
 3
 4pub const SaveGame = struct {
 5    current_level: i32,
 6    death_count: i32, 
 7    huge_sword_unlocked: bool,
 8    awesome_shield_unlocked: bool,
 9
10    const Name = "SaveGame.sav";
11
12    pub fn read(path: []const u8) !SaveGame {
13        var file = try std.fs.cwd().openFile(path, .{});
14        defer file.close();
15
16        var reader = file.reader();
17
18        var result: SaveGame = undefined;
19
20        result.current_level = try reader.readIntLittle(i32);
21        result.death_count = try reader.readIntLittle(i32);
22        result.huge_sword_unlocked = try reader.readByte() > 0;
23        result.awesome_shield_unlocked = try reader.readByte() > 0;
24
25        return result;
26    }
27
28    pub fn write(self: SaveGame, path: []const u8) !void {
29        var file = try std.fs.cwd().createFile(path, .{});
30        defer file.close();
31
32        var writer = file.writer();
33
34        try writer.writeIntLittle(i32, self.current_level);
35        try writer.writeIntLittle(i32, self.death_count);
36        try writer.writeByte(std.mem.toBytes(self.huge_sword_unlocked));
37        try writer.writeByte(std.mem.toBytes(self.awesome_shield_unlocked));
38    }
39
40    pub fn delete(path: []const u8) !void {
41        try std.fs.cwd().deleteFile(path);
42    }
43};
44
45pub fn main() !void {
46    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
47    var document_path_opt = try known_folders.getPath(gpa.allocator(), .document);
48    defer gpa.allocator().free(document_path_opt);
49
50    if (document_path_opt) |document_path| {
51        var save_file_path = try std.fs.path.join(gpa.allocator(), &.{ document_path, "Cold Bytes Games", "MyGame", SaveGame.Name});
52
53        var save_game = try SaveGame.read(save_file_path);
54
55        save_game.death_count += 1;
56
57        try save_game.write(save_file_path);
58    }
59}

However, once you want to submit your game to a store that use cloud storage or submit your game to consoles, handling save game gets a little more involved.

In this article, we would like highlight things to keep in mind while implementing save data management.

Some definitions:

  • platform = any online service (Steam, EGS, GDK) or console
  • system = a sub-system inside your game engine.

Assume every read/write/delete of a save game is asynchronous or take a long time

Once you know that save data can be syncronized to a cloud storage, never assume that any save operation will execute and resolve instantly. Execute your save operation on another thread or queue a task in your task system. It is far more important on some platforms because their save game API needs to be executed on another thread than the rendering thread if you use the blocking version of their API.

For reading, you should display any loading indicator in your UI before showing a list of your save data or any save information.

For writing, usually games display an animated save indicator at the bottom-right on the screen to notify the user that their previous save data information is persisted.

Don’t assume standard file system operation

Like stated before, some platforms use cloud storage and don’t necessary use standard file operations. When using XGameSave from Microsoft GDK for instance, you need to handle containers and blobs.

If you have a hard time imagining what is a container and a blob, here an example how you could configure your save data using GDK:

1+ My Game Name (Container)
2  - First Save Slot (Blob)
3  - Second Save Slot (Blob)
4  - Third Save Slot (Blob)
5  - Options (Blob)

So your save game management should always serialize to memory and let the specific platform implementation handle how it is serialized on disk or to the cloud storage.

On some platforms, save data is tied to a logged user

On some platforms, multiple users can be logged at the same time and save game need to be handled per user. With some API, you need to pass an user handle in order to open the save data repository. For single player games, it is really easy to handle you just use the initial logged user but for local multiplayer games you’ll need to tie save game data to a user.

Always initialize your user management system before initializing your save game management system.

On some platforms, save data repository needs to be opened first

On some platforms, you can use standard file operation, but you’ll need to open a special save folder first. And they expect you to use the path given to you for that opened directory.

Opening your save data repository can and will fail and you need to handle it

On some platforms, it is required that when opening your save data repository that you handle typical errors and let the user decided to how handle it.

If you are lucky, there is some platforms that have standard system dialogs for those failures.

Save got corrupted

It is recommended to display to the user that a save game got corrupted. You can let the user deals with it and delete/overwrite the corrupted save.

Not enough space to create the save game

Display a dialog that display the space required to create a new save game so that the user know how much space it need to reclaim from its file system in order to save.

Know your maximum size of your save games

On some platforms, when opening your save data repository, you’ll need to pass the maximum size of your save game data. So know the maximum size of all your save game data.

Version your save game

Include a version field in your save game. It will really useful to be able to read any save game from a prior version of your game and migrate it to a newer version.

An quick example:

 1const Version_AddedInventory = 2;
 2const Version_NewInventory = 3;
 3
 4pub fn read(self: SaveGame, serializer: Serializer) {
 5    const version = serializer.readIntLittle(u16);
 6
 7    // Format of the inventory was changed
 8    if (version < Version_NewInventory) {
 9        var old_count = serializer.readIntLittle(u32);
10        // Assume code to insert old data into the new format
11    } else {
12        // Read new version of the inventory
13    }
14}

Use a structured format (binary or text) to serialize your save game (Optional)

Use a binary or text format that can loaded back into a dictionary or a generic object so you can retrieve any fields by name or id. It will simplify the handling of the previous advice but it can be done without a structured format as well.

Conclusion

I hope you have enjoyed my advice on how to handle save game in your game. In the future, I may dedicate another article on how I implemented my save game system inside our engine.