# Cloud saves for browser apps Hackemist injects `window.Hackemist` before the app's scripts. Do not copy platform credentials into an app bundle. ## Reconciled progress store (SDK 1.5.0) Copy the [complete runnable counter](/docs/progress-example.md) for self-contained HTML with every status, checkpoint and recovery callback implemented. The short integration sketch below assumes your existing app supplies the named UI functions. Both `createProgressStore` and `createProgressSaver` are valid SDK 1.5.0 exports; see [version and migration support](/docs/platform-contract.md). Use `createProgressStore` for new integrations. It adds identity-bound browser recovery, explicit local/cloud reconciliation, and a write gate to the managed saver. It cannot discover arbitrary JavaScript variables or IndexedDB data: your callbacks must capture and validate the complete durable state. Remove your old unconditional cache writes from blur, visibilitychange and pagehide handlers; use this store's gated methods instead. Its default local-recovery layer needs available browser localStorage on an isolated project origin. An opaque sandbox, blocked browser storage or a full archive stops the store safely; it does not silently continue without recovery. Configure isolated hosting when available, or deliberately integrate the existing `createProgressSaver` account-JSON contract without claiming durable browser recovery. ```javascript // Inside an async startup function, after creating your app's initial state: const saves = Hackemist.createProgressStore({ capture: () => ({level: game.level, score: game.score}), initial: () => { game.level = 1; game.score = 0; renderGame(); }, restore: saved => { // Validate your app's saved fields before applying them. if (!Number.isInteger(saved.level) || !Number.isFinite(saved.score)) { throw new Error('Unsupported saved progress.'); } game.level = saved.level; game.score = saved.score; renderGame(); }, // Implement this UI. Return 'stop' to leave saving paused while choosing. // After a player clicks a choice: await saves.resolve('local'|'cloud'|'fresh'). onRecovery: details => { showRecoveryChoices(details); return 'stop'; }, onStatus: status => { saveLabel.textContent = status; }, onError: error => { showSaveError(error.message); } }); await saves.ready; // Restoration completes before enabling player actions. enableGameInput(); // Polling observes changes to capture's JSON. This optional call speeds up detection: saves.changed(); // At an explicit checkpoint, await the result before claiming this state is saved: await saves.flush(); ``` `initial(info)` handles a genuinely empty slot or a deliberate reset; `restore(data, info)` handles validated nonempty progress and may be asynchronous. They are separate callbacks so an empty cloud slot cannot automatically reset good local work. `info` includes `slotId`, `revision`, `formatVersion`, `expectedFormatVersion`, `saveIdentity`, `saveState` and `migrationRequired`. An unchanged successful load does not report `saved` or write a new cloud revision. Guests and read-only/standalone previews are `disabled` without account reads or writes. The server supplies `save_state`: `empty` means no stored revision, `existing` means stored JSON, and `reset` means a positive empty revision produced by an intentional clear/reset. Failures and incompatible formats are never converted to `empty`. The opaque `save_identity` is stable for account/project/slot across app publications and format updates; it is not a credential. Named slots and different accounts have separate identities. The historical default database key and native player origin are unchanged. An empty cloud slot with valid local work, different local/cloud snapshots, invalid cache, or unbound legacy cache pauses saving for a choice. `onRecovery` receives the reason, cloud/local candidates and allowed choices. `local` explicitly adopts local progress; `cloud` restores cloud progress; `fresh` deliberately starts over. `stop` (or no callback) keeps writes paused and rejects `ready` with `SAVE_RECOVERY_REQUIRED`. Render a recovery UI, catch that rejection, then call `resolve(choice)` after the player chooses. A changed cloud snapshot invalidates an earlier choice. A positive reset revision never automatically resurrects a stale local cache. Use an optional stable `namespace` (1–64 letters, digits, dot, underscore or hyphen), optional Storage-compatible `storage`, and optional `legacyKeys` (at most eight) for explicit same-origin adoption. Legacy keys have no proven account ownership and always need player approval; they are preserved. No helper can read another browser origin or silently merge a guest's cache into an account. For another origin/device, use the player's validated save export/import or an explicit creator recovery flow after confirming the destination account/app/slot. `cacheLocal()` records browser recovery only and returns false while startup, recovery, save management or errors block writes. `localStatus === 'cached'` never means cloud saved. Replace unconditional lifecycle cache writes with this method and handle errors visibly. `exportLocalRecovery()` returns the identity-bound cache, original copies and per-tab pending candidates. Each writer preserves its unacknowledged candidate before checking/replacing the shared cache, so delayed storage events do not silently discard another tab's work; a changed cache pauses saving. Import a chosen pending candidate through an explicit validated recovery flow after checking account/app/slot. `removeLocalOriginal(index)` deletes one archived original only when the player requests it. Up to eight distinct originals are retained before startup replacement/migration; a full, malformed or unavailable archive blocks replacement instead of silently evicting originals. Export before deleting a copy. Browser eviction or clearing still removes local recovery; server checkpoint retention is separate. After exporting, an explicit `discardLocalRecoveryArchive(identity)` can clear an invalid/full originals archive; `removeLocalPending(index)` removes a chosen pending candidate. These are deliberate deletion actions, never automatic startup cleanup. Native browser sync also exposes `Hackemist.exportBrowserRecovery()`, `listBrowserRecovery()` and `selectBrowserRecovery('local'|'cloud', optionalEntryId)` for creator recovery interfaces; selection requires reopening. The player supplies recovery buttons for apps without their own interface, including export followed by a confirmed archive clear. Migration preserves the source locally and leaves writes paused with `SAVE_MIGRATION_REQUIRED`; use the explicit migration methods below, then reload. A failed restore leaves the prior cache and original cloud snapshot available. A storage change in another tab pauses the helper; revision checks also reject stale cloud writes. A failed read reports `SAVE_READ_FAILED` or `SAVE_AUTH_REQUIRED`, preserving data until an explicit reload. Unresolved recovery notices remain visible in the player; ordinary readiness information expires. ### Compatibility with SDK 1.3 integrations `createProgressSaver` keeps its existing callback contract, including `restore(null, info)`. It still stops managed cloud writes after read/restore errors, but does not manage a creator's independent local cache writes. Upgrade integrations with local caches to `createProgressStore` and remove unconditional reset/cache handlers. Existing apps are not rewritten automatically. No schema/data migration or save deletion is required for the new helper. `capture` must return finite, plain JSON synchronously, without cycles, sparse arrays, custom class objects, undefined values, or symbol fields, within 64 KiB. Capture every durable gameplay field you want restored. Avoid rendering resources, timers, DOM nodes, and transient animation frames. A declaration schema can additionally validate that JSON on the server. Options are `capture`, `restore`, optional `onStatus(status, info)`, `onError(error)`, `pollIntervalMs` (integer 1,100–60,000; default 1,500) and `debounceMs` (integer 500–60,000; default 600). Writes are serialized and spaced by at least `pollIntervalMs`; unchanged snapshots are skipped. A change during a request is captured again after its acknowledgement and saved in sequence. UI callback exceptions are ignored; capture/restore exceptions stop persistence. One managed controller owns the whole progress channel; dispose it before creating a replacement. The controller exposes `ready`, `status`, `enabled`, `changed()`, `flush()`, `reload()`, and `dispose()`. `changed()` returns whether the active saver accepted the notification, and returns false while paused or disabled. `flush()` resolves with `saved: false` for an unchanged snapshot or disabled/paused saver, or with an acknowledged revision and `saved: true`. Statuses are `loading`, `ready`, `dirty`, `saving`, `saved`, `paused`, `error`, `disabled`, and `disposed`. Only `saved` indicates that the latest captured snapshot has a confirmed server acknowledgement. A failed read, invalid save format, capture/restore error, conflict, quota/rate limit, expired session, or rejected write stops automatic writes. It does not replay a stale snapshot automatically. Preserve the app's local work and show the error. An explicit `await saves.reload()` re-reads and restores the current cloud save before restarting; this intentionally replaces the in-memory state with that save. A selected-slot change requires reopening the app. Save Manager pauses the helper's automatic writes; closing it resumes the existing slot when its data has not changed. Session shutdown and `dispose()` stop timers. Page exit makes a best-effort eligible flush and stops the helper; abrupt closing, a crash, offline operation, or a interrupted response cannot guarantee a final save. Use awaited checkpoints while the app remains open. A successful acknowledgement guarantees the submitted JSON was accepted for the account/app/slot at that revision; later saves or deliberate recovery can replace it. The guarantee covers the fields your callback captures. ## Direct checkpoint API Apps may also manage checkpoint timing themselves: ```javascript const session = await Hackemist.sessionInfo(); if (!session.authenticated || session.cloud_save === false) throw Error('Use the Hackemist player for account saves.'); const snapshot = await Hackemist.getSaveInfo(); if (snapshot.migration_required) throw Error('Migrate this slot before writing.'); // Reconcile any local original before deciding to initialize an empty slot. const game = snapshot.save_state === 'existing' ? validateAndRestore(snapshot.data) : initializeAfterPlayerChoice(snapshot); // Save meaningful checkpoints, not every frame or an entire GPU buffer. try { await Hackemist.saveProgress(game); } catch (error) { // Keep unsaved local work and let the player retry or reopen the app. console.warn('Progress could not be saved:', error.message); } ``` `Hackemist.loadProgress()` resolves to the saved JSON data or `null`. `saveProgress(data)` accepts JSON fitting within 64 KiB and acknowledges the account save. `sessionInfo()` supplies the player session information available through the parent player. `ready` resolves to the current project/launch identifiers. `serviceWorkerReady` refers to the approved managed asset cache. Await each save before starting another. The player spaces closely timed sequential saves by at least 500 ms; server-side save mutations are bounded to 60 per account/app per minute, including slot management. Debounce frequent gameplay changes and save meaningful checkpoints. A conflict, quota limit, expired session or rate limit is a failed save: preserve local work and let the player retry deliberately. Account progress is keyed by the signed-in account and project. The parent bridge checks the iframe, runtime origin, nonce, active account, launch binding and revision. Stale writes conflict rather than silently overwriting a newer save. Handle rejected saves and preserve the player's work; do not treat every save request as a successful write. The SDK's account bridge runs inside Hackemist's player or ordinary studio preview. A standalone short-lived runtime preview can render the app but has no parent account bridge: it loads no account progress and rejects account saves. The legacy `loadProgress()` compatibility stub returns null there; the additive `loadProgressSnapshot()` marks `save_state: 'unavailable'`, `preview: true`, `account_save_available: false`. Never interpret this as an authenticated empty slot. Prefer the managed helper or check `sessionInfo()` and `getSaveInfo()` first. Open the app through Hackemist to test persistence. Approved automatic browser-storage syncing can save localStorage and supported app-cookie emulation, with a separate bounded storage snapshot. It does not capture arbitrary IndexedDB databases, external accounts or all in-memory state. Integrate explicit checkpoint data for those apps. Guest/browser-only state can be lost when browser data is cleared. ## Save slots and recovery Signed-in players open **Saves** in the player toolbar, or the save manager from their library. Each account has a separate default slot and up to seven additional named slots per app. The default slot preserves existing progress and browser-state rows. Each slot has two independent channels: explicit app JSON (`progress`, 64 KiB) and approved automatic browser storage (`browser`, 5 MiB). Renaming a slot does not change its ID. The manager can create, select, rename, export, import, reset, delete and recover slots. Reset clears both channels and creates newer revisions; previous data remains in retained checkpoints. The default slot can be reset but cannot be deleted. Deleted named slots remain recoverable until displaced by more deletions: only the last eight deleted slots are retained. **Remove permanently** deletes a tombstone and its checkpoints, freeing storage. By default, checkpoint history retains up to ten revisions per channel, within 20 MiB per account/app and 64 MiB per account overall. Large saves can therefore retain fewer checkpoints. The combined account quota is 256 MiB, including legacy default saves, named channel data and recovery history. Administrators can configure slot/retention counts and byte limits; the manager displays the applied bounds and current use. The server charges the final transaction after retention pruning and preserves the prior save if it exceeds quota. Export before permanently removing data. Restoring a checkpoint always creates a **new** revision; it never rewinds the revision number or silently overwrites another tab's newer data. **Clear permanently** removes both channels' data and all recovery history while keeping newer empty revisions, including for the default slot. This lets an account with old saves above a newly reduced quota export and free its own storage safely. Old tabs cannot reuse a pre-clear revision. Selecting a slot changes the preference for the next launch. Every existing launch keeps its slot binding, including JSON-only SDK apps, so an old in-memory snapshot cannot overwrite another slot by accident. Close and reopen through the player to restore the selected slot before making changes; Save Manager does this automatically. Each named slot has a separate, stable native storage origin; a fresh slot starts empty without clearing another slot's localStorage. The default slot retains its historical origin so earlier browser-only progress can be imported when cloud sync is enabled. Guest storage and other accounts remain separate. The same account/app/slot continues to sync across devices. Simultaneous edits use revision checks: a stale device cannot overwrite newer cloud progress. Two tabs for the same slot share native storage, so automatic saving pauses when another tab changes that storage and explains how to resume. Close other copies of that slot, then reopen the app to restore the latest cloud save; review **Saves** and checkpoint history if needed. Tabs for different slots can keep saving independently. Upgrading from the previous shared-origin implementation expires old save-bound launches once; reopen those apps before continuing. First-time browser-only import and divergent local/cloud dictionaries now require explicit player adoption; [Browser recovery](/docs/browser-recovery.md) explains the player controls, archive export and deliberate cleanup. Reserved platform recovery keys are filtered from hydration/snapshots and rejected on new browser-state writes; existing stored rows remain readable/exportable. If an app was opened while account saves were unavailable in the player's plan, reopen it after account saving becomes available. Existing launches never acquire a different slot or restore newly available cloud data into their earlier browser storage origin. The SDK adds these methods, all using the existing launch-bound bridge: ```javascript const {slots, selected_slot_id} = await Hackemist.listSaveSlots(); const {slot} = await Hackemist.createSaveSlot('Second playthrough'); const selected = await Hackemist.selectSaveSlot(slot.id); // If selected.reopen_required is true, ask the player to reopen through Hackemist. await Hackemist.renameSaveSlot(slot.id, 'Chapter two', slot.revision); const {checkpoints} = await Hackemist.saveHistory(slot.id); const info = await Hackemist.getSaveInfo(); // data, revision, format versions, slot_id const browser = await Hackemist.loadBrowserSave(); // Supply the CURRENT revision of the checkpoint's channel, not its old revision. await Hackemist.restoreSaveCheckpoint(slot.id, checkpoints[0].id, info.revision); // Reset/delete use the current SLOT metadata revision returned by listSaveSlots(). await Hackemist.resetSaveSlot(slot.id, slot.revision); await Hackemist.deleteSaveSlot(slot.id, slot.revision); await Hackemist.recoverSaveSlot(slot.id, deletedSlot.revision); ``` Read a fresh list before metadata changes; do not reuse the example's revisions after a successful mutation. Channel revisions and slot metadata revisions are different counters. Handle conflicts by re-reading and giving the player a choice, rather than retrying stale data without review. The save manager pauses bridge writes while it is open and reopens the app after recovery that changes loaded data. ```javascript const file = await Hackemist.exportSaveSlot(slot.id); await Hackemist.importSaveSlot(slot.id, file, currentSlot.revision); ``` Exports use the `hackemist-save` JSON envelope, version 1, containing the app ID, slot name, export time, and both typed channels. Imports must be for the same app, carry no unknown envelope/channel fields, contain finite bounded JSON, and fit within 6 MiB. File imports retain duplicate-field checks by submitting the original JSON text. Imported revision numbers are descriptive: the server commits newer local revisions using the current target-slot revision. Older formats are preserved for recovery and require a migration before new app saves can replace them. ## Author-declared save formats Package an optional UTF-8 `hackemist-save.json` at the bundle root. Its declaration belongs to that immutable app version; uploading a new declaration creates a new app version. Apps without it use format 1. ```json { "format_version": 2, "description": "Score moved from legacy_score to score.", "progress_schema": { "type": "object", "properties": {"score": {"type": "integer", "minimum": 0}}, "required": ["score"], "additionalProperties": false } } ``` The declaration is at most 16 KiB; format versions are integers from 1 through 65535. Optional `progress_schema` supports local `type`, `properties`, `required`, `additionalProperties`, `items`, numeric/string/array bounds and scalar `enum`. It is bounded to 12 levels and 200 schema nodes. References, remote schemas, executable migration source and unknown keywords are rejected by preflight. Automatic browser data remains a bounded string dictionary; it does not use the app-JSON schema. A format mismatch does not erase or reinterpret old saves. Reads return the stored format, the app's expected format and `migration_required`; ordinary writes cannot replace mismatched data. Automatic hydration and syncing stop until a migration or reset is committed. Recovery/export remain available. Migrations run **inside your app**, with your code and an independent copy of the loaded snapshot. Hackemist validates the replacement data and commits it only if its source revision/format still match and the target format matches the launched app version. No migration code runs on the server. ```javascript const info = await Hackemist.getSaveInfo(); if (info.migration_required) { await Hackemist.migrateProgress((old, {fromVersion, toVersion}) => { if (fromVersion === 1 && toVersion === 2) return {score: old.legacy_score}; throw new Error('This save format needs another migration.'); }); } // For approved browser-state syncing: await Hackemist.migrateBrowserState((values, versions) => { const next = {...values}; // Apply an app-specific transformation to string values only. return next; }); // Browser migration returns reopen_required; reopen through the player to hydrate it. ``` If a transformation throws, produces invalid data, or loses a revision race, the old cloud save remains intact. Successful migration checkpoints the old state and creates a newer revision. Never relabel a format without actually transforming and validating its data. ## HTTP API and account lifecycle Slot routes are under `/api/projects/{project_id}/saves`: list/create; `select`; `/{slot_id}` rename/delete; and `history`, `restore`, `reset`, `clear`, `recover`, `purge`, `export`, `import`, `progress`, `browser`, `migrate`. Mutations require the signed-in cookie/CSRF pair. SDK calls additionally carry the originating launch binding; the server rechecks the current session, account, project access and launch. Administrator settings are `save_account_storage_mb` (combined account quota), `save_slots_per_project`, `save_deleted_slots_per_project`, `save_history_per_channel`, `save_history_project_mb`, and `save_history_account_mb`. Setting a history bound to zero disables that retained history without deleting current channel data. Lowering a quota does not automatically erase existing saves; reads/exports and permanent clear remain available. Legacy `/progress` and `/browser-state` calls without a launch header keep using the default channel. Launch-bound calls use that launch's slot. Browser writes still require administrator-approved automatic saves. Account export includes slot metadata, channel rows, selection and checkpoints; account deletion removes these records together with existing default saves. No slot API reads or changes another account's data, including administrator accounts.