Recursive AI

Save slots and settings

All onegame recipes

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:

C#
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" }
Terminal
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)" }
Terminal
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, 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).
  • 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.