Skip to content

Save System

The save system stores per-slot JSON data and supports two strict async policies:

  • SaveSyncPolicy::Local (default): use ISaveStore only.
  • SaveSyncPolicy::Remote: use ICloudSaveProvider only, with no local fallback/cache writes.

All storage-facing APIs are async and callbacks are deferred through SaveSystem::update(...).

Key Types

  • SaveSystem
  • SaveSlot
  • SaveDoc
  • ICloudSaveProvider

Status Types

  • OpenSlotStatus: Success, NotFound, Failed, Unavailable
  • OpenSlotResult: { status, slot }
  • SaveWriteStatus: Success, Failed, Unavailable

API Shape (Async-Only)

  • SaveSystem::initialize(std::function<void(bool)> onComplete)
  • SaveSystem::openSlotAsync(const std::string&, std::function<void(OpenSlotResult)>)
  • SaveSystem::notifyRemoteProfileChanged()
  • SaveDoc::saveAsync(std::function<void(SaveWriteStatus)> onComplete)
  • SaveSlot::saveAsync(const T&, std::function<void(SaveWriteStatus)> onComplete)
  • SaveSlot::removeAsync(std::function<void(SaveWriteStatus)> onComplete)

SaveSlot::exists() reports in-memory slot state only. SaveSlot::loadDoc() and SaveSlot::load<T>() read in-memory JSON only.

Initialization and Readiness

SaveSystem::initialize(...) remains async.

  • Local: readiness is immediate.
  • Remote: readiness depends on provider availability plus provider init/timeout.
  • isReady() means the startup gate has been released; it does not mean the cloud is currently operational.
  • If remote init fails or times out, app startup continues while provider initialization retries once per second.
  • Remote reads and mutations remain queued through transient Unavailable results. Reads use remoteLoadTimeoutSeconds per attempt, and writes/removes execute in FIFO order.
  • A terminal provider Failed result completes that operation and does not block later mutations.

Login state is surfaced via events for telemetry/UI, but it is not used as a hard gate for remote readiness.

Remote profile generations

Every remote SaveSlot and SaveDoc belongs to the profile generation in which it was opened. A portal account-change notification advances that generation before further save work is processed. At that boundary:

  • queued operations from the old generation complete as Unavailable;
  • old in-flight provider callbacks are ignored;
  • an old slot/doc handle cannot enqueue a write or removal into the new profile;
  • the application must open a new slot before saving again.

Local providers always use generation zero. Portal adapters notify the engine; they do not decide whether the application should reload or merge data.

Platform and Portal Availability

Local saves use the platform ISaveStore on Windows, macOS, full-profile web, and iOS, plus app-private filesDir storage on Android. Android slot writes use atomic replacement, require no storage permission, survive app updates, and are included by the generated backup rules while caches are excluded. The PLAYABLE_AD web profile removes the save system entirely (SNOWPULSE_HAS_SAVES=0).

Remote saves depend on the selected web portal adapter:

Portal Remote provider
Poki Browser localStorage provider used by the bundled adapter
CrazyGames CrazyGames SDK Data, including verified migration from the legacy local key
Playgama Playgama bridge storage
YouTube YouTube host save envelope
Default, Spiktar, Google Ads, GamePix, Meta Ads, AppLovin Ads None

With SaveSyncPolicy::Remote, selecting a portal without a provider produces unavailable results; it does not fall back to local storage. See the complete web integration matrix.

Events

When SaveSystemConfig::events is set, the save system emits:

  • OnSaveHydrated
  • OnSaveCloudAvailabilityChanged
  • OnSaveProfileChanged

OnSaveHydrated::wasLate is set when cloud hydration finishes after startup was released because initialization had not completed successfully. OnSaveProfileChanged::generation identifies the newly active remote profile generation. Applications should pause profile-bound writes, open a new slot, and only resume after they have classified or hydrated the destination profile.

OnSaveHydrated is emitted only for a successful remote slot read. OnSaveCloudAvailabilityChanged reports both provider availability and login state. Save events use immediate EventBus::emit() dispatch when the transition is processed (normally during save-system update), so listeners that capture this must retain their EventBus::Subscription handles.

class SaveEventObserver final : public snowpulse::Script {
protected:
    void onStart() override {
        _hydrated = events()->on<snowpulse::OnSaveHydrated>(
            [this](const snowpulse::OnSaveHydrated& event) {
                _lastHydratedSlot = event.slot;
                _lateHydration = event.wasLate;
            });
        _availability = events()->on<snowpulse::OnSaveCloudAvailabilityChanged>(
            [this](const snowpulse::OnSaveCloudAvailabilityChanged& event) {
                _cloudAvailable = event.available;
                _loggedIn = event.loggedIn;
            });
        _profile = events()->on<snowpulse::OnSaveProfileChanged>(
            [this](const snowpulse::OnSaveProfileChanged& event) {
                _activeGeneration = event.generation;
                _reopenProfileSlots = true;
            });
    }

private:
    snowpulse::EventBus::Subscription<snowpulse::OnSaveHydrated> _hydrated;
    snowpulse::EventBus::Subscription<snowpulse::OnSaveCloudAvailabilityChanged>
        _availability;
    snowpulse::EventBus::Subscription<snowpulse::OnSaveProfileChanged> _profile;
    std::string _lastHydratedSlot;
    std::uint64_t _activeGeneration = 0;
    bool _lateHydration = false;
    bool _cloudAvailable = false;
    bool _loggedIn = false;
    bool _reopenProfileSlots = false;
};

Migrations

SaveSlot::migrate(from, to, fn) applies migrations to in-memory slot data.

  • The current version is read from the top-level integer version field and defaults to 0.
  • Register migrations after a successful open. Each migrate() call applies any now-reachable chain immediately.
  • The migration callback mutates a snowpulse::Json&; the save system writes the destination version after the callback.
  • If data changes, auto-persist is attempted asynchronously.
  • Open callbacks are not blocked by migration persist.
auto* saves = context()->saves();
saves->openSlotAsync("preferences", [](snowpulse::OpenSlotResult result) {
    if (result.status != snowpulse::OpenSlotStatus::Success) {
        return;
    }

    result.slot.migrate(1, 2, [](snowpulse::Json& data) {
        const float previousScale = data.value("interfaceScale", 1.0f);
        data["display"]["scale"] = previousScale;
        data.erase("interfaceScale");
    });

    const auto migrated = result.slot.loadDoc();
    const int version = migrated.get<int>("version", 0);
});

Typed Slots and Removal

SaveSlot::load<T>(fallback) converts the entire in-memory JSON value to T. saveAsync(value, callback) serializes the entire value. Define the normal nlohmann::json conversions for custom types. Conversion failures return the fallback without throwing through the API.

struct Preferences {
    float interfaceScale = 1.0f;
    bool notifications = true;
};

NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(
    Preferences, interfaceScale, notifications)

auto* saves = context()->saves();
saves->openSlotAsync("preferences", [](snowpulse::OpenSlotResult result) {
    if (result.status != snowpulse::OpenSlotStatus::Success &&
        result.status != snowpulse::OpenSlotStatus::NotFound) {
        return;
    }

    Preferences preferences = result.slot.load(Preferences {});
    preferences.interfaceScale = 1.25f;
    result.slot.saveAsync(preferences, [](snowpulse::SaveWriteStatus status) {
        // Success means the selected local or remote provider accepted it.
    });
});

Removal is also asynchronous. The shared in-memory slot state changes to nonexistent only after a successful provider removal:

auto* saves = context()->saves();
saves->openSlotAsync("temporary", [](snowpulse::OpenSlotResult result) {
    if (result.status != snowpulse::OpenSlotStatus::Success) {
        return;
    }
    result.slot.removeAsync([](snowpulse::SaveWriteStatus status) {
        if (status == snowpulse::SaveWriteStatus::Success) {
            // Copies sharing this slot state now report exists() == false.
        }
    });
});

Profile Generation

profileGeneration() is 0 for local saves. For remote saves, an account boundary advances the generation during the next SaveSystem::update() and emits OnSaveProfileChanged. Saves and removals attempted through an old SaveSlot or SaveDoc complete with SaveWriteStatus::Unavailable; open a fresh slot for the new generation.

Portal adapters call notifyRemoteProfileChanged() automatically. A custom account integration should call it once at the boundary, not every frame:

auto* saves = context()->saves();
const std::uint64_t previous = saves->profileGeneration();
saves->notifyRemoteProfileChanged();
// After the next SaveSystem::update(), profileGeneration() is previous + 1.

Sample Usage

auto* saves = context()->saves();

saves->openSlotAsync("profile", [](snowpulse::OpenSlotResult result) {
    if (result.status == snowpulse::OpenSlotStatus::Success ||
        result.status == snowpulse::OpenSlotStatus::NotFound) {
        auto doc = result.slot.loadDoc();
        const float scale = doc.get<float>("display.scale", 1.0f);
        doc.set("display.scale", scale + 0.1f);
        doc.set("version", 2);
        doc.saveAsync([](snowpulse::SaveWriteStatus status) {
            if (status != snowpulse::SaveWriteStatus::Success) {
                // Keep the in-memory value and surface retry/error state.
            }
        });
    }
});