WINDAGE ENGINE v1.0.0
Contents

Docs / World

Saving

SaveStore keeps what your game remembers between runs. It stores any Codable value as a JSON file, atomically, in the one place a Mac app can always write, sandboxed or not. PlayerSettings is the engine's share of a settings screen, ready to save with it.

SaveStore

let store = SaveStore<SampleRun.Progress>(container: class="p">"WindageSample", name: class="p">"progress")

let progress = (try? store.load()) ?? SampleRun.Progress()   // nil when there is nothing; throws when it cannot be read
try store.save(progress)
try store.clear()                                            // "reset progress"

SaveStore<Value: Codable & Sendable> is a small value type:

MemberWhat it does
init(container:name:)container is the folder for your game; name is the file, without extension
urlwhere the file lives; only a path, so asking for it creates nothing
load() throws -> Value?the saved value, nil when nothing has been saved, and an error when a file is there and will not read
save(_:) throwswrites the value, replacing what was there, and makes the folder the first time
clear() throwsremoves the file; clearing nothing is not an error

Name the container after your game, so two games on one machine do not collide and a person can find your files. Use one store per kind of thing: the sample keeps settings and progress apart, so resetting progress does not reset a player's keys.

private static let settingsStore = SaveStore<PlayerSettings>(container: container, name: class="p">"settings")

Where files go

The file is <container>/<name>.json in the user's Application Support folder:

This is why SaveStore is in the engine at all. The engine never assumes a writable location outside the app's container, because your game, not the engine, is what gets distributed, and it may be sandboxed. A path beside the executable works right up until the app is sandboxed, so there is one place that knows where a game may write. Resources you ship are read from bundles. The save itself is pretty-printed JSON with sorted keys.

Atomic writes

save writes to a temporary file and renames it over the old one, and a rename is atomic. If the game crashes mid-write, the save is either the old one or the new one, never half of either.

A bad file says so

load() returns nil when there is no file, and throws when there is one that is truncated or written by a build that cannot be decoded. A save that will not load must still not stop the game starting — losing a high score is a disappointment; refusing to launch is a bug report — so catch it, log it, and start fresh. (try? store.load()) ?? fallback is the short form.

Starting fresh is the right failure for progress and a bad one for settings, where it means every setting reset. The fix is in the type you save, not in SaveStore; see versioning below.

In tests, use a container of your own and clear what you write: tests sharing the real file clobber a player's save, and parallel tests clobber each other.

PlayerSettings

PlayerSettings is what a player sets once and expects to find set again. It is Codable, so it saves through a SaveStore as it is:

private var settings = (try? SampleGame.settingsStore.load()) ?? PlayerSettings()

// Written the moment anything changes: no "apply" button to forget.
try SampleGame.settingsStore.save(settings)
PropertyDefaultRange
bindingsInputBindings.standardwhich key does what
padBindingsPadBindings.standardwhich controller button does what
mouseSensitivity, stickSensitivity1PlayerSettings.sensitivityRange, 0.1…5
invertMouse, invertStickfalse
moveDeadzone, lookDeadzoneStickResponse.moveDeadzone, .lookDeadzonePlayerSettings.deadzoneRange, 0…0.5
headBobAmount10…1; 0 is off — stored as headBob
volume0.80…1
ambienceVolume10…1, under volume
brightness0PlayerSettings.brightnessRange, −2…2 stops

Every numeric setting is clamped to its range when set, and a value that is not finite falls back to its default. mouseLookScale and stickLookScale give the multipliers to apply before Camera.look(mouseDelta:) and Camera.turn(rate:deltaTime:). See input for bindings and the rules for rebinding.

A game with more settings, such as a field of view, a difficulty or subtitles, keeps them beside PlayerSettings rather than inside it.

Versioning saved data

SaveStore does not version what it stores; the type you save does. Two patterns, both in the engine and its sample:

Decode field by field. PlayerSettings decodes every field as optional: a missing field takes its default, an unknown one is ignored, and a key name this build does not recognize costs only that one binding. A settings file from an older build loads into a newer one with everything it did say intact. A file written before the mouse and the stick had separate settings has one sensitivity and one invert, and both devices inherit them.

Carry a version from the first save. The sample's SampleRun.Progress has a version field and currentVersion = 2. Version 1 held a single best time; version 2 keeps one per level. A version 1 save migrates when it loads instead of being discarded, and the encoder never writes the old field, so migration is one way.

The first save format that needs changing is always one that did not have a version. Add the field before you need it.

Engine.version is the engine's version, not your save's: a save's format changes when your data does.

What it does not do

The sample's level editor saves edited levels to the same folder; see levels.