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.
What you’ll see in action
Section titled “What you’ll see in action”| Flow | Keys | What it exercises |
|---|---|---|
| Normal save & load | 1/2/3 select slot, E edit, S save, L load | save_game(payload, cb) + load_game(cb) |
| Version conflict | C to force, A/K to resolve | 409 response with server_version + server_data |
| Data-loss recovery | W to wipe local, then auto-load | clear_cache() + load_game() |
Run it
Section titled “Run it”docker compose up -ddocker exec -i quest-data-db psql -U quest -d quest_data < backend/sql/seed.sql# Open examples/save-load-demo/project.godot, F5Press 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.
Code tour
Section titled “Code tour”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-208func _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.
Save with callback
Section titled “Save with callback”# game.gd:211-218func _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-233func _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.
The conflict path — pause and ask
Section titled “The conflict path — pause and ask”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-247else: 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-159func _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 conflictAccept server — overwrite local
Section titled “Accept server — overwrite local”# game.gd:314-331func _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)Keep local — bump version, retry
Section titled “Keep local — bump version, retry”# game.gd:334-343func _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 versionThe 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.”
Data-loss recovery (W key)
Section titled “Data-loss recovery (W key)”# game.gd:295-311func _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.
Dashboard walkthrough
Section titled “Dashboard walkthrough”| Page | What to look at |
|---|---|
| Cloud Saves → your player_id | Latest version, payload preview, conflict timeline |
| Custom Events → save_attempted / save_succeeded / save_conflict | Funnel — how often saves fail and how |
| Achievements | first_save, five_saves, conflict_resolved, all_slots_used, backup_hero |
| Logs | The _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.
Try it yourself
Section titled “Try it yourself”- Test the conflict on two devices. Open two Godot windows pointing at the same player_id (set
player_idexplicitly inproject.godot). Save in window A. Edit and save in window B. Now save again in window A — you should hit the conflict UI. - 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"}). - Throttle saves. The default cadence (one per
Spress) is fine for a demo but excessive in a real game. Add a 5-second debounce + a “save pending” indicator. Verifysave_conflictrate drops in the dashboard.
Next Steps
Section titled “Next Steps”- Cloud Saves SDK Reference — full versioning model + raw HTTP semantics
- Boundary: SDK ↔ Game — when to put debounce/throttle in the SDK vs the game