Guides

Saves and progress

AdminUpdated Sep 22, 2026

Saves and progress

Cool GPT Games gives every game a small key/value store for each player. You can use it for progress, settings, unlocks, stats and save slots, and you don't need a backend of your own. You write the same code for every player. The site decides where the data lives:

Player

Where saveData writes

Follows the player across devices?

Signed in

Their Cool GPT Games account (cloud)

Yes

Guest

The browser's local storage on the Cool GPT Games site

No. Only this browser keeps it.

When a guest signs in, the site copies their guest saves into their account (see Guest progress when a player signs in).

This guide explains how saves behave and suggests patterns that work well. For exact signatures and return shapes, see GameSDK saves & scores reference.

Your game runs in a sandboxed iframe with no origin of its own. window.localStorage, sessionStorage, IndexedDB and cookies are not available to your game code, and calling them usually throws a SecurityError. Store everything you want to keep with GameSDK.saveData.

The four calls

GameSDK.saveData(key, value);          // Promise<{ ok: true, storage } | { ok: false, error }>
GameSDK.loadData(key);                 // Promise<string | null>
GameSDK.listSaves();                   // Promise<Array<{ key, updatedAt? }>>
GameSDK.deleteSave(key);               // remove a slot. Fire-and-forget.

A minimal example:

// Load once at boot
const raw = await GameSDK.loadData("progress");
const progress = raw ? JSON.parse(raw) : { level: 1, coins: 0 };

// ...later, after the player makes real progress
progress.level += 1;
const res = await GameSDK.saveData("progress", JSON.stringify(progress));
if (!res.ok) console.warn("Save failed:", res.error);

You don't have to await saveData. Ignoring the promise is fine when you don't need to know whether the write landed.

Keys and values

Rule

Limit

What happens if you break it

Value type

Strings only. The SDK calls String(value) on whatever you pass.

Objects turn into "[object Object]", undefined into "undefined". Always JSON.stringify structured data.

Value size

64 KB per slot: at most 65,536 UTF-8 bytes

Not saved. saveData resolves { ok: false, error: "too_large" }. This is checked for every player before anything is sent.

Key length

1–128 characters

For signed-in players an empty key or a key longer than 128 characters is rejected: { ok: false, error: "validation_error" }.

Slots per game

100 keys per player per game (cloud)

The 101st new key is rejected: { ok: false, error: "too_many_saves" }. Overwriting an existing key never counts against the limit.

Write rate

120 writes per minute per player per game (cloud)

Writes over the limit are rejected: { ok: false, error: "rate_limited" }.

Messages to the site

About 30 SDK messages per second in total, across every SDK call

Anything over that is dropped. A dropped save resolves { ok: false, error: "timeout" } after 8 seconds.

Non-ASCII text uses more bytes. The limit counts UTF-8 bytes, not characters. A 30,000-character string of emoji or CJK text can be well over 64 KB. If your saves may contain non-ASCII text, measure them with new TextEncoder().encode(value).length.

A rejected write never replaces the stored value: the slot keeps whatever it held before. Stay well inside these limits. Don't design a save format that sits close to 64 KB.

How reads and writes behave

Writes are last-write-wins

Each (player, game, key) holds a single value. Writing a key replaces the old value completely. There is no merging, no version check and no history. If the same player has your game open on two devices, whichever write reaches the server last wins.

Writes report their result

saveData posts a message to the site and returns a promise. The site sends the write to the server and answers with the result:

Result

Meaning

{ ok: true, storage: "cloud" }

Saved to the signed-in player's account.

{ ok: true, storage: "local" }

Saved in this browser only: always for guests, and for a signed-in player when the cloud couldn't be reached.

{ ok: false, error }

Not saved. error is "too_large", an API error code such as "too_many_saves" or "rate_limited", "storage_unavailable" (the browser's storage is full or blocked), or "timeout" (no answer within 8 seconds).

The promise never rejects. Keep the current state in memory as the source of truth and treat the store as write-behind persistence. If you need to know a write landed before doing something else, await the result rather than reading the value back with loadData.

Reads

loadData(key) resolves with the stored string, or null when there is nothing to return. For a signed-in player:

  1. The site asks the cloud store. If that succeeds, you get the cloud value, or null if the key doesn't exist.

  2. If the request fails (a network error or a non-OK response), the site falls back to this browser's local guest storage for the same key. That usually returns null.

So null normally means "no save yet", but it can also mean "the cloud couldn't be reached". See Protect existing progress for how to handle that.

loadData also resolves null if the site doesn't answer within 8 seconds (for example, the message was dropped by the rate limit, or the game is running outside Cool GPT Games). It never hangs. Parallel loadData calls, even for the same key, each resolve with their own answer.

Write failures on a signed-in player

If the server rejects a cloud write (too many slots, rate limited, an invalid key, an expired sign-in), saveData resolves { ok: false, error } with the server's error code and nothing is stored anywhere. Tell the player, or retry later.

If a cloud write fails with a network error, the site saves the value to this browser's local storage instead and resolves { ok: true, storage: "local" }. Cloud reads don't look there while the cloud is reachable, so that copy is invisible on other devices and usually on this one too. It's only picked up by the player's next sign-in migration, and even then it is skipped if the key already exists in the cloud (see below). If a signed-in player's save comes back with storage: "local", save again later to get it into their account.

Guests vs signed-in players

Behaviour

Guest

Signed in

saveData

Written to local storage in this browser

Written to the account

loadData

Read from local storage

Read from the account. Falls back to local storage if the request fails.

listSaves

[{ key }] with no updatedAt

[{ key, updatedAt }]

deleteSave

Removes the local copy

Removes the cloud copy only

saveData result

{ ok: true, storage: "local" }

{ ok: true, storage: "cloud" }

Size limits

64 KB per value (UTF-8 bytes)

64 KB per value (UTF-8 bytes), 128-char keys, 100 slots, 120 writes/min

Survives clearing browser data

No

Yes

Available on another device

No

Yes

Use GameSDK.getPlayer() to check signedIn if you want to show a hint such as "Sign in to keep your progress on every device". Your save code doesn't need to change.

Guest progress when a player signs in

When a player signs in on Cool GPT Games, the site takes every guest save held in that browser (for all games, not just yours) and imports it into their account:

  • Existing cloud slots are never overwritten. If the account already has a value for a key, the cloud value is kept and the guest value is discarded. Progress from another device always wins over guest progress.

  • Keys that don't exist in the cloud yet are copied across.

  • After a successful import, the site deletes the imported guest saves from the browser. That includes guest saves that were skipped because the key already existed in the cloud.

  • If the import fails, the guest saves stay in the browser and the site tries again at the next sign-in.

The import is all-or-nothing. It fails completely if any single guest slot breaks the cloud rules: a key longer than 128 characters, an empty key, a value over 65,536 UTF-8 bytes, more than 100 slots for one game, or more than 200 guest slots in total across all games. Follow the key and value limits above even for guests, so their progress can move to their account.

Signing out

After sign-out the player counts as a guest again. loadData reads this browser's local storage, which is usually empty because it was migrated at sign-in. Their cloud saves are still there and come back when they sign in again.

Patterns

Protect existing progress

A cloud request that fails, or a load that times out, makes loadData return null for a player who really does have a save. If your game then autosaves a fresh default state, it overwrites their progress. To guard against that:

async function loadProgress() {
  let raw = await GameSDK.loadData("progress");     // null after 8 s with no answer
  if (raw === null) {
    // Double-check: does the slot exist?
    const slots = await GameSDK.listSaves();
    if (slots.some((s) => s.key === "progress")) {
      raw = await GameSDK.loadData("progress");     // it exists — retry once
      if (raw === null) return { state: null, safeToSave: false };
    }
  }
  return { state: raw ? JSON.parse(raw) : defaultState(), safeToSave: true };
}

If safeToSave is false, keep the game playable but hold off on autosaving (or ask the player) until a load succeeds.

Autosave

Save when something meaningful happens, such as a level cleared, an item bought or a settings change. Don't save every frame. If you also want a time-based autosave, save only when the state changed, and no more than once every few seconds:

let dirty = false;
let lastSave = 0;

function markDirty() { dirty = true; }

async function save() {
  dirty = false;
  const res = await GameSDK.saveData("progress", JSON.stringify(state));
  if (!res.ok) {
    dirty = true;                                  // try again on the next tick
    if (res.error === "too_large") console.error("Save is over 64 KB. Shrink the format.");
  }
}

function maybeAutosave(now = Date.now()) {
  if (!dirty || now - lastSave < 5000) return;   // ≤ 12 writes/min, far under the 120/min cap
  lastSave = now;
  save();
}

setInterval(maybeAutosave, 1000);
// Try to flush when the player leaves the tab.
GameSDK.on("pause", () => { if (dirty) save(); });

A failed write marks the state dirty again, so the next autosave retries it. A too_large error won't fix itself by retrying, so log it and fix your save format.

The pause event fires when the Cool GPT Games tab is hidden. A write sent right as the page closes may not reach the server, so save at meaningful moments rather than relying on an exit save.

Save slots

Use one key per slot and a small index key for the menu. listSaves() tells you which keys exist. For signed-in players it also gives you updatedAt, an ISO-8601 timestamp.

const SLOT_KEYS = ["slot1", "slot2", "slot3"];

async function slotMenu() {
  const saves = await GameSDK.listSaves();              // resolves [] on timeout
  const byKey = Object.fromEntries(saves.map((s) => [s.key, s]));
  return SLOT_KEYS.map((key) => ({
    key,
    used: !!byKey[key],
    // updatedAt is only present for signed-in players
    lastPlayed: byKey[key]?.updatedAt ? new Date(byKey[key].updatedAt) : null,
  }));
}

async function saveSlot(key, state) {
  const res = await GameSDK.saveData(key, JSON.stringify({ v: 2, savedAt: Date.now(), state }));
  if (!res.ok && res.error === "too_many_saves") {
    showMessage("You have too many saves for this game. Delete one first.");
  }
  return res.ok;
}

function deleteSlot(key) {
  GameSDK.deleteSave(key);
  // Update your menu optimistically — a listSaves() sent right after may still show the key.
}

For guests, updatedAt is missing. If your menu shows "last played", store your own timestamp inside the value, as savedAt above does. That works for everyone.

listSaves() returns keys in no guaranteed order, so sort them yourself.

Schema versioning

Put a version number in every saved blob and migrate when you load. Players come back months later with old saves.

const CURRENT = 3;

function migrate(data) {
  if (!data || typeof data !== "object") return defaultState();
  let d = data;
  if (!d.v) d = { v: 1, ...d };                                    // unversioned → v1
  if (d.v === 1) d = { ...d, v: 2, inventory: d.items ?? [] };     // v1 → v2: rename field
  if (d.v === 2) d = { ...d, v: 3, settings: { sfx: 1, music: 1 } }; // v2 → v3: new field
  return d;
}

const raw = await GameSDK.loadData("progress");
let state;
try { state = raw ? migrate(JSON.parse(raw)) : defaultState(); }
catch { state = defaultState(); /* corrupt JSON — don't crash */ }

Some rules that keep this safe:

  • Never remove a migration step. A player can jump from v1 straight to v5.

  • Wrap JSON.parse in try/catch.

  • Write back in the new format the next time you save. Writing again just to migrate isn't necessary.

Splitting data across keys

Keep data that changes often and data that changes rarely under different keys. One slot for settings, one for progress and one for lifetime stats keeps each write small and limits the damage if one slot goes wrong. Every key counts toward the 100-slot limit, so use a fixed set of names. Don't generate keys from player input or timestamps.

High scores are not saves

To keep a personal best in the game's own UI, a best slot is fine. Only submitScore or replay.submit puts a score on the leaderboard, though. See Scores, leaderboards & anti-cheat. Use getMyScore() for the player's server-recorded best.

Where saves work

The save calls work while your game runs inside Cool GPT Games. If you open your bundle directly, for example on localhost during development, there's no site to answer. deleteSave does nothing, saveData resolves { ok: false, error: "timeout" } and loadData resolves null after about 8 seconds, and listSaves resolves [] after about 6 seconds. If the SDK script doesn't load at all, use a stub while developing locally:

const SDK = window.GameSDK || {
  saveData: () => Promise.resolve({ ok: false, error: "no_sdk" }),
  deleteSave() {},
  loadData: () => Promise.resolve(null),
  listSaves: () => Promise.resolve([]),
};

For how your game is loaded and which runtimes include the SDK, see How games run and GameSDK core reference. For the cloud-save REST endpoints, see REST API overview.

Related

Was this page helpful?