SDK / Unreal

Saves

UnstableUnreal 5.8.4+

Brain keeps each player's saves with their account, so their progress follows them to every device and sign-in. A player who reinstalls and signs in with Game Center, Play Games, Steam, Apple or Google gets their progress back, and a guest who links an account keeps theirs.

Turn saves on in brain/game.toml:

[saves]

How saves work

  • A save is a JSON object in a named slot. A game with one save uses the slot main; a game with save files names its own (1 to 16 characters of a-z, 0-9 and _).
  • At most 64 KiB per save, plus a summary of at most 1 KiB that a file-select screen shows.
  • The device first. A save writes the device at once and the account a few seconds later. A burst of saves goes as one. Offline or signed out, the game plays on and the save goes once it can.
  • Your format's version is schema, a number you raise whenever the save's shape changes. Brain never reads your data, but it refuses a write with an older schema than the stored save's, so an old build can't overwrite what a newer one wrote.
  • Nothing is overwritten silently. When two devices save the same slot from the same starting point, they conflict. The save with more progress wins, or your game merges the two itself.
  • Seasons follow your game's stage. Saves made while the game is in playtest don't carry over to launch: players start fresh.

Keep purchases out of saves. A save is the player's own claim, so what they bought comes from Brain (Purchases). The exception is a deliver product, which hands an amount to your save once.

Platform cloud saves

Brain is your game's cloud save. Don't also sync progress through Steam Cloud, iCloud or Play Games Saved Games: two clouds fight over one save, and those services join no account linking, export or deletion.

Set up, load and save

#include "BrainSubsystem.h"

void UMyGameInstance::StartSaves()
{
    UBrainSubsystem* Brain = GetSubsystem<UBrainSubsystem>();
    Brain->OnSaveLoaded.AddDynamic(this, &UMyGameInstance::HandleSaveLoaded);
    Brain->OnSaveChanged.AddDynamic(this, &UMyGameInstance::HandleSaveChanged);
    Brain->OnSaveRefused.AddDynamic(this, &UMyGameInstance::HandleSaveRefused);
    Brain->SaveSetup(3, /*bMerges=*/ false);
    Brain->SaveLoad(TEXT("main"));
}

// UFUNCTION()
void UMyGameInstance::HandleSaveLoaded(const FString& Slot, EBrainSaveResult Result, int32 Schema,
    const FString& DataJson, const FString& MigrationTicket)
{
    if (Result != EBrainSaveResult::Ok) return;
    if (!MigrationTicket.IsEmpty())
    {
        const FString Migrated = MigrateSave(Schema, DataJson);
        GetSubsystem<UBrainSubsystem>()->CompleteSaveMigration(MigrationTicket, Migrated);
        ApplySave(Migrated);
        return;
    }
    ApplySave(DataJson);
}

void UMyGameInstance::SaveGame()
{
    GetSubsystem<UBrainSubsystem>()->SaveWrite(TEXT("main"), SaveJson(), TEXT("{\"level\":4}"), Progress);
}

Saves cross as JSON object text, in C++ and Blueprint alike, and full-width integers pass through unchanged. Every call returns an EBrainSaveResult at once; events run on the game thread after the call that caused them returns.

Call or event Does
SaveSetup(Schema, bMerges) Your format's version, and whether your game merges conflicts itself
SaveLoad(Slot) then OnSaveLoaded The slot's save; MigrationTicket is set when it's from an older schema
CompleteSaveMigration(Ticket, DataJson) Hands back a save you migrated, once per ticket
SaveWrite(Slot, DataJson, SummaryJson, Progress) Saves a slot; an empty summary keeps the device's
SaveErase(Slot) Empties a slot
SaveList() then OnSavesListed The slots that hold a save, as JSON
SaveFlush() Sends waiting saves now: call it when the app pauses or quits
OnSaveChanged A slot you loaded was replaced: another device's save, a merge or an erase
OnSaveRefused Brain refused a save for good, or the device couldn't keep it

When the device's save is from a newer version of your game, OnSaveLoaded answers with an empty DataJson and that version's Schema. Ask the player to update; until then the slot isn't overwritten.

Migrations

Add one step per version, from each old version to the next. Steps run in order on every older save, so never delete one: a player may come back after many versions. A missing step is an error, and the save stays as it is.

Migrate DataJson from Schema in your own code, then call CompleteSaveMigration with the MigrationTicket from OnSaveLoaded. A ticket works once, and only while the device's copy is still the one it handed out.

Conflicts and merges

Two devices that saved the same slot from the same revision conflict. Without a merge, the save with more progress wins, and on a tie the newer one. Give a progress whenever saves can conflict, and merge yourself when losing either side's progress would hurt: take the max of each counter, the union of unlocks.

When two accounts merge (a guest links an account that already has saves), your merge also folds in the save the other account had.

Call SaveSetup(Schema, true) and handle OnSaveMergeRequested(Ticket, Slot, MineJson, TheirsJson):

// UFUNCTION()
void UMyGameInstance::HandleSaveMerge(const FString& Ticket, const FString& Slot,
    const FString& MineJson, const FString& TheirsJson)
{
    UBrainSubsystem* Brain = GetSubsystem<UBrainSubsystem>();
    FString Merged;
    if (MergeSaves(MineJson, TheirsJson, Merged))
        Brain->CompleteSaveMerge(Ticket, Merged, FString(), ProgressOf(Merged));
    else
        Brain->FailSaveMerge(Ticket);
}

Either save may need your migrations first. A failed merge keeps the device's copy and holds the slot for the rest of the run.

Saves that change under you

Another device's newer save, a merge, or an emptied slot (erased elsewhere, a new season, the account deleted) replaces your game's copy. Use the new one:

  • Unreal: OnSaveChanged(Slot, DataJson).

When the player's save is from a newer version of your game, ask them to update. Nothing of that save is sent or overwritten until then:

  • Unreal: OnSaveLoaded with an empty DataJson.