Skip to content

Dev Log: 2026-09-12

A recurring "my progress just vanished" report on itch.io turned into a save-recovery fix, an opt-in cloud save for the web build, a matching cloud save for Android via Google Play, and a leaderboard migration that had been overdue for a while.


"Progress lost, new game started"

This one has come up a few times now, always vague in the same way: sometimes after uploading a new build, sometimes not, no clear trigger, no clean repro. A player opens the game on itch.io and it just starts fresh, as if they had never played.

The honest first answer is that web save data lives in IndexedDB, and browsers are allowed to throw that away. Safari's Intelligent Tracking Prevention purges storage for origins with no first-party interaction in about a week, and itch embeds games in an iframe, which historically gets treated as a third-party context and evicted more aggressively than a normal site. Chrome does something similar under storage pressure. None of that is a bug I can fix from inside the game. It is a policy decision made several layers above me.

But "browsers sometimes clear storage" doesn't fully explain what I was seeing, so I went looking at the save code itself instead of stopping at the shrug.

The game already had a real save-recovery path from an earlier pass: every save_game() writes to a temp file, verifies the write by reading it back, and only then promotes it to savegame.json and refreshes a savegame.json.bak copy. load_game() is supposed to fall back to that backup if the primary is corrupt. Here's the actual gate it used:

var local_existed := FileAccess.file_exists("user://savegame.json")
if local_existed:
    ... load and validate primary ...
if local_data == null and local_existed:   # only tries backup if primary EXISTED
    local_data = _try_load_backup()

Read that condition again. The backup is only ever consulted when the primary file exists but fails to validate. If the primary is simply absent, which is exactly the shape of an async IndexedDB write landing inconsistently, one file syncing and not the other, the backup never gets checked, even though it might be sitting right there with a perfectly good save in it. load_game() returns false, and the boot code treats that as "no save, start fresh." Silently. With a working backup one if away.

The fix is one line:

if local_data == null:
    local_data = _try_load_backup()

Small, but it directly addresses the "sometimes it just happens" shape of the reports: it doesn't require the primary to be broken, only missing, which is the more likely failure mode for an async storage write that didn't fully land.


Cloud save, opt-in, web only

The backup-file fix helps with local inconsistency, but it can't survive the browser deciding to evict the whole origin. Both savegame.json and its .bak live in the same IndexedDB store; if that gets purged, they go together. The only real defense against that is getting a copy of the save somewhere that isn't the player's own browser storage.

So: an opt-in cloud save, for web builds only. Steam already has real cloud saves through Steamworks, and reddit-lite never persists a local save to begin with, so this is specifically for the itch.io audience, which is also the one actually exposed to the failure mode above.

The design constraint I wanted was no account system. A username-and-password flow is a lot of infrastructure for "please don't lose my save," and it adds a login wall to a feature meant to be a safety net, not a chore. Instead: the first time a player reaches the world map after clearing the first stage, they get offered cloud save. If they accept, the server hands back a one-time, server-generated recovery phrase, a chunk of a few dozen bits of real randomness, formatted as dash-grouped groups so it's copyable and not painful to read. That phrase is the entire authentication model. No account, no password to forget, just a string the player has to save somewhere themselves. From then on, every local save also pushes quietly to the server under that phrase, and if the local copy is ever gone, pasting the phrase back into Options restores it, after a confirm dialog that shows the timestamp of what it's about to overwrite.

The server side reuses the small self-hosted leaderboard service rather than standing up something new: same PHP file, same SQLite database, two new tables. The token itself only ever gets stored as a hash, never in plaintext, so even a full database dump doesn't hand anyone a working credential.

Deploying it produced its own small lesson. The leaderboard's config.php has a database path constant, and a while back I'd pointed the live deployment at a randomized filename as a stopgap, directly on the server, without changing the committed default. Pushing config.php from the repo to deploy the new cloud-save tables quietly reverted that override back to the plain default path. Nothing errored. The site just started reading and writing a brand new, empty database, and every existing score and cloud save became invisible until I noticed the board was suddenly clean. Caught within minutes, one stray test row lost, nothing real. But it's exactly the kind of mistake that's silent by nature, so the deploy instructions now say, in bold, to diff the live config before pushing it.

One more thing surfaced almost immediately after shipping it: pasting the recovery phrase back in didn't work on itch.io at all. Its iframe embed blocks the Permissions Policy that Godot's own clipboard bridge depends on, so DisplayServer.clipboard_set()/clipboard_get() just silently do nothing there, not an error, not a fallback, nothing. The fix is a small native-input helper that shows the text in a plain HTML <input> the browser itself can copy from and paste into, bypassing Godot's bridge entirely for that one interaction. Both the phrase display and the restore field on web route through it now.


Cloud save, now on Android too

The web version above only ever solved half the problem, because it was deliberately scoped to the platform actually exposed to browser storage eviction. Android's user:// save is regular app storage, not IndexedDB, so it doesn't have that specific failure mode, but "my phone died" or "I switched phones" are the same shape of problem from the player's side, and Android had a real answer sitting unused the whole time.

The GodotPlayGameServices addon, already in the project for Play Games achievements, ships a complete Saved Games client, PlayGamesSnapshotsClient, that nothing in the codebase had ever touched. Wiring it up turned out to be less about the Play Games API itself and more about the shape mismatch with how the game already does cloud saves. Steam's cloud save calls are synchronous: call it, get a string back. Saved Games is not. save_game()/load_game() fire-and-forget, and the actual result shows up later on a signal, game_saved, game_loaded, conflict_emitted. Meanwhile GameManager.load_game() runs synchronously at boot, and turning that into something that awaits a network round-trip felt like the wrong yak to shave for this feature. So it mirrors the web design instead: pushing to the cloud happens automatically and silently on every local save, restoring is a deliberate action from Options that can afford to await a signal because a person is sitting there waiting for it anyway.

func load_from_cloud() -> Dictionary:
    ...
    _snapshots.game_loaded.connect(conn, CONNECT_ONE_SHOT)
    _snapshots.load_game(SAVE_FILE_NAME, false)

    var elapsed := 0.0
    while not done and elapsed < LOAD_TIMEOUT_SEC:
        await get_tree().process_frame
        elapsed += get_process_delta_time()

No phrase to manage on this side, which is the one real advantage over the web version: signing in to Google Play already ties the save to an identity, so there's nothing for the player to write down or lose. There's also no account-creation wall to speak of, since most Android players are already signed in to Google Play in the background.

The one open question is conflict resolution: if the same Google account saves from two devices independently, the SDK reports a conflict rather than picking a winner for you, and the addon has no bundled documentation and no local copy of its Kotlin source to confirm the exact resolution contract. The resolution logic picks whichever side has the newer embedded save timestamp and re-saves it, matching Google's documented pattern for handling a SnapshotConflict, but that path is genuinely untested against the real two-device case and is flagged as such until it gets a proper pass on hardware.


Leaderboards, on our own server

While in that part of the codebase: the Challenge Mode leaderboard had been running on a third-party service, and that service had quietly died. Custom scoreboards there were fan-out only, no per-board targeting, which meant chapter and stage had to be packed into the score number itself and unpacked on read. Nicknames were capped at twelve characters with silent collisions. It worked, in the sense that it had once worked, but it had accumulated enough workarounds that when the host went away it wasn't worth chasing.

The replacement is the same small self-hosted PHP-and-SQLite service the cloud save now shares. Real per-chapter, per-stage, per-mode boards this time, identity by a UUID the game already generates rather than by display name, and none of the score-packing arithmetic. Existing scores got migrated across rather than dropped, and a one-time save migration re-submits any score a returning player already had locally but that had never made it to the new backend.

One side effect worth a mention: the world map's rank badges used to be drawn once, from whatever was cached the moment the map was built, and never updated again for the rest of that session. A player who stayed on the map for a while would watch a rank slowly go stale as other people played. It now polls in the background and updates the existing badges in place, without rebuilding the map (which would have torn down anything the player had open on top of it, like the leaderboard preview modal).


Challenge Mode: one action, not two

A separate report, this one about a specific target score looking unreachable on a simple early stage, turned into a small parity bug between the live game and the Python solver that generates those targets.

The solver's action model lets a single move carry an attack along with it: walk five tiles, hit the drone you were walking toward, one action. The live game never did that. Every code path, including the one that auto-paths a unit toward a drone clicked from outside its current range, always recorded the walk and the attack as two separate actions. So a target computed against the solver's "walk-and-hit is one action" accounting was quietly harder to reach than intended in the actual game, exactly because it was measuring a maneuver the live game charged double for.

The fix keeps track of which unit's move is still "open," meaning nothing else has happened since:

func _record_attack_action(attacker: Unit, target: Drone) -> void:
    _action_log.record_attack_action(attacker, target)
    if GameManager.challenge_mode or GameManager.reddit_checkpoint_seeding:
        var attacker_id := _unit_action_id(attacker)
        if _challenge_pending_move_unit_id.is_empty() \
                or _challenge_pending_move_unit_id != attacker_id:
            _challenge_action_count += 1
        _challenge_pending_move_unit_id = ""

If the same unit that just moved is the one attacking next, with no other unit's action or an undo in between, it merges into one action, matching the solver's own accounting. A standalone attack from a unit that was already in range still costs its own action, same as before. The target scores didn't need to change at all, since the solver's own reference score is self-referential (it always scores against its own action count), just the live game's bookkeeping needed to stop overcharging for a maneuver the target was never penalizing in the first place.


Bug fixes

  • Restoring a save could silently discard itself. Both the Save Transfer import code and the new cloud-save restore flow reload the current scene right after applying the restored save, and the boot code that runs after that reload checks a save_loaded_successfully flag to decide whether to show the restored progress or fall back to a fresh game. _apply_save_dict(), the shared function both flows call to actually apply the restored state, never set that flag itself, only the normal disk-load path did. So a restore would apply correctly in memory, then get quietly overwritten by "no save, start fresh" the moment the reload's boot check ran. Caught while building the Android restore flow above, which goes through the exact same function. Fixed by making _apply_save_dict() responsible for its own flag, since every caller is already applying a save it has validated.
  • Web supporter gate could be bypassed by the post-credits auto-advance. A gating check on chapter progression only looked at the Android purchase flag, not the web supporter key, so finishing Chapter 1 on a gated web build could walk straight into Chapter 2 without the key ever being checked. Android was never affected. Fixed with a matching gate check and a self-healing clamp for saves that had already advanced past it.
  • Build script silently skipped Android debug symbols. The Gradle output folder naming changed to include a product flavor prefix a while back (standardRelease instead of a bare Release), and the path glob that finds the native library folder for packaging never matched it, so every AAB build quietly shipped without a symbols zip. Also fixed an adb daemon warning printing on every single build regardless of platform, and a filename bug in the Play Console verification helper that only showed up once the symbols path was actually being found.

Miscellaneous

  • Added regression tests for the missing-primary backup recovery, the cloud save opt-in/offer-once logic, the save-restore flag fix, the Android conflict-resolution/queue logic (_pick_newer_snapshot, the pending-save queue while sign-in is still in flight), and the move-then-attack action merge (with cases for standalone attacks, cross-unit non-merging, and the undo/reset boundary).
  • Server-side PHP test suite: 26 passing (up from 19, covering the new cloud-save endpoints).
  • GDScript test suite and Python solver test suite both green; nothing in combat, movement, drones, or progression math changed, so the solver mirror needed no updates beyond the action-count fix above.
  • Build 0.1.8.101 goes out to Steam, Google Play, and itch.io.

Four threads by the time this posted, and all of them trace back to the same root: something quietly stopped working (a third-party leaderboard, a backup file nobody checked, an action that cost double what it should, a flag one function forgot to set) and nobody noticed until a player did, or until building the next platform's version of a feature walked straight into the last one's bug. The fix in each case turned out to be small once found. Finding it was the actual work.