Skip to content

Saving and Settings

Two different jobs, and Joystick keeps them apart on purpose.

Settings are the player's preferences — volume, language, key rebinds, difficulty. One per player, small, changed from a menu, and it doesn't matter much if the newest change is lost in a crash.

Save games are progress — where they are, what they have, what they've unlocked. Many per player, written at checkpoints, and losing one is the difference between a player continuing and a player uninstalling. Saves get atomic writes, a backup copy and a checksum; settings don't need any of that.

Placeholder: a load-game screen listing save slots with their level, playtime and timestamp

Settings

A flat key/value store that writes to a file for you:

csharp
float volume     = Settings.GetFloat("audio.master", 1.0f);
int   difficulty = Settings.GetInt("gameplay.difficulty", 1);
bool  shake      = Settings.GetBool("gameplay.screenShake", true);
string name      = Settings.GetString("player.name", "Player 1");

Settings.SetFloat("audio.master", 0.8f);
Settings.SetBool("gameplay.screenShake", false);
Settings.Save();        // writes to disk — call it when the player leaves the menu

Settings.Load() reads the file back, Has(key) tests for a key, Erase(key) removes one, and Clear() wipes the lot. Every getter takes a default, so a fresh install with no settings file behaves exactly like one with defaults saved.

Keys the engine uses itself

These prefixes are read by the engine, so pick your own names outside them:

PrefixUsed for
audio.*Master volume and other audio settings
display.*Resolution, fullscreen
input.*Key rebind overrides, controller sensitivity
languageThe player's chosen language
haptics.*Vibration intensity

Everything else belongs to your game. gameplay.difficulty, ui.showTutorial, player.name — whatever you like.

Rebinds are overrides, not edits

When a player rebinds a control, the change is stored in input.* on top of your .jinput file. Your action map on disk is never rewritten, so shipping an updated map in a patch doesn't wipe the player's rebinds — and "reset to defaults" is just erasing the override.

Save games

csharp
if (SaveSystem.Save(slotIndex: 0))
    Log.Info("Saved");

if (SaveSystem.HasSave(0))
    SaveSystem.Load(0);

SaveSystem.Delete(0);

foreach (int slot in SaveSystem.EnumerateSlots())
{
    // ...
}

Save, Load and Delete each return true on success. Check the result — a full disk, a revoked permission and a corrupt file all show up here, and a save screen that silently claims success is worse than no save screen.

Choosing what gets saved

Nothing is saved automatically. That's deliberate: a whole-scene snapshot captures bullets in flight, half-finished particle bursts and physics velocities, and it breaks the moment you change a level layout in a patch. Instead, you say what matters.

Register a pair of callbacks under a name, and the save system calls them at the right moment:

csharp
public class Player : GameEntity
{
    private float m_Health = 100f;
    private int m_Coins;

    protected override void OnCreate()
    {
        SaveSystem.RegisterSaver("player", w =>
        {
            w.Write("hp", m_Health);
            w.Write("coins", m_Coins);
        });

        SaveSystem.RegisterLoader("player", r =>
        {
            m_Health = r.ReadFloat("hp", 100f);
            m_Coins  = r.ReadInt("coins", 0);
        });
    }
}

The name ("player" here) namespaces the keys, so two systems both writing "hp" don't collide. ClearRegistrations() removes them all.

You can write and read float, int, bool and string. For anything richer — a position, an inventory — write the parts: w.Write("x", Translation.X) and so on. Every read takes a default, which is what makes a save from an older version of your game still load.

ISaveable isn't wired up in 0.1.0

There is an ISaveable interface with OnSave/OnLoad, and it looks like the obvious thing to implement. Nothing discovers it — implementing it on an entity has no effect. Use RegisterSaver/RegisterLoader as above. If you like the interface shape, implement it and register its methods:

csharp
SaveSystem.RegisterSaver("player", OnSave);
SaveSystem.RegisterLoader("player", OnLoad);

Save slot metadata

A load screen wants each slot's level, playtime and timestamp. Reading that shouldn't mean deserializing every save you have:

csharp
foreach (int slot in SaveSystem.EnumerateSlots())
{
    if (!SaveSystem.GetMetadata(slot, out SaveMetadata meta))
        continue;                                  // missing or unreadable

    Log.Info($"{meta.SlotName} — {meta.Level}, {meta.PlaytimeSeconds / 60f:0} minutes");
}

SaveMetadata carries SlotName, Level, PlaytimeSeconds, Timestamp (Unix time), ScreenshotPath and IsComplete.

Versioning and migrations

When your save format changes, bump SaveSystem.SaveVersion and register a migration:

csharp
SaveSystem.SaveVersion = 2;

SaveSystem.RegisterMigration(fromVersion: 1, toVersion: 2, data =>
{
    // Reshape `data` from the version-1 layout into the version-2 one.
});

Migrations run before your loaders, so a loader only ever sees the current format. Chains apply in order — a version-1 save moving to version 3 runs 1→2 then 2→3 — regardless of what order you registered them in.

SaveSystem.GameVersion is yours to set alongside it, if you want to record which build wrote a save.

A save the engine can't migrate is kept, not deleted

An unreadable or unmigratable save is refused and left on disk. A player can send it to you; they can't send you a file you already deleted.

Where files end up

Settings and saves go to the per-user locations each platform expects, under the Company and Game Name you set in Settings → Project Settings:

SettingsSaves
File namesettings.yamlslot0.sav, slot1.sav, …
Backupsettings.yaml.bad if it fails to parseslot0.sav.bak

On Windows that's under %APPDATA%, on macOS under ~/Library/Application Support, and on Linux under the usual XDG directories — Company/GameName in each case. On the web there's no filesystem at all, which is why saving is designed to be able to fail and return false.

Not everything belongs in a cloud backup

On iOS you can choose whether the game's data is included in the device backup. Player progress usually should be; a large regenerable cache shouldn't, and Apple will complain if it is.

Data that only needs to survive a scene change

If you just need a score or a checkpoint index to survive Scenes.Load — not a reboot — that's Session, which is in memory only and costs nothing:

csharp
Session.Set("score", 2150);
int score = Session.Get<int>("score", 0);

See Session for the full reference, and Scenes for how it fits with scene loading. When it is time to write to disk, Session is the natural thing for a saver to copy out.

Habits worth having

  • Save at checkpoints and menu exits, not every frame.
  • Check the return value and tell the player when it fails.
  • Give every read a sensible default — that's what makes old saves keep working.
  • Don't save derived state. Physics velocity, particle state and current animation frame should be recomputed on load, not restored.
  • Autosave when the app loses focus on mobile. Application.IsFocused is accurate on desktop today; on mobile there's no app-lifecycle hook feeding it yet, so use your own pause button as the trigger there.

See also

  • ScenesSession and carrying data between levels
  • Session — the full cross-scene store reference
  • Input — where rebinds and haptics settings come from
  • Audio — reading and applying a saved master volume