> For the complete documentation index, see [llms.txt](https://docs.allout.game/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.allout.game/data-and-persistence/save.md).

# Save System

The save system provides a key-value data storage system for persisting player progress and preferences across sessions.

The Save system is a simple **key-value store**. You give it a string key (like `"xp"`), and it stores a value that will still be there the next time the player joins.

There are two kinds of save data:

* **Player save**: one player's personal data (XP, upgrades, settings, inventory, etc)
* **Game-wide save**: shared by everyone in the game (world records, server settings, global counters, etc)

{% hint style="warning" %}
Do not use the save system to store purchases players made with sparks. Use the [Purchasing/Product APIs](/core-engine-concepts/purchasing-product-apis.md) instead.
{% endhint %}

### Quick start (save + load a stat)

The most common pattern is:

* Load saved values in `ao_start`
* Save again whenever the value changes

```go
Player :: class : Player_Base {
    xp: s64;
    level: s64;

    ao_start :: method() {
        // Defaults are used for brand new players
        xp    = Save.get_int(this, "xp", 0);
        level = Save.get_int(this, "level", 1);
    }
}

add_xp :: proc(player: Player, amount: s64) {
    player.xp += amount;

    // ... your level-up logic here ...

    // Save immediately when it changes
    Save.set_int(player, "xp", player.xp);
    Save.set_int(player, "level", player.level);
}
```

### Player save (per-player data)

Player save is scoped to a single player. Every player has their own isolated key/value data.

Supported types:

* **String**: `Save.set_string` / `Save.get_string`
* **Integer (`s64`)**: `Save.set_int` / `Save.get_int`
* **Float (`f64`)**: `Save.set_f64` / `Save.get_f64`
  * For ordinary CSL `float` (`f32`) values, `Save.set_float` / `Save.get_float` are convenience aliases that use the same f64 storage and cast at the API boundary.
* **Inventory item reference**: `Save.save_item_reference` / `Save.get_item_reference`
* **JSON (advanced)**: `Save.set_json` / `Save.try_get_json`

```go
// Preferences
Save.set_string(player, "selected_skin", "knight");
music_volume := Save.get_f64(player, "music_volume", 0.8);

// Deleting a key (useful when migrating/removing old data)
Save.delete_key(player, "old_key_name");
```

{% hint style="info" %}
Always provide a sensible default to scalar getters. New players won't have keys yet; scalar `get_*` calls return that default, while `get_item_reference` returns `null`.
{% endhint %}

### Saving an equipped item

Use an item reference to remember a selection from any of the player's persistent inventories without copying item ownership or properties into Save:

```go
Save.save_item_reference(player, "equipped_weapon", weapon);

weapon := Save.get_item_reference(player, "equipped_weapon");
if weapon != null {
    equip_weapon(player, weapon);
}

Save.save_item_reference(player, "equipped_weapon", null); // Clear the selection
```

`Save.save_item_reference` accepts items in the player's auto-saved `default_inventory` or any inventory created with `Items.create_player_inventory`. Named inventories qualify even when default inventory auto-save is disabled. References survive slot rearrangement within that inventory and rejoining. Removing, dropping, destroying, or transferring an item to any other inventory invalidates its old references, even for the same player or if the item later returns. Save it again to select it in its new inventory. A stack fully consumed by a merge also becomes `null`; references to the surviving stack remain valid.

Create all named inventories in `Player.ao_start` before resolving saved references or JSON records. An inventory that has not been created cannot resolve its items yet; getters return `null` without creating it. Existing saved references remain readable.

### Saving “bigger” data (JSON)

If you have a little bundle of fields (like progress + unlocked things), it's often nicer to save it as one JSON blob.

Only fields marked with `@ao_serialize` are saved.

```go
Player_Progress :: class {
    @ao_serialize version: s64 = 1;
    @ao_serialize max_health: s64 = 100;
    @ao_serialize unlocked_skins: [..]string;
    @ao_serialize equipped_weapon: Item_Instance;
}

save_progress :: proc(player: Player, progress: Player_Progress) {
    Save.set_json(player, "progress", ref progress);
}

load_progress :: proc(player: Player) -> Player_Progress {
    progress := new(Player_Progress);
    if !Save.try_get_json(player, "progress", ref progress) {
        // Missing key (or parse failed) already has the class defaults.
    }
    return progress;
}
```

{% hint style="info" %}
`Save.try_get_json` returns `false` if the key doesn't exist or the JSON string is malformed. Allocate class data before loading because the false path does not allocate it. Missing fields keep their class defaults, and unknown fields are ignored.
{% endhint %}

`Item_Instance` fields (including derived item types) use the same inventory references as `Save.save_item_reference`. Every non-null item must belong to one of that player's persistent inventories. Transferring it to any other inventory invalidates the reference, even for the same player. A missing or invalidated item, or one that no longer matches the field's derived type, becomes `null`. Create named inventories before loading the record. The inventory remains authoritative for ownership and item data.

### Save versioning (migrating old data safely)

If you ever change your save format, keep a `version` key and migrate older saves forward.

```go
Player :: class : Player_Base {
    hp: f64;

    ao_start :: method() {
        save_version := Save.get_int(this, "version", 0);

        if save_version < 6 {
            // Example migration: hp used to be an int, now it's a float
            save_version = 6;
            old_hp := Save.get_int(this, "hp", 100);
            Save.delete_key(this, "hp");
            Save.set_f64(this, "hp", old_hp.(f64));
        }

        Save.set_int(this, "version", save_version);

        // Load current format
        hp = Save.get_f64(this, "hp", 100);
    }
}
```

### Game-wide save (shared by everyone)

Game-wide save is shared across the whole game, not per-player.

```go
// Global record holder
Save.set_game_string("world_record_holder", player.get_username());
holder := Save.get_game_string("world_record_holder", "nobody yet");

// Global counters (atomic increment, safe when many players update it)
Save.increment_game_int("total_games_played", 1);
total := Save.get_game_int("total_games_played", 0);

// Delete either kind of game-wide value
Save.delete_game_key("retired_event");
```

{% hint style="info" %}
Use `Save.increment_game_int` for counters that multiple players might update at the same time (kills, joins, rounds played, etc). Its `optimistic_update` parameter defaults to `true`, so the local cached value changes immediately.

`Save.delete_game_key` queues and deduplicates deletions with the normal game-save flush. A later `set_game_string` for the same key cancels the queued delete. A later `increment_game_int` keeps the delete, resets the stored counter to zero, and then applies the increment.
{% endhint %}

### Common patterns

#### Booleans

Save booleans as `0/1`:

```go
// Save
Save.set_int(player, "tutorial_complete", tutorial_complete ? 1 : 0);

// Load
tutorial_complete = Save.get_int(player, "tutorial_complete", 0) != 0;
```

#### Key naming

Keys are just strings, so pick names that won't collide later:

* `"xp"`, `"level"`, `"selected_skin"`
* `"tycoon.cash"`, `"tycoon.upgrades.mouth_level"`

If you have multiple games connected via game parenting (hub + minigames), see [Cross-Game Products/Data](/data-and-persistence/cross-game-products-data.md) for how save data can be shared.

### Additional APIs

```go
Save :: struct {
    // Persistent selections across the player's persistent inventories
    save_item_reference :: proc(player: Player, key: string, item: Item_Instance);
    get_item_reference :: proc(player: Player, key: string) -> Item_Instance;

    // Delete keys
    delete_key      :: proc(player: Player, key: string);
    delete_all_keys :: proc(player: Player);

    // Enumerate keys
    get_all_keys :: proc(player: Player) -> []string;

    // Game-wide
    delete_game_key      :: proc(key: string);
    get_all_game_strings :: proc() -> []Save_Game_String;
    get_all_game_ints    :: proc() -> []Save_Game_Int;
    get_all_game_keys    :: proc() -> []Save_Game_Key;

    // Ordered/ranked data (leaderboards)
    ordered_set     :: proc(document: string, key: string, value: f64);
    ordered_get     :: proc(document: string, key: string, default: f64,
                            userdata: Object,
                            callback: proc(entry: Ordered_Save_Entry, userdata: Object));
    ordered_get_all :: proc(document: string, offset: s64, limit: s64,
                            userdata: Object,
                            callback: proc(entries: []Ordered_Save_Entry, userdata: Object));
}
```

`ordered_get` and `ordered_get_all` are asynchronous and execute on the server. Call them from the normal shared gameplay path; do not add a `Game.is_server()` guard.

Callback arrays are valid only during the callback, so copy any entries you need to keep:

```go
Ranking_State :: class : Component {
    entries: [..]Ordered_Save_Entry;

    refresh :: method() {
        Save.ordered_get_all(
            "weekly_score",
            0,
            100,
            this,
            proc(results: []Ordered_Save_Entry, userdata: Object) {
                state := userdata.(Ranking_State);
                state.entries.clear();
                for result: results {
                    state.entries.append(result);
                }
            }
        );
    }
}
```

Each completed `ordered_get` request invokes its callback once. A missing entry or request failure returns the supplied default. Each completed `ordered_get_all` request also invokes its callback once; an empty result or request failure supplies an empty array. Request failures are logged.
