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 do | Panel page |
|---|---|
| See a player’s keys, sizes and value; delete one if needed | Players › 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
| Limit | Value |
|---|---|
| Key | ^[a-z0-9][a-z0-9._-]{0,63}$ (lowercase letters, digits, ., _, -; at most 64 characters) |
| Value | Top-level JSON object; its serialized (compact) size is at most 65,536 bytes |
| Number of keys | At most 16 per player |
| Write rate | At 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 path | Identity | What it does |
|---|---|---|
GET /v1/player/data | player session | Lists a summary of the player’s own keys |
GET /v1/player/data/{key} | player session | Reads one key (ETag: "<version>") |
PUT /v1/player/data/{key} | player session | Creates or updates with a precondition |
DELETE /v1/player/data/{key} | player session | Deletes with If-Match (204) |
GET /v1/players/{player_id}/data | server key | Key summaries of one player (read only) |
GET /v1/players/{player_id}/data/{key} | server key | One 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": "…" }.
| Status | code | When |
|---|---|---|
| 404 | not_found | The key does not exist (on server endpoints also when the player is not in this environment) |
| 409 | version_conflict | The version did not match, or If-None-Match: * on an existing key; currentVersion carries the current version (null if the key does not exist) |
| 413 | value_too_large | The value exceeded 64 KiB; the limit field gives the limit |
| 422 | invalid_key | The key does not match the pattern |
| 422 | invalid_value | The value is not an object, the body is not { "value": … }, or it is not JSON |
| 422 | too_many_keys | The player already has 16 keys; the limit field gives the limit |
| 428 | precondition_required | If-Match / If-None-Match: * is missing, doubled or malformed |
| 429 | rate_limited | The 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 theversionreturned 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).