Save System¶
The save system stores per-slot JSON data and supports two strict async policies:
SaveSyncPolicy::Local(default): useISaveStoreonly.SaveSyncPolicy::Remote: useICloudSaveProvideronly, with no local fallback/cache writes.
All storage-facing APIs are async and callbacks are deferred through SaveSystem::update(...).
Key Types¶
SaveSystemSaveSlotSaveDocICloudSaveProvider
Status Types¶
OpenSlotStatus:Success,NotFound,Failed,UnavailableOpenSlotResult:{ 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
Unavailableresults. Reads useremoteLoadTimeoutSecondsper attempt, and writes/removes execute in FIFO order. - A terminal provider
Failedresult 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:
OnSaveHydratedOnSaveCloudAvailabilityChangedOnSaveProfileChanged
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
versionfield and defaults to0. - 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 destinationversionafter 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.
}
});
}
});