words on sand

from shri at drone-ah.com

08 Jun 2025

Managing Configuration

Endeavour: triangle

This post relates to triangle , an arcade, ARPG, factory game I’m working on.

Before making the game available for playtesting, I wanted the player to be able to configure the game to some degree.

As a starting point, my keyboard layout is colemak, and I doubt that the controls I use would suit the majority of players.

I am putting off a UI based config management option down the road (did I mention that I do not enjoy GUI work?). As such I’ve been pondering alternative configuration options.

Platform independence

Before I even get to that, the first problem I need to solve is a way to determine the location for the config files independent of the platform.

Fortunately, known-folders came to the rescue and provided an easy to use framework that can be used to determine the various relevant locations for multiple platforms.

1
2
3
4
5
6
const known_folders = @import("known-folders");
const maybe_config = try known_folders.getPath(allocator, .roaming_configuration);
if (maybe_config) |config| {
    defer allocator.free(config);
    std.debug.print("roaming config path: {s}\n", .{config});
}

Locations

There are three real locations of relevance for triangle

  • The binary / package
  • Config, technically, split into two
    • user (or in Windows parlance, the remote dir, and can be shared across computers)
    • system (in windows parlance, local, and is specific to that system)
  • Save Data

User & Game Config Files

With that sorted out, the next bit is to identify the relevant config files. I expect that triangle will continue to use these, and will eventually just get a UI config option as well.

User Config

There are two main bits of user configuration

  • Preference like controls
  • System details like resolution

I am currently unsure when it’ll support system config.

Game Config

There are two bits of config that the game will store. One set of config is to remember game choices the user has made.

Remember Player Actions

For example, it will be useful to show the user details of changes to the game since they last played. To do this, we need to track the last set of changes that the user saw.

The game will show a notice on startup about its extremely early access status, and provide an option for the user to hide that in the future. We need to save that somewhere too.

Telemetry

The second bit of config is metrics. While a lot of games will simply send telemetry information directly to the developer, player privacy is really important to me. I recognise that I will get far less data because of this, and that there will be a bit of survivorship bias with the data - but I feel that privacy is more important.

The way I want telemetry to work is that it will all be saved in a human readable telemetry file in the config file location.

The data is stored only locally, and is never automatically sent. The player is welcome to use this data for themselves if they wish and also share at their discretion. The information will be stored in a human readable format that should be as easy to understand as possible - no data dumps.

The location will also store logs (if enabled).

Setting Config

In terms of allowing the player to manage config, there are a couple of challenges:

  • Providing enough documentation that it is easy to do
  • Allowing for updates, particularly to the addition of new keys

To tackle this, I am going to provide an annotated template file with all the config options. The user can create a separate file based on this with only the config they wish to override.

It will be tricky to change how particular parameters are configured. E.g. If a single value key needs to switch to an array. I’d be loathe to sprinkle the code with checks for legacy keys/formats. We’ll play it by ear.

I considered updating the config file automatically, but this would discard any comments the user had added. While I could offer an option to merge changes in, it’s not straightforward enough to implement just yet.

Format

I’ve been considering toml and yaml for this, with zig-toml and zig-yaml respectively.

zig-yaml seems to be more active (more stars, forks, issues and pr’s and currently also the more recent commit).

I am also more familiar with and prefer yaml.

However, it does not currently support default values. I would like the user to have to specify only the config they’d like to override. zig-yaml currently expects all the keys to be defined if you want to parse it into a struct.

#85 should bring it in, but I could not get it to work

So, I tried out zig-toml and the test worked the first time.

src/toml_with_defaults.zig

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const std = @import("std");

const Controls = struct {
    forward: []const u8 = "w",
    craft: []const u8 = "q",
    inventory: []const u8 = "e",
};

const User = struct {
    controls: Controls = .{},
};

test "load partial toml config" {
    const toml = @import("toml");
    const allocator = std.testing.allocator;
    var parser = toml.Parser(User).init(allocator);
    defer parser.deinit();

    const source =
        \\[controls]
        \\craft = "s"
    ;
    var result = try parser.parseString(source);
    defer result.deinit();

    const config = result.value;
    const default = User{};
    try std.testing.expectEqualStrings(default.controls.forward, config.controls.forward);
    try std.testing.expectEqualStrings("s", config.controls.craft);
}

Using the config

The final part is to use the config.

Loading config

All config is loaded at startup and attached to a Config struct, which is in turn part of a Context struct that is passed around.

src/load_save_config.zig

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
user: User,

game_path: []const u8,
game: Game,

pub fn init(allocator: std.mem.Allocator) ConfigError!Self {
    const maybe_config = known_folders.getPath(allocator, .roaming_configuration) catch {
        return ConfigError.UnableToDetermineConfigLocation;
    };
    if (maybe_config) |config| {
        defer allocator.free(config);

        // user config path
        const full_path = std.fmt.allocPrint(allocator, "{s}/triangle/user.toml", .{config}) catch {
            std.debug.panic("oom", .{});
        };
        defer allocator.free(full_path);

        // game config path
        const game_path = std.fmt.allocPrint(allocator, "{s}/triangle/game.toml", .{config}) catch {
            std.debug.panic("oom", .{});
        };

        return .{
            .user = loadConfig(allocator, User, full_path),

            .game_path = game_path,
            .game = loadConfig(allocator, Game, game_path),
        };
    }

    return ConfigError.UnableToDetermineConfigLocation;
}

fn loadConfig(allocator: std.mem.Allocator, ConfigType: type, path: []const u8) ConfigType {
    var parser = toml.Parser(ConfigType).init(allocator);
    defer parser.deinit();

    var result = parser.parseFile(path) catch {
        log.warn("unable to read config file: {s}", .{path});
        return .{};
    };
    defer result.deinit();

    return result.value;
}

User config (controls)

This one involves a little translation as we need to know the rl.KeyboardKey for each mapping to be able to detect it.

I use a std.StringArrayHashMapUnmanaged(rl.KeyboardKey) to map the string to each key

src/load_save_config.zig

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const Input = struct {
    keymap: KeyMaps,

    pub fn init(allocator: std.mem.Allocator) Self {
        var keymap = KeyMaps{};
        for (default_keybindings) |entry| {
            keymap.put(allocator, entry.name, entry.key) catch {
                std.debug.panic("oom", .{});
            };
        }

        return .{
            .keymap = keymap,
        };
    }

    const KeyMap = struct {
        name: []const u8,
        key: rl.KeyboardKey,
    };

    const default_keybindings = [_]KeyMap{
        .{ .name = "a", .key = .a },
        .{ .name = "b", .key = .b },
        .{ .name = "c", .key = .c },
    };
}

Game Config

As a starting point, we’ll probably only have one config entry here - the last time the news was marked as read by the player.

We’ll store this as an i64 and loading it is exactly the same as above.

The main difference with the Game config class is that on writing any value, it will also save it to disk.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
pub fn markNewsAsRead(self: *Self) void {
    self.game.news_read = std.time.timestamp();
    saveConfig(self.allocator, self.game, self.game_path);
}

fn saveConfig(allocator: std.mem.Allocator, Config: anytype, full_path: []const u8) void {
    const path = std.fs.path.dirname(full_path) orelse ".";
    std.fs.cwd().makePath(path) catch |err| { // creates parent dirs if needed
        log.warn("unable to save: {any}", .{err});
        return;
    };

    var file = std.fs.cwd().createFile(full_path, .{
        .read = false,
        .truncate = true,
    }) catch |err| {
        log.warn("unable to save: {any}", .{err});
        return;
    };
    defer file.close();

    var writer = file.writer().any();
    toml.serialize(allocator, Config, &writer) catch |err| {
        log.warn("unable to write to config file: {any}", .{err});
    };
}

I ran into a bug where the api for serialization was broken . There is (currently) a pending pr #33 to resolve it

One of the challenges of using emerging language and ecosystem is that you’re more likely to run into bugs. One of the great joys of working with such ecosystem is the greater opportunity to contribute and get involved!

Links