---
title: "Save slots and settings"
url: https://recursiveai.org/onegame/cookbook/save-slots-and-settings/
description: "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…"
version: "1.5.1"
---

# Save slots and settings

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`:

```csharp
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.Version` and the tick. It returns a result; `Ok` is false, with a `Detail`, 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 through `game.World` every time.
- **`Slots.List()`** reads the headers for a load screen without restoring anything. `QuickSave()`, `Autosave(label)` (rotating over `auto-0` to `auto-2`), `QuickLoad()` and `LoadLatest()` are the usual buttons.
- **Settings** are a class plus a `SettingsSchema<T>` of `Whole`, `Flag`, `Number`, `Text` and `Choice<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`**

```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" }
```

```shell
onegame test
```

```text
PASS 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`**

```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`**

```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)" }
```

```shell
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
```

```text
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](https://recursiveai.org/onegame/cookbook/live-control-mcp/), each result cut at 200 characters except the last full listing:

```text
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/farm` on Linux, which `--persist-data` on 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](https://recursiveai.org/onegame/cookbook/export-web-android-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](https://recursiveai.org/onegame/cookbook/fixtures-and-snapshots/).

Every call is in [Saves and settings](https://recursiveai.org/onegame/docs/saves/).
