Saves
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 ofa-z,0-9and_). - 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
progresswins, 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:
OnSaveLoadedwith an emptyDataJson.