Skip to content

Progress reset — design

Brainstormed with Chris 2026-10-04. Status: spec, awaiting review. Next step: implementation plan (writing-plans).

The App Store description will say the game is in active development and that some updates may need to reset progress (wording below; “beta” is avoided because of App Review Guideline 2.2). So we need a reset that is real, announced and recoverable, instead of a silent one.

What happens today [verified in code]: MetaState.SCHEMA_VERSION / MIN_COMPAT_SCHEMA (sim/meta_state.gd:14-18) were built as the reset lever. Raise them and from_dict() returns a fresh state, and MetaStore.last_load_compatible goes false. Boot-time writes respect that flag, but MetaStore.save_state() (meta/meta_store.gd:220) does not check it, and main.gd calls it at run end and on shop purchases (main.gd:631/647/772/1685/2698). So the first run after the update silently overwrites the old save, with no notice and no backup. The same happens to a corrupt save and to a save from a newer schema.

  1. Each release picks a reset kind: required (this build can’t read the old save) or recommended (it can; the player chooses).
  2. Wipe scope = progress only. Cleared: banked_gold, gold_spent, levels, selected_ship, drone_loadout, seen_enemies. Kept: tutorial_done, seen_survival_intro, shop_tip_views, install_id, share_diagnostics. Returning players aren’t sent back through the tutorial, and their Diagnostics choice and anonymous id survive.
  3. Manual reset in the pause menu, available at any time.
  4. Reset offers are bundled in the build, never delivered by remote config. A wipe only ever arrives with a reviewed store build.

Required. Raise SCHEMA_VERSION and MIN_COMPAT_SCHEMA together (the existing lever). A save with schema < MIN_COMPAT_SCHEMA gets the required notice at boot (§4). On OK: back up, wipe (§2), save. Nothing is written to disk before the OK.

Recommended. meta/save_reset.gd (new, class_name SaveReset) holds:

  • const RESET_EPOCH := 0: the build’s epoch, bumped by a release that recommends a reset.
  • const RESET_NOTICE := "": why this release recommends it, shown in the offer.

MetaState gains reset_epoch: int. The two defaults are deliberately different:

  • MetaState.new() defaults to SaveReset.RESET_EPOCH, so a fresh install is never offered a reset.
  • from_dict() defaults a missing key to 0, so any save from before this feature counts as epoch 0.

At boot, if meta.reset_epoch < SaveReset.RESET_EPOCH, the recommended offer appears with two choices, Reset now and Keep my progress. Either choice sets reset_epoch = RESET_EPOCH and saves, so the offer is asked once only. Reset now also runs the wipe (§2).

Other unreadable saves (handled by the same notice, different copy):

  • Corrupt / unparseable JSON: “Your save couldn’t be read, so progress has been reset.” The behaviour matches required (back up, then wipe).
  • Newer schema than this build (a downgrade; can’t happen through store updates, but can between dev and test installs): “This save is from a newer version of Dark Cosmos. Update the game to keep it. Progress this session won’t be saved.” No wipe; writes stay blocked (§3).

2. MetaStore.wipe_progress(old: Dictionary) -> MetaState

Section titled “2. MetaStore.wipe_progress(old: Dictionary) -> MetaState”

One function used by required, recommended and manual resets.

  1. Backup: copy the current save file byte-for-byte to meta.backup-<BUILD>.json next to it (same user:// → cache-dir fallback as save_state; tvOS uses the cache dir). An existing backup with the same name is overwritten, which is fine: one backup per build.
  2. Build a fresh MetaState, carry over the kept fields (decision 2) from old, read leniently from the raw dictionary (the required case can’t use from_dict()), and set reset_epoch = SaveReset.RESET_EPOCH.
  3. Save it, and set last_load_compatible = true.
  4. EventQueue.push("save_reset", {"kind": "required"|"recommended"|"manual"|"corrupt", "from_schema": n, "from_epoch": n}). This is gameplay telemetry, not a new kind of personal data. The planner checks whether the privacy page or ASC label enumerates event types, and updates them if so.

main.gd swaps its meta for the returned state.

save_state() refuses (a no-op that still counts save_count, and sets last_save_error = "blocked: unreadable save not acknowledged") while last_load_compatible is false. The only ways to clear the flag are wipe_progress() or a clean load. This turns the current behaviour (every caller has to remember) into an impossible one, and closes the silent-overwrite path for corrupt and newer-schema saves too. The existing boot-time if last_load_compatible: checks become redundant but harmless; leave them.

One new panel, ui/save_reset_notice.gd, in the existing neon-panel style using live tokens, with MenuNav focus for controller/remote. Modes:

  • required / corrupt: title, body text, one button OK.
  • recommended: title, RESET_NOTICE body, buttons Keep my progress (default focus, the safe choice) and Reset now. Reset now asks a second time (“Reset all progress? This can’t be undone.”) before wiping.
  • newer-schema: title, body, OK (no wipe).

main._ready decides after MetaStore.load_state() and shows the notice before the start menu is interactive. On close, the start menu proceeds as normal. Precedence when several apply: required/corrupt > newer-schema > recommended, with at most one notice per boot. A required reset also sets reset_epoch, so a recommended offer in the same build doesn’t follow it.

A Reset progress row in ui/pause_menu.gd, next to Diagnostics. Press once and the row changes to “Press again to reset all progress”; the confirm state times out after a few seconds or on focus loss. The confirm press emits progress_reset_requested. main calls wipe_progress(meta.to_dict()) with kind manual, abandons the run (no gold banked, no results screen) and returns to the start menu. Hidden during the tutorial/story lesson track, where the pause menu already trims rows (the planner confirms the existing gate).

Debug Settings ↔ console parity: this is a player setting, not a dev row, so the console is not required. (Planning, 2026-10-04: there’s no dev command. Screenshots come from tools/ui_sweep screens that construct the panel directly and never touch the save, which avoids hand-written console JS plus a Worker redeploy.)

5b. Undo a reset (Chris, 2026-10-04: “a way that allows a player to restore if they decide after they wished they hadnt wiped”)

Section titled “5b. Undo a reset (Chris, 2026-10-04: “a way that allows a player to restore if they decide after they wished they hadnt wiped”)”

This replaces §5’s two-press row. The pause row opens a small progress dialog (SaveResetNotice in mode manage): Reset progress, Restore previous progress (only when an undo slot exists) and Cancel (default focus). Reset and Restore each ask a second time inside the dialog. The row reads “Reset / restore” when an undo slot exists, otherwise “Reset progress”.

  • One undo slot, meta.undo.json beside the save. A manual or recommended wipe copies the current save there before wiping, replacing any earlier slot. A required or corrupt wipe deletes the slot instead: this build can’t read a too-old save, so it can’t be offered back. (The per-build meta.backup-<BUILD>.json support copy is still written for every wipe.)
  • Restore (second press: “Restore your previous progress? Anything earned since the reset will be replaced.”): loads the slot (it must be schema-compatible, or the option is hidden), keeps the current share_diagnostics / install_id (settings, not progress), sets reset_epoch = max(slot's, RESET_EPOCH) so a recommended offer doesn’t come back, saves it, deletes the slot (one step; a restore isn’t itself undoable), sends save_reset with kind: "restore", and returns to the start menu like a manual reset.
  • The slot doesn’t expire; it lasts until the next reset or a restore.
  • The recommended offer’s confirm text adds: “You can undo this from the pause menu.”

6. Release procedure (documented in docs/architecture/meta-and-story.md)

Section titled “6. Release procedure (documented in docs/architecture/meta-and-story.md)”
  • Recommended reset: bump SaveReset.RESET_EPOCH, write RESET_NOTICE, mention it in What’s New.
  • Required reset: bump both schema consts, and mention it in What’s New.
  • Never both in one release unless deliberate (required already covers it).

App Store copy (goes into marketing/appstore/copy.md with this work; listed in 1.0.2)

Section titled “App Store copy (goes into marketing/appstore/copy.md with this work; listed in 1.0.2)”

Dark Cosmos is in active development. Expect big updates as we build it out: new weapons, enemies and systems, plus balance changes. While the game is still taking shape, some updates may need to reset your progress. We’ll always tell you in the update notes first.

GUT (all with disk_io_enabled off, plus temp paths via load_from-style injection, never the real save):

  • wipe_progress clears exactly the cleared fields and keeps exactly the kept ones, from both a compatible dict and a raw too-old-schema dict.
  • A backup file is written and byte-identical to the pre-wipe save.
  • save_state is blocked while last_load_compatible is false, and unblocked after a wipe.
  • Epoch: a fresh MetaState is never offered; a save missing reset_epoch is offered when RESET_EPOCH > 0; after either choice it’s not offered again; a round-trip through to_dict/from_dict preserves reset_epoch.
  • Notice precedence picks one mode per boot.
  • save_reset event pushed with the right kind (injectable push spy; EventQueue.push is a no-op headless).
  • Pause-menu confirm: one press doesn’t emit, two presses emit, and the confirm state times out.

Visual: bh-visual-qa on the notice (required + recommended) and the progress dialog (with an undo slot), via new tools/ui_sweep screens, at iPhone, iPad and ATV sizes. Sim untouched, so no determinism change.

  • A general in-game “What’s New” screen.
  • Restoring the per-build meta.backup-<BUILD>.json support copies in the UI (the player-facing undo is the single meta.undo.json slot, §5b).
  • Remote-config-triggered resets (decision 4).