SDK / Godot

Saves

StableGodot 4.7+

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

var save: Dictionary = {}

func _ready() -> void:
	Brain.saves.changed.connect(_on_save_changed)
	Brain.saves.too_new.connect(_on_save_too_new)
	Brain.saves.setup({
		schema = 3,                                    # the save format's version
		migrations = {1: _v1_to_v2, 2: _v2_to_v3},     # from-version: func(data) -> data
		progress = func(d: Dictionary) -> int: return d.get("xp", 0),
		merge = _merge,                                # optional: func(mine, theirs) -> merged
		summary = func(d: Dictionary) -> Dictionary: return {level = d.get("level", 1)},
	})
	save = await Brain.saves.load()                  # or load("slot_2")

func _on_level_cleared() -> void:
	save.level = save.get("level", 1) + 1
	Brain.saves.save(save)                           # or save(save, "slot_2", {level = 4})

Call setup once at startup, then load. load answers the newer of the device's and the account's save, migrated up to schema, or {} for an empty slot. Signed out or offline it answers from the device within a few seconds, and a newer save found later arrives as changed.

A save holds only what JSON does: bools, ints, floats, Strings, Arrays and Dictionaries with String keys. Ints come back as ints. Anything else is refused with an error.

Brain.saves.erase(slot) empties a slot. await Brain.saves.list() answers every slot that holds a save, with its summary, progress, schema and updated_at, for a file-select screen; offline, from the device.

The SDK keeps what it writes under user://ase_brain/. A Steam game that uses Auto-Cloud for its own settings excludes that folder in Steamworks' Auto-Cloud settings.

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.

func _v2_to_v3(d: Dictionary) -> Dictionary:
	d.gold = d.get("coins", 0)   # renamed
	d.erase("coins")
	return d

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.

func _merge(mine: Dictionary, theirs: Dictionary) -> Dictionary:
	mine.xp = maxi(mine.get("xp", 0), theirs.get("xp", 0))
	var unlocks: Array = mine.get("unlocks", [])
	for item: String in theirs.get("unlocks", []):
		if item not in unlocks:
			unlocks.append(item)
	mine.unlocks = unlocks
	return mine

Both saves arrive migrated to your current schema.

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:

  • Godot: Brain.saves.changed(save, slot).

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:

  • Godot: Brain.saves.too_new(schema).