Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Player data (cloud save)

This page describes how to store a player’s progress (level, settings, game save) in Open Play and resume it on another device. Read it if your game carries its own save format and you want to attach it to the player account.

What it does

Player data is a per-player key-value store. The value is an opaque JSON object that the server does not interpret: Open Play stores it, measures its size and returns it unchanged; it does not query its fields or compute anything from them. Your game decides the save format and its version.

This feature is part of the core: it is not tied to a product module and cannot be turned off on the Game settings › Modules page.

Where it is in the panel

What you want to doPanel page
See a player’s keys, sizes and value; delete one if neededPlayers › player profile › “Game data” section

Values cannot be written from the panel; they appear only as read-only formatted JSON. An operator deletion requires a reason (8–500 characters) and is written to the audit log.

Limits

LimitValue
Key^[a-z0-9][a-z0-9._-]{0,63}$ (lowercase letters, digits, ., _, -; at most 64 characters)
ValueTop-level JSON object; its serialized (compact) size is at most 65,536 bytes
Number of keysAt most 16 per player
Write rateAt most 60 writes per minute per player (PUT and DELETE; reads are not counted)

Floating-point numbers are allowed in the value: the value is opaque data and the server does no arithmetic on it. There are two caveats: the order of object keys is not preserved, and integers larger than 2^64 and decimals beyond double precision may be rounded; store large numbers as strings.

Versioned writes (CAS)

Every key has a version: 1 on the first write, increased by 1 on every successful write. The version is an i64 and is carried in JSON as a decimal string ("3"); in the HTTP header it takes the form ETag: "3".

There is no unconditional write. Every PUT carries exactly one of:

  • If-None-Match: * — create only if the key does not exist.
  • If-Match: "<version>" — update only if the current version is this one.

Sending both headers, sending neither, or sending a malformed value returns 428 precondition_required. If the version does not match (another device wrote at the same time) the response is 409 version_conflict together with the current version; read the remote record, merge it with your own rule and retry. Source: crx-domain::player_data.

Calls your game makes

Player endpoints work with a player session: the __Secure-crx.session cookie or an opaque session token (Authorization: Bearer) and x-crx-env: <environment uuid>. Writes (PUT, DELETE) carry the same browser protection as the store purchase endpoint: Origin check and, for a cookie session, x-csrf-token (see Sessions and CSRF). A developer key is not accepted on player endpoints (401).

Method and pathIdentityWhat it does
GET /v1/player/dataplayer sessionLists a summary of the player’s own keys
GET /v1/player/data/{key}player sessionReads one key (ETag: "<version>")
PUT /v1/player/data/{key}player sessionCreates or updates with a precondition
DELETE /v1/player/data/{key}player sessionDeletes with If-Match (204)
GET /v1/players/{player_id}/dataserver keyKey summaries of one player (read only)
GET /v1/players/{player_id}/data/{key}server keyOne key of one player (read only)

Server endpoints require Authorization: Bearer crx_live_<prefix>_<secret>; a client key gets 403 and a player session gets 401. Responses carry Cache-Control: private, no-store.

List

GET /v1/player/data
x-crx-env: 0192f000-0000-7000-8000-00000000e001
{ "items": [ { "key": "my-game.save", "version": "3", "size": 214, "updatedAt": "2026-11-03T10:15:30.120Z" } ] }

Create

PUT /v1/player/data/my-game.save
x-crx-env: 0192f000-0000-7000-8000-00000000e001
x-csrf-token: <csrf token>
If-None-Match: *
Content-Type: application/json

{ "value": { "level": 4, "stars": 17, "playTimeSeconds": 913.5 } }
{ "key": "my-game.save", "version": "1", "updatedAt": "2026-11-03T10:15:30.120Z" }

The response also carries the ETag: "1" header. The body must be exactly { "value": <object> }; any other field returns 422 invalid_value.

Update and delete

PUT /v1/player/data/my-game.save
If-Match: "1"
{ "value": { "level": 5, "stars": 21 } }
DELETE /v1/player/data/my-game.save
If-Match: "2"

Delete returns 204. A player deletion is conditional too; an operator deletion is done from the panel.

Read

GET /v1/player/data/my-game.save
{ "key": "my-game.save", "value": { "level": 5, "stars": 21 }, "version": "2", "updatedAt": "2026-11-03T10:16:02.004Z" }

If the key does not exist the response is 404 with {"code":"not_found"}.

Error codes

Unlike the rest of the core, the errors of these endpoints are JSON: { "code": "…", "message": "…" }.

StatuscodeWhen
404not_foundThe key does not exist (on server endpoints also when the player is not in this environment)
409version_conflictThe version did not match, or If-None-Match: * on an existing key; currentVersion carries the current version (null if the key does not exist)
413value_too_largeThe value exceeded 64 KiB; the limit field gives the limit
422invalid_keyThe key does not match the pattern
422invalid_valueThe value is not an object, the body is not { "value": … }, or it is not JSON
422too_many_keysThe player already has 16 keys; the limit field gives the limit
428precondition_requiredIf-Match / If-None-Match: * is missing, doubled or malformed
429rate_limitedThe 60 writes per minute limit was exceeded; Retry-After gives the wait in seconds
401—No session, an invalid session, or a banned player (the identity layer does not leak the ban distinction)
403—Origin, Fetch Metadata or CSRF rejection; on server endpoints, a client key

A write by a banned player ends with 401 in the identity layer; as a defence the store also rejects the write with 403 and {"code":"account_banned"}.

TypeScript SDK

PlayerClient.data is the typed counterpart of these endpoints: list(), get(key), create(key, value), update(key, value, version), delete(key, version). Versions are bigint; the value is a plain JS object (safe integers are number, out-of-range integers are bigint, decimals are number). The SDK manages the CSRF token and the cookie. A 409 conflict is thrown as PlayerDataConflict (+ currentVersion: bigint | null); other rejections are CrxError and are told apart with playerDataErrorCode(error). See TypeScript SDK.

Working example: save, merge on conflict

/** Oyuncu verisi (bulut kaydı): kaydı oku, değiştir, sürümlü yaz; çatışmada uzak kayıtla birleştirip yeniden dene. */
import { PlayerDataConflict, type PlayerClient, type PlayerDataValue } from "@ferdiunal/open-play-ts";

const KEY = "my-game.save";

/** İki kaydı oyunun kendi kuralıyla birleştirir: burada her bölümde en iyi yıldız sayısı kalır. */
function merge(remote: PlayerDataValue, local: PlayerDataValue): PlayerDataValue {
  const best = (a: unknown, b: unknown): number => Math.max(typeof a === "number" ? a : 0, typeof b === "number" ? b : 0);
  return { ...remote, ...local, stars: best(remote.stars, local.stars) };
}

/** Kaydı buluta yazar; sürüm çatışırsa (başka cihaz yazmıştır) uzak kaydı okur, birleştirir ve en çok 3 kez dener. */
export async function saveToCloud(player: PlayerClient, local: PlayerDataValue): Promise<void> {
  let value = local;
  let current = await player.data.get(KEY).catch((error: unknown) => {
    if (error instanceof Error && "status" in error && error.status === 404) return null;
    throw error;
  });
  for (let attempt = 0; attempt < 3; attempt += 1) {
    try {
      if (current === null) await player.data.create(KEY, value);
      else await player.data.update(KEY, value, current.version);
      return;
    } catch (error: unknown) {
      if (!(error instanceof PlayerDataConflict)) throw error;
      current = error.currentVersion === null ? null : await player.data.get(KEY);
      if (current !== null) value = merge(current.value, value);
    }
  }
  throw new Error("Kayıt üç denemede birleştirilemedi");
}

Retention and deletion

  • The player row is never deleted; when an abandoned guest account is closed (anonymization), that account’s player data is deleted in the same transaction.
  • An operator can delete a key from the panel with a reason; a player deletes their own key with If-Match.
  • Data is isolated per player and environment: a player of one environment cannot be read from another environment.

Common situations

  • I keep getting 409. Another device is writing, or you are passing a stale version to update. Keep the version returned after every write; on a conflict, read the remote record and merge it, do not just increment the raw version and send again.
  • I get 429. The 60 writes per minute limit is per player. Write with a debounce and at meaningful points, not on every keystroke; wait for Retry-After.
  • The 16-key limit. Use one key per save type instead of many small keys (for example my-game.save, my-game.settings).