Quick answer: Construct 3’s Local Storage is asynchronous. A save that triggers before a previous load has finished captures the pre-load state and silently overwrites your real save data. Gate saves behind On item get, protect all IO with a io_busy mutex global, and model save/load as an explicit state machine rather than a pair of independent triggers.

Your game seems to save fine. Players open the title screen, press Continue, and sometimes their progress is back to zero — as if the save had been overwritten with a blank slate. You add logs, play through dozens of cycles, and finally catch it: a save action fired a couple of frames after the load began, wrote out whatever was in globals before the load finished, and the next load returned that empty data. Classic async race. Here is how to stop it for good.

Why This Happens

Local Storage in Construct 3 does not complete instantly. When you trigger Get item, the request goes out to the browser’s IndexedDB and returns control to the event sheet immediately. The value is not available until the On item get trigger fires on a subsequent tick. The same is true for Set item: the save request goes out, the event sheet continues, and On item set fires when the write completes.

If you queue a save right after triggering a load, the order is:

  1. Trigger Get item “save_data”.
  2. Continue ticking. Globals still hold the pre-load values.
  3. Trigger Set item “save_data” with current globals — which are still the defaults or previous-session values.
  4. On item get fires with the real saved value, populates globals.
  5. On item set fires with the default values, which get stored and overwrite the real save.

Next time the player loads, Get item returns the empty defaults and their progress is “gone.” The data is not corrupt in any meaningful sense — you wrote exactly what you asked for. You just asked at the wrong time.

The State Machine Approach

The simplest reliable fix is an explicit state machine. Declare a global text variable io_state with values idle, loading, or saving. Every save and load checks and transitions the state. Until the triggering On item get/set event fires, no other IO is allowed.

In pseudocode for the event sheet:

// Event: try_load (called from menu, game start, etc.)
if (io_state == "idle") {
  set(io_state, "loading");
  LocalStorage.GetItem("save_data");
}

// Trigger: LocalStorage On item "save_data" get
parse_save_json(LocalStorage.ItemValue);
set(io_state, "idle");

// Event: try_save (called on checkpoint, menu close, etc.)
if (io_state == "idle") {
  set(io_state, "saving");
  LocalStorage.SetItem("save_data",
                         serialize_save_json());
}

// Trigger: LocalStorage On item "save_data" set
set(io_state, "idle");

With this structure, a save that arrives during a load sees io_state == "loading" and is skipped. A load during a save is similarly skipped. Once the trigger fires and transitions back to idle, the next operation is free to proceed.

Handling Dropped Operations

Silently skipping a save is dangerous — the player might hit a checkpoint that never writes. Instead of dropping, queue. Add a Dictionary object named io_queue. When a new IO request arrives while io_state is not idle, push it; when On item get/set fires, pop the next entry and process it.

// try_save, busy-path
if (io_state != "idle") {
  io_queue.Add("save", serialize_save_json());
  return;
}

// On item get/set completion
set(io_state, "idle");
if (io_queue.KeyCount > 0) {
  var next_key = io_queue.FirstKey();
  if (next_key == "save") {
    set(io_state, "saving");
    LocalStorage.SetItem("save_data",
                           io_queue.Get("save"));
  } else if (next_key == "load") {
    set(io_state, "loading");
    LocalStorage.GetItem("save_data");
  }
  io_queue.DeleteKey(next_key);
}

A production-grade queue adds a timestamp per entry so that if multiple saves queue up, you only process the latest (older saves are obsolete by definition). A load request can be coalesced with any pending operation — if a save is already queued and a load arrives, run the save first, then the load.

Chain Saves Off On Item Get

For a very common pattern — “load at game start, save every 30 seconds after” — you can avoid most of the complexity by chaining the save off the load-complete trigger:

Because the timer is not started until the load completes, the race is structurally impossible: there cannot be an auto-save in flight before the load ever finishes. This is the design that works best for most games and does not require a queue.

Testing the Race

Races are notoriously hard to trigger on purpose. To force the problem, add an artificial delay to the On item get trigger (say, Wait 1 second) and then hammer the save button during that window. Without the state machine, saves during the delay should produce the bug. With the state machine, they are blocked or queued. Once the logic is correct, remove the artificial delay and ship.

Also test the first-run case: no save_data key exists yet. On item get fires with an empty value, your parse function should fall back to defaults, and the state should transition to idle so the first save proceeds normally. Many bugs cluster in this path because developers only test after a save already exists.

“Local Storage is not synchronous. Treat every save and every load as a future, and do not let two futures overlap on the same key.”

Related Issues

If saves never appear to persist at all, see Local Storage Data Not Persisting — that covers the browser-level storage quota and cookie-mode edge cases. If family instance variables are getting lost in the round-trip, read Family Instance Variables Not Saving.

One io_state global + On item get/set triggers = no more ghost saves wiping real progress.