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.
Settings
A flat key/value store that writes to a file for you:
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 menuSettings.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:
| Prefix | Used for |
|---|---|
audio.* | Master volume and other audio settings |
display.* | Resolution, fullscreen |
input.* | Key rebind overrides, controller sensitivity |
language | The 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
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:
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:
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:
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:
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:
| Settings | Saves | |
|---|---|---|
| File name | settings.yaml | slot0.sav, slot1.sav, … |
| Backup | settings.yaml.bad if it fails to parse | slot0.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:
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.IsFocusedis 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.