Save and load a C# game in named slots with onegame’s storage service, list them for a load screen, add quick-save and autosave, keep the player’s settings in a typed schema, and test that a save survives a restart.
New in 1.4.0. game.Storage keeps what a player wants back: save slots (a whole world with a label), settings (a typed object of the player’s choices) and plain files. Writes are atomic, a damaged file is refused with a reason instead of a crash, and the same code runs on desktop, Android, iOS and in a browser, where the files go to IndexedDB.
The game
A farm with a day counter and a barn of wheat. Create the game with onegame new farm, delete art/ and everything in scenarios/, and replace Game.cs:
using System.Linq; using OneGame.Core; using OneGame.Host; using OneGame.Storage; using OneGame.UI; namespace Farm; /// <summary>A farm: the day and the wheat in the barn. This is what a save slot keeps.</summary> [Entity] public partial class Barn : Entity { [Synced] public partial int Day { get; private set; } [Synced] public partial int Wheat { get; private set; } [Command] public void Harvest() => Wheat += 3; [Command] public void Sleep() => Day++; } /// <summary>The player’s choices: a plain class plus a schema. No reflection, so it works in NativeAOT and on iOS.</summary> public sealed class Options { public int Volume { get; set; } = 7; public bool Subtitles { get; set; } public static SettingsSchema<Options> Schema { get; } = new SettingsSchema<Options>("options.bin") .Whole("volume", o => o.Volume, (o, v) => o.Volume = v, min: 0, max: 10) .Flag("subtitles", o => o.Subtitles, (o, v) => o.Subtitles = v); } public sealed class FarmGame : GameDefinition { public override string Name => "farm"; public override string Version => "1.0.0"; public override void RegisterEntities(EntityRegistry registry) => OneGame.Generated.Farm.EntityTypes.Register(registry); public override void Setup(World world) => world.Add(new Barn { }); public override void DefineScreens(ScreenSet screens) => screens.Add("main", game => { // Settings load once, here; defaults if the file is missing or damaged. var options = game.Storage.OpenSettings(Options.Schema); return Ui.Column( Ui.Label(() => $"Day {BarnOf(game).Day}, {BarnOf(game).Wheat} wheat").Named("status"), Ui.Row( Ui.Button("Harvest", () => BarnOf(game).Send.Harvest()).Named("harvest"), Ui.Button("Sleep", () => BarnOf(game).Send.Sleep()).Named("sleep")).Gap(Length.Px(8)), Ui.Row( // Save and load between ticks, from the UI: never from sim code. Ui.Button("Save", () => game.Storage.Save("farm", $"Day {BarnOf(game).Day}")).Named("save"), Ui.Button("Load", () => game.Storage.Load("farm")).Named("load"), Ui.Button("Continue", () => game.Storage.LoadLatest()).Named("continue")).Gap(Length.Px(8)), Ui.Button("Louder", () => { options.Value.Volume = options.Value.Volume + 1; options.Save(); }).Named("louder"), Ui.Label(() => $"Volume {options.Value.Volume}").Named("volume"), // A load screen reads slot headers only, newest first, without restoring a world. Ui.Label(() => string.Join(" | ", game.Storage.Slots.List().Select(s => $"{s.Slot}: {s.Label} ({s.Kind})"))).Named("slots")) .Padding(Length.Px(16)).Gap(Length.Px(8)).TextColor(Color.White); }); // Read the world through the instance: a load replaces it. private static Barn BarnOf(GameInstance game) => (Barn)game.World.Entities[0]; }
Storage.Save(slot, label)writes the whole world plus a small header: the label, the kind (Manual,Auto,Quick), the time,GameDefinition.Versionand the tick. It returns a result;Okis false, with aDetail, when the store couldn’t write, and any earlier save in that slot is unchanged.Storage.Load(slot)builds a fresh world and swaps it in only when the save is good, so a damaged slot leaves the running game alone. That is why the screen reads the barn throughgame.Worldevery time.Slots.List()reads the headers for a load screen without restoring anything.QuickSave(),Autosave(label)(rotating overauto-0toauto-2),QuickLoad()andLoadLatest()are the usual buttons.- Settings are a class plus a
SettingsSchema<T>ofWhole,Flag,Number,TextandChoice<TEnum>fields. A field out of range takes its default and is noted; the other fields keep what the player chose. - Save and load from the host or view code, between ticks. Saving reads the clock and does I/O, which sim code can’t.
Test it with a scenario
scenarios/save-and-load.yaml
name: save-and-load description: A saved day comes back after more work, a quick-save and an autosave join the list, and a setting is written. steps: - click: harvest - click: sleep - step: 1 - click: save # slot "farm", labelled "Day 1" - click: harvest - click: harvest - step: 1 - expect: state: { entity: Barn, property: Wheat, equals: 9 } - click: load # back to the saved world - step: 1 - saves: quick-save - saves: autosave - click: louder expect: - state: { entity: Barn, property: Wheat, equals: 3 } - state: { entity: Barn, property: Day, equals: 1 } - saves: { has: farm, slots: 3, slot: { name: farm, label: "Day 1", kind: manual } } - saves: { settings: "volume = 8" } - ui: { id: volume, text: "Volume 8" }
onegame testPASS save-and-load (45 ms, tick 2, hash 45147ffa057ef7bc) 1 passed, 0 failed
The saves: step runs quick-save, quick-load, autosave, load-latest or an { action: ..., slot: ... }, and the saves: expectation checks has, lacks, slots, latest, files, settings and one slot’s header.
Prove a save outlives the process
A headless run keeps its files in memory, so a test never touches a player’s saves. To test a restart, point two runs at one folder with ONEGAME_DATA_DIR (or --data <folder> on the game). The first saves:
scenarios-persist/1-save.yaml
name: save description: Work two days and save. steps: - click: harvest - click: sleep - step: 1 - click: harvest - click: sleep - step: 1 - click: save - click: louder expect: - saves: { has: farm, slots: 1 }
The second starts a new process on day 0 and presses Continue:
scenarios-persist/2-continue.yaml
name: continue description: A new process starts on day 0 and Continue brings back day 2 and the louder volume. steps: - expect: state: { entity: Barn, property: Day, equals: 0 } - click: continue - step: 1 expect: - state: { entity: Barn, property: Day, equals: 2 } - state: { entity: Barn, property: Wheat, equals: 6 } - ui: { id: volume, text: "Volume 8" } - ui: { id: slots, text: "farm: Day 2 (Manual)" }
ONEGAME_DATA_DIR=/tmp/farm-data onegame test --no-unit --scenario scenarios-persist/1-save.yaml ls /tmp/farm-data ONEGAME_DATA_DIR=/tmp/farm-data onegame test --no-unit --scenario scenarios-persist/2-continue.yaml
PASS save (42 ms, tick 2, hash 135d9195de4cd76c) 1 passed, 0 failed options.bin slot-farm.sav PASS continue (40 ms, tick 3, hash b687969514d1fac6) 1 passed, 0 failed
Run the second scenario without the folder and it fails on all four lines: day 0, no wheat, Volume 7 and an empty slot list. The scenarios live in scenarios-persist/, not scenarios/, so a plain onegame test doesn’t run them out of order.
A damaged settings file
The saves tool can plant a file, here three bytes of nonsense as options.bin, and reload the settings. With the MCP client from Drive a running game over MCP, each result cut at 200 characters except the last full listing:
ui_click {"id": "sleep"} -> {"focused":"Button#sleep","hovered":"Label","tick":0} step {"frames": 1} -> {"frame":1,"tick":1,"hash":"7221bee5cc670000"} ui_click {"id": "save"} -> {"focused":"Button#save","hovered":"Label","tick":1} saves {"action": "write-file", "name": "options.bin", "data": "AAEC"} -> {"store":"memory","slots":[{"slot":"farm","label":"Day 1","kind":"manual","savedAt":"2026-10-27T15:40:08.597Z","gameVersion":"1.0.0","tick":1,"worldHash":"7221bee5cc670000","bytes":95}],"files":["opti saves {"action": "settings-load"} -> {"store":"memory","slots":[{"slot":"farm","label":"Day 1","kind":"manual","savedAt":"2026-10-27T15:40:08.597Z","gameVersion":"1.0.0","tick":1,"worldHash":"7221bee5cc670000","bytes":95}],"files":["opti saves {} -> {"store":"memory","slots":[{"slot":"farm","label":"Day 1","kind":"manual","savedAt":"2026-10-27T15:40:08.597Z","gameVersion":"1.0.0","tick":1,"worldHash":"7221bee5cc670000","bytes":95}],"files":["options.bin","slot-farm.sav"],"settings":[{"file":"options.bin","status":"defaulted","reason":"this is not a settings file of this format","notes":[],"text":"volume = 7\nsubtitles = false\n"}],"pending":0,"tick":1,"hash":"7221bee5cc670000","migrated":false} quit {} -> {"frame":1,"tick":1,"hash":"7221bee5cc670000"}
The settings fell back to their defaults with a reason, and the game kept running. In code, settings.LastLoad.Clean is false and LastLoad.Reason says why. The damaged file stays until the next Save() replaces it.
Good to know
- On desktop the files go in a folder named after the game under the user’s local application data (
~/.local/share/farmon Linux, which--persist-dataon a headless run used here). Android and iOS use the same folder inside the app’s storage. - In a browser, saves, settings and files go to IndexedDB, loaded into memory before the program starts. A write is visible at once and committed a moment later. This wasn’t run for this page: the web export needs an engine checkout at 1.5.1 (see Export for the web, Android and iOS).
- A slot written by an older version of the game loads through the entities’
[Migrate]methods. - A save keeps the world, not the UI. Screens and view state are rebuilt after a load.
- Everything read back is checked against limits before anything is allocated: 64 MiB for a slot (
GameDefinition.MaxSaveBytes) and 64 KiB for settings. - For snapshots in tests and live control, see Start tests from fixtures and snapshots.
Every call is in Saves and settings.