Skip to content

Save / Load Demo Walkthrough

This walkthrough covers the Save / Load Demo. Unlike the other tutorials it isn’t a “real” game — it’s a focused harness for the three flows every shipping game needs: normal save & load, version conflicts, and recovery from a data wipe.

If you’re building anything with persistent player progress, read this one carefully. The conflict-resolution UX is the part most teams get wrong on their first try.

FlowKeysWhat it exercises
Normal save & load1/2/3 select slot, E edit, S save, L loadsave_game(payload, cb) + load_game(cb)
Version conflictC to force, A/K to resolve409 response with server_version + server_data
Data-loss recoveryW to wipe local, then auto-loadclear_cache() + load_game()
Terminal window
docker compose up -d
docker exec -i quest-data-db psql -U quest -d quest_data < backend/sql/seed.sql
# Open examples/save-load-demo/project.godot, F5

Press 1/2/3 to pick a slot, E to edit it (level up + gold + new inventory item), S to save. Then read on for the conflict + recovery flows.

Multi-slot payload — one save, three slots

Section titled “Multi-slot payload — one save, three slots”

The demo packs three independent character slots into a single save_game call:

# game.gd:203-208
func _pack_payload() -> Dictionary:
return {
"slots": _slots, # Array[Dictionary] of length 3
"active": _active_slot, # which slot is currently selected
"save_count": _save_count,
}

This is the recommended shape for slot-based games. Don’t make three separate cloud-save documents — they’d race and conflict separately. One document, three slot keys, atomic versioning.

# game.gd:211-218
func _save_to_cloud() -> void:
var payload: Dictionary = _pack_payload()
QuestData.track("save_attempted", {"slot_count": _filled_slot_count()})
QuestData.save_game(payload, _on_save_complete)

save_game(payload, callback) is async — you get a Dictionary back with success, version, and (on conflict) conflict: true + server_version + server_data.

The success path — version + achievements

Section titled “The success path — version + achievements”
# game.gd:221-233
func _on_save_complete(response: Dictionary) -> void:
if bool(response.get("success", false)):
_save_version = int(response.get("version", _save_version + 1))
_save_count += 1
QuestData.track("save_succeeded", {"version": _save_version, "saves_total": _save_count})
if _save_count == 1:
QuestData.unlock_achievement("first_save")
if _save_count == 5:
QuestData.unlock_achievement("five_saves")
if _filled_slot_count() == SLOT_COUNT:
QuestData.unlock_achievement("all_slots_used")

The key thing: trust the server’s version, don’t compute it locally. The server is authoritative.

This is the bit most teams botch. When two devices save in parallel, the second one gets a 409 with the current server state. The demo does not auto-resolve — it pauses, shows both versions, and asks the player which to keep:

# game.gd:235-247
else:
if bool(response.get("conflict", false)):
_conflict_pending = {
"server_version": response.get("server_version", 0),
"server_data": response.get("server_data", {}),
"local_version": _save_version,
"local_data": _pack_payload(),
}
QuestData.track("save_conflict", {
"server_version": _conflict_pending["server_version"],
"local_version": _conflict_pending["local_version"],
})

While _conflict_pending is non-empty, normal input is blocked — only A (accept server) and K (keep local) work:

# game.gd:152-159
func _input(event: InputEvent) -> void:
if not _conflict_pending.is_empty():
if event.is_action_pressed("accept_server"):
_resolve_conflict_accept_server()
return
if event.is_action_pressed("keep_local"):
_resolve_conflict_keep_local()
return
return # block everything else during conflict
# game.gd:314-331
func _resolve_conflict_accept_server() -> void:
var server_data: Dictionary = _conflict_pending.get("server_data", {})
var server_version: int = int(_conflict_pending.get("server_version", 0))
# Overwrite local slots from server data
var raw_slots: Array = server_data.get("slots", [])
for i in range(SLOT_COUNT):
var entry: Variant = raw_slots[i]
_slots[i] = (entry as Dictionary) if entry is Dictionary else {}
_save_version = server_version
_conflict_pending = {}
QuestData.track("save_conflict_resolved", {"resolution": "server", "version": server_version})
QuestData.unlock_achievement("conflict_resolved")
QuestData.get("_cloud_saves").set_version(_save_version)
# game.gd:334-343
func _resolve_conflict_keep_local() -> void:
var server_version: int = int(_conflict_pending.get("server_version", 0))
_save_version = server_version # adopt server's version number
_conflict_pending = {}
QuestData.track("save_conflict_resolved", {"resolution": "local", "base_version": server_version})
QuestData.unlock_achievement("conflict_resolved")
QuestData.get("_cloud_saves").set_version(_save_version)
_save_to_cloud() # retry — now using server's version

The trick on “keep local”: you can’t just retry — the next save would 409 again. Adopt the server version first, then retry. Conceptually, “I see your version, I’m overwriting it intentionally.”

# game.gd:295-311
func _simulate_wipe() -> void:
for i in range(SLOT_COUNT):
_slots[i] = {}
_save_version = 0
QuestData.track("data_wipe_simulated", {})
QuestData.get("_cloud_saves").clear_cache() # invalidate local SDK cache
await get_tree().create_timer(0.3).timeout
QuestData.load_game(func(data: Dictionary, version: int) -> void:
_on_load_complete(data, version)
if version > 0 and _filled_slot_count() > 0:
QuestData.unlock_achievement("backup_hero")
)

This proves the cloud save is a real backup. clear_cache() forces the next load_game to bypass any locally cached payload and refetch from the server — exactly the path a player on a new device or after a userdata:// reset would take.

PageWhat to look at
Cloud Saves → your player_idLatest version, payload preview, conflict timeline
Custom Events → save_attempted / save_succeeded / save_conflictFunnel — how often saves fail and how
Achievementsfirst_save, five_saves, conflict_resolved, all_slots_used, backup_hero
LogsThe _log() lines from the demo are mirrored to the dashboard

A useful funnel to build: save_attempted → (save_succeeded ∪ save_conflict). If save_conflict is more than ~1% of attempts, your save cadence is too aggressive.

  1. Test the conflict on two devices. Open two Godot windows pointing at the same player_id (set player_id explicitly in project.godot). Save in window A. Edit and save in window B. Now save again in window A — you should hit the conflict UI.
  2. Add a “merge” resolution. Right now the demo offers accept-server or keep-local. Build a third option that takes the highest-level slot from each side and merges them. Track resolution choice as track("save_conflict_resolved", {"resolution": "merge"}).
  3. Throttle saves. The default cadence (one per S press) is fine for a demo but excessive in a real game. Add a 5-second debounce + a “save pending” indicator. Verify save_conflict rate drops in the dashboard.