# Open Play > Open Play is a game backend platform: player identity and sessions, characters, inventory, economy, store, rewards, social features, leaderboards, notifications, webhooks and realtime connections in a single server. Games connect over HTTP (REST) and WebSocket with the TypeScript or Unity SDK; the game team configures the same server from the admin panel. --- Source: https://open-play.b4klabs.dev/docs/en/getting-started/overview.html # Open Play overview This page summarizes which ready-made building blocks Open Play offers your game and which channel you use to talk to each of them. Read it before you start integrating; follow the links at the end of each section for details. ## What is Open Play? Open Play covers a game's backend needs as a single server: player identity and sessions, characters, inventory, economy, store, rewards, social features, leaderboards, notifications, webhooks, and realtime connections. You connect your game to this server over HTTP (REST) and WebSocket; the team that runs the game configures the same server from the **admin panel**. > **Note** > > Today Open Play is a Docker Compose stack and runs on your or your team's infrastructure. For installation and deployment, see the production deployment guide (`docs/production-and-adapters.tr.md`); this document describes the game developer's point of view. ## What does it offer? The module map Most features are turned on and off as **modules**. The endpoints of a disabled module do not work and return `404` with the code `module_disabled` (see [Concepts](concepts.md#modules)). Modules are managed in the panel under **Game settings › Modules**. | Module | What it gives your game | Guide | |---|---|---| | `characters` | Character definitions, characters, commands, rules, traits, level curves | [Characters](../guides/characters.md) | | `economy` | Currencies, wallets, transfers, ledger | [Economy](../guides/economy.md) | | `items` | Item definitions, item instances, inventory | [Inventory and items](../guides/inventory-items.md) | | `trading` | Merchants and player-to-player trading | Raw REST: [endpoints](../reference/endpoints.md) | | `store` | Store catalog, purchasing, in-app purchase (IAP) verification | [Store and IAP](../guides/store-iap.md) | | `rewards` | Reward tables, quests, NPC rewards, VIP, daily login reward | [Rewards](../guides/rewards.md) | | `social` | Clans and parties | [Social](../guides/social.md) | | `chat` | Chat | [Chat](../guides/chat.md) | | `competition` | Leaderboards and tournaments | [Competition](../guides/competition.md) | | `notifications` | Notification channels, templates, player inbox, web push | [Notifications](../guides/notifications.md) | | `webhooks` | Delivering game events to your own server, signed | [Webhooks](../guides/webhooks.md) | | `scenes` | Scenes and visual assets | [Scenes, assets, models](../guides/scenes-assets-models.md) | | `plugins` | The plugin platform and the realtime surface | [Plugins](../plugins/overview.md), [Realtime](../realtime/overview.md) | **Core** surfaces that have no module switch are always on: - **Player identity and sessions:** guest, email/password, Google. See [Authentication](../auth/overview.md). - **Content groups and catalog:** data managed from the panel and read by the game. See [Content groups](../guides/content-groups.md). - **Game rules and automations.** See [Game rules](../guides/game-rules.md). - **API keys, environments, and domains.** See [Concepts](concepts.md). ## Which channel do you talk through? | Channel | Who uses it? | Credential | What for? | |---|---|---|---| | **REST** (`/v1/...`) + server key | Your game server (trusted backend) | `Authorization: Bearer crx_live__` | Administration and operations that change money or items | | **REST** + client key | Your game client | a client key in the same format | Limited, non-sensitive operations | | **REST** + player session | The player's own client (browser, Unity) | cookie or session token + `x-crx-env` | The player's own data: sign-in, catalog, store, rankings, plugin commands | | **WebSocket** (`/v1/realtime/ws` etc.) | The player's client | single-use ticket | Plugin state, live input, uninterrupted streaming | | **Webhook** (Open Play → your server) | Your server | `X-Crx-Signature` signature | Event notification | | **SDK** | TypeScript and Unity | a wrapper over the channels above | Typed use of REST and WebSocket | > **Warning** > > A server key is a secret. Do not put it in the browser, in a mobile app, or in the game bundle. On the client side, use only the player session and (if needed) a client key. A gRPC interface exists on the server side, but it is not a public channel for game developers; by default it listens on loopback only and the SDKs do not use it. This document does not cover it. ## Where to start 1. [Concepts](concepts.md): studio, game, environment, module, key types, player session. 2. [Quickstart](quickstart.md): your first server call and your first player sign-in. 3. [Architecture and hosting](architecture-and-hosting.md): direct CORS or reverse proxy, domains, what runs where. 4. [Authentication](../auth/overview.md) and the [SDKs](../sdk/typescript.md). ## Current limits The following are written honestly on purpose; plan your roadmap accordingly. - **CORS is open only to origins in the environment's domain list, and the cookie needs the same site.** A browser game can call Open Play directly if its domain is bound to an environment; if the game is on a different registrable domain than Open Play, the `SameSite=Lax` session cookie is not sent (use a reverse proxy). See [Architecture and hosting](architecture-and-hosting.md#calling-from-the-browser-direct-cors-or-reverse-proxy). - **Loading third-party plugins is not supported.** Plugins ship compiled into the server; you cannot upload a plugin from the panel. See [Plugins](../plugins/overview.md). - **SDK coverage is partial.** Modules that have no typed client in the TypeScript SDK are used through raw REST. See [TypeScript SDK](../sdk/typescript.md#scope-which-modules-have-a-typed-client). - **On Unity, only Unity 6000.6.7f1 is verified.** See [Unity SDK](../sdk/unity.md#verified-platforms). - **Error bodies are mostly plain text.** Only a few places have a JSON error envelope. See [Errors](../reference/errors.md). --- Source: https://open-play.b4klabs.dev/docs/en/getting-started/concepts.html # Core concepts Everything in Open Play revolves around a few concepts: the **environment** that data belongs to, the **modules** that turn features on and off, two kinds of **API key**, the player's **session**, and the **plugin** that carries game-specific logic. Read this page once; the other pages assume you know these words. ## Studio, game, environment Open Play uses a three-level hierarchy. In the panel you manage this tree on the **Studios and games** screen (visible only to the super admin). | Level | Meaning | Note | |---|---|---| | **Studio** | The organization that holds your games together | A server can have more than one studio | | **Game** | A single product | When a new game is created, the `dev` and `prod` environments are opened automatically | | **Environment** | An isolated copy of the game: `dev`, `prod`, and `staging` if you want it | Players, characters, wallets, and keys belong to an environment | **The environment is the data boundary.** A player, character, wallet, or key in one environment is not visible from another environment. Every API call runs against a single environment: - In calls made **with an API key**, the environment comes from the key; you do not send an extra header. - In calls made **with a player session**, you declare the environment with the `x-crx-env: ` header. The ID is a UUID. The SDKs send it from the `environmentId` setting. > **Note** > > Today the panel does not show the environment ID (UUID) in a readable field. Ask your operator team for the ID, or read it from the `environmentId` field in the response to the `GET /v1/panel/modules` request that the **Game settings › Modules** tab makes in a panel session. This gap is on record. For the panel steps, see [Quickstart](quickstart.md#1-create-a-game-and-an-environment) and [Game settings](../panel/game-settings-environments.md). ## Modules Most features are modules: `characters`, `economy`, `items`, `trading`, `store`, `rewards`, `social`, `chat`, `competition`, `notifications`, `webhooks`, `scenes`, `plugins` (13 modules). Modules are turned on and off **per environment**; at installation the wizard has you choose a starter set, and afterwards you use the **Game settings › Modules** tab (super admin only). - The endpoints of a disabled module do not work: HTTP `404` with the body `{"code":"module_disabled","message":"…","module":""}`. A caller with invalid credentials gets `401` and cannot find out that the module is disabled. - Disabling does not delete data, it only stops the surface; when you enable it again the data is still there. - Some modules depend on others (for example `trading` relies on `economy` and `items`), and a module that an active plugin needs cannot be disabled. - Core surfaces (player sign-in, content groups, API keys) are not modules and cannot be disabled. For error details, see [Errors](../reference/errors.md). ## API keys: server and client An API key is a secret string bound to an **environment**, sent with the `Authorization: Bearer` header. Its format: ```text crx_live__ ``` The panel shows only the `crx_live_` part (the prefix); the secret is shown **once, at the moment it is created** and cannot be recovered. If you lose it, you create a new key or rotate the key: the old secret becomes invalid and the new one is shown once. | | Server key | Client key | |---|---|---| | Where is it used? | Your game server, your backend code | The game client (browser, mobile) | | Can it be embedded? | **No**, keep it secret | Yes | | What can it do? | All operations: creating definitions, granting money, minting items, registering webhooks, administration | Only permitted, non-sensitive operations | | Example | the `add_xp` command, granting money, minting items | reading a character, the `set_flag` command | The exact rule changes **per endpoint** and is tested per endpoint in code. Which endpoint accepts which key type is listed in the [endpoint reference](../reference/endpoints.md). If the key type is insufficient, the call gets `403`. To create a key in the panel: **Game settings › API keys › New API key** (see [API keys](../panel/game-settings-api-keys.md)). When you create one, you enter a name, the type (Server key or Client key), the validity period, and a note written to the audit log (8–500 characters). > **Warning** > > **Advanced settings** (scope labels, permissions, extra information) are for record-keeping only today; they do not restrict what the key can access. Only the key type determines access. Do not treat a server key as "restricted". For managing keys and secret values, see [Security](../operations/security.md). ## Player session Players talk to the server not with an API key but with their own **sessions**. A session is opened in these ways: guest sign-in, email and password, Google. On a successful sign-in the server writes a session cookie (`__Secure-crx.session`) and a refresh cookie (`__Secure-crx.refresh`); the same tokens are also returned in the response body, so clients without cookies (the Unity native build, command-line tools) can use `Authorization: Bearer `. Every request made with a player session carries the `x-crx-env` header. In a cookie-based (browser) session, state-changing requests additionally require **CSRF** protection (the `x-csrf-token` header). The SDKs manage these details for you; with raw REST you implement them yourself: - [Authentication overview](../auth/overview.md) - [Sessions and CSRF](../auth/sessions-and-csrf.md) The player session can also be carried to **your game server**: the player's short-lived **access JWT** (EdDSA) is verified offline with a shared JWKS. See [Server-side verification](../auth/server-verification.md). ## Plugin A plugin is a package **compiled into the server** that carries game-specific server logic (commands, queries, realtime modules). Plugin calls are made under `/v1/plugins/{plugin}/...`; state-changing commands carry an idempotency key and, if the schema requires it, a version check with `expectedVersion`. A plugin's schema is an artifact (`.plugin.schema.json`), and the TypeScript/Unity bindings are generated from this schema. > **Note** > > Loading third-party plugins (from the panel or the API) is not supported today. Plugins come with the Open Play server release. See [Plugins overview](../plugins/overview.md). ## Common call rules The same three rules apply to many endpoints; you will find the details in the reference pages: - **Idempotency-Key:** for state-changing commands, the same key and the same body replay the result; the same key with a different body returns `422`. See [Idempotency and concurrency](../reference/idempotency-and-concurrency.md). - **If-Match / expectedVersion:** by declaring the version of the resource that you read, you catch a concurrent write conflict (`409`). - **Numbers:** large integers (`i64`) become `bigint` in JavaScript; in the plugin protocol they are carried as decimal strings. See [Numbers and encoding](../reference/numbers-and-encoding.md). For short definitions of terms, see the [Glossary](../reference/glossary.md). --- Source: https://open-play.b4klabs.dev/docs/en/getting-started/quickstart.html # Quickstart In this guide you start from zero and do the following: create a game and a server key in the panel, make your first server call with `curl` and TypeScript, sign a player in as a guest from your browser game, and do the same from Unity. It takes about half an hour. Before you begin, skim the [Concepts](concepts.md) page. **Prerequisites** - A running Open Play server and an admin account that can sign in to the panel (super admin). For installation, see the production deployment guide (`docs/production-and-adapters.tr.md`). - The server's public HTTPS address. This page calls it `OPEN_PLAY_URL` (for example `https://game.example.com/open-play`). - Node.js 22 or later and `curl`; `jq` and `openssl` for the raw REST examples. ## 1. Create a game and an environment If the setup wizard has already created the first studio and game, skip this step. 1. In the panel, open the **Studios and games** screen and create a studio with the **New studio** button. 2. On the studio card, choose **New game**; enter the game's name (and a short name if you like). The `dev` and `prod` environments are created automatically with the game. 3. In the **Active game and environment** selector in the top bar, choose the `dev` environment of the game you created. The rest of the panel shows the data of the selected environment. Details: [Studios and games](../panel/tenancy.md). ## 2. Check the modules On the **Game settings › Modules** tab, confirm that the `characters` module is **On** for the call in this guide. A disabled module returns `404` and `module_disabled`. Details: [Modules](../panel/game-settings-modules.md). ## 3. Create a server key 1. On the **Game settings › API keys** tab, choose **New API key**. 2. Give it a name (for example `Game server`), choose the type **Server key**, set the validity period, and write a short note for the audit log (8–500 characters). 3. Press Create. **Copy the secret value** on the card that opens **right away** and save it in your server's secret settings. You cannot see this value again. The key has this format: `crx_live__`. On this page we will use it as environment variables: ```bash export OPEN_PLAY_URL="https://game.example.com/open-play" export OPEN_PLAY_SERVER_KEY="crx_live__" ``` > **Warning** > > Do not write a real key in documents, repositories, chats, or logs. If you suspect it has leaked, rotate or delete the key in the panel. ## 4. Your first server call With the server key, request the first page of characters in the environment. Because the environment comes from the key, no extra header is needed. ```bash curl -sS "$OPEN_PLAY_URL/v1/characters?limit=1" \ -H "Authorization: Bearer $OPEN_PLAY_SERVER_KEY" ``` If there are no characters yet, the expected response (`200`) is: ```json {"items":[],"next_cursor":null} ``` The same call with the TypeScript SDK (for installation, see [TypeScript SDK](../sdk/typescript.md#installation)): ```ts import { CrxClient, CrxError } from "@ferdiunal/open-play-ts"; /** * Sunucu anahtarıyla ilk çağrı: ortamdaki karakterlerin ilk sayfasını okur. * Bu kod yalnızca güvenilen arka uçta çalışır; sunucu anahtarını tarayıcıya göndermeyin. */ export async function listFirstCharacters(baseUrl: string, serverKey: string): Promise { const client = new CrxClient({ baseUrl, apiKey: serverKey }); try { const page = await client.listCharacters({ query: { limit: 1 } }); return page.items.length; } catch (error) { if (error instanceof CrxError) { // kind, status ve attempts güvenlidir; anahtar ve istek gövdesi hata nesnesine girmez. console.error("Open Play çağrısı başarısız:", error.kind, error.status); } throw error; } } // Kullanım (Node 22, ESM): // const count = await listFirstCharacters(process.env.OPEN_PLAY_URL!, process.env.OPEN_PLAY_SERVER_KEY!); ``` Possible problems: | Symptom | Cause | Fix | |---|---|---| | `401` | The key is wrong, deleted, expired, or the `Bearer` prefix is missing | Check the key and the header; create a new key if needed | | `404`, body `{"code":"module_disabled",…}` | The `characters` module is disabled in this environment | Step 2 | | `403` | The key type is insufficient for this operation (a client key) | Use a server key | | Connection/TLS error | The address is wrong or is not HTTPS | Use the public HTTPS address; `http://localhost` is for local development only | ## 5. Bind your game's domain to the environment The browser adds an `Origin` header to state-changing requests; the server checks that this origin belongs to that environment. If your game page's domain is not defined, player sign-in is rejected with `403`. 1. On the **Game settings › Environments and domains** tab, in the **Domains** field of the relevant environment, enter your game page's domain (one domain per line; for example `game.example.com`). Wildcards, IPs, and ports are not accepted. 2. Write a reason and press **Save domains**. If you do not enter a domain, the server's global allowed list applies; that list is an operator setting. Details: [Architecture and hosting](architecture-and-hosting.md#domains-and-trusted-origins). ## 6. Your first player sign-in from the browser (guest) Your game page calls the API either from the same origin (reverse proxy) or, if its domain is bound to an environment, directly at the Open Play address (CORS) (see [Architecture and hosting](architecture-and-hosting.md#calling-from-the-browser-direct-cors-or-reverse-proxy)). The code below restores a stored session; if there is none, it opens a guest session. `environmentId` is the environment's UUID. ```ts import { createPlayerClient, type PlayerClient } from "@ferdiunal/open-play-ts"; /** * Tarayıcıdaki oyun sayfasından oyuncu oturumu açar: kayıtlı oturum varsa onu geri yükler, * yoksa misafir oturumu açar. `baseUrl` oyun sayfasıyla aynı origin'dir (aynı köken kuralı). */ export async function signInAsGuest(environmentId: string): Promise { const client = createPlayerClient({ baseUrl: window.location.origin, environmentId }); // restore() yalnızca bakar; oturum yoksa misafir yaratmaz. const restored = await client.session.restore(); const session = restored.status === "authenticated" ? restored : await client.session.signInGuest(); console.log("oyuncu:", session.playerId, "misafir mi:", session.isGuest, "ad:", session.displayName); return client; } ``` The SDK generates and stores the guest secret for you, gets the CSRF token, and manages the cookies. When you refresh the page in the same browser, `restore()` brings back the same player. ### The same with raw REST The script below shows what the SDK does behind the scenes (HTTPS is required; the server writes a `Secure` cookie): ```bash export OPEN_PLAY_ENV="" JAR="$(mktemp)" # 1) Get a CSRF token (it is also written to the cookie jar) TOKEN="$(curl -sS -c "$JAR" "$OPEN_PLAY_URL/v1/auth/csrf" | jq -r .token)" # 2) A 32-byte random guest secret: 64 lowercase hexadecimal characters. As long as you keep it, it returns to the same player. BOOTSTRAP="$(openssl rand -hex 32)" # 3) Guest sign-in curl -sS -b "$JAR" -c "$JAR" -X POST "$OPEN_PLAY_URL/v1/auth/sign-in/guest" \ -H "content-type: application/json" \ -H "x-crx-env: $OPEN_PLAY_ENV" \ -H "x-csrf-token: $TOKEN" \ -d "{\"bootstrap_token\":\"$BOOTSTRAP\"}" ``` Response (`200`; `is_new_player` is `true` the first time): ```json { "player": { "id": "0192…", "display_name": "Guest", "email": null, "email_verified": false, "is_guest": true, "image": null, "created_at": "2026-11-03T10:00:00+00:00" }, "tokens": { "session_token": "<64 hex>", "refresh_token": "<64 hex>", "access_token": "", "expires_in_secs": 900 }, "is_new_player": true } ``` The cookie jar now holds `__Secure-crx.session` and `__Secure-crx.refresh`. Verify the session: ```bash curl -sS -b "$JAR" "$OPEN_PLAY_URL/v1/auth/session" -H "x-crx-env: $OPEN_PLAY_ENV" ``` On every state-changing request the CSRF token is **single-use**: the response's `x-csrf-token` header carries the next one. Details: [Sessions and CSRF](../auth/sessions-and-csrf.md), [Guest sign-in](../auth/guest.md). ## 7. Your first player sign-in from Unity After installing the Unity package ([Unity SDK](../sdk/unity.md#installation)), add the component below to a `GameObject`. Fill in the `baseUrl` and `environmentId` fields. In a WebGL build, `baseUrl` must be the same origin as the page. ```csharp using System; using OpenPlay; using OpenPlay.Session; using OpenPlay.Unity; using UnityEngine; /// /// Misafir girişi: kayıtlı oturum varsa geri yükler, yoksa misafir oturumu açar. /// Bir GameObject'e ekleyin; baseUrl ve environmentId alanlarını doldurun. /// public sealed class QuickstartGuest : MonoBehaviour { [SerializeField] private string baseUrl = "https://oyun.example.com"; // WebGL'de sayfayla aynı origin olmalı [SerializeField] private string environmentId = ""; // ortam kimliği (UUID) [SerializeField] private bool allowInsecureTransport = false; // yalnız http://localhost için private OpenPlayClient _client; private async void Start() { try { var options = OpenPlayUnity.Options(new Uri(baseUrl), Guid.Parse(environmentId)); options.AllowInsecureTransport = allowInsecureTransport; _client = OpenPlayClient.Create(options); var state = await _client.Session.RestoreAsync(); // oturum yoksa misafir YARATMAZ if (state.Status != SessionStatus.Authenticated) state = await _client.Session.SignInGuestAsync(); if (state.Status == SessionStatus.Banned) { Debug.Log("Hesap askıya alındı."); // terminal durum; yalnız LogoutAsync ile çıkılır return; } Debug.Log("hazır: oyuncu=" + state.PlayerId + " misafir=" + state.IsGuest); } catch (OpenPlayException ex) { Debug.LogError("Open Play hatası: " + ex.Kind + " (" + ex.Code + ")"); } } private async void OnDestroy() { if (_client != null) await _client.DisposeAsync(); } } ``` > **Note** > > The Unity SDK is verified only with Unity 6000.6.7f1. For details, see [Unity SDK](../sdk/unity.md#verified-platforms). ## What is next? - [Authentication](../auth/overview.md): email, Google, account linking, session management. - [Characters](../guides/characters.md), [Economy](../guides/economy.md), [Store and IAP](../guides/store-iap.md): connect your first feature. - [Webhooks](../guides/webhooks.md): receive events on your server, signed. - [Production checklist](../operations/production-checklist.md): before you go live. --- Source: https://open-play.b4klabs.dev/docs/en/getting-started/architecture-and-hosting.html # Architecture and hosting This page explains how to position your game with Open Play: the parts of the stack, how browser games call Open Play directly (CORS) or through a reverse proxy, binding a domain to an environment, and cookie/TLS requirements. If you will publish a browser or WebGL game, read this page from start to finish. ## What runs where The reference deployment is a Docker Compose stack: | Part | Role | Exposed externally? | |---|---|---| | **api** | REST, WebSocket, and the realtime surface; all business logic | Only through the gateway | | **worker** | Background jobs: event queue (outbox), webhook delivery, scheduled jobs, ranking projections | No | | **PostgreSQL** | Persistent data | No | | **Redis** | Rate limits, CSRF usage records, realtime keys | No | | **converter** | 3D model conversion (`amd64` only) | No | | **public-gateway** | The Caddy gateway opened to players and to the game server; cuts `/v1/panel/*` off with `404` | Yes (the layer in front terminates TLS) | | **admin-gateway** | The admin panel | Loopback only by default | | **grpc-proxy** | gRPC for trusted services | Loopback only by default | ```text Player browser ──HTTPS──▶ game.example.com ─┬─ / ▶ your game's static files └─ /open-play/* ▶ Open Play public-gateway ▶ api Your game server ───────HTTPS───────────────────────────────▶ Open Play public-gateway ▶ api Open Play worker ──signed webhook───────────────────────────▶ your server ``` Public TLS is not terminated inside this stack; you need Cloudflare, a load balancer, or a Caddy/nginx entry point. Because the panel cookie is `Secure`, the panel also requires HTTPS. For installation, resource budgets, and network details, see the production deployment guide (`docs/production-and-adapters.tr.md`). > **Note** > > The gRPC interface is not a public channel for game developers; by default it listens on loopback only and the SDKs do not use it. ## Calling from the browser: direct CORS or reverse proxy **If your game's domain is in the environment's domain list, your browser game can call Open Play directly.** On the public API (panel and setup endpoints excluded) the server sends CORS (cross-origin resource sharing) headers. The allowed set is the union of the domains written to all environments under **Game settings › Environments and domains** in the panel and the operator's global allowed list (`CRX_AUTH_TRUSTED_ORIGINS`); there is no game-specific setting. For example, if the game runs at `https://game.example.com`, Open Play at `https://open-play.example.com`, and `game.example.com` is an environment domain, the game talks to it directly with `baseUrl: "https://open-play.example.com/open-play"`. Rules: - The allowed origin is exactly `https://` (no path, no non-default port). The response reflects that origin verbatim in `Access-Control-Allow-Origin` and adds `Access-Control-Allow-Credentials: true`; `*` is never used and unknown origins get no headers. - A preflight (`OPTIONS`) returns `204` for an allowed origin and `403` for a disallowed one; `Access-Control-Max-Age` is 600 s. - In non-production environments (`CRX_ENV` development or test), if `localhost` or `127.0.0.1` is in the list, `http://localhost:` and `http://127.0.0.1:` are allowed too. - A domain newly added in the panel takes effect within a few seconds (the server snapshot refreshes every 5 s). - **CORS is not authorization.** The `Origin`/CSRF/environment checks run unchanged; CORS only lets the browser read the response. The panel path (`/v1/panel/*`) never gets CORS headers. The realtime WebSocket handshake is unchanged: `Origin` is checked per environment. > **Warning** > > **The session cookie needs the same site.** Cookies are written with `__Secure-`, `HttpOnly`, `SameSite=Lax`. If the game is on a **different registrable domain** than Open Play (for example `game.example.com` and `open-play.other.dev`), the browser does not attach the session cookie to cross-site `fetch` requests; even with correct CORS headers a session cannot be established. Subdomains of the same registrable domain (for example `game.example.com` and `open-play.example.com`) are the same site and work. A different site is not supported; use the reverse proxy below in that case. **A reverse proxy is still an option.** If you want to serve the game and the API from the same origin (or the game is on a different site), set up a reverse proxy on the game's own gateway that routes the API path to Open Play; CORS then does not come into play. Example (Caddy): ```caddyfile game.example.com { # Strips the /open-play/ prefix and forwards to the Open Play public-gateway. handle_path /open-play/* { reverse_proxy open-play-gateway:8080 } # Everything else is your game's static files. handle { root * /srv/game try_files {path} /index.html file_server } } ``` With a reverse proxy, the client-side configuration is: | Setting | Value | |---|---| | TypeScript `baseUrl` | `https://game.example.com/open-play` (the SDKs accept a path prefix), or `window.location.origin` if you do not use a prefix | | Unity `BaseUrl` | the same address; today the Unity WebGL SDK requires the same origin as the page, otherwise you get the `webgl_cross_origin_unsupported` configuration error (server CORS does not lift this; see [Unity SDK](../sdk/unity.md)) | | `x-crx-env` | the environment ID (the SDKs send it from the `environmentId` setting) | Watch out for these on the reverse proxy: - **The WebSocket upgrade** (`Upgrade`/`Connection`) must pass through; the realtime connection is on `/v1/realtime/ws`. Caddy's `reverse_proxy` does this on its own; on nginx, set `proxy_http_version 1.1` and the `Upgrade` headers explicitly. - **The `Origin` and `Cookie` headers** must be forwarded as they are. - **Client IP:** set the `X-Forwarded-For` header correctly. If there is an extra proxy in front and the proxy addresses the server should trust have not been declared, all players fall into a single IP bucket; the symptom is different players getting `429` at the same time. Resolve this with your operator team (see [Rate limits](../reference/rate-limits.md)). - **Do not open the panel path.** The `/v1/panel/*` path is `404` on the public-gateway; do not give the panel a route through your game domain. ### Cookie behavior | Cookie | Purpose | Attributes | |---|---|---| | `__Secure-crx.session` | Player session | `HttpOnly; Secure; SameSite=Lax; Path=/` | | `__Secure-crx.refresh` | Session refresh | same | | `__Secure-crx.csrf` | CSRF double-submit cookie | same; 15-minute lifetime | `Secure` cookies require HTTPS. In local development, the SDKs allow plain `http` only on loopback addresses (`localhost`, `127.0.0.1`, `::1`); check whether your browser accepts `Secure` cookies for `localhost`, and if it does not, use HTTPS locally as well. ## Domains and trusted origins The browser adds an `Origin` header to state-changing (POST, PUT, PATCH, DELETE) requests. The server checks whether this origin is a domain bound to the relevant environment: - **Domain bound to the environment:** the names you enter in the panel under **Game settings › Environments and domains › Domains**. Each line is one full domain (for example `game.example.com`); there are no wildcards, IPs, or ports; at most 10 domains can be bound per environment. The same domain cannot be bound to two environments (`409`). - **Global allowed list:** the operator's server setting (`CRX_AUTH_TRUSTED_ORIGINS`: a full domain or `*.domain`). If the environment has no domain of its own, this list applies. - **If a domain is bound to another environment**, a browser coming from that domain cannot write to this environment and cannot read this environment (`403`). - **Clients that do not send `Origin`** (your game server, non-browser clients such as `curl`) are not caught by the origin check; if they use cookies, the CSRF double-submit protection checks them. - If `Sec-Fetch-Site: cross-site` and `Sec-Fetch-Mode: navigate` arrive together, the state-changing request is rejected. The realtime ticket is also bound to the same origin rule: a ticket is single-use and bound to the origin it was obtained from. > **Note** > > Unity native builds send a fixed `Origin` derived from configuration on ticket and WebSocket requests (the scheme, domain, and non-default port of `BaseUrl`; can be changed with `NativeOrigin`). This origin must be in the environment's domains or in the global allowed list. See [Unity SDK](../sdk/unity.md). Domain binding steps: [Environments and domains](../panel/game-settings-environments.md). ## Calling from your game server Your game server connects **directly** (HTTPS) to Open Play with a server key. No reverse proxy, CORS, or domain binding is needed (CORS concerns only the browser); the environment comes from the key. Keep the key only in the server environment (an environment variable or secret management). To verify a player's identity on your server, you can verify the access JWT offline: [Server-side verification](../auth/server-verification.md). ## Webhooks (Open Play → your server) The worker sends events to your address as signed HTTPS requests. Your address must be publicly reachable and must verify the signature. See [Webhooks](../guides/webhooks.md) and [Security](../operations/security.md). ## Pre-release check The [Production checklist](../operations/production-checklist.md) gathers the items for domain binding, key management, TLS, and monitoring. --- Source: https://open-play.b4klabs.dev/docs/en/getting-started/ai-tools.html # Using AI tools (MCP) The Open Play MCP (Model Context Protocol) server lets your AI assistant answer questions such as "how do I sign in a guest", "how do I save player data" or "how do I call a plugin" from the official documentation on this site and from the SDK API, instead of guessing. The server is **read-only**: it never touches your Open Play server or your keys; the docs and the SDK index are read only from the public docs site. ## Installation ### Hosted server (recommended) Connect to `https://open-play.b4klabs.dev/mcp` without installing anything (Streamable HTTP, read-only, no authentication): ```bash claude mcp add --transport http open-play https://open-play.b4klabs.dev/mcp ``` For Cursor add this to `.cursor/mcp.json`, for VS Code to `.vscode/mcp.json` (use the key `servers` instead of `mcpServers` there): ```json { "mcpServers": { "open-play": { "url": "https://open-play.b4klabs.dev/mcp" } } } ``` The server loads the docs and SDK index from `https://open-play.b4klabs.dev/docs/mcp/index.json`; the index is rebuilt together with the docs site, so you do not need to update the server when a page is published. ### Local package If you prefer a local process over the network, the package `@ferdiunal/open-play-mcp` is published on GitHub Packages (source: [ferdiunal/open-play-mcp](https://github.com/ferdiunal/open-play-mcp)). Add the line `@ferdiunal:registry=https://npm.pkg.github.com` and a GitHub token with `read:packages` to your user `.npmrc` (never write the token into docs or a repository), then: ```bash claude mcp add open-play -- npx -y @ferdiunal/open-play-mcp ``` For Cursor and VS Code use `"command": "npx"` and `"args": ["-y", "@ferdiunal/open-play-mcp"]`. The local package loads the same index from the live address; without a network it falls back to its last cache, and then to the snapshot bundled in the package. ## Tools Tool names and descriptions are in English; pass `lang: "tr"` to get the Turkish page content (the default is `en`). | Tool | What it does | Example input | |---|---|---| | `search_docs` | Searches the docs; scoring runs title, then headings, then body; returns the page id, description and public URL | `{"query": "guest sign in"}` | | `read_doc` | Returns the full markdown of one page (at most 40,000 characters) | `{"page": "auth/guest"}` | | `list_docs` | Lists page ids and titles; `section` limits it to one section | `{"section": "auth"}` | | `search_sdk` | Searches type, function and member names in the TypeScript and Unity SDKs | `{"query": "signInGuest", "sdk": "typescript"}` | | `get_sdk_symbol` | Returns the signature, summary and members of a type; the `Type.Member` form is supported | `{"name": "PlayerSession", "sdk": "unity"}` | Every doc page is also a **resource** at `open-play-docs:///` (for example `open-play-docs://en/auth/guest`), and a **prompt** named `integrate_open_play` (`platform`: `web`, `unity` or `server`) returns integration steps and the right page links. > **Limit:** The Unity API index is extracted from the C# source by a lightweight parser; details such as inheritance and generic type parameters may be missing. For an exact reference use the generated reference linked from the [Unity SDK](../sdk/unity.md) page. SDK summaries are in Turkish for now, because they come from the Turkish comments in the source code. ## llms.txt and llms-full.txt For tools that cannot use MCP (or when you want to paste the docs into a chat), the docs are also published as plain text in the [llmstxt.org](https://llmstxt.org) format: | File | Contents | |---|---| | [`https://open-play.b4klabs.dev/llms.txt`](https://open-play.b4klabs.dev/llms.txt) | English index: every page's title, one-line description and the address of its `.md` copy | | [`https://open-play.b4klabs.dev/llms-full.txt`](https://open-play.b4klabs.dev/llms-full.txt) | All English docs in one file | | [`https://open-play.b4klabs.dev/docs/tr/llms.txt`](https://open-play.b4klabs.dev/docs/tr/llms.txt) | Turkish index | | [`https://open-play.b4klabs.dev/docs/tr/llms-full.txt`](https://open-play.b4klabs.dev/docs/tr/llms-full.txt) | All Turkish docs in one file | The English files are also at `https://open-play.b4klabs.dev/docs/llms.txt` and `https://open-play.b4klabs.dev/docs/llms-full.txt`. Every page has a markdown copy at its HTML address with `.md` instead of `.html` (for example `https://open-play.b4klabs.dev/docs/en/auth/guest.md`). The files are refreshed with the docs site. ## Example questions - "How do I sign a player in as a guest?" → `search_docs` + `read_doc` (`auth/guest`). - "How do I save player data?" → `guides/player-data`. - "How do I call a plugin?" → `plugins/calling-plugins`. - "Which methods does `PlayerSession` have in Unity?" → `get_sdk_symbol`. For a first look, start from the [Quickstart](quickstart.md). To make your agent use Open Play correctly in your game, put the ready-made rules file into your repository: [AGENTS.md for your game](agents-md.md). --- Source: https://open-play.b4klabs.dev/docs/en/getting-started/agents-md.html # AGENTS.md for your game `AGENTS.md` is the rules file that AI coding agents (Claude Code, Cursor, Codex and similar) read automatically from the root of your repository. When agents connect a game to Open Play, the mistakes they make most often are: guessing an endpoint or header, putting the server key in the client, skipping the CSRF and session rules, not pinning the plugin schema, removing a domain too early, and leaving stale cached JavaScript running in production. The template on this page puts the rules that prevent those mistakes into one file. The rules come from problems met in real game integrations (the example games ArenaRace and LanternTide), and every rule links to the docs page behind it. ## How to use it 1. Download the template: [`https://open-play.b4klabs.dev/docs/AGENTS.md`](https://open-play.b4klabs.dev/docs/AGENTS.md) (or copy the block below) and save it as `AGENTS.md` in the root of **your game repository**. If you already have an `AGENTS.md`, add the template under an "Open Play integration" heading. 2. In "Project facts", replace the placeholders ``, ``, ``, ``, `` and `` with your values. Environment ids are not secret; **do not write keys or secrets into the file**. 3. Add the Open Play MCP server to your agent ([Using AI tools](ai-tools.md)): `claude mcp add --transport http open-play https://open-play.b4klabs.dev/mcp`. The template tells the agent to use `search_docs`, `read_doc`, `search_sdk` and `get_sdk_symbol` first, so it takes behavior from the current docs and not from memory. 4. Delete the sections your game does not use (for example section 6 if you use no plugins). Add your own rules (code style, test commands, branch workflow) above or below the template. ## What the template covers | Section | What it prevents | |---|---| | MCP first | Endpoints, headers and field names written from guesses | | Keys and environments | A server key leaking into the client, dev and prod mixed up | | Sign-in and sessions | Guest flow, Google popup with a redirect fallback, the redirect base (`/auth/callback`), the CSRF race, single active session, account deletion | | Domains and browser | Unregistered origins, removing a live domain too early, a proxy dropping headers | | Player data | A separate save server, writing without `If-Match` | | Plugins | Schema mismatch, a command resent with a new key, a disabled grant after a version bump | | Realtime | Ticket reuse, replayed inputs, a one-at-a-time input flow that feels laggy | | Caching and deploys | A CDN overriding `no-cache` and keeping old JavaScript | | Errors and limits | Parsing error text, a shared per-IP bucket | | Testing and Do / Don't | A checklist before you say "done" | The template is in English because AI tools and the MCP index of the docs site work best with English text. It is also listed in the "Optional" section of the `https://open-play.b4klabs.dev/docs/llms.txt` index. > **Note** > > The template is a **rules file**, not a guarantee from Open Play. The docs pages are the only source of truth for behavior; if the template and a page disagree, the page is right. We recommend trying the template in a real agent session and tightening it for your game. ## The full template ````markdown # AGENTS.md — integrating this game with Open Play > Drop this file in the root of your game repository. Replace every `` and delete rules that do not apply. > Source and updates: https://open-play.b4klabs.dev/docs/AGENTS.md This game uses **Open Play**, a game backend platform: player identity and sessions, player data (cloud save), characters, economy, store, rewards, social features, leaderboards, notifications, plugins and realtime connections. The game talks to Open Play over HTTP and WebSocket, through the TypeScript SDK (`@ferdiunal/open-play-ts`) or the Unity SDK. The game does **not** run its own account or save server for anything Open Play provides. Docs root: https://open-play.b4klabs.dev/docs/en/ — every rule below links to its page. ## 0. Project facts (fill in) - Environment ids (the `x-crx-env` value): dev ``, prod ``. One environment per stage; data is never shared between them. - Open Play base URL: `` (the API address the browser calls). - Game origins registered per environment: ``, ``. - SDK version (exact, no `^`, no `latest`): ``. ## 1. Use the Open Play MCP server first Before you write any call to Open Play, **look it up**. Never guess an endpoint, header, field or error code. ```bash claude mcp add --transport http open-play https://open-play.b4klabs.dev/mcp ``` Cursor (`.cursor/mcp.json`) and VS Code (`.vscode/mcp.json`, key `servers`): `{"mcpServers":{"open-play":{"url":"https://open-play.b4klabs.dev/mcp"}}}`. - `search_docs` then `read_doc` (for example `auth/oauth`) for behavior and HTTP contracts. - `search_sdk` then `get_sdk_symbol` (for example `PlayerSession`, `sdk: "typescript"` or `"unity"`) for exact SDK signatures. - The server is read-only and public; it never needs or sees a key. If the docs and your memory disagree, the docs win. - No MCP available: read https://open-play.b4klabs.dev/docs/llms.txt (index) or `llms-full.txt` (everything). Docs: [Using AI tools (MCP)](https://open-play.b4klabs.dev/docs/en/getting-started/ai-tools.html). ## 2. Keys and environments - **Client** code (browser, WebGL, mobile) knows only the environment id and the player session. It carries **no secret**. - A **server key** (`crx_live__`) lives only on your own server or backend-for-frontend (BFF): in its secret store, never in the client bundle, the repository, logs, or `.env` files that are committed. Use a separate key per environment and service, with an expiry. - A client key is not a server key; calling a server endpoint with it is rejected. Player operations use the player session, not a key. - Never point a dev build at the prod environment or the reverse. Read the environment id from per-stage configuration. - Do not hard-code or print secrets, tokens, cookies or `x-csrf-token` values in code, tests, logs or analytics. Docs: [Security](https://open-play.b4klabs.dev/docs/en/operations/security.html), [API keys](https://open-play.b4klabs.dev/docs/en/panel/game-settings-api-keys.html), [Production checklist](https://open-play.b4klabs.dev/docs/en/operations/production-checklist.html). ## 3. Sign-in and sessions - **Guest first.** Open a guest session on first launch so the player plays at once; offer Google (or email) later and **link** it to the same player. Linking a guest upgrades the same player id; progress is kept. - **Google OAuth:** try a popup window and fall back to a full-page redirect when the popup is blocked. Open the popup synchronously inside the click handler, before any `await`. Let the callback page hand the result back (for example through `BroadcastChannel`) and close itself. - The server builds the Google `redirect_uri` from the configured **redirect base + `/google`**. The base must end at `/auth/callback` (not `/google`); the redirect URI registered with Google must be exactly `/google`, and that path must be a page of your game that finishes the sign-in. - The OAuth `code` and `state` are single-use: finish exactly once, then remove them from the address bar (`history.replaceState`). Use the SDK helpers; they bind `state` to the browser tab. - A cookie session sends `x-crx-env` on every call and a CSRF token on every state-changing request. The token is **single-use and rotates**: never send two mutations at once with the same token, and keep the new token from each response. The SDKs serialize this for you; share the token between tabs with the SDK `csrf.channel` option. Retry a CSRF `403` once with a fresh token. - Session lifetime is 30 days sliding, 180 days absolute. On `401` refresh or sign in again; do not loop. - If the environment allows a **single active session**, a newer sign-in revokes the older one. Handle `reauthRequired` with a clear "signed in on another device" screen and a sign-in button, not a silent retry. - Show the sign-in methods you actually enabled; hide the rest. Do not use SDK methods the docs mark as unsupported. - **Account deletion** is a store requirement (Apple, Google Play): provide an in-game "Delete my account" action (`POST /v1/auth/account/delete`, `confirm: "delete_my_account"`). A linked account must have signed in within the last 10 minutes (`recent_sign_in_required`): ask the player to sign in again, then repeat. - Verify identity on your own game server with the access JWT and the JWKS, never by trusting a client-sent player id. Docs: [Auth overview](https://open-play.b4klabs.dev/docs/en/auth/overview.html), [Guest](https://open-play.b4klabs.dev/docs/en/auth/guest.html), [Google sign-in](https://open-play.b4klabs.dev/docs/en/auth/oauth.html), [Sessions and CSRF](https://open-play.b4klabs.dev/docs/en/auth/sessions-and-csrf.html), [Account linking](https://open-play.b4klabs.dev/docs/en/auth/account-linking.html), [Account deletion](https://open-play.b4klabs.dev/docs/en/auth/account-deletion.html), [Server-side verification](https://open-play.b4klabs.dev/docs/en/auth/server-verification.html). ## 4. Domains and calling from the browser - Register **every origin your game runs on** in each environment's domain list (Game settings, Environments and domains): one full domain per line, no wildcards, no ports, at most 10, and a domain belongs to one environment only. An unregistered origin gets `403` on writes. - Call Open Play either directly (your game domain is in the list; the server sends CORS headers with credentials) or through a same-origin reverse proxy that forwards `Cookie`, `Origin`, `x-crx-env`, `x-csrf-token`, `Idempotency-Key`, `If-Match` and the WebSocket upgrade, and that forwards the real client IP. - **Moving to a new domain:** add the new domain and the new OAuth redirect first, test it, and only then retire the old one. **Removing a domain from an environment locks that live host out immediately.** Guest identities are per-origin: a new domain starts new guests. - Send `Origin` only as the browser sets it. Never fake it in game code. Docs: [Architecture and hosting](https://open-play.b4klabs.dev/docs/en/getting-started/architecture-and-hosting.html), [Environments and domains](https://open-play.b4klabs.dev/docs/en/panel/game-settings-environments.html). ## 5. Player data (cloud save) - Use the generic per-player key-value store; **do not build a game-specific save server**. The value is an opaque JSON object the server never interprets; your game owns the format and its version field. - Limits: key `^[a-z0-9][a-z0-9._-]{0,63}$`, at most 16 keys per player, 64 KiB per value, 60 writes per minute per player. Store large integers as strings. - **There is no blind write.** Every `PUT` sends exactly one of `If-None-Match: *` (create) or `If-Match: ""` (update). On `409 version_conflict` read the remote record, merge it with your own rule, retry. `428` means a precondition header is missing. - Batch saves (on level end, on pause, on a timer), not every frame. Keep a local copy and reconcile on sign-in; a guest-to-Google link keeps the same player. - Never write personal data you do not need. Player data is erased with the account. Docs: [Player data](https://open-play.b4klabs.dev/docs/en/guides/player-data.html), [Idempotency and concurrency](https://open-play.b4klabs.dev/docs/en/reference/idempotency-and-concurrency.html). ## 6. Plugins (game-specific server logic) - Call plugins only through **generated bindings**, never hand-written DTOs or copied field names. Get the schema document from `GET /v1/plugins//schema` and generate bindings from it. - **Pin** `pluginId`, `schemaSha256`, `protocolVersion`, `codecProfile` and the plugin version in your release manifest. A mismatch must **fail closed**: show an "update the game" screen, never talk to the server with the wrong fields. After every server update compare the live digest with the pin (at startup or in CI). - Every command carries an `Idempotency-Key`, generated **once** per logical action and reused on retries. If the outcome is uncertain (timeout, drop), resend with the **same key and body** or ask the result query; never generate a new key for the same action. - Retry only when it is safe: idempotent calls on network errors, `429`, `503`, with exponential backoff and jitter, and honor `Retry-After`. Never retry business rejections (`422`). - `int64` values travel as decimal strings. Send `If-Match` / `expectedVersion` when writing a shared resource; on `409` re-read first. - If a plugin call suddenly returns `403`, check that the plugin is **enabled for that environment** and that the enabled version matches the server version. A server version bump needs its grant switched too. Docs: [Plugins overview](https://open-play.b4klabs.dev/docs/en/plugins/overview.html), [Bindings and pinning](https://open-play.b4klabs.dev/docs/en/plugins/bindings.html), [Calling plugins](https://open-play.b4klabs.dev/docs/en/plugins/calling-plugins.html). ## 7. Realtime - Use the SDK realtime client; if you implement the protocol yourself follow it exactly (single-use ticket, `Origin`, subprotocol). - Ask for a **new ticket on every connection**. On a drop reconnect with exponential backoff, `Join` again with the last cursor (durable streams) or without one (ephemeral state), and on `ResyncRequired` drop the cursor and join fresh. - Unacknowledged inputs are **not replayed**; continue from the latest snapshot. Pipeline inputs inside the SDK window (default 4 in flight); one input per round trip makes fast games feel laggy on real networks. - Keep the frame rate below 40 per second; at most 4 sockets per player. Handle idle close (`4100`) with a "Continue" screen. An open socket does not keep the session alive. - Notification and chat sockets cannot be opened straight from a browser; use inbox polling or REST. Docs: [Realtime overview](https://open-play.b4klabs.dev/docs/en/realtime/overview.html), [Protocol](https://open-play.b4klabs.dev/docs/en/realtime/protocol.html), [Reconnect and limits](https://open-play.b4klabs.dev/docs/en/realtime/reconnect-and-limits.html). ## 8. Caching and deploys - Serve your game's JavaScript modules and config (including the file that holds the environment id) with `Cache-Control: no-cache`, or content-hash the filenames. A stale config file after a deploy silently runs the old integration. - If a CDN sits in front, set it to **respect existing headers** for HTML, JS and CSS. A CDN default browser TTL can override your `no-cache` and keep old code for hours. - Never put a CDN cache in front of API responses that carry a cookie or `Authorization`. - Deploy order when Open Play changes a plugin: new server and grant, then client with regenerated bindings. Pin the SDK version exactly and upgrade from the changelog. Docs: [Production checklist](https://open-play.b4klabs.dev/docs/en/operations/production-checklist.html), [Versioning](https://open-play.b4klabs.dev/docs/en/reference/versioning.html). ## 9. Errors and rate limits - Core endpoints return **plain-text** errors; plugin endpoints return a JSON envelope (`code`, `message`, `retryable`, `requestId`). Decide by status code and JSON `code`, **never by parsing message text**. Log `requestId` / `x-request-id`. - There is no general per-key quota, but identity endpoints are limited per client IP (for example 30 guest sign-ins per minute). Behind a proxy or a shared network (NAT, a hosting platform's shared egress) many players share one IP: forward the real client IP. - Back off on `429`, `503` and network errors; wait `Retry-After` when present. Never loop on `401`, `403` or `422`. Docs: [Errors](https://open-play.b4klabs.dev/docs/en/reference/errors.html), [Rate limits](https://open-play.b4klabs.dev/docs/en/reference/rate-limits.html). ## 10. Testing checklist (before you call it done) 1. Fresh browser profile: guest sign-in, play, reload, same player and progress. 2. Guest upgraded to Google keeps progress; a second browser signs in to the same account and loads the save. 3. Two tabs open: no CSRF `403` storm; sign-out in one tab is handled in the other. 4. Same command sent twice with the same `Idempotency-Key` gives one effect. 5. Two devices write the same player-data key: the `409` is merged, nothing is lost. 6. Kill the network mid-command and mid-socket: the game recovers without duplicate effects. 7. Prod build points at the prod environment id and prod origin; dev at dev. No secrets in the bundle (search it). 8. Run these in a **headless browser or test harness** you control; do not test against another team's environment or real player data. ## Do - Look up docs through MCP first; link the page you used in the PR description. - Keep Open Play calls in one module/service layer, one writer per feature (never write the same state both locally and through Open Play). - Handle every SDK session state, including `reauthRequired`, `banned` and `logoutUncertain`. - Add a test with each integration change; report failing checks, never skip them. ## Don't - Do not guess endpoints, headers, field names or error codes. - Do not put a server key, token or cookie in client code, tests, logs or commits. - Do not use `latest` or git/vendored copies of the SDK; do not hand-copy DTOs. - Do not bypass CSRF, `Origin` or idempotency checks "just for testing". - Do not remove a domain, redirect URI or environment that a live host still uses. - Do not store save data in browser storage alone once the player can sign in. - Do not write retry loops without backoff, or retry non-idempotent calls with new keys. - Do not run production deploys or destructive operations unless the user explicitly asks. ```` Next step: [Quickstart](quickstart.md) and the [Production checklist](../operations/production-checklist.md). --- Source: https://open-play.b4klabs.dev/docs/en/auth/overview.html # Authentication overview This page summarizes how a player signs in to Open Play: which methods exist, which rules they all share, and which page to go to for details. If you are wiring up player sign-in, start here. ## Sign-in methods | Method | When? | SDK | Details | |---|---|---|---| | **Guest** | Quick start without an account; progress is kept on the same device | TS: `signInGuest` · Unity: `SignInGuestAsync` | [Guest sign-in](guest.md) | | **Email and password** | A permanent account; includes password reset and change | TS and Unity: sign-up, sign-in, upgrade, password operations | [Email and password](email-password.md) | | **Google** (OAuth) | One-click web sign-in; if there is a verified email, it links automatically to the existing account | TS: `startGoogleOAuth`, `completeGoogleOAuth` · Unity: none | [Sign in with Google](oauth.md) | | **Native platform proof** (Apple, Game Center, Play Games) | Native mobile sign-ins | **No SDK helper yet**; HTTP endpoint only | [Sign in with Google › Native proofs](oauth.md#native-platform-proofs) | | **Magic link** | A single-use link sent by email | **No SDK helper yet**; the server endpoint exists | [Email and password › Magic link](email-password.md#magic-link) | Which methods to offer players is the game's choice; for Google to work, it must be configured in the environment (**Game settings › Sign-in methods**). Topics after sign-in: [Sessions and CSRF](sessions-and-csrf.md), [Account linking](account-linking.md), [Server-side verification](server-verification.md). ## Which method should I choose? - **If an account is not required**, start with guest; as the player progresses, link them to email or Google with **guest upgrade**. The player ID (`playerId`) does not change. - **If you want multiple devices**, you need email/password or Google; a guest identity depends on the secret the device stores. - For **native mobile sign-in** (Apple, Game Center, Play Games), HTTP endpoints exist, but the SDKs have no helper. ## Rules that apply to every call The rules below apply to all endpoints under `/v1/auth/*`. **1. Environment header.** Every request carries `x-crx-env: `. If it is missing or not a UUID, the response is `401`. **2. Content type.** Requests with a body are sent with `Content-Type: application/json`. **3. CSRF.** Every request other than `GET` requires double-submit CSRF protection: the `__Secure-crx.csrf` cookie and a matching `x-csrf-token` header. On every successful use the token is returned and changes. You first get a token with `GET /v1/auth/csrf`. Details: [Sessions and CSRF](sessions-and-csrf.md#csrf-double-submit). **4. Origin check.** For state-changing requests coming from a browser, the `Origin` domain must be bound to the environment. See [Architecture and hosting](../getting-started/architecture-and-hosting.md#domains-and-trusted-origins). **5. Field names are `snake_case`.** Request and response bodies use `snake_case` (`display_name`, `bootstrap_token`, `old_password`). Two exceptions: `displayName` in the `GET /v1/auth/session` response, and the body/response of `POST /v1/auth/session/display-name` (`displayName`). The SDKs hide this difference from you and present `camelCase`. **6. Error bodies.** Errors are mostly **plain text** (`permission denied`, `rate limited`…). The only meaningful error with a JSON body is the `403` `{"code":"account_banned","message":"account banned"}` response for a banned account. Status codes: `400` invalid input, `401` environment or session missing/invalid, `403` denied (wrong password, CSRF, expired token — **the distinction is deliberately not leaked**), `409` conflict, `422` unprocessable, `429` rate limit. Details: [Errors](../reference/errors.md). ## Successful sign-in response The sign-in, sign-up, refresh, and OAuth callback endpoints return the same envelope and write two cookies (`__Secure-crx.session`, `__Secure-crx.refresh`; `HttpOnly; Secure; SameSite=Lax; Path=/`, `Max-Age=15552000` = 180-day upper bound): ```json { "player": { "id": "0192…", "display_name": "Guest", "email": null, "email_verified": false, "is_guest": true, "image": null, "created_at": "2026-11-03T10:00:00+00:00" }, "tokens": { "session_token": "<64 hex>", "refresh_token": "<64 hex>", "access_token": "", "expires_in_secs": 900 }, "is_new_player": true } ``` | Field | Meaning | |---|---| | `player.id` | The permanent player ID (UUID). Does not change on guest upgrade | | `player.is_guest` | Whether the account is still a guest | | `tokens.session_token` | An opaque session token. Can also be used with `Authorization: Bearer` instead of the cookie | | `tokens.refresh_token` | Only for `/v1/auth/refresh` and `/v1/auth/logout` | | `tokens.access_token` | A short-lived (15 min) EdDSA JWT; it is for offline verification by your game server. See [Server-side verification](server-verification.md) | | `tokens.expires_in_secs` | The lifetime of the access JWT (seconds) | | `is_new_player` | `true` if the call created a new player | > **Warning** > > In browser games, do not store the session and refresh tokens in JavaScript; the cookies are `HttpOnly`, and that is what they are for. The SDKs do not put token bodies in state, logs, or errors. ## Rate limits The auth endpoints are subject to **fixed-window** rate limits. When exceeded, `429` and the plain text `rate limited` are returned; **there is no `Retry-After` header**: you must wait out the window yourself. Do not resend a failed attempt immediately. | Limit | Key | Value | |---|---|---| | Opening a guest (including re-sign-in) | IP | 30 / 60 s | | Email sign-in | IP | 5 / 60 s | | Failed email sign-in lock | address + IP | 10 / 15 min (while locked, even the correct password gets `429`; a successful sign-in refunds the allowance) | | Sign-up | IP · address | 10 / 60 s · 5 / 15 min | | Guest upgrade | IP · guest player · address | 10 / 60 s · 30 / 60 s · 5 / 15 min | | Password change | IP · account | 10 / 60 s · 6 / 60 s | | Password reset request (forgot) and magic link | IP · address | 5 / 60 s · 5 / 15 min | | Password reset (reset) | IP | 5 / 60 s (shared bucket with forgot and magic link) | | OAuth start | IP | 30 / 60 s | | OAuth callback | IP | 60 / 60 s | | Display name change | player | 10 / hour | If there is an extra proxy in front of the server and the trusted proxy addresses have not been declared, all players fall into a single IP bucket. See [Architecture and hosting](../getting-started/architecture-and-hosting.md#calling-from-the-browser-direct-cors-or-reverse-proxy). ## Endpoint summary | Method and path | Session | Description | Page | |---|---|---|---| | `GET /v1/auth/csrf` | not required | Generates a CSRF token | [Sessions and CSRF](sessions-and-csrf.md) | | `POST /v1/auth/sign-in/guest` | not required | Guest sign-in | [Guest](guest.md) | | `POST /v1/auth/sign-up/email` | not required | Sign-up | [Email](email-password.md) | | `POST /v1/auth/sign-in/email` | not required | Sign-in | [Email](email-password.md) | | `POST /v1/auth/guest/upgrade/email` | guest session | Upgrades a guest to email | [Email](email-password.md) | | `POST /v1/auth/change-password` | required | Changes the password; all sessions close | [Email](email-password.md) | | `POST /v1/auth/forgot-password` | not required | Requests a reset email | [Email](email-password.md) | | `POST /v1/auth/reset-password` | not required | New password with a token | [Email](email-password.md) | | `POST /v1/auth/sign-in/magic-link` · `GET /v1/auth/magic-link/verify` | not required | Magic link | [Email](email-password.md#magic-link) | | `GET /v1/auth/oauth/providers` | not required | Providers enabled in the environment | [OAuth](oauth.md) | | `POST /v1/auth/sign-in/oauth/{provider}` | not required | Starts OAuth (`{url, state}`) | [OAuth](oauth.md) | | `GET /v1/auth/callback/{provider}` · `POST` (form) | not required, or required for link | Completes OAuth | [OAuth](oauth.md) | | `POST /v1/auth/native/{provider}` | not required | Native platform proof | [OAuth › Native](oauth.md#native-platform-proofs) | | `POST /v1/auth/link/{provider}` | required | Starts linking an identity | [Account linking](account-linking.md) | | `POST /v1/auth/unlink/{provider}` | required | Unlinks an identity | [Account linking](account-linking.md) | | `GET /v1/auth/identities` | required | Linked identities | [Account linking](account-linking.md) | | `GET /v1/auth/session` | required | Session and profile | [Sessions](sessions-and-csrf.md) | | `POST /v1/auth/session/display-name` | required | Changes the display name | [Sessions](sessions-and-csrf.md) | | `POST /v1/auth/account/delete` | required | Deletes the player's own account (irreversible) | [Account deletion](account-deletion.md) | | `POST /v1/auth/refresh` | refresh token | Refreshes the session (rotation) | [Sessions](sessions-and-csrf.md) | | `POST /v1/auth/logout` | refresh token | Closes the session | [Sessions](sessions-and-csrf.md) | | `GET /v1/auth/jwks` | not required | JWT verification keys | [Server-side verification](server-verification.md) | The `/v1/auth/organization/*` endpoints (player organizations) are being **deprecated**: they work, but every response carries a `Deprecation` header and they are not described in this document. The `/v1/auth/admin/*` endpoints are for player administration with a server key (list, ban, rename, session revocation) and are listed in the [endpoint reference](../reference/endpoints.md). ## Banned accounts When an operator bans a player, all of the player's sessions are revoked and their open realtime connection closes. How the player appears at sign-in: - A banned email account with the **correct password**: `403` and `{"code":"account_banned","message":"account banned"}`. A wrong password or an unknown address is an indistinguishable plain-text `403`; the ban status does not leak. - **A banned guest:** if a guest sign-in is made with the same bootstrap secret, or by presenting the banned player's old session, `403 account_banned` is returned again and **no new player is created**. - `GET /v1/auth/session` returns `401` for a banned account (the ban code does not leak); refresh, the OAuth callback, and guest upgrade are rejected indistinguishably for a banned owner. - The response contains no ban reason or end time. - **SDK behavior:** in TypeScript the session state becomes `banned`; in Unity `SessionStatus.Banned`. Neither opens a new guest, and neither deletes the stored guest secret (so a banned player cannot escape by opening a new guest). Show the player that the account is suspended; do not build a retry loop. --- Source: https://open-play.b4klabs.dev/docs/en/auth/guest.html # Guest sign-in Guest sign-in gives a player a permanent player identity without asking for an email or a password. The player returns to the same account thanks to a secret stored on their device; later, if they want, they move to a permanent account with email or Google. This page describes the HTTP endpoint, the secret's lifecycle, and SDK behavior. ## How does it work? 1. The client generates **a random 32-byte secret** and writes it as 64 lowercase hexadecimal characters (`bootstrap_token`). This secret is the key to the player's account. 2. The client sends the secret with `POST /v1/auth/sign-in/guest`. The server stores only the SHA-256 digest of the secret; the raw secret is not recorded or written to logs. 3. If the secret arrives for the first time, a new guest player (`display_name` `Guest`) and a new session are created (`is_new_player: true`). If the same secret arrives again, a new session is opened for the **same player** (`is_new_player: false`). 4. The secret has a **180-day** lifetime that slides as it is used. A secret that has expired or been retired (for example because the account was upgraded to permanent) is rejected with `403`. > **Warning** > > The guest secret is an authority: a player who knows the secret enters the account. Do not pass it to other players, logs, or analytics. ## Request First get a CSRF token ([Sessions and CSRF](sessions-and-csrf.md#csrf-double-submit)); then: ```http POST /v1/auth/sign-in/guest content-type: application/json x-crx-env: x-csrf-token: cookie: __Secure-crx.csrf= {"bootstrap_token":"<64 lowercase hexadecimal characters>"} ``` The body carries only the `bootstrap_token` field. ## Response `200` and the [shared sign-in envelope](overview.md#successful-sign-in-response): ```json { "player": { "id": "0192…", "display_name": "Guest", "email": null, "email_verified": false, "is_guest": true, "image": null, "created_at": "2026-11-03T10:00:00+00:00" }, "tokens": { "session_token": "<64 hex>", "refresh_token": "<64 hex>", "access_token": "", "expires_in_secs": 900 }, "is_new_player": false } ``` Two `Set-Cookie` headers (`__Secure-crx.session`, `__Secure-crx.refresh`) and the new CSRF token (the `x-csrf-token` header) are also returned. | Status | Meaning | |---|---| | `400` | The `bootstrap_token` format is broken (not 64 lowercase hexadecimal characters) or the JSON is broken | | `401` | `x-crx-env` is missing or not a UUID | | `403` | The secret was retired/expired, the account was deleted, or a CSRF/origin rejection (plain text); **JSON for a banned owner** `{"code":"account_banned"}` | | `429` | 30 per 60 s per IP exceeded (re-sign-ins count too) | **A banned guest:** if the request carries an old session cookie/Bearer that belongs to a banned player, or the secret belongs to a banned player, no new guest is **created** and `403 account_banned` is returned; this response does not delete the cookies. See [Banned accounts](overview.md#banned-accounts). ## Display name The guest's name is `Guest`. The player can change their own name with `POST /v1/auth/session/display-name` (body `{"displayName":"…"}`): the name is converted to NFC, trimmed, and inner whitespace is collapsed to single spaces; control/direction/zero-width characters are rejected; the result must be **2–24 characters**; 10 changes per hour per player. In Unity it is `Session.RenameAsync`; the TypeScript SDK has no method for this operation, so use raw REST. ## TypeScript SDK `session.signInGuest()` generates the secret for you, stores it **before the request**, and manages the CSRF token; so even if the response is lost or the cookie is deleted, the same secret returns to the same guest. `session.restore()` only restores an existing session and does not create a guest. Secret storage options (the `guestBootstrap` setting of `createPlayerClient`): | Option | Behavior | |---|---| | Default | `localStorage` in the browser (memory if it is inaccessible); memory in SSR/Node | | `false` | Nothing is stored; every `signInGuest` creates a new guest | | `GuestBootstrapStore` | Your own store (`get`/`set`/`clear`; Keychain, IndexedDB, file). Timeout is 3 s; if the store throws, the SDK falls back to memory | Behavior rules: - The secret is **deleted** after `logout`, a successful email sign-in/sign-up, `upgradeGuest`, and a session seen with `isGuest: false`. A `logout` from the banned state does not delete the secret (so a banned player cannot escape by opening a new guest). - If the server rejects the secret (the endpoint handler's `403`; a CSRF/origin `403` and `401` do not count), the SDK deletes the secret once and makes **one** retry with a new secret. - In the `banned` state there is no retry; no new guest is created. > **Warning** > > The default `localStorage` store is readable by every script on the same origin; an XSS hole can steal the secret and take over the guest account. For high-value accounts, use `guestBootstrap: false` or provide the platform's secure store. ```ts import { ACCOUNT_BANNED_CODE, CrxError, createMemoryGuestBootstrapStore, createPlayerClient, type PlayerClient, } from "@ferdiunal/open-play-ts"; /** Varsayılan davranış: misafir kimliği tarayıcıda `localStorage`'da saklanır ve aynı oyuncuya döner. */ export function createDefaultClient(environmentId: string): PlayerClient { return createPlayerClient({ baseUrl: window.location.origin, environmentId }); } /** Kalıcı kimlik istemeyen oyun: her `signInGuest` yeni bir misafir oyuncu açar. */ export function createEphemeralGuestClient(environmentId: string): PlayerClient { return createPlayerClient({ baseUrl: window.location.origin, environmentId, guestBootstrap: false }); } /** Sunucu tarafında çalışan kod için (SSR/Node) isteğe özel bellek deposu; sır sunucu belleğinden çıkmaz. */ export function createRequestScopedClient(baseUrl: string, environmentId: string): PlayerClient { return createPlayerClient({ baseUrl, environmentId, guestBootstrap: createMemoryGuestBootstrapStore() }); } /** Misafir girişi; banlı hesabı ayrı bir ekranla karşılar. */ export async function signInGuestOrShowBan(client: PlayerClient, showBanned: () => void): Promise { try { await client.session.signInGuest(); } catch (error) { if (error instanceof CrxError && error.code === ACCOUNT_BANNED_CODE) { showBanned(); // client.session.state.status === "banned"; yeniden deneme döngüsü kurmayın. return; } throw error; } } ``` In SSR (server-side rendering) the SDK does not use the environment's `localStorage` and the secret is written to memory. An SSR app that wants a permanent guest identity must provide its own per-request store through `guestBootstrap`; the `createRequestScopedClient` example above uses only a process-lifetime memory store. ## Unity SDK `Session.SignInGuestAsync()` implements the same flow; the secret is kept in `OpenPlayOptions.SecretStore`. The default `PlayerPrefs` store is **plain text and is not a secure store**; in production provide your own `ISecretStore` implementation backed by Keychain/Keystore ([Unity SDK](../sdk/unity.md#secret-storage)). `Session.RestoreAsync()` does not create a guest. On a banned account the state becomes `SessionStatus.Banned`. ## Turning a guest into a permanent account The player can move to a permanent account without losing progress; `player.id` does not change: - Email and password: [`upgradeGuest`](email-password.md#guest-upgrade) - Google: [`linkOAuth`](oauth.md#linking-guest-progress-to-google) After the upgrade the guest secret is retired; the secret on the device is no longer useful. --- Source: https://open-play.b4klabs.dev/docs/en/auth/email-password.html # Email and password Email and password give a player a permanent account across devices. On this page you will find the sign-up, sign-in, guest-to-permanent upgrade, password change, and reset flows, with request and response examples and SDK calls. All calls follow the [shared rules](overview.md#rules-that-apply-to-every-call) (environment header, CSRF). ## Rules - The **email** is trimmed and lowercased; it must contain exactly one `@` and a dot in the domain part. An address can belong to only one account in the same environment. - The **password** is 8–128 characters (counted in Unicode characters, not bytes). The password is stored with Argon2id. - The **display name** is required; use 1–100 characters (the server enforces this limit on guest upgrade). The player can later change their name under the 2–24 character rule ([Guest sign-in › Display name](guest.md#display-name)). - Today there is **no email verification endpoint**; sign-up returns `email_verified` as `false`. - The SDKs do a plain bounds check before sending the request (email 3–320, password at most 1024, display name at most 256 units; no control characters). The real policy is on the server. ## Sign-up ```http POST /v1/auth/sign-up/email content-type: application/json x-crx-env: x-csrf-token: {"email":"player@example.com","password":"","display_name":"Alex"} ``` `201` and the [shared sign-in envelope](overview.md#successful-sign-in-response) (`is_new_player: true`, `player.is_guest: false`, `player.email` filled) + session cookies. | Status | Meaning | |---|---| | `400` | Email format or password policy violation | | `409` | The address is registered in this environment (the body does not repeat the address) | | `429` | 10 / 60 s per IP or 5 / 15 min per address exceeded | ## Sign-in ```http POST /v1/auth/sign-in/email content-type: application/json x-crx-env: x-csrf-token: {"email":"player@example.com","password":""} ``` `200` and the sign-in envelope (`is_new_player: false`). - **A wrong password and an unknown address get the same response** (plain-text `403`); which one it was does not leak. - An account with the **correct password but banned** gets `403` and `{"code":"account_banned","message":"account banned"}`; the cookie is not deleted. - 10 failed sign-ins in 15 minutes for the same address + IP lock the account; while locked, even the correct password gets `429`. A successful sign-in refunds the allowance. There is also a separate 5 / 60 s limit per IP. - If the request carries a usable session of another player (for example an old guest in the same browser), **that session family is revoked** after the sign-in succeeds. The same player's session is not touched. ## Guest upgrade Turns a guest player into a permanent account with the same `player.id`; progress is kept. A guest **session** is required (a cookie or `Authorization: Bearer `). ```http POST /v1/auth/guest/upgrade/email content-type: application/json x-crx-env: x-csrf-token: {"email":"player@example.com","password":"","display_name":"Alex"} ``` `200`; the same `player.id`, `is_guest: false`, only a **new** session pair. The upgrade is a single database transaction: the identity is added, the guest secret is retired, old sessions are revoked, and a new session is opened. | Status | Meaning | |---|---| | `400` | Email, password, or name validation | | `401` | Environment header or session missing | | `403` | The session is not active, belongs to another environment, or a CSRF rejection | | `409` | The address is registered to another account in this environment; the operation is rolled back and the guest session is not damaged | | `422` | The player is no longer a guest (a duplicate or concurrent request) | | `429` | IP 10 / 60 s, guest player 30 / 60 s, or address 5 / 15 min exceeded | If the response is lost (the connection dropped), the old guest sessions may have been revoked; the recovery path is a normal email sign-in. ## Password change A session is required. The old password is verified; on success **all sessions** (including this one) are revoked and the cookies are deleted; the player signs in again. ```http POST /v1/auth/change-password content-type: application/json x-crx-env: x-csrf-token: {"old_password":"","new_password":""} ``` `204`. Errors: `400` new password policy violation, `403` session or old password rejected, `429` IP 10 / 60 s or account 6 / 60 s. A wrong current password also counts toward the sign-in lock counter. ## Password reset 1. A reset email is requested with `POST /v1/auth/forgot-password`. **Whether or not the account exists, the response is the same `200`** (account existence does not leak). 2. `POST /v1/auth/reset-password` is called with the token from the email. The token is **single-use** and valid for **1 hour**. On success **all sessions are revoked**. ```http POST /v1/auth/forgot-password content-type: application/json x-crx-env: x-csrf-token: {"email":"player@example.com"} ``` ```http POST /v1/auth/reset-password content-type: application/json x-crx-env: x-csrf-token: {"token":"<64 hex>","new_password":""} ``` `reset-password` errors: `400` new password policy violation (**the token is not burned**, you can retry with the same link), `403` token invalid/used/expired or CSRF (the distinction does not leak), `429` limit exceeded (5 / 60 s, the IP bucket shared with forgot and magic link). > **Warning** > > **Known limitation:** The link in the reset email goes not to your game's page but to `/v1/auth/reset-password?token=…` at the server's API root. There is no `GET` endpoint on this path; that is, clicking the link does not show a page. To offer reset in your game, the player must carry the `token` value from the email to your own reset screen (for example by routing this path to a game page on your own reverse proxy; this routing is not verified in this repository). Email delivery depends on the operator's SMTP setting. ## SDK ```ts import { CrxError, ErrorKind, type PlayerClient } from "@ferdiunal/open-play-ts"; /** Yeni hesap açar ve oturumu başlatır. Adres kayıtlıysa sunucu 409 döner (`ErrorKind.Http`, status 409). */ export async function register(client: PlayerClient, email: string, password: string, displayName: string): Promise { await client.session.signUpEmail({ email, password, displayName }); } /** Mevcut hesapla girer. Yanlış parola ile bilinmeyen adres aynı yanıtı alır; hangisi olduğu sızmaz. */ export async function login(client: PlayerClient, email: string, password: string): Promise { await client.session.signInEmail({ email, password }); } /** * Misafir ilerlemesini koruyarak kalıcı hesaba geçer: aynı `playerId`, yeni oturum. * Önce misafir olarak girilmiş olmalıdır; adres başka bir hesapta kayıtlıysa 409 döner ve misafir oturumu bozulmaz. */ export async function upgradeGuestToEmail(client: PlayerClient, email: string, password: string, displayName: string): Promise { if (client.session.state.status !== "authenticated") await client.session.signInGuest(); try { await client.session.upgradeGuest({ email, password, displayName }); } catch (error) { if (error instanceof CrxError && error.kind === ErrorKind.Http && error.status === 409) { throw new Error("Bu e-posta adresi başka bir hesapta kayıtlı."); } throw error; } } /** Parola sıfırlama isteği: hesap olsa da olmasa da yanıt aynıdır. */ export async function requestReset(client: PlayerClient, email: string): Promise { await client.session.requestPasswordReset({ email }); } /** E-postadaki token ile yeni parola belirler; başarıda tüm oturumlar kapanır, oyuncu yeniden giriş yapar. */ export async function finishReset(client: PlayerClient, token: string, newPassword: string): Promise { await client.session.resetPassword({ token, newPassword }); } /** Oturum açıkken parola değiştirir; başarıda tüm oturumlar (bu dahil) kapanır. */ export async function changePassword(client: PlayerClient, currentPassword: string, newPassword: string): Promise { await client.session.changePassword({ currentPassword, newPassword }); } ``` | TypeScript | Unity | Endpoint | |---|---|---| | `signUpEmail` | `SignUpEmailAsync` | `sign-up/email` | | `signInEmail` | `SignInEmailAsync` | `sign-in/email` | | `upgradeGuest` | `UpgradeGuestAsync` | `guest/upgrade/email` | | `changePassword({ currentPassword, newPassword })` | `ChangePasswordAsync` | `change-password` (`old_password` in the body) | | `requestPasswordReset` | `RequestPasswordResetAsync` | `forgot-password` | | `resetPassword` | `ResetPasswordAsync` | `reset-password` | Shared SDK behavior: - The password and token are written only to the request body; they do not enter session state, event listeners, error objects, or logs. - On the auth endpoints, only if the CSRF layer rejected a `403` (there is no new `x-csrf-token` in the response) does the SDK retry **once** with a fresh token. - If the connection is lost after the request was sent, the sign-in endpoints make at most one session probe; if the outcome is uncertain, you get an `UnknownOutcome` error. Calls that return no result, such as password change and reset, are also not blindly retried. - `upgradeGuest` sends no network request when there is no session; the server's definitive rejections (4xx) do not drop the guest session. - On success, `changePassword` applies the same transition as `logout` (the state becomes `signedOut`). Unity example: ```csharp using System.Threading.Tasks; using OpenPlay; using OpenPlay.Session; using OpenPlay.Support; /// E-posta ve parola akışları. Parola SecretText ile taşınır; günlüğe ve ToString()'e girmez. public static class EmailAuth { public static Task Register(OpenPlayClient client, string email, string password, string displayName) => client.Session.SignUpEmailAsync(new SignUpEmailInput { Email = email, Password = new SecretText(password), DisplayName = displayName }); public static Task Login(OpenPlayClient client, string email, string password) => client.Session.SignInEmailAsync(new SignInEmailInput { Email = email, Password = new SecretText(password) }); /// Misafir ilerlemesini korur: aynı oyuncu, yeni oturum. Adres kayıtlıysa HTTP 409 gelir. public static async Task UpgradeGuest(OpenPlayClient client, string email, string password, string displayName) { try { return await client.Session.UpgradeGuestAsync(new UpgradeGuestInput { Email = email, Password = new SecretText(password), DisplayName = displayName }); } catch (OpenPlayException ex) when (ex.Kind == ErrorKind.Http && ex.Status == 409) { throw new System.InvalidOperationException("Bu e-posta adresi başka bir hesapta kayıtlı."); } } public static Task RequestReset(OpenPlayClient client, string email) => client.Session.RequestPasswordResetAsync(new PasswordResetRequestInput { Email = email }); /// E-postadaki token ile yeni parola; başarıda tüm oturumlar kapanır. public static Task FinishReset(OpenPlayClient client, string token, string newPassword) => client.Session.ResetPasswordAsync(new ResetPasswordInput { Token = new SecretText(token), NewPassword = new SecretText(newPassword) }); /// Görünen ad kuralı: normalize sonrası 2..24 karakter, saatte 10 değişiklik. public static Task Rename(OpenPlayClient client, string displayName) => client.Session.RenameAsync(displayName); } ``` ## Magic link A magic link sends a single-use link to the email. The server endpoints exist, there is **no SDK helper yet**, and it is not documented as an in-game flow. ```http POST /v1/auth/sign-in/magic-link content-type: application/json x-crx-env: x-csrf-token: {"email":"player@example.com","name":"Alex"} ``` `200` (email existence does not leak); the token is only in the email, is valid for **5 minutes**, and is single-use. Limit: IP 5 / 60 s, address 5 / 15 min. The token is consumed with `GET /v1/auth/magic-link/verify?token=`; on success `200`, the sign-in envelope, and session cookies are returned, and an invalid token gives `403`. > **Warning** > > The `verify` endpoint also requires the `x-crx-env` header. In a browser, clicking the link in the email directly does not send this header and gets `401`. If you want to build a magic link flow, your game page must take the token and verify it with a request that carries the header; this flow is not covered by the SDKs or by this document. --- Source: https://open-play.b4klabs.dev/docs/en/auth/oauth.html # Sign in with Google Sign in with Google lets a player sign in on the web with one click. This page describes the two steps of the flow (start and complete), what your callback page must do, the rule for automatic linking to an existing email account, and the HTTP contract of native platform proofs, which have no SDK. > **Note** > > Today the SDK helpers for the web are only for **Google**. The server also has an Apple form callback and a generic provider registration, but there is no SDK helper for them and no detail in this document. ## Prerequisite: configure the provider Google works only in environments where it is configured. The operator (the super admin in the panel) enters the Google client ID, the client secret, and the **redirect base** on the **Game settings › Sign-in methods › Google › Edit OAuth credentials** screen; the secret is write-only and cannot be read again. If it is not configured, `GET /v1/auth/oauth/providers` returns an empty list and the start request gets `403`. The **Authorized redirect URI** in the Google Cloud console must be **exactly** the same as the `redirect_uri` the server uses: `/google`. For example, if the base is `https://game.example.com/auth/callback`, you register `https://game.example.com/auth/callback/google` with Google. This address is **your own game page** (the callback page below). The player returns there from Google with `code` and `state`. The base itself must not end with `/google`: if `https://game.example.com/auth/callback/google` is typed, the server builds `…/google/google` (Google answers `redirect_uri_mismatch`); the panel and the API refuse that value and show the corrected example. Configuration details belong to the operator; panel help: [Sign-in methods](../panel/game-settings-sign-in.md). ## Flow 1. The game page gets `{url, state}` with `POST /v1/auth/sign-in/oauth/google` and redirects the player to `url`. 2. The player grants consent at Google; Google returns the player to `/google?code=…&state=…`. 3. The callback page completes the session with a `GET /v1/auth/callback/google?code=…&state=…` call. The response is the [shared sign-in envelope](overview.md#successful-sign-in-response) and writes the session cookies. ### 1. Start ```http POST /v1/auth/sign-in/oauth/google content-type: application/json x-crx-env: x-csrf-token: {"callback_url":"https://game.example.com/auth/callback/google"} ``` ```json {"url":"https://accounts.google.com/o/oauth2/v2/auth?client_id=…&state=…&code_challenge=…","state":"<64 hex>"} ``` - `url` carries PKCE (`code_challenge`), `state`, and an OIDC `nonce`; `state` is valid for **10 minutes**. - `callback_url` is required (`400` if empty), but it does **not determine** the redirect: the `redirect_uri` sent to Google comes from the base configured on the server. Provide an address consistent with your game page. - `403`: the provider is unknown, not configured, or disabled. `429`: 30 / 60 s per IP. ### 2. Complete ```http GET /v1/auth/callback/google?code=&state= x-crx-env: cookie: ``` The response is `200` and the sign-in envelope + cookies. In the browser, use `fetch(..., { credentials: "include" })`; the call goes either to the same origin as the API or, if the game's domain is in the environment's domain list, directly to the Open Play address (CORS; see [Architecture and hosting](../getting-started/architecture-and-hosting.md#calling-from-the-browser-direct-cors-or-reverse-proxy)). Because it is a `GET`, it does not require a CSRF token. | Status | Meaning | |---|---| | `403` | `state` missing/consumed/expired, provider rejection, or a signature/`nonce` error; **in the link intent**, no session or another player's session. The distinction does not leak | | `409` | `already exists: account_already_linked`: the Google identity is linked to another player; there is no merging | | `422` | `unprocessable: provider_already_linked`: this provider is already linked on the player | | `429` | Callback limit: 60 / 60 s per IP | `code` and `state` are **single-use**; do not call the callback a second time. ## TypeScript SDK The SDK binds `state` to the browser for you: at start it writes `{state, provider, intent, startedAt}` to `sessionStorage` (`crx-oauth-pending:v1`), and at completion it checks it in the same **tab**. If there is no record, it is older than 10 minutes, it is corrupt, or the provider/`state` do not match, **no network request is sent at all** and the session state does not change; the `CrxError` code is `OAuthBindingCode.Missing` (missing/expired/corrupt) or `OAuthBindingCode.Mismatch` (another browser/tab). This prevents session takeover with someone else's callback link. ```ts import { CrxError, OAuthBindingCode, type PlayerClient } from "@ferdiunal/open-play-ts"; // Google'ın kullanıcıyı döndürdüğü sayfa: sunucuda yapılandırılan yönlendirme tabanı + "/google" ile aynı yol olmalıdır // (örn. taban https://oyun.example.com/auth/callback ise sayfa https://oyun.example.com/auth/callback/google). const CALLBACK_PATH = "/auth/callback/google"; /** 1. adım: Google'a yönlendirin. State'i saklamanız gerekmez; SDK onu bu sekmeye bağlar (sessionStorage). */ export async function beginGoogleSignIn(client: PlayerClient): Promise { const callbackUrl = `${window.location.origin}${CALLBACK_PATH}`; const { url } = await client.session.startGoogleOAuth(callbackUrl); window.location.assign(url); } /** Misafir (ya da kayıtlı) oyuncunun hesabına Google kimliğini bağlar; tamamlama aynı callback sayfasında yapılır. */ export async function beginGoogleLink(client: PlayerClient): Promise { const callbackUrl = `${window.location.origin}${CALLBACK_PATH}`; const { url } = await client.session.linkOAuth("google", callbackUrl); window.location.assign(url); } /** 2. adım: Google'ın döndüğü sayfada (`/auth/callback/google`) code ve state ile oturumu tamamlayın. */ export async function finishGoogleSignIn(client: PlayerClient): Promise { const params = new URL(window.location.href).searchParams; const code = params.get("code"); const state = params.get("state"); history.replaceState(null, "", window.location.pathname); // code ve state adres çubuğunda kalmasın if (code === null || state === null) throw new Error("Callback adresinde code ya da state yok."); try { const session = await client.session.completeGoogleOAuth(code, state); return session.playerId; } catch (error) { if (error instanceof CrxError && error.code === OAuthBindingCode.Mismatch) throw new Error("Giriş başka bir tarayıcıda başlatılmış."); if (error instanceof CrxError && error.code === OAuthBindingCode.Missing) throw new Error("Giriş süresi dolmuş; yeniden başlatın."); throw error; } } /** Hesabıma bağlı sağlayıcı kimlikleri (sağlayıcı hesap kimliği ve e-posta dönmez). */ export async function linkedProviders(client: PlayerClient): Promise { const result = await client.session.listIdentities(); return result.identities.map((identity) => identity.provider); } ``` - `startGoogleOAuth(callbackUrl)` = `startOAuth('google', callbackUrl)`; `completeGoogleOAuth(code, state)` = `completeOAuth({ provider, code, state })`. - `linkOAuth(provider, callbackUrl)` starts the link flow (see below); completion is again with `completeOAuth`. - `listIdentities()` returns the linked identities. - On the callback page, remove `code` and `state` from the address bar right away (`history.replaceState`). - The Unity SDK has **no** OAuth helper. > **Warning** > > A method named `startIdentityTransfer` appears in the TypeScript SDK; it has no counterpart on the server (operator-assisted identity transfer was cancelled). Do not use it. ## Linking guest progress to Google For a player with an open session (guest or registered), `linkOAuth` is used: `POST /v1/auth/link/{provider}` requires a session and returns `{url, state}`; completion is the same callback endpoint, but **the callback request must come with the session of the player who started it** (a cookie or Bearer), otherwise `403`. - If the player is a **guest**, linking is an **upgrade**: the same `player.id`, `is_guest: false`, the guest secret is retired, and a new session is opened. A name the guest set themselves is kept; if the name is still the default `Guest`, the Google name is applied. - If the player is **registered**, only the new identity is added; their name is not touched. - There is **one identity** per provider (`422 provider_already_linked`). - If the Google identity is linked to another player, `409 account_already_linked` is returned; progress is not merged. Details: [Account linking](account-linking.md). ## Automatic linking by email If the email of a player who signs in with Google with **a new identity** matches an existing email/password account, the Google identity is **automatically linked** to that account and the player enters that account. The conditions of the rule: - The email must be **verified** by Google **and** Google must vouch for the current ownership of this email: the domain is `gmail.com`/`googlemail.com`, or the `hd` claim of the signed ID token equals the email's domain (Google Workspace). For a third-party address, `email_verified` alone is not enough. - The match is made with the player's profile email (`players.email`); the password, existing sessions, and the profile email are not touched. - If the matched account is banned, the link is not written and the sign-in is rejected. - If the matched account already has another Google identity, automatic linking is not done; a new player is created. - It works even if sign-up is disabled (it is not a new sign-up). - There is **no** setting that turns this behavior off; this linking is not applied on the native ID-token path. - When a guest links to Google (upgrade), if the Google email matches **another player's** email, no merge is done; `409 account_already_linked` is returned. What is stored on the account: the Google identity is recognized only by `(environment, provider, sub)`. Google's name, verified email, language, and profile image address may be written to the identity; the provider's access and refresh tokens are **not stored**. This information is visible only in the operator panel. ## Native platform proofs There is a single endpoint for native mobile sign-ins; there is **no SDK helper yet** (not in TypeScript, not in Unity). The endpoint is subject to the shared CSRF rules and does not write cookies: use the response as a Bearer. ```http POST /v1/auth/native/{provider} content-type: application/json x-crx-env: x-csrf-token: ``` | `provider` | Body | |---|---| | `apple`, `google` | `{"id_token":"","nonce":""}` | | `gamecenter` | `{"proof":{"player_id":"…","bundle_id":"…","public_key_url":"…","signature_b64":"…","salt_b64":"…","timestamp_ms":1700000000000,"game_player_id":"…"}}` | | `playgames` | `{"proof":{"server_auth_code":"…","application_id":"…"}}` | `200` and `{player, tokens, is_new_player}` (no cookies). `400`: the provider is not suitable for native sign-in or is not registered in the environment, or the envelope format is broken. `403`: the proof was rejected (no distinction leaks, such as signature, certificate chain, bundle ID, timeout, or `nonce`). To use it, the relevant platform must be configured in the environment. > **Note** > > The native proof path does not apply automatic linking by email; the same person can be split into separate players by the native and web paths. --- Source: https://open-play.b4klabs.dev/docs/en/auth/sessions-and-csrf.html # Sessions and CSRF A player who signs in is given a **session**. This page describes how the session is carried, how long it lives, how it is refreshed and closed, and the **CSRF** (cross-site request forgery) protection that is required on state-changing requests. If you use an SDK, it manages most of this for you; if you are writing raw REST, this page is the contract. ## How the session is carried The server reads the session from one of two places; the cookie first, otherwise the `Authorization` header: | Channel | Who uses it? | Format | |---|---|---| | **Cookie** | Browser games | `__Secure-crx.session=<64 hex>` (`HttpOnly; Secure; SameSite=Lax; Path=/`) | | **Bearer** | Clients without cookies (command line, some native clients) | `Authorization: Bearer ` (the opaque token in the sign-in response) | Every player request also carries `x-crx-env: `. What is carried on the Bearer path is the **opaque session token**; it is not the access JWT (that is for offline verification by your game server: [Server-side verification](server-verification.md)). ### Reading the session ```http GET /v1/auth/session x-crx-env: ``` ```json { "environment_id": "0192…", "player_id": "0192…", "session_id": "0192…", "is_guest": false, "active_organization_id": null, "displayName": "Alex", "email": "alex@example.com" } ``` `email` is `null` for guest and email-less (Google-only) accounts. The response carries personal data and comes with `Cache-Control: private, no-store`. If there is no session, it has been revoked, it has expired, or the account is banned, **`401`** is returned (the distinction does not leak). ## Lifetimes | Item | Duration | |---|---| | Session and refresh window | **30 days, sliding** | | Absolute upper bound | **180 days** (from the start of the session family; cannot be exceeded by any means) | | Access JWT | 15 minutes | | Cookie `Max-Age` | 15,552,000 s (180 days); it is only an upper bound, the decision is on the server | | CSRF cookie | 15 minutes | - **Sliding:** when the player makes a request with the session at least once a day, the session end is pushed 30 days ahead (without exceeding the upper bound). Every refresh also pulls the duration back to 30 days. - A session that makes no request for 30 days dies. On day 180 even an active session ends; the player signs in again (a guest returns to the same account with the same secret). - Keeping a realtime connection open does **not** extend the session. ## Refresh ```http POST /v1/auth/refresh content-type: application/json x-crx-env: x-csrf-token: {} ``` The refresh token is carried in the body (`{"refresh_token":"…"}`) or in the `__Secure-crx.refresh` cookie (the body takes precedence; if the cookie is present, the body can be empty). The response is `200` and a **new** session and refresh pair (the cookies are also renewed; the shared sign-in envelope). The old refresh token counts as used. - **Reuse detection:** presenting a used refresh token again **revokes the entire session family** (a defense against a stolen token). - Error: `403` (the token is invalid, expired, reused, or a CSRF rejection). - The SDKs refresh on their own when needed; you do not call it. ## Sign-out ```http POST /v1/auth/logout content-type: application/json x-crx-env: x-csrf-token: {} ``` `204`; the session is revoked and the cookies are deleted (`Max-Age=0`). The call is **idempotent**: an unknown token succeeds silently. If there is no refresh token at all (the cookie and the body are empty), `400` is returned. Password change, password reset, and an operator ban also revoke sessions; see the relevant pages. ## Single active session (optional) The **Game settings › Sign-in methods › Sign-in settings › Single active session** switch in the panel (super admin only) is turned on per environment. When it is on, every sign-in path that gives the player a new session **revokes the player's other sessions** after the new session is written ("the latest sign-in wins"). - The switch does not close open sessions at the moment it is turned on; it applies only from the next sign-in. - Refresh does not trigger this policy. - The session on the other device gets `401` on its next request; the SDK state becomes `reauthRequired`. Your game can show a screen such as "signed in from another device" in this case. ## CSRF double-submit A session carried in a cookie is also added automatically by the browser to other sites' requests. To prevent this, **double-submit** CSRF protection is applied to state-changing requests: the same token must arrive both in the cookie and in the header. **Scope.** It is required on these requests: all non-`GET` requests under `/v1/auth/*` (including the sign-in endpoints); plugin commands called with a player session (and queries called with POST); player store purchases (only in a cookie session; in an `Authorization: Bearer` session only the Origin and Fetch Metadata checks apply); the realtime ticket request. `GET` requests are exempt. Calls made **with a server key** are not subject to CSRF. **Flow.** 1. `GET /v1/auth/csrf` is called. The response: body `{"token":""}`, header `x-csrf-token: `, and `Set-Cookie: __Secure-crx.csrf=`. 2. Every state-changing request sends the **cookie** (`__Secure-crx.csrf`) and the **header** (`x-csrf-token`) together. The browser adds the cookie itself; your code adds the header. 3. On every successful request the token **rotates**: the response carries a new `x-csrf-token` header and a new `Set-Cookie`. Use **the new one** on the next request. 4. The token is **single-use.** A request sent again with the old token gets `403` (replay protection). ```bash # Get a token TOKEN="$(curl -sS -c "$JAR" "$OPEN_PLAY_URL/v1/auth/csrf" | jq -r .token)" # Use it; keep the new token from the response header for the next request curl -sS -D headers.txt -b "$JAR" -c "$JAR" -X POST "$OPEN_PLAY_URL/v1/auth/logout" \ -H "content-type: application/json" -H "x-crx-env: $OPEN_PLAY_ENV" -H "x-csrf-token: $TOKEN" -d '{}' TOKEN="$(grep -i '^x-csrf-token:' headers.txt | awk '{print $2}' | tr -d '\r')" ``` Things to watch out for: - **Do not send two concurrent mutations with the same token;** when one succeeds, the other gets `403`. Put the requests in a queue. The SDKs do this with a single lock. - The CSRF cookie lives for 15 minutes; when it expires you get `403`. Get a fresh token and retry **once**. The `403` is plain text and does not distinguish between a CSRF rejection and an authorization rejection; if no new `x-csrf-token` header came in the response, you know it was a CSRF layer rejection. - The Origin check is the first layer of CSRF: the `Origin` domain of a browser request must be bound to the environment. See [Architecture and hosting](../getting-started/architecture-and-hosting.md#domains-and-trusted-origins). - Clients without cookies (Bearer) must also carry the CSRF cookie and header on `/v1/auth/*` and plugin endpoints (the Bearer branch of the store purchase is the exception). The Unity SDK does this by managing the cookies itself in a native build and with the browser's cookies in WebGL. ## SDK and session states TypeScript `session.state.status` values: | State | Meaning | |---|---| | `signedOut` | No session | | `authenticating` | Sign-in or restore in progress | | `authenticated` | Session open; `playerId`, `sessionId`, `isGuest`, `displayName`, `email` are filled | | `refreshing` | Refresh in progress | | `reauthRequired` | The session was lost or could not be refreshed; sign-in is needed again | | `signingOut` / `logoutUncertain` | Sign-out in progress / its outcome is uncertain (can be retried) | | `banned` | The account is banned; terminal | In Unity the equivalent is the `SessionStatus` values (`SignedOut`, `Authenticating`, `Authenticated`, `Refreshing`, `ReauthRequired`, `SigningOut`, `LogoutUncertain`, `Banned`, `Disposed`); state changes arrive through the `Session.StateChanged` event. ```ts import { CrxError, ErrorKind, createPlayerClient, type PlayerClient, type SessionState } from "@ferdiunal/open-play-ts"; /** Oturum durumunu arayüze bağlar; `state.status` değerleri kapalı bir kümedir. */ export function watchSession(client: PlayerClient, render: (label: string) => void): () => void { const label = (state: SessionState): string => { switch (state.status) { case "authenticated": return state.isGuest === true ? "Misafir" : (state.displayName ?? "Oyuncu"); case "banned": return "Hesap askıya alındı"; case "reauthRequired": return "Yeniden giriş gerekiyor"; case "signedOut": return "Giriş yapılmadı"; default: return "…"; // authenticating, refreshing, signingOut, logoutUncertain } }; render(label(client.session.state)); return client.session.subscribe((state) => render(label(state))); // dönen işlev aboneliği kapatır } /** Sayfa kapanırken: sunucu oturumunu kapatmak için `logout`, yalnız yerel kaynakları bırakmak için `dispose`. */ export async function leave(client: PlayerClient, signOut: boolean): Promise { try { if (signOut) await client.session.logout(); } catch (error) { if (!(error instanceof CrxError && error.kind === ErrorKind.UnknownOutcome)) throw error; // Yanıt kaybolduysa sunucu oturumu kapanmamış olabilir (durum `logoutUncertain`); gerekirse logout'u yeniden deneyin. } finally { await client.dispose(); } } export function createClient(environmentId: string): PlayerClient { return createPlayerClient({ baseUrl: window.location.origin, environmentId }); } ``` `dispose()` only releases local resources; to close the server session, call `logout()` first. `AuthenticatedSession.email` is only for the player's own screen; do not write it to logs or analytics. --- Source: https://open-play.b4klabs.dev/docs/en/auth/account-linking.html # Account linking A player's identity is recognized by `player.id`; sign-in methods are the paths **linked** to this identity. This page describes a guest's move to a permanent account, linking and unlinking a Google identity, listing linked identities, and what happens in conflict cases. > **Note** > > Open Play does **not merge** accounts. If an identity is linked to another player, the request is rejected; the progress of two players is never combined into one account by any means. The only exception is [Google's automatic linking by email](oauth.md#automatic-linking-by-email) rule; even that prevents a new player from being created and adds the identity to the **existing** account. ## From guest to permanent account (upgrade) A guest player moves to a permanent account in two ways; in both, `player.id` does not change, the guest secret is retired, and a new session is opened: | Path | Call | Details | |---|---|---| | Email and password | `POST /v1/auth/guest/upgrade/email` · TS `upgradeGuest` · Unity `UpgradeGuestAsync` | [Email and password](email-password.md#guest-upgrade) | | Google | `POST /v1/auth/link/google` + callback · TS `linkOAuth` + `completeOAuth` | [Sign in with Google](oauth.md#linking-guest-progress-to-google) | Upgrade is **for guest players only**: for a player who is not a guest, email upgrade gives `422`. Email/password cannot be added later to an account that was created with Google or is already registered. ## Listing linked identities ```http GET /v1/auth/identities x-crx-env: ``` ```json { "player_id": "0192…", "identities": [ { "provider": "google", "linked_at": "2026-11-03T10:00:00+00:00" } ] } ``` - A session is required (otherwise `401`). The response carries `Cache-Control: private, no-store`. - Only **provider** identities are listed (`google`, …); email/password sign-in is not listed. The provider's account ID (`sub`), email, and tokens are **not** in the response. - TypeScript: `session.listIdentities()` returns `{ playerId, identities: [{ provider, linkedAt }] }` (`camelCase`); the response is strictly validated, an unexpected shape becomes `CrxError(Contract)` and does not drop the session. This endpoint is enough to show "Link Google" or "Google linked" on your "My account" screen. ## Linking an identity `POST /v1/auth/link/{provider}` starts an OAuth flow for the player in the session and returns `{url, state}`; the player is redirected to `url`, and on return the [callback](oauth.md#2-complete) completes it. Rules: - **One identity per provider:** if this provider is already linked on the player, **starting** gives `422` (`unprocessable: provider_already_linked`). - **Session binding:** the callback must come with the session of the player who started the link; a callback with no session or with another player's session gets `403`. - **Identity on another player:** if the Google identity is linked to another player, the callback gives `409` (`already exists: account_already_linked`); the guest progress is left untouched. - A verified email is **not required**; email-less accounts can also link. - Start and callback rates: 30 / 60 s and 60 / 60 s per IP. ## Unlinking an identity ```http POST /v1/auth/unlink/google x-crx-env: x-csrf-token: ``` `204`. A session is required. Errors: | Status | Meaning | |---|---| | `400` | The last remaining provider identity cannot be unlinked (so the account is not locked out) | | `404` | There is no identity linked to this provider | > **Note** > > Because a player today usually has a single provider identity, the unlink call in practice mostly gets `400`. The endpoint is in place to be used when a second provider is added. **There is no SDK helper for unlink**; use raw REST. ## Conflict table | Case | Result | |---|---| | A guest tries to upgrade with an email whose address is registered to another account | `409`; the guest session is not damaged | | A guest links to Google and the Google identity is on another player | `409 account_already_linked` | | A guest links to Google and the email Google verified matches another player's email | `409 account_already_linked` (no merge) | | A registered player tries to link the same provider a second time | `422 provider_already_linked` | | A new Google identity matches the email of an existing email/password account (Google vouches) | The identity is **automatically linked** to that account, and the player enters that account | | Linking to a banned account | Rejected (`403`) | --- Source: https://open-play.b4klabs.dev/docs/en/auth/account-deletion.html # Account deletion A player can delete their own account from inside the game. The Apple App Store and Google Play expect apps that allow account creation to also offer in-app account deletion; this endpoint meets that expectation. Deletion is **irreversible**: neither the player nor an operator can bring a deleted account back. ## Request First get a CSRF token ([Sessions and CSRF](sessions-and-csrf.md#csrf-çift-gönderim)); then: ```http POST /v1/auth/account/delete content-type: application/json x-crx-env: x-csrf-token: { "confirm": "delete_my_account" } ``` The player id does not come from the body; it comes from the session. `confirm` is an explicit confirmation; a missing or different value is rejected with `422`. A successful request returns `204` and clears the session and refresh cookies. > **Note** > > This endpoint works only with a **player session**. You cannot delete a player through it with a server key (`Bearer crx_…`); you get `401`. To delete a player as an operator, use the player page in the panel. ## Recent sign-in rule - A **guest account** is deleted directly. - For an **account with a linked identity** (email/password, Google or another provider) the session's **sign-in moment** must be at most **10 minutes** ago. Otherwise the server deletes nothing and answers `403` with this body: ```json { "code": "recent_sign_in_required" } ``` The app then has the player sign in again and repeats the request. The time is counted from **the sign-in itself**, not from a session refresh; even a player who has been in an open session all day must sign in again. If the sign-in moment cannot be proven, the server stays on the safe side and gives the same `403`. ## What is erased and what stays? Deletion runs in a single database transaction; either everything happens or nothing does: | Data | Result | |---|---| | Sessions | All are revoked; open realtime connections close. | | Linked identities (email/password, Google…) | Detached; the password and provider tokens are erased. The same email or the same Google account can later start as a **new player**. | | Player data (`player data` blobs) | Erased. | | Player row | Not removed; it is anonymized (`Deleted player`, email and image cleared) and marked deleted. | | Audit events | Unchanged. The deletion is added as a new `player_self_deleted` event; the event carries no email or provider id. | | Wallet ledger and plugin records | Unchanged (append-only); because the player row is anonymous, the person cannot be resolved from these records. | > **Warning** > > Free-form personal data that plugins keep in their own tables is not erased by this endpoint. If your plugin stores personal data, you must wire up your own deletion flow separately. ## Errors and limits | Status | Meaning | |---|---| | `204` | The account was deleted. | | `401` | No/invalid session, the player is not active (banned or already deleted), or a server key was presented. A banned player cannot escape the ban through deletion. | | `403` without a body | CSRF rejection. | | `403` `recent_sign_in_required` | A fresh sign-in is required; nothing was deleted. | | `422` | `confirm` is missing or is not `delete_my_account`. | | `429` | The limit of 5 attempts per hour per player was exceeded. | ## SDK In the TypeScript SDK: ```ts try { await client.session.deleteAccount({ confirm: "delete_my_account" }); // the state is now signedOut; take the player to the sign-in screen. } catch (error) { if (error instanceof CrxError && error.reason === "recent_sign_in_required") { // have the player sign in again, then repeat the call } else { throw error; } } ``` On success the SDK ends the session as a password change does and returns `{ status: "signedOut" }`. On `recent_sign_in_required` the session and the account are unchanged. If the response is lost after the request was sent, the outcome is uncertain (`UnknownOutcome`): the SDK does not retry blindly, and the app's `restore()` call decides the real state. The Unity SDK does not have this method yet. ## App-store privacy note Put an in-app "Delete my account" button where players can reach it, ask for an explicit confirmation before the click is acted on, and wire the button to the `deleteAccount` call. State in your privacy text which data remains after the account is deleted (the wallet ledger stays, anonymized). The deleted account's email and provider id are freed; if the same person registers again they start from scratch. ## Related pages - [Sessions and CSRF](sessions-and-csrf.md) - [Guest sign-in](guest.md) - [Rate limits](../reference/rate-limits.md) --- Source: https://open-play.b4klabs.dev/docs/en/auth/server-verification.html # Verifying the player on your game server Your game server wants to know which player an incoming request or connection belongs to. This page describes two paths: **verifying the access JWT that Open Play signed offline** (with the JWKS) and **verifying by asking Open Play about the session**. The comparison table at the end of the page tells you which path suits you. > **Warning** > > **Today's SDK limit:** The TypeScript and Unity SDKs do **not expose** the access JWT to the application; the token exists only in the body of the raw REST sign-in response (`tokens.access_token`). A client that uses an SDK cannot give the JWT to your server. For SDK-based clients, **Path B** below (session verification) can be used. This gap is on record. ## Path A: verify the access JWT offline ### The token The sign-in, sign-up, refresh, and OAuth callback responses return `tokens.access_token`: a compact JWT signed with **EdDSA (Ed25519)**, valid for **15 minutes**. The token is only in the body; it is not carried in a cookie. When it expires, `POST /v1/auth/refresh` gives a new one. Header: `{"alg":"EdDSA","typ":"JWT","kid":""}`. Payload: | Field | Meaning | |---|---| | `sub` | Player ID (UUID) | | `env` | Environment ID (UUID) | | `sid` | Session ID (UUID) | | `org` | Active organization (the field is absent if there is none) | | `iat`, `exp` | Issue and expiry time: **Unix milliseconds** (not seconds) | > **Warning** > > `iat` and `exp` are **milliseconds**, and the token has no `iss`/`aud`. Standard JWT libraries count `exp` in seconds; they mistake the millisecond value for a date in the far future and **accept an expired token.** If you use a library, have it verify the signature but do the `exp` check **yourself** (`exp > Date.now()`). ### Keys (JWKS) ```bash curl -sS "$OPEN_PLAY_URL/v1/auth/jwks" ``` ```json {"keys":[{"kty":"OKP","crv":"Ed25519","kid":"","use":"sig","alg":"EdDSA","x":""}]} ``` No credentials are needed. If the server has not published the signer, `404` is returned. Cache the response and refresh it only if the `kid` in the token is not in the list; do not fetch it on every request. ### Verification steps 1. Split the token into three parts; the `alg` value in the header must be `EdDSA`, otherwise reject it. 2. Find the key in the JWKS by `kid`; if it is missing, refresh the JWKS once. 3. Verify the signature over the `header.payload` text with Ed25519. 4. Check that `exp > now (ms)`. 5. **Compare the `env` value with your own environment ID.** > **Warning** > > The tokens of all games on the same Open Play server are verified with the same JWKS. If you skip the `env` comparison, a player of another game appears valid on your server. This step is mandatory. Node.js (only the built-in `node:crypto`, no dependencies): ```ts import { createPublicKey, verify, type JsonWebKey } from "node:crypto"; /** Open Play erişim JWT'sinin (EdDSA) yük alanları. `iat` ve `exp` saniye değil, Unix MİLİSANİYEDİR. */ export interface AccessClaims { readonly sub: string; // oyuncu kimliği readonly env: string; // ortam kimliği readonly sid: string; // oturum kimliği readonly org?: string; readonly iat: number; readonly exp: number; } interface Jwks { readonly keys: ReadonlyArray; } function decode(part: string): Buffer { return Buffer.from(part, "base64url"); } /** * Oyuncunun erişim JWT'sini çevrimdışı doğrular: imza, süre ve ortam eşleşmesi. * JWKS'i her istekte çekmeyin; `jwks` parametresini önbellekten verin (anahtar döndürülürse bilinmeyen `kid` gelince yenileyin). */ export function verifyAccessToken(token: string, jwks: Jwks, expectedEnvironmentId: string, nowMs: number = Date.now()): AccessClaims { const parts = token.split("."); const [header, payload, signature] = parts; if (parts.length !== 3 || header === undefined || payload === undefined || signature === undefined) { throw new Error("Biçimi bozuk JWT."); } const head = JSON.parse(decode(header).toString("utf8")) as { alg?: string; kid?: string }; if (head.alg !== "EdDSA") throw new Error("Beklenmeyen algoritma."); const jwk = jwks.keys.find((key) => key.kid === head.kid); if (jwk === undefined) throw new Error("Bilinmeyen anahtar kimliği; JWKS'i yenileyin."); const publicKey = createPublicKey({ key: jwk, format: "jwk" }); const signed = Buffer.from(`${header}.${payload}`, "utf8"); if (!verify(null, signed, publicKey, decode(signature))) throw new Error("İmza doğrulanamadı."); const claims = JSON.parse(decode(payload).toString("utf8")) as AccessClaims; if (claims.exp <= nowMs) throw new Error("Belirtecin süresi dolmuş."); // JWKS tüm oyunlar için ortaktır: başka bir ortamın oyuncusunu kabul etmemek için `env` mutlaka karşılaştırılır. if (claims.env !== expectedEnvironmentId) throw new Error("Belirteç başka bir ortama ait."); return claims; } /** JWKS'i çeker (kimlik gerekmez). */ export async function fetchJwks(baseUrl: string): Promise { const response = await fetch(`${baseUrl}/v1/auth/jwks`); if (!response.ok) throw new Error(`JWKS okunamadı: ${response.status}`); return (await response.json()) as Jwks; } ``` ### Limits - **It does not see revocation.** Offline verification cannot know that the session was closed, revoked, or that the player was banned; the token looks valid until its `exp` (at most 15 minutes). For operations that must be revocation-aware, use Path B or accept this 15-minute window. - **Key rotation.** A key change restarts the server; the new `kid` appears in the JWKS. Your cache must refresh when it sees an unknown `kid`. ## Path B: ask Open Play about the session You resolve the player by forwarding the player's session token (the cookie value or the `Authorization: Bearer` token) and the environment ID to Open Play. In a browser game, requests that go to the game's origin already carry the session cookie (`__Secure-crx.session`); your server can read this value and forward it to Open Play as a Bearer. ```bash curl -sS "$OPEN_PLAY_URL/v1/auth/session" \ -H "Authorization: Bearer $PLAYER_SESSION_TOKEN" \ -H "x-crx-env: $OPEN_PLAY_ENV" ``` `200` and `{"environment_id","player_id","session_id","is_guest","displayName","email",…}`. A revoked, expired, or banned session gives `401`. Because it is a `GET`, no CSRF token is needed. > **Warning** > > The session token is an authority. Do not write it to plain-text logs, do not spread it to other services, and forward it only over TLS. ## Which path should I choose? | | Path A: offline JWT | Path B: session query | |---|---|---| | Request to Open Play | None (the JWKS is cached) | One request per verification | | Sees revocation/ban | No (up to 15 min delay) | Yes | | Token source | Only the raw REST sign-in response; **the SDK does not provide it** | The cookie or Bearer session token; also works with an SDK-based client (the cookie in the browser) | | Environment verification | You compare the `env` claim | The environment is checked against the `x-crx-env` in the request | | When? | Very high request volume, a revocation delay is acceptable | The default recommendation; operations where player identity is critical | For server-to-server calls, a **server key** is used instead of the player ([Concepts](../getting-started/concepts.md#api-keys-server-and-client)). --- Source: https://open-play.b4klabs.dev/docs/en/sdk/typescript.html # TypeScript SDK The TypeScript SDK is the typed way to connect to Open Play from the browser, from Node.js, and from the JavaScript layer of game engines: player sign-in, plugin commands, the realtime connection, and administration calls with a server key. This page describes installation, entry points, the core clients, and **which modules have a typed client**. > **Note** > > This document is verified with `@ferdiunal/open-play-ts` version **0.1.15**. The package is published on GitHub Packages; the repository is private today, so installation requires an authorized GitHub token. **API reference:** the generated reference for every type and function is at [https://open-play.b4klabs.dev/docs/sdk/typescript/](https://open-play.b4klabs.dev/docs/sdk/typescript/) (`@open-play/sdk` and `@open-play/panel-kit`). It is in Turkish for now, because it mirrors the Turkish source comments. ## Installation Requirements: Node.js **22 or later**; the package is **ESM only** (it cannot be loaded with `require`). In the browser you need a modern browser that supports Fetch, `AbortController`, and `BigInt`. 1. **Declare the registry.** In the `.npmrc` file at the root of your project, write the scope and the token variable. The token is a GitHub classic token with `read:packages` permission (a fine-grained token was not tried); **do not write the value** into the file, use an environment variable: ```ini @ferdiunal:registry=https://npm.pkg.github.com //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} ``` 2. **Install the package:** ```bash export GITHUB_TOKEN="" npm install @ferdiunal/open-play-ts@0.1.15 ``` `pnpm` works with the same `.npmrc`. Do not put the token in the repository, the image, or the CI log. 3. **Import it:** ```ts import { CrxClient, createPlayerClient } from "@ferdiunal/open-play-ts"; ``` > **Warning** > > The package name is `@ferdiunal/open-play-ts`. The name `@open-play/sdk` that appears in some files and examples in the SDK repository is only an internal name in the source repository; do not use it with the published package. ## Entry points | Import | Contents | Environment | |---|---|---| | `@ferdiunal/open-play-ts` | `CrxClient`, `createPlayerClient`, `createGameClient`, error and number helpers, catalog/store/leaderboard types | Browser and Node | | `.../player` | The player client and plugin-binding creation types | Browser and Node | | `.../game` | `GameClient` | Browser and Node | | `.../realtime` | Realtime client types | Browser and Node | | `.../browser` | IndexedDB journal and tab coordination (`createBrowserAdapter`) | Browser | | `.../codegen` | Binding and type generator | **Node only** | CLI: `crx` (`npx crx --help`). See the code generation section. ## Clients ### `CrxClient`: REST with a server key `CrxClient` is the typed REST client that works with an API key. Use the **server key only on a trusted backend**; do not put it in the browser or in the game bundle. ```ts const client = new CrxClient({ baseUrl: "https://game.example.com/open-play", apiKey: serverKey }); ``` `ClientOptions`: `baseUrl`, `apiKey`, optional `fetch`, `timeoutMs` (1–120,000 ms, default **15 s**), `maxRetries` (0–5, default **2**), `maxResponseBytes` (default 2 MiB, at most 16 MiB). Every call takes a `{ path, query, body, headers }` input and optional `{ signal, timeoutMs, maxRetries }`. ```ts import { CrxClient, CrxError, ErrorKind } from "@ferdiunal/open-play-ts"; /** * Sunucu anahtarıyla bir karaktere XP verir. Aynı `requestKey` ile yapılan tekrar aynı sonucu döndürür; * `If-Match`, okuduğunuz sürümle yazdığınız sürümün çakışmasını yakalar. */ export async function grantXp( baseUrl: string, serverKey: string, characterId: string, amount: bigint, requestKey: string, ): Promise { const client = new CrxClient({ baseUrl, apiKey: serverKey }); const current = await client.getCharacter({ path: { id: characterId } }); try { const result = await client.executeCommand({ path: { id: characterId }, body: { type: "add_xp", amount }, headers: { "Idempotency-Key": requestKey, "If-Match": current.version.toString() }, }); return result.version; } catch (error) { if (error instanceof CrxError && error.kind === ErrorKind.Http && (error.status === 409 || error.status === 422)) { // 409: sürüm çakışması; yeniden okuyup karar verin. 422: aynı anahtar başka bir gövdeyle kullanılmış. // Yeni bir anahtarla kör yeniden deneme yapmayın. throw error; } throw error; } } /** Sayfalamayı SDK yürütür: imleç (cursor) opaktır, `break` yeni sayfa istemez. */ export async function countCharacters(baseUrl: string, serverKey: string, definitionKey: string): Promise { const client = new CrxClient({ baseUrl, apiKey: serverKey }); let total = 0; for await (const _character of client.iterateListCharacters({ query: { definition: definitionKey, limit: 100 } })) { total += 1; } return total; } ``` **Call rules** - `baseUrl` must be HTTPS; only loopback (`localhost`, `127.0.0.1`, `[::1]`) accepts plain HTTP. The address cannot contain user info, a query, or a fragment. Redirects are not followed. - The key is only in the `Authorization` header; it does not appear in error objects or debug output. - **Retries** are made only for read `GET`s and for commands that carry **an `Idempotency-Key` that was actually provided**. Transient `408`, `429`, `500`, `502`, `503`, `504` and, for a safe operation, a network error are retried with bounded exponential backoff; `Retry-After` is within the total time limit. **`409` and `422` are not retried.** Create and delete calls are not repeated automatically. - On commands, you provide the `If-Match` (the version of the resource that you read) and `Idempotency-Key` headers ([Idempotency and concurrency](../reference/idempotency-and-concurrency.md)). - Pagination cursors are opaque; the `iterate…` methods (`iterateListCharacters`, `iterateListCharacterEvents`, `iterateGetCharacterTimeline`, `iterateListRules`, `iterateListWebhooks`) walk the pages for you. ### `PlayerClient`: player session `createPlayerClient({ baseUrl, environmentId })` is the player-side client (**cookie** mode only; a server key does not enter this client). What it contains: | Member | What does it do? | |---|---| | `session` | Sign-in, session state, refresh, sign-out: [Authentication](../auth/overview.md) | | `plugin(binding)` · `publicPlugin(binding)` | Plugin commands/queries/modules: [Plugin bindings](../plugins/bindings.md) | | `realtime` | The realtime socket (below) | | `catalog` | Reading panel-managed item/data/model/content group data (conditional GET, `watch…` pollers) | | `leaderboard(key, { limit })` | Reading a core leaderboard (`limit` 1–50) | | `store.catalog()` · `store.buy()` | Store showcase and purchasing | | `data.list()` · `data.get()` · `data.create()` · `data.update()` · `data.delete()` | Player data (cloud save): versioned writes, `PlayerDataConflict` on conflict; see [Player data](../guides/player-data.md) | | `notifications` | Device registration, inbox, web push | | `dispose()` | Releases local resources (does not close the server session) | For first use, see the [Quickstart](../getting-started/quickstart.md#6-your-first-player-sign-in-from-the-browser-guest). For session states, see [Sessions and CSRF](../auth/sessions-and-csrf.md#sdk-and-session-states). `createPlayerClient` does not start a network request in the constructor; `baseUrl` is the same origin as your game page (reverse proxy) or, if the game's domain is bound to the environment, the Open Play address (CORS; the same site is required) ([Architecture and hosting](../getting-started/architecture-and-hosting.md#calling-from-the-browser-direct-cors-or-reverse-proxy)). ### `GameClient`: player + plugin + realtime in a single lifecycle `createGameClient` keeps a player client, the plugin bindings, and the single realtime socket in the same lifecycle. `dispose()` closes the child bindings and the player client it created itself; for a server sign-out, also call `game.session.logout()`. A `PlayerClient` that you pass in from outside is not owned by `GameClient`. ```ts import { createGameClient, type GameClient } from "@ferdiunal/open-play-ts"; import { createBrowserAdapter } from "@ferdiunal/open-play-ts/browser"; /** * Bir oyunun oyuncu istemcisi, eklenti bağlamaları ve tek realtime soketi aynı yaşam döngüsünde tutulur. * IndexedDB günlüğü (journal) yarım kalan eklenti komutlarının sayfa yenilendikten sonra kurtarılmasını sağlar. */ export function startGame(environmentId: string): { game: GameClient; stop: () => Promise } { const browser = createBrowserAdapter({ journal: "indexeddb", allowMemoryFallback: false }); const game = createGameClient({ playerOptions: { baseUrl: window.location.origin, environmentId, browser }, }); return { game, stop: async () => { await game.dispose(); await browser.dispose(); }, }; } ``` `journal: "indexeddb"` lets unfinished plugin commands be recovered after the page is reloaded. In SSR/Node, choose `journal: "memory"` and do not share the client across requests. ### Realtime socket `client.realtime.connect()` gets a single-use ticket, opens the socket, and authenticates; on a drop it renews the ticket and resumes where it left off with the cursor/epoch. The server closes a socket that shows no activity with code **4100**; if you provide `onIdleClose`, the SDK does not reconnect automatically and the "Continue" decision is yours. Protocol details are in the [Realtime](../realtime/overview.md) section. ```ts import { createPlayerClient, isIdleClose, type PlayerClient } from "@ferdiunal/open-play-ts"; /** * Realtime soketini açar. Sunucu, etkinlik göstermeyen soketi 4100 koduyla kapatır; `onIdleClose` verirseniz * SDK otomatik yeniden bağlanmaz ve "Devam et" kararını size bırakır. */ export function createRealtimeClient(environmentId: string, showContinue: (resume: () => Promise) => void): PlayerClient { const client: PlayerClient = createPlayerClient({ baseUrl: window.location.origin, environmentId, realtime: { onIdleClose: () => showContinue(() => client.realtime.connect()), }, }); return client; } /** Durumu yoklayan uygulama aynı bilgiyi `lastClose` üzerinden okuyabilir. */ export function wasIdleClosed(client: PlayerClient): boolean { return isIdleClose(client.realtime.lastClose); } /** Oturum açıldıktan sonra soketi kurar; bilet yenileme ve yeniden bağlanma SDK'dadır. */ export async function connect(client: PlayerClient): Promise { const connection = await client.realtime.connect(); console.log("realtime durumu:", connection.state); } ``` ## Scope: which modules have a typed client? The SDK does **not** wrap the whole REST surface. The table below shows today's state; for modules marked "raw REST" you find the endpoints in the [endpoint reference](../reference/endpoints.md) and make the HTTP call yourself (the key and header rules are the same). | Module / area | Typed client | Where | |---|---|---| | Character definitions, traits, level curves, characters, commands, history, statistics | **Yes** | `CrxClient` (server key) | | Rules | **Yes** | `CrxClient` (`createRule`, `listRules`, `dryRunRule`, …) | | Webhook endpoint registration (create/list/read/delete) | **Yes** | `CrxClient`; signature verification is your own code ([Webhooks](../guides/webhooks.md)) | | Clans | **Yes** | `CrxClient` (20 operations) | | Catalog (item/data/model) and content groups | **Yes** | `PlayerClient.catalog` (player), `CrxClient.catalog` (server) | | Reward table registration and granting | **Yes** | `CrxClient.createRewardTable`, `getRewardTable`, `grantRewardTable` | | Store showcase and purchasing | **Yes** | `PlayerClient.store` | | Reading a core leaderboard | **Yes** | `PlayerClient.leaderboard` | | Notifications: device registration, inbox, web push | **Yes** | `PlayerClient.notifications` | | Plugins and realtime | **Yes** (with a binding generated from the schema) | `PlayerClient.plugin`, `GameClient` | | Items and inventory | No | Raw REST | | Currencies, wallets, transfers, ledger | No | Raw REST | | Trading, merchants | No | Raw REST | | Store administration, IAP verification | No | Raw REST | | Quest, VIP, daily login, and NPC rewards | No | Raw REST | | Parties | No | Raw REST | | Server-side leaderboards, tournaments | No | Raw REST | | Chat | No | Raw REST/WebSocket | | Scenes, assets | No | Raw REST | | Magic link, unlinking an account, native platform sign-ins | No | Raw REST ([Authentication](../auth/overview.md)) | The number of typed operations in `CrxClient` is 45 (25 core + 20 clan), and the catalog and reward table operations are added to these. Using a module that has no typed client is not a problem; you just write the types by hand. ## Errors All errors arrive as `CrxError`: `kind`, `status`, `attempts`, and safe metadata (`code`, `operation`, `deliveryState`, `reference`, `reason`). The HTTP body, the request URL, and the key do **not** enter the error object. | `ErrorKind` | Meaning | |---|---| | `Http` | The server returned an error status (`status` is filled) | | `Network` | The connection dropped; a mutation may have been applied | | `Timeout` | The total time limit ran out | | `Cancelled` | Cancelled with `signal` | | `Contract` | The input or the response does not conform to the contract | | `Configuration` | An unsafe or broken setting | | `Auth` | The player session wants re-authentication | | `UnknownOutcome` | The request was sent but the outcome is unknown; do not blindly repeat, query the outcome | | `Storage` | The journal, browser coordination, or the OAuth binding store is unavailable | | `Realtime` · `Backpressure` · `UpgradeRequired` · `ScopeChanged` · `Disposed` | Realtime lifecycle, queue full, plugin version mismatch, session changed, client closed | A banned account is distinguished with `error.code === ACCOUNT_BANNED_CODE` ([Authentication overview](../auth/overview.md#banned-accounts)). ## Numbers and JSON - `i64`/`u64` fields are **`bigint`**; 32-bit fields are bounds-checked `number`. `9007199254740993n` is carried losslessly. - Do not round-trip the SDK payload through `JSON.parse`/`JSON.stringify`; use `parseJson`, `stringifyJson`, `encodeModel`, `decodeModel`. Decimal numbers become `LosslessNumber`. - Default limits: JSON 2 MiB, depth 96, node count 200,000. - In the plugin protocol, `i64` values are decimal strings (`plugin-v1-decimal-int64`). See [Numbers and encoding](../reference/numbers-and-encoding.md). ## Code generation (`crx`) The CLI is used to generate a typed binding from a plugin schema and types from a character definition (Node only; there is no network access, the input is a file, the output is written only to a new file, `--check` compares bytes): ```bash # Plugin binding: the schema digest (sha256) is pinned; generation is rejected if it changes npx crx plugins generate --input plugin.schema.json --schema-sha256 "$SCHEMA_SHA256" --output binding.ts npx crx plugins generate --input plugin.schema.json --schema-sha256 "$SCHEMA_SHA256" --output binding.ts --check # Character definition types npx crx types generate --input definition.json --version 1 --schema-sha256 "$SCHEMA_SHA256" --output hero.ts ``` Where to get the plugin schema and what pinning `schemaSha256` means are described on the [Plugin bindings](../plugins/bindings.md) page. ## Known limits - Only cookie mode exists; there is no Bearer or server-key mode on the player client. - The access JWT is not exposed to the application ([Server-side verification](../auth/server-verification.md)). - There is no helper for magic link, unlink, and native platform sign-ins. - There is no method for changing the display name (`POST /v1/auth/session/display-name`). - The server grants CORS only to origins in the environment domain list; because the session cookie is `SameSite=Lax`, the game and Open Play must be on the same site, otherwise use a reverse proxy. --- Source: https://open-play.b4klabs.dev/docs/en/sdk/unity.html # Unity SDK `com.openplay.sdk` is the C# client that connects to Open Play from Unity: guest and email/password sessions, restoring the session across restarts, plugin commands and queries, and the realtime socket. This page honestly describes installation, usage, and **what has been verified**. > **Warning** > > The package is verified only with **Unity 6000.6.7f1**; other editor versions and most platforms are not verified ([Verified platforms](#verified-platforms)). This document is for package version **0.1.0**. **API reference:** the generated reference for the C# types is at [https://open-play.b4klabs.dev/docs/sdk/unity/](https://open-play.b4klabs.dev/docs/sdk/unity/). It is in Turkish for now, because it mirrors the Turkish source comments. ## Installation The package comes from the GitHub Packages npm registry at `https://npm.pkg.github.com/@ferdiunal`. The package name is unscoped (`com.openplay.sdk`); the scope is in the registry address. 1. **Credentials.** GitHub asks for a token even for public packages. Create `~/.upmconfig.toml` in your user folder. The token is a classic token with `read:packages` permission (a fine-grained token was not tried) and is a secret: do not put it in the repository, do not write it to logs. ```toml [npmAuth."https://npm.pkg.github.com/@ferdiunal"] token = "" alwaysAuth = true ``` 2. **Manifest.** Add the scoped registry and the dependency to your game project's `Packages/manifest.json` file: ```json { "dependencies": { "com.openplay.sdk": "0.1.0" }, "scopedRegistries": [ { "name": "Open Play", "url": "https://npm.pkg.github.com/@ferdiunal", "scopes": ["com.openplay"] } ] } ``` 3. **Open Unity.** Two known behaviors: the package does **not appear** on Package Manager's search screen (GitHub's search endpoint returns `405`; you write the package into `manifest.json` by hand), and the log shows the warning `Package 'com.openplay.sdk' has been installed without a signature` (harmless). The `com.unity.nuget.newtonsoft-json` 3.2.1 and `com.unity.modules.unitywebrequest` dependencies are resolved automatically. As a fallback, a local path (`"com.openplay.sdk": "file:/sdks/unity"`) can be used; this is only for those who have access to the source repository. The package includes a **Basic Login** sample (Package Manager › Open Play SDK › Samples). ## Basic usage `OpenPlayClient.Create(OpenPlayUnity.Options(baseUrl, environmentId))` sets up the client; `Options` chooses the main-thread pump, the `PlayerPrefs` store, and the transport suitable for the platform. The component in the [Quickstart](../getting-started/quickstart.md#7-your-first-player-sign-in-from-unity) restores the stored session and opens a guest if there is none. `OpenPlayOptions` settings: | Setting | Meaning | |---|---| | `BaseUrl` | The API root. Absolute `https` (loopback `http` in development); cannot contain a query, fragment, or user info | | `EnvironmentId` | The environment ID (`x-crx-env`); cannot be empty | | `AllowInsecureTransport` | Development only: plain `http`/`ws` on loopback addresses (`localhost`, `127.0.0.1`, `::1`). Default `false` | | `NativeOrigin` | The fixed `Origin` sent on ticket and WebSocket requests in a native build. If empty, derived from `BaseUrl`. Must be in the environment's domains or in the global allowed list | | `PageOrigin` | WebGL only: the page's address; must be the same origin as `BaseUrl` (`OpenPlayUnity.Options` fills it in) | | `SecretStore` | The persistent store for the guest secret and the refresh token (see below) | | `TimeoutMs` | Total request time: 1–120,000, default 15,000 | | `MaxResponseBytes` | Largest response: 1 byte–16 MiB, default 2 MiB | | `UserAgent` | If empty, it is not sent at all | | `Realtime` | Realtime settings (`AutoReconnect`, `ReconnectBaseMs`, `MaxReconnectAttempts`, `HeartbeatMs`, `AuthTimeoutMs`, `MaxPendingInputs`, `OnIdleClose`) | A wrong setting gives `OpenPlayException` with `ErrorKind.Configuration` and a coded reason (for example `base_url_scheme_invalid`, `insecure_transport_not_loopback`, `environment_id_required`). ## Session `client.Session`: | Method | What does it do? | |---|---| | `RestoreAsync()` | Restores the stored session; does **not** create a guest if there is none | | `SignInGuestAsync()` | Guest sign-in ([Guest](../auth/guest.md)) | | `SignUpEmailAsync`, `SignInEmailAsync`, `UpgradeGuestAsync` | Email sign-up/sign-in/upgrade ([Email](../auth/email-password.md)) | | `ChangePasswordAsync`, `RequestPasswordResetAsync`, `ResetPasswordAsync` | Password operations | | `RenameAsync(displayName)` | Changing the display name (2–24 characters, 10 per hour) | | `RefreshAsync()` · `LogoutAsync()` | Refresh and sign-out | | `StateChanged` event · `State` | `SessionState`: `Status`, `PlayerId`, `SessionId`, `IsGuest`, `DisplayName`, `Email` | `SessionStatus`: `SignedOut`, `Authenticating`, `Authenticated`, `Refreshing`, `ReauthRequired`, `SigningOut`, `LogoutUncertain`, `Banned` (terminal; you leave it only with `LogoutAsync`), `Disposed`. Passwords and tokens are carried with `SecretText`; they do not enter `ToString()` or logs. For email and password examples, see the [Email and password](../auth/email-password.md#sdk) page. ### Secret storage The guest secret and the refresh token are stored with `ISecretStore`. The default `PlayerPrefs` store is **plain text and is not a secure store** (no iOS Keychain, no Android Keystore). In production, provide your own `ISecretStore` implementation that writes to the platform's secure store: ```csharp using System.Threading; using System.Threading.Tasks; using OpenPlay.Support; /// Platformunuzun güvenli deposunu (iOS Keychain, Android Keystore, masaüstü kimlik deposu) temsil eden kendi arayüzünüz. public interface IPlatformKeystore { string Read(string key); void Write(string key, string value); void Delete(string key); } /// /// ISecretStore sözleşmesi: misafir bootstrap sırrı ve yenileme belirteci burada saklanır. /// Varsayılan PlayerPrefs deposu düz metindir ve güvenli depo DEĞİLDİR; üretimde bunun gibi kendi deponuzu verin: /// options.SecretStore = new KeystoreSecretStore(myKeystore); /// public sealed class KeystoreSecretStore : ISecretStore { private readonly IPlatformKeystore _keystore; public KeystoreSecretStore(IPlatformKeystore keystore) { _keystore = keystore; } public Task GetAsync(string key, CancellationToken cancellationToken) => Task.FromResult(_keystore.Read(key)); public Task SetAsync(string key, string value, CancellationToken cancellationToken) { _keystore.Write(key, value); return Task.CompletedTask; } public Task ClearAsync(string key, CancellationToken cancellationToken) { _keystore.Delete(key); return Task.CompletedTask; } } ``` Contract: the value does not enter logs, exception messages, or `ToString()`; `GetAsync` returns `null` for a missing key; every operation can fail or return late (the SDK gives up after 3 s). ## Plugin commands and queries `client.Plugin(binding, pin)` gives a plugin client (`PrepareCommand`, `QueryAsync`, `PublicQueryAsync`, `VerifyServerSchemaAsync`). When a command is prepared, **a single `Idempotency-Key`** is generated and the body is frozen; on an uncertain outcome (`ErrorKind.UnknownOutcome`) it is queried with `LookupAsync` using the same key, and if it was lost the exact same command is resent with `ResumeAsync`. No blind new command is generated. > **Note** > > In the published package, **only the `package-ride` plugin** has a generated typed binding (`OpenPlay.PackageRide`). For any other plugin there is no generated Unity binding today; you work with a raw `PluginBinding`. The binding is pinned to the schema digest on the server (`schemaSha256`): on a mismatch you get `UpgradeRequired`. ```csharp using System.Threading.Tasks; using OpenPlay; using OpenPlay.PackageRide; using OpenPlay.Plugins; /// /// Eklenti komutu: yayımlı pakette yalnız package-ride için üretilmiş tipli bağlama vardır. /// Komut hazırlanırken tek Idempotency-Key üretilir; belirsiz sonuçta AYNI anahtarla sorgulanır. /// public static class PluginCall { public static async Task ClaimLoginBonus(OpenPlayClient client, long expectedVersion) { var rides = PackageRideClient.Create(client); // bağlama sürümü ve şema özeti sunucuyla eşleşmezse UpgradeRequired var command = rides.Commands.ClaimLoginBonus.Prepare(new LoginBonusInput { ExpectedVersion = expectedVersion }); try { var receipt = await command.SendAsync(); LoginBonusOutput output = rides.Commands.ClaimLoginBonus.ParseOutput(receipt); // tipli çıktı UnityEngine.Debug.Log("giriş bonusu işlendi, HTTP durumu: " + receipt.Status + ", çıktı: " + output.GetType().Name); } catch (OpenPlayException ex) when (ex.Kind == ErrorKind.UnknownOutcome) { // Yanıt kayboldu; komut uygulanmış olabilir. Yeni komut üretmeyin, aynı anahtarla sorgulayın. var lookup = await command.LookupAsync(); if (lookup.Status == CommandLookupStatus.NotFound) await command.ResumeAsync(); // tam aynı anahtar ve gövde } } } ``` ## Realtime `client.Realtime.ConnectAsync()` gets the ticket, opens the socket, and authenticates; on a drop it reconnects with increasing delay. In a native build the ticket is obtained with `Authorization: Bearer` + `x-crx-env` + the fixed `Origin`; no cookie is used. To open a subscription, use `RealtimeConnection.JoinAsync(module, JoinTarget.PlayerSelf() / Room / Scene / InterestSet)`. Protocol details: [Realtime](../realtime/overview.md). ```csharp using System; using System.Threading.Tasks; using OpenPlay; using OpenPlay.Realtime; using OpenPlay.Unity; /// /// Realtime soketi: bilet alma, bağlanma ve yeniden bağlanma SDK'dadır. /// Sunucu etkinlik göstermeyen soketi 4100 koduyla kapatır; OnIdleClose verirseniz otomatik yeniden bağlanma olmaz. /// public static class RealtimeConnect { public static OpenPlayClient Create(Uri baseUrl, Guid environmentId, Action showContinue) { var options = OpenPlayUnity.Options(baseUrl, environmentId); options.Realtime = new RealtimeOptions { OnIdleClose = _ => showContinue() }; // "Devam et" düğmesini gösterin return OpenPlayClient.Create(options); } /// Oturum açıldıktan sonra çağrılır; "Devam et" onayında da aynı çağrı yeni bilet alıp sürdürür. public static Task Connect(OpenPlayClient client) => client.Realtime.ConnectAsync(); } ``` ## WebGL In the WebGL player the SDK switches to **browser mode** (`OpenPlayUnity.Options` chooses this itself): HTTP goes through `UnityWebRequest`, realtime goes through the browser's WebSocket (a small `jslib` bridge), and the browser manages the session cookies. The SDK does not set a `Cookie`, `Origin`, `Authorization`, or `User-Agent` header; every mutation carries `x-csrf-token`. - **Same origin only (SDK limitation).** Today the Unity WebGL SDK requires the game page and the Open Play API to be served from the same root; the server now grants CORS to origins in the environment domain list, but the Unity SDK does not use it yet ([Architecture and hosting](../getting-started/architecture-and-hosting.md#calling-from-the-browser-direct-cors-or-reverse-proxy)). A different origin gives `OpenPlayException` with `Configuration` and the code `webgl_cross_origin_unsupported`; it does not fail silently. - **Hidden tab.** The heartbeat continues with the browser timer, and when the tab goes to the background the connection stays open. Messages and HTTP completions that arrive while hidden wait for the Unity frame and are processed when the tab becomes visible. With `Application.runInBackground` off, the game loop stops in a hidden tab; if your game must progress while hidden, set `Application.runInBackground = true`. - **Multiple tabs.** Tabs on the same origin share a single cookie jar; signing in in the second tab changes the first tab's session cookie. ## Verified platforms Level: **R** = measured in Unity, **B** = documentation or `dotnet` indicator only, **—** = none. | Target | Level | Note | |---|---|---| | Unity 6000.6.7f1 Editor (Linux) | R | Mono and IL2CPP measured | | Windows | B | not verified | | macOS | B | not verified | | Android | B | not verified (no phone measurement was made) | | iOS | — | not verified | | WebGL (same origin) | R | The BasicLogin WebGL player against a real API: guest and email sign-in, a plugin query, realtime `Ready`, join, and input acknowledgment; the heartbeat continued for 35 s in a hidden tab. **Chrome only**; Firefox and Safari are not verified | The `unity: 2022.3` field in `package.json` is only a lower bound; Unity 2022.3 is not verified. ## Errors All errors arrive as `OpenPlayException`: `Kind` (`ErrorKind`), `Status`, `Attempts`, `Code`, `Operation`, `DeliveryState`, `Reference`, `Reason`. The `ErrorKind` values: `Http`, `Network`, `Timeout`, `Cancelled`, `Contract`, `Configuration`, `Pagination`, `Codegen`, `Auth`, `UnknownOutcome`, `Storage`, `Realtime`, `Backpressure`, `UpgradeRequired`, `CapabilityUnavailable`, `Disposed`, `ScopeChanged`. Their meanings are the same as those in the TypeScript SDK ([TypeScript SDK › Errors](typescript.md#errors)). ## Out of scope and known limits - **Not in the Unity SDK:** OAuth (Google), catalog, store, leaderboard, notifications, and administration operations with a server key. Use raw REST for these; **do not put** a server key into the game client. - There is no response compression (`permessage-deflate`). - The access JWT is not exposed to the application ([Server-side verification](../auth/server-verification.md)). - Android, iOS, and macOS player runs were not made; WebGL works only on the same origin. - The `PlayerPrefs` store is not secure; in production provide your own store. --- Source: https://open-play.b4klabs.dev/docs/en/guides/characters.html # Characters This page explains how the character a player plays is defined, created and changed in Open Play. Read it if you want to keep character state (attributes, resources, XP, level, abilities, flags) on the server and change it with cheat-proof commands. ## What it does The character module (`characters`) keeps player-bound character state in one trustworthy place. State changes only through **commands**, and every change is recorded as an immutable event. This lets you read back a character's state at any point in time and undo a faulty change with a new event. If the module is disabled, every endpoint on this page returns `404` with `{"code":"module_disabled","module":"characters"}`. Enable the module on the [Game settings › Modules](../panel/game-settings-modules.md) page. ## Where it lives in the panel | What you want to do | Panel page | |---|---| | Define a character template, a trait and a level curve | [Character templates](../panel/character-templates.md) | | List characters, open one and see its state and history | [Characters](../panel/characters.md), [Character profile](../panel/character-profile.md) | | See a player's account and characters together | [Players](../panel/players.md), [Player profile](../panel/player-profile.md) | | Write event-driven automation | [Game rules](game-rules.md) | > **Note** > > Panel help pages are not translated yet; the panel links above point to the Turkish source. ## Data model (in plain words) - **Trait:** A reusable bundle of fields. For example, a "warrior" trait brings strength and health fields. A template combines several traits. - **Template (definition):** A character type identified by a key (`warrior`). It says which traits it uses, the defaults of the fields, how the character is created (`defaults`, `point_buy`, `roll`) and which level curve it follows. A template becomes **a new version** on every publish; existing characters stay bound to the version they were born with. - **Level curve:** Describes how many XP a level corresponds to. The level is not stored; it is derived from cumulative XP, so changing the curve does not require migrating character data. There are three kinds: `table` (a list of thresholds), `piecewise` (XP per level range) and `formula` (a quadratic formula, with `max_level`). - **Character:** An instance born from a template. You can write your game's own player ID into `external_id`; two characters in the same environment cannot have the same `external_id`. - **State:** `attributes` (trait values), `resources` (consumed resources such as health/mana), `progression` (`level`, `xp`, `highest_level`), `abilities`, `flags` (yes/no flags) and the free-form `custom` field. - **Version (`version`):** A number that grows with every command on the character. It is used to prevent concurrent writes (see below). Numbers are integers (`i64`); there are no decimals. Percentages are written in basis points, `10000` = 100%. Details: [Numbers and encoding](../reference/numbers-and-encoding.md). ## Calls your game makes Character endpoints are called with an **API key**; a player session (cookie or session token) is not valid on these endpoints. Request header: ``` Authorization: Bearer crx_live__ ``` The key type matters (see [Concepts](../getting-started/concepts.md)): a **server key** runs every command, a **client key** can only run the `set_flag` command. Do not embed a server key in your game. | Method and path | Key | What it does | |---|---|---| | `POST /v1/definitions` | any (see note below) | Saves a template as a new version | | `GET /v1/definitions/{key}` | any | Reads the current template and the resolved schema | | `POST /v1/traits` | any (note) | Saves a trait | | `POST /v1/level-curves` | any (note) | Saves a level curve | | `GET /v1/level-curves/{key}` | any | Reads a curve | | `POST /v1/characters` | any | Creates a character from a template | | `GET /v1/characters` | any | Lists characters (`definition`, `cursor`, `limit`) | | `GET /v1/characters/{id}` | any | Reads a character with its state | | `DELETE /v1/characters/{id}` | any | Deletes a character (soft delete, `204`) | | `POST /v1/characters/{id}/commands` | depends on the command | Runs a command | | `GET /v1/characters/{id}/stats` | any | Returns effective values and the calculation breakdown | | `GET /v1/characters/{id}/events` | any | Event log (`cursor`, `limit`) | | `GET /v1/characters/{id}/timeline` | any | Human-readable timeline | | `GET /v1/characters/{id}/state?at=` | any | Reconstructs the state at a past moment | Identity and module rules and error bodies: [Errors](../reference/errors.md). Pagination: [Pagination](../reference/pagination.md) (`limit` 1–200, default 50; when `next_cursor` is `null`, it is the last page). > **Note** > > Error bodies on these endpoints are plain text (`text/plain`). Only `module_disabled` and `account_banned` return JSON. > **Warning** > > **Known limitation:** The endpoints that write templates, traits, level curves and rules, and the endpoints that read or delete characters, do not look at the key type; a valid **client key** can call them too. A client key is scoped to the environment, not to the player: with a key embedded in the game, any character in that environment can be read or deleted. So make management calls and character deletion only from your own server, and apply an ownership check on your server to any character ID that comes from a player. A gate by key type does not exist yet. ### Creating a character ``` POST /v1/characters Authorization: Bearer crx_live__ Content-Type: application/json { "definition": "warrior", "external_id": "player-1234" } ``` ```json { "id": "0192f0f2-5b1a-7c3e-9d11-4a5b6c7d8e9f", "definition": "warrior", "definition_version": 3, "external_id": "player-1234", "version": 1, "level": 1, "state": { "attributes": { "strength": 10 }, "resources": { "hp": 100 }, "progression": { "level": 1, "xp": 0, "highest_level": 1, "granted_rewards": [] }, "abilities": [], "flags": {}, "custom": {} }, "created_at": "2026-11-03T09:00:00Z" } ``` The body can also contain `allocation` (for point-buy creation: field → points spent), `seed` (required for dice-roll creation, a deterministic seed) and `custom` (free-form JSON, at most 64 KB). The mode is defined in the template; the request only carries the input the mode asks for. > **Warning** > > The create and delete endpoints do not accept an idempotency key and are not retried automatically. A second create with the same `external_id` returns `409`; this also prevents duplicate characters. After a network error, do not blindly resend; first look the character up by `external_id`. ### Running a command ``` POST /v1/characters/{id}/commands Authorization: Bearer crx_live__ Content-Type: application/json Idempotency-Key: quest-17-player-1234 If-Match: 1 { "type": "add_xp", "amount": 250 } ``` ```json { "id": "0192f0f2-5b1a-7c3e-9d11-4a5b6c7d8e9f", "version": 2, "state": { "...": "current state" }, "events": [ { "id": "…", "seq": 2, "type": "xp_added", "payload": { "...": "…" }, "created_at": "2026-11-03T09:01:00Z" } ], "replayed": false } ``` The command type is chosen with the `type` field: | `type` | Body fields | Key | |---|---|---| | `set_flag` | `key`, `value` (bool) | client or server | | `set_attribute` | `key`, `value` (integer) | server | | `add_resource` | `key`, `delta` (integer, can be negative) | server | | `add_modifier` | `stat`, `operation` (`flat_add`, `flat_sub`, `percent_add`, `percent_mul`, `clamp`), `value`, `priority`, `source_kind` (`item`, `buff`, `skill`, `curse`, `quest`), `source_id`, `expires_at` | server | | `remove_modifier` | `source_kind`, `source_id` | server | | `add_xp` | `amount` | server | | `rollback` | `to_version` | server | If you send any command other than `set_flag` with a client key, you get `403` and your idempotency key is not burned. One command can produce several events: for example, if `add_xp` causes a level up, the response contains `xp_added` and `level_up` together; defined rules produce additional events as well. ## Concurrency and replay safety - **`Idempotency-Key` (optional, 1–200 characters):** If the same key and the same body arrive a second time, the command does not run again; the first response is returned with `replayed: true`. The same key with a different body is `422`. The key is "burned" only when the command succeeds; a rejected command does not consume the key. The same key in flight at the same time gets `409`. - **`If-Match` (optional):** The `version` value you read, as `3` or `"3"`. If the character is not at that version, the command is rejected with `409` and nothing changes. `*` and the weak `W/` form are not accepted (`400`). `If-Match` is not processed on the `rollback` command. General rules: [Idempotency and concurrency](../reference/idempotency-and-concurrency.md). ### Rollback The `rollback` command does not delete history. It writes **a new event** (`character_rolled_back`) that returns the character to the state at `to_version`; the event log is append-only. ## Events and webhooks Character events (`flag_set`, `attribute_set`, `resource_changed`, `modifier_added`, `modifiers_removed`, `xp_added`, `level_up`, `ability_unlocked`, `event_emitted`, `character_rolled_back`) can also be delivered as webhooks. The envelope contains `event_id`, `type`, `character_id`, `environment_id`, `seq` and `data`. Registration and verification: [Webhooks](webhooks.md). ## Limits - The `custom` field is at most 64 KB; it cannot contain decimal numbers. - `limit` on list endpoints is 1–200. - A level cannot be below 1; the maximum is 65535. - `GET …/state?at=` returns `400` for a future or negative time. `at` is a Unix millisecond timestamp or an RFC 3339 time. ## Common errors | Symptom | Cause | Fix | |---|---|---| | `403 permission denied` | A command other than `set_flag` with a client key | Send the command from your server with a server key | | `409 version conflict: expected 3, actual 4` | `If-Match` is stale | Re-read the character and resend with the new version | | `409 already exists` | The same `external_id` already exists | Find the existing character with `GET /v1/characters` | | `422 idempotency mismatch` | The same key, a different body | Generate a new key; use the same key only for network retries | | `400 If-Match tam sürüm numarası olmalı` | `*` or a weak tag was sent | Send only the number | | `404` + `module_disabled` | The `characters` module is disabled | Enable the module | > **Note** > > The `400` message text is returned by the server in Turkish, exactly as shown; match on the status code, not on the text. ## Working example The example below creates a character and safely grants XP. The template (`warrior`) must already be defined, from the panel or with `POST /v1/definitions`. ```ts /** Güvenilir backend örneği: karakter oluşturur, XP verir. Server anahtarı yalnız sunucu ortamından gelir. */ import { CrxClient, CrxError } from "@ferdiunal/open-play-ts"; /** Oyuncunun karakterini açar ve ilk XP'yi güvenle (idempotent) verir. */ export async function createAndReward(baseUrl: string, serverApiKey: string, playerId: string): Promise { const crx = new CrxClient({ baseUrl, apiKey: serverApiKey }); // 1) Karakteri tanımdan oluştur. `external_id` oyunun kendi oyuncu kimliğidir. const character = await crx.createCharacter({ body: { definition: "warrior", external_id: playerId }, }); // 2) XP ver: aynı Idempotency-Key ile tekrar denemek ikinci XP vermez. // If-Match, karakterin okuduğunuz sürümüdür; araya başka komut girdiyse 409 döner. try { const result = await crx.executeCommand({ path: { id: character.id }, body: { type: "add_xp", amount: 250n }, headers: { "Idempotency-Key": `quest-1-${playerId}`, "If-Match": character.version.toString(), }, }); return result.version; } catch (error) { if (error instanceof CrxError && error.status === 409) { // Sürüm çakışması: karakteri yeniden okuyup komutu yeni sürümle gönderin. throw new Error("Karakter bu arada değişti; yeniden okuyup tekrar deneyin."); } throw error; } } ``` ## Related pages - [Concepts: key types and sessions](../getting-started/concepts.md) - [Game rules and automation](game-rules.md) - [Inventory and items](inventory-items.md) - [Errors](../reference/errors.md), [Idempotency and concurrency](../reference/idempotency-and-concurrency.md) --- Source: https://open-play.b4klabs.dev/docs/en/guides/inventory-items.html # Inventory and items This page describes how to define your game's items (potions, weapons, cosmetics…), give them to players, consume them, transfer them and place them in the inventory. Read it if you want inventory state to be held safely on the server. ## What it does The items module (`items`) manages two separate things: - **Item definition:** A kind such as "Small health potion". Its key, kind, whether it is stackable, whether it is tradable and the NPC buy/sell price live here. - **Item instance:** The real item in a player's hands. It carries the definition version it was born with, its owner, its quantity and its customization data (`metadata`). An **inventory** is the placement of an owner's items. The inventory only holds *which item is in which slot*; quantity and customization live on the item instance. If the module is disabled, the endpoints return `404` with `{"code":"module_disabled","module":"items"}`. Enable the module on the [Game settings › Modules](../panel/game-settings-modules.md) page. ## Where it lives in the panel | What you want to do | Panel page | |---|---| | Edit item definitions and game data (the built-in item group) | [Content](../panel/content.md) | | Attach a 3D model to an item | [3D models](../panel/models.md), [Model profile](../panel/model-profile.md) | | See a player's inventory and wallet | [Player profile](../panel/player-profile.md) | | Sell an item in the store | [Store](../panel/store.md) | > **Note** > > Panel help pages are not translated yet; the panel links above point to the Turkish source. ## Data model (in plain words) - **Kind (`kind`):** `consumable`, `equipment`, `material`, `cosmetic`, `vip_card`. - **Stackable (`stackable`) and `max_stack`:** For a stackable item, instances from the same definition version with empty `metadata` merge into a single row. If `stackable: true`, `max_stack` is required (between 1 and `2147483647`). - **`tradeable`:** If `false`, the item cannot be transferred to another owner (`422`). The default is `true`. - **`npc_buy_price`, `npc_sell_price`:** An integer in the smallest unit. If `null`, buying/selling with a merchant is closed. - **Version pinning:** Each instance carries the definition version it was born with (`definition_version`). Changing a definition later does not change the rules of old instances. - **Owner (`owner`):** The form `:`; the kind can be `player`, `character`, `clan` or `system`. Example: `player:0192f0f2-5b1a-7c3e-9d11-4a5b6c7d8e9f`. - **Inventory:** Opened by itself on the first command (default capacity 32). The capacity is the upper bound on the **number of occupied slots**; a slot number can be any number 0 or greater. Reading an inventory that has never been opened returns `404` and does not open it. - **`version`:** Both the item instance and the inventory have their own version; it grows with every change and is used with `If-Match`. ## Calls your game makes Item and inventory endpoints are called with an **API key**: ``` Authorization: Bearer crx_live__ ``` The player side offers two read endpoints; they are called with a **player session** (a cookie or session token, with the `x-crx-env: ` header): | Method and path | Credential | What it does | |---|---|---| | `POST /v1/item-definitions` | server key | Saves a definition as a new version (game data can also be written with the `content` field) | | `GET /v1/item-definitions/{key}` | API key | Reads the current definition version | | `POST /v1/items` | server key | Creates (mints) an item | | `GET /v1/items?owner=player:` | API key | Lists the owner's items (`cursor`, `limit`) | | `GET /v1/items/{id}` | API key | Reads a single instance | | `POST /v1/items/{id}/consume` | API key | Consumes quantity | | `POST /v1/items/{id}/destroy` | server key | Destroys the item | | `POST /v1/items/{id}/transfer` | API key | Transfers ownership | | `GET /v1/inventories/{owner}` | API key | Reads the inventory and the occupied slots | | `POST /v1/inventories/{owner}/move` | API key | Places or moves an item into a slot | | `POST /v1/inventories/{owner}/split` | API key | Splits a quantity off a stack into a new slot | | `POST /v1/inventories/{owner}/stack` | API key | Merges two stacks | | `POST /v1/inventories/{owner}/equip` | API key | Puts an item into an equip slot | | `POST /v1/inventories/{owner}/unequip` | API key | Moves an equipped item down to another slot | | `GET /v1/player/inventory` | player session | The player's own inventory (read-only) | | `GET /v1/player/items` | player session | The player's own items (read-only) | | `GET /v1/catalog/items`, `GET /v1/catalog/items/{key}` | server key or player session | The item catalog ([below](#item-catalog)) | > **Note** > > The TypeScript SDK has no typed client for items and inventory; these endpoints are called with raw REST. There is no SDK helper yet for reading with a player session (`/v1/player/*`) either. Coverage table: [TypeScript SDK](../sdk/typescript.md). > **Warning** > > **Known limitation:** The `consume`, `transfer`, `move`, `split`, `stack`, `equip` and `unequip` endpoints do not look at the key type; a valid **client key** can call them too, and `owner` or the item ID is given freely in the request. A client key is scoped to the environment, not to the player. Verify **on your own server** that a player touches only their own items; against the risk of cheating, make these calls from the game server. `mint` and `destroy` work only with a server key. ### Minting an item ``` POST /v1/items Authorization: Bearer crx_live__ Content-Type: application/json Idempotency-Key: quest-17-potion-player-1234 { "definition_key": "health_potion", "owner": "player:0192f0f2-5b1a-7c3e-9d11-4a5b6c7d8e9f", "quantity": 3, "source": "quest" } ``` ```json { "instance": { "id": "0192f1aa-1111-7222-8333-944455556666", "definition_id": "0192f0aa-0000-7000-8000-000000000001", "definition_version": 2, "owner_type": "player", "owner_id": "0192f0f2-5b1a-7c3e-9d11-4a5b6c7d8e9f", "quantity": 3, "metadata": {}, "version": 1, "created_event_id": "0192f1aa-2222-7333-8444-955566667777", "created_at": "2026-11-03T09:00:00Z", "updated_at": "2026-11-03T09:00:00Z" }, "event": { "type": "item_instance_created" }, "replayed": false } ``` - `quantity` is between 1 and `2147483647`. - `source` can be `mint`, `craft`, `purchase`, `quest` or `grant`; if not given, `mint`. - If `metadata` is non-empty, the item does not stack and is always a single row. - For a stackable definition, if the owner already holds a stack of the same version with empty `metadata`, no new row is opened and the quantity is added to that stack. If `max_stack` is exceeded, `422`. ### Consuming, destroying, transferring | Endpoint | Body | Note | |---|---|---| | `POST /v1/items/{id}/consume` | `{"amount": 1}` | `amount` is between 1 and the current quantity; exceeding it is `422`. If the quantity drops to zero, the row is deleted and the next `GET` returns `404`. The item kind is not checked. | | `POST /v1/items/{id}/destroy` | `{"amount": 1}` | Server key only. The same rule applies; past events are not deleted. | | `POST /v1/items/{id}/transfer` | `{"to_owner": "player:"}` | `422` if `tradeable: false`. | `Idempotency-Key` is required on all three of these endpoints (`400` if absent). `If-Match` is optional and is the item instance's `version` value. ### Inventory placement Placement commands have the same shape: ``` POST /v1/inventories/player:0192f0f2-5b1a-7c3e-9d11-4a5b6c7d8e9f/move Authorization: Bearer crx_live__ Content-Type: application/json Idempotency-Key: move-1 If-Match: 4 { "item_instance_id": "0192f1aa-1111-7222-8333-944455556666", "to_slot": 5 } ``` | Endpoint | Body | |---|---| | `move` | `item_instance_id`, `to_slot` | | `equip`, `unequip` | `item_instance_id`, `to_slot` | | `split` | `from_instance_id`, `amount`, `to_slot` | | `stack` | `from_instance_id`, `to_instance_id` | The response has the form `{ "inventory": {…}, "slots": [{ "slot_idx": 5, "item_instance_id": "…", "created_at": "…" }], "event": {…}, "replayed": false }`. Rules: - Placing into an occupied slot is `422`. When placing a new item, exceeding the capacity is also `422`; moving an already placed item does not consume capacity. - `If-Match` is the **inventory's** version. If you send `If-Match` on the first open (while the inventory does not exist yet), you get `400`. - If the same item is already placed in another inventory, `409` (double-placement lock). - `split`: `amount` is between 1 and one less than the source quantity; the item must be stackable. The new instance's ID is returned in the event in the response (`new_instance_id`). - `stack`: both stacks must be placed; they must be of the same definition version and have empty `metadata`. - `equip`: if the definition's `metadata` has an `equip_condition` (a CEL expression), equipping depends on this condition. If the condition or its evaluation errors, equipping is **rejected** (`422`). In the condition the `character` context is empty for now, and `now` and `seed` are a constant `0`. ## Item catalog The game client reads the item definition, the **typed data rows** and the model address from the catalog. There are two audiences: a **player** sees only rows with `player` visibility; a **server key** sees all rows, with each row's visibility and with `content_revision`. Responses carry an `ETag`; with `If-None-Match`, `304` is returned if nothing changed. Details and a watching example: [Content groups](content-groups.md#catalog-items-and-characters) and [Scenes, assets and 3D models](scenes-assets-models.md). ## Versioning and replay safety - `Idempotency-Key` is **required** on mint, consume, destroy, transfer and all inventory commands. If the same key and the same body arrive a second time, the operation does not run again; the response returns with `replayed: true`. The same key with a different body is `422`. The key is burned only on success. - `If-Match` prevents concurrent writes (a mismatch is `409`). - General rules: [Idempotency and concurrency](../reference/idempotency-and-concurrency.md). ## Events Item events (`item_instance_created`, `item_ownership_changed`, `item_consumed`, `item_stacks_merged`, `item_stack_split`) can be used in rule automation and quest objectives. **There is no item event among the event types you can select as a filter for a webhook endpoint.** An endpoint registered without a filter (empty `event_types`) receives all events and so receives item events too; the envelope format of these events is not a documented contract, so do not rely on it. If you need to announce an item change to the outside, read it from your own server. Selectable types: [Webhook events](../reference/webhook-events.md). ## Limits - `limit` is 1–200 on list endpoints; pagination is cursor-based. - The default inventory capacity is 32 occupied slots. - `quantity`, `amount` and prices are integers; there are no decimals. ## Common errors | Symptom | Cause | Fix | |---|---|---| | `400` (no key) | The `Idempotency-Key` header is missing | Send a key on every write | | `403` | `mint`/`destroy`/definition registration with a client key | Use a server key | | `404` (inventory) | The inventory was never opened | The first inventory command (for example `move`) opens it; a read does not | | `409` | `If-Match` is stale or the item is in another inventory | Re-read and try again | | `422` insufficient quantity | `amount` is greater than the current quantity | Reduce the quantity | | `422` trade | `tradeable: false` | Enable trading on the definition | | `422` slot occupied | The target slot is occupied | Choose an empty slot | ## Working example An example that gives a potion and consumes it with raw REST (it uses `fetch` because the SDK has no typed client): ```ts /** Envanter ve eşya uçları için SDK'da tipli istemci yoktur; ham REST ile çağrılır (sunucu anahtarı). */ /** Küçük yardımcı: Bearer + Idempotency-Key ile JSON POST. */ async function post(baseUrl: string, key: string, path: string, idempotencyKey: string, body: unknown, ifMatch?: number): Promise { const headers: Record = { Authorization: `Bearer ${key}`, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, }; if (ifMatch !== undefined) headers["If-Match"] = String(ifMatch); const response = await fetch(`${baseUrl}${path}`, { method: "POST", headers, body: JSON.stringify(body) }); if (!response.ok) throw new Error(`${path}: HTTP ${response.status} ${await response.text()}`); return response.json(); } /** Oyuncuya bir iksir verir (mint, yalnız server anahtarı) ve 1 adedini tüketir. */ export async function grantAndDrink(baseUrl: string, serverKey: string, playerId: string): Promise { const minted = (await post(baseUrl, serverKey, "/v1/items", `mint-${playerId}-potion-1`, { definition_key: "health_potion", owner: `player:${playerId}`, quantity: 3, })) as { instance: { id: string; version: number } }; await post( baseUrl, serverKey, `/v1/items/${minted.instance.id}/consume`, `drink-${minted.instance.id}-1`, { amount: 1 }, minted.instance.version, // If-Match: okuduğunuz sürüm; uyuşmazlık 409 ); } ``` ## Related pages - [Economy: wallets, currencies, merchants and trading](economy.md) - [Store and in-app purchases](store-iap.md) - [Content groups](content-groups.md) - [Errors](../reference/errors.md) --- Source: https://open-play.b4klabs.dev/docs/en/guides/economy.html # Economy This page describes how in-game money is held and moves in Open Play: currency definitions, wallets, the ledger, player-to-player transfers, NPC merchants and player trading. Read it if you are looking for the safe way to give players money, let them spend it and monitor the economy. ## What it does The economy spans two modules: - `economy`: currencies, wallets, the ledger, transfers and reports. - `trading`: NPC merchants (`merchants`) and player-to-player trading (`trades`). Every money movement is **double-entry**: the amount leaving one wallet enters another wallet or a system account, and both sit in the ledger under the same transaction ID (`tx_id`). The ledger is append-only; no row is updated or deleted. To correct a mistake, a reversing entry is written. If a module is disabled, the endpoints return `404` with `{"code":"module_disabled","module":"economy"}` (or `trading`). ## Where it lives in the panel | What you want to do | Panel page | |---|---| | Create a currency; set the icon, decimal places and supply cap | [Currencies](../panel/currencies.md), [Currency profile](../panel/currency-profile.md) | | See the daily summary, balance ranking, trades and wallet lookup | [Economy reports](../panel/economy.md) | | See a player's wallet and movements | [Player profile](../panel/player-profile.md) | | Set up a reward table | [Reward tables](../panel/reward-tables.md) | | Tie a product to money and sell it | [Store](../panel/store.md) | > **Note** > > Panel help pages are not translated yet; the panel links above point to the Turkish source. ## Data model (in plain words) - **Currency:** Has a key (`gold`), a name, `transferable` (whether player-to-player transfer is allowed), `max_supply` (the total supply cap; unlimited if absent) and an icon. Registering again with the same key produces **a new version**; old versions do not change. The decimal places (`display_decimals`) and the icon image are set in the panel; a REST registration leaves them at `0`/empty. - **Wallet:** An owner's balance in one currency. The owner has the form `:` (`player`, `character`, `clan`, `system`). **There is no wallet write endpoint**; a wallet is born by itself on its first movement and is bound to the currency version it was born with. - **Balance:** An integer in the smallest unit (`i64`). There are no decimals: to display "12.50 gold", apply the decimal places set in the panel in your client. - **Ledger row:** `direction` (in/out), `amount`, `reason` (`transfer`, `grant`, `burn`, `merchant_purchase`, `merchant_sale`, `iap`, `iap_revoke`, `party_reward`, `clan_treasury`, `quest`, `login`, `npc_reward`, `reward_table`, `trade_lock`, `trade_refund`, `trade_settle` …) and `reference` (context). - **Merchant:** An NPC with a key, a name and per-item buy/sell price overrides (`price_overrides`). The item's own `npc_buy_price`/`npc_sell_price` is the default. - **Trade:** A state machine between two players: `draft` → `offered` → `locked` → `completed` or `cancelled`/`expired`. ## How to give a player money There is no REST endpoint that directly "adds this much to a wallet". Money enters a player's account through these routes: | Route | When | |---|---| | **Reward table grant** (`POST /v1/reward-tables/{key}/grant`) | Money the game gives, such as quests, tournaments and login rewards — see [Rewards](rewards.md) | | **Daily login reward, quest, NPC reward** | [Rewards](rewards.md) | | **In-app purchase** (IAP) | Game money bought with real money — see [Store](store-iap.md) | | **Selling an item to a merchant** (`POST /v1/merchants/{key}/sell`) | A player sells an item to an NPC | | **Party reward distribution, clan treasury** | [Social](social.md) | | **Manual grant from the panel** | An operator; the operation is written to the audit trail | Each shows up in the ledger with its own `reason` value. ## Calls your game makes Endpoints are called with `Authorization: Bearer crx_live__`. Currency and merchant **management** needs a server key (`403` otherwise); reads and player operations can be done with any API key. | Method and path | Key | What it does | |---|---|---| | `POST /v1/currencies` | server | Saves a currency as a new version | | `GET /v1/wallets?owner=player:` | API key | The owner's wallets (one row per currency) | | `POST /v1/transfers` | API key | Wallet-to-wallet transfer (`Idempotency-Key` required) | | `GET /v1/ledger?wallet_id=` | API key | A wallet's ledger stream (`cursor`, `limit`) | | `POST /v1/merchants` | server | Saves a merchant as a new version | | `GET /v1/merchants/{key}` | API key | The merchant's current content | | `POST /v1/merchants/{key}/buy` | API key | A player buys an item from the merchant | | `POST /v1/merchants/{key}/sell` | API key | A player sells an item to the merchant | | `POST /v1/trades` | API key | Opens a trade draft | | `GET /v1/trades/{id}?actor_id=` | API key | Views a trade (only the two parties) | | `POST /v1/trades/{id}/offer` | API key | Locks the legs | | `POST /v1/trades/{id}/accept` | API key | Final confirmation; atomic exchange | | `POST /v1/trades/{id}/cancel` | API key | Cancels; locks are released and money is refunded | | `GET /v1/economy/faucet-sink`, `/flags`, `/merchant-gap`, `/top-holders` | server | Daily economy reports | | `GET /v1/auth/admin/player-wallet-movements?player_id=` | server | A player's recent wallet movements (`limit` 1–100, default 20) | > **Note** > > The TypeScript SDK has no typed client for these modules; call them with raw REST. See [TypeScript SDK](../sdk/typescript.md). > **Warning** > > **Known limitation:** The transfer, merchant buy/sell and trade endpoints do not look at the key type; a valid **client key** can call them too, and the wallet/player IDs are given freely in the request. A client key is scoped to the environment, not to the player. Make money movements only from your own game server and verify the ID coming from the player there. ### Currency registration ``` POST /v1/currencies Authorization: Bearer crx_live__ Content-Type: application/json { "key": "gold", "name": "Gold", "transferable": true, "max_supply": 1000000000 } ``` ```json { "id": "0192f0aa-1000-7000-8000-000000000001", "key": "gold", "version": 1, "name": "Gold", "transferable": true, "max_supply": 1000000000, "icon": null } ``` `key` and `name` cannot be empty; if `max_supply` is given it must be greater than 0 (`400`). A currency with `transferable: false` returns `422` on a player-to-player transfer (in-game spending still works). ### Transfer ``` POST /v1/transfers Authorization: Bearer crx_live__ Content-Type: application/json Idempotency-Key: transfer-2026-11-03-0001 { "from_wallet": "0192f1…", "to_wallet": "0192f2…", "amount": 500, "reference": { "reason": "gift" } } ``` ```json { "tx_id": "0192f3…", "from": { "id": "0192f1…", "balance": 1500, "version": 7 }, "to": { "id": "0192f2…", "balance": 500, "version": 3 }, "replayed": false } ``` (The `from` and `to` in the response contain the full fields of the wallet records; they are abbreviated above.) Rejected cases: an amount of 0 or negative `400`; different currencies `400`; wallet not found `404`; insufficient balance, `transferable: false`, or the same key with a different body `422`; the same key in flight `409`. ### Merchant buy and sell ``` POST /v1/merchants/blacksmith/buy Authorization: Bearer crx_live__ Content-Type: application/json Idempotency-Key: buy-player-1234-17 { "player_id": "0192f0f2-…", "item_key": "iron_sword", "currency_key": "gold", "quantity": 1 } ``` Response: `{ "event": {…}, "wallet_balance": 900, "wallet_version": 8, "inventory_id": "…", "inventory_version": 5, "replayed": false }`. A purchase, in one transaction, deducts from the wallet, creates the item as a **new instance**, places it in the first empty slot and reduces the stock. `422` if the price is undefined, the balance is insufficient or the inventory is full; `409` if the stock is insufficient. For a sale (`sell`), the body carries `{ "player_id", "item_instance_id", "currency_key" }` and **the whole stack** is sold; for a partial sale, apply `split` first. `Idempotency-Key` is required on both. ### Player trading 1. `POST /v1/trades` — open a draft with `{ "initiator_player_id", "counterparty_player_id", "ttl_ms" }` (`ttl_ms` > 0; when the time runs out the trade becomes `expired`). 2. Both parties lock their legs with `POST /v1/trades/{id}/offer`: `{ "actor_id", "legs": [ {"type":"item","item_instance_id":"…"}, {"type":"currency","currency_id":"…","amount":100} ] }`. An item must be in an inventory, tradable and not locked in another trade (`409` if locked); money is taken into escrow (a holding account). 3. When both parties have locked, the trade becomes `locked`. `POST /v1/trades/{id}/accept` (`{ "actor_id" }`) works only with the **initiating** player and exchanges all legs in one transaction. If the recipient's inventory has no room, `422` is returned and the trade stays locked. 4. `POST /v1/trades/{id}/cancel` can be called by either party; locks are released and money is refunded. If someone who is not a party calls `GET /v1/trades/{id}`, they get `404` (the trade's existence is hidden); the `actor_id` query parameter is required. ## Versioning and replay safety - All of the `transfers`, `merchants/{key}/buy|sell` and `trades` commands **require** `Idempotency-Key` (`400` if absent). The same key and the same body do not repeat the operation and return `replayed: true`; a different body is `422`. The key is burned only on success. - Currency and merchant registration writes a new version each time; old versions are untouchable. - Ledger reads are cursor-based (`limit` 1–200, default 50). ## Reports The report endpoints (`faucet-sink`, `flags`, `merchant-gap`, `top-holders`) are computed over **closed days** in UTC: `from` and `to` are inclusive and a range of at most 366 days can be given. Missing days are listed in the response as `missing_dates`; they are not counted as zero. `iap_net` is game money, not real money. `flags` is a review signal; no automatic sanction is applied. ## Events Money movements write events to the outbox. **Among the types you can select as a webhook filter there is no economy-specific type**; endpoints registered without a filter receive all events and so receive money events too, but the envelope format of these events is not a documented contract. Store and reward events are selectable types: [Webhooks](webhooks.md). ## Limits - Balances and amounts are `i64` smallest units; an amount must be greater than 0. - Production that exceeds the currency's `max_supply` cap (reward, IAP, sale) returns `422`. - At most 366 days in reports. ## Common errors | Symptom | Cause | Fix | |---|---|---| | `400` (`Idempotency-Key`) | The header is missing | Send a key on every write | | `403` | Currency/merchant registration with a client key | Use a server key | | `404` wallet | The wallet ID given in the transfer does not exist | Read the ID with `GET /v1/wallets`; if the owner has never had a movement in that currency, the wallet is not born yet and the list comes back empty | | `422` insufficient balance | The balance is less than the amount | Read the balance with `GET /v1/wallets` | | `422` `transferable=false` | The currency cannot be transferred | Enable transfer on the currency or use another route | | `409` stock | The merchant's stock is not enough | Reduce the quantity | ## Working example: grant money, then buy a product The server gives the player money from a reward table; the player buys a product from the store with their own session. The first step runs on the server, the second in the game client. The `welcome_pack` table must already be set up from the panel ([Reward tables](../panel/reward-tables.md)) or with `POST /v1/reward-tables`. ```ts /** Ekonomi akışı: sunucu ödül tablosundan para verir, oyuncu kendi oturumuyla mağazadan ürün alır. */ import { CrxClient, StoreBuyErrorCode, storeBuyErrorCode, type PlayerClient } from "@ferdiunal/open-play-ts"; /** 1. adım (güvenilir sunucu): oyuncuya tablodan ödül verir. Aynı `requestKey` ile tekrar ödeme yapmaz. */ export async function grantWelcomeReward(baseUrl: string, serverApiKey: string, playerId: string, requestKey: string): Promise { const crx = new CrxClient({ baseUrl, apiKey: serverApiKey }); const grant = await crx.grantRewardTable("welcome_pack", { playerId, source: { type: "manual", operatorId: "game-server" }, idempotencyKey: requestKey, }); // `rewards` verilen birleşik ödüllerdir; `replayed: true` ikinci çağrının aynı sonucu geri verdiği anlamına gelir. console.log(grant.rewards, grant.replayed); } /** 2. adım (oyun istemcisi): oyuncunun gördüğü ilk ürünü satın alır. */ export async function buyFirstListing(player: PlayerClient, pageKey: string, idempotencyKey: string): Promise { const catalog = await player.store.catalog({ page: pageKey }); const listing = catalog.pages[0]?.listings[0]; if (listing === undefined || listing.version === null) return "Vitrinde ürün yok."; try { // Gördüğünüz sürümü geri gönderin: fiyat arada değiştiyse sunucu satmaz (409 listing_changed). const result = await player.store.buy(listing.id, { expectedVersion: listing.version, quantity: 1, idempotencyKey }); return `Satın alındı. Kalan bakiye: ${result.balanceAfter}`; } catch (error: unknown) { switch (storeBuyErrorCode(error)) { case StoreBuyErrorCode.InsufficientFunds: return "Bakiye yetmiyor."; case StoreBuyErrorCode.OutOfStock: return "Stok tükendi."; case StoreBuyErrorCode.ListingChanged: return "Ürün değişti; mağazayı yenileyin."; default: throw error; } } } ``` ## Related pages - [Rewards](rewards.md) · [Store and IAP](store-iap.md) · [Inventory and items](inventory-items.md) · [Social](social.md) - [Idempotency and concurrency](../reference/idempotency-and-concurrency.md) · [Numbers and encoding](../reference/numbers-and-encoding.md) --- Source: https://open-play.b4klabs.dev/docs/en/guides/store-iap.html # Store and in-app purchases This page describes the store showcase the player sees, the flow of buying with game money and the verification of in-app purchases (IAP) made with real money. Read it if you want to build a showcase, sell products and complete payment safely on the server. ## What it does The store module (`store`) ties three things together: - **Showcase (page) and product (listing):** The catalog an operator sets up in the panel and that is shown to the player. - **Payment:** A product is sold either with **game money** (`currency`: through a merchant, money in exchange for an item) or with **real money** (`iap`: an App Store or Google Play receipt). - **Reservation:** A purchase first reserves an entitlement (stock and the per-player limit), then completes the payment in one transaction. Payment and delivery either both happen or neither does. If the module is disabled, the endpoints return `404` with `{"code":"module_disabled","module":"store"}`. Enable the module on the [Game settings › Modules](../panel/game-settings-modules.md) page. ## Where it lives in the panel | What you want to do | Panel page | |---|---| | Create showcases and products; set price, stock, visibility window and per-player limit | [Store](../panel/store.md), [Product](../panel/store-product.md) | | Define the currency for a price | [Currencies](../panel/currencies.md) | | Define the item to be sold | [Content](../panel/content.md) | In the panel, IAP products and receipts are **only listed**. For attaching an IAP product to the store and for the platform credentials (App Store / Google Play), see [Limits and known limitations](#limits-and-known-limitations). > **Note** > > Panel help pages are not translated yet; the panel links above point to the Turkish source. ## Data model (in plain words) - **Showcase (page):** `key` (lowercase letters, digits, `_`, `-`; at most 64), `title`, `sort_order` and visibility. Showcase fields such as the description, the banner image, the product title, the badge and the image are entered in the panel; the server-key REST draft does not carry them. - **Product (listing):** Attached to a showcase. The payment form is `currency` (merchant + item definition + currency + `price_amount`) or `iap` (an IAP product ID). `limit_per_player` (at least 1) is the lifetime per-player quantity limit. - **Visibility:** `active`, a date window (`starts_at`/`ends_at`, Unix ms) and an optional CEL `visibility_rule` (a player-specific condition; compiled at registration, at most 8192 bytes). The player showcase is recomputed on **every read**: the condition, the time and the remaining limit are evaluated against the state at that moment. - **Catalog version (`version`):** Every change made to a showcase increases the catalog version; on management commands `If-Match` is this version. - **Product version:** The `version` field of the product the player sees; it is sent back as `expected_version` on purchase. - **Reservation:** Its status is `reserved`, `processing`, `completed`, `cancelled`. `reserved` and `processing` **do not count as payment**. - **Price:** An integer in the smallest unit; it comes with `display_decimals` (0–4) in the showcase. Build the display with integer/string operations; do not convert it to a decimal number. ## Calls your game makes There are two credential types: - **Player session** (cookie or session token + `x-crx-env: `): reads the showcase and buys on its own behalf. With a cookie session, mutations require the CSRF double-submit (`x-csrf-token`) and the `Origin` check; see [Sessions and CSRF](../auth/sessions-and-csrf.md). - **Server key** (`Authorization: Bearer crx_live__`): catalog management, player preview, reservation and payment completion. | Method and path | Credential | What it does | |---|---|---| | `GET /v1/store/pages[?page=&at=now]` | player session | The showcases the player can see; `page` narrows to one showcase | | `POST /v1/player/store/listings/{listing_id}/buy` | player session | The player buys on their own behalf | | `GET /v1/store/catalog` | server key | The raw management catalog (visibility is not filtered) | | `GET /v1/store/preview/{player_id}[?at=now]` | server key | Previews the showcase a player would see | | `POST /v1/store/pages`, `PUT/DELETE /v1/store/pages/{id}`, `POST /v1/store/pages/reorder` | server key | Showcase management | | `POST /v1/store/pages/{page_id}/listings`, `PUT/DELETE /v1/store/listings/{id}`, `POST …/listings/reorder` | server key | Product management | | `POST /v1/store/listings/{listing_id}/reserve` | server key | Reserves an entitlement for a player (not a payment) | | `POST /v1/store/reservations/{id}/checkout/currency` | server key | Completes payment with game money | | `POST /v1/store/reservations/{id}/checkout/iap` | server key | Completes payment with a verified IAP | | `POST /v1/store/reservations/{id}/cancel` | server key | Cancels a `reserved` reservation (not a refund) | | `POST /v1/iap/verify` | API key | Verifies an IAP receipt and grants the reward | | `POST /v1/webhooks/iap/{platform}/{environment_id}` | no key (signature) | Receives the platform's refund notification | Error envelopes: errors from the management and reservation endpoints are `text/plain`; the player purchase endpoint returns JSON with a typed `code` (below). General list: [Errors](../reference/errors.md). ### Reading the showcase ``` GET /v1/store/pages?page=garage x-crx-env: 0192f000-0000-7000-8000-00000000e001 ``` ```json { "version": 12, "pages": [ { "id": "…", "key": "garage", "title": "Garage", "listings": [ { "id": "0192f111-…", "version": 3, "title": "Fast Car", "badge": "sale", "price": { "currency_key": "gold", "currency_name": "Gold", "amount": 1250, "display_decimals": 0, "icon_url": null }, "item": { "key": "fast_car", "name": "Fast Car" }, "remaining": 40 } ] } ] } ``` The response carries `Cache-Control: private, no-store`. A `page` key that is not visible or unknown returns `404`. The player identity, the time and the CEL result are **not taken from the client**; adding `player_id` or any other field to the query returns `400`. ### A player's purchase ``` POST /v1/player/store/listings/0192f111-…/buy x-crx-env: 0192f000-0000-7000-8000-00000000e001 x-csrf-token: Idempotency-Key: buy-player-1234-0001 Content-Type: application/json { "quantity": 1, "expected_version": 3 } ``` ```json { "reservation_id": "…", "payment_reference": "…", "item_instance_ids": ["…"], "balance_after": 750, "replayed": false } ``` The body contains only `quantity` (positive) and `expected_version`; **a player ID, a price or a payment claim is not accepted**, and an unknown field returns `400`. The identity comes from the session. Reservation and charging are **in a single transaction**. `Idempotency-Key` is required: the same key and the same body repeat the durable result with `replayed: true` and make no second charge; the same key with a different body is `422`. An IAP product cannot be bought on this endpoint (`422`). Rejected purchases carry a typed JSON code, `{"code": "...", "message": "..."}`: | `code` | HTTP | Meaning | |---|---|---| | `not_visible` | 404 | The product is not visible to this player or does not exist | | `listing_changed` | 409 | The product changed while you were looking (`expected_version` is stale); refresh the showcase | | `out_of_stock` | 422 | Out of stock | | `limit_reached` | 422 | The per-player limit is full | | `insufficient_funds` | 422 | The balance is insufficient | | `inventory_full` | 422 | The inventory is full; the operation is rolled back completely | Other errors (`401`, `403` CSRF/Origin, `400`, `429` budget, `500`) follow the general rules. ### Server-side flow: reservation and payment In cases where the game server manages the payment (for example, when your server receives the real-money receipt), use two steps: ``` POST /v1/store/listings/{listing_id}/reserve Authorization: Bearer crx_live__ Idempotency-Key: reserve-player-1234-0001 If-Match: 12 Content-Type: application/json { "player_id": "0192f0f2-…", "quantity": 1 } ``` The response has the form `{ catalog_version, entity_id, event_id, event, reservation, replayed }`; `reservation.status` is `reserved`. Then: ``` POST /v1/store/reservations/{id}/checkout/currency Authorization: Bearer crx_live__ Content-Type: application/json { "player_id": "0192f0f2-…" } ``` ```json { "reservation_id": "…", "payment_reference": "…", "kind": "currency", "replayed": false } ``` `Idempotency-Key` and `If-Match` are **not sent** on the `checkout` endpoints (`400` if sent); the durable reservation ID is the anchor of replay safety. If there is an error when paying with a currency, the operation is rolled back and the reservation stays `reserved`; you can retry or cancel. If the outcome of an IAP payment is uncertain, the reservation becomes `processing` and the player limit is preserved; retry **with the same `credential`**. In an IAP payment `quantity` is 1. `Idempotency-Key` is required on `reserve` and the catalog management commands; `If-Match` is the catalog version and is optional (a mismatch is `409`). A failed command does not burn the key. ### IAP verification ``` POST /v1/iap/verify Authorization: Bearer crx_live__ Idempotency-Key: iap-player-1234-tx-abc Content-Type: application/json { "platform": "app_store", "product_sku": "gems_500", "credential": "", "player_id": "0192f0f2-…" } ``` ```json { "receipt_id": "…", "status": "granted", "product_sku": "gems_500", "transaction_id": "…", "grants": [ … ], "replayed": false } ``` - `platform`: `app_store` or `google_play`. `credential`: the `transactionId` for the App Store, the `purchaseToken` for Google Play. The server queries the platform **itself**; the client is not trusted. - Verifying the same transaction twice does **not** grant a second reward (`200`, `replayed: true`). - `status` can be `verified`, `granted`, `revoked` or `failed`; the happy path is `granted`. - Errors: a forged receipt `403`; the product is not in the catalog or the transaction is not recognized on the platform `404`; a `product_sku` mismatch, a platform that is not configured or a supply cap overrun `422`; another request in flight with the same key `409`. - Refund notification: App Store (ASN V2) and Google Play (RTDN) notifications arrive at `POST /v1/webhooks/iap/{platform}/{environment_id}`; the endpoint is keyless and authentication is the signature. On a refund of a known receipt the reward is taken back **with reversing entries** and the receipt becomes `revoked`; cases that can only be partially reversed are marked `flagged` (the default policy; it does not touch the consumed part). Non-refund notifications and unknown transactions get `200` and are ignored. ## Versioning and replay safety - Player purchase: `expected_version` (the product version) + `Idempotency-Key`. - Catalog management and reservation: `Idempotency-Key` required, `If-Match` = the catalog version. - Payment completion: the durable reservation ID; no header. - General rules: [Idempotency and concurrency](../reference/idempotency-and-concurrency.md). ## Events and webhooks Store events can be delivered as webhooks: `store_page_created`, `store_page_updated`, `store_page_deleted`, `store_pages_reordered`, `store_listing_created`, `store_listing_updated`, `store_listing_deleted`, `store_listings_reordered`, `store_purchase_initiated`, `store_payment_started`, `store_purchase_completed`, `store_reservation_cancelled`. The envelope contains `event_id`, `environment_id`, `type`, `seq`, `now_ms`, `actor_api_key_id` and `data`. Registration and verification: [Webhooks](webhooks.md). ## Limits and known limitations - A showcase key is `^[a-z0-9_-]{1,64}$`; at most 2000 entries in a reorder request. - An IAP product is not supported on the player purchase endpoint; IAP products are bought with `checkout/iap` or `iap/verify`. - **IAP product registration:** In the panel, IAP products and receipts are read-only, and there is no public HTTP endpoint yet to save a product. For now, adding an IAP product is done through the Open Play operator. - **Platform credentials:** The App Store and Google Play verification credentials are defined by the operator at deployment. If they are not defined, `iap/verify` and `checkout/iap` return `422`. - Read the showcase when the player opens or refreshes it; changes are recomputed at read time. ## Common errors | Symptom | Cause | Fix | |---|---|---| | `409 listing_changed` | The product version you read in the showcase went stale | Re-read the showcase and try with the new `version` | | `404 not_visible` | The product is not visible to the player (window, condition, inactive) | Refresh the showcase; check the condition in the panel | | `422 insufficient_funds` | The balance is insufficient | Show the balance; give money through a reward or IAP | | `400` (unknown field) | An extra field was added to the purchase body | Send only `quantity` and `expected_version` | | `400` (checkout) | `Idempotency-Key`/`If-Match` was sent | Remove these headers on the checkout endpoints | | `403` (purchase) | The CSRF pair or the `Origin` was rejected | Refresh the CSRF token; see [Sessions and CSRF](../auth/sessions-and-csrf.md) | | `403` (iap/verify) | A forged receipt or one with an invalid signature | Get the receipt again from the platform | | `422` (iap) | The platform is not configured or `product_sku` does not match | Contact the operator; check the sku | ## Working example An example that reads the showcase and buys the first product is on the [Economy](economy.md#working-example-grant-money-then-buy-a-product) page; it shows a separate message per error code (`storeBuyErrorCode`). The TypeScript SDK provides the `player.store.catalog()` and `player.store.buy()` helpers; if `Idempotency-Key` is not given, it generates one per call, and after a network interruption you must supply the same key yourself. ## Related pages - [Economy](economy.md) · [Inventory and items](inventory-items.md) · [Rewards](rewards.md) · [Webhooks](webhooks.md) - [TypeScript SDK](../sdk/typescript.md) · [Sessions and CSRF](../auth/sessions-and-csrf.md) --- Source: https://open-play.b4klabs.dev/docs/en/guides/rewards.html # Rewards This page describes how the rewards your game gives players are defined and granted in Open Play: chance-based reward tables, the daily login reward, quests, NPC rewards and VIP cards. Read it if you want a reward to be granted on the server, cheat-proof and only once. ## What it does The rewards module (`rewards`) does the job of "give the player money or an item" through a single trustworthy line. Every reward grant is **a single database transaction**: money is written to the wallet, the item is put into the inventory and the event is recorded. If the inventory is full or the currency would exceed its supply cap, **nothing is granted** (`422`). Table rewards are **deterministic**: the same seed gives the same result. The seed is kept on the server; it does not appear in the response, the webhook event or the ledger record. If the module is disabled, the endpoints return `404` with `{"code":"module_disabled","module":"rewards"}`. Enable the module on the [Game settings › Modules](../panel/game-settings-modules.md) page. ## Where it lives in the panel | What you want to do | Panel page | |---|---| | Set up a reward table, edit row chances and guaranteed rewards, see versions | [Reward tables](../panel/reward-tables.md), [Reward table profile](../panel/reward-table-profile.md) | | Define the currency to use in a reward | [Currencies](../panel/currencies.md) | | Define the item to use in a reward | [Content](../panel/content.md) | | Grant rewards with event-driven automation | [Game rules](game-rules.md) | > **Note** > > Panel help pages are not translated yet; the panel links above point to the Turkish source. ## Data model (in plain words) - **Reward table:** `key`, `name`, `rolls` (how many draws are made, 1–10), `entries` (rows) and `guaranteed` (rewards given every time). Every registration becomes **a new version**; the current version is used at grant time and the version number is returned in the result. - **Row (entry):** `chance_bp` (basis points, 1–10000) and `drop`. The row chances of one draw must **add up to exactly 10000** (100%); "no reward" can be a row. - **Reward (`drop`):** `currency` (`key`, `min`, `max`), `item` (`key`, `min`, `max`) or `nothing`. The quantity is always chosen from between `min` and `max` with a uniform distribution; quantities are between 1 and 1,000,000. A table has at most 50 rows and 10 guaranteed rewards. - **Guaranteed reward:** Given independently of the draw; it cannot be `nothing`. - **Source (`source`):** Writes, in a typed way, why the reward was given: `content_entry`, `tournament`, `leaderboard_season`, `quest`, `npc_reward`, `rule` or `manual`. ## Calls your game makes All reward endpoints are called with a **server key** (or a key of the admin type); a client key and a player session are rejected. The player ID is carried in the body by the trusted server component: ``` Authorization: Bearer crx_live__ ``` | Method and path | What it does | |---|---| | `POST /v1/reward-tables` | Saves a reward table as a new version | | `GET /v1/reward-tables/{key}` | Reads the current version | | `POST /v1/reward-tables/{key}/grant` | Grants a reward from the table, idempotently | | `GET /v1/login-rewards/{player_id}` | Reads today's login reward status | | `POST /v1/login-rewards/{player_id}/claim` | Grants the daily login reward | | `POST /v1/quests` | Saves a quest definition | | `GET /v1/quests`, `GET /v1/quests/{key}` | Lists / reads quests | | `POST /v1/quests/{key}/claim` | Grants the quest period's reward | | `GET /v1/npc-rewards/{key}/eligibility` | Reads NPC reward eligibility (does not write) | | `POST /v1/npc-rewards/{key}/claim` | Grants the NPC reward | | `GET /v1/vip/{player_id}` | Reads the VIP tier | | `POST /v1/vip/{player_id}/cards/{item_instance_id}/use` | Uses a VIP card | > **Note** > > In the TypeScript SDK only the reward **table** has a typed client (`CrxClient.createRewardTable`, `getRewardTable`, `grantRewardTable`). The login reward, quest, NPC and VIP endpoints are called with raw REST. See [TypeScript SDK](../sdk/typescript.md). ### Saving a reward table ``` POST /v1/reward-tables Authorization: Bearer crx_live__ Content-Type: application/json { "key": "welcome_pack", "name": "Welcome pack", "rolls": 1, "entries": [ { "chance_bp": 7000, "drop": { "type": "currency", "key": "gold", "min": 100, "max": 300 } }, { "chance_bp": 2500, "drop": { "type": "item", "key": "health_potion", "min": 1, "max": 3 } }, { "chance_bp": 500, "drop": { "type": "nothing" } } ], "guaranteed": [ { "type": "currency", "key": "gold", "min": 50, "max": 50 } ] } ``` The response is `201` with `{ id, version, key, name, rolls, entries, guaranteed, created_at_ms }`; the draw seed is not present in any response. If the chances do not add up to 10000, the table is empty or the limits are exceeded, the registration is rejected with `422` and a plain-text message. The `validate` key is reserved and cannot be used as a table key. ### Granting a reward from a table ``` POST /v1/reward-tables/welcome_pack/grant Authorization: Bearer crx_live__ Idempotency-Key: welcome-player-1234 Content-Type: application/json { "player_id": "0192f0f2-5b1a-7c3e-9d11-4a5b6c7d8e9f", "source": { "type": "manual", "operator_id": "game-server" } } ``` ```json { "grant_id": "…", "tx_id": "…", "event_id": "…", "table_key": "welcome_pack", "table_version": 1, "rewards": [ { "drop": "currency", "key": "gold", "quantity": 250 }, { "drop": "item", "key": "health_potion", "quantity": 2 } ], "replayed": false } ``` `Idempotency-Key` is **required**. The same key and the same body do not grant the reward a second time; the first result is returned with `replayed: true`. The same key with a different body is `422`. The `source` variants: | `type` | Additional fields | |---|---| | `manual` | `operator_id` | | `content_entry` | `group`, `entry` | | `quest` | `quest_key` | | `npc_reward` | `npc_key` | | `tournament` | `tournament_id`, `placement` | | `leaderboard_season` | `leaderboard_key`, `season` | | `rule` | `rule_key`, `event_id` | Money and items are granted in one transaction: if the inventory is full or the currency supply cap would be exceeded, the whole grant is rolled back and `422` is returned. An NPC reward cannot be granted for a character that is not bound to a player. ### Daily login reward `GET /v1/login-rewards/{player_id}` returns today's status: `period_date`, `claimed`, `streak_day`, `current_streak`, `config_version`. `POST /v1/login-rewards/{player_id}/claim` grants the reward and returns `{ period_date, streak_day, rewards, event_id, tx_id, replayed }`; **a second call on the same day returns the same receipt** with `replayed: true` and grants no second reward. The day is computed according to the environment's time zone (`timezone`, [Environments and domains](../panel/game-settings-environments.md)). The reward schedule (the number of days, each day's money reward) and the missed-day policy (`reset_on_miss`: the streak returns to 1; `freeze`: the streak is preserved) are a versioned configuration. > **Note** > > There is no public endpoint or panel screen yet that writes the login reward configuration (the schedule and the streak policy); for now the configuration is set up by the Open Play operator. ### Quests `POST /v1/quests` saves a quest definition as a new version: ```json { "key": "kill_10_wolves", "name": "Hunt ten wolves", "objectives": [ { "key": "wolves", "event_type": "event_emitted", "count": 10, "filter_cel": "event.event_key == \"wolf_killed\"" } ], "rewards": [ { "type": "currency", "key": "gold", "amount": 500 }, { "type": "table", "key": "welcome_pack" } ], "recurrence": "daily" } ``` - `objectives[].event_type`: the event type that advances the quest (character events, `score_submitted`, `login`, item and money events and so on); `count` is how many times it is required; `filter_cel` is an optional condition. - `rewards[].type`: `currency` (`key`, `amount`) or `table` (a reward table key). - `recurrence`: `none`, `daily`, `weekly`. `window` and `prerequisite_cel` (compiled at registration) are optional. - The body of `POST /v1/quests/{key}/claim` is `{ "player_id": "…", "period_key": "…" }` (`period_key` at most 160 characters). The response is `{ status: "claimed" | "already_claimed", reward_count, event_id }` — claiming the same period a second time returns `already_claimed` and the reward is not repeated. - `GET /v1/quests` lists with `search`, `limit`, `offset` (it returns an in-page `total`). > **Note** > > There is no public REST endpoint yet that reads quest **progress** (which quest a player is at and how far). Progress is computed on the server from events; for this reason your game server itself must know the `period_key` value. ### NPC rewards `GET /v1/npc-rewards/{key}/eligibility?character_id=…&period_key=…` reports eligibility without writing: `{ eligible, reason }` (`reason`: `cooldown`, `daily_limit`, `condition`). `POST /v1/npc-rewards/{key}/claim` (`Idempotency-Key` required, body `{ "character_id", "period_key" }`) either grants the reward or returns `rejected`: `{ status: "claimed" | "rejected", reward_count, claim_count, tx_id, reason }`. There is no public endpoint yet that registers the NPC rule (cooldown, daily limit, CEL condition, reward). ### VIP The VIP tier is derived from cumulative points. `GET /v1/vip/{player_id}` returns `{ player_id, cumulative_points, tier, config_version }`. `POST /v1/vip/{player_id}/cards/{item_instance_id}/use` (`Idempotency-Key` required) consumes a `vip_card` item and adds points; if the card does not meet the condition of the next tier up (`current tier + 1`), `422` is returned. A client key and a server key can use the same VIP surface. ## Versioning and replay safety - Table grant, NPC claim and VIP card use: `Idempotency-Key` is **required**. - Login reward: a second claim on the same day returns the same receipt; no header is needed. - Quest claim: the period key is the natural replay safety (`already_claimed`). - Table and quest registrations are a new version each time; old versions do not change. - General rules: [Idempotency and concurrency](../reference/idempotency-and-concurrency.md). ## Events and webhooks Every reward granted from a table or as a fixed amount of money produces the `reward_granted` event and can be delivered as a webhook. The envelope contains `event_id`, `environment_id`, `player_id`, `type` and `data` (`source_kind`, `table_key`, `table_version` and what was granted); there is no seed. Registration and verification: [Webhooks](webhooks.md). ## Limits - At most 50 rows, 10 guaranteed rewards and 10 draws (`rolls`) in a table; quantities 1–1,000,000. - The `chance_bp` total is exactly 10000. - Quest `period_key` and `claim` fields are at most 160 characters. ## Common errors | Symptom | Cause | Fix | |---|---|---| | `403` | A call with a client key or a player session | Make the call with a server key, from your server | | `422` (table registration) | The chances do not complete to 10000 or a limit was exceeded | Fix the rows; complete them with a `nothing` row | | `422` inventory full | There is no empty slot for the item to be granted | Have the player make room; nothing was written, so it can be retried | | `422` supply cap | The currency's `max_supply` limit | Raise the cap in the panel | | `422 idempotency mismatch` | The same key, a different body | Use a new key | | `404` table not found | `key` is wrong or the table has not been saved yet | Verify with `GET /v1/reward-tables/{key}` | ## Working example The flow of granting money from the welcome table and then buying from the store is on the [Economy](economy.md#working-example-grant-money-then-buy-a-product) page. In TypeScript, the call `crx.grantRewardTable("welcome_pack", { playerId, source, idempotencyKey })` goes to the same endpoint; if `idempotencyKey` is not given, the SDK generates one per call, so on a network retry you must supply the same key **yourself**. ## Related pages - [Economy](economy.md) · [Inventory and items](inventory-items.md) · [Characters](characters.md) · [Game rules](game-rules.md) - [Errors](../reference/errors.md) · [Webhooks](webhooks.md) --- Source: https://open-play.b4klabs.dev/docs/en/guides/social.html # Clans and parties This page describes how to set up and manage persistent player communities (clans) and temporary teams (parties) in Open Play. Read it if you want to manage membership, invites, the clan treasury, clan XP and party reward distribution from a trusted server. ## What it does The social module (`social`) manages two structures: - **Clan:** A persistent community. It has leader, officer and member roles, an invite and application flow, a shared treasury (a currency) and XP/level. - **Party:** A temporary team. It has a capacity, a loot rule and reward splitting; it ends when it disbands. **Important:** All of these endpoints are called with a **server key**. A player client cannot call a clan or party endpoint directly; the player's identity is written in the request body (as `actor_player_id` and similar) by your trusted server. Verifying that a player "can manage their own clan" is your server's job; Open Play enforces the role rules inside the clan (who can kick whom, who can withdraw from the treasury). If the module is disabled, the endpoints return `404` with `{"code":"module_disabled","module":"social"}`. Enable the module on the [Game settings › Modules](../panel/game-settings-modules.md) page. ## Where it lives in the panel | What you want to do | Panel page | |---|---| | List clans; see membership, invites, applications, the vault and command history | [Clans](../panel/clans.md) | | See parties, their members, the loot rule and reward distributions | [Parties](../panel/parties.md) | | Clan and party chat | [Chat](chat.md), [City chat](../panel/scene-chat.md) | | The currency used in the clan treasury | [Currencies](../panel/currencies.md) | > **Note** > > Panel help pages are not translated yet; the panel links above point to the Turkish source. ## Data model (in plain words) **Clan** - `name` (3–64 characters), `tag` (2–8 characters), `leader_player_id`, `xp`, `highest_level_reached`, `state` (`active` or `disbanded`) and `members`. - **Roles:** `leader`, `officer`, `member`. - **Environment settings** (`/v1/clan-settings`): `max_members` (the member limit), `request_ttl_seconds` (the lifetime of invites and applications), `officer_can_withdraw` (whether an officer can withdraw from the treasury) and `level_curve_key` (the [level curve](characters.md) for the clan level). - **Invite:** A call the clan sends to a player. Its statuses are `pending`, `accepted`, `declined`, `revoked`, `expired`. - **Application:** A player's application to a clan. Its statuses are `pending`, `accepted`, `rejected`, `cancelled`, `expired`. - **Treasury:** The clan's wallet; currency movement is done with `deposit` and `withdraw` and appears in the [ledger](economy.md) as `clan_treasury`. **Party** - `leader_player_id`, `capacity` (positive), `loot_rule` (`equal`: equal shares, `weighted`: basis-point shares), `state` (`active`, `disbanded`) and `members`. - **Invite:** Carries `share_bp` (the member's basis-point share; under the `weighted` rule the shares must add up to exactly 10000) and is valid for 24 hours. Its statuses are `pending`, `accepted`, `expired`. - **Reward distribution:** Splits a single currency amount among the members. With equal distribution the leftover minor units are distributed in `player_id` order; the result is always deterministic. The distribution record carries the member snapshot, the legs, `delivery_mode` (`inline` or `outbox`) and the status (`processing`, `queued`, `completed`). ## Calls your game makes All with a server (or admin) key: ``` Authorization: Bearer crx_live__ ``` A client key and a player session are rejected (`403`). ### Clan | Method and path | Body | What it does | |---|---|---| | `GET /v1/clan-settings`, `PATCH /v1/clan-settings` | `max_members`, `request_ttl_seconds`, `officer_can_withdraw`, `level_curve_key` | Reads / updates the environment settings | | `GET /v1/clans` | — | Lists clans | | `POST /v1/clans` | `leader_player_id`, `name`, `tag` | Creates a clan | | `GET /v1/clans/{clan_id}` | — | Reads a clan with its members | | `PATCH /v1/clans/{clan_id}` | `actor_player_id`, `name`, `tag` | Updates the name and tag | | `POST /v1/clans/{clan_id}/invites` | `inviter_player_id`, `invitee_player_id` | Sends an invite | | `GET /v1/clan-invites/{invite_id}`, `POST …/respond` | `actor_player_id`, `decision` (`accept`, `decline`, `revoke`, `expire`) | Reads / responds to an invite | | `POST /v1/clans/{clan_id}/applications` | `applicant_player_id` | Opens an application | | `GET /v1/clan-applications/{application_id}`, `POST …/resolve` | `actor_player_id`, `decision` (`accept`, `reject`, `cancel`, `expire`) | Reads / resolves an application | | `POST /v1/clans/{clan_id}/leave` | `actor_player_id` | A member leaves | | `POST /v1/clans/{clan_id}/members/kick` | `actor_player_id`, `target_player_id` | Removes a member | | `POST /v1/clans/{clan_id}/members/role` | `actor_player_id`, `target_player_id`, `role` | Changes a role | | `POST /v1/clans/{clan_id}/leadership` | `actor_player_id`, `target_player_id` | Transfers leadership | | `POST /v1/clans/{clan_id}/disband` | `actor_player_id` | Disbands the clan | | `POST /v1/clans/{clan_id}/treasury/deposit`, `…/withdraw` | `actor_player_id`, `currency_id`, `amount` | Deposits to / withdraws from the treasury | | `POST /v1/clans/{clan_id}/xp` | `amount` | Adds XP to the clan | `Idempotency-Key` is **required** on all clan write commands. The response is always in the same envelope: `{ "type": "clan" | "invite" | "application" | "settings" | "treasury" | "xp", "": { … }, "replayed": false }`. For example, creating a clan: ``` POST /v1/clans Authorization: Bearer crx_live__ Idempotency-Key: create-clan-player-1234 Content-Type: application/json { "leader_player_id": "0192f0f2-5b1a-7c3e-9d11-4a5b6c7d8e9f", "name": "Wolf Pack", "tag": "WLF" } ``` Returns `201` and the clan record comes inside a `type: "clan"` envelope. The TypeScript SDK exposes these endpoints in a typed way (`createClan`, `getClan`, `createClanInvite`, `respondClanInvite`, `depositClanTreasury`, `addClanXp` …); see [TypeScript SDK](../sdk/typescript.md). ### Party | Method and path | Body | What it does | |---|---|---| | `POST /v1/parties` | `leader_player_id`, `capacity`, `loot_rule` | Creates a party | | `GET /v1/parties?player_id=` | — | Lists a player's parties | | `GET /v1/parties/{party_id}` | — | Reads a party with its members | | `PATCH /v1/parties/{party_id}` | `actor_player_id`, `capacity` | Updates the capacity | | `POST /v1/parties/{party_id}/invites` | `inviter_player_id`, `invitee_player_id`, `share_bp` | Sends an invite | | `POST /v1/party-invites/{invite_id}/accept` | `actor_player_id` | Accepts an invite | | `POST /v1/parties/{party_id}/leave` | `actor_player_id` | A member leaves | | `POST /v1/parties/{party_id}/disband` | `actor_player_id` | Disbands the party | | `POST /v1/parties/{party_id}/rewards` | `currency_key`, `amount` | Splits a reward among the members | `Idempotency-Key` is **required** on party reward distribution; the same key returns the same distribution again with `replayed: true` and does not distribute a second time. The other party commands do not ask for `Idempotency-Key`. > **Note** > > The TypeScript SDK has no typed client for the party endpoints; call them with raw REST. ## Versioning and replay safety - Clan commands and party reward distribution ask for `Idempotency-Key`: the same key and the same body do not run again (`replayed: true`); the same key with a different body is `422`; the same key in flight is `409`. - `If-Match` is not used in this module. - General rules: [Idempotency and concurrency](../reference/idempotency-and-concurrency.md). ## Events Clan and party events (`ClanMemberJoined`, `ClanXpAdded`, `PartyMemberJoined`, `RewardDistributed` …) can be used in rule automation and quest objectives. **Among the types you can select as a webhook filter there is no clan or party event**; endpoints registered without a filter receive all events and clan/party events are included, but the envelope format is not a documented contract. Selectable types: [Webhook events](../reference/webhook-events.md). ## Limits - A clan name is 3–64 characters, its tag 2–8 characters. - A party capacity must be positive; exceeding the capacity is rejected. A party invite is valid for 24 hours. - The clan member limit and the invite/application lifetime are in the environment settings. - In a party reward the `weighted` shares must add up to 10000; `amount` must be positive. ## Common errors | Symptom | Cause | Fix | |---|---|---| | `403` | A call with a player session or a client key; or the `actor_player_id` has no permission for this operation inside the clan (for example a member cannot kick another member) | Make the call with a server key; check the role of `actor_player_id` | | `400` | `Idempotency-Key` is missing; the clan name/tag violates the length or character rule; XP or an amount is not positive; the party capacity is not positive | Fix the input | | `409` | A clan with the same name or tag exists; the player is already in another active clan/party; a pending invite or application already exists; the same key is in flight | Read the conflicting record and leave the decision to the game | | `422` | Party capacity/share rules (capacity exceeded, `weighted` share total is not 10000, a duplicate player) or the same key with a different body | Fix the input; use a new key | | `404` | The clan, invite or party does not exist | Verify the IDs with `GET` | ## Working example Creating a clan and sending an invite in TypeScript (with the SDK's typed clan client): ```ts /** Güvenilir backend örneği: klan kurar ve bir oyuncuya davet gönderir. Server anahtarı yalnız sunucuda durur. */ import { CrxClient } from "@ferdiunal/open-play-ts"; /** Lider için klan kurar, ardından davet gönderir; her yazma kendi `Idempotency-Key`'iyle tekrar güvenlidir. */ export async function foundClanAndInvite(baseUrl: string, serverApiKey: string, leaderId: string, friendId: string): Promise { const crx = new CrxClient({ baseUrl, apiKey: serverApiKey }); const created = await crx.createClan({ body: { leader_player_id: leaderId, name: "Kurt Sürüsü", tag: "KRT" }, headers: { "Idempotency-Key": `clan-kur-${leaderId}` }, }); if (created.type !== "clan") throw new Error("Beklenmeyen yanıt zarfı"); await crx.createClanInvite({ path: { clan_id: created.clan.id }, body: { inviter_player_id: leaderId, invitee_player_id: friendId }, headers: { "Idempotency-Key": `davet-${created.clan.id}-${friendId}` }, }); return created.clan.id; } ``` ## Related pages - [Chat](chat.md) · [Economy](economy.md) · [Characters](characters.md) · [Game rules](game-rules.md) - [TypeScript SDK](../sdk/typescript.md) · [Errors](../reference/errors.md) --- Source: https://open-play.b4klabs.dev/docs/en/guides/chat.html # Chat This page describes channel chat between players (global, world, clan, party and direct). Read it if you want to send messages with a player session, read history and receive live messages. ## What it does The chat module (`chat`) provides channel-based text chat. Messages are written to a durable archive; live delivery is done separately over a WebSocket. The same message can be sent over REST or over the WebSocket, and both apply the same rules (mute, rate limit, profanity filter, replay protection). It works with the player's own session. **Chat endpoints cannot be called with an API key.** If the module is disabled, the endpoints return `404` with `{"code":"module_disabled","module":"chat"}`; the chat socket first sends an `error` frame (`code: "module_disabled"`) and then closes. Enable the module on the [Game settings › Modules](../panel/game-settings-modules.md) page. ## Where it lives in the panel | What you want to do | Panel page | |---|---| | Enable or disable the module | [Game settings › Modules](../panel/game-settings-modules.md) | | Read the chat of the city (scene) plugin, delete a message, mute a player | [City chat](../panel/scene-chat.md) | > **Note** > > Panel help pages are not translated yet; the panel links above point to the Turkish source. The panel's "City chat" screen manages the chat of the **scene plugin**; for the channel chat on this page there is no panel screen yet for reading, deleting or muting messages. ## Data model (in plain words) - **Channel:** Distinguished by `kind`: | `kind` | `reference_id` | Who can join | |---|---|---| | `global` | none | Everyone in the environment | | `world` | required (world/map UUID) | Players who know the reference | | `clan` | required (clan UUID) | Members of the clan | | `party` | required (party UUID) | Members of the party | | `direct` | none; `direct_player_id` is required | Only the two players | A channel of the same kind and the same reference is created once; asking again returns the existing channel. In a `direct` channel the other player cannot be yourself. - **Message:** `id`, `channel_id`, `sender_player_id`, `client_message_id`, `body`, `sent_at_ms`. - **`client_message_id`:** A replay key generated by the client (cannot be empty, at most 200 characters). A second send with the same value in the same channel does not create a new message; it returns the existing message with `duplicate: true`. - **Mute:** Muting a player in the scope `all`, `global`, `world`, `clan`, `party` or `direct`. A send by a muted player returns `403`. ## Calls your game makes All chat endpoints are called with a **player session**: the session cookie or an opaque session token (`Authorization: Bearer `), plus the environment header: ``` x-crx-env: ``` On these endpoints the `x-csrf-token` double-submit and the `Origin` check are **not applied**; the session cookie or session token is enough (unlike the store purchase endpoint). Session rules: [Sessions and CSRF](../auth/sessions-and-csrf.md). | Method and path | What it does | |---|---| | `POST /v1/chat/channels` | Creates a channel or returns the existing one | | `GET /v1/chat/channels` | Lists the channels the player is authorized for | | `POST /v1/chat/channels/{channel_id}/messages` | Sends a message | | `GET /v1/chat/channels/{channel_id}/messages` | Reads the archive backwards (`before_ms`, `limit` 1–100, default 50) | | `GET /v1/chat/ws` | Live chat WebSocket | > **Note** > > The TypeScript and Unity SDKs have no ready-made client for chat; call the endpoints with raw REST and WebSocket. See [TypeScript SDK](../sdk/typescript.md). ### Creating a channel ``` POST /v1/chat/channels x-crx-env: Content-Type: application/json { "kind": "clan", "reference_id": "0192f0aa-…" } ``` ```json { "channel": { "id": "0192f2bb-…", "environment_id": "…", "kind": "clan", "reference_id": "0192f0aa-…", "participant_a": null, "participant_b": null, "created_at_ms": 1790000000000 } } ``` A player who is not a member of the clan or party is rejected when creating or reading that channel. ### Sending a message ``` POST /v1/chat/channels/0192f2bb-…/messages x-crx-env: Content-Type: application/json { "client_message_id": "c-0001", "body": "Hello!" } ``` ```json { "message": { "id": "…", "channel_id": "0192f2bb-…", "sender_player_id": "…", "client_message_id": "c-0001", "body": "Hello!", "sent_at_ms": 1790000000123 }, "duplicate": false } ``` The rules, in order: 1. `400` if `client_message_id` is empty or longer than 200 characters. 2. The body must be 1–2000 Unicode characters and contain at least one non-whitespace character; otherwise `400`. 3. `403` if the player is muted for that channel. 4. **Rate limit:** at most 20 messages per player per 10 seconds; exceeding it returns `429`. 5. **Profanity filter:** if the message contains a banned word (a whole word separated by whitespace, case-insensitive), `422` is returned. 6. The message is written to the archive, then delivered to live subscribers. Even if delivery fails, the archived message is not deleted. ### Reading the archive `GET /v1/chat/channels/{channel_id}/messages?before_ms=1790000000000&limit=50` returns `{ "messages": [ … ] }`. `before_ms` asks for messages **older** than this Unix millisecond timestamp; for the next page, pass the `sent_at_ms` of the oldest message. ## Live chat (WebSocket) When connecting to `GET /v1/chat/ws`, `x-crx-env` and the session identity (cookie or `Authorization: Bearer`) are sent **as headers**. The upgrade is not performed without authentication (`401`); `404` if the module is disabled. > **Warning** > > The browser `WebSocket` API cannot send custom headers (`x-crx-env`); therefore the chat socket cannot be connected directly from a browser. Browser games can poll with REST; native clients (for example Unity desktop/mobile) can connect by adding headers. A query parameter or subprotocol instead of a header is not supported yet. Client frames (JSON; the `type` field is lower camelCase, the other fields are snake_case): | `type` | Fields | Meaning | |---|---|---| | `subscribe` | `channel_id` | Subscribe to the channel's live stream | | `unsubscribe` | — | Drop the subscription | | `send` | `channel_id`, `client_message_id`, `body` | Send a message, as in REST | | `ping` | `nonce` | Liveness probe | Server frames: | `type` | Fields | |---|---| | `subscribed` | `channel_id` | | `unsubscribed` | — | | `message` | `message`, `duplicate` | | `pong` | `nonce` | | `error` | `code` (`invalid_frame`, `invalid_payload`, `forbidden_channel`, `unavailable`, `message_rejected`, `module_disabled`) | A socket is subscribed to a single channel at a time. A frame with an unrecognized field gets `invalid_frame`. If the module is disabled later, the open socket is closed too. For the general realtime surface, see [Realtime: overview](../realtime/overview.md). ## Versioning and replay safety - Message sending has **no** `Idempotency-Key` header; replay safety is provided by `client_message_id`. After a network error, resend with the same value: the message appears once (`duplicate: true`). - `If-Match` is not used. ## Events A chat event is not delivered as a webhook; **chat is not among the webhook event types**. Use the WebSocket for the live stream. ## Limits - Message body 1–2000 characters; `client_message_id` at most 200 characters. - Send rate: 20 messages per player per 10 seconds. - An archive page is 1–100 messages. - A WebSocket can be subscribed to a single channel at a time. ## Common errors | Symptom | Cause | Fix | |---|---|---| | `401` | No session, expired, or `x-crx-env` missing | Refresh the session; add the header | | `403` (send) | The player is muted or has no permission in the channel | Inform the user | | `400` | Empty or too long body / `client_message_id` | Fix the input | | `422` | The profanity filter rejected the message | Change the message | | `429` | 20 messages per 10 seconds exceeded | Wait and retry | | `error: forbidden_channel` (WS) | No permission for the channel | Create the channel with REST first / verify the permission | ## Working example Writing to a channel over REST with a session cookie (`cookies.txt`) and reading the history: ``` curl -X POST "$API/v1/chat/channels/$CHANNEL/messages" \ -H "x-crx-env: $ENV" \ -H "Content-Type: application/json" -b cookies.txt \ -d '{"client_message_id":"c-0001","body":"Hello!"}' curl "$API/v1/chat/channels/$CHANNEL/messages?limit=20" \ -H "x-crx-env: $ENV" -b cookies.txt ``` ## Related pages - [Clans and parties](social.md) · [Realtime: overview](../realtime/overview.md) - [Sessions and CSRF](../auth/sessions-and-csrf.md) · [Errors](../reference/errors.md) --- Source: https://open-play.b4klabs.dev/docs/en/guides/competition.html # Leaderboards and tournaments This page describes leaderboards, where players compete on score, and elimination-style tournaments. Read it if you want to write scores from a trusted server and show the ranking in your game. ## What it does The competition module (`competition`) provides two things: - **Leaderboard:** A ranking that keeps each player's **best score** and can be split into seasons. - **Tournament:** A single-elimination, double-elimination or round-robin bracket with player or clan participants, with a final prize. Writing scores and managing boards is done with a **server key**. The game client reads the board with a **player session** and does not need a server key. If the module is disabled, the endpoints return `404` with `{"code":"module_disabled","module":"competition"}`. Enable the module on the [Game settings › Modules](../panel/game-settings-modules.md) page. ## Where it lives in the panel | What you want to do | Panel page | |---|---| | Define a board; set season, unit and visibility | [Leaderboard](../panel/leaderboards.md) | | See a board's top entries and season history | [Leaderboard detail](../panel/leaderboard-detail.md) | | The prize currency for a tournament | [Currencies](../panel/currencies.md) | There is no panel screen for tournaments; they are managed only with REST. > **Note** > > Panel help pages are not translated yet; the panel links above point to the Turkish source. ## Data model (in plain words) - **Board definition:** `key`, `name`, `metric_expr`, `order` (`asc`: a smaller score is better, `desc`: a larger score is better), `season`, `tie_policy`, `player_visible`, `unit` and `unit_suffix`. A definition is versioned. - **Season (`season`):** `none` (never reset), `daily`, `weekly`, `monthly` (aligned to the UTC calendar: a day turns over at 00:00 UTC, a week on Monday at 00:00 UTC, a month on the first of the month) or `custom` (a period of `custom_period_ms` length). When a season closes, its ranking is stored as an immutable record. - **Score:** An integer (`i64`). A player's **best score** in the season counts (the highest for `desc`, the lowest for `asc`). Sending a worse score does not change the ranking. - **Tie-break (`tie_policy`):** For now the only option is `score_achieved_player_id`: score first, then whoever **reached that score first**, then the player ID. - **Rank (`rank`):** Starts at 1. - **Unit (`unit`):** `points`, `money` (smallest currency unit) or `duration_ms`; `unit_suffix` is a 1–8 character suffix (`$`, `ms` …). The unit is for display only. - **`player_visible`:** If `true`, a player session can read the board; the default is `false`. - **`metric_expr`:** A CEL expression compiled at registration (required, cannot be empty). Boards that plugins create on their own use `event.score`; write the same expression when you create one with REST. ## Calls your game makes | Method and path | Credential | What it does | |---|---|---| | `POST /v1/leaderboards` | server key | Creates a board definition | | `GET /v1/leaderboards`, `GET /v1/leaderboards/{key}` | server key | Lists / reads definitions | | `POST /v1/leaderboards/{key}/scores` | server key | Writes a player score (`Idempotency-Key` required, `202`) | | `GET /v1/leaderboards/{key}/entries?limit=&cursor=` | server key | Top ranks (paginated) | | `GET /v1/leaderboards/{key}/around-me?player_id=&radius=` | server key | Ranks around a player | | `GET /v1/player/leaderboards/{key}?limit=` | player session | The first page of the board and the player's own row | | `POST /v1/tournaments` | server key | Creates a tournament | | `GET /v1/tournaments/{id}` | server key | Reads the tournament state | | `POST /v1/tournaments/{id}/participants` | server key | Registers a participant (`Idempotency-Key`) | | `POST /v1/tournaments/{id}/start` | server key | Generates the bracket and starts | | `POST /v1/tournaments/{id}/matches/{match_id}/result` | server key | Reports a match result or resolves a dispute (`Idempotency-Key`) | Server endpoints require `Authorization: Bearer crx_live__`; a client key and a player session are rejected. The player endpoint works with a cookie or session token and `x-crx-env: `; a developer key is not accepted. > **Note** > > The TypeScript SDK has `player.leaderboard(key, { limit })` for player reads; there is no typed client for the server endpoints (writing scores, definitions, tournaments), which are called with raw REST. See [TypeScript SDK](../sdk/typescript.md). ### Board definition ``` POST /v1/leaderboards Authorization: Bearer crx_live__ Content-Type: application/json { "key": "daily-earnings", "name": "Daily earnings", "metric_expr": "event.score", "order": "desc", "season": "daily", "tie_policy": "score_achieved_player_id", "player_visible": true, "unit": "money", "unit_suffix": "$" } ``` Returns `201` with `{ id, key, name, metric_expr, order, season, custom_period_ms, tie_policy, season_key }`. For `season: "custom"`, `custom_period_ms` must be positive. ### Writing a score ``` POST /v1/leaderboards/daily-earnings/scores Authorization: Bearer crx_live__ Idempotency-Key: score-player-1234-2026-11-03 Content-Type: application/json { "player_id": "0192f0f2-5b1a-7c3e-9d11-4a5b6c7d8e9f", "score": 4200 } ``` ```json { "entry": { "rank": 7, "player_id": "0192f0f2-…", "score": 4200, "achieved_at_ms": 1790000000000 }, "replayed": false } ``` The response is `202`: the score has been durably accepted; the ranking projection is updated after a short delay, so a board read immediately afterwards can lag by a few seconds. `Idempotency-Key` is required: the same key and the same body return the same result with `replayed: true`. ### Reading the board with a player session ``` GET /v1/player/leaderboards/daily-earnings?limit=10 x-crx-env: 0192f000-0000-7000-8000-00000000e001 ``` ```json { "leaderboard": { "key": "daily-earnings", "name": "Daily earnings", "order": "desc", "season": "daily", "seasonKey": "2026-11-03", "seasonEndsAtMs": 1790121600000, "unit": "money", "unitSuffix": "$" }, "entries": [ { "rank": "1", "playerId": "…", "displayName": "Ayşe", "score": "9800", "achievedAtMs": 1790000000000 } ], "me": { "rank": "7", "playerId": "…", "displayName": "You", "score": "4200", "achievedAtMs": 1790000000000 } } ``` - Only `player_visible` boards can be read; otherwise `404`. `404` also if the module is disabled. - `limit` is 1–50 (default 20). - `rank` and `score` are **decimal strings** in JSON (lossless `i64`); the SDK turns them into `bigint`. Field names on this endpoint are camelCase. - Banned and deleted players are filtered out and ranks are numbered densely within the page; `me` is your rank among visible players. If you have no score this season, `me` is `null`. - The first page is cached for a short time (at most a few seconds). ## Tournaments ``` POST /v1/tournaments Authorization: Bearer crx_live__ Content-Type: application/json { "name": "Winter Cup", "format": "single_elimination", "participant_type": "player", "capacity": 16, "registration_opens_at_ms": 1790000000000, "registration_closes_at_ms": 1790086400000, "seeding_source": "registration_order", "prize_table": { "currency_key": "gold", "amount": 5000 } } ``` - `format`: `single_elimination`, `double_elimination`, `round_robin`. `participant_type`: `player` or `clan`. - `capacity` is 2–1024; the registration close must be after the open; `name` is 1–200 characters. - `seeding_source`: `registration_order` (registration order; `leaderboard_id` is not given) or `leaderboard` (player tournaments only; `leaderboard_id` is required). - `prize_table`: an object with `currency_key` (no whitespace, at most 200 characters) and a positive `amount`. - Flow: create → register with `participants` (`{ "participant_id" }`) → generate the bracket with `start` → report `result` for each match (`reporter_id`, `reported_side`: `home` `away` `admin`, `outcome`: `home_win` `away_win` `draw`, `score`). The `admin` side is for resolving a dispute. - Responses carry the tournament's current view (`{ "tournament": { … } }`). ## Versioning and replay safety - Writing a score, registering a participant and a match result require `Idempotency-Key` (the same key + the same body: does not run again; a different body: `422`). - A board definition is versioned; list and ranking reads do not need a version header. - General rules: [Idempotency and concurrency](../reference/idempotency-and-concurrency.md). ## Events Accepting a score produces the `score_submitted` event, and a season rollover produces `season_closed`/`season_opened`; these can be used in quest objectives and rule automation. **Among the types you can select as a webhook filter there is no leaderboard or tournament event**; endpoints registered without a filter receive all events, and leaderboard events are included, but the envelope format is not a documented contract. For the final prize, use the reward granting flow on the [Rewards](rewards.md) page. ## Limits - `limit` is 1–50 on player reads; pagination on the server `entries` endpoints is cursor-based. - Tournament `capacity` is 2–1024. - `season: custom` needs a positive `custom_period_ms`. ## Common errors | Symptom | Cause | Fix | |---|---|---| | `404` (player read) | The board is not `player_visible`, does not exist, or the module is disabled | Check the definition | | `403` | A server endpoint called with a client key or a player session | Use the server key from your server | | `400` (`Idempotency-Key`) | The header is missing when writing a score | Send a key | | The score was written but the ranking did not change | The player's best score this season is better | Only a better score changes the ranking | | The score was written but the board did not update right away | Projection delay | Wait a few seconds and read again | | `400` (tournament) | `capacity`, the registration window, `prize_table` or the seeding rule is wrong | Fix the body | ## Working example: write a score, read the board ```ts /** Skor tablosu: sunucu puan yazar (ham REST), oyun istemcisi tabloyu oyuncu oturumuyla okur. */ import type { PlayerClient } from "@ferdiunal/open-play-ts"; /** Sunucu: `POST /v1/leaderboards/{key}/scores`. Sunucu anahtarı gerekir; Idempotency-Key zorunludur. */ export async function submitScore(baseUrl: string, serverApiKey: string, board: string, playerId: string, score: number, requestKey: string): Promise { const response = await fetch(`${baseUrl}/v1/leaderboards/${encodeURIComponent(board)}/scores`, { method: "POST", headers: { Authorization: `Bearer ${serverApiKey}`, "Content-Type": "application/json", "Idempotency-Key": requestKey, }, body: JSON.stringify({ player_id: playerId, score }), }); if (response.status !== 202) throw new Error(`Puan yazılamadı: HTTP ${response.status} ${await response.text()}`); const body = (await response.json()) as { entry: { rank: number }; replayed: boolean }; return body.entry.rank; } /** Oyun istemcisi: tablonun ilk sayfası ve oyuncunun kendi satırı (`player_visible` tablolar). */ export async function showBoard(player: PlayerClient, board: string): Promise { const view = await player.leaderboard(board, { limit: 10 }); for (const row of view.entries) console.log(`${row.rank}. ${row.displayName} — ${row.score}`); if (view.me !== null) console.log(`Siz: ${view.me.rank}. sıra, ${view.me.score} puan`); } ``` ## Related pages - [Rewards](rewards.md) · [Clans and parties](social.md) · [Characters](characters.md) - [TypeScript SDK](../sdk/typescript.md) · [Numbers and encoding](../reference/numbers-and-encoding.md) --- Source: https://open-play.b4klabs.dev/docs/en/guides/notifications.html # Notifications This page describes how to send players in-game (inbox) and push notifications: provider, template and rule setup in the panel; the game's device registration, inbox reading and direct sending from the server. Read it if you want to use notification infrastructure without writing it yourself. ## What it does The notifications module (`notifications`) first **writes a notification to a queue**, then a background worker sends it to the provider (FCM, APNs or OneSignal). The API has no synchronous push send: when your request is written to the queue you get `200`; delivery happens afterwards. The notification text comes from a **template**. A template is written in the panel; the game sends only the template key and the fill data. You can produce a notification in two ways: - **Sending from the server:** Your game server calls `POST /v1/notifications`. - **Rule:** A rule you set up in the panel produces a notification automatically when a game event happens (for example a level up, quest completion or store purchase). If the module is disabled, the endpoints return `404` with `{"code":"module_disabled","module":"notifications"}`. Enable the module on the [Game settings › Modules](../panel/game-settings-modules.md) page. ## Where it lives in the panel | What you want to do | Panel page | |---|---| | Enter push provider (FCM, APNs, OneSignal) credentials | [Notifications](../panel/notifications.md) › Providers | | Write a template (title and body per language, placeholders) | [Notifications](../panel/notifications.md) › Templates | | Set up an event-driven rule, preview the rule live | [Notifications](../panel/notifications.md) › Rules | | Send a test notification to a player, see the send history and devices | [Notifications](../panel/notifications.md) › History, Devices | Provider credentials are entered in the panel only by an authorized role and are stored sealed on the server; the panel shows only the fingerprint. > **Note** > > Panel help pages are not translated yet; the panel links above point to the Turkish source, and the panel tabs are shown in Turkish. ## Data model (in plain words) - **Provider:** `fcm`, `apns`, `one_signal`. Each is switched on and off separately per environment. - **Device:** A player's registered token (`token`) at a provider and its platform (`ios`, `android`, `web`). The same (environment, provider, token) can be registered again; the response says which branch handled it (`created`, `revived`, `refreshed`). - **Template:** A key, a title and body per language, `{{placeholder}}` fields. If the requested language is missing, English (`en`) is used. Each notification is bound to the template version at the moment it was sent. - **Channel:** `in_game` (inbox only), `push` (push only), `both`. - **Notification (inbox row):** `id`, `template_key`, `title`, `body`, `data`, `channel`, `status`, `created_at`, `sent_at`, `read_at`. Status: `queued`, `sent`, `delivered`, `failed`, `read`. - **Dedup key (`dedup_key`):** The same (environment, player, key) is not written a second time. ## Calls your game makes There are two credential types: a **player session** (cookie or session token + `x-crx-env: `) and a **server key** (`Authorization: Bearer crx_live__`). | Method and path | Credential | What it does | |---|---|---| | `POST /v1/notifications` | server key | Writes a notification for a player to the queue | | `GET /v1/notifications` | player session | Inbox (`status`, `cursor`, `limit`) | | `POST /v1/notifications/{id}/read` | player session | Marks as read | | `POST /v1/devices` | player session | Registers a push device | | `DELETE /v1/devices/{token}?provider=` | player session | Revokes a device registration (`204`) | | `GET /v1/push/config` | no credential (`x-crx-env` required) | Settings exposed to the client for web push | | `GET /v1/ws` | player session | Live notification push socket (server to player only; the environment is given with the `x-crx-env` **header**, so it cannot be connected with the browser `WebSocket` API; frames arrive at most once, the durable surface is the inbox) | On the player endpoints the `x-csrf-token` and `Origin` checks are **not applied**; the session cookie or session token is enough. The player side is ready in the TypeScript SDK: under `player.notifications` there are `getPushConfig`, `registerDevice`, `unregisterDevice`, `enableWebPush`, `disableWebPush`, `inbox` and `markRead`. There is no typed client for sending from the server; use raw REST. See [TypeScript SDK](../sdk/typescript.md). ### Sending a notification from the server ``` POST /v1/notifications Authorization: Bearer crx_live__ Content-Type: application/json { "player_id": "0192f0f2-5b1a-7c3e-9d11-4a5b6c7d8e9f", "template_key": "daily_reward_ready", "locale": "en", "data": { "amount": 250 }, "channel": "both", "dedup_key": "daily-2026-11-03" } ``` ```json { "id": "0192f400-…", "duplicate": false } ``` - `locale`: a short language code (`tr`, `en` …). If the requested language is not in the template, `en` is used. - `dedup_key` (optional, 1–256 characters): if the same key arrives a second time, no new row is written; `200` and `duplicate: true` are returned with **the ID of the first notification**. - `403`: a client key or a player session. `404`: the template does not exist in any language. `400`: `template_key` is empty or longer than 128 characters, the `locale` format is invalid, or a placeholder in the template is missing from `data`. ### Reading the inbox ``` GET /v1/notifications?status=queued&limit=20 x-crx-env: 0192f000-0000-7000-8000-00000000e001 ``` ```json { "items": [ { "id": "0192f400-…", "template_key": "daily_reward_ready", "template_version": 2, "title": "Your daily reward is ready", "body": "250 gold is waiting for you.", "data": { "amount": 250 }, "channel": "both", "status": "sent", "created_at": "2026-11-03T09:00:00Z", "sent_at": "2026-11-03T09:00:02Z", "read_at": null } ], "next_cursor": null } ``` The list is ordered newest to oldest (`created_at`, `id`). `limit` is 1–100; when `next_cursor` is `null`, it is the last page. Rows whose delivery **failed** are listed too. `POST /v1/notifications/{id}/read` makes the row `read`; a second call returns the same row and produces no new event. ### Registering a push device ``` POST /v1/devices x-crx-env: 0192f000-0000-7000-8000-00000000e001 Content-Type: application/json { "provider": "fcm", "platform": "android", "token": "", "device_meta": { "model": "Pixel 8" } } ``` ```json { "id": "…", "registration": "created", "provider": "fcm", "platform": "android", "created_at": "2026-11-03T09:00:00Z" } ``` `provider` is `apns`, `fcm` or `one_signal`; `platform` is `ios`, `android` or `web`. The token cannot be empty. The player ID comes from the session, not from the body. `app_key` separates the app in multi-app environments. > **Note** > > The generated OpenAPI document writes the device provider as `onesignal`; the server and the SDK use `one_signal`. This is a known documentation deviation; send `one_signal`. For web push, `player.notifications.enableWebPush` runs the browser permission flow with the settings it gets from `GET /v1/push/config`; on sign-out, `session.logout()` automatically revokes the web device registered from this page. ## Rules: automatic notifications when an event happens In the panel, a rule consists of a **trigger** (a game event from a closed list; for example `level_up`, `quest_completed`, `store_purchase_completed`, `vip_tier_up`), an optional **condition**, a **template**, a **language**, a **channel** and the recipient player path. Rule versions appear on the panel's history tab. A notification itself does not trigger a new rule. The rule editor offers a "live preview" and a test send. For the detailed fields, see the panel page. ## Versioning and replay safety - Sending: replay safety with `dedup_key`; there is **no** `Idempotency-Key` header. - Device registration and the read mark are naturally idempotent. - The template version is bound to the notification; changing the template later does not affect an old notification. ## Events When a notification is written to the queue, a `notification_queued` event is produced and the send worker consumes it. **Among the types you can select as a webhook filter there is no notification type**; endpoints registered without a filter receive this internal event too, but the envelope format is not a documented contract. ## Limits - `dedup_key` is 1–256 characters; inbox `limit` is 1–100. - Push delivery is best-effort: if the provider rejects it, the row becomes `failed`. If a device is permanently invalid (for example the app was uninstalled), the device is revoked automatically. - If provider credentials are not defined, the notification waits in the queue and the provider status in the panel shows a warning. ## Common errors | Symptom | Cause | Fix | |---|---|---| | `403` (send) | A client key instead of a server key | Use the server key | | `404` (send) | `template_key` does not exist in any language | Create and publish the template in the panel | | `duplicate: true` | The same `dedup_key` was already written | Expected behavior; use a new key for a new notification | | The notification stays `queued` | The provider is disabled or the credential is wrong | Check the provider status in the panel | | `400` (device) | Empty token or unknown provider/platform | Fix the input | ## Working example Draining the inbox on the player side: ```ts /** Oyuncu tarafı: gelen kutusunu okur ve okunmamış bildirimleri okundu işaretler. */ import type { PlayerClient } from "@ferdiunal/open-play-ts"; /** İlk sayfadaki okunmamış bildirimleri yazdırır ve okundu yapar. */ export async function drainInbox(player: PlayerClient): Promise { const page = await player.notifications.inbox({ limit: 20 }); let marked = 0; for (const item of page.items) { if (item.readAt !== null) continue; console.log(item.title, item.body, item.data); await player.notifications.markRead(item.id); marked += 1; } return marked; } ``` The curl example for sending from the server is above. ## Related pages - [Characters](characters.md) · [Rewards](rewards.md) · [Store and IAP](store-iap.md) · [Game rules](game-rules.md) - [TypeScript SDK](../sdk/typescript.md) · [Errors](../reference/errors.md) --- Source: https://open-play.b4klabs.dev/docs/en/guides/webhooks.html # Webhooks This page describes how Open Play forwards game events to your own server with an HTTP POST: endpoint registration, event types, signature verification, retries and coping with repeated deliveries. Read it if you want to trigger your own backend system on a character, store or reward event. ## What it does When an event occurs (for example `level_up`, `store_purchase_completed`, `reward_granted`), Open Play sends a signed POST to your registered endpoints. Delivery is **at-least-once**: the same event can arrive more than once because of network errors, so your receiver must drop repeats using `X-Crx-Event-Id`. If the module is disabled, the endpoints return `404` with `{"code":"module_disabled","module":"webhooks"}`. Enable the module on the [Game settings › Modules](../panel/game-settings-modules.md) page. ## Where it lives in the panel | What you want to do | Panel page | |---|---| | See registered endpoints, delivery attempts, failed (DLQ) deliveries and the event queue | [Webhooks and event queue](../panel/webhooks.md) | | Enable the module | [Game settings › Modules](../panel/game-settings-modules.md) | The panel is **read-only** on this page: endpoint registration is done with REST; there is no button in the panel to resend or to rescue a failed delivery. The panel does not show secrets, the raw body or the raw error text. > **Note** > > Panel help pages are not translated yet; the panel links above point to the Turkish source. ## Data model (in plain words) - **Endpoint:** `url`, an optional `event_types` filter, `enabled` and `created_at`. - **Secret:** Generated by the server (`whsec_` + 64 lowercase hex characters), returned **once** only in the registration response and stored encrypted. No endpoint gives it again afterwards. There is no endpoint to rotate the secret: to renew it, delete the endpoint and register it again. - **Event envelope:** The JSON body that is sent. Every envelope contains `event_id`, `type`, `environment_id` and `data`. - **Delivery:** The sending of one event to one endpoint. Its statuses are pending, delivered and failed (DLQ). ### Selectable event types If `event_types` is empty, the endpoint receives **all** events (see the note below). If it is non-empty, you can choose only from these types: | Area | Types | |---|---| | Character | `flag_set`, `attribute_set`, `resource_changed`, `modifier_added`, `modifiers_removed`, `xp_added`, `level_up`, `ability_unlocked`, `event_emitted`, `character_rolled_back` | | Store | `store_page_created`, `store_page_updated`, `store_page_deleted`, `store_pages_reordered`, `store_listing_created`, `store_listing_updated`, `store_listing_deleted`, `store_listings_reordered`, `store_purchase_initiated`, `store_payment_started`, `store_purchase_completed`, `store_reservation_cancelled` | | Reward | `reward_granted` | An unknown type returns `400`. Full list and data fields: [Webhook events](../reference/webhook-events.md). > **Warning** > > If you leave the filter **empty**, the endpoint is not limited to the list above: it receives all events written to the outbox (such as item, wallet, clan, party, score and notification queue events). The envelope format of these extra events is not a documented contract and may change without notice. In production, always give the `event_types` filter explicitly. ### Envelope format Character events: ```json { "event_id": "0192f500-1111-7222-8333-944455556666", "type": "level_up", "character_id": "0192f0f2-5b1a-7c3e-9d11-4a5b6c7d8e9f", "environment_id": "0192f000-0000-7000-8000-00000000e001", "seq": 12, "data": { "...": "event-specific fields" } } ``` Store events have no `character_id`; together with `seq` they carry `now_ms` (the moment the event was processed) and `actor_api_key_id`. The `reward_granted` event has `player_id`, and `data` contains `source_kind`, `table_key`, `table_version` and what was granted; **the draw seed is in no event.** ## Calls your game makes All management endpoints are called with a **server key**; a client key gets `403`: ``` Authorization: Bearer crx_live__ ``` | Method and path | What it does | |---|---| | `POST /v1/webhooks` | Registers an endpoint (`201`; the secret only here) | | `GET /v1/webhooks` | Lists endpoints (`cursor`, `limit` 1–200, default 50) | | `GET /v1/webhooks/{id}` | Reads a single endpoint (without the secret) | | `DELETE /v1/webhooks/{id}` | Deletes an endpoint (soft delete, `204`) | The TypeScript SDK exposes these four endpoints in a typed way: `createWebhook`, `listWebhooks`, `getWebhook`, `deleteWebhook` (see [TypeScript SDK](../sdk/typescript.md)). ### Registering an endpoint ``` POST /v1/webhooks Authorization: Bearer crx_live__ Content-Type: application/json { "url": "https://hooks.example-game.com/open-play", "event_types": ["level_up", "store_purchase_completed"] } ``` ```json { "id": "0192f600-…", "url": "https://hooks.example-game.com/open-play", "event_types": ["level_up", "store_purchase_completed"], "enabled": true, "created_at": "2026-11-03T09:00:00Z", "secret": "whsec_<64 hex characters>" } ``` Write the `secret` value to a safe place (a secrets management service) right away; it cannot be read again. Registration rules: - The URL must be `https`; it must not carry user information (`user:password@`); it must not contain non-letter control characters or whitespace. Loopback, private and link-local **IP addresses** are rejected at registration (`400`). - In addition, on every send the IP that the name resolves to is re-checked; a name that resolves to a private or loopback address cannot be sent to. Redirects are **not followed**. - A second registration while the same URL is active returns `409`. ## Delivery For every event, this request arrives at your endpoint: ``` POST /open-play Content-Type: application/json X-Crx-Signature: t=1790000000,v1=9c1f…e2 X-Crx-Event-Id: 0192f500-1111-7222-8333-944455556666 X-Crx-Event-Type: level_up { …envelope… } ``` | Header | Meaning | |---|---| | `X-Crx-Signature` | `t=,v1=` | | `X-Crx-Event-Id` | The event ID; your dedup key | | `X-Crx-Event-Type` | The `type` value in the envelope | ### Verifying the signature The signature is the HMAC-SHA256 digest (lowercase hex) of the text `"{t}.{raw body}"`, computed with **the whole secret returned at registration** (with the `whsec_…` prefix) as the key. Verification steps: 1. Separate the `t` and `v1` values from the `X-Crx-Signature` header. 2. Take the **raw** body bytes. Do not parse the JSON and re-serialize it; the signature is for the exact bytes on the wire. 3. Compute `HMAC-SHA256(secret, t + "." + raw_body)` and compare it with `v1` in **constant time**. 4. Compare `t` with your own clock; reject requests that are too old (for example older than 5 minutes). `t` is refreshed on every retry, so a genuine retry stays within this window. 5. If `X-Crx-Event-Id` has already been processed, return `2xx` without repeating the work. ### Response and retry rules - Only **`2xx`** is success. Every other response, a timeout and a connection error trigger a retry. - 5 seconds are waited for the connection and 10 seconds in total, including DNS. - If the body exceeds 64 KB, it is not sent and counts as an error. - The delay is `min(1 s × 2^attempt, 5 min)` and includes ±20% randomness. At most **10 attempts** are made; after the 10th failed attempt the delivery is moved to the failed (DLQ) state and is not repeated automatically. - When you delete the endpoint, pending deliveries are also moved to the failed state. - There is **no** ordering guarantee: events can be ordered with `seq` for a character, but the delivery order can change. - Events that occur while there is no endpoint are not sent afterwards; there is no retroactive delivery. ## Versioning and replay safety - Receiver side: dedup with `X-Crx-Event-Id` is mandatory. The same event arrives on a retry with the **same** `event_id`; the signature time (`t`) is refreshed. - The registration endpoint has no `Idempotency-Key`; registering the same URL a second time returns `409`. - There is no event version field in the envelope; ignore fields you do not recognize and do not reject the body with a strict schema. ## Limits - Body 64 KB; total wait 10 seconds; 10 attempts. - `limit` on the list endpoint is 1–200. - Redirects are not followed; `https` only. ## Common errors | Symptom | Cause | Fix | |---|---|---| | `400` on registration | The URL is not `https`, is an IP address, has user information, or an unknown event type | Fix the URL and `event_types` values | | `403` on registration | A client key | Use a server key | | `409` on registration | The same URL is already active | Delete or use the existing one | | The signature does not verify | The body was re-serialized; the `whsec_` prefix of the secret was dropped; clock skew | Compute with the raw bytes and the whole secret | | The same event arrives twice | At-least-once delivery | Drop it with `X-Crx-Event-Id` | | Deliveries stopped | The 10 attempts were used up (DLQ) or the endpoint was deleted | Fix the receiver; for missed events, read from your own source | | No delivery ever arrives | The endpoint name resolves to a private/loopback IP, or the response exceeds 10 seconds | Use a public address; respond quickly | ## Working example: a receiver with Node The server below verifies the signature, drops a repeated event and returns `2xx`. The secret comes from an environment variable; in production, do the dedup with a durable store. ```ts /** Webhook alıcısı (Node 22): imzayı doğrular, tekrar eden teslimatı `X-Crx-Event-Id` ile eler. */ import { createHmac, timingSafeEqual } from "node:crypto"; import { createServer } from "node:http"; const SECRET = process.env["OPEN_PLAY_WEBHOOK_SECRET"] ?? ""; // uç noktayı kaydederken bir kez dönen sır const TOLERANCE_SECONDS = 300; // eski teslimatı (replay) reddetme penceresi: alıcının kendi politikası const seen = new Set(); // örnek amaçlı bellek; üretimde kalıcı depo kullanın /** `X-Crx-Signature: t=,v1=` değerini ham gövdeye karşı doğrular. */ export function verifySignature(secret: string, header: string, rawBody: Buffer, nowSeconds: number): boolean { const parts = new Map(header.split(",").map((part) => part.split("=", 2) as [string, string])); const t = parts.get("t"); const v1 = parts.get("v1"); if (t === undefined || v1 === undefined) return false; if (Math.abs(nowSeconds - Number(t)) > TOLERANCE_SECONDS) return false; const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex"); const a = Buffer.from(expected, "utf8"); const b = Buffer.from(v1, "utf8"); return a.length === b.length && timingSafeEqual(a, b); } createServer((req, res) => { const chunks: Buffer[] = []; req.on("data", (chunk: Buffer) => chunks.push(chunk)); req.on("end", () => { const rawBody = Buffer.concat(chunks); // imza ham bayt üzerindendir: JSON'u yeniden serileştirmeyin const signature = req.headers["x-crx-signature"]; if (typeof signature !== "string" || !verifySignature(SECRET, signature, rawBody, Math.floor(Date.now() / 1000))) { res.writeHead(401).end(); return; } const eventId = String(req.headers["x-crx-event-id"] ?? ""); if (!seen.has(eventId)) { seen.add(eventId); const event = JSON.parse(rawBody.toString("utf8")) as { type: string; data: unknown }; console.log("olay", event.type, event.data); } res.writeHead(200).end(); // yalnız 2xx başarıdır; başka kod yeniden denemeyi tetikler }); }).listen(8080); ``` ## Related pages - [Webhook events](../reference/webhook-events.md) · [Characters](characters.md) · [Store and IAP](store-iap.md) · [Rewards](rewards.md) - [Security](../operations/security.md) · [Errors](../reference/errors.md) --- Source: https://open-play.b4klabs.dev/docs/en/guides/scenes-assets-models.html # Scenes, assets and 3D models This page describes the three parts that make up your game's world: real-time **scene** (stage/room) registration, **images** uploaded from the panel and the server, and **3D models** uploaded by an operator. Read it if you want to open city rooms from the server, manage icon and banner images, or serve models to the game. ## What it does - **Scene:** The room where the real-time plugin gathers players. The game server registers the scenes it needs at startup; registration is idempotent. - **Image (asset):** PNG, JPEG or WebP. It is content-addressed: the address is the file's `sha256` digest, and the same file is stored once. Store banners, currency icons and content group `image` fields use this store. - **3D model:** A `glb` (or a converted source file) uploaded by an operator in the panel. Once published it is bound to an item or a character and the game gets it from the catalog. The scene and image write endpoints depend on the `scenes` module; if the module is disabled, `404` with `{"code":"module_disabled","module":"scenes"}` is returned. The image and model **read** endpoints are core and work even when the module is disabled. Enable the module on the [Game settings › Modules](../panel/game-settings-modules.md) page. ## Where it lives in the panel | What you want to do | Panel page | |---|---| | Upload, preview, publish and archive a 3D model, add a clip | [3D models](../panel/models.md), [Model profile](../panel/model-profile.md) | | Bind a model to an item or a character | [Content](../panel/content.md) (built-in item/character groups) | | Upload store banners and product images, currency icons | [Store](../panel/store.md), [Currencies](../panel/currencies.md) | | Moderate city chat (scene plugin) | [City chat](../panel/scene-chat.md) | | See that the scene plugin is installed, and its permissions | [Plugins](../panel/plugins.md) | > **Note** > > Panel help pages are not translated yet; the panel links above point to the Turkish source. ## Data model (in plain words) **Scene** - The ID is derived from three parts: `plugin_id` (the plugin), `module_id` (the real-time module) and `key` (at most 96 characters). The same triple always resolves to the same scene. - `capacity`: The room's player capacity; at most 10,000. - `state`: `open`, `draining` (emptying) or `closed`. - `partition_id`: The ID of the partition the scene runs on in the distributed setup (generated by the server). **Image** - The type is derived from the first bytes of the file; the `Content-Type` header is only advisory. Accepted types are `image/png`, `image/jpeg`, `image/webp`. - At most **2 MiB**; width and height are each at most 4096 pixels. - The response gives `sha256`, `contentType`, `byteSize` and `created` (whether it was written in this call). **3D model** - An upload can be `glb`, or it is converted from source formats such as `fbx`, `obj`, `usdz`, `stl`, `blend`, `3mf`, `dxf`. The conversion status is `queued`, `converting`, `ready`, `failed`; the reason for a failure appears in the panel as a sentence in Turkish (for example an external resource reference, a budget overrun, a 2D DXF). - Every model is versioned; the published version appears in the catalog. The lifecycle is `active`, `archived`, `purged`. - Default limits: uploaded file 50 MiB, environment quota 1 GiB, 200 models per environment, 20 versions per model, 10 uploads per operator per 10 minutes. - The converter runs only on the amd64 architecture; on other architectures only `glb` can be uploaded. ## Calls your game makes | Method and path | Credential | What it does | |---|---|---| | `PUT /v1/scenes` | server key | Provisions a scene idempotently | | `PUT /v1/assets` | server key | Uploads an image as a raw body | | `GET /v1/assets` | server key | Lists the environment's images (`cursor`, `limit`) | | `DELETE /v1/assets/{sha256}` | server key | Deletes an image row (`204`) | | `GET /v1/assets/{sha256}` | no credential | Reads the image bytes | | `GET /v1/model-files/{sha256}` | no credential | Reads a published model's `glb` file | Management endpoints require `Authorization: Bearer crx_live__`; a client key gets `403`. ### Provisioning a scene ``` PUT /v1/scenes Authorization: Bearer crx_live__ Content-Type: application/json { "plugin_id": "package-ride", "module_id": "package-ride.presence", "key": "istanbul-1", "capacity": 200 } ``` ```json { "scene_id": "…", "plugin_id": "package-ride", "module_id": "package-ride.presence", "key": "istanbul-1", "partition_id": "…", "capacity": 200, "state": "open", "created": true } ``` - If the scene does not exist, it is written (`201`, `created: true`); if it exists, it is returned **unchanged** (`200`, `created: false`). `Idempotency-Key` is not needed. - `400`: the ID, key or capacity is invalid (body errors are `400` too). `403`: a client key. `409`: a scene with a different ID on the same key. `422`: the plugin is not enabled in this environment. For a plugin's real-time modules and the player-side flow, see [Realtime: overview](../realtime/overview.md) and [Plugins](../plugins/overview.md). ### Uploading an image ``` PUT /v1/assets Authorization: Bearer crx_live__ Content-Type: image/png ``` ```json { "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "contentType": "image/png", "byteSize": 18234, "created": true } ``` `201` with `created: true` is the first write; if the same bytes already exist, `200` and `created: false` are returned and the row does not change, so the upload is idempotent. An empty body, an unrecognized type, a size over 2 MiB or a width/height over 4096 pixels is rejected with `400` (error codes `asset_empty`, `asset_unsupported_type`, `asset_too_large`, `asset_dimensions_too_large`). To show the image in the game, use ``: the endpoint is **unauthenticated**, and it takes the environment from the `x-crx-env` header or (for ``/texture loaders that cannot send headers) from the `env` query parameter. The response carries the `sha256` as its `ETag` and can be cached indefinitely (`immutable`); if `If-None-Match` matches, `304` is returned. A new image means a new `sha256`, that is a new address. > **Note** > > The image and model read endpoints send cross-origin readable response headers so that a browser's texture loader can use them. The other public endpoints grant CORS only to origins in the environment's domain list; see [Architecture and hosting](../getting-started/architecture-and-hosting.md). > **Warning** > > `DELETE /v1/assets/{sha256}` deletes only the row; the store does not know whether other records (a product, an icon, content) refer to this image. Make sure the image is unused before deleting it. ### Bringing a 3D model into the game Upload and publish the model in the panel and bind it to an item or a character. The game reads the model's address and computed facts (triangle count, bones, clips, bounding box) **from the catalog**; `model.glb.url` has the form `/v1/model-files/?env=`. Details: [Content groups › Catalog](content-groups.md#catalog-items-and-characters) and Game catalog integration (`docs/oyun-katalog-entegrasyonu.md`) (Turkish). `GET /v1/model-files/{sha256}` is unauthenticated and content-addressed: - The environment is given with the `x-crx-env` header or the `env` query; if both are given and differ, or neither is present, `400`. - Only the `glb` file of a model that is ready, whose files are retained and which is not deleted is returned; `404` for a deleted model. - A matching `If-None-Match` gives `304` without opening the file. A `200` response carries `Content-Length`, the `model/gltf-binary` type and indefinite cache headers. `Range` is not supported. - The same `sha256` is always the same bytes: cache the file by `sha256` and do not download it again. > **Note** > > Model upload and publishing are done only from the panel; there is no public write endpoint for models. The Unity SDK has no catalog and model read helpers; use raw REST. ## Versioning and replay safety - `PUT /v1/scenes` and `PUT /v1/assets` are naturally idempotent (a derived ID, a content address); they do not ask for `Idempotency-Key`. - A model file and an image are content-addressed: the same address never gives different bytes. - Model versions are tracked in the panel; the game always sees the current published version from the catalog. ## Events These surfaces produce no webhook events. A model change is reflected in the catalog as a new `sha256`; poll with `ETag`. ## Limits - An image is at most 2 MiB and 4096×4096 pixels; only PNG, JPEG, WebP. - Scene `key` is at most 96 characters, `capacity` at most 10,000. - The model default limits are above (50 MiB, 1 GiB quota, 200 models, 20 versions). - The `GET /v1/assets` page size is at most 200 (default 50). ## Common errors | Symptom | Cause | Fix | |---|---|---| | `422` (scene) | The plugin is not enabled in this environment | Enable the plugin ([Plugins](../panel/plugins.md)) | | `409` (scene) | A scene with a different ID on the same key | Change the key | | `400` (image) | The type was not recognized, or 2 MiB or 4096 pixels was exceeded | Upload PNG/JPEG/WebP under 2 MiB and 4096 pixels | | `` does not load | `env` or `x-crx-env` is missing | Add `?env=` to the address | | `404` (model file) | The model was deleted or is not ready yet | Check the `model` field in the catalog | | The model is `failed` in the panel | A conversion error | Fix the file according to the reason the panel shows | ## Working example Uploading and reading an image: ``` curl -X PUT "$API/v1/assets" \ -H "Authorization: Bearer $SERVER_KEY" -H "Content-Type: image/png" \ --data-binary @banner.png curl "$API/v1/assets/?env=$ENV" -o banner.png ``` ## Related pages - [Content groups](content-groups.md) · [Inventory and items](inventory-items.md) · [Store and IAP](store-iap.md) - [Realtime: overview](../realtime/overview.md) · [Plugins](../plugins/overview.md) --- Source: https://open-play.b4klabs.dev/docs/en/guides/content-groups.html # Content groups This page describes **content groups**, which let you change game data such as tools, weapons, NPCs or police units without shipping code. Read it to learn how the data is defined and published in the panel and how your game reads it. ## What it does A content group is a typed table you define: the group has **fields** (for example `speed`, `damage`, `color`, `model`), and every **entry** (for example `sedan`) carries a value for those fields. An operator edits the values in the panel and **publishes** them; the game reads only the current published state. This way you do not have to redeploy the game to change a value. Content groups are part of the core: there is no separate module switch, and they work in every environment. ## Where it lives in the panel | What you want to do | Panel page | |---|---| | Create a group, add fields, start from a template | [Content](../panel/content.md), [Edit group](../panel/content-group-edit.md) | | Add an entry, edit values, publish or archive an entry | [Edit entry](../panel/content-entry-edit.md) | | See the publish history and the diff, send changes to the game | [Content](../panel/content.md) › Publish | | Attach a 3D model to a model field | [3D models](../panel/models.md) | > **Note** > > Panel help pages are not translated yet; the panel links above point to the Turkish source, and the panel itself is shown in Turkish. ## Data model (in plain words) - **Group:** A `key` (`^[a-z][a-z0-9_]{1,47}$`, that is 2–48 characters), a name and fields. The key does not change after creation. - **Field:** A `key` (the same rule) and a **kind**. There are ten kinds: | Kind (`kind`) | What it holds | Settings | |---|---|---| | `integer` | An integer (with an optional decimal display scale) | `min`, `max`, `scale` (0–3), `unit` (at most 12 characters), `default` | | `percent` | A percentage in basis points (10000 = 100%) | `min_bp`, `max_bp`, `default_bp` | | `text` | Text | `max_chars` (at most 512), `multiline` | | `bool` | Yes/no | `default` | | `select` | Option(s) | `options` (at most 64), `multiple` | | `color` | A color | `#rrggbb` | | `model` | A 3D model | model library ID | | `image` | An image | the asset's `sha256` value | | `reference` | A link to another entry | target: another group, a built-in item or a built-in character | | `table` | A matrix with repeated rows | `columns` (1–8), `min_rows`, `max_rows` (at most 100) | - **Visibility (audience):** Each field can be `player` (goes to the player) or `server` (only a server key sees it). - **Entry:** A `key` (`^[a-z0-9][a-z0-9_-]{0,63}$`) and field values. Its status is one of: Published (`Yayında`), Has draft changes (`Taslak değişikliği var`), Archived (`Arşivli`), Not published yet (`Henüz yayınlanmadı`); the panel shows these labels in Turkish. - **Publish:** Done in the panel with "Değişiklikleri oyuna gönder" (send changes to the game); it takes a note of at most 280 characters and produces a sequential publish counter (`seq`). The next publish does not change the previous one, it adds a new one; the history and the diff are visible in the panel. If someone published while you were looking, the panel refreshes the diff and asks for confirmation again. - **Numbers:** Integers; fractional or exponent notation (`40.0`, `1e2`) is rejected. Display a scaled integer in the game with `formatScaled`. > **Note** > > Item and character templates also appear in the panel as **built-in groups**; they behave as "live in the game as soon as you save" and need no publish step. User-defined groups need a publish. Limits: at most 64 groups per environment, 48 fields per group and 1000 entries; one entry's table data cannot exceed 128 KiB, and the publish snapshot for a server audience cannot exceed 1 MiB. ## Catalog: items and characters The game reads item and character definitions (key, name, typed data rows and the 3D model address) from the **catalog** endpoints: | Method and path | What it returns | |---|---| | `GET /v1/catalog/items`, `GET /v1/catalog/items/{key}` | Item manifests | | `GET /v1/catalog/characters`, `GET /v1/catalog/characters/{key}` | Character manifests | These depend on the `items` and `characters` modules; `404` if the module is disabled. The list is paginated (`limit` 1–100, default 50; `cursor`). Each manifest carries `kind`, `key`, `name`, `definition_version`, `data` (typed rows), `model` (`null` if none) and `content_sha256`. For details, watching with ETag and showing models with `three`: Game catalog integration (`docs/oyun-katalog-entegrasyonu.md`) (Turkish) and [Scenes, assets and 3D models](scenes-assets-models.md). ## Calls your game makes (content groups) Content groups are exposed through a **read-only** surface; the game cannot reach drafts. There are two credential types: - **Player session** (cookie or session token + `x-crx-env: `): sees only fields with `visibility = player` and their values. - **Server key** (`Authorization: Bearer crx_live__`): sees all fields with their visibility and with `seq`. An invalid `Bearer crx_…` key gets `401` even with a valid cookie; it does not fall back to the cookie. | Method and path | What it returns | |---|---| | `GET /v1/catalog/groups` | The key and name of published groups and a `content_sha256` per audience | | `GET /v1/catalog/groups/{group}` | The group's current publish: `group`, `fields`, `entries`, `seq` | | `GET /v1/catalog/groups/{group}/entries/{key}` | The same shape, only that entry | Headers: | Header | Meaning | |---|---| | `x-crx-env` | Required on a player session; with a server key the environment comes from the key | | `If-None-Match` | The `ETag` you hold; if it matches, `304` and no body | Responses carry an audience-specific `ETag`. The player `ETag` and the `content_sha256` value are computed from the **filtered** body; so changing a server-only field shows no change on the player side. A group that was never published, or does not exist, returns `404`. > **Note** > > Server-only rows are not a secret store: operators in the panel and holders of a server key can see them. Do not put passwords, keys or personal data in them. ### Table field A `table` field holds rows repeated within an entry (a weapon's level → damage table, an NPC loot list). Column kinds are `integer`, `percent`, `text`, `bool`, `select` and `reference`. The value is an array of objects that **preserves row order**; changing the order means a new publish digest. A server-only table is absent from the player body entirely. ### Reading from the game The TypeScript SDK provides a typed reader for both audiences (`player.catalog.groups`, and `CrxClient.catalog.groups` on the server; `list`, `get`, `entry`). There are `watchCatalogGroup` for watching and `formatScaled` for displaying a scaled number. The Unity SDK has no catalog; use raw REST. ## Versioning and replay safety - The read endpoints have no `Idempotency-Key` or `If-Match`; you do conditional reads with `If-None-Match`. - The publish counter (`seq`) grows with every publish; if `seq` has not changed, the content has not changed. - Group, field and entry keys do not change after creation; a field kind change is checked, and the panel lists the problems before publishing. ## Events Publishing a content group does not write an event to the outbox (only an audit record is made), so it does not produce a webhook. To catch a change in the game, poll with `ETag`: a change reaches the player at most one polling interval later; a one-minute interval is enough. ## Limits - 64 groups / 48 fields / 1000 entries; a table has at most 8 columns and 100 rows; an entry's table data 128 KiB; the server publish snapshot 1 MiB. - A text field is at most 512 characters; a select field at most 64 options. - A publish note is at most 280 characters. ## Common errors | Symptom | Cause | Fix | |---|---|---| | `404` (group) | The group was never published or does not exist | Publish the group in the panel | | `401` | No credential or an invalid `Bearer crx_…`; `x-crx-env` missing on a player session | Fix the credential and the header | | Publish rejected in the panel | A reference (`reference`) target is missing; the table or publish size is exceeded | Fix the problems the panel lists | | I changed the value but the game does not see it | The draft was not published, or the field has `server` visibility | Publish; check the visibility | | `304` | The `ETag` matched | Expected behavior; your copy is current | ## Working example Reading the current publish of the `weapons` group with an `ETag`: ```ts /** İçerik grubunu (panelde yayınlanan oyun verisi) oyuncu oturumuyla okur; ETag ile değişmediyse 304 döner. */ import type { PlayerClient } from "@ferdiunal/open-play-ts"; /** `weapons` grubunun güncel yayınındaki kayıt anahtarlarını döndürür; değişmediyse boş liste. */ export async function readWeapons(player: PlayerClient, previousEtag?: string): Promise { const result = await player.catalog.groups.get("weapons", previousEtag === undefined ? {} : { ifNoneMatch: previousEtag }); if (result.status === 304) return []; return result.value.entries.map((entry) => entry.key); } ``` ## Related pages - [Content groups as a reward source](rewards.md) · [Inventory and items](inventory-items.md) · [Characters](characters.md) · [Scenes, assets and 3D models](scenes-assets-models.md) - Game catalog integration (detailed guide, Turkish) (`docs/oyun-katalog-entegrasyonu.md`) · [TypeScript SDK](../sdk/typescript.md) --- Source: https://open-play.b4klabs.dev/docs/en/guides/game-rules.html # Game rules and automations This page describes the two parts of the panel's "Rules" screen: the **game rule values** that the game's plugin reports (for example police thresholds) and the **automations** that do work automatically when an event happens. Read it if you want to tune the game's behavior without shipping code or react automatically to an event. ## What it does - **Game rule values:** The game's plugin reports which numbers are adjustable (name, unit, lower and upper bound, step, default). An operator changes these values in the panel and **publishes** them; the new values take effect in the game shortly (in about 6 seconds at the latest). Every publish becomes a new version and you can return to old values with one click. - **Automations:** Rules of the form "when this event happens, if this condition is true, do this". A rule attaches to character events (`level_up`, `xp_added` …) and gives the character a stat, resource, flag, modifier or reward. Conditions are written as a CEL expression; in the panel a structured builder generates the CEL for you. Game rule values are a panel surface: your game server does **not** write them with a REST endpoint; the plugin reads the values on the server. Automations are managed both from the panel and with REST (they are part of the `characters` module). ## Where it lives in the panel | What you want to do | Panel page | |---|---| | An overview of the Rules screen, topics and cards | [Rules](../panel/rules.md) | | Edit, publish and preview a game rule card | [Rule card](../panel/rules-game-card.md) | | See the publish history and the diff, roll back | [Rule history](../panel/rules-history.md) | | Create, edit, enable and disable an automation | [Automation editor](../panel/rules-automation-editor.md) | > **Note** > > Panel help pages are not translated yet; the panel links above point to the Turkish source, and the panel itself is shown in Turkish. ## Game rule values ### Model - **Catalog:** Reported by the game's plugin. There are topics, cards and each card's parameters; every parameter comes with a unit (for example seconds, meters, percent) and bounds. If there is no catalog or the plugin does not report one, only Automations appear in the panel. - **Version:** The effective values are the latest version. If there has been no publish, the plugin's code defaults are used ("Kod varsayılanı", the code default). Publishing and rolling back write a new version; history is not deleted. - **Apply time:** If a parameter is `canlı` (live), it is applied shortly after the publish; if it is `yeniden başlatmada` (on restart), the old value stays until the game server restarts, and the panel shows both together as "Yayında / Bekliyor" (Published / Pending). - **Validation:** Values are validated on the server against the catalog bounds, step and cross-field constraints; an invalid field is reported per field. The plugin re-validates with the same bounds; an invalid value does not break the game, and the plugin falls back to its code defaults. - **Permission:** Reading is open to the four panel roles; publishing and rolling back only to the `super_admin` and `game_manager` roles. Publishing and rolling back require a justification of 8–500 characters and are written to the audit log. - **Concurrency:** A publish carries the version you saw (`expectedVersion`); if someone else published while you were looking, `409` is returned and you need to refresh the tab. - **Preview:** The panel runs a scenario (for example a continuous speed violation) with the plugin's real logic using today's and the proposed values and shows the event timeline; it saves nothing. - **Client warning:** If a parameter is `clientVisible`, the game client reads it from the plugin's query. If the client does not read that value yet, the panel warns you while publishing. ### Effect on the game Your game code does not read game rule values with REST. The plugin takes the values from a single snapshot at every step on the server; the values the client needs come through the plugin's own query. This behavior is plugin-specific; today only the `package-ride` plugin reports a catalog. Third-party plugins reporting a catalog and being loaded is **not supported yet**; see [Plugins: overview](../plugins/overview.md). ## Automations ### Model - **Rule:** `key` (a unique key), `trigger_type` (the trigger event), `condition` (a CEL expression), `actions` (a list of actions) and `priority`. A rule is versioned; every publish is a new version. - **Trigger:** `flag_set`, `attribute_set`, `resource_changed`, `modifier_added`, `modifiers_removed`, `xp_added`, `level_up`, `ability_unlocked`, `event_emitted`, `character_rolled_back`. An unknown type is rejected. The panel builder does not offer the `character_rolled_back` option. - **Actions:** `add_stat` (`key`, `delta`), `add_resource` (`key`, `delta`), `set_flag` (`key`, `value`), `add_modifier`, `remove_modifier` and `grant_reward` (`what`: a fixed amount of money `{ "type": "currency", "key", "amount" }` or a reward table `{ "type": "table", "key" }`). - **CEL context:** The condition has the variables `character`, `event`, `now` and `seed`; the functions `has_ability`, `base_stat` and `clamp` can be used. The context carries time and seed, a rule cannot read its own clock; there is no `now()` or `random()`. Rules are compiled at registration, not at run time. - **Budget (fixed):** In one command chain at most 50 rules are evaluated and 200 actions are produced, the chain depth cannot exceed 5, and a condition expression cannot exceed 500 nodes. Exceeding it is **fail-closed**: the command is rejected with `429` and which limit was exceeded is stated. Two rules that trigger each other are cut off by this limit. ### Panel builder The builder produces CEL from a "When → Conditions → Do" structure: condition rows (field, operator, value) are combined with **all** (`&&`) or **any** (`||`); at most 20 conditions and 10 actions can be added. Text values are escaped; line breaks and control characters are rejected. When the builder is used, the server recompiles the rule (the CEL text the client sends is not read for a builder rule). A new rule starts **disabled**; the "enable right away" option is unchecked. Before publishing you can do a **dry run** with a sample event; it is not required. ### Calls your game makes Rule endpoints are called with an **API key**: ``` Authorization: Bearer crx_live__ ``` | Method and path | What it does | |---|---| | `POST /v1/rules` | Saves a rule (`201`) | | `GET /v1/rules` | Lists rules (`cursor`, `limit`) | | `GET /v1/rules/{key}` | Reads the rule's current version | | `POST /v1/rules/{key}/enable`, `POST /v1/rules/{key}/disable` | Enables / disables the rule (`204`) | | `DELETE /v1/rules/{key}` | Deletes the rule (`204`) | | `POST /v1/rules/dry-run` | Tries a rule against a character without writing | > **Warning** > > **Known limitation:** The endpoints that write rules do not look at the key type; a valid **client key** can also save or delete a rule. If you embed a client key in the game, manage rules from your own server and do not call these endpoints from the client. The TypeScript SDK exposes these endpoints in a typed way: `createRule`, `listRules`, `getRule`, `deleteRule`, `enableRule`, `disableRule`, `dryRunRule` (see [TypeScript SDK](../sdk/typescript.md)). These endpoints take raw CEL text; the panel builder's structured model exists only on the panel surface. #### Saving a rule ``` POST /v1/rules Authorization: Bearer crx_live__ Content-Type: application/json { "key": "lvl10_reward", "trigger_type": "level_up", "condition": "event.to == 10", "actions": [ { "type": "grant_reward", "what": { "type": "currency", "key": "gold", "amount": 1000 } }, { "type": "set_flag", "key": "reached_level_10", "value": true } ], "priority": 0 } ``` ```json { "key": "lvl10_reward", "current_version": 1, "enabled": true, "created_at": "2026-11-03T09:00:00Z" } ``` A rule saved with REST starts **enabled right away** (`enabled: true`); unlike the panel builder, to start it disabled call `POST /v1/rules/{key}/disable` after saving. `event.to` is the new level of the level-up event; event fields vary by trigger. The registration is rejected if the condition cannot be compiled, the trigger is unknown or a budget limit is exceeded. #### Dry run ``` POST /v1/rules/dry-run Authorization: Bearer crx_live__ Content-Type: application/json { "condition": "character.progression.level >= 10", "actions": [ { "type": "set_flag", "key": "veteran", "value": true } ], "character_id": "0192f0f2-…" } ``` ```json { "condition_result": true, "node_cost": 7, "would_produce_events": [ { "...": "…" } ], "would_grant": [], "warnings": [] } ``` A dry run writes no events and grants no rewards. ## Versioning and replay safety - Publishing and rolling back game rules: done by the panel with `Idempotency-Key` and `expectedVersion`; the same key returns the same version again and writes a single audit record. - Automation publishing: every publish is a new version; old versions are not deleted, and "roll back" rewrites the old version as a new version. - The REST rule endpoints have no `Idempotency-Key` or `If-Match`. ## Events The actions a rule runs produce normal character events (`flag_set`, `xp_added` …); these can be delivered through [webhooks](webhooks.md). A reward produced by a rule also produces the `reward_granted` event. ## Limits - In a command chain: 50 rules, 200 actions, depth 5, 500 nodes per condition. - In the panel builder: at most 20 conditions and 10 actions. - A game rule justification is 8–500 characters. - Game rule integer values are limited to ±(2^53−1) in the panel. ## Common errors | Symptom | Cause | Fix | |---|---|---| | `429` (command) | The rule chain exceeded the budget | Simplify the rules that trigger each other | | `400`/`422` on registration | The CEL could not be compiled, unknown trigger or field | Try the condition with a dry run first | | `409` in the panel (publish) | Someone else published while you were looking | Refresh the tab and publish again | | Field error in the panel (`422`) | The value is outside the bound, step or constraint | Respect the bound the field shows | | I published the value but the game behaves the old way | The parameter is applied `yeniden başlatmada` (on restart), or the 6 seconds have not passed | Look at the panel's "Bekliyor" (Pending) indicator | ## Working example The two requests above (the `lvl10_reward` registration and the dry run) together form a complete flow: first try the condition with a dry run, then save it with `POST /v1/rules`, then make a character level up with an `add_xp` command and verify that the rule ran, following the flow on the [Characters](characters.md) page. ## Related pages - [Characters](characters.md) · [Rewards](rewards.md) · [Webhooks](webhooks.md) · [Notifications](notifications.md) - [Plugins: overview](../plugins/overview.md) · [TypeScript SDK](../sdk/typescript.md) --- Source: https://open-play.b4klabs.dev/docs/en/guides/player-data.html # 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](../panel/game-settings-modules.md) 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](../panel/players.md) › 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: ""` — 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: `. **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](../auth/sessions-and-csrf.md)). 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: ""`) | | `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__`; 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 ``` ```json { "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: If-None-Match: * Content-Type: application/json { "value": { "level": 4, "stars": 17, "playTimeSeconds": 913.5 } } ``` ```json { "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": }`; 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 ``` ```json { "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](../sdk/typescript.md). ## Working example: save, merge on conflict ```ts /** 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 { 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`). ## Related pages - [Guest sign-in](../auth/guest.md) - [Sessions and CSRF](../auth/sessions-and-csrf.md) - [TypeScript SDK](../sdk/typescript.md) --- Source: https://open-play.b4klabs.dev/docs/en/realtime/overview.html # Realtime overview This page describes the three WebSockets Open Play offers and when to use each. For in-game state (movement, room, scene) you use the plugin realtime socket, and for notifications and chat you use two separate sockets; all of them have different identity and close rules. ## Three sockets | Socket | Path | What for | Who speaks | Credential | |---|---|---|---|---| | Realtime (plugin) | `/v1/realtime/ws` | Game state: live input, state snapshot, durable commands and events | Two-way | Single-use ticket | | Notification | `/v1/ws` | Pushing a notification to the player live | Server → player only | Player session (cookie or Bearer) | | Chat | `/v1/chat/ws` | Channel chat (subscribe, send a message) | Two-way | Player session (cookie or Bearer) | Rule: all three are **player** sockets. A socket is not opened with a server key; for server-to-server work use the [REST endpoints](../reference/endpoints.md). ## Which one should I use | Need | Choice | |---|---| | Publishing the live state of a vehicle, a player, or a room and receiving player input in order | The realtime socket, [protocol](protocol.md) | | Getting the result of a command durably and replay-safely (reward, delivery) | The realtime `Command` frame or the HTTP command endpoint ([calling plugins](../plugins/calling-plugins.md)) | | Showing the player a "new notification" instantly | `/v1/ws`; the durable source is always the `GET /v1/notifications` inbox ([notifications](../guides/notifications.md)) | | Channel chat | `/v1/chat/ws` or REST ([chat](../guides/chat.md)) | | Leaderboard, store, inventory | REST; there is no socket for these | > **Note** > > The TypeScript SDK manages the realtime socket for you: getting the ticket, authentication, reconnecting, and snapshot recovery are included. You need to write the protocol by hand only for a client without an SDK (for example the Unity native socket). For SDK usage, see the [TypeScript SDK](../sdk/typescript.md) page. ## Connection requirements (shared) 1. **HTTPS and an allowed origin.** Your browser game calls the API from the same origin through a reverse proxy or, if its domain is registered in an environment, directly at the Open Play address (CORS). Sockets open to the same address: `wss:///` or `wss:///`; the handshake checks `Origin` per environment (CORS does not change this). Details: [architecture and hosting](../getting-started/architecture-and-hosting.md). 2. **Environment header.** A player identity is always bound to an environment and requires the `x-crx-env: ` header. On the realtime socket the header is given with the HTTP ticket request; the WebSocket upgrade carries no header, the ticket knows the environment. `/v1/ws` and `/v1/chat/ws`, however, require the header on the upgrade request itself. The browser's WebSocket API cannot add custom headers; therefore **these two sockets cannot be opened directly from the browser** (a known limitation). In browser games, poll the inbox with REST for notifications and use the REST endpoints for chat; non-browser clients can send the header themselves. 3. **No token in the query string.** No socket accepts a session token in the URL; `?token=` is not supported so that it does not leak into access logs. A realtime upgrade that carries a query string is rejected. 4. **The Origin header is required** (realtime): both the ticket request and the upgrade must carry `Origin`, and it must be one of the environment's allowed origins. The browser adds it itself; if it is a non-browser client, you must send an allowed `Origin` value yourself. 5. **Module gate.** The realtime socket depends on the `plugins` module, `/v1/ws` on `notifications`, and `/v1/chat/ws` on `chat`. If the module is disabled, the upgrade returns `404` and `module_disabled`; an open socket is closed with `1008` (reason `module_disabled`) if the module is disabled later, and with `1013` if the gate state cannot be verified. You manage modules from the panel: [Game settings: modules](../panel/game-settings-modules.md). ## Notification socket (`/v1/ws`) - It flows only from the server to the player. Frames coming from the client are ignored. - The incoming frame is a JSON object: `{"type":"notification","notification_id":"…","title":"…","body":"…","data":{…},"channel":"in_game","sent_at_ms":1760000000000}`. Field names are `snake_case`. - A live frame is delivered **at most once**. The durable source is the inbox; the same notification can arrive both as a frame and through an inbox query, so deduplicate by `notification_id`. - The connection counts as a presence (online) signal. ## Chat socket (`/v1/chat/ws`) Frames are distinguished by the `type` field (with `camelCase` values); the other field names are `snake_case`, and an unknown field is rejected. Example: `{"type":"send","channel_id":"…","client_message_id":"retry-1","body":"hello"}`. | Direction | `type` | Fields | Meaning | |---|---|---|---| | Client → server | `subscribe` | `channel_id` | Channel subscription (one channel at a time) | | Client → server | `unsubscribe` | — | Drops the subscription | | Client → server | `send` | `channel_id`, `client_message_id`, `body` | Replay-safe message sending, the same as REST | | Client → server | `ping` | `nonce` (number) | Liveness probe | | Server → client | `subscribed` / `unsubscribed` | `channel_id` (only `subscribed`) | Accepted | | Server → client | `message` | `message`, `duplicate` | Channel message; if `duplicate: true`, the `client_message_id` had been processed before | | Server → client | `pong` | `nonce` | The reply to `ping` | | Server → client | `error` | `code` | `invalid_frame`, `invalid_payload`, `forbidden_channel`, `unavailable`, `message_rejected`, `module_disabled` when the module is being disabled | Message sending limit: 20 messages per 10 seconds per player; you see an overage as `message_rejected` or, in REST, as `429` ([rate limits](../reference/rate-limits.md)). For detailed usage, see the [chat guide](../guides/chat.md). ## Next step - To see the plugin realtime protocol from start to finish, see the [protocol](protocol.md). - To see what happens when the connection drops, the limits, and idle close, see [reconnect and limits](reconnect-and-limits.md). --- Source: https://open-play.b4klabs.dev/docs/en/realtime/protocol.html # Realtime protocol This page describes the wire protocol of the `/v1/realtime/ws` socket: getting a ticket, connecting, authentication, and the fields of every client and server frame. If you use the TypeScript SDK you do not write this protocol by hand; but read from here to understand the behavior, write another client, or troubleshoot. > **Note** > > The protocol version is `1`. Every frame carries `protocolVersion: 1`; any other value gets an `UnsupportedProtocol` error ([versioning](../reference/versioning.md)). ## General rules - Every frame is a JSON object sent as **a single WebSocket text message**. Binary messages are not accepted. - The `type` field determines the frame type and is `PascalCase` (`Authenticate`, `Join`, …). The other field names are `camelCase`. A frame that contains an unknown field is rejected. - Large integers (**sequence numbers, epoch, revision, version**) are **decimal text**, not JSON numbers: `"sessionEpoch": "7"`. Canonical form: non-empty, no leading zero, digits only, non-negative, within the `i64` range. Details: [numbers and encoding](../reference/numbers-and-encoding.md). `protocolVersion` and `schemaVersion` are small integers and are sent as JSON numbers. - `requestId` (UUID) is the correlation ID that the client generates for **every attempt**; the server returns the response and the error with the same ID. - Before authentication only `Authenticate` is valid, and afterwards frames other than `Authenticate` are valid; a frame in the wrong phase gets `UnexpectedFrame`. ## Connection flow ```mermaid sequenceDiagram participant C as "Client" participant H as "HTTP (REST)" participant S as "WebSocket /v1/realtime/ws" C->>H: "POST /v1/realtime/tickets" H-->>C: "200 ticket, expiresAt" C->>S: "Upgrade (Sec-WebSocket-Protocol openplay.realtime.v1)" C->>S: "Authenticate(protocolVersion 1, ticket)" S-->>C: "Ready(connectionId, playerId, sessionEpoch, authorityGeneration)" C->>S: "Join(pluginId, moduleId, target)" S-->>C: "Joined(subscriptionId)" S-->>C: "Snapshot or DurableRecovery" C->>S: "Input or Command" S-->>C: "Ack or CommandResult" ``` ### 1. Get a ticket ```http POST /v1/realtime/tickets HTTP/1.1 Host: game.example.com Origin: https://game.example.com x-crx-env: 0192a3b4-0000-7000-8000-000000000001 Cookie: __Secure-crx.session=; __Secure-crx.csrf= x-csrf-token: ``` ```http HTTP/1.1 200 OK Cache-Control: no-store Content-Type: application/json {"ticket":"tkt_AbC123-xyz","expiresAt":"2026-11-03T10:15:30+00:00","protocol":"openplay.realtime.v1"} ``` | Header | Required | Note | |---|---|---| | `x-crx-env` | yes | The player's environment. Must match the session's environment. | | `Origin` | yes | Must be one of the environment's allowed origins; the ticket is bound to this origin. | | `Cookie` or `Authorization: Bearer ` | yes | If a cookie is present, cookie mode is always chosen; with a broken cookie it does not fall back to Bearer. | | `x-csrf-token` + the `__Secure-crx.csrf` cookie | only in cookie mode | Double submit. A new CSRF value is returned on every successful request; store the `x-csrf-token` header and the `Set-Cookie` in the response ([sessions and CSRF](../auth/sessions-and-csrf.md)). | - The ticket is **single-use**, valid for **30 seconds**, and bound to the `Origin`. Get it right before opening the socket; do not hold on to it. - On this endpoint, identity, CSRF, and Origin errors get the same response: `403` and the plain text `permission denied`. If the module is disabled (`plugins`), `404` and `{"code":"module_disabled",…}`. ### 2. Open the socket - Address: `wss:///v1/realtime/ws`. There **must be no query string**; if there is, the upgrade is rejected. - `Sec-WebSocket-Protocol: openplay.realtime.v1` must be requested; the server selects it. If it is not requested, the upgrade is rejected. - `Origin` is required and must be one of the environment's allowed origins or in the global allowed list. The browser sends it itself. - An upgrade rejection is `401` (plain text `unauthorized`); if the module is disabled, `404`. - There are per-address and per-player connection limits ([limits](reconnect-and-limits.md)). ### 3. Send `Authenticate` The first frame must arrive within **5 seconds** after the socket opens; if it does not, the server writes an `AuthenticationFailed` error and closes. A frame in the authentication phase can be at most **4 KiB**. ```json {"type":"Authenticate","protocolVersion":1,"ticket":"tkt_AbC123-xyz"} ``` The server consumes the ticket (verifying the origin and the subprotocol), re-verifies the session, and replies: ```json {"type":"Ready","protocolVersion":1,"connectionId":"0192a3b4-0000-7000-8000-0000000000a1","playerId":"0192a3b4-0000-7000-8000-0000000000b2","sessionEpoch":"7","authorityGeneration":"42"} ``` | Field | Meaning | |---|---| | `connectionId` | The server's ID for this socket. | | `playerId` | The authenticated player. | | `sessionEpoch` | This socket's write epoch. It is given back unchanged in `Input` frames. If a newer socket of the same player starts writing to the same resource, the old socket's inputs get `Stale`. | | `authorityGeneration` | The environment's authority generation; it is for server-side limiting, the client only stores it. | A ticket that is invalid, used, expired, or belongs to another origin is rejected with `AuthenticationFailed`; the server closes the socket. Get a new ticket and reconnect. ## Client frames They can be sent after `Ready`. The frame size is at most **16 KiB**; a frame that exceeds it gets `PayloadTooLarge`. ### `Join`: join a subscription ```json {"type":"Join","protocolVersion":1,"requestId":"0192a3b4-0000-7000-8000-000000000010","pluginId":"package-ride","moduleId":"package-ride.street","target":{"kind":"Scene","sceneId":"0192a3b4-0000-7000-8000-000000000031"}} ``` | Field | Type | Meaning | |---|---|---| | `requestId` | UUID | The attempt ID. | | `pluginId` | text | The plugin that owns the module ([plugins](../plugins/overview.md)). | | `moduleId` | text | The realtime module the plugin defines; it is listed in the `modules` section of the plugin's schema document. | | `target.kind` | text | `PlayerSelf`, `Room`, `Scene`, or `InterestSet`. | | `target.roomId` / `sceneId` / `interestSetId` | UUID | The target's ID (according to `kind`; `PlayerSelf` carries no ID). | | `cursor` | UUID, optional | An opaque resume cursor from a previous `DurableRecovery`. | The server authorizes the target; a raw Redis key or topic name is **not given**. Success: `Joined`. After that, a `Snapshot` or `DurableRecovery` arrives depending on the module's type. ### `Leave`: leave a subscription ```json {"type":"Leave","protocolVersion":1,"requestId":"…","subscriptionId":"…"} ``` The response is `Left`; a repeated `Leave` is harmless. ### `Input`: transient input Player input (movement, steering) in server-authoritative modules. Loss is not acceptable, a sequence number is required. ```json {"type":"Input","protocolVersion":1,"requestId":"…","pluginId":"arena","moduleId":"arena.movement","messageType":"arena.movement.input","schemaVersion":1,"subscriptionId":"…","sessionEpoch":"7","inputSeq":"42","payload":{"delta":1}} ``` | Field | Meaning | |---|---| | `messageType`, `schemaVersion` | Must match exactly the input definition in the module's schema document. | | `subscriptionId` | The subscription from `Joined`. | | `sessionEpoch` | The value in `Ready`. | | `inputSeq` | An increasing sequence number (decimal text). If the same number and the same content are sent again you get `Replayed`; the same number with different content gets `Mismatch`. | | `payload` | An object that conforms to the plugin's input schema. Its encoded size is at most 4 KiB and the module's `maxBytes` value; exceeding it gets `InvalidPayload`. | The input rate is defined by the `ratePerSecond` and `burst` values in the module's schema document; a general per-connection frame budget is applied separately ([limits](reconnect-and-limits.md)). ### `Command`: durable command A command that writes durably with a database transaction (reward, delivery); the result returns only after the transaction is committed. It is subject to the same rules as the HTTP command endpoint ([calling plugins](../plugins/calling-plugins.md)). ```json {"type":"Command","protocolVersion":1,"requestId":"…","pluginId":"package-ride","moduleId":null,"messageType":"package-ride.progression-missions.claim.input","schemaVersion":1,"idempotencyKey":"claim-7f3a","expectedVersion":"12","payload":{"missionId":"daily-1"}} ``` | Field | Meaning | |---|---| | `idempotencyKey` | The required logical replay key. The same key and the same content return the same result ([idempotency](../reference/idempotency-and-concurrency.md)). | | `expectedVersion` | Optional optimistic version (decimal text). A mismatch is an error. | | `moduleId` | The module context for room-scoped commands; otherwise `null`. | | `payload` | Conforms to the plugin's command schema; its encoded size is at most 16 KiB, exceeding it gets `InvalidPayload`. | ### `Ping` ```json {"type":"Ping","protocolVersion":1,"nonce":"a1"} ``` The response is `{"type":"Pong","nonce":"a1"}`. `nonce` is optional. The application-level `Ping` measures connection liveness; it **does not count as player activity** and does not postpone idle close ([idle close](reconnect-and-limits.md)). ## Server frames | `type` | When | Fields | |---|---|---| | `Ready` | `Authenticate` succeeded | `protocolVersion`, `connectionId`, `playerId`, `sessionEpoch`, `authorityGeneration` | | `Joined` | `Join` was authorized | `requestId`, `subscriptionId`, `target` | | `Left` | `Leave` was processed | `requestId`, `subscriptionId` | | `CommandResult` | `Command` was committed | `requestId`, `messageType`, `schemaVersion`, `payload` | | `Ack` | `Input` was processed | `requestId`, `inputSeq`, `status`, `revision` (optional) | | `Snapshot` | Transient state broadcast | `subscriptionId`, `resourceId`, `messageType`, `schemaVersion`, `streamEpoch`, `revision`, `payload` | | `DurableRecovery` | Recovery barrier for a durable stream | `subscriptionId`, `cursor`, `barrierSequence`, `coveredEventIds`, `snapshot` (may be empty) | | `DurableEvent` | A durable, ordered plugin event | `subscriptionId`, `streamId`, `sequence`, `eventId`, `messageType`, `schemaVersion`, `cursor`, `payload` | | `Pong` | The reply to `Ping` | `nonce` | | `Error` | Error | `requestId` (if any), `code`, `retryable` | ### `Ack` statuses | `status` | Meaning | What to do | |---|---|---| | `Applied` | The input advanced the state. `revision` is the new revision. | — | | `Replayed` | The same sequence number and content had already been processed. | Ignore it; a safe replay. | | `Stale` | The sequence number is behind the current writer or a newer socket took over. | Drop this input; if there is a new socket, this socket is no longer the writer. | | `Mismatch` | The same sequence number arrived with different content. | It is a client bug; do not regenerate the sequence number, do not repeat. | | `ResyncRequired` | A sequence gap or an unrecoverable state. | `Join` again for a fresh snapshot (without `cursor`). | `revision` may be absent other than for `Applied` and `Replayed`. When the field is absent, `null` is not sent; the field is simply not there. ### `Snapshot` and `DurableRecovery` - A `Snapshot` is **transient** state: `streamEpoch` (increases when the server cache restarts) and `revision` (monotonic) are ordered together. Within the same `streamEpoch`, an older `revision` must not overwrite the new state. If `streamEpoch` has changed, a new stream has started; do not compare with the old `revision`. - `DurableRecovery` is the **baseline** in a durable stream: `cursor` is the new cursor tied to this point, `barrierSequence` is the stream start covered by the snapshot and the replay, and `coveredEventIds` are the event IDs that the snapshot already contains (do not apply them separately). `snapshot` is filled only on a fresh `Join`; it is absent when resuming with `cursor`. - `DurableEvent`s arrive in `sequence` order; `eventId` is the end-to-end deduplication key, and `cursor` is the new cursor advanced up to that event. Store the cursor: when reconnecting you provide it as `Join.cursor` ([reconnect](reconnect-and-limits.md)). ## Error frame ```json {"type":"Error","requestId":"0192a3b4-0000-7000-8000-000000000010","code":"InvalidPayload","retryable":false} ``` The error frame **carries no reason text**: only the code and `retryable`. Even a plugin rejection (for example a business rule) appears as `InvalidPayload`; the reason is not given to the client over the wire. | `code` | When | `retryable` | Socket | |---|---|---|---| | `UnexpectedFrame` | A frame that does not fit its phase (for example `Join` before authentication, or a second `Authenticate`) | no | closes | | `UnsupportedProtocol` | `protocolVersion` is not 1 | no | closes | | `InvalidPayload` | Broken JSON, unknown field, a payload that does not conform to the schema, a plugin/business rule rejection, no such resource, a version conflict, the module is disabled | no | **stays open** on a per-frame error; closes on a parse error | | `AuthenticationFailed` | The ticket is invalid/used/expired, the 5 s timeout, the session was revoked | no | closes | | `PermissionDenied` | No authority for the target, module, or operation; a banned account | no | stays open | | `PayloadTooLarge` | Exceeded the frame budget (4 KiB authentication, 16 KiB afterwards) | no | closes | | `CapacityExceeded` | The rate or capacity limit is full | yes at the frame budget and the rate limit; no at the per-player/per-address socket count limit | closes at the frame budget and the socket count limit | | `Unavailable` | A dependency is temporarily unavailable or the placement is not ready | usually yes (no for the `IdempotencyExpired` case) | depends | | `ResyncRequired` | The cursor is missing, expired, mismatched, has a gap, or exceeded the replay budget | — | stays open | > **Warning** > > Do not infer whether the socket will close from `retryable` alone. After an `Error` frame, the client must learn whether the socket is still open **from the connection state** (the close event). On long-lived sockets, per-frame errors (`InvalidPayload`, `PermissionDenied`) do not drop the connection. ### Close codes | Code | Reason | Meaning | |---|---|---| | `4100` | `idle_timeout` | No player activity arrived; idle close ([details](reconnect-and-limits.md)). There is **no** typed `Error` frame. | | `1008` | `module_disabled` | The `plugins` module was disabled. | | `1013` | `unavailable` | The module gate could not be verified; retry later. | ## Example: connecting by hand (summary) In the browser, use the SDK; the skeleton below shows the protocol order. ```ts const res = await fetch("/v1/realtime/tickets", { method: "POST", credentials: "include", headers: { "x-crx-env": environmentId, "x-csrf-token": csrfToken }, }); const { ticket } = await res.json(); const ws = new WebSocket(`wss://${location.host}/v1/realtime/ws`, ["openplay.realtime.v1"]); ws.onopen = () => ws.send(JSON.stringify({ type: "Authenticate", protocolVersion: 1, ticket })); ws.onmessage = (event) => { const frame = JSON.parse(event.data as string); if (frame.type === "Ready") { // Send Join, store frame.sessionEpoch for Input frames. } }; ``` ## Next step For how to resume with the cursor and epoch when the connection drops, limits, and compression, see [reconnect and limits](reconnect-and-limits.md). --- Source: https://open-play.b4klabs.dev/docs/en/realtime/reconnect-and-limits.html # Reconnect and limits This page describes how state is recovered when the realtime socket drops, what to do when `ResyncRequired` arrives, the idle close (`4100`) behavior, and the limits the server applies. If you are writing a game that is played on a mobile or unstable network, read this page from start to finish. ## Recovery after a drop A socket can always drop (network, deployment, load balancer). In Open Play, recovery is done by **re-joining with a new socket**; the old socket's state is not reused on the server. 1. **Get a new ticket.** Every connection asks for a new ticket (single-use, 30 s); the old ticket cannot be reused ([protocol](protocol.md)). 2. **`Authenticate`, `Ready`.** A new `sessionEpoch` arrives. When the new socket starts writing to the same resource, the player's old socket is no longer the writer: the old socket's inputs get `Ack: Stale`. 3. **`Join` again for each subscription.** - **In durable streams:** give the last `cursor` you received as `Join.cursor`. The server replays `DurableEvent`s from where you left off and sends a new `DurableRecovery` barrier (`snapshot` is empty). - **In ephemeral state:** there is no `cursor`; `Join` brings a fresh `Snapshot`. `Input`s that were sent but did not get an `Ack` are **not replayed**: their outcome is uncertain, and your game logic must continue according to the latest `Snapshot`. - **In durable commands (`Command`):** the outcome of a command that dropped before an `Ack` arrived is uncertain. Resend with the same `idempotencyKey` or query the result ([calling plugins](../plugins/calling-plugins.md)); do not generate a new key. 4. **If `ResyncRequired` arrives** (the cursor is missing, expired, belongs to another player, has a gap, or exceeded the replay budget), discard the cursor and `Join` again **without a `cursor`**; this brings a fresh snapshot. Do not insist on the same cursor. ### Cursor rules - It is opaque and **bound to the player**; another player's cursor cannot be used. Do not parse its contents. - Every `DurableRecovery` and every `DurableEvent` carries a new cursor; store **the latest one**. - At most **256** durable events are replayed in a single recovery; anything beyond that is turned into a fresh snapshot with `ResyncRequired`. - The cursor and delivery retention window is **24 hours**; a cursor older than that gets `ResyncRequired`. - The events in `coveredEventIds` are already in the fresh snapshot; do not apply them separately. ## What the TypeScript SDK does The SDK runs this flow for you (`RealtimeClient`, `client.realtime`): | Behavior | Default | Setting | |---|---|---| | Automatic reconnect | on | `autoReconnect` | | Attempt delay | `min(reconnectBaseMs · 2^(n−1), 30 000)` ms + `[0, reconnectBaseMs)` jitter | `reconnectBaseMs`: 1000 (between 100 and 10,000) | | Maximum attempts | 8 | `maxReconnectAttempts`: between 0 and 20 | | Authentication timeout | 5 s | `authTimeoutMs` (at most 5 s) | | Application `Ping` interval | 15 s | `heartbeatMs`: 10 ms to 60 s | | Durable command timeout | 15 s | `commandTimeoutMs`: 1 ms to 120 s | | Window of inputs awaiting a reply | 4 | `maxPendingInputs`: 1 to 8 | | Frame size | 16 KiB | `maxFrameBytes` (at most 16 KiB) | The state machine goes through the values `idle`, `ticketing`, `connecting`, `authenticating`, `ready`, `recovering`, `live`, `backoff`, `reauthRequired`, `upgradeRequired`, `idleClosed`, and `closed`. The important ones: - `recovering` / `backoff`: the SDK is reconnecting; the subscriptions' cursors are preserved. - `reauthRequired`: the ticket or the identity was rejected (`AuthenticationFailed`, 401/403). Re-establish the session; the SDK does not enter an endless loop on its own. - `upgradeRequired`: the server did not recognize the protocol version or the frame (`UnsupportedProtocol`, 426). Update the client. - If the server sends `PayloadTooLarge` or `CapacityExceeded`, the SDK surfaces it as a backpressure error; it does not change the connection state. > **Note** > > Incomplete inputs are rejected as "uncertain outcome" when the socket drops, and are **not automatically resent** after reconnecting. `best-effort` signal messages are not repeated either. ## Idle close (`4100`) The server closes a socket that has a subscription that **accepts input** (interactive) and sends no player activity for a certain time. - The close code is `4100`, the reason `idle_timeout`. There is no typed `Error` frame; only the WebSocket close frame. - The duration is **5 minutes** by default and can be changed or turned off by the operator. Because the check interval is `min(duration/4, 15 s)`, the close arrives at most that much later than the threshold. - In the menu or before the game (when there is no subscription that accepts input), the counter does not run. - What counts as activity is the plugin's definition: an input frame usually counts; the application `Ping`, `Authenticate`, and the protocol-level ping do not. If your game will stay connected on a paused screen too, send the keep-alive input that the plugin defines; sending a keep-alive input on screens such as pause or garage keeps the player from being counted as AFK. - An open socket does **not keep the session alive**: the session lifetime is extended only as the player makes REST requests ([security](../operations/security.md)); the server re-verifies the session on the socket every 25 seconds. - Do not treat this close as an error: show the player "Continue", and on confirmation get a new ticket and reconnect; thanks to the cursors the subscriptions are recovered. If you provide the `onIdleClose` callback in the SDK, the client moves to the `idleClosed` state and **does not reconnect on its own**; call `connect()` on the user's confirmation. If you do not provide the callback, the close is handled like an ordinary drop and the SDK reconnects itself. ```ts import { createPlayerClient, type PlayerClient } from "@ferdiunal/open-play-ts"; /** * Sunucu boşta kapanışında (`4100`, `idle_timeout`) SDK kendiliğinden yeniden bağlanmaz; oyuncuya "Devam et" * gösterin ve onayında `connect()` çağırın. Abonelikler imleçleriyle kurtarılır. */ export function createClientWithIdlePrompt( baseUrl: string, environmentId: string, showContinueButton: (onContinue: () => void) => void, ): PlayerClient { const client: PlayerClient = createPlayerClient({ baseUrl, environmentId, realtime: { reconnectBaseMs: 250, // ilk yeniden bağlanma denemesi 250 ms (varsayılan 1000) onIdleClose: () => showContinueButton(() => void client.realtime.connect()), }, }); return client; } ``` ## Limits | Limit | Value | When exceeded | |---|---|---| | Ticket lifetime | 30 s, single use, bound to `Origin` | `AuthenticationFailed` | | `Authenticate` wait time | 5 s | `AuthenticationFailed`, the socket closes | | Authentication-phase frame | 4 KiB | `PayloadTooLarge`, closes | | Frame (after authentication) | 16 KiB | `PayloadTooLarge`, closes | | Transport-layer message | 64 KiB | between 16 KiB and 64 KiB gets a typed error; above 64 KiB it drops with a socket error without a typed error | | `Input` payload | at most 4 KiB and the module's `maxBytes` value | `InvalidPayload` | | `Command` payload | at most 16 KiB | `InvalidPayload` | | Per-connection frame rate | 40 frames/s average, a 4 s (160-frame) burst allowance | `CapacityExceeded` (`retryable: true`) and the socket closes | | Open sockets per player | 4 | `CapacityExceeded` (`retryable: false`), the socket closes | | Open sockets per address | 8 | `CapacityExceeded` (`retryable: false`), the socket closes (only counted if the operator puts it in observation mode) | | New connections per address | 30/minute | the upgrade is rejected | | Subscriptions per socket | 32 (server default) | `Join` is rejected | | Session re-verification | every 25 s | `AuthenticationFailed` and close if the session was revoked | | Module gate re-check | every 2 s | close with `1008` / `1013` | | Events replayed in recovery | 256 | `ResyncRequired` | | Cursor retention | 24 hours | `ResyncRequired` | | Idle close | 5 min (default) | `4100` `idle_timeout` | Notes: - The frame rate budget is not limited to `Input`; **every text frame received** and the protocol-level `Ping` count as a frame (`Pong` does not). An honest client sends roughly 30 to 35 frames/s; stay under 40/s and do not send frames that accumulated after a pause in a batch. - The numbers are server defaults; the operator can change some of them (idle duration, connection limits, frame budget) with the environment configuration. If you use a hosted Open Play, ask your provider for the values. - The `Input` rate is also defined by the `ratePerSecond` and `burst` values in the module's schema document. ## Compression - The server supports **permessage-deflate** (RFC 7692) compression, and it takes effect **only if the client offers it**. Browsers offer it on their own; you do not need to do anything separately. It gives a noticeable reduction on similar JSON frames. - The operator can turn compression off; if it is off, the socket works without compression and the protocol does not change. - There is no compression yet in the Unity native WebSocket client; this is a known limitation. In WebGL the browser takes it on ([Unity SDK](../sdk/unity.md)). ## Server-side liveness probe The server sends a protocol-level WebSocket `Ping` about every 5 seconds to measure connection latency. Browsers and standard client libraries answer `Pong` on their own; if you write a WebSocket by hand, do not forget the `Pong` reply. This probe does not count as player activity. ## Troubleshooting | Symptom | Likely cause | Fix | |---|---|---| | An immediate `AuthenticationFailed` on every connect | The ticket was reused or 30 s passed; `Origin` differs from the ticket request | Get a new ticket for every connection; connect from the origin you obtained the ticket from | | Closing with `CapacityExceeded` | Frame rate or connection limit | Lower the frame send rate; close old tabs (4 sockets per player) | | A `ResyncRequired` loop | Insisting on an old cursor | Discard the cursor, `Join` without `cursor` | | A drop after 5 minutes | Idle close (`4100`) | Build a "Continue" flow or send a keep-alive input | | Upgrade `401` | There is a query string, the subprotocol was not requested, or `Origin` is not allowed | [Protocol: open the socket](protocol.md) | For more, see [troubleshooting](../operations/troubleshooting.md). --- Source: https://open-play.b4klabs.dev/docs/en/plugins/overview.html # Plugins overview This page explains the **plugin** concept in Open Play: packages that offer game-specific commands, queries, and realtime modules without adding a game name to the core. Here you see how you connect to a game's logic beyond the core modules (character, economy, store…) and what is not supported today. ## What is a plugin A plugin is a package that runs inside the Open Play server and defines these three surfaces: | Surface | What it gives | How it is called | |---|---|---| | **Command** | A state-changing, durable, replay-safe operation (for example claim a reward, complete a delivery) | HTTP `POST` or the realtime `Command` frame: [calling plugins](calling-plugins.md) | | **Query** | A read-only read (for example the active delivery, the catalog) | HTTP `GET` or `POST` | | **Realtime module** | Live input and state broadcast (movement, room, scene) | `/v1/realtime/ws`: [realtime protocol](../realtime/protocol.md) | Every plugin publishes a **schema document** (artifact): it describes which commands, queries, and modules exist with which input and output schemas, which identity can call them, and the retry rule. The SDKs' typed bindings are generated from this document: [bindings](bindings.md). Commands are committed in a single database transaction: the command's effect, its result, its event, and the replay record are written together or not at all. Realtime transient state, on the other hand, is kept only for speed and is not the authority for durable outcomes such as payment, XP, items, or rewards. ## Today's state: only trusted, compiled plugins > **Warning** > > **Loading third-party plugins is not available yet.** Plugins are code (Rust) compiled into the Open Play server itself and are considered trusted code. As a game developer you cannot upload your own plugin from the panel or the API; sandboxed execution, resource metering, and signed distribution are a future design and do not work today. You connect your own game logic today in these ways: - **Rules** (the Rules screen in the panel): automations bound to character events. See [game rules](../guides/game-rules.md). - **Webhooks**: Open Play notifies your own server of events. See the [webhooks guide](../guides/webhooks.md). - **Your own server + a server key**: run your own logic on your own server and call the core REST endpoints. See the [endpoint reference](../reference/endpoints.md). Plugins that ship in the repository: | Plugin | Version | What for | Surface | |---|---|---|---| | `package-ride` | 1.44.0 | A delivery game: driving, quests, race bets, scene | 36 commands, 33 queries, 3 realtime modules | | `arena` | 1.0.0 | A minimal example: a single movement module | 0 commands, 0 queries, 1 realtime module | These come with the server; you can see which ones are enabled in your environment on the **Plugins** screen in the panel ([Plugins](../panel/plugins.md)). ## Per-environment state A plugin is in a separate state for each environment: | State | Commands | Queries | |---|---|---| | Enabled (`Enabled`) | accepted | accepted | | Read-only (`ReadOnly`) | rejected | accepted | | Draining (`Draining`) | new commands rejected, running work finishes | accepted | | Disabled (`Disabled`) | rejected | rejected | A rejected call gets `403` and `forbidden` ([errors](../reference/errors.md)). The same result is also seen when the plugin version granted to the environment does not match the version on the server: if the server was updated with a new plugin version, the environment's plugin grant must also be moved to that version. If you get an unexpected `403 forbidden`, first look at the plugin's state and version in the panel. In addition, if the product module `plugins` is disabled, all `/v1/plugins/...` and realtime endpoints return `404` and `module_disabled` ([Game settings: modules](../panel/game-settings-modules.md)). ## Access by identity Every operation has an `access` value in the schema document: | `access` | Who calls | |---|---| | `player` | The player session (a cookie or a Bearer session token) | | `server` | Only a server key; not visible in the player client | | `public` | A `GET` query that requires no identity (only `x-crx-env` is needed) | An unspecified or unknown value falls to the safe side (`player`). A plugin cannot be called with a client API key embedded in the client: it gets `401`. Details: [calling plugins](calling-plugins.md). ## Next step - To call commands and queries, see [calling plugins](calling-plugins.md). - To generate a typed client and pin the schema version, see [bindings](bindings.md). - To write a new plugin for your own server, see [writing a plugin](authoring.md). --- Source: https://open-play.b4klabs.dev/docs/en/plugins/authoring.html # Writing a plugin This page is for people who want to write a **new plugin** for Open Play: it shows the parts of a plugin, a small working example, the safe order to follow when you raise a version, and the common mistakes taken from real incidents. To **use** a plugin, see [plugins overview](overview.md), [calling plugins](calling-plugins.md), and [bindings](bindings.md). ## Who this page is for > **Warning** > > **Hosted studios cannot upload plugin code today.** Plugins are **Rust code compiled into** the Open Play server and are considered trusted code. Sandboxed execution, resource metering, and signed distribution do not exist yet. This guide is for teams that **run Open Play themselves** (self-hosted) and for Open Play contributors. If you cannot compile code on your own server, you connect your game logic today in these ways: [game rules](../guides/game-rules.md) in the panel, [webhooks](../guides/webhooks.md), or your own server + a server key ([endpoint reference](../reference/endpoints.md)). ## Anatomy of a plugin A plugin is a single Rust crate (for example `crx-plugin-arena`, `crx-plugin-work-items`, `crx-plugin-package-ride`). Its parts: | Part | What it does | Where it is defined | |---|---|---| | **Manifest** | The plugin's identity (`PluginId`), version (`PluginVersion`), host API major, the plugins it provides and requires, its permissions (`PluginPermission`), configuration schema, messages, migration checksum, and realtime modules | `OpenPlayPlugin::manifest` | | **Typed messages** | Command/query input and output; each has one direction (`CommandInput`, `CommandOutput`, `QueryInput`, `QueryOutput`, realtime input/state, event) and is a strict `serde` type | structs that implement `PluginMessage` | | **Command** | A state-changing, durable, replay-safe operation. The decision function is **pure**: no I/O, clock, or randomness | `TypedCommand::decide`, `OpenPlayPlugin::register` | | **Query** | A read-only read; only the input/output codecs are registered | `OpenPlayPlugin::register_queries` | | **Artifact (schema document)** | The `plugin.schema.json` that client SDKs and bindings read: schemas, the operation table, modules, `schemaSha256` | `OpenPlayPlugin::artifact`, `PluginArtifact::from_manifest` | | **Realtime module** (optional) | A server-authoritative live input/state reducer | `OpenPlayPlugin::register_realtime`, `TypedRealtimeModule` | | **Dispatcher** | Where a command actually writes to the database and a query reads; it uses the server's shared transaction boundary (`PluginUnitOfWork`) | `PluginCommandDispatcher`, `PluginQueryDispatcher`, `PluginCommandResultLookup` | | **Panel surface** (optional) | Declarative admin screens in the panel, custom components, a game-rule catalog, a leaderboard declaration | `admin_surface`, `ui_components`, `game_rule_catalog`, `leaderboards` | The split of responsibility matters: the **server (host)** does idempotency, the optimistic lock, the single database transaction, event and outbox writes, the receipt, and the access check. The **plugin** only supplies the messages, the pure decision, and the plan of records to write (`PluginMutationPlan`). A durable result (payment, XP, items, rewards) always goes through the command path; realtime state is only for speed and cannot produce an economic effect. ## A small example: "hello counter" The example below follows the pattern of the smallest real plugins (`crx-plugin-arena`, `crx-plugin-scene`): one command (`increment`) and one query (`getCounter`). The code excerpts are taken byte for byte from the files under `docs/developer/examples/rust/` in the repository, and a test in the `crx-plugin-arena` crate really compiles them and registers the plugin, so the code on this page cannot go stale. The three parts live in **the same module** (a `use` in one is visible in the others). ### 1. Messages Every message ID starts with the plugin ID (the `hello-counter.` prefix; otherwise registration is rejected). Use `PluginI64` for `int64` values: on the wire they travel as decimal text ([numbers and encoding](../reference/numbers-and-encoding.md)). `deny_unknown_fields` rejects unknown fields. ```rust // Mesajlar: her biri tek bir yönü olan, katı (deny_unknown_fields) serde tipleridir. use crx_plugin::{MessageDirection, PluginI64, PluginMessage}; use serde::{Deserialize, Serialize}; pub const PLUGIN_ID: &str = "hello-counter"; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct IncrementInput { pub by: i32, } impl PluginMessage for IncrementInput { const MESSAGE_ID: &'static str = "hello-counter.increment.input"; const SCHEMA_VERSION: u32 = 1; const DIRECTION: MessageDirection = MessageDirection::CommandInput; } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct IncrementOutput { pub value: PluginI64, // int64 tel üzerinde ondalık metindir } impl PluginMessage for IncrementOutput { const MESSAGE_ID: &'static str = "hello-counter.increment.output"; const SCHEMA_VERSION: u32 = 1; const DIRECTION: MessageDirection = MessageDirection::CommandOutput; } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct GetInput {} impl PluginMessage for GetInput { const MESSAGE_ID: &'static str = "hello-counter.get.input"; const SCHEMA_VERSION: u32 = 1; const DIRECTION: MessageDirection = MessageDirection::QueryInput; } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct GetOutput { pub value: PluginI64, } impl PluginMessage for GetOutput { const MESSAGE_ID: &'static str = "hello-counter.get.output"; const SCHEMA_VERSION: u32 = 1; const DIRECTION: MessageDirection = MessageDirection::QueryOutput; } ``` ### 2. The plugin: manifest, command, query The command's decision function `decide` is **not async and does no I/O**; it only validates the input and produces the result. If you need time and a seed, the dispatcher takes them from the host and writes them to the event (`eval_now_ms`, `eval_seed`); `decide` never uses `SystemTime` or `rand`. ```rust // Eklenti: manifest + komut/sorgu kaydı. Kalıcı yazım burada değil, dispatcher'ın mutasyon planındadır. use crx_application::{ plugin_platform::{OpenPlayPlugin, PluginRegistryError}, plugin_runtime::{PluginRegistrar, PluginRuntimeError, TypedCommand}, }; use crx_plugin::{ManifestError, PluginArtifact, PluginId, PluginManifest, PluginPermission, PluginVersion}; use serde_json::json; pub struct IncrementCommand; impl TypedCommand for IncrementCommand { type Input = IncrementInput; type Output = IncrementOutput; // Saf ve eşzamansız olmayan karar: yalnız doğrular, I/O yapmaz. fn decide(&self, input: &Self::Input) -> Result { if !(1..=100).contains(&input.by) { return Err(PluginRuntimeError::HandlerRejected); } Ok(IncrementOutput { value: PluginI64::new(i64::from(input.by)) }) } } pub struct HelloCounterPlugin; fn plugin_id() -> PluginId { PluginId::try_new(PLUGIN_ID).expect("static plugin id") } impl OpenPlayPlugin for HelloCounterPlugin { fn manifest(&self) -> Result { PluginManifest::new( plugin_id(), PluginVersion::new(1, 0, 0), 1, // host API major vec![plugin_id()], // provides vec![], // requires vec![PluginPermission::Outbox], json!({"type":"object","additionalProperties":false,"properties":{}}), vec![ IncrementInput::descriptor().expect("descriptor"), IncrementOutput::descriptor().expect("descriptor"), GetInput::descriptor().expect("descriptor"), GetOutput::descriptor().expect("descriptor"), ], "0".repeat(64), // migration checksum (64 onaltılık karakter) vec![], // realtime modülleri ) } fn register(&self, registrar: &mut PluginRegistrar) -> Result<(), PluginRuntimeError> { registrar.register_command(IncrementCommand)?; Ok(()) } fn register_queries(&self, registrar: &mut PluginRegistrar) -> Result<(), PluginRuntimeError> { registrar.register_query::()?; Ok(()) } fn artifact(&self) -> Result, PluginRegistryError> { Ok(Some(plugin_artifact()?)) } } ``` ### 3. The artifact The artifact must tie **every** message in the manifest to an operation or a module; an extra or missing message is an error at registration. Commands require `idempotency_required: true`, a positive receipt retention, and a retry policy other than `Read`; for queries the opposite holds. ```rust // Artifact: SDK'nın ve bağlamaların okuduğu şema belgesi; manifestle birebir eşleşmek zorundadır. use std::collections::BTreeMap; use crx_plugin::{ MessageId, PluginAccess, PluginArtifactSpec, PluginHttpMethod, PluginJournalPolicy, PluginOperationDefinition, PluginOperationKind, PluginRetryPolicy, }; fn operation( name: &str, kind: PluginOperationKind, request: (&str, &str), // (şema adı, mesaj kimliği) response: (&str, &str), ) -> PluginOperationDefinition { let is_command = kind == PluginOperationKind::Command; let segment = if is_command { "commands" } else { "queries" }; PluginOperationDefinition { name: name.to_owned(), kind, path: format!("/v1/plugins/{PLUGIN_ID}/{segment}/{}", request.1), method: if is_command { PluginHttpMethod::Post } else { PluginHttpMethod::Get }, request_schema: request.0.to_owned(), response_schema: response.0.to_owned(), schema_version: 1, retry: if is_command { PluginRetryPolicy::SameKeyCommand } else { PluginRetryPolicy::Read }, journal: if is_command { PluginJournalPolicy::Full } else { PluginJournalPolicy::None }, resource: None, result_path: is_command .then(|| format!("/v1/plugins/{PLUGIN_ID}/commands/{}/result", request.1)), success: Some(200), access: PluginAccess::Player, request_message_type: MessageId::try_new(request.1).expect("message id"), response_message_type: MessageId::try_new(response.1).expect("message id"), receipt_retention_seconds: is_command.then_some(86_400), idempotency_required: is_command, deprecated: None, } } pub fn plugin_artifact() -> Result { let value = json!({ "type":"object","additionalProperties":false, "properties":{"value":{"type":"integer","format":"int64"}}, "required":["value"] }); let schemas = BTreeMap::from([ ("IncrementInput".to_owned(), json!({ "type":"object","additionalProperties":false, "properties":{"by":{"type":"integer","format":"int32","minimum":1,"maximum":100}}, "required":["by"] })), ("IncrementOutput".to_owned(), value.clone()), ("GetInput".to_owned(), json!({"type":"object","additionalProperties":false,"properties":{}})), ("GetOutput".to_owned(), value), ]); let spec = PluginArtifactSpec { schemas, commands: BTreeMap::from([("increment".to_owned(), operation( "increment", PluginOperationKind::Command, ("IncrementInput", IncrementInput::MESSAGE_ID), ("IncrementOutput", IncrementOutput::MESSAGE_ID), ))]), queries: BTreeMap::from([("getCounter".to_owned(), operation( "getCounter", PluginOperationKind::Query, ("GetInput", GetInput::MESSAGE_ID), ("GetOutput", GetOutput::MESSAGE_ID), ))]), modules: BTreeMap::new(), }; let manifest = HelloCounterPlugin.manifest().map_err(PluginRegistryError::Manifest)?; Ok(PluginArtifact::from_manifest(&manifest, spec)?) } ``` ### What the example does not do: durable writes `IncrementCommand::decide` only validates the input. The code that really increments the counter lives in the **dispatcher**. A dispatcher does the following (real example: `crx-plugin-work-items`, `prepare_create` and `prepare_claim`): 1. It authorizes the call with `PluginRegistry::prepare_command`, strictly decodes the input, and computes the fingerprint. 2. It reads the target record (`PluginRecordQuery`) and checks its version for the optimistic lock. 3. It builds a `PluginMutationPlan`: the records to write (`PluginRecordWrite`, with expected version and indexes) and the events to append (`PluginEventAppend`). 4. It applies the plan with `PluginUnitOfWork` in **a single database transaction**; the result returns only after the commit (or from the receipt of the same key). Plugins write their records to the shared `plugin_records` (and index, event, receipt) tables; a dedicated SQL table per plugin is mostly not needed. If custom SQL is needed, it goes in an additive migration in the plugin's own migration namespace. > **Note** > > Today command routing on the server is done with per-plugin dispatchers wired at the composition root (`bin/crx-api`). A new plugin that has commands also needs its own dispatcher and this wiring; a manifest + `register` alone is not enough. Plugins that only have a realtime module (`crx-plugin-arena`, `crx-plugin-scene`) do not have this step. ## Registering with the server The plugin crate is added to the dependencies of `bin/crx-api` (and `bin/crx-worker` if it has scheduler or outbox work) and handed to the registry at startup: ```rust let mut registry = PluginRegistryBuilder::new(); registry.register_plugin(HelloCounterPlugin)?; let registry = registry.freeze()?; ``` `freeze` consumes the registry and makes it immutable. A duplicate plugin/message/module, a missing or incompatible dependency, an unsupported host API major, a mixed codec, and a namespace violation **stop startup**; the error shows up right after the build, not at runtime. ## Artifact, pins, and the release lock `schemaSha256` is the canonical JSON digest of the artifact's **schemas**. If the schemas change, the digest changes; if only the version text changes, it does not. Clients pin this digest in their bindings ([bindings](bindings.md)). In the repository, three places are updated from the same source in **the same change**: 1. `artifacts/plugins/.plugin.schema.json`: exported from Rust (`PluginArtifact::to_pretty_json`; example: `export_arena_artifact` in `crx-plugin-arena`). 2. `artifacts/plugins/release-lock.json`: per plugin, the artifact path, the generated binding path, the exact version, `schemaSha256`, the protocol major, the codec profile, and the module IDs. 3. The generated client binding: never edited by hand; regenerated with the generator. A test verifies that "the JSON in the repository is an exact copy of what Rust produces" (`checked_in_artifacts_are_exact_rust_exports`, `crx-plugin-arena`). A pin mismatch **blocks** the release; no compatibility layer that accepts the old digest is added. Regenerate. ## Generating a binding for game clients A game developer takes the plugin's artifact, generates their own binding, and pins the digest deliberately: ```bash npx crx plugins generate \ --input hello-counter.plugin.schema.json \ --schema-sha256 \ --output src/generated/hello-counter.ts ``` In CI, `--check` verifies the file is up to date. Details and the Unity limit: [bindings](bindings.md). A ready Unity binding exists today only for the `package-ride` plugin. ## Versioning and safe release The environment's plugin grant must match the **exact version**. If a new plugin version is running on the server while the environment's grant still points to the old one, plugin and realtime calls are rejected in that environment (`403 forbidden`; the schema endpoint returns `404`). This is a real incident: raising the version and forgetting to switch the grant to the new version. Order for raising a version: 1. **Gates:** the plugin's tests, the artifact exact-match test, and `cargo xtask check` must be green; if there is a migration, up + down must work. 2. **Drain:** move the environment's plugin grant from `Enabled` to `Draining`. New commands and inputs are rejected immediately. Wait at least the **drain minimum**: the longest realtime state retention in the manifest + 15 s; 15 s for a plugin with no realtime module. 3. **Binary:** deploy the new server image (with the migration, if any); migrations must be additive. 4. **Switch the grant to the new version:** in the panel, **Switch to version** ([Plugins](../panel/plugins.md)). If you skip this, the new binary rejects plugin calls. 5. **Client:** ship the game client with the new binding and (if needed) SDK. 6. **Verify:** watch command results, receipt replays, and the count of rejected calls. Rollback rules: if the plugin **has not yet produced writes** in the new version, go `Draining` → the previous compatible binary and grant. After writes have been produced, do not blindly roll the binary back: stop writes with `ReadOnly` or `Disabled`, verify state from receipts and event records, and ship a **forward-fix** version. ## Enabling per environment Being compiled into the server is not enough; every environment needs a separate **grant row**. In the panel, you bind the plugin to the game on the **Plugins** screen; the states are `Enabled`, `ReadOnly`, `Draining`, `Disabled` (what they mean: [plugins overview](overview.md)). If a plugin permission depends on another product module (for example the leaderboard permission on the Competition module), the transition returns `409 plugin_requires_module` while that module is off. For a canary, first set `ReadOnly` in a separate test environment, then `Enabled`, and observe. ## Panel integration A plugin offering screens in the panel is optional and needs **no plugin-specific panel code**: - `admin_surface`: a declarative admin surface. It binds only to operator operations with `server` access and is validated against the manifest and artifact at registration. The surface is not part of the artifact: it is not shown to players or the SDK, and it does not change `schemaSha256`, the version, or the grant. - `ui_components`: single-file HTML components the plugin embeds into the build with `include_str!` (they run in an isolated frame). The digest in the declaration is computed from the asset; a mismatch is rejected at registration. - `game_rule_catalog`: game-rule values adjustable from the panel ([game rules](../guides/game-rules.md)). - `leaderboards`: a declaration of the tables the plugin writes scores to in the core leaderboard (needs `PluginPermission::Leaderboard`). Registering a surface requires the plugin to have an artifact; a surface without an artifact is rejected at registration. ## Tests Add at least: - Manifest ↔ artifact exact match and schema digest drift (`PluginArtifact::validate_against`). - Whether the artifact JSON in the repository and the `release-lock.json` row equal the Rust output. - The `int64` codec, bounded input (length, range), and rejected boundary values. - Several plugins in one frozen registry (`PluginRegistryBuilder`; example: `crx-plugin-arena/tests/two_game_contract.rs`). - For durable commands, a transaction test with real PostgreSQL: a single application when the same command repeats, version conflict, receipt (example: `crx-infra-postgres/tests/plugin_uow_d1_integration.rs`). At close, run `cargo xtask check` and the full gate with isolated dependencies; a `--lib` run alone is not full proof. ## Checklist - [ ] The plugin ID is lowercase with hyphens; every message has the `plugin.` prefix. - [ ] All input/output types use `deny_unknown_fields`; limits are explicit in the schema; `PluginI64` for `int64`. - [ ] The decision function is pure (no I/O, clock, or randomness); the durable result is in the dispatcher's plan. - [ ] Commands are idempotent with receipt retention defined; queries are read-only with explicit access (`player`, `server`, `public`). - [ ] The artifact, the JSON in the repository, `release-lock.json`, and the generated binding were updated in the same change. - [ ] Registration was done in `bin/crx-api` (and `bin/crx-worker` if needed); startup passes `freeze`. - [ ] The release order was followed: drain, binary, switch the grant to the new version, client. - [ ] The environment grant exists and its state is right. ## Common mistakes | Mistake | Symptom | The right way | |---|---|---| | Raised the version, did not switch the grant | Plugin and realtime calls get `403 forbidden`, the schema endpoint `404` | Drain, binary, then **Switch to version** | | Changed a schema, did not refresh the artifact/pin | The artifact exact-match test is red; `schemaSha256` mismatch in the client | Regenerate the artifact from Rust; update the release lock and binding in the same change | | Resent a command with an uncertain outcome using a new key | The same operation is applied twice | The same `Idempotency-Key` and the same body; if unsure, use the result query first ([calling](calling-plugins.md)) | | Defined `int64` as a JSON number | Loss on large values or a codec error | `PluginI64`; decimal text on the wire | | Clock, randomness, or network inside `decide` | Replay gives a different result | Time and seed come from the host context; the durable effect is in the dispatcher plan | | An economic result in a realtime reducer | The result is lost if Redis is lost | Durable results go through the command path | | An extra or missing message in the artifact | Startup `freeze`/registration error | Derive the manifest and artifact from the same Rust source | | A client API key for a command | `401` | A player session or a server key | | Added a command without wiring a dispatcher | The command cannot be routed | Write a dispatcher and wire it at the composition root | ## Known limits - There is no third-party plugin upload, sandboxed execution, or signed distribution. - There is no separate SDK for writing a plugin out of tree; a plugin is a crate in the Open Play repository tree. - There is no command that generates a new plugin skeleton (scaffold); copy the pattern from the existing small plugins. - For plugins with commands, the dispatcher wiring is done by hand. ## Next step - To call a plugin, see [calling plugins](calling-plugins.md). - For a client binding and pinning the schema version, see [bindings](bindings.md). - To enable it in an environment and switch versions, see [panel: Plugins](../panel/plugins.md). --- Source: https://open-play.b4klabs.dev/docs/en/plugins/calling-plugins.html # Calling plugins This page describes how to call a plugin's commands and queries over HTTP: headers, version checks, replay safety, the result query, and the error envelope. If you use an SDK, the SDK does most of this for you; still, knowing what happens makes debugging easier. > **Note** > > For plugins there is **no shared `/openapi.json`**; the plugin's own schema document (artifact) is obtained from the `GET /v1/plugins/{plugin_id}/schema` endpoint. The list of operations you can use is there ([bindings](bindings.md)). ## Endpoints | Method and path | Purpose | |---|---| | `POST /v1/plugins/{plugin_id}/commands/{message_type}` | Runs the command | | `GET /v1/plugins/{plugin_id}/commands/{message_type}/result` | Queries the command's result with the idempotency key | | `GET /v1/plugins/{plugin_id}/queries/{message_type}` | Runs the query (parameters in the query string) | | `POST /v1/plugins/{plugin_id}/queries/{message_type}` | Runs the query (parameters in the body) | | `GET /v1/plugins/{plugin_id}/schema` | Returns the plugin's schema document | `{plugin_id}` is the plugin ID (for example `package-ride`). `{message_type}` is the operation's **request message type** (for example `package-ride.progression-missions.login-bonus.input`); it is not the operation's name (`claimLoginBonus`). The full path and message type are written in the `path` and `requestMessageType` field of each operation in the schema document. Which method (GET or POST) to use is also stated by the operation's `method` field: some server queries are called with `POST`. ## Identity | Caller | What to send | |---|---| | Player (cookie) | `Cookie` (`__Secure-crx.session`) + `x-crx-env: `; on modifying (POST) requests also `x-csrf-token` + the `__Secure-crx.csrf` cookie | | Player (Bearer) | `Authorization: Bearer ` + `x-crx-env: `. On modifying requests the plugin endpoint requires the CSRF pair (the `__Secure-crx.csrf` cookie + `x-csrf-token`) **with Bearer too**; a client without cookies gets the pair with `GET /v1/auth/csrf` and carries it itself ([sessions and CSRF](../auth/sessions-and-csrf.md)) | | Server | `Authorization: Bearer crx_live__` (a server key). If you send `x-crx-env` it must be the same as the key's environment, otherwise `401`. CSRF and Origin checks are not applied | | Public query | Only `x-crx-env`; only `GET`, and only queries that have `access: public` in the schema document | A key that starts with `crx_` but is not of the server type (a client key) gets `401 unauthorized`. In player requests, if there is an `Origin` header it must be one of the environment's allowed origins (the browser adds it itself); otherwise `403 forbidden`. When a modifying call succeeds, the response returns the new CSRF pair (`Set-Cookie` and the `x-csrf-token` header); use it on the next modifying request. ## Calling a command ```http POST /v1/plugins/package-ride/commands/package-ride.progression-missions.login-bonus.input HTTP/1.1 Host: game.example.com Origin: https://game.example.com Cookie: __Secure-crx.session=; __Secure-crx.csrf= x-crx-env: 0192a3b4-0000-7000-8000-000000000001 x-csrf-token: x-crx-schema-version: 1 Idempotency-Key: 5d1f2c88-4f0b-4c8f-9f1e-9a3f6b0c1d22 If-Match: "3" Content-Type: application/json {"expectedVersion":"3"} ``` ```http HTTP/1.1 200 OK Content-Type: application/json x-crx-message-type: package-ride.progression-missions.login-bonus.output x-crx-schema-version: 1 {"version":"4","rewardMinor":"250","streak":"2","day":"19300","walletBalanceMinor":"1250","claimEventId":"0192a3b4-0000-7000-8000-0000000000c1","ledgerCorrelationId":"0192a3b4-0000-7000-8000-0000000000c2"} ``` | Header | Required | Meaning | |---|---|---| | `Idempotency-Key` | yes on commands | The logical replay key; cannot be empty. [Rules](../reference/idempotency-and-concurrency.md) | | `x-crx-schema-version` | yes | The schema version of the payload you send (a positive integer); must match the operation's `schemaVersion` value. It is required on queries too | | `If-Match` | no | The expected resource version (**optimistic lock**). A decimal integer, quoted or unquoted; values with `*` and the `W/` prefix get `400`. If the real version differs, `409` | | `Content-Type: application/json` | yes | The body is JSON and conforms to the operation's request schema | > **Note** > > `int64` values in body fields go as **decimal text** (`"expectedVersion":"3"`), not as a JSON number; the response has the same form. Details: [numbers and encoding](../reference/numbers-and-encoding.md). `int32` and `uint32` fields are JSON numbers. The body of a successful response is JSON that conforms to the operation's response schema; the `x-crx-message-type` and `x-crx-schema-version` response headers indicate the output message. ### Repeating with the same key If the network drops or you cannot get the response, resend with **the same** `Idempotency-Key` and **the same** body; the server does not apply the operation a second time and returns the recorded result. | Case | Response | |---|---| | First request | Processed, `200` | | Same key, same body | The recorded result is returned | | Same key, **different** body | `422` and `idempotency_mismatch` | | A request with the same key is still being processed | `409` and `idempotency_in_flight` (`retryable: true`); try again shortly | | The record expired (the operation's `receiptRetentionSeconds` value, 86,400 s in the example) | `410` and `idempotency_expired` | Generating a new key means **a new operation**; do not resend a command with an uncertain outcome with a new key. ## Result query Find out the result of a command whose response you never received, without resending it: ```http GET /v1/plugins/package-ride/commands/package-ride.progression-missions.login-bonus.input/result?idempotency_key=5d1f2c88-4f0b-4c8f-9f1e-9a3f6b0c1d22&output_message_type=package-ride.progression-missions.login-bonus.output&output_schema_version=1 HTTP/1.1 x-crx-env: 0192a3b4-0000-7000-8000-000000000001 Cookie: __Secure-crx.session= ``` All three query parameters are required: `idempotency_key`, `output_message_type`, and `output_schema_version`. The path is the same as the operation's `resultPath` field. | Response | Meaning | What to do | |---|---|---| | `200` + body | The command had been committed; the body is the output | Use the result | | `202` (empty body) | Still being processed | Query again shortly | | `404` | There is no record with this key: the command was never applied | Only in this case can you send the command **with the same key and body** | | `422` + `reason` | The command was processed and rejected by a business rule | Do not send; `reason` is a machine code | | `410` | The record expired | The result is unknown; re-read the state with a query | ## Calling a query With `GET`, the parameters go in the query string. Each value is first parsed as JSON; if that fails, it is treated as text; the value `null` becomes `null`. Giving the same parameter twice gets `400`. ```http GET /v1/plugins/package-ride/queries/package-ride.delivery-trip.active.input HTTP/1.1 x-crx-env: 0192a3b4-0000-7000-8000-000000000001 x-crx-schema-version: 1 Cookie: __Secure-crx.session= ``` With `POST`, the same fields go in the JSON body. `POST` is suitable for complex or long inputs; read which method is valid from the operation's `method` field. A public query example: ```http GET /v1/plugins/package-ride/queries/package-ride.world-catalog.get.input HTTP/1.1 x-crx-env: 0192a3b4-0000-7000-8000-000000000001 x-crx-schema-version: 1 ``` ## Schema endpoint `GET /v1/plugins/{plugin_id}/schema` returns the plugin's schema document (JSON) (`Cache-Control: no-store`). A player session or a server key is required. If the plugin is not enabled for your environment or the grant version does not match, it is `404`. You find the document's fields and how to use the `schemaSha256` value on the [bindings](bindings.md) page. ## Error envelope Plugin endpoints return a **JSON** error envelope (unlike the plain-text errors of the core REST endpoints; for a comparison see [errors](../reference/errors.md)): ```json {"code":"conflict","message":"version conflict: expected 3, actual 5","retryable":false,"requestId":"0192a3b4-0000-7000-8000-0000000000d1"} ``` The response also has an `x-request-id` header; give this ID in a support request. | `code` | HTTP | `retryable` | When | |---|---|---|---| | `unauthorized` | 401 | no | Session/key missing, invalid, a client key, or an environment mismatch | | `forbidden` | 403 | no | CSRF or Origin rejection; the plugin is not enabled or the version does not match; a banned account (`message: account banned`) | | `module_disabled` | 404 | no | The `plugins` module is disabled (the `module` field carries the module name) | | `not_found` | 404 | no | Unknown plugin or message type; no record | | `invalid_argument` | 400 | no | Missing/broken header, unknown schema version, a payload that does not conform to the schema, a duplicate query parameter | | `conflict` | 409 | no | The `If-Match` version does not match the real one | | `idempotency_in_flight` | 409 | yes | A request with the same key is still being processed | | `idempotency_mismatch` | 422 | no | Same key, different body | | `unprocessable` | 422 | no | A business rule rejection; the `reason` field carries the machine code | | `idempotency_expired` | 410 | no | The record expired | | `rate_limited` | 429 | yes | Rate limit; `Retry-After: 60` ([rate limits](../reference/rate-limits.md)) | | `tenant_unavailable` | 503 | yes | The environment's placement is temporarily unavailable; `Retry-After: 30` | | `internal` | 500 | yes | Server error | The `reason` field exists only on a business rule rejection (`unprocessable`) (for example `street_reset_too_early`) and is plugin-specific; its meaning is in the plugin's documentation. On a version mismatch (`conflict`) the message contains the expected and the real version: re-read the state and try again with the new version. > **Warning** > > On plugin endpoints a banned account does **not** come with the `account_banned` code: `403` and `code: "forbidden"`, `message: "account banned"` is returned. The `account_banned` code belongs to the player identity endpoints (sign-in, sign-up) ([errors](../reference/errors.md)). If your client will distinguish these two cases, look at the session state, not at the message. ## Example: calling a command safely against an uncertain outcome The TypeScript example below prepares the command with an SDK binding (a single `Idempotency-Key` is generated), sends it; if the outcome is uncertain it queries the result **without generating a new command** and resends with the same key only on a `notFound` response. The `demo-game` plugin is a scenario prepared for this example: in your real plugin you take the operation names and fields from your own schema document ([bindings](bindings.md)). ```ts import { CrxError, ErrorKind, type PlayerClient } from "@ferdiunal/open-play-ts"; import { DemoGameBinding } from "./generated/demo-game.js"; /** * Günlük hediyeyi alır. `prepare` tek `Idempotency-Key` üretir; ağ hatasında sonuç belirsizse yeni komut üretilmez: * önce sonuç sorgulanır, yalnız "kayıt yok" yanıtında AYNI anahtarla yeniden gönderilir. */ export async function claimDailyGift(client: PlayerClient, expectedVersion: bigint): Promise { const demo = client.plugin(DemoGameBinding); const command = await demo.commands.claimDailyGift.prepare({ giftKey: "daily" }, { expectedVersion }); try { const output = await command.send(); return output.walletBalanceMinor; // int64 alanlar bigint gelir } catch (error) { if (!(error instanceof CrxError) || error.kind !== ErrorKind.UnknownOutcome) throw error; const outcome = await command.lookup(); if (outcome.status === "committed") return outcome.result.walletBalanceMinor; if (outcome.status === "notFound") return (await command.resume()).walletBalanceMinor; // aynı anahtar, aynı gövde throw error; // pending, rejected ya da expired: kör tekrar yok } } /** Salt-okunur sorgu: tekrar güvenlidir, anahtar gerekmez. */ export async function hasClaimedToday(client: PlayerClient): Promise { const demo = client.plugin(DemoGameBinding); const status = await demo.queries.getGiftStatus({ includeHistory: false }); return status.claimedToday; } ``` ## Next step - Typed client and schema pinning: [bindings](bindings.md). - Rules for replay safety and version checks: [idempotency and concurrency](../reference/idempotency-and-concurrency.md). --- Source: https://open-play.b4klabs.dev/docs/en/plugins/bindings.html # Bindings and schema pinning This page describes generating typed client code from a plugin's schema document (artifact) and **pinning** which schema version your client was built with. Thanks to pinning, when the server updates the plugin your client notices the mismatch instead of silently talking with the wrong fields. ## What the schema document contains `GET /v1/plugins/{plugin_id}/schema` and `artifacts/plugins/.plugin.schema.json` in the repository are the same document. The top-level fields: | Field | Meaning | |---|---| | `pluginId` | The plugin ID (for example `package-ride`) | | `version` | The plugin version (for example `1.44.0`) | | `schemaSha256` | The digest of the `schemas` section; **the schema pinning key** (below) | | `protocolVersion` | The plugin protocol major version (`1` for now) | | `codecProfile` | The wire encoding: `plugin-v1-decimal-int64` ([numbers](../reference/numbers-and-encoding.md)) | | `schemas` | Input and output JSON schemas (component name → schema) | | `queries`, `commands` | Operation definitions | | `modules` | Realtime modules | An operation definition carries these fields: | Field | Meaning | |---|---| | `name` | The operation name; it is the method name in client code (for example `claimLoginBonus`) | | `kind`, `method`, `path` | `command` or `query`; the HTTP method; the full path | | `requestSchema`, `responseSchema`, `schemaVersion` | The input and output schemas and the `x-crx-schema-version` to send | | `requestMessageType`, `responseMessageType` | The `{message_type}` in the path and the output message type | | `access` | `player`, `server`, or `public` | | `retry` | `read` (safe to repeat), `same-key-command` (repeat only with the same key), `never` | | `journal` | `none`, `full`, or `reference`: how the SDK records the command locally (to continue after a reload) | | `idempotencyRequired`, `receiptRetentionSeconds`, `resultPath` | For commands: whether the key is required, the record retention, the result query path | | `deprecated` | Deprecation information if present: `since`, `sinceUnixS`, `replacement` ([versioning](../reference/versioning.md)) | A realtime module definition carries `moduleId`, `input`, `state`, `events`, `signals`, `authorityMode`, `deliveryClass`, `interestPolicy`, `stateTtlMs`, and `sequencePolicy`; the input and state messages have `maxBytes`, `ratePerSecond`, and `burst` ([protocol](../realtime/protocol.md)). ## What is `schemaSha256` `schemaSha256` is the SHA-256 digest of the **canonical JSON** (keys sorted, no whitespace, UTF-8) output of the `schemas` object in the document (lowercase hexadecimal, 64 characters). If the schema fields change, the digest changes; even if the version number stays the same. Therefore saying "the version matches" is not enough: the **digest** must match. You can also recompute and verify the digest yourself: ```ts import { createHash } from "node:crypto"; type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; /** Anahtarları özyinelemeli sıralar; sunucudaki kurallı JSON biçimiyle aynı baytları üretir (boşluksuz). */ function canonical(value: Json): Json { if (Array.isArray(value)) return value.map(canonical); if (value !== null && typeof value === "object") { const sorted: { [key: string]: Json } = {}; for (const key of Object.keys(value).sort()) sorted[key] = canonical(value[key] as Json); return sorted; } return value; } /** Şema belgesinin `schemas` bölümünden `schemaSha256` değerini yeniden hesaplar. */ export function schemaSha256(artifact: { schemas: Record }): string { return createHash("sha256").update(JSON.stringify(canonical(artifact.schemas)), "utf8").digest("hex"); } /** Belgenin kendi bildirdiği özet, içeriğiyle uyuşuyor mu? */ export function artifactIsConsistent(artifact: { schemaSha256: string; schemas: Record }): boolean { return schemaSha256(artifact) === artifact.schemaSha256; } ``` > **Note** > > This computation covers only the `schemas` section; it does not cover the operation list (`commands`, `queries`) or the `version` field. If an operation's path or access type changes, the digest may not change; so when regenerating the binding, also look at the difference in the whole document. ## Generating a TypeScript binding 1. Get the schema document and save it to a file. From the server: `GET /v1/plugins/{plugin_id}/schema` with a player session or a server key. 2. Read the digest from the document (`schemaSha256`) and **write it into your release manifest/CI**: this is your pin value. 3. Generate the binding with the SDK's `crx` command: ```bash npx crx plugins generate \ --input demo-game.plugin.schema.json \ --schema-sha256 ffc38829f30ea0dd1b829f351f905854a9875806fbc8a2bbd591815c7ee52c2f \ --output src/generated/demo-game.ts ``` 4. In CI, check that the generated file is up to date; `--check` does **not write** the file, it compares bytes: ```bash npx crx plugins generate --input demo-game.plugin.schema.json \ --schema-sha256 ffc38829f30ea0dd1b829f351f905854a9875806fbc8a2bbd591815c7ee52c2f \ --output src/generated/demo-game.ts --check ``` Behavior: - The input document is a local file; the command does not reach the network and does not execute the document. - `--schema-sha256` is **required** and must be the same as the `schemaSha256` in the document; if it differs, generation is rejected. The digest is not refreshed automatically: you approve the change deliberately. - The output writes only a new file; it does not overwrite an existing file (to regenerate, delete the file). The output folder must exist beforehand and its path must not contain a symbolic link (the command compares the path with its real path); symbolic links and special files are rejected; the target must be `.ts`. The input file can be at most 2 MiB. - On success it prints `CRX PLUGINS GENERATE PASS` (with `--check`, `CRX PLUGINS CHECK PASS`) and exits with `0`. - On failure the command gives no details (so that paths and contents do not leak); it prints a single line `CRX TYPES FAIL: input/version/hash/output reddedildi` and exits with `1`. First check the input file, the digest, and the output path. - The same input always produces the same byte output. The generated file carries the TypeScript types from the schemas (`int64` as `bigint`), the operation tables, the module definitions, and the embedded artifact + digest. Usage: ```ts import { DemoGameBinding } from "./generated/demo-game.js"; const demo = client.plugin(DemoGameBinding); await demo.queries.getGiftStatus({ includeHistory: true }); const handle = await demo.commands.claimDailyGift.prepare({ giftKey: "daily" }); ``` Operations with `access: "server"` are **not visible in the player client** and cannot be called; call them over HTTP from your server with a server key ([calling plugins](calling-plugins.md)). `access: "public"` queries are called without identity with `game.publicPlugin(...)`. > **Note** > > If a command definition is `retry: "same-key-command"`, the SDK retries on a network error only **with the same key**; if it is `journal: "full"`, an unfinished command is continued with the same key when the page is reloaded. SDK installation and journal options are on the [TypeScript SDK](../sdk/typescript.md) page. ## Pinning and mismatch The client checks version compatibility in two places: 1. **Build/startup time, local:** when setting up a binding with `createGameClient`, you provide the expectation (`pin`) from your game's release manifest. If the `pluginId`, `schemaSha256`, `protocolVersion`, `codecProfile` (and `version` if you provided it) in the binding do not match the expectation **exactly**, it throws `CrxError` (`kind: upgrade_required`, `code: plugin_binding_mismatch`); no request goes out. This check does not ask the server; it only verifies that the binding you hold is the binding you expect. 2. **The real document on the server:** the TypeScript SDK does **not** call the `/schema` endpoint on its own at runtime. To see a mismatch with your client's old binding in case the server updated the plugin, query the document yourself at startup and compare the digest: ```ts import { DemoGameBinding } from "./generated/demo-game.js"; /** * Sunucudaki şema belgesini alır ve elinizdeki bağlamayla karşılaştırır. Uyuşmazsa istemciyi güncelleyin: * yeni belgeyi alın, yeni `schemaSha256`'yı manifestinize yazın, bağlamayı yeniden üretin. * Çerez oturumu açıkken tarayıcıda çalışır (aynı kökenden, `credentials: "include"`). */ export async function serverSchemaMatches(baseUrl: string, environmentId: string): Promise { const response = await fetch(`${baseUrl}/v1/plugins/${DemoGameBinding.pluginId}/schema`, { credentials: "include", headers: { "x-crx-env": environmentId }, }); if (!response.ok) throw new Error(`şema belgesi alınamadı: HTTP ${response.status}`); const live = (await response.json()) as { pluginId: string; schemaSha256: string; protocolVersion: number; codecProfile: string; }; return ( live.pluginId === DemoGameBinding.pluginId && live.schemaSha256 === DemoGameBinding.schemaSha256 && live.protocolVersion === DemoGameBinding.protocolVersion && live.codecProfile === DemoGameBinding.codecProfile ); } ``` In the Unity SDK this check is ready-made: `PluginClient.VerifyServerSchemaAsync()` gets the server's document and compares the `pluginId`, `schemaSha256`, `protocolVersion`, `codecProfile` (and `version` if given in the pin) fields with the binding's pin. On a mismatch it throws `OpenPlayException` (`ErrorKind.UpgradeRequired`, code `plugin_binding_mismatch`); if the pin field is missing or broken, `ErrorKind.Contract`. ```csharp using System.Threading.Tasks; using OpenPlay; using OpenPlay.PackageRide; /// /// Oyuna girerken sunucudaki eklenti şemasının, pakete gömülü bağlamayla aynı olduğunu doğrular. /// Uyuşmazlıkta (UpgradeRequired, kod plugin_binding_mismatch) oyuncuyu güncellemeye yönlendirin. /// public static class PluginPinCheck { public static async Task ServerSchemaMatches(OpenPlayClient client) { var rides = PackageRideClient.Create(client); // pin: pluginId, schemaSha256, protocolVersion, codecProfile, version try { await rides.Plugin.VerifyServerSchemaAsync(); // GET /v1/plugins/package-ride/schema; yalnız pin alanlarını okur return true; } catch (OpenPlayException ex) when (ex.Kind == ErrorKind.UpgradeRequired) { UnityEngine.Debug.LogWarning("Eklenti şeması değişti; istemciyi güncelleyin: " + ex.Code); return false; } } } ``` ### What the client sees on a mismatch If the server updates the plugin and your client calls with the old binding: | Change | The client sees | |---|---| | The operation's `schemaVersion` value increased | `400` `invalid_argument` (unknown schema version). `x-crx-schema-version` was sending the old one | | The operation or message type was removed | `404` `not_found` | | A required field was added to the input schema | `400` `invalid_argument` (a payload that does not conform to the schema) | | The output schema changed | The SDK cannot decode the response according to the binding; an error of type `contract` | | The plugin is not enabled for your environment / the version grant does not match | `403` `forbidden` | The fix is always the same: get the current schema document, deliberately write the new `schemaSha256` into your manifest, regenerate the binding, and ship the client. If old clients need to keep working for a while longer, ask your plugin provider whether the old schema version is kept on the server; the platform has no guarantee of this. ## Unity binding In the Unity SDK, the binding comes ready **only for the `package-ride` plugin** (`OpenPlay.PackageRide`: `PackageRideBinding`, `PackageRideClient`). A Unity binding for another plugin cannot be generated today: the SDK's generator script is an in-repository tool and does not ship in the published package. The generator packages only operations whose `access` value is `player` or `public` (server-only operations do not enter the package) and accepts only the `plugin-v1-decimal-int64` encoding; `int64` fields become C# `long`. For usage, see the `PluginCall` example on the [Unity SDK](../sdk/unity.md) page. ## Next step - All the version concepts (protocol, schema, deprecation): [versioning](../reference/versioning.md). - Number encoding: [numbers and encoding](../reference/numbers-and-encoding.md). --- Source: https://open-play.b4klabs.dev/docs/en/reference/errors.html # Error reference This page lists every kind of error Open Play returns, its status code, and its body format. When writing error handling code, first look at which endpoint **family** (core REST or plugin) is being called: the error bodies of the two families differ. > **Warning** > > **Most core REST endpoints return plain-text (`text/plain`) errors**, not JSON. Only the few errors marked "JSON" on this page carry a JSON envelope. Do not parse the error text; decide by the **status code** and (if present) the JSON `code` field. Plain-text messages can change between versions. ## Error families | Family | Which endpoints | Body | |---|---|---| | Core REST | `/v1/characters…`, `/v1/wallets…`, `/v1/store…`, `/v1/auth/…` etc. ([endpoints](endpoints.md)) | Plain text; a few errors are JSON `{code, message[, module]}` | | Plugin | `/v1/plugins/…` | Always JSON `{code, message, retryable, requestId[, reason, module]}` + the `x-request-id` header | | Realtime | `/v1/realtime/ws` | `Error` frame: `{type, requestId?, code, retryable}` ([protocol](../realtime/protocol.md)) | | Chat socket | `/v1/chat/ws` | `{"type":"error","code":"…"}` ([realtime overview](../realtime/overview.md)) | ## Core REST: status table | Status | Body (plain text unless stated otherwise) | When | Retry? | |---|---|---|---| | `400` | A description of the error | Missing or broken header, invalid field, unknown command type, `If-Match` format | No; fix the request | | `401` | `unauthorized` | API key or player session missing, invalid, expired; `x-crx-env` missing or broken; a banned account (no distinction is made) | No; renew the credentials | | `403` | `permission denied` | The key type is insufficient (for example a server endpoint with a client key), CSRF or Origin rejection | No | | `403` | **JSON** `{"code":"account_banned","message":"account banned"}` | On sign-in endpoints, a banned account after ownership has been proven | No; do not build a loop | | `404` | `not found` | The resource does not exist or was deleted | No | | `404` | **JSON** `{"code":"module_disabled","message":"module disabled","module":"store"}` | The product module is disabled in the environment | No; enable the module ([modules](../panel/game-settings-modules.md)) | | `409` | `version conflict: expected 3, actual 5` | The `If-Match` version does not match the real one | Re-read the state and repeat with the new version | | `409` | `already exists: ` | A resource with the same ID/key already exists | No | | `409` | `idempotent request in progress` | The first request with the same `Idempotency-Key` is still being processed | Yes, shortly, with the same key | | `410` | `idempotent request result expired` | The key's record retention (24 hours) expired | With a new key, reading the state first | | `413` | No body | The request body exceeded the 2 MiB limit (the image upload endpoint is subject to its own limit) | No | | `422` | `idempotency mismatch` | Same key, different content | No; do not reuse the key | | `422` | `unprocessable: ` | A business rule rejection (insufficient balance, stock, limit…) | No; do not repeat without changing the state | | `422` | `rejected: ` | A plugin business rule rejection (through core routes) | No | | `429` | `rate limited` or `rule budget exceeded: ` | Rate limit or rule budget | Yes, by waiting ([rate limits](rate-limits.md)); **there is no `Retry-After`** | | `500` | `internal error` | An unexpected server error; no detail is leaked | Yes, bounded | | `503` | `tenant unavailable`; header `Retry-After: 30` | The environment's placement is being moved or is temporarily unavailable | Yes, after `Retry-After` | Notes: - In body parsing errors, depending on the extractor, `415` (wrong `Content-Type`) or `422` (JSON that does not conform to the schema) may also be seen. Rely only on the status code for the decision. - The `401` and `403` difference: if the **identity** is missing or cannot be resolved, `401`; if the identity is valid but insufficient for this operation, `403`. On the player ticket endpoint all identity errors return `403` ([realtime protocol](../realtime/protocol.md)). - If you call a server endpoint with a client key, you get `403 permission denied`; if the key is unknown, it is `401`. ### Purchase (`POST /v1/player/store/listings/{listing_id}/buy`) error codes The player purchase endpoint states the rejection reason with a **JSON `code`**: `{"code":"out_of_stock","message":"…"}` (`message` is detail text; make your decision by `code`). | `code` | Status | Meaning | |---|---|---| | `not_visible` | 404 | The listing does not exist or is not visible to this player | | `listing_changed` | 409 | The listing's version (`expected_version`) changed; refresh the catalog and show it again | | `out_of_stock` | 422 | Out of stock | | `limit_reached` | 422 | The per-player purchase limit is full | | `insufficient_funds` | 422 | The balance is insufficient | | `inventory_full` | 422 | The inventory is full | Other errors (`400` header/body, `401`, `403` Origin or CSRF, idempotency mismatch `422`) are as in the general table above. An IAP listing cannot be bought from this endpoint (`422`). Details: [store guide](../guides/store-iap.md). ## Plugin endpoints Plugin endpoints return a **JSON envelope**; give the `requestId` value in support requests (the same value is also in the `x-request-id` header). ```json {"code":"unprocessable","message":"plugin command rejected","retryable":false,"requestId":"0192a3b4-0000-7000-8000-0000000000d1","reason":"street_reset_too_early"} ``` | `code` | Status | `retryable` | Note | |---|---|---|---| | `unauthorized` | 401 | no | Session/key missing or invalid; a client key | | `forbidden` | 403 | no | CSRF/Origin rejection; the plugin is not enabled; the version grant does not match; a banned account (`message: account banned`) | | `module_disabled` | 404 | no | The `module` field names the disabled module | | `not_found` | 404 | no | Unknown plugin or message type | | `invalid_argument` | 400 | no | Broken/missing header, unknown schema version, a payload that does not conform to the schema | | `conflict` | 409 | no | The `If-Match` version does not match | | `idempotency_in_flight` | 409 | **yes** | A request with the same key is being processed | | `idempotency_mismatch` | 422 | no | Same key, different body | | `unprocessable` | 422 | no | A business rule rejection; `reason` is a plugin-specific machine code | | `idempotency_expired` | 410 | no | The record expired | | `rate_limited` | 429 | **yes** | `Retry-After: 60` | | `tenant_unavailable` | 503 | **yes** | `Retry-After: 30` | | `internal` | 500 | **yes** | | Details and examples: [calling plugins](../plugins/calling-plugins.md). > **Note** > > On a plugin endpoint, a banned account, unlike the core identity endpoints, does not come with the `account_banned` code: `403`, `forbidden`, and `message: "account banned"` is returned. You need this distinction only for a user-interface message; do not build a retry loop. ## Differences between core and plugin | | Core REST | Plugin | |---|---|---| | Body | Plain text (a few JSON exceptions) | Always JSON | | Machine code | Only on errors that are JSON | `code` on every error | | `retryable` | None; decide by status code | Present | | Retry delay | Only `503` (30 s) | `429` (60 s) and `503` (30 s) | | Banned account | `403` `account_banned` (on sign-in endpoints) | `403` `forbidden` | | `x-request-id` | None | Present | ## Error handling recommendation 1. First look at the status code; for `401` renew the session or have the player sign in again; `403` is an authorization or configuration problem (key type, CSRF, module, plugin state). 2. For `404`, read the body: if it is JSON and `module_disabled`, the module is disabled; otherwise the resource does not exist. 3. Treat `409` in two separate ways: a version conflict (re-read the state) versus idempotency "in progress" (wait and repeat with the same key). 4. Use exponential backoff on `429` and `503`; if there is a `Retry-After`, wait for it (the example on the [idempotency and concurrency](idempotency-and-concurrency.md) page). 5. `422` is a business rule rejection; repeating the same request as is does not change the outcome. 6. The server does not leak error detail; when reporting a problem, give the status code, the request time, and (on plugin endpoints) the `requestId` ([troubleshooting](../operations/troubleshooting.md)). --- Source: https://open-play.b4klabs.dev/docs/en/reference/idempotency-and-concurrency.html # Idempotency and concurrency This page describes how to repeat a request **without applying it twice** when the network drops (idempotency, that is, replay safety), and how to handle two clients changing the same resource at the same time with **optimistic version checking** (optimistic locking). Use these two rules on every call that grants money, items, or rewards. ## Idempotency-Key A modifying request (`POST`, some `PUT`/`PATCH`/`DELETE`) can or must carry the `Idempotency-Key` header. The key's rules: | Rule | Value | |---|---| | Length | 1 to 200 characters (cannot be empty) | | Character set | Printable ASCII without whitespace (the SDK generates and accepts only this) | | Recommended value | A random UUID (v4); some endpoints **require UUID format** (clan, group, and tournament endpoints: it must be a valid UUID) | | Scope | In core commands the key is unique **across the environment**: if two different callers in the same environment use the same key, they collide. In plugin commands the key is per plugin and caller (player or server). Therefore always use a random UUID, and do not use predictable values such as `player-1` | | Record retention | 24 hours (command records); in plugin commands the operation's `receiptRetentionSeconds` value (86,400 s for now) | The [endpoint reference](endpoints.md) shows on which endpoint the key is **required** and on which it is **optional**. Example: it is optional on character commands, and required on wallet transfers, inventory and item operations, trading, store, and plugin commands. ### How the key works 1. The first request arrives with the key; the server applies the operation and stores the result bound to the key. 2. If **the same key + the same content** arrives again, the operation is not applied again; the recorded result is returned. Some responses signal this with the `replayed: true` field (character commands, store purchase). 3. **The same key + different content** gets `422` and `idempotency mismatch` (`idempotency_mismatch` in plugins). 4. If the first request is still being processed, the second gets `409` (`idempotent request in progress` or `idempotency_in_flight`); try again shortly **with the same key**. 5. If the record retention has expired, `410` is returned. 6. In core commands the key is **"burned" only on success**: an authorization rejection (for example a server command with a client key), a validation error, or a rolled-back operation does not consume the key; you can fix it and resend with the same key. > **Warning** > > Do not repeat a request with an uncertain outcome **with a new key**. A new key means a new operation and creates a double reward or double spend. Generate the key once, store it (so that it stays the same across the request and the retries), and query the result if needed. ### When to generate a new key - When the user **deliberately restarts the same operation** (for example a second store purchase). - When you change the state and retry after a business rule rejection (`422`). - After `410` (the key expired), after reading the state and making sure the operation was really not applied. ### Example: a safe retry The helper below generates the key once, retries with the same key on network errors and on `408`, `429`, `5xx` responses, and waits out `Retry-After`: ```ts /** `Retry-After` değerini milisaniyeye çevirir; saniye ya da HTTP tarihi olabilir, bilinmiyorsa `null`. */ function retryAfterMs(header: string | null): number | null { if (header === null) return null; if (/^\d+$/.test(header)) return Number(header) * 1000; const date = Date.parse(header); return Number.isNaN(date) ? null : Math.max(0, date - Date.now()); } const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]); /** * Bir değiştirici isteği AYNI `Idempotency-Key` ile güvenle yeniden dener. * Anahtarı bir kez üretin ve her denemede aynen gönderin; yeni anahtar yeni işlem demektir. * 429 ve 503'te sunucunun `Retry-After` süresini bekler; yoksa üstel geri çekilme kullanır. */ export async function postWithRetry(url: string, body: unknown, headers: Record, maxAttempts = 4): Promise { const idempotencyKey = crypto.randomUUID(); let lastError: unknown; for (let attempt = 1; attempt <= maxAttempts; attempt++) { let response: Response | undefined; try { response = await fetch(url, { method: "POST", credentials: "include", headers: { ...headers, "content-type": "application/json", "idempotency-key": idempotencyKey }, body: JSON.stringify(body), }); } catch (error) { lastError = error; // ağ hatası: istek uygulanmış olabilir, aynı anahtarla tekrar güvenlidir } if (response !== undefined) { if (!RETRYABLE_STATUS.has(response.status)) return response; // başarı ya da kalıcı hata lastError = new Error(`HTTP ${response.status}`); if (attempt === maxAttempts) return response; const wait = retryAfterMs(response.headers.get("retry-after")) ?? Math.min(200 * 2 ** (attempt - 1), 5000); await new Promise((resolve) => setTimeout(resolve, wait)); continue; } if (attempt < maxAttempts) await new Promise((resolve) => setTimeout(resolve, Math.min(200 * 2 ** (attempt - 1), 5000))); } throw lastError; } ``` The TypeScript SDK does this for you: `CrxClient` (server key) retries `GET` requests and requests that carry an `Idempotency-Key` on the `408`, `429`, `500`, `502`, `503`, `504` statuses (default 2 attempts, 0 to 5 with `maxRetries`) with exponential backoff and respecting `Retry-After`. On plugin commands, `PlayerClient` generates the key at preparation time and writes it to the browser journal; even if the page is refreshed, it continues with the same key ([bindings](../plugins/bindings.md)). ## Querying the result (plugin commands) For a plugin command, if you never received the response, query the result with `GET …/commands/{message_type}/result?idempotency_key=…` without resending. Only on a `404` (no record) response resend with the same key ([calling plugins](../plugins/calling-plugins.md)). There is no separate query endpoint for core commands; resending with the same key and body returns the same recorded result. ## Optimistic version checking The server keeps an increasing **version** (`version`) on modifiable resources (character, inventory, plugin resource, store listing). If you say in your request "I saw this version and made my decision", and in the meantime someone else changed the resource, the request is **not applied** and a conflict is returned. This way two clients do not silently overwrite each other's change. | Where used | How it is sent | |---|---| | Character commands (`POST /v1/characters/{id}/commands`) | `If-Match: 3` (a quoted `"3"` is also accepted) | | Inventory and item operations (endpoints that support `If-Match`) | `If-Match` | | Store administration endpoints (page, listing changes) | `If-Match` (optional) | | Plugin commands (HTTP) | `If-Match: "12"` | | Plugin commands (realtime `Command` frame) | `expectedVersion: "12"` (decimal text) | | Player purchase (`/v1/player/store/listings/{id}/buy`) | **`If-Match` is not used**; the listing version is the `expected_version` field in the body (if you send the header, `400`) | The `If-Match` format: an integer version number. `*` and weak validators (`W/"3"`) are **rejected** (`400`); a negative number and a non-numeric value get `400`. If the header is not given, no version check is made (the last writer wins); we recommend sending it on critical resources such as money and items. ### When a conflict comes | Endpoint family | Response | |---|---| | Core REST | `409` and `version conflict: expected 3, actual 5` | | Plugin | `409` and `{"code":"conflict","message":"version conflict: expected 3, actual 5",…}` | | Store player purchase | `409` and `{"code":"listing_changed",…}` | | Realtime command | An `Error` frame `InvalidPayload` (`retryable: false`); the detail is not given over the wire | The resolution loop: 1. Re-read the resource (with the current `version`). 2. Decide whether the user's intent is still valid (for example whether there is still enough balance). 3. Send again **with a new key** (this is a new attempt) with the current version. Note: a version conflict is not a business rule but a result of concurrency; blindly sending the same request again will conflict again. The idempotency key is for repetition, and version checking is for conflict; you can use both together: a repeat sent with the same key + the same `If-Match` returns the recorded result. ## Summary rule list - Add a key to **every** request that grants money, items, rewards, or sends a command; generate the key once and do not change it on retries. - **Query** an uncertain outcome or repeat it **with the same key**; never with a new key. - When writing a shared resource, send `If-Match`/`expectedVersion`; on `409`, re-read. - `422` is a business rule rejection; repeating does not change the outcome. - For the detailed error table, see the [error reference](errors.md). --- Source: https://open-play.b4klabs.dev/docs/en/reference/pagination.html # Pagination This page describes how to walk the pages of endpoints that return lists. In Open Play there is **no single pagination format**: most endpoints use an opaque cursor, while a few use a different method. The table below shows which applies on which endpoint and the page size limits. ## Cursor (keyset) pagination Most core list endpoints are **cursor-based**: - Request: `?limit=&cursor=`. On the first page you do not provide `cursor`. - Response: `{"items":[…],"next_cursor":"…"}`. If `next_cursor` is **`null`**, you are on the last page. - Next page: give **the `next_cursor` from the previous response** exactly as the `cursor`. - The cursor is **opaque**: do not parse its contents, do not modify it, and do not assume you can store it and use it after a long time. A broken cursor gets `400`. - The ordering is fixed and `OFFSET` is not used; if new records are added between pages, no skipping or repetition occurs (records are ordered by a stable key). - The page size is given with `limit`; a value outside the bounds is **clamped** on most endpoints (it does not give an error). The exceptions are noted in the table. ```http GET /v1/characters?limit=2 HTTP/1.1 Authorization: Bearer crx_live__ ``` ```json {"items":[{"id":"…","definition":"hero","version":3}],"next_cursor":""} ``` ```http GET /v1/characters?limit=2&cursor= HTTP/1.1 Authorization: Bearer crx_live__ ``` ## Formats by endpoint | Endpoint | Format | Default | Limit | |---|---|---|---| | `GET /v1/characters` | Cursor (`definition` filter) | 50 | clamped between 1 and 200 | | `GET /v1/characters/{id}/events`, `…/timeline` | Cursor (ascending `seq`) | 50 | clamped between 1 and 200 | | `GET /v1/rules`, `GET /v1/webhooks` | Cursor | 50 | clamped between 1 and 200 | | `GET /v1/ledger` | Cursor (`wallet_id` required) | 50 | clamped between 1 and 200 | | `GET /v1/items`, `GET /v1/player/items` | Cursor (with `owner`) | 50 | clamped between 1 and 200 | | `GET /v1/assets` | Cursor | 50 (`0` or absent = default) | at most 200 | | `GET /v1/catalog/items`, `GET /v1/catalog/characters` | Cursor | 50 | clamped between 1 and 100 | | `GET /v1/notifications` | Cursor (`status`, `limit`, `cursor`) | 50 | clamped between 1 and 100 | | `GET /v1/leaderboards/{key}/entries` | Cursor | 20 (`0` or absent = default) | **at most 100; more gets `400`** (not clamped) | | `GET /v1/leaderboards/{key}/around-me` | Around the player (`player_id`, `radius`) | — | No pages; the surrounding windows are bounded | | `GET /v1/player/leaderboards/{key}` | Single page (`limit`) | 20 | **1 to 50; outside that `400`** | | `GET /v1/chat/channels/{channel_id}/messages` | Time-based (`before_ms` + `limit`) | 50 | 1 to 100 (the service validates) | | `GET /v1/quests` | **`offset` + `limit`** (`search` filter) | 20 (`0` or absent = default) | **`limit` at most 100, `offset` at most 100000; more gets `400`** | | `GET /v1/auth/admin/list-players` | Cursor (UUID) | the endpoint's default | Endpoint-specific | | `GET /v1/clans`, `GET /v1/parties` | No pagination; the whole result (a `player_id` filter for parties) | — | — | | `GET /v1/wallets`, `GET /v1/inventories/{owner}` | No pagination (the whole list by owner) | — | — | | Plugin queries (`/v1/plugins/…/queries/…`) | Plugin-specific; defined in the input schema | — | The plugin's schema document | > **Note** > > In the response, the cursor field comes as `next_cursor` (snake_case) in core REST. In chat messages, to get a new page you give the oldest message's `sent_at_ms` value as `before_ms`. The pagination of the panel APIs (`offset`/grid) is for the panel interface's internal use and is outside the scope of this documentation. ## Pagination in the SDK The TypeScript SDK offers iterators for cursor lists: `iterateListCharacterEvents`, `iterateGetCharacterTimeline`, `iterateListRules`, `iterateListWebhooks`. These iterators follow `next_cursor` for you. Important rules: - The cursor is opaque; the iterator gives it back without converting it. - An empty `next_cursor` ends the loop; a cursor loop (the same cursor coming back) or exceeding `maxPages` is **not a silent cut-off but an error of type `pagination`**. - `limit` is `u32` on the wire; the server clamps it to the range 1 to 200. ## Patterns **Walking everything** (server-to-server work): build a loop until the cursor comes back empty; put a short upper bound on pages (for example at most 1000 pages). ```text cursor = none repeat: page = GET /v1/rules?limit=200[&cursor=cursor] process page.items cursor = page.next_cursor stop if cursor is empty ``` **An in-game "load more" list:** use a small `limit` (20 to 50), keep `next_cursor` in memory, and request the next page as the user scrolls. Do not store the cursor persistently. **Consistency:** cursor pages walk live data; new records added while walking may not be visible if they fall behind the cursor. For reports that need a snapshot, record the query time (for example `as of`) on your side. Related: [endpoints](endpoints.md), [rate limits](rate-limits.md). --- Source: https://open-play.b4klabs.dev/docs/en/reference/rate-limits.html # Rate limits This page describes the rate limits Open Play applies today and what to do when you get a `429`. An important honesty note: **there is no general rate limit per API key.** Limits are placed on specific operations (sign-in, sign-up, chat, plugin command, socket connection); the other endpoints are unlimited and you must manage the load. ## What you need to know - **There is no general "N requests per minute" quota.** For calls made with a server key, there is no general limit per key or per environment. - Counters are kept in Redis and **if Redis cannot be reached, the limit is not applied** (fail-open); the only exception is the audit-row ceiling noted below. Do not use the limit alone as a security control. - The `Retry-After` header exists only on the plugin `429` (60 s) and on `503` responses (30 s). The `429` of the core and identity endpoints carries no `Retry-After`; apply your own backoff. - The client IP is the key of the limits on the identity endpoints. If the reverse proxy does not forward the client IP, **all players share the same bucket** ([production checklist](../operations/production-checklist.md)). ## Identity endpoints (the player sign-in line) These limits are applied in the service layer; they are the same in REST itself. The window is fixed (it starts with the first request and the counter resets when the time expires; it is not a sliding window); an overage returns `429` and the plain text `rate limited` (no `Retry-After`). | Bucket | Limit | Window | Key | Which endpoints | |---|---|---|---|---| | Opening a guest | 30 | 60 s | Client IP | `POST /v1/auth/sign-in/guest` (re-sign-in counts too) | | Email sign-in | 5 | 60 s | Client IP | `POST /v1/auth/sign-in/email` (every attempt) | | Failed sign-in lock | 10 | 900 s | Address + IP | Wrong password attempts; the allowance is refunded on a successful sign-in; a wrong current password on a password change counts too | | Sign-up (IP) | 10 | 60 s | Client IP | `POST /v1/auth/sign-up/email`, guest upgrade | | Sign-up (session) | 30 | 60 s | Session owner | `POST /v1/auth/guest/upgrade/email` | | Sign-up (address) | 5 | 900 s | Email address | Sign-up and upgrade | | Password change (IP) | 10 | 60 s | Client IP | `POST /v1/auth/change-password` | | Password change (account) | 6 | 60 s | Player | `POST /v1/auth/change-password` | | Recovery (IP) | 5 | 60 s | Client IP | `forgot-password`, `reset-password`, `sign-in/magic-link` (the three endpoints share one bucket) | | Recovery (address) | 5 | 900 s | Email address | `forgot-password`, `sign-in/magic-link` | | OAuth start | 30 | 60 s | Client IP | `sign-in/oauth/{provider}`, `link/{provider}` | | OAuth return | 60 | 60 s | Client IP | `callback/{provider}` | In addition: | Limit | Limit | Key | Note | |---|---|---|---| | Display name change | 10 / 3600 s | Player | `POST /v1/auth/session/display-name`; an overage is `429` | | Account deletion attempt | 5 / 3600 s | Player | `POST /v1/auth/account/delete`; an overage is `429` | | Failed sign-in audit row | 20 / 3600 s | Account | Does **not** reject the sign-in; it only throttles the background audit record. During a Redis outage the record is skipped (fail-closed) | > **Note** > > The guest opening bucket is per IP. If the reverse proxy does not forward the client IP, or many players connect from behind a NAT/dormitory network, the limit of 30 guests/minute fills quickly. If you plan a launch or a load test, verify IP forwarding. ## Chat and notifications | Limit | Limit | Window | Key | When exceeded | |---|---|---|---|---| | Sending a chat message | 20 | 10 s | Environment + player | `429` in REST; `message_rejected` on the chat socket | | Notification delivery | 60 | 60 s | Environment + player | Delivery is deferred (the provider is not hit), no error returns to the player | ## Plugin commands Plugin commands and queries can be subject to per-minute budgets **per player, per bucket**. The plugin defines the budget; the server applies it with the same decision on the REST, realtime command, and gRPC paths. An overage arrives with the plugin envelope: `429`, `code: "rate_limited"`, `retryable: true`, `Retry-After: 60`. The plugin that defines a budget today is `package-ride`: 6 to 60 commands per bucket in a 60 s window (for example delivery accept/start/complete 12, login bonus 10, quest reward and garage 30, collision/race bet 60). The bucket values are plugin-specific and may change; for exact numbers, see the plugin's documentation. The operator can put some buckets into "observation" mode; in this mode an overage is only counted, and the command is not rejected. In the Unity SDK, `PluginClient.RetryAfter(error)` returns **60 seconds** for the plugin `429`. ## Realtime socket | Limit | Value | When exceeded | |---|---|---| | New connections per address | 30 / minute | The upgrade is rejected | | Open sockets per address | 8 | `CapacityExceeded`, the socket closes | | Open sockets per player | 4 | `CapacityExceeded`, the socket closes | | Per-connection frame rate | 40/s (a 160-frame burst allowance) | `CapacityExceeded` (`retryable: true`), the socket closes | Details: [reconnect and limits](../realtime/reconnect-and-limits.md). ## When you get a 429 ```http HTTP/1.1 429 Too Many Requests Content-Type: text/plain; charset=utf-8 rate limited ``` On a plugin endpoint: ```http HTTP/1.1 429 Too Many Requests Content-Type: application/json Retry-After: 60 x-request-id: 0192a3b4-0000-7000-8000-0000000000d1 {"code":"rate_limited","message":"rate limited","retryable":true,"requestId":"0192a3b4-0000-7000-8000-0000000000d1"} ``` 1. If there is a `Retry-After`, wait that long; if not, use exponential backoff (for example 1, 2, 4, 8 s; upper bound 30 s) and add a random jitter. 2. If the request is modifying, repeat with **the same `Idempotency-Key`** ([idempotency and concurrency](idempotency-and-concurrency.md)). 3. For limits triggered by a user action (sign-in, sign-up), instead of an automatic retry show the user the wait time; on the password and sign-up endpoints an automatic retry extends the lock time. 4. `CrxClient` (server key) retries a `429` on `GET` and keyed requests itself, respecting `Retry-After` (default 2 attempts). > **Warning** > > A rule budget overage (`rule budget exceeded: `) also returns `429`; this is not a rate limit but the rule engine budget (50 rules per command, 200 actions, 500 evaluation nodes, and chain depth 5). Retrying does not change the outcome; simplify the rule ([game rules](../guides/game-rules.md)). ## What you need to do on your side Because there is no general server limit, you need to add protection under heavy load: - In server-to-server bulk jobs, limit concurrency (for example 5 to 10 requests at a time) and manage errors with backoff. - Measure the request rate per key on your own side; a single leaked key can make unlimited calls ([security](../operations/security.md)). - Before a launch, cache `GET /v1/…` reads (catalog endpoints support conditional reads with `ETag` and `If-None-Match`; do not poll per player more often than every 15 s). --- Source: https://open-play.b4klabs.dev/docs/en/reference/numbers-and-encoding.html # Numbers and encoding This page describes how Open Play carries numbers, money, time, and identifiers over JSON. The most common mistake is converting 64-bit integers to JavaScript `number` and losing precision; read this page to avoid that mistake. ## Short rules | Field type | Core REST | Plugin and realtime | |---|---|---| | 64-bit integer (`int64`) | JSON **number** (`"amount": 250`) | Decimal **text** (`"amount": "250"`) | | 32-bit integer | JSON number | JSON number | | Floating point | **Nowhere** | **Nowhere** | | Money | Minor-unit integer (`amount`, `balance`) | Minor unit, `...Minor` fields, `int64` | | Percentage | Basis point (10000 = 100%) | Basis point | | Time | RFC 3339 text (`created_at`) | Unix milliseconds integer (`...Ms`) | | Identifier | UUID text | UUID text | | Enum | `snake_case` text | `snake_case` (plugin), `PascalCase` (realtime frame types) | ## 64-bit integers JavaScript's `JSON.parse` converts numbers to `double`; integers above `2^53 − 1` (9,007,199,254,740,991) are **silently corrupted**. Open Play therefore uses two profiles: - **Core REST** sends `int64` fields as JSON numbers (`version`, `balance`, `amount`). In practice the values stay in the safe range, but the guarantee is a parser that reads with `bigint`. The TypeScript SDK reads with `lossless-json` and returns `bigint`. - **Plugin and realtime** (the `plugin-v1-decimal-int64` encoding profile) send and expect `int64` fields as **decimal text**: `{"expectedVersion":"3"}`, `{"sessionEpoch":"7"}`. This way every client language carries them losslessly. ### Canonical decimal text In plugins a signed `int64` must be written in canonical form; the server rejects any other form (`400 invalid_argument`): | Valid | Invalid | Why | |---|---|---| | `"0"`, `"42"`, `"-7"` | `"+1"` | No plus sign | | `"9223372036854775807"` | `"01"`, `"007"` | No leading zero | | `"-9223372036854775808"` | `"-0"` | No negative zero | | | `" 5"`, `"5 "` | No whitespace | | | `"1.5"`, `"1e3"` | No fraction or exponent | | | `"9223372036854775808"` | The `int64` bound was exceeded | | | `5` (JSON number) | A plugin `int64` field wants text | The counters in realtime frames (`sessionEpoch`, `inputSeq`, `revision`, `sequence`, `expectedVersion`, `streamEpoch`, `barrierSequence`) are in the same text form and **cannot be negative**. `protocolVersion`, `schemaVersion`, and other small counters are JSON numbers. In the plugin schema document, the bounds of an `int64` field are also written as text (`"minimum": "0"`, `"maximum": "9223372036854775807"`); this is normal. ### In client languages | Language | Mapping | |---|---| | TypeScript SDK | `int64` → `bigint`. Do not convert to a number; use `value.toString()` for display. Do not read JSON yourself with `JSON.parse` | | Unity (C#) | `int64` → `long`; in plugin outputs, raw JSON `JsonValue` stays as decimal text | | Raw JSON (another language) | Read plugin fields as `string` and convert to `int64` | If you parse JSON yourself in TypeScript, use a library such as `lossless-json`; otherwise large values are corrupted. ## Money and scaled values - Money values are **minor-unit integers** (for example cents): how many main units `250` is depends on your game's display decision (the currency's "minor digits" setting in the panel determines the display); the API carries only the integer. Balance and amount fields come as `amount`, `balance`, and in plugins as `...Minor`. - Floating point (`float`/`double`) is **used nowhere**: not in requests, not in responses, and not in computation. On your side too, do not use `float` for money; work with integers and convert to decimals only for display. - Scaled values in content groups are an `integer + scale` pair: `4350` and scale `2` → `43.50`. In the TypeScript SDK, `formatScaled(value: bigint, scale)` formats this losslessly (`0 to 18` digits). Do not do your own `float` division. ## Percentages: basis points Percentage and ratio fields are **basis point** integers: `10000` = 100%, `2500` = 25%, `1` = 0.01%. Example: the group reward share `share_bp` must be between `0` and `10000`; outside that gets `400`. ## Time | Where | Format | Example | |---|---|---| | Core REST responses (`created_at`, `as_of`) | RFC 3339 | `2026-11-03T10:15:30+00:00` | | Plugin and realtime (`createdAtMs`, `sent_at_ms`) | Unix epoch milliseconds, integer | `1793700930000` | | Chat query `before_ms` | Unix epoch milliseconds | `1793700930000` | | `t` in the webhook signature | Unix epoch seconds | `1793700930` | | `Deprecation` header | `@` (RFC 9745) | `@1793491200` | | Plugin deprecation (`sinceUnixS`) | Unix seconds | `1793577600` | Interpret server times as **UTC**. The client clock is not used in server decisions: time and randomness are generated on the server and written to the event. ## Identifiers and keys - **UUID:** lowercase, hyphenated text (`0192a3b4-0000-7000-8000-000000000001`). Most resource IDs are time-ordered **UUID v7**; you can use them for ordering, but do not infer meaning from the format. - **`key` fields** (definition keys, leaderboard, quest): readable identifiers given by the game developer; use them exactly as created, do not change the case or whitespace. - **Plugin IDs:** lowercase and hyphens (`package-ride`). **Message types:** dot-separated, plugin-prefixed (`package-ride.delivery-trip.active.input`). - **Cursors:** opaque text; do not parse their contents ([pagination](pagination.md)). - **API keys:** `crx_live__`; the prefix is only for identification, the secret part is shown only once ([security](../operations/security.md)). ## Enumeration (enum) values All enumeration values come from a single definition on the server, and an unknown value is **rejected** (not silently ignored): - Core REST fields are `snake_case` text (`"type": "set_flag"`, `"channel": "in_game"`, `"status": "pending"`). - Realtime frame `type` and `kind` values are `PascalCase` (`Authenticate`, `PlayerSelf`); the field names are `camelCase`. - Chat socket frame `type` values are `camelCase`, and the field names are `snake_case` (`channel_id`). - Webhook event types are `snake_case` ([webhook events](webhook-events.md)). - Values listed as `enum` in a plugin schema document (for example `"image/png"`) are used exactly. If an unknown enum value arrives in your client (it may have been added in a new version), fall back to a safe default instead of crashing; the SDKs reject an unknown value in some places and skip it in others, so follow version upgrades from the changelog. ## Text - All text is UTF-8 JSON text. - Fields have length limits (for example `Idempotency-Key` 200 characters, `minLength`/`maxLength` on plugin string fields); a limit violation gets `400`. - Plain-text error messages (core REST) can be in English or Turkish and change between versions; do not use them for machine decisions ([errors](errors.md)). --- Source: https://open-play.b4klabs.dev/docs/en/reference/versioning.html # Versioning This page describes the version numbers you will encounter in Open Play (API path, protocol, plugin schema, SDK) and how the server notifies you when an endpoint or operation is being deprecated. Important: **there is no written breaking-change and removal policy yet**; below, only the behavior that is in the code today is described. ## What each version tells you | Version | Where it appears | Meaning | |---|---|---| | API path `/v1` | All endpoint paths | The single path version; there is no `/v2` | | Realtime `protocolVersion` | In every frame (`1`) | The socket protocol major version; a different value gets `UnsupportedProtocol` | | Plugin `protocolVersion` | Schema document (`1`) | The major version of the plugin contract | | `codecProfile` | Schema document (`plugin-v1-decimal-int64`) | The wire encoding; a single profile for now | | Plugin `version` | Schema document (for example `1.44.0`) | The plugin's version text | | `schemaVersion` | Every operation and message definition; the `x-crx-schema-version` header | The schema version of a single message (a positive integer) | | `schemaSha256` | Schema document | The digest of the schemas; the pinning key ([bindings](../plugins/bindings.md)) | | SDK version | Package version | TypeScript `@ferdiunal/open-play-ts` (0.x), Unity `com.openplay.sdk` (0.x) | ### `/v1` and the server version All HTTP endpoints are under `/v1`. The server has no separate "API version" header; which endpoints exist is shown by the [endpoint reference](endpoints.md) and (for core REST) `GET /openapi.json`. `/openapi.json` is a hand-compiled document that covers core REST: it **does not cover plugins, WebSockets, or the panel API** and may not be complete for every endpoint. ### Protocol version `protocolVersion: 1` is required in realtime frames; any other value gets an `UnsupportedProtocol` error and the socket closes. If your client gets this error, instead of retrying ask the user to update (the TypeScript SDK moves to the `upgradeRequired` state in this case). ### Plugin schema versions A plugin schema is versioned at two levels: 1. **Operation/message level** `schemaVersion`: declared on every request with `x-crx-schema-version`. If you send a version different from what the server knows, you get `400 invalid_argument`. 2. **Document level** `schemaSha256`: any change in the schemas changes the digest. You pin your client to this digest. The version text (`1.44.0`) is informational; make the compatibility decision by the digest. For how to verify the plugin version and the digest match, see [bindings](../plugins/bindings.md). ## Deprecation When an endpoint or operation is being deprecated, it **keeps working**; its behavior, status codes, and body do not change. The deprecation is notified to you with these signals: | Signal | Where | |---|---| | The `Deprecation: @` response header ([RFC 9745](https://www.rfc-editor.org/rfc/rfc9745)) | On every deprecated response (successful or erroneous) | | OpenAPI `deprecated: true` | On the relevant operation in `/openapi.json` | | A `deprecated` object in the schema document | On plugin operations: `since` (version text), `sinceUnixS`, and `replacement` (the short name of the surface that replaces it) | | `@deprecated` in the SDK | On the relevant operation in the generated TypeScript binding | What is deprecated today: | Surface | Header value | Replacement | |---|---|---| | `/v1/auth/organization/*` (player organizations, 19 endpoints) | `@1793491200` (2026-11-01 UTC) | None; the organization feature is being deprecated, do not confuse it with the studio concept | | The `package-ride` plugin's daily score queries (`getDailyBoard`, `getLeaderboard`) | `@1793577600` (2026-11-02 UTC) | The core leaderboard ("Daily earnings") | | The `package-ride` operator player view (`operatorPlayer`, `operatorPlayers`) | `@1793577600` | The core character profile and list | The header's value gives the moment the deprecation **started** (UTC unix seconds); it does **not** give a removal date. There is no `Sunset` header in the response. > **Warning** > > There is **no** promise such as a removal date, a minimum notice period, or "a deprecated endpoint works for at least N months". Do not add a new dependency on a deprecated surface, and when you see the `Deprecation` header, make a migration plan. To track the header in your logs, record the response headers. ## SDK versions - The TypeScript SDK is in `0.x` versions; breaking changes may occur between `0.x` versions. Follow each version's changes from the package's changelog and release notes; pin the version exactly (like `0.1.15`), and do not use a `^` range. - The Unity SDK is at version `0.1.0` and is verified only with Unity `6000.6.7f1` ([Unity SDK](../sdk/unity.md)). - The SDK and the server evolve together: a new SDK feature (for example leaderboard, store) requires the server version that carries that feature. Do not update the SDK before the server. - In plugin bindings, compatibility depends not on the SDK version but on the `schemaSha256` pin ([bindings](../plugins/bindings.md)). ## What to do 1. Write your SDK version and your plugin `schemaSha256` values into your release manifest. 2. After every server update, compare the schema document and, if needed, regenerate the binding. 3. Open a migration task for every call that sees the `Deprecation` header. 4. On a realtime and plugin protocol version mismatch, ask the user to update; do not build a retry loop. --- Source: https://open-play.b4klabs.dev/docs/en/reference/endpoints.html # Endpoint reference This page shows, for every HTTP endpoint, which credentials to call it with and which headers it expects. The table is generated from the route registry in the code; it changes when an endpoint is added. Descriptions are currently in Turkish. ## How to read it - **Module:** the endpoints of a disabled module return `404` with `module_disabled` ([errors](errors.md)). Core endpoints cannot be disabled. - **Credentials:** server and client keys are sent as `Authorization: Bearer crx_live__`. A player session arrives as a cookie or a Bearer session token and needs the `x-crx-env: ` header; with a cookie session, mutating requests also carry `x-csrf-token` and an allowed `Origin`. - Calling an endpoint marked **Server key** with a client key returns `403`. Endpoints marked **Server or client key** have no key-type gate in the code; being callable with a key embedded in the client is a security decision, so call these endpoints only from your server ([security](../operations/security.md)). - **Idempotency-Key:** `required` means the request gets `400` without the header; `optional` makes the request safe to repeat ([idempotency](idempotency-and-concurrency.md)). **If-Match:** the endpoint supports optimistic version checks. - For plugin endpoints (`/v1/plugins/...`) the requirements live in the plugin's schema document; see [calling plugins](../plugins/calling-plugins.md). Total endpoints: **225**. ## Characters `characters` | Method | Path | Credentials | Idempotency-Key | If-Match | Description | |---|---|---|---|---|---| | `GET` | `/v1/characters` | Server or client key | — | — | Karakterleri listeler (imleç sayfalama, `definition` süzgeci). | | `POST` | `/v1/characters` | Server or client key | — | — | Bir tanımdan (definition) yeni karakter üretir. | | `GET` | `/v1/characters/{id}` | Server or client key | — | — | Tek karakteri durumuyla okur. | | `DELETE` | `/v1/characters/{id}` | Server key | — | — | Karakteri siler (yumuşak silme). | | `POST` | `/v1/characters/{id}/commands` | Server key (client key only for `set_flag`) | optional | yes | Karakter komutunu çalıştırır (`set_flag`, `set_attribute`, `add_resource`, `add_modifier`, `remove_modifier`, `add_xp`, `rollback`). | | `GET` | `/v1/characters/{id}/events` | Server or client key | — | — | Karakterin olay akışını listeler (imleç sayfalama, artan `seq`). | | `GET` | `/v1/characters/{id}/state` | Server or client key | — | — | Karakterin `at` anındaki durumunu olayları yeniden oynatarak verir. | | `GET` | `/v1/characters/{id}/stats` | Server or client key | — | — | Etkin istatistikleri adım adım döküm (breakdown) ile verir. | | `GET` | `/v1/characters/{id}/timeline` | Server or client key | — | — | Olay akışını okunabilir özetlerle verir. | | `POST` | `/v1/definitions` | Server key | — | — | Karakter tanımını kaydeder (değişmez sürüm üretir). | | `GET` | `/v1/definitions/{key}` | Server or client key | — | — | Güncel tanımı çözülmüş şemasıyla okur. | | `POST` | `/v1/level-curves` | Server key | — | — | Seviye eğrisini doğrulayıp kaydeder (değişmez sürüm). | | `GET` | `/v1/level-curves/{key}` | Server or client key | — | — | Güncel seviye eğrisini okur. | | `GET` | `/v1/rules` | Server or client key | — | — | Kuralları listeler (imleç sayfalama). | | `POST` | `/v1/rules` | Server key | — | — | Kural kaydeder; derlenemeyen kural hiç yazılmaz. | | `POST` | `/v1/rules/dry-run` | Server or client key | — | — | Kuralı gerçek bir karakter üzerinde yazmadan simüle eder. | | `GET` | `/v1/rules/{key}` | Server or client key | — | — | Kuralın güncel sürümünü okur. | | `DELETE` | `/v1/rules/{key}` | Server key | — | — | Kuralı siler (yumuşak silme). | | `POST` | `/v1/rules/{key}/disable` | Server key | — | — | Kuralı değerlendirme zincirinden çıkarır. | | `POST` | `/v1/rules/{key}/enable` | Server key | — | — | Kuralı değerlendirme zincirine alır. | | `POST` | `/v1/traits` | Server key | — | — | Özelliği (trait) kaydeder (değişmez sürüm). | | `GET` | `/v1/catalog/characters` | Server key or player session | — | — | Karakter kataloğunu listeler. | | `GET` | `/v1/catalog/characters/{key}` | Server key or player session | — | — | Karakter katalog girdisini okur. | ## Economy `economy` | Method | Path | Credentials | Idempotency-Key | If-Match | Description | |---|---|---|---|---|---| | `GET` | `/v1/auth/admin/player-wallet-movements` | Server key or player session (admin role) | — | — | Bir oyuncunun cüzdan hareketlerini listeler. | | `POST` | `/v1/currencies` | Server key | — | — | Para birimi tanımlar. | | `GET` | `/v1/economy/faucet-sink` | Server key | — | — | Günlük kaynak (faucet) ve gider (sink) toplamlarını verir. | | `GET` | `/v1/economy/flags` | Server key | — | — | Ekonomi anomali işaretlerini listeler. | | `GET` | `/v1/economy/merchant-gap` | Server key | — | — | Tüccar alış-satış farkı raporunu verir. | | `GET` | `/v1/economy/top-holders` | Server key | — | — | Bir para biriminde en çok bakiyeye sahip oyuncuları listeler. | | `GET` | `/v1/ledger` | Server or client key | — | — | Bir cüzdanın defter (ledger) kayıtlarını listeler (imleç sayfalama). | | `POST` | `/v1/transfers` | Server key | required | — | İki cüzdan arasında para aktarır. | | `GET` | `/v1/wallets` | Server or client key | — | — | Bir sahibin cüzdanlarını (`owner`) listeler. | ## Items `items` | Method | Path | Credentials | Idempotency-Key | If-Match | Description | |---|---|---|---|---|---| | `GET` | `/v1/inventories/{owner}` | Server or client key | — | — | Sahibin envanterini okur. | | `POST` | `/v1/inventories/{owner}/equip` | Server key | required | yes | Eşyayı donatır. | | `POST` | `/v1/inventories/{owner}/move` | Server key | required | yes | Eşyayı envanterde başka bir yuvaya taşır. | | `POST` | `/v1/inventories/{owner}/split` | Server key | required | yes | Bir yığını ikiye böler. | | `POST` | `/v1/inventories/{owner}/stack` | Server key | required | yes | İki yığını birleştirir. | | `POST` | `/v1/inventories/{owner}/unequip` | Server key | required | yes | Donatılmış eşyayı çıkarır. | | `POST` | `/v1/item-definitions` | Server key | — | — | Eşya tanımını kaydeder. | | `GET` | `/v1/item-definitions/{key}` | Server or client key | — | — | Eşya tanımını okur. | | `GET` | `/v1/items` | Server or client key | — | — | Bir sahibin eşya örneklerini listeler (imleç sayfalama). | | `POST` | `/v1/items` | Server key | required | — | Yeni eşya örneği üretir (mint). | | `GET` | `/v1/items/{id}` | Server or client key | — | — | Tek eşya örneğini okur. | | `POST` | `/v1/items/{id}/consume` | Server key | required | yes | Eşyayı tüketir. | | `POST` | `/v1/items/{id}/destroy` | Server key | required | yes | Eşyayı yok eder. | | `POST` | `/v1/items/{id}/transfer` | Server key | required | yes | Eşyayı başka bir sahibe aktarır. | | `GET` | `/v1/player/inventory` | Player session | — | — | Oturumdaki oyuncunun envanterini okur. | | `GET` | `/v1/player/items` | Player session | — | — | Oturumdaki oyuncunun eşyalarını listeler (imleç sayfalama). | | `GET` | `/v1/catalog/items` | Server key or player session | — | — | Eşya kataloğunu listeler. | | `GET` | `/v1/catalog/items/{key}` | Server key or player session | — | — | Eşya katalog girdisini okur. | ## Trading `trading` | Method | Path | Credentials | Idempotency-Key | If-Match | Description | |---|---|---|---|---|---| | `POST` | `/v1/merchants` | Server key | — | — | Tüccar tanımlar. | | `GET` | `/v1/merchants/{key}` | Server or client key | — | — | Tüccarı okur. | | `POST` | `/v1/merchants/{key}/buy` | Server key | required | — | Oyuncunun tüccardan satın almasını yürütür. | | `POST` | `/v1/merchants/{key}/sell` | Server key | required | — | Oyuncunun tüccara satışını yürütür. | | `POST` | `/v1/trades` | Server key | required | — | İki oyuncu arasında takas başlatır. | | `GET` | `/v1/trades/{id}` | Server key | — | — | Takası okur. | | `POST` | `/v1/trades/{id}/accept` | Server key | required | — | Takası kabul eder. | | `POST` | `/v1/trades/{id}/cancel` | Server key | required | — | Takası iptal eder. | | `POST` | `/v1/trades/{id}/offer` | Server key | required | — | Takasa teklif ekler ya da günceller. | ## Store `store` | Method | Path | Credentials | Idempotency-Key | If-Match | Description | |---|---|---|---|---|---| | `POST` | `/v1/iap/verify` | Server key | required | — | Mağaza makbuzunu doğrular ve ürünü teslim eder (IAP). | | `GET` | `/v1/store/catalog` | Server key | — | — | Ham mağaza kataloğunu verir (yönetim görünümü). | | `PUT` | `/v1/store/listings/{id}` | Server key | required | yes | Mağaza ilanını günceller. | | `DELETE` | `/v1/store/listings/{id}` | Server key | required | yes | Mağaza ilanını siler. | | `POST` | `/v1/store/listings/{listing_id}/reserve` | Server key | required | yes | İlan için rezervasyon açar. | | `GET` | `/v1/store/pages` | Player session | — | — | Oturumdaki oyuncunun görebildiği mağaza sayfalarını verir. | | `POST` | `/v1/store/pages` | Server key | required | yes | Mağaza sayfası oluşturur. | | `POST` | `/v1/store/pages/reorder` | Server key | required | yes | Mağaza sayfalarını yeniden sıralar. | | `PUT` | `/v1/store/pages/{id}` | Server key | required | yes | Mağaza sayfasını günceller. | | `DELETE` | `/v1/store/pages/{id}` | Server key | required | yes | Mağaza sayfasını siler. | | `POST` | `/v1/store/pages/{page_id}/listings` | Server key | required | yes | Sayfaya ilan ekler. | | `POST` | `/v1/store/pages/{page_id}/listings/reorder` | Server key | required | yes | Sayfadaki ilanları yeniden sıralar. | | `GET` | `/v1/store/preview/{player_id}` | Server key | — | — | Belirli bir oyuncunun göreceği vitrini önizler. | | `POST` | `/v1/store/reservations/{id}/cancel` | Server key | required | yes | Rezervasyonu iptal eder. | | `POST` | `/v1/store/reservations/{id}/checkout/currency` | Server key | optional | yes | Rezervasyonu oyun içi para ile tahsil eder. | | `POST` | `/v1/store/reservations/{id}/checkout/iap` | Server key | optional | yes | Rezervasyonu mağaza makbuzu ile tahsil eder. | | `POST` | `/v1/player/store/listings/{listing_id}/buy` | Player session | required | yes | Oyuncu, kendi oturumuyla ilanı oyun içi para ile satın alır. | | `POST` | `/v1/webhooks/iap/{platform}/{environment_id}` | Store platform signature | — | — | Mağaza platformunun iade/iptal bildirimini alır. | ## Rewards `rewards` | Method | Path | Credentials | Idempotency-Key | If-Match | Description | |---|---|---|---|---|---| | `GET` | `/v1/login-rewards/{player_id}` | Server key | — | — | Giriş ödülü durumunu verir. | | `POST` | `/v1/login-rewards/{player_id}/claim` | Server key | — | — | Giriş ödülünü teslim eder. | | `POST` | `/v1/npc-rewards/{key}/claim` | Server key | required | — | NPC ödülünü teslim eder. | | `GET` | `/v1/npc-rewards/{key}/eligibility` | Server key | — | — | NPC ödülüne uygunluğu sorgular. | | `POST` | `/v1/reward-tables` | Server key | — | — | Ödül tablosunu kaydeder. | | `GET` | `/v1/reward-tables/{key}` | Server key | — | — | Ödül tablosunu okur. | | `POST` | `/v1/reward-tables/{key}/grant` | Server key | required | — | Ödül tablosundan oyuncuya ödül verir. | | `GET` | `/v1/quests` | Server key | — | — | Görevleri listeler (`offset` + `limit`). | | `POST` | `/v1/quests` | Server key | — | — | Görev tanımlar. | | `GET` | `/v1/quests/{key}` | Server key | — | — | Görevi okur. | | `POST` | `/v1/quests/{key}/claim` | Server key | — | — | Görev ödülünü teslim eder. | | `GET` | `/v1/vip/{player_id}` | Server or client key | — | — | Oyuncunun VIP durumunu verir. | | `POST` | `/v1/vip/{player_id}/cards/{item_instance_id}/use` | Server or client key | required | — | VIP kartını kullanır. | ## Social `social` | Method | Path | Credentials | Idempotency-Key | If-Match | Description | |---|---|---|---|---|---| | `GET` | `/v1/clan-applications/{application_id}` | Server key | — | — | Klan başvurusunu okur. | | `POST` | `/v1/clan-applications/{application_id}/resolve` | Server key | required | — | Klan başvurusunu kabul ya da reddeder. | | `GET` | `/v1/clan-invites/{invite_id}` | Server key | — | — | Klan davetini okur. | | `POST` | `/v1/clan-invites/{invite_id}/respond` | Server key | required | — | Klan davetini yanıtlar. | | `GET` | `/v1/clan-settings` | Server key | — | — | Klan çalışma ayarlarını okur. | | `PATCH` | `/v1/clan-settings` | Server key | required | — | Klan çalışma ayarlarını günceller. | | `GET` | `/v1/clans` | Server key | — | — | Klanları listeler. | | `POST` | `/v1/clans` | Server key | required | — | Klan kurar. | | `GET` | `/v1/clans/{clan_id}` | Server key | — | — | Klanı okur. | | `PATCH` | `/v1/clans/{clan_id}` | Server key | required | — | Klan bilgilerini günceller. | | `POST` | `/v1/clans/{clan_id}/applications` | Server key | required | — | Klana başvuru oluşturur. | | `POST` | `/v1/clans/{clan_id}/disband` | Server key | required | — | Klanı dağıtır. | | `POST` | `/v1/clans/{clan_id}/invites` | Server key | required | — | Klana davet gönderir. | | `POST` | `/v1/clans/{clan_id}/leadership` | Server key | required | — | Klan liderliğini devreder. | | `POST` | `/v1/clans/{clan_id}/leave` | Server key | required | — | Üyeyi klandan çıkarır (ayrılma). | | `POST` | `/v1/clans/{clan_id}/members/kick` | Server key | required | — | Üyeyi klandan atar. | | `POST` | `/v1/clans/{clan_id}/members/role` | Server key | required | — | Üyenin rolünü değiştirir. | | `POST` | `/v1/clans/{clan_id}/treasury/deposit` | Server key | required | — | Klan kasasına para yatırır. | | `POST` | `/v1/clans/{clan_id}/treasury/withdraw` | Server key | required | — | Klan kasasından para çeker. | | `POST` | `/v1/clans/{clan_id}/xp` | Server key | required | — | Klana XP ekler. | | `GET` | `/v1/parties` | Server key | — | — | Bir oyuncunun gruplarını (party) listeler (`player_id`). | | `POST` | `/v1/parties` | Server key | — | — | Grup kurar. | | `GET` | `/v1/parties/{party_id}` | Server key | — | — | Grubu okur. | | `PATCH` | `/v1/parties/{party_id}` | Server key | — | — | Grup kapasitesini günceller. | | `POST` | `/v1/parties/{party_id}/disband` | Server key | — | — | Grubu dağıtır. | | `POST` | `/v1/parties/{party_id}/invites` | Server key | — | — | Gruba davet gönderir. | | `POST` | `/v1/parties/{party_id}/leave` | Server key | — | — | Üyeyi gruptan çıkarır (ayrılma). | | `POST` | `/v1/parties/{party_id}/rewards` | Server key | required | — | Grup ödülünü üyelere paylaştırır. | | `POST` | `/v1/party-invites/{invite_id}/accept` | Server key | — | — | Grup davetini kabul eder. | ## Chat `chat` | Method | Path | Credentials | Idempotency-Key | If-Match | Description | |---|---|---|---|---|---| | `GET` | `/v1/chat/channels` | Player session | — | — | Sohbet kanallarını listeler. | | `POST` | `/v1/chat/channels` | Player session | — | — | Sohbet kanalı oluşturur. | | `GET` | `/v1/chat/channels/{channel_id}/messages` | Player session | — | — | Kanal mesajlarını listeler (`before_ms` + `limit`). | | `POST` | `/v1/chat/channels/{channel_id}/messages` | Player session | — | — | Kanala mesaj gönderir (`client_message_id` ile tekrar güvenli). | | `GET` | `/v1/chat/ws` | Player session (cookie or Bearer) | — | — | Sohbet WebSocket'i (kanal aboneliği ve mesaj gönderme). | ## Competition `competition` | Method | Path | Credentials | Idempotency-Key | If-Match | Description | |---|---|---|---|---|---| | `GET` | `/v1/leaderboards` | Server key | — | — | Skor tablosu tanımlarını listeler. | | `POST` | `/v1/leaderboards` | Server key | — | — | Skor tablosu tanımı kaydeder (değişmez sürüm). | | `GET` | `/v1/leaderboards/{key}` | Server key | — | — | Skor tablosu tanımını ve etkin sezonu okur. | | `GET` | `/v1/leaderboards/{key}/around-me` | Server key | — | — | Bir oyuncunun çevresindeki sıraları verir. | | `GET` | `/v1/leaderboards/{key}/entries` | Server key | — | — | Sıralamanın üst sayfasını verir. | | `GET` | `/v1/player/leaderboards/{key}` | Player session | — | — | Oturumdaki oyuncunun gördüğü sıralamayı verir. | | `POST` | `/v1/leaderboards/{key}/scores` | Server key | required | — | Oyuncu skoru gönderir. | | `POST` | `/v1/tournaments` | Server key | — | — | Turnuva oluşturur. | | `GET` | `/v1/tournaments/{tournament_id}` | Server or client key | — | — | Turnuvayı okur. | | `POST` | `/v1/tournaments/{tournament_id}/matches/{match_id}/result` | Server key | required | — | Maç sonucunu bildirir. | | `POST` | `/v1/tournaments/{tournament_id}/participants` | Server key | required | — | Turnuvaya katılımcı kaydeder. | | `POST` | `/v1/tournaments/{tournament_id}/start` | Server key | — | — | Turnuvayı başlatır. | ## Notifications `notifications` | Method | Path | Credentials | Idempotency-Key | If-Match | Description | |---|---|---|---|---|---| | `POST` | `/v1/devices` | Player session | — | — | Bildirim cihazı kaydeder. | | `DELETE` | `/v1/devices/{token}` | Player session | — | — | Bildirim cihazı kaydını siler. | | `GET` | `/v1/notifications` | Player session | — | — | Oyuncunun bildirim kutusunu listeler. | | `GET` | `/v1/push/config` | No credentials (`x-crx-env`) | — | — | Web push için istemci yapılandırmasını verir (sırsız). | | `POST` | `/v1/notifications` | Server key | — | — | Oyuncuya bildirim gönderir. | | `POST` | `/v1/notifications/{id}/read` | Player session | — | — | Bildirimi okundu işaretler. | | `GET` | `/v1/ws` | Player session (cookie or Bearer) | — | — | Bildirim WebSocket'i (yalnız sunucudan oyuncuya canlı itme). | ## Webhooks `webhooks` | Method | Path | Credentials | Idempotency-Key | If-Match | Description | |---|---|---|---|---|---| | `GET` | `/v1/webhooks` | Server key | — | — | Webhook uç noktalarını listeler (sırsız). | | `POST` | `/v1/webhooks` | Server key | — | — | Webhook uç noktası kaydeder; gizli anahtar yalnız bu yanıtta döner. | | `GET` | `/v1/webhooks/{id}` | Server key | — | — | Webhook uç noktasını okur (sırsız). | | `DELETE` | `/v1/webhooks/{id}` | Server key | — | — | Webhook uç noktasını siler. | ## Scenes and assets `scenes` | Method | Path | Credentials | Idempotency-Key | If-Match | Description | |---|---|---|---|---|---| | `GET` | `/v1/assets` | Server key | — | — | Görselleri listeler (imleç sayfalama). | | `PUT` | `/v1/assets` | Server key | — | — | Görsel yükler (içerik adresli, SHA-256). | | `DELETE` | `/v1/assets/{sha256}` | Server key | — | — | Görseli siler (kullanımdaysa reddedilir). | | `PUT` | `/v1/scenes` | Server key | — | — | Sahneyi oluşturur ya da var olanı döndürür. | ## Plugins and realtime `plugins` | Method | Path | Credentials | Idempotency-Key | If-Match | Description | |---|---|---|---|---|---| | `POST` | `/v1/plugins/{plugin_id}/commands/{message_type}` | Player session or server key (per operation) | required | yes | Eklenti komutunu çalıştırır (`Idempotency-Key` zorunlu). | | `GET` | `/v1/plugins/{plugin_id}/commands/{message_type}/result` | Player session or server key (per operation) | — | — | Komut sonucunu idempotency anahtarıyla sorgular. | | `GET` | `/v1/plugins/{plugin_id}/queries/{message_type}` | Player session or server key (per operation) | — | — | Eklenti sorgusunu çalıştırır (parametreler sorgu dizesinde). | | `POST` | `/v1/plugins/{plugin_id}/queries/{message_type}` | Player session or server key (per operation) | — | — | Eklenti sorgusunu çalıştırır (parametreler gövdede). | | `GET` | `/v1/plugins/{plugin_id}/schema` | Player session or server key (per operation) | — | — | Eklentinin şema belgesini (artifact) verir. | | `POST` | `/v1/realtime/tickets` | Player session (CSRF in cookie mode) | — | — | Realtime WebSocket için tek kullanımlık bilet üretir. | | `GET` | `/v1/realtime/ws` | Single-use ticket | — | — | Realtime WebSocket'i (`openplay.realtime.v1`). | ## Core: Discovery | Method | Path | Credentials | Idempotency-Key | If-Match | Description | |---|---|---|---|---|---| | `GET` | `/openapi.json` | No credentials | — | — | Küratörlü OpenAPI belgesi (çekirdek REST yüzeyi; eklentiler ve WebSocket dahil değil). | ## Core: Player identity and sessions | Method | Path | Credentials | Idempotency-Key | If-Match | Description | |---|---|---|---|---|---| | `POST` | `/v1/auth/account/delete` | Player session | — | — | Oyuncunun kendi hesabını siler; geri alınamaz. | | `GET` | `/v1/auth/callback/{provider}` | No credentials | — | — | OAuth dönüş adresi (code + state ile oturumu tamamlar). | | `POST` | `/v1/auth/callback/{provider}` | No credentials | — | — | OAuth form_post dönüş adresi (Apple). | | `POST` | `/v1/auth/change-password` | Player session | — | — | Parolayı değiştirir; tüm oturumlar düşer. | | `GET` | `/v1/auth/csrf` | No credentials (`x-crx-env`) | — | — | Yeni CSRF jetonu verir. | | `GET` | `/v1/auth/identities` | Player session | — | — | Oyuncunun bağlı kimlik sağlayıcılarını listeler. | | `POST` | `/v1/auth/forgot-password` | No credentials (`x-crx-env`) | — | — | Parola sıfırlama bağlantısı gönderir. | | `POST` | `/v1/auth/guest/upgrade/email` | Player session | — | — | Misafir hesabı aynı oyuncu kimliğiyle e-posta hesabına yükseltir. | | `GET` | `/v1/auth/jwks` | No credentials (`x-crx-env`) | — | — | Erişim JWT'sini çevrimdışı doğrulamak için açık anahtar kümesini verir. | | `POST` | `/v1/auth/link/{provider}` | Player session | — | — | Mevcut oyuncuya sağlayıcı kimliği bağlamayı başlatır. | | `POST` | `/v1/auth/logout` | Session or refresh cookie | — | — | Oturumu kapatır (bilinmeyen jeton sessizce başarılıdır). | | `GET` | `/v1/auth/magic-link/verify` | No credentials (`x-crx-env`) | — | — | Sihirli bağlantıyı doğrular ve oturum açar. | | `POST` | `/v1/auth/native/{provider}` | No credentials (`x-crx-env`) | — | — | Yerel platform kanıtıyla (Apple, Game Center, Play Games) giriş yapar. | | `GET` | `/v1/auth/oauth/providers` | No credentials (`x-crx-env`) | — | — | Ortamda etkin OAuth sağlayıcılarını listeler. | | `POST` | `/v1/auth/refresh` | Session or refresh cookie | — | — | Oturumu yeniler (jeton döndürme). | | `POST` | `/v1/auth/reset-password` | No credentials (`x-crx-env`) | — | — | Sıfırlama jetonunu tüketir ve yeni parolayı yazar. | | `GET` | `/v1/auth/session` | Player session | — | — | Oturum kapsamını ve oyuncunun profil özetini verir. | | `POST` | `/v1/auth/session/display-name` | Player session | — | — | Oyuncunun görünen adını değiştirir. | | `POST` | `/v1/auth/sign-in/email` | No credentials (`x-crx-env`) | — | — | E-posta ve parola ile giriş yapar. | | `POST` | `/v1/auth/sign-in/guest` | No credentials (`x-crx-env`) | — | — | Misafir olarak giriş yapar (aynı sır aynı oyuncuya döner). | | `POST` | `/v1/auth/sign-in/magic-link` | No credentials (`x-crx-env`) | — | — | Sihirli bağlantı gönderir. | | `POST` | `/v1/auth/sign-in/oauth/{provider}` | No credentials (`x-crx-env`) | — | — | OAuth yetkilendirme adresi ve state üretir. | | `POST` | `/v1/auth/sign-up/email` | No credentials (`x-crx-env`) | — | — | E-posta ve parola ile kayıt olur, oturum açar. | | `POST` | `/v1/auth/unlink/{provider}` | Player session | — | — | Sağlayıcı kimlik bağlantısını kaldırır. | ## Core: Server-side player administration | Method | Path | Credentials | Idempotency-Key | If-Match | Description | |---|---|---|---|---|---| | `POST` | `/v1/auth/admin/ban-player` | Server key or player session (admin role) | — | — | Oyuncuyu yasaklar. | | `POST` | `/v1/auth/admin/create-player` | Server key or player session (admin role) | — | — | Yönetici eliyle oyuncu hesabı açar. | | `GET` | `/v1/auth/admin/list-player-sessions` | Server key or player session (admin role) | — | — | Oyuncunun oturumlarını listeler. | | `GET` | `/v1/auth/admin/list-players` | Server key or player session (admin role) | — | — | Oyuncuları listeler (imleç sayfalama). | | `GET` | `/v1/auth/admin/player-security-events` | Server key or player session (admin role) | — | — | Bir oyuncunun güvenlik olaylarını listeler. | | `POST` | `/v1/auth/admin/rename-player` | Server key or player session (admin role) | — | — | Oyuncunun görünen adını değiştirir. | | `POST` | `/v1/auth/admin/reset-password` | Server key or player session (admin role) | — | — | Oyuncunun parolasını sıfırlar. | | `POST` | `/v1/auth/admin/revoke-player-sessions` | Server key or player session (admin role) | — | — | Oyuncunun tüm oturumlarını kapatır. | | `GET` | `/v1/auth/admin/security-events` | Server key or player session (admin role) | — | — | Ortamın güvenlik olay günlüğünü listeler. | | `POST` | `/v1/auth/admin/set-player-role` | Server key or player session (admin role) | — | — | Oyuncunun rolünü değiştirir. | | `POST` | `/v1/auth/admin/unban-player` | Server key or player session (admin role) | — | — | Oyuncunun yasağını kaldırır. | | `POST` | `/v1/auth/admin/upgrade-guest` | Server key or player session (admin role) | — | — | Misafir oyuncuyu e-posta hesabına yükseltir. | ## Core: Content group catalog | Method | Path | Credentials | Idempotency-Key | If-Match | Description | |---|---|---|---|---|---| | `GET` | `/v1/catalog/groups` | Server key or player session | — | — | Yayınlanmış içerik gruplarını listeler. | | `GET` | `/v1/catalog/groups/{group}` | Server key or player session | — | — | İçerik grubunu okur. | | `GET` | `/v1/catalog/groups/{group}/entries/{key}` | Server key or player session | — | — | İçerik grubu girdisini okur. | ## Core: Player data (cloud save) | Method | Path | Credentials | Idempotency-Key | If-Match | Description | |---|---|---|---|---|---| | `GET` | `/v1/player/data` | Player session | — | — | Oyuncunun bulut kaydı anahtarlarını (sürüm, boyut, zaman) listeler. | | `GET` | `/v1/player/data/{key}` | Player session | — | — | Bir bulut kaydı anahtarını okur; yanıt `ETag` taşır. | | `PUT` | `/v1/player/data/{key}` | Player session | — | yes | Bulut kaydı yazar; `If-None-Match: *` (oluştur) ya da `If-Match` (güncelle) zorunludur. | | `DELETE` | `/v1/player/data/{key}` | Player session | — | yes | Bulut kaydı anahtarını siler; `If-Match` zorunludur. | | `GET` | `/v1/players/{player_id}/data` | Server key | — | — | Bir oyuncunun bulut kaydı anahtarlarını sunucu anahtarıyla listeler (salt okuma). | | `GET` | `/v1/players/{player_id}/data/{key}` | Server key | — | — | Bir oyuncunun bulut kaydı anahtarını sunucu anahtarıyla okur (salt okuma). | ## Core: Public content reads | Method | Path | Credentials | Idempotency-Key | If-Match | Description | |---|---|---|---|---|---| | `GET` | `/v1/assets/{sha256}` | No credentials | — | — | Görsel baytlarını verir (içerik adresli, kimliksiz). | | `GET` | `/v1/model-files/{sha256}` | No credentials | — | — | 3D model dosyasını (GLB) verir (içerik adresli, kimliksiz). | ## Core: Player organizations (deprecated) These endpoints carry a `Deprecation: @1793491200` response header; they keep working. | Method | Path | Credentials | Idempotency-Key | If-Match | Description | |---|---|---|---|---|---| | `POST` | `/v1/auth/organization/accept-invitation` | Player session | — | — | Kullanımdan kalkıyor: organizasyon davetini kabul eder. | | `POST` | `/v1/auth/organization/add-team-member` | Player session | — | — | Kullanımdan kalkıyor: takıma üye ekler. | | `POST` | `/v1/auth/organization/cancel-invitation` | Player session | — | — | Kullanımdan kalkıyor: daveti iptal eder. | | `POST` | `/v1/auth/organization/create` | Player session | — | — | Kullanımdan kalkıyor: organizasyon kurar. | | `POST` | `/v1/auth/organization/create-team` | Player session | — | — | Kullanımdan kalkıyor: takım kurar. | | `POST` | `/v1/auth/organization/delete` | Player session | — | — | Kullanımdan kalkıyor: organizasyonu siler. | | `POST` | `/v1/auth/organization/delete-team` | Player session | — | — | Kullanımdan kalkıyor: takımı siler. | | `POST` | `/v1/auth/organization/invite-member` | Player session | — | — | Kullanımdan kalkıyor: üye davet eder. | | `GET` | `/v1/auth/organization/list` | Player session | — | — | Kullanımdan kalkıyor: oturumdaki oyuncunun üyeliklerini listeler. | | `GET` | `/v1/auth/organization/list-members` | Player session | — | — | Kullanımdan kalkıyor: üyeleri listeler. | | `GET` | `/v1/auth/organization/list-team-members` | Player session | — | — | Kullanımdan kalkıyor: takım üyelerini listeler. | | `GET` | `/v1/auth/organization/list-teams` | Player session | — | — | Kullanımdan kalkıyor: takımları listeler. | | `POST` | `/v1/auth/organization/reject-invitation` | Player session | — | — | Kullanımdan kalkıyor: daveti reddeder. | | `POST` | `/v1/auth/organization/remove-member` | Player session | — | — | Kullanımdan kalkıyor: üyeyi çıkarır. | | `POST` | `/v1/auth/organization/remove-team-member` | Player session | — | — | Kullanımdan kalkıyor: takımdan üye çıkarır. | | `POST` | `/v1/auth/organization/set-active` | Player session | — | — | Kullanımdan kalkıyor: oturuma etkin organizasyonu yazar. | | `POST` | `/v1/auth/organization/update` | Player session | — | — | Kullanımdan kalkıyor: organizasyonu yeniden adlandırır. | | `POST` | `/v1/auth/organization/update-member-role` | Player session | — | — | Kullanımdan kalkıyor: üye rolünü değiştirir. | | `POST` | `/v1/auth/organization/update-team` | Player session | — | — | Kullanımdan kalkıyor: takımı yeniden adlandırır. | --- Source: https://open-play.b4klabs.dev/docs/en/reference/webhook-events.html # Webhook events This page lists the event types you can subscribe a webhook endpoint to and the headers sent with every delivery. For registration and signature verification see the [webhook guide](../guides/webhooks.md). Descriptions are currently in Turkish. ## Delivery headers | Header | Description | |---|---| | `X-Crx-Signature` | `t=,v1=`; `v1` = `HMAC-SHA256(secret, "{t}.{raw body}")`. | | `X-Crx-Event-Id` | Event id (UUID); use it to drop repeated deliveries. | | `X-Crx-Event-Type` | Event type (one of the values in the table below). | ## Webhook events Total event types: **23**. | Event type | Source | When it is sent | |---|---|---| | `flag_set` | Character | Bir bayrak (flag) ayarlandı. | | `attribute_set` | Character | Bir öznitelik değeri ayarlandı. | | `resource_changed` | Character | Bir kaynağın değeri değişti (yeni değer olayda yer alır). | | `modifier_added` | Character | Karaktere bir değiştirici (modifier) eklendi. | | `modifiers_removed` | Character | Bir kaynaktan gelen değiştiriciler kaldırıldı. | | `xp_added` | Character | Karaktere XP eklendi. | | `level_up` | Character | Karakter seviye atladı. | | `ability_unlocked` | Character | Karakterin bir yeteneği açıldı. | | `event_emitted` | Character | Bir kural özel bir olay yayınladı. | | `character_rolled_back` | Character | Karakter önceki bir sürüme geri alındı (yeni olay olarak yazılır). | | `store_page_created` | Store | Mağaza sayfası oluşturuldu. | | `store_page_updated` | Store | Mağaza sayfası güncellendi. | | `store_page_deleted` | Store | Mağaza sayfası silindi. | | `store_pages_reordered` | Store | Mağaza sayfaları yeniden sıralandı. | | `store_listing_created` | Store | Mağaza ilanı oluşturuldu. | | `store_listing_updated` | Store | Mağaza ilanı güncellendi. | | `store_listing_deleted` | Store | Mağaza ilanı silindi. | | `store_listings_reordered` | Store | Bir sayfadaki ilanlar yeniden sıralandı. | | `store_purchase_initiated` | Store | Satın alma rezervasyonu açıldı. | | `store_payment_started` | Store | Satın alma için ödeme başladı. | | `store_purchase_completed` | Store | Satın alma tamamlandı ve ürün teslim edildi. | | `store_reservation_cancelled` | Store | Satın alma rezervasyonu iptal edildi. | | `reward_granted` | Reward | Bir ödül tablosundan oyuncuya ödül verildi. | --- Source: https://open-play.b4klabs.dev/docs/en/reference/glossary.html # Glossary This page briefly defines the terms used in the documentation and links them to the relevant page. Terms are grouped by topic, not alphabetically. ## Structure: studio, game, environment | Term | Definition | |---|---| | **Studio** | The top ownership level; it represents a team or a company. It hosts multiple games. Its management is in the **Server** section of the panel. | | **Game** | The product under a studio. It has its own environments, modules, and API keys. | | **Environment** | An isolated working copy of a game (for example development, live). Data is not shared between environments. Every API key and player session is bound to a single environment. | | **Environment domain** | A domain bound to an environment; it is also used as the list of allowed origins (`Origin`) in browser requests. | | **Module** | A product part that can be turned on and off: characters, economy, items, trading, store, rewards, social, chat, competition, notifications, webhooks, scenes, and plugins. The endpoints of a disabled module return `404 module_disabled`. | | **Panel** (admin panel) | The web interface where you manage the game. The panel API is separate from the player API and is outside the scope of this documentation. | ## Identity and keys | Term | Definition | |---|---| | **Server key** | A secret API key in the format `crx_live__`, kept only on your server. It can call all server endpoints. It is never put in the client or the game bundle. | | **Client key** | An API key that can be embedded in the client. It cannot run sensitive commands (for example `add_xp`); however, on some endpoints there is no type gate, so use it only if it is really needed ([endpoints](endpoints.md)). | | **Player session** | The session created after the player signs in. It is carried with a cookie (`__Secure-crx.session`) or a Bearer session token and requires the `x-crx-env` header. | | **Access JWT** | A short-lived token returned in the body when the session is opened, which your game server can verify offline with the JWKS. It is not the same thing as the session token. | | **JWKS** | The public key set for verifying the access JWT (`GET /v1/auth/jwks`). | | **Guest** | A player that needs no registration; it returns to the same player each time with the same secret value and can later be upgraded to an email account. | | **CSRF** | Double submit that prevents the browser from sending unwanted requests on the user's behalf: a cookie + the `x-csrf-token` header. Required on modifying requests in a cookie session. | | **Origin check** | The rule that on modifying requests the `Origin` header must be one of the environment's allowed origins. The server sends CORS headers only to origins in the same domain list; CORS is not authorization, this check runs separately. | | **Same origin** | Serving the game and the API under the same domain (with a reverse proxy); [architecture and hosting](../getting-started/architecture-and-hosting.md). | | **Realtime ticket** | A single-use, 30-second identity token obtained to connect to the realtime socket ([protocol](../realtime/protocol.md)). | ## Reliability: replay and conflict | Term | Definition | |---|---| | **Idempotency** (replay safety) | The same request being applied once even if it is sent multiple times. Provided with the `Idempotency-Key` header ([idempotency and concurrency](idempotency-and-concurrency.md)). | | **Idempotency-Key** | A 1 to 200 character key, generated by the client, that identifies an operation. | | **Optimistic lock** | Adding the resource's version to the request and rejecting the request if the version changed. Done with `If-Match` or `expectedVersion`. | | **`expectedVersion`** | The expected resource version: the `If-Match` header in HTTP, a field in the realtime `Command` frame, an option in the SDK. | | **Fail-open** | A request passing without being limited if a limiter dependency (Redis) cannot be reached ([rate limits](rate-limits.md)). | ## Plugins | Term | Definition | |---|---| | **Plugin** | A trusted package compiled into the server that offers game-specific commands, queries, and realtime modules ([overview](../plugins/overview.md)). Loading third-party plugins does not exist yet. | | **Command** | A state-changing, durable, replay-safe operation. | | **Query** | A read-only read. | | **Schema document** (artifact) | The JSON document that describes all of a plugin's operations and schemas (`GET /v1/plugins/{id}/schema`). | | **`schemaSha256`** | The digest of the `schemas` section in the schema document; the version pinning key ([bindings](../plugins/bindings.md)). | | **Binding** | Typed client code generated from the schema document. | | **Pin** | The record of which plugin and schema digest the client was built with; an `upgrade_required` error on a mismatch. | | **Access type** (`access`) | The identity boundary of a plugin operation: `player`, `server`, or `public`. | | **Plugin state** (grant state) | The plugin's state in an environment: enabled, read-only, draining, disabled. | | **`plugin-v1-decimal-int64`** | The encoding profile that carries plugin and realtime `int64` values as decimal text ([numbers and encoding](numbers-and-encoding.md)). | ## Realtime | Term | Definition | |---|---| | **Subscription** | A data stream opened with `Join`, bound to a module and a target; recognized by `subscriptionId`. | | **Target** | What the subscription is bound to: `PlayerSelf`, `Room`, `Scene`, or `InterestSet`. | | **Snapshot** | A full broadcast of transient state; ordered with `streamEpoch` and `revision`. | | **Durable event** | A plugin event that can be recovered from the database, ordered by `sequence`. | | **Cursor** | An opaque, player-bound value that shows where you left off in a durable stream ([reconnect](../realtime/reconnect-and-limits.md)). | | **Epoch** | `sessionEpoch`: a socket's write epoch; a new socket invalidates the old one's inputs. `streamEpoch`: the number of resets of the state stream. | | **Idle close** | The server closing the socket with `4100` when no player activity arrives on a subscription that accepts input. | | **Backpressure** | Work being rejected because the client queue is full or because of the server's frame rate limit. | ## Data and in-game concepts | Term | Definition | |---|---| | **Definition** | A character's schema and starting values; stored with immutable versions. | | **Trait** | A reusable schema fragment mixed into definitions. | | **Modifier** | A temporary or permanent bonus that affects a stat (for example from an item). | | **Rule** | An automation bound to character events ([game rules](../guides/game-rules.md)). | | **Minor unit** | The integer equivalent of a money value (for example cents). | | **Basis point** | A percentage unit where `10000` = 100%. | | **Soft delete** | A record becoming invisible while its event history is preserved. | | **Content group** | A data set published from the panel and offered to the game as a catalog ([content groups](../guides/content-groups.md)). | | **Webhook** | Open Play notifying your own server of events with a signed HTTP request ([webhooks guide](../guides/webhooks.md)). | | **At-least-once** | Webhook delivery being done at least once, and more than once if needed; deduplicate with `X-Crx-Event-Id`. | --- Source: https://open-play.b4klabs.dev/docs/en/operations/security.html # Security This page gathers what you need to know to operate a game integrated with Open Play securely: where to keep keys, how to rotate them, how the browser session is protected, and how to verify webhook requests. Read it together with the [production checklist](production-checklist.md) before going to production. ## 1. API keys There are two key types, and both are bound to a single **environment**: | Type | Where it is kept | What it can do | |---|---|---| | **Server key** (`server`) | Only on your server (a secret manager or an environment variable) | All server endpoints | | **Client key** (`client`) | Can be embedded in the client | Sensitive commands (for example `add_xp`, `set_attribute`) do not work; but some endpoints have no key-type gate ([endpoints](../reference/endpoints.md)) | > **Warning** > > **A client key is not "harmless".** On endpoints such as character creation/deletion, inventory movements, trading, merchant buy-sell, and wallet transfer, there is no key-type gate in code; a key embedded in the client can call them. Call these endpoints only from your server, and for the player use the endpoints that work with a player session (`/v1/player/...`, plugins, `PlayerClient`). ### Key format and storage - Format: `crx_live__`. The server stores only the key's **digest** (argon2id); it cannot regenerate the secret part. - The secret value is shown **only once, at creation time**. If you lose it, it cannot be recovered; you generate a new key. - Do not put the key in the repository, logs, error reports, the client bundle, or screenshots. Filter the `Authorization` header out of logs. - Use a **separate key per environment and per service** (for example the live API server, a batch job, an admin script); in a leak you revoke only one. - Give the key an **expiry date**. `expiresAt` can be set when creating it in the panel; an expired key is rejected. ### Lifecycle Keys are managed in the panel on the **Game settings: API keys** screen ([API keys](../panel/game-settings-api-keys.md)): | Action | Effect | |---|---| | Create | A new key; the secret value is shown once | | Disable / enable | Temporary and reversible | | Rotate | Creates a new key with the same name and **immediately revokes the old one in the same operation** | | Revoke | Permanent; cannot be undone | | Delete | Soft delete + revoke | The key state is **re-read from the database on every request**: a revoke or disable takes effect on the next request (only the verification result of the secret value is cached for 60 s, the state is not). A reason is written for every operation and it goes into the [operation history](../panel/audit.md). ### Rotation without downtime The **Rotate** action in the panel revokes the old key immediately; until you roll out the new key, your calls get `401`. To rotate without downtime: 1. In the panel, create a second key **with a different name** (there can be only one active key with the same name and owner at a time). 2. Write the new key into your secret manager and roll it out by restarting your services. 3. Verify that no service uses the old key (the operation history and your own logs). 4. **Revoke** the old key. ### If a leak happens 1. **Revoke** the leaked key immediately (the effect is on the next request). 2. Roll out the new key in the order above. 3. In the operation history and your own logs, look for unexpected operations after the moment of the leak (wallet transfers, item minting, webhook registration). 4. In the environments the key could access, review the webhook endpoints; an attacker may have registered an endpoint (registered secrets are shown once, but a new registration generates a new secret). ## 2. Player session and browser security - After player sign-in the server writes three cookies: `__Secure-crx.session` (session), `__Secure-crx.refresh` (refresh), and `__Secure-crx.csrf` (CSRF). All three carry `HttpOnly`, `Secure`, `Path=/`, and `SameSite=Lax` (scripts cannot read them); the plain value of the CSRF token given to the script is not in the cookie but in the response header and body. The `__Secure-` prefix **requires HTTPS**: a session cannot be established over plain HTTP. - The session **slides for 30 days**: it is extended if the player makes a request at least once a day. **The absolute ceiling is 180 days**: no matter how active, a new sign-in is needed after 180 days. An open realtime socket does not keep the session alive. The durations are fixed in server code. - The environment can turn on a **single active session** rule if it wants (a new sign-in drops the old one); this is among the sign-in settings in the panel. - The access JWT (EdDSA) is not the session token; your game server verifies it offline with `GET /v1/auth/jwks` ([server verification](../auth/server-verification.md)). ### CSRF In a cookie session, every modifying (POST, PUT, PATCH, DELETE) request passes two protections: 1. **Origin check:** the host in the `Origin` (or `Referer`) header must be one of the environment's allowed origins. A host bound to an environment can target only that environment. It is blocked if `Sec-Fetch-Site: cross-site` and `Sec-Fetch-Mode: navigate` are together. Clients that send no `Origin` at all (curl, server-to-server) pass this check; the layer that protects them is double submit. 2. **Double submit:** the `__Secure-crx.csrf` cookie and the `x-csrf-token` header must arrive together and match. The token is **single-use** and rotates on every successful verification: store the new `Set-Cookie` and `x-csrf-token` value in the response for the next request. `GET /v1/auth/csrf` for a new token. The TypeScript SDK manages this automatically; if you write HTTP by hand, see the [sessions and CSRF](../auth/sessions-and-csrf.md) page. The failure case: `403` and the plain text `permission denied`. ### CORS and same origin The server sends CORS headers **only to `https://` origins in the environment domain list** (and the operator's global allowed list), and only on the public API: the allowed origin is reflected verbatim, `Access-Control-Allow-Credentials: true` is added, `*` is never used, and the panel path (`/v1/panel/*`) never gets CORS. **CORS is not authorization:** `Origin`, the CSRF double submit, and the environment checks run unchanged; CORS only lets the browser read the response. Do not add extra CORS headers to your own proxy: duplicate headers cause a browser error. Because the session cookie is `SameSite=Lax`, a direct call works only if the game and Open Play are on the **same site** (subdomains of the same registrable domain); on a different site, use a reverse proxy. Topology: [architecture and hosting](../getting-started/architecture-and-hosting.md#calling-from-the-browser-direct-cors-or-reverse-proxy). The allowed origins are **your environment domains**. The domain you define for the environment in the panel is the basis both for routing to that environment and for the `Origin` check ([Game settings: environments](../panel/game-settings-environments.md)). A modifying request from an unknown host is rejected. ## 3. Webhook verification Open Play sends the events you subscribe to your endpoint with a signed `POST`. **You must verify every request**; do not process an unsigned request. | Header | Meaning | |---|---| | `X-Crx-Signature` | `t=,v1=` | | `X-Crx-Event-Id` | The event ID (UUID); the deduplication key | | `X-Crx-Event-Type` | The event type ([webhook events](../reference/webhook-events.md)) | The signature: `HMAC-SHA256(secret, "{t}.{raw body}")`, lowercase hexadecimal. The key is the `whsec_…` text you received at registration itself (as bytes). Rules: 1. Sign/verify the **raw body**: if you decode the JSON and rewrite it, the bytes change and the signature does not match. 2. Use a **constant-time** comparison (`timingSafeEqual`, `hmac.compare_digest`). 3. Compare the `t` value with your own clock and reject anything outside the window (5 minutes is recommended; this is a receiver policy, the server does not enforce it). 4. **At-least-once:** the same event may arrive more than once. Deduplicate with `X-Crx-Event-Id` and make the operation replay-safe. 5. Only a `2xx` response is success. Respond quickly (queue the work); other statuses are retried. Delivery behavior: | Behavior | Value | |---|---| | Retry | Exponential backoff: starts at 1 s, grows by powers of 2, capped at 5 minutes, ±20% jitter | | Maximum attempts | 10; after that the delivery becomes `failed` (dead-letter queue, DLQ) | | Redirect | Not followed | | Body limit | A body above 64 KiB is not sent | | Endpoint address | `https` only; private, loopback, and link-local IP addresses and addresses containing user info (`user:password@`) are rejected at registration (SSRF protection) | | Secret key | Returned in the registration response **only once**; rotation = deleting the endpoint and registering it again | > **Note** > > There is no in-place "rotate" for the webhook secret. For a seamless transition: register the new endpoint (with the new secret), have your receiver verify both, and delete the old endpoint. Verification example (Node, `node:crypto`): ```ts import { createHmac, timingSafeEqual } from "node:crypto"; /** İmzalı webhook isteğinin doğrulama sonucu. */ export type WebhookCheck = { ok: true; eventId: string; eventType: string } | { ok: false; reason: string }; const TOLERANCE_SECONDS = 300; // yeniden oynatma penceresi: alıcı politikasıdır, 5 dakika önerilir /** * Open Play webhook isteğini doğrular. `rawBody`, ayrıştırılmamış HAM bayt dizisi olmalıdır: * JSON'u çözüp yeniden yazarsanız imza tutmaz. * İmza: HMAC-SHA256(secret, "{t}.{ham gövde}"), başlık biçimi `t=,v1=`. */ export function verifyWebhook( secret: string, rawBody: Buffer, headers: { signature: string | undefined; eventId: string | undefined; eventType: string | undefined }, nowSeconds: number = Math.floor(Date.now() / 1000), ): WebhookCheck { const parts = Object.fromEntries((headers.signature ?? "").split(",").map((part) => part.trim().split("=", 2) as [string, string])); const timestamp = Number(parts["t"]); const received = parts["v1"]; if (!Number.isInteger(timestamp) || received === undefined) return { ok: false, reason: "imza başlığı bozuk" }; if (Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) return { ok: false, reason: "zaman damgası pencere dışında" }; const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex"); const a = Buffer.from(expected, "utf8"); const b = Buffer.from(received, "utf8"); if (a.length !== b.length || !timingSafeEqual(a, b)) return { ok: false, reason: "imza uyuşmuyor" }; if (headers.eventId === undefined || headers.eventType === undefined) return { ok: false, reason: "olay başlıkları eksik" }; return { ok: true, eventId: headers.eventId, eventType: headers.eventType }; } ``` Monitor delivery status and the DLQ in the panel: [Webhooks and event queue](../panel/webhooks.md). For registration and endpoint management, see the [webhooks guide](../guides/webhooks.md). ## 4. Rotating secrets: summary | Secret | How it is rotated | |---|---| | Server/client API key | Create a second key, roll it out, revoke the old one (above) | | Webhook secret | Register a new endpoint, update the receiver to accept both, delete the old one | | Player password | The player changes their own password (all sessions drop) or the password reset flow ([email and password](../auth/email-password.md)) | | Player sessions | The user signs out; an admin revokes the player's sessions (panel or `POST /v1/auth/admin/revoke-player-sessions`) | ## 5. What not to write to logs The API key, session and refresh tokens, the CSRF token, the realtime ticket, the webhook secret, OAuth `state` and `code` values, passwords, and the guest secret value. The SDKs do not include these in error objects; in your own logging code, too, do not print the request raw. ## 6. Other notes - **Error detail is not leaked:** the server gives no internal detail in a `500` response; account existence is also not leaked (a wrong password and an unknown address get the same response). - **Banned account:** on sign-in endpoints, `403 account_banned` after ownership has been proven; no distinction is made on other endpoints ([errors](../reference/errors.md)). - **Safety switches:** in the panel you can loosen or force back some protection rules per environment; every change is recorded with a reason ([Safety switches](../panel/safety-switches.md)). - **Plugins** are trusted code compiled into the server; running third-party plugins and isolation do not exist yet ([plugins](../plugins/overview.md)). - **Panel admin accounts** have a separate identity line and two-factor authentication; there is no two-factor authentication for player accounts. --- Source: https://open-play.b4klabs.dev/docs/en/operations/production-checklist.html # Production checklist This page gathers the items you will tick off one by one before going live. Each item comes with a short reason and links to the relevant page. Review the list once before launch and again after every major change. ## 1. Network and hosting - [ ] **Everything is HTTPS.** Session cookies have the `__Secure-` prefix and carry the `Secure` flag; a session cannot be established over plain HTTP. Sockets are opened with `wss://`. If you use `http://localhost` in local development, verify your browser's `localhost` exception. - [ ] **Browser call path chosen.** Either the game's domain is in an environment's domain list and the game calls Open Play directly (the server sends CORS headers; the game and Open Play must be subdomains of the same registrable domain, otherwise the `SameSite=Lax` session cookie is not sent), or the game calls the API from under its own domain through a reverse proxy ([architecture and hosting](../getting-started/architecture-and-hosting.md)). - [ ] **The proxy forwards these headers as they are:** `Authorization`, `Cookie` (and `Set-Cookie` in the response), `Origin`, `x-crx-env`, `x-csrf-token`, `Idempotency-Key`, `If-Match`, `x-crx-schema-version`, `Sec-WebSocket-*` (including subprotocol and extension negotiation). It does not strip the response headers (`x-csrf-token`, `Deprecation`, `Retry-After`, `ETag`). - [ ] **The WebSocket upgrade** is open on the proxy, the `Upgrade`/`Connection` headers are forwarded, and the connection can stay **idle for at least 5 minutes** (the server does the idle close itself: [limits](../realtime/reconnect-and-limits.md)). - [ ] **The request body limit is at least 2 MiB.** The server rejects anything above 2 MiB with `413`; your proxy should not set a lower limit. - [ ] **Timeout:** the proxy's response-header wait time does not cut off long operations (Open Play's own gateway uses 65 s). - [ ] **The client IP is forwarded.** The rate limits on the identity endpoints depend on the client IP. If the proxy does not add `X-Forwarded-For`, **all players gather in a single bucket** (for example guest opening is 30 per minute). If multiple proxies are chained, the operator must define the trusted proxy blocks. - [ ] **The panel path is closed to the internet.** The `/v1/panel/*` path returns `404` on the public gateway; the panel is reached only from the management network or from a separate domain. - [ ] **Caching:** API responses that carry a cookie or `Authorization` are not put in a shared cache (CDN). Only public, content-addressed responses (`/v1/assets/{sha256}`, `/v1/model-files/{sha256}`) and catalog responses that carry an `ETag` are subject to an appropriate caching rule. ## 2. Environments and domains - [ ] You use **separate environments** for development, staging, and live; data is not shared. - [ ] Each environment's **domain** is defined in the panel ([Game settings: environments](../panel/game-settings-environments.md)); this is the allowed `Origin` list. - [ ] The correct environment ID (the `x-crx-env` value) is in the client configuration; the live client does not point to the staging environment. - [ ] Only the needed **modules are on** ([modules](../panel/game-settings-modules.md)). A disabled module returns `404 module_disabled`; every module you leave on is an attack surface. - [ ] Sign-in methods are configured in the panel (guest, email, Google); the methods you do not use are off ([Sign-in methods](../panel/game-settings-sign-in.md)). ## 3. Keys and secrets - [ ] **Server keys are only on the server.** They are not in the client bundle, the mobile app, the WebGL build, or the repository ([security](security.md)). - [ ] A **separate key per environment and service**; the keys have been given an expiry date. - [ ] If you use a **client key**, you have verified that you know which endpoints it can call; player operations are done with the player session ([endpoints](../reference/endpoints.md)). - [ ] A **key rotation rehearsal** was done: second key, rollout, revoke the old one. - [ ] Keys and tokens are not in the logs; `Authorization`, `Cookie`, and `x-csrf-token` are filtered. - [ ] The webhook secret is in your receiver's secret manager; because it is shown only once at registration, it has a backup. ## 4. Replay safety and concurrency - [ ] **Every call** that grants money, items, rewards, or sends a command **carries an `Idempotency-Key`**; the key is generated once and used exactly on repeats ([idempotency](../reference/idempotency-and-concurrency.md)). - [ ] On an uncertain outcome (timeout, network drop), the command is completed **with the same key**, not a new one, or with the result query. - [ ] Calls that write a shared resource send `If-Match`/`expectedVersion`; on `409` the state is re-read. - [ ] There is exponential backoff + jitter on `429`, `503`, and network errors; `Retry-After` is respected ([rate limits](../reference/rate-limits.md)). - [ ] Error handling is by **status code**; the plain-text message is not parsed ([errors](../reference/errors.md)). ## 5. Player identity - [ ] The session-loss flow is set up: session refresh when `401` arrives, otherwise sign in again; a separate screen for a banned account ([sessions and CSRF](../auth/sessions-and-csrf.md)). - [ ] The guest identity's storage behavior and the upgrade flow to email or Google are set up (guest progress is kept with the upgrade). - [ ] Your game server verifies the access JWT **with the JWKS**, checking the expiry and environment fields ([server verification](../auth/server-verification.md)). - [ ] The identity rate limits (guest 30/min, email sign-in 5/min, sign-up 10/min, per IP) can carry your expected launch traffic; a load test was done if needed. ## 6. Realtime - [ ] The client uses the SDK or implements the protocol (ticket, `Origin`, subprotocol) completely ([protocol](../realtime/protocol.md)). - [ ] **Reconnect:** a new ticket on every connection, exponential backoff, `Join` with the last cursor on durable streams, `Join` without a cursor on `ResyncRequired` ([reconnect](../realtime/reconnect-and-limits.md)). - [ ] There is a "Continue" screen for **idle close (`4100`)**; a keep-alive input is sent on pause screens. - [ ] The frame rate is **below 40/s**; the limit of 4 tabs/sockets per player is known. - [ ] The notification and chat sockets cannot be opened directly from the browser (header restriction); inbox polling or the REST endpoints are used ([realtime overview](../realtime/overview.md)). ## 7. Plugins - [ ] The **schema digest** (`schemaSha256`) of the plugin you use is written into your release manifest and the bindings were generated with this digest ([bindings](../plugins/bindings.md)). - [ ] A digest comparison (at startup or in CI) is done after a server update. - [ ] The plugin's state in the environment is **enabled** ([Plugins](../panel/plugins.md)); this is the first place to look for an unexpected `403 forbidden`. - [ ] A migration task was opened for calls that carry the `Deprecation` header ([versioning](../reference/versioning.md)). ## 8. Webhooks - [ ] The endpoint is `https`; **the signature is verified on every request**, the timestamp window is checked, and deduplication is done with `X-Crx-Event-Id` ([security](security.md)). - [ ] The receiver returns a fast `2xx` (queues the work); there is a monitoring plan for deliveries that end up `failed` (DLQ) after 10 attempts ([Webhooks and event queue](../panel/webhooks.md) offers only monitoring, there is no resend); you have a way to make up for missed events by reading the relevant resource with REST. ## 9. Observability and operations - [ ] Your request log has the status code, duration, called path, and (on plugin endpoints) `x-request-id`; there are no secret values. - [ ] You have an alert for the `5xx` and `429` rate; it was verified that `503` + `Retry-After` responses are retried. - [ ] Panel admin accounts are personal and two-factor authentication is on ([Panel operators](../panel/operators.md)). - [ ] The [operation history](../panel/audit.md) is reviewed regularly; the [safety switches](../panel/safety-switches.md) are in their default position (if you changed them deliberately, the reason is recorded). - [ ] The SDK version is pinned **exactly** (no `^`); upgrades are followed from the changelog ([versioning](../reference/versioning.md)). ## 10. Final rehearsal before launch 1. Run the **end-to-end flow** in a staging environment similar to the live one: sign-in, reward, purchase, socket connection, connection drop, and reconnect. 2. Deliberately send the same request twice with the same `Idempotency-Key`; verify that the second response returns the same result. 3. Revoke a server key once and observe the switch to the second key. 4. Send a request with a wrong signature to your webhook receiver; verify that it rejects it. 5. Do a trial on the identity endpoints that imitates your launch traffic; look at the `429` rate. If problems come up, see the [troubleshooting](troubleshooting.md) page. --- Source: https://open-play.b4klabs.dev/docs/en/operations/troubleshooting.html # Troubleshooting This page lists the problems you will encounter in integration as a **symptom, cause, fix** triple. Find the symptom from the error code or the status code. For the full code tables, see the [error reference](../reference/errors.md). > **Note** > > When reporting a problem, include: the path and method called, the status code and body (with secret values removed), the request time (UTC), and on plugin endpoints the `x-request-id`/`requestId`. The server does not leak error detail outward; this information speeds up the investigation. ## Identity and session | Symptom | Likely cause | Fix | |---|---|---| | Server call `401 unauthorized` | The `Authorization: Bearer crx_live_…` header is missing or its format is wrong; the key was revoked, disabled, or expired; the key belongs to another environment | Check the header and the key; look at the key's state and environment in the panel ([API keys](../panel/game-settings-api-keys.md)). The server does not distinguish "wrong key" from "revoked" | | Server call `403 permission denied` | You are calling a server endpoint with a client key | Use a server key ([endpoints](../reference/endpoints.md): the endpoints marked "Server key") | | Player call `401` | The `x-crx-env` header is missing or not a UUID; the session is missing, expired, or revoked; the environment does not match | Add the header; refresh the session or sign in again. A banned account also looks like `401` (no distinction is made) | | Player `403 permission denied` (POST) | The CSRF pair is missing/does not match; the token was already used; `Origin` is not allowed | See the CSRF rows below | | `403` and `{"code":"account_banned"}` at sign-in | The account is banned | Show the player a suspension screen; do not build a retry loop; do not open a new guest | | The session ends earlier than expected | No request was made for 30 days; or the 180-day absolute ceiling was reached; the password changed (all sessions drop); "single active session" is on in the environment and a sign-in was made from another device | Build a sign-in-again flow; the behavior is on the [security](security.md) page | | The session cookie is never set in the browser | You are working over HTTP (`Secure` + the `__Secure-` prefix); the API is on another domain (the cookie becomes third-party, `SameSite=Lax`); the browser blocks cookies | Use HTTPS and **the same origin** ([architecture and hosting](../getting-started/architecture-and-hosting.md)) | | `429 rate limited` on sign-in/sign-up | The per-IP rate limit is full (guest 30/min, email sign-in 5/min…) | Wait; if many players come from one IP or the proxy does not forward the client IP, see [rate limits](../reference/rate-limits.md) and the [production checklist](production-checklist.md) | | Email sign-in fails, the password is correct | Failed-attempt lock (10 per 15 min per address + IP) | Wait 15 minutes or reset the password | ### CSRF and Origin | Symptom | Likely cause | Fix | |---|---|---| | Every POST `403` | The `x-csrf-token` header or the `__Secure-crx.csrf` cookie is not being sent | Get a token with `GET /v1/auth/csrf`; send the cookie and the header together. The SDK does this automatically | | The first POST passes, the second `403` | The CSRF token is **single-use** and renewed with every successful response; you did not store the response's new value, or you sent two requests **in parallel** with the same token | Use the new `x-csrf-token` and `Set-Cookie` value in each response; put modifying requests in a queue | | `403` only in the browser, works in curl | `Origin` is not in the environment's allowed origins; a navigation request with `Sec-Fetch-Site: cross-site` | Define the game's domain as an environment domain ([Game settings: environments](../panel/game-settings-environments.md)); call the API from the same origin | | A "CORS policy" error in the browser console | The game's domain is in no environment's domain list or the global allowed list (the server sends no CORS to that origin); or the origin is not `https://` (`http`, a port, `null`) | Add the domain as an environment domain ([Game settings: environments](../panel/game-settings-environments.md)); if that is not possible, set up a same-origin reverse proxy. Do not add extra CORS headers to your proxy | ## Call errors | Symptom | Likely cause | Fix | |---|---|---| | `404` and JSON `module_disabled` (the `module` field) | The product module is disabled in the environment | Enable the module in the panel ([modules](../panel/game-settings-modules.md)) | | `404 not found` | The resource does not exist, was deleted, or the wrong environment | Check the ID and the environment | | A `400` message like "Idempotency-Key required" | The header is missing on an endpoint where the key is required, or it is empty/200+ characters | Add the header ([idempotency](../reference/idempotency-and-concurrency.md)) | | `400` "must be a valid UUID" | On a clan, group, or tournament endpoint the key is not a UUID | Generate a UUID v4 | | `400` `If-Match` error | `*`, `W/"3"`, a negative or non-numeric value | Send the exact version number (`3` or `"3"`) | | `409 version conflict` | The resource changed after you read it | Re-read, repeat with the new version | | `409 idempotent request in progress` | The request with the same key is still being processed | Repeat shortly **with the same key** | | `422 idempotency mismatch` | You used the same key with a different body | A new key for every different operation; do not change the body when repeating the same operation (remember that the key is unique across the environment) | | `422 unprocessable: …` | A business rule rejection (balance, stock, limit) | Read the state and show it to the user; do not repeat the same request | | `410` | The key's 24-hour record retention expired | Read the state and, if the operation was not applied, restart it with a new key | | `413` | The body is above 2 MiB | Shrink the body; for images use the image upload endpoint | | `429` | Rate limit or rule budget | Wait and repeat with the same key; for the rule budget simplify the rule | | `503` and `Retry-After: 30` | The environment's placement is temporarily unavailable | Wait for `Retry-After` and repeat | | A big number is corrupted (`9007199254740992` instead of `9007199254740993`) | You read the JSON with `JSON.parse`; `int64` did not fit in a JS `number` | Use the SDK's `bigint` reading or `lossless-json` ([numbers](../reference/numbers-and-encoding.md)) | | An SDK error of type `Contract` | The response does not conform to the schema the SDK knows (the server is newer) or the response is above 2 MiB | Update the SDK; match the server and SDK versions | ## Plugins | Symptom | Likely cause | Fix | |---|---|---| | `403` and `forbidden` | The plugin is not enabled in the environment (read-only, draining, disabled) or the environment's plugin version does not match the server's; CSRF/Origin rejection | Look at the plugin's state and version in the panel ([Plugins](../panel/plugins.md)) | | `400` and `invalid_argument` ("x-crx-schema-version is required") | The `x-crx-schema-version` header is missing or the schema version is wrong; the payload does not conform to the schema; the same parameter twice in a query | Verify the operation's `schemaVersion` value and the input schema from the schema document | | `404` and `not_found` | The plugin ID or the message type is wrong; you confused the message type with the operation **name** | Write the `requestMessageType` value in the schema document into the path | | `409` `idempotency_in_flight` | The command with the same key is being processed | `retryable: true`; wait and repeat with the same key | | `422` `unprocessable` + `reason` | A plugin business rule rejection | `reason` is a plugin-specific code; see the plugin's documentation; repeating does not help | | The command result is uncertain | The network dropped | Query with `…/result?idempotency_key=…` or repeat with the same key ([calling plugins](../plugins/calling-plugins.md)) | | SDK `upgrade_required` (`plugin_binding_mismatch`) | The binding's pin does not match the expectation you hold or the document on the server | Get the current document and regenerate the binding ([bindings](../plugins/bindings.md)) | | `Cannot find module '@open-play/sdk/player'` in the generated binding | The generator writes the import with the name `@open-play/sdk`; the published package's name is `@ferdiunal/open-play-ts` | Define an alias in `package.json`: `"@open-play/sdk": "npm:@ferdiunal/open-play-ts@"` | | `crx plugins generate` error: `CRX TYPES FAIL` | The digest does not match, the output file already exists, the output folder does not exist, or there is a symbolic link in the path | Match the digest with the `schemaSha256` in the document; delete the existing output; create the folder | ## Realtime | Symptom | Likely cause | Fix | |---|---|---| | Ticket request `403` | An identity, CSRF, `Origin`, or `x-crx-env` error (all get the same response) | Check CSRF and `Origin`; the game's domain must be in the environment domain list | | Upgrade `401` | There is a query string, the `openplay.realtime.v1` subprotocol was not requested, or `Origin` is not allowed | [Protocol: open the socket](../realtime/protocol.md) | | An immediate `AuthenticationFailed` and close | The ticket was used (single-use), 30 s expired, or it was obtained from a different `Origin`; `Authenticate` did not arrive within 5 s | A new ticket on every connection; send `Authenticate` right after the socket opens | | `UnexpectedFrame` | A frame in the wrong phase (`Join` before authentication, a second `Authenticate`) | Set the order according to the [protocol](../realtime/protocol.md) | | `InvalidPayload` | Broken JSON, unknown field, a payload that does not conform to the schema, `int64` was sent as a number | Send counters as decimal text; validate the payload against the schema | | `CapacityExceeded` and close | Frame rate above 40/s; 4 sockets per player or 8 per address | Reduce sending; close old tabs | | The socket closes in 5 minutes (`4100`) | Idle close | A "Continue" flow; keep-alive input ([limits](../realtime/reconnect-and-limits.md)) | | Inputs get `Ack: Stale` | A newer socket of the same player was opened | Close the old socket; work with a single socket | | A `ResyncRequired` loop | Insisting on an expired or gapped cursor | Discard the cursor, `Join` without a cursor | | `/v1/ws` or `/v1/chat/ws` `401` in the browser | These two sockets require the `x-crx-env` header on the upgrade; the browser cannot add headers | Use REST in the browser ([realtime overview](../realtime/overview.md)) | ## Webhooks | Symptom | Likely cause | Fix | |---|---|---| | No request ever arrives at my endpoint | The endpoint is not registered, was deleted, or is disabled; the event type is not in the subscription list; the `webhooks` module is disabled (deliveries wait in place, data is not deleted); the endpoint is not publicly reachable | Check the endpoint and the module in the panel ([Webhooks and event queue](../panel/webhooks.md)) | | Registration `400` | The address is not `https`; it contains a private/loopback IP or user info | Provide a publicly reachable `https` address ([security](security.md)) | | The signature does not match | You parsed and reserialized the body (raw bytes are needed); the secret is wrong (the `whsec_…` text itself, not base64); you forgot the `t.` prefix | Verify the raw body; example: [security](security.md) | | Timestamp rejection | Your server's clock has drifted | Sync the clock with NTP; keep the window at 5 minutes | | The same event arrived twice | Delivery is at-least-once | Deduplicate with `X-Crx-Event-Id` | | Deliveries are `failed` | No `2xx` was received in 10 attempts | Fix the receiver error. The panel's deliveries are only for **monitoring** (there is no resend/replay action); make up for the effect of missed events by reading the relevant resource with REST | | A large event did not arrive | A body above 64 KiB is not sent | Shrink the event size | ## Unity | Symptom | Likely cause | Fix | |---|---|---| | The package does not install | `~/.upmconfig.toml` or the scoped registry configuration is missing; the token lacks `read:packages` | The [Unity SDK](../sdk/unity.md) installation steps | | Requests are blocked in WebGL | The game and the API are not on the same origin; the Unity WebGL SDK requires the same origin today | Set up a same-origin reverse proxy ([Unity SDK](../sdk/unity.md)) | | `UpgradeRequired` (`plugin_binding_mismatch`) | The `package-ride` binding embedded in the package does not match the server's | Update the SDK | | The session is not restored at runtime | The default secret storage is not secure (`PlayerPrefs`) | [Unity SDK](../sdk/unity.md): attach your own secure storage | ## If it is still not resolved 1. Repeat the same request with `curl` (keeping secret values safe); tell apart whether the problem is in the SDK, the network, or the server. 2. Find the status code in the [error reference](../reference/errors.md). 3. Review the network items in the [production checklist](production-checklist.md) (header forwarding, client IP, HTTPS). 4. Add the information above to your support request. --- Source: https://open-play.b4klabs.dev/docs/en/operations/faq.html # Frequently asked questions This page gathers the questions that come up most often in integration with short answers. For details, go to the linked pages. ## Getting started and topology **Can I put the server key inside the game?** No. The server key is only on your server. The game client works with the player session; if you need a key embedded in the client there is a client key, but the endpoints it can call are limited, and on some endpoints there is no key-type gate ([security](security.md), [endpoints](../reference/endpoints.md)). **Why am I getting a CORS error?** The server does not send CORS headers to an origin that is not in an environment's domain list (or the operator's global allowed list). Add the domain in the panel; the origin must be exactly `https://`. If the game is on a different registrable domain than Open Play, the session cookie (`SameSite=Lax`) is not sent; use a same-origin reverse proxy then. Do not add extra CORS headers to your own proxy ([architecture and hosting](../getting-started/architecture-and-hosting.md)). **My game is a mobile app or Unity desktop; do the browser restrictions apply?** The CORS and same-site rule concerns only the browser. Non-browser clients can use a Bearer session token instead of a cookie; but rules such as the CSRF pair on plugin commands and `Origin` on the realtime ticket request still apply ([calling plugins](../plugins/calling-plugins.md), [protocol](../realtime/protocol.md)). For Unity, see the [Unity SDK](../sdk/unity.md). **Can I use Open Play's gRPC interface?** It is not supported as a public surface. Game integrations go through REST, WebSocket, and the SDKs. **Which SDKs are there?** TypeScript (`@ferdiunal/open-play-ts`) and Unity (`com.openplay.sdk`). For modules that have no typed `CrxClient`, you use raw REST ([endpoints](../reference/endpoints.md)). Details are on the [TypeScript SDK](../sdk/typescript.md) and [Unity SDK](../sdk/unity.md) pages. ## Identity **How does the server verify who a player is?** Your game server verifies the access JWT returned at player sign-in offline with the JWKS ([server verification](../auth/server-verification.md)). It is not the session token or the cookie. **How long does a session live?** It slides for 30 days (one request a day is enough), and the absolute ceiling is 180 days. An open realtime socket does not keep the session alive ([security](security.md)). **What happens to a banned player?** On sign-in endpoints, after ownership is proven, they get `403` and `account_banned`; on other endpoints it looks like `401`. On plugin endpoints `403 forbidden` (`message: account banned`) ([errors](../reference/errors.md)). ## Replay safety, version, numbers **When is a new `Idempotency-Key` generated?** For every **new operation**. The same key is used on retries of the same operation; repeating a command with an uncertain outcome with a new key leads to double application ([idempotency](../reference/idempotency-and-concurrency.md)). **I got `409`. Can I repeat the same request?** There are two different `409`s: on a version conflict (`version conflict`) first re-read the state; on an "in progress" (`idempotent request in progress`) response repeat shortly with the same key. **Why is the balance a big integer?** Money is a minor-unit integer; floating point is used nowhere. You compute the display decimals yourself ([numbers and encoding](../reference/numbers-and-encoding.md)). **Why do some numbers come as text?** Plugin and realtime `int64` fields can exceed the safe range of the JavaScript `number`; so they are carried as decimal text. The TypeScript SDK turns them into `bigint`. **Why are percentage fields out of 10000?** Basis points: 10000 = 100% ([numbers and encoding](../reference/numbers-and-encoding.md)). **Why is the error body sometimes plain text and sometimes JSON?** Core REST returns plain text (a few JSON exceptions), plugin endpoints always return JSON. For the decision, look at the status code and, if present, the `code` field ([errors](../reference/errors.md)). ## Rate limits **Is there a per-key request limit?** No; there is no general quota. Certain operations (sign-in, sign-up, chat, plugin commands, socket connection) have limits. In big bulk jobs, apply your own concurrency limit ([rate limits](../reference/rate-limits.md)). **Hundreds of guests will be opened at the same time at launch. Will it be a problem?** Opening a guest is 30 per minute per IP. If the players are on separate IPs it is not a problem; but if your reverse proxy does not forward the client IP, they all gather in one bucket. Verify IP forwarding and do a load test ([production checklist](production-checklist.md)). ## Plugins **Can I upload my own plugin?** No, not yet. Plugins are trusted code compiled into the server; there is no third-party loading or isolation. Today's paths: rules, webhooks, and your own server ([plugins overview](../plugins/overview.md)). **The plugin schema changed; will my client break?** Plugin schemas are pinned by digest (`schemaSha256`). If the server changes the schema, the old binding sees a mismatch and you must regenerate it from the current document ([bindings](../plugins/bindings.md)). **A plugin call returned `403 forbidden`. Why?** Mostly the plugin is not enabled in your environment or the grant version does not match the server's; or a CSRF/Origin rejection. Look at the plugin's state in the panel ([Plugins](../panel/plugins.md)). ## Realtime **What should I do when the socket drops?** Get a new ticket, reconnect, and rejoin durable streams with the last cursor; if `ResyncRequired` arrives, join without a cursor. The TypeScript SDK does this on its own ([reconnect](../realtime/reconnect-and-limits.md)). **Why does the socket close every 5 minutes?** Because no player activity arrived on a subscription that accepts input, the server does an idle close (`4100`). Show a "Continue" screen or send a keep-alive input. **Can I use a socket for notifications and chat?** Not directly from the browser: `/v1/ws` and `/v1/chat/ws` require the `x-crx-env` header on the upgrade and the browser cannot add it. In the browser, read the inbox with REST and use the REST endpoints for chat ([realtime overview](../realtime/overview.md)). ## Webhooks **Can I accept webhooks without a signature?** No. Verify every request's signature over the raw body and deduplicate with `X-Crx-Event-Id` ([security](security.md)). **Can I resend an event I missed?** There is no resend action in the panel; deliveries are only monitored. Make up for it by reading the state of the relevant resource with REST. ## Administration **I lost the key; can I get it back?** No; the secret value is shown only at creation time. Generate a new key and revoke the old one ([security](security.md)). **What can I do in the panel, and what should I do with the API?** The panel is for managing the game (content, store, economy reports, rules, settings). In-game flows are done with the API and the SDK. For the panel screens, see the relevant [panel help pages](../panel/plugins.md). **There is something I cannot find in the documentation.** First look at [troubleshooting](troubleshooting.md) and the [glossary](../reference/glossary.md); if you cannot find it, send your question with the status code, path, and time information. --- Source: https://open-play.b4klabs.dev/docs/en/panel/dashboard.html # Game overview ## Purpose This is the first screen you see when you open the panel. It summarises the state of the selected game and environment; each card's **Aç** (Open) button takes you to the matching detail page. The page title has the form "game name · environment name" (for example "My Game · Production"), so you always see which environment you are working in. ## When to use - To check the overall state of the game at the start of the day. - To confirm that the numbers you expect (players, characters, rules) are in place after a change. - To see the recent actions and quickly understand who changed what. - To notice whether one of the safety protections has been left loose. ## Step by step 1. Make sure the selector at the top left shows the right game and environment. Every number on this page covers only the selected environment. 2. Look at the cards. If you see a note under a card, the number could not be fetched (see the table below). 3. Press the card's **Aç** (Open) button for details. 4. The "Son işlemler" (Recent actions) card shows the six newest admin actions. Use the **Tüm kayıtları gör** (See all records) button for the full list. ## Fields and options There is no form to fill in on this page. The cards show the following: | Card | What it shows | Open button | |---|---|---| | Oyuncular (Players) | The total number of player accounts in the selected environment | [Players](players.md) | | Oyuncu karakterleri (Player characters) | The number of characters; if the list is long, a "+" appears after the number | [Characters](characters.md) | | İçerik (Content) | The number of character templates and item definitions | İçerik (Content) | | Kurallar (Rules) | "enabled rules / total rules" (for example 3/5) | Kurallar (Rules) | | Eklenti (Plugin) | The state of the plugin attached to the game: Açık (On), Salt okunur (Read-only), Drain (closing, with the seconds left), Yetki yok (No permission) or Eklenti yok (No plugin) | Eklentiler (Plugins) | The "Son işlemler" card appears only if you have permission to read the audit log. Each row has the action name, the time, who did it, the target and the action reason. If a safety protection is loose, a yellow banner appears at the top of the page ("N koruma gevşek", N protections are loose); if its state could not be read, an "unknown" banner appears. The **Şalterleri aç** (Open the switches) button takes you to the safety switches page. ## Common situations | Situation | What to do | |---|---| | A card says "Bu sayıyı görme yetkiniz yok." (You do not have permission to see this number.) | Your account's role cannot read that area; ask an admin to widen your role. | | A card says "Sayı alınamadı." (The number could not be fetched.) | It is a temporary read error; refresh the page. If it continues, check the server status. | | The text "Bu oyunda henüz oyuncu yok" (There are no players in this game yet) appears | Your game client has not signed in yet; the number updates after the first player signs in. | | The plugin card says "Yetki yok" (No permission) | The plugin has no active grant for this environment; check its state on the Plugins page. | | The numbers differ from what I expected | You may have selected another environment (for example development instead of live). Check the environment name in the title. | ## Related docs - [Core concepts](../getting-started/concepts.md) - [Open Play overview](../getting-started/overview.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/players.html # Players ## Purpose This page lists the player accounts of the selected game and environment. Use it to find a player, check an account's status, open a profile and, if needed, create a new account by hand. In the left menu this page appears as "Oyuncular" (Players). ## When to use - To find a player's account when they ask for help. - To filter banned or suspicious accounts. - To create a player account by hand for testing. ## Step by step ### Finding a player 1. Make sure the environment selector at the top of the page shows the right game and environment. The list shows only the selected environment. 2. Type the player's name or email in the table's search box. 3. Use the table's filter menu to narrow the list by status, sign-in method or date. 4. Click the row, or press **Detay** (Details), to open the profile. ### Creating an account by hand 1. Press **Kullanıcı oluştur** (Create user). Only admins who are allowed to create accounts see this button. 2. Enter the display name. Email, password and extra information are optional. 3. Write a short reason in the **İşlem nedeni** (Action reason) field. 4. Save with the **Kullanıcı oluştur** button. The account appears in the list. > **Note** > > Players usually sign in from the game on their own. Use this form only for testing and support. ## Fields and options ### Account creation form | Field | Required | Description | |---|---|---| | Görünen ad (Display name) | Yes | The player's display name; at least 2 characters | | E-posta (Email) | No | The player's email address | | E-posta doğrulandı (Email verified) | No | Makes the address count as verified | | Geçici parola (Temporary password) | No | At least 12 characters; if empty, no password account is created | | Metadata JSON | No | Extra information, a JSON object | | İşlem nedeni (Action reason) | Yes | The reason written to the audit log, 8-500 characters | ### Columns in the list The columns Kullanıcı (User), Durum (Status), Giriş yöntemi (Sign-in method), Aktif oturum (Active sessions), Son giriş (Last sign-in) and Oluşturulma (Created) are shown by default. You can turn on the columns E-posta (Email), Oyuncu kimliği (Player ID), Hesap türü (Account type), Güncellenme (Updated) and E-posta doğrulandı (Email verified) from the table's column menu. ## Common situations | Situation | What to do | |---|---| | The list looks empty | Check that you selected the right environment and that no filters are active. | | I cannot find the player | Guest accounts have no email; search by name or ID, or use the sign-in method. | | The "Kullanıcı oluştur" button is missing | Your account's role cannot create accounts; ask an admin. | | The form shows a "metadata" error | Enter a valid JSON object; an array or plain text is not accepted. | ## Related docs - [Authentication overview](../auth/overview.md) - [Account linking](../auth/account-linking.md) - [Security](../operations/security.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/characters.html # Player characters ## Purpose This page lists the characters in the selected environment. A character is the entity a player plays in the game; its state (level, attributes, resources) changes through commands, and every change is recorded. Use it to find a character, open its profile and, if needed, create a new character by hand. Character templates (definitions) are not on this page; they are on the Content page. ## When to use - To check the state of a player's character. - To filter characters of a given type, such as drivers. - To create a character by hand for testing or support. > **Note** > > If the characters module is disabled, this page does not appear in the menu. Enable the module on the [Game settings › Modules](game-settings-modules.md) page. ## Step by step ### Finding a character 1. Make sure you selected the right game and environment; the list shows only the selected environment. 2. Narrow by type with the **Tümü** (All), **Sürücüler** (Drivers) and **Diğer** (Other) buttons at the top. 3. Use the table's search box and filter menu to narrow by name, type, level or date. 4. Click the row to open the profile. For the profile page, see [Character profile](character-profile.md). ### Creating a new character 1. Press **Yeni karakter** (New character). Only admins with permission to create characters see this button. 2. Choose the character's type from the **Tanım** (Definition) list. If there is no definition, first publish one on the Content page. 3. If you like, write the ID from your game server in the **Dış kimlik** (External ID) field. 4. Write an **İşlem nedeni** (Action reason) and press **Oluştur** (Create). The character is created immediately and you are taken to its profile. ## Fields and options ### New character form | Field | Required | Description | |---|---|---| | Tanım (Definition), or Tanım anahtarı (Definition key) | Yes | The definition the character is created from; its current version is used | | Dış kimlik (External ID) | No | An ID to match your game server; a second character cannot be created with the same ID | | İşlem nedeni (Action reason) | Yes | The reason written to the audit log, 8-500 characters | ### Columns in the list The columns Oyuncu (Player), Tür (Type), Seviye (Level), Puan (Score), Teslimat (Deliveries), Son değişiklik (Last change) and Oluşturulma (Created) are shown by default. You can turn on the Dış kimlik (External ID) and Oyuncu kimliği (Player ID) columns from the column menu. Old placeholder driver records that have no player carry a "Taşınmamış" (Not migrated) badge. ## Common situations | Situation | What to do | |---|---| | The list looks empty | Check that you selected the right environment and that no filters are active. | | It says "Bu ortamda henüz tanım yok" (There is no definition in this environment yet) | A character is created from a definition. Publish a definition on the Content page and come back. | | "Bu anahtarla yayınlanmış bir tanım yok" error (There is no published definition with this key) | The definition key is wrong or the definition is not published. Pick it from the list or publish the definition. | | "Bu dış kimlikle zaten bir karakter var" error (A character with this external ID already exists) | Enter another external ID or leave the field empty. | | The "Yeni karakter" button is missing | Your account's role cannot create characters; ask an admin. | ## Related docs - [Characters](../guides/characters.md) - [Core concepts](../getting-started/concepts.md) - [Authentication overview](../auth/overview.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/clans.html # Clans ## Purpose This page lists the clans in the selected environment and shows the details of the clan you select: clan settings, members, invites, applications, clan treasury (vault) transfers and completed command records. The page is **read-only**; you cannot create a clan, add a member or take money out of the treasury from the panel. Clan actions are done from the game, through server calls. ## When to use - To see a clan's members, leader and level. - To verify the amount a player paid into or took out of the clan treasury. - To check the state of an application or invite. ## Step by step 1. Make sure you selected the right game and environment. The list shows only the selected environment. 2. In the "Klan atlası" (Clan atlas) table, search and filter by name, tag, status, level or date. 3. Click the clan's name, or open the row, to see the details. When the page first opens, the newest clan is selected automatically. 4. In the "Klan detay ve ilişkiler" (Clan details and relations) card you see members, invites, applications, treasury transfers and command records. Long tables are paginated. 5. To go to a player's profile, use the player link in the table. ## Fields and options There is no form to fill in on this page. ### Columns in the list Klan (Clan; the name and the tag in square brackets), Durum (Status), Seviye (Level), XP, Üye (Members) and Oluşturulma (Created) are shown by default. You can turn on the Etiket (Tag), Lider (Leader) and Güncellenme (Updated) columns from the column menu. The status is "active" or "disbanded". ### Detail cards | Card | Contents | |---|---| | Klan ayarları (Clan settings) | The environment's clan rules; read-only | | Üyeler (Members) | Player, role, join time and leave time. Members who left are also listed and marked with an "Ayrıldı" (Left) badge | | Davetler (Invites) | The invited player, the status and the expiry | | Başvurular (Applications) | The applying player, the status and the creation time | | Kasa transferleri (Treasury transfers) | Deposit or withdrawal, amount, the player who did it, balances and currency. Amounts are shown as text | | Komut geçmişi (Command history) | The type of the completed command, the request digest (hash) and the time; the response body is not shown | ## Common situations | Situation | What to do | |---|---| | The list looks empty | Check that you selected the right environment and that no filters are active. | | The member count does not match the row count | Members who left are also listed; rows with the "Ayrıldı" badge are not counted in the active member count. | | I cannot edit the clan from the panel | The page is read-only; clan actions are done from the game or through server calls. | | A warning like "Bu sayıyı görme yetkiniz yok" (You do not have permission to see this number) appears | Your role has no clan read permission; ask an admin. | ## Related docs - [Clans and parties](../guides/social.md) - [Core concepts](../getting-started/concepts.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/parties.html # Parties ## Purpose This page lists the parties in the selected environment; it shows the members and invites of the party you select, and the result of a reward distribution. The page is **read-only**: you cannot form a party, remove a member or distribute a reward from the panel. Party actions are done from the game, through server calls. ## When to use - To see a party's members, leader and loot rule. - To understand, from the invite status, why a player could not join a party. - To verify the amount, status and delivery mode of a reward distribution. ## Step by step ### Inspecting a party 1. Make sure you selected the right game and environment. 2. In the "Party roster" table, filter by status, capacity, loot rule or date. When the page opens, the newest party is selected automatically. 3. To select another party, click its name or open the row. 4. In the "Party detay ve ilişkiler" (Party details and relations) card you see the leader, the loot rule, the capacity, the members and the invites. ### Looking up a reward distribution 1. Have a party selected. 2. In the "Reward distribution lookup" card, paste the distribution's UUID into the **Dağıtım kimliği** (Distribution ID) field. 3. Press **Yükle** (Load). If the ID is not a valid UUID, no request is sent and an error appears next to the field. 4. The result shows the amount, the status and the delivery mode. The raw reward content is not shown. ## Fields and options | Field | Required | Description | |---|---|---| | Dağıtım kimliği (Distribution ID) | Yes (for Yükle) | The reward distribution's UUID; it belongs to the selected party, and the field resets when the party changes | ### Columns in the list Party (a shortened ID), Durum (Status), Üye (Members; "member count/capacity"), Ganimet kuralı (Loot rule), Lider (Leader) and Oluşturulma (Created) are shown by default. You can turn on the Kapasite (Capacity) and Güncellenme (Updated) columns from the column menu. The status is "active" or "disbanded", and the loot rule is "equal" or "weighted". In the member and invite tables, the "Pay" (Share) column shows each record's reward share as a percentage. ## Common situations | Situation | What to do | |---|---| | The list looks empty | Check that you selected the right environment and that no filters are active. | | "Dağıtım kimliği 8-4-4-4-12 biçiminde olmalı" error (The distribution ID must be in 8-4-4-4-12 form) | Copy the ID completely, with its dashes, and make sure there are no extra characters at the start or end. | | The distribution cannot be found | The ID may belong to another party or environment; make sure you selected the right party. | | I cannot change the party from the panel | The page is read-only; party actions are done from the game or through server calls. | ## Related docs - [Clans and parties](../guides/social.md) - [Rewards](../guides/rewards.md) - [Core concepts](../getting-started/concepts.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/scene-chat.html # City chat ## Purpose This page shows the messages written to the game's city chat and lets you moderate: you can delete the text of a harmful message, mute the player who wrote it for a set time and lift active mutes. Message text is shown as plain text. Messages are kept for 30 days and then deleted automatically. ## When to use - To find and inspect a message when a complaint about a player comes in about chat. - To delete a swear word, spam or a harmful message. - To mute a player who broke the rules for a while. > **Note** > > This page works only if the plugin attached to the selected game provides scene chat. If it does not, the text "Bu oyunda sahne sohbeti yok" (There is no scene chat in this game) appears. In the menu it appears only for such games. ## Step by step ### Deleting a message 1. Find the message on the **Aktarım** (Transcript) tab. You can filter the table by room, author, account type, deleted or not, and time; the newest message is at the top. 2. Press **Sil** (Delete) in the row. 3. Write an **İşlem nedeni** (Action reason) and confirm with **Mesajı sil** (Delete the message). The message text is deleted; its length stays in the record and the row shows "Silindi · N karakter" (Deleted · N characters). This cannot be undone. ### Muting a player 1. Press **Yazarı sustur** (Mute the author) in the message's row. 2. Choose the duration from the **Süre** (Duration) list. If you choose "Özel bitiş…" (Custom end…), enter the end time. 3. Write an **İşlem nedeni** and press **Sustur** (Mute). The player's messages are rejected; the player is not told the reason and their session is not dropped. ### Lifting a mute 1. Open the **Aktif susturmalar** (Active mutes) tab. Mutes that have run out leave the list automatically. 2. Press **Susturmayı kaldır** (Lift the mute) in the row, write the reason and confirm. The player can write again immediately. > **Warning** > > A guest account can get around a mute by creating a new guest. The lasting fix is to ban the account; for that, use the "Banla" (Ban) action on the [Player profile](player-profile.md) page. ## Fields and options ### Mute window | Field | Required | Description | |---|---|---| | Süre (Duration) | Yes | 5 minutes, 1 hour, 24 hours, 7 days or Özel bitiş (Custom end) | | Bitiş anı (End time) | Only for a custom duration | Must be in the future, at most 30 days away | | İşlem nedeni (Action reason) | Yes | 8-500 characters | The message delete and lift-mute windows have only the **İşlem nedeni** field. ### Table columns Transcript: Zaman (Time), Yazar (Author, with an account type badge: Misafir (Guest) or Kayıtlı (Registered)), Oda (Room), Mesaj (Message) and Eylemler (Actions). The Hesap türü (Account type), Silindi (Deleted), Uzunluk (Length) and Oyuncu kimliği (Player ID) columns are turned on from the column menu. Active mutes: Oyuncu (Player), Bitiş (End, with the time left), Gerekçe (Reason), Susturan (Muted by) and Eylem (Action). ## Common situations | Situation | What to do | |---|---| | It says "Bu oyunda sahne sohbeti yok" | The selected game's plugin does not provide scene chat; the page works once a plugin that declares a scene is attached. | | The Sil and Sustur buttons are not visible | Your role has no chat write permission; you can only read. | | The custom end gives an error | The end must be in the future and no more than 30 days away. | | I cannot find an old message | Messages are deleted automatically after 30 days. | | A muted player is writing again | A guest account may have opened a new guest; consider banning the account. | ## Related docs - [Chat](../guides/chat.md) - [Core concepts](../getting-started/concepts.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/organizations.html # Player organizations ## Purpose This page lists the organizations players have created and lets you manage the members, invites, teams and roles of the organization you select. An organization is not a studio; for studios, use the [Studios and games](tenancy.md) page. > **Warning** > > This screen is being retired. It keeps working and is hidden in the menu by default; for new work, use [Clans](clans.md) for player groups. ## When to use - To add members, invites, teams or roles to existing organizations. - To fix or remove an organization. - To inspect the organization data of an old setup. ## Step by step ### Creating an organization 1. Press **Organizasyon oluştur** (Create organization). Only admins with organization write permission see this button. 2. Fill in the **Ad** (Name) and **Plan** fields. 3. Write an **İşlem nedeni** (Action reason) and save. The organization's page opens. ### Inviting a member 1. On the organization page, on the **Üyeler** (Members) or **Davetler** (Invites) tab, press **Davetle üye ekle** (Add a member by invite) or **Davet oluştur** (Create invite). 2. Enter the email, the role and the expiry date; write the reason and save. 3. The invite appears on the **Davetler** tab. You can renew an expired or pending invite with a new expiry using **Yeniden gönder** (Resend), and make a pending invite unusable with **İptal** (Cancel). > **Note** > > The panel only creates and manages the invite record; it does not send the invite email. ### Changing a member's role 1. On the **Üyeler** tab, press **Düzenle** (Edit) in the member's row. 2. Choose the built-in role, add dynamic roles if needed, write the reason and save. 3. To remove the member, use **Çıkar** (Remove) in the row; to confirm, you type the member's name (or their email if there is no name). ### Managing teams and roles 1. On the **Takımlar** (Teams) tab, add a team with **Takım oluştur** (Create team); add one of the organization's members to the team with the **Üye** (Member) button. 2. On the **Roller** (Roles) tab, add a new role with **Dinamik rol oluştur** (Create dynamic role). Built-in roles (marked "Built-in") cannot be deleted or renamed. ## Fields and options | Field | Window | Description | |---|---|---| | Ad, Plan (Name, Plan) | Organization | The name and the plan text (the plan is at most 64 characters) | | Built-in rol, Dinamik roller (Built-in role, Dynamic roles) | Edit member | The built-in role and extra dynamic roles | | E-posta, Rol, Son geçerlilik (Email, Role, Expiry) | Invite | The role is admin or member; validity is at most 30 days | | Yeni son geçerlilik (New expiry) | Resend invite | The new expiry date | | Takım adı (Team name) | Team | The team's name | | Oyuncu (Player) | Add team member | One of the organization's members | | Rol adı, Permissions JSON (Role name, Permissions JSON) | Dynamic role | The role name and the permissions object | | İşlem nedeni (Action reason) | All | 8-500 characters | | Onay metni (Confirmation text) | Delete and remove | Type the record's name exactly; if there is no name, type "SİL" (DELETE) | ## Common situations | Situation | What to do | |---|---| | I cannot see the page in the menu | It is hidden by default because it is being retired; turn on the "Gizlenen eski ekranları göster" (Show hidden legacy screens) switch in your account menu, or use the address directly. | | The invite gives a validity error | The expiry date must be in the future and no more than 30 days away. | | A "Permissions JSON" error | The permissions object must have the form `{ "resource": ["action"] }`; actions cannot be empty or repeated. | | I cannot add a player to a team | Only the organization's members can be added to a team; add them as a member first. | | I cannot delete a built-in role | Built-in roles cannot be deleted; only dynamic roles can. | ## Related docs - [Clans and parties](../guides/social.md) - [Core concepts](../getting-started/concepts.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/sessions.html # Session center ## Purpose This page lists the players in the selected environment together with their open session counts. A session is a device or browser a player signed in on. Here you see only a summary; to see device details and close sessions, you go to the player's profile. The page is not in the menu; it opens from the session center link on the [Players](players.md) page. ## When to use - To see which players have open sessions. - To find accounts with many open sessions or that have not signed in for a long time. - To go to a player's profile in order to close their sessions. ## Step by step 1. Make sure you selected the right game and environment. 2. Sort or filter the table by status and open session count. 3. To see and close the player's sessions, press **Oturumları yönet** (Manage sessions) in the row. The [Player profile](player-profile.md) opens; on the **Oturumlar** (Sessions) tab you find the individual sessions, and in the **Diğer işlemler** (More actions) menu you find the "Tüm oturumları kapat" (Close all sessions) action. ## Fields and options There is no form to fill in on this page. Columns: Kullanıcı (User), Durum (Status), Aktif oturum (Active sessions), Son giriş (Last sign-in) and Güvenlik (Security). You can turn on the Oluşturulma (Created) column from the column menu. ## Common situations | Situation | What to do | |---|---| | I cannot close the session from this page | The page is only a summary; closing is done on the player profile. | | The option to close sessions is not visible | Your role has no session revoke permission; ask an admin. | | The player was able to sign in again | Closing sessions does not block sign-in; to block sign-in, ban the account. | ## Related docs - [Sessions and CSRF](../auth/sessions-and-csrf.md) - [Authentication overview](../auth/overview.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/content.html # Content ## Purpose This page gathers your game's content in **groups**: vehicles, NPCs, weapons and so on. Each group has a schema made of fields you define (number, text, color, 3D model...) and entries filled in with that schema. The game sees content only as you **published** it; a change you make in the draft does not reach the player until you publish. ## When to use - When you set up the first group to add a new kind of content (for example vehicles) to your game. - To change a group's entries and send them to the game (publish). - To go back to a version you published earlier. ## Step by step ### Create a new group 1. Press **Yeni grup** (New group). Only admins with content write permission see the button. 2. You can start with an empty group or choose one of the ready templates; a template only fills in a field suggestion, it does not create entries. 3. Write the group name, icon and description and set up the fields with **Devam: alanlar** (Continue: fields). See [Edit group](content-group-edit.md) for details. ### Change entries and publish 1. Click the group card; the list of entries opens. You can switch between cards and the table view. 2. Open an entry, change it with **Düzenle** (Edit) and say **Taslağı kaydet** (Save draft). The change is only in the draft. See the [Entry](content-entry-edit.md) page for details. 3. Press **Yayınla** (Publish) at the top of the list. The window that opens shows, entry by entry, what will go to the game; if there are **Engelleyici** (Blocking) rows you must fix them first, **Uyarı** (Warning) rows do not stop the release. 4. If you wish, write the "Ne değişti?" (What changed?) note and publish. ### Go back to an older release 1. Press **Geçmiş** (History) in the entry list. 2. Look at what will change with **Farkı gör** (See the difference) next to the release you want to return to. 3. Say **Bu hale geri dön** (Go back to this state). This creates a new release; it does not touch your draft. ### Suggested groups If a plugin offers its own content ready-made, a **Önerilen** (Suggested) section appears at the top of the page. The **Kur** (Set up) button creates that group with today's values; you can then edit it as you like. ## Fields and options | Field | Where | Description | |---|---|---| | Ara (Search) | Entry list (card view) | Searches by entry name or key | | Taslak değişikliği olanlar (With draft changes) | Entry list (card view) | Shows only entries with unpublished changes | | Ne değişti? | Publish window | Optional release note, at most 280 characters | ### Sections on the page - **Önerilen:** Ready-made groups offered by plugins. - **Gruplarınız** (Your groups): Groups you created; the card shows the entry count, last release time and an "N unpublished changes" badge. - **Yerleşik içerik** (Built-in content): The system's own groups such as character and item definitions. They have no release step: as soon as you save, it is valid in the game. The cards go to the [Character templates](character-templates.md) page. - **3D model kütüphanesi** (3D model library): A link to the [3D models](models.md) page. If the Scenes module is off, the reason is written instead of the link. ## Common situations | Situation | What to do | |---|---| | The "Yeni grup" button is missing | Your account's role has no content write permission; ask an admin. | | The Publish button does not work, I see "Engelleyici" | Open the related entry with the link on the blocking row and fix the missing or wrong value. | | Players do not see the change I made | The change stayed in the draft; **Yayınla** the group. | | The 3D model library link is not visible | The Scenes module is off in this environment; turn it on from the Modules tab in Game settings. | ## Related docs - [Edit group](content-group-edit.md) - [Entry](content-entry-edit.md) - [Character templates](character-templates.md) - [3D models](models.md) - [Content groups guide](../guides/content-groups.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/character-templates.html # Character templates ## Purpose This page holds the game's character and item **templates**. It has four sections: character definitions, item definitions, level curves and traits. A character definition decides which traits and which level curve a character is born with; an item definition is the template of every item that can be given in the game. When you change a template the old version is not deleted; a new version is released. ## When to use - To define a new item (weapon, consumable, cosmetic...). - To change which traits characters are born with. - To set the XP needed per level. - To try whether a definition is valid before releasing it. ## Step by step ### Add a new item 1. In the **Eşya tanımları** (Item definitions) section press **Yeni silah** (New weapon). Only admins with the related write permission see the button. Weapon is not a separate kind: it is the "Weapon / equipment" kind; you can change the kind in the form. 2. **Temel bilgiler** (Basic information): name, key, kind; the max stack if the item is stackable. 3. **Ticaret** (Trade): tradeability and NPC buy and sell prices. 4. **Görünüm (3D)** (Appearance (3D)): if you wish, choose a published model. 5. **Oyun verisi** (Game data): add the extra values the game will read, row by row. 6. **Kayıt** (Save): write a reason and save. The item's system information and details open when you press the row. ### Release a new version for a character definition 1. In the **Karakter tanımları** (Character definitions) section press **Yeni sürüm yayınla** (Release new version); or open a definition and choose **Bu tanımla yeni sürüm hazırla** (Prepare a new version with this definition). 2. Write the key and choose at least one trait. 3. If needed, change the values coming from the traits for this definition with **Üzerine yazılacak değerler** (Values to overwrite). 4. Set the display name, the model and the game data. 5. Write a reason and release. The panel states the work to be done in one sentence before saving. > **Note** > > The trait order and values are merged at release time and become a new version. The display name, model and game data are independent of the version; they take effect immediately when saved. ### Try without releasing With **Doğrula / önizle** (Validate / preview) you can see whether the definition is valid and its merged form without saving anything. **Önizle** (Preview) in the level curve section also computes on the server the curve's highest level and the total XP for that level. ### Release a level curve 1. In the **Seviye eğrileri** (Level curves) section press **Yeni sürüm yayınla**. 2. Write the curve key and the curve's JSON definition; check the result first with **Önizle**. 3. Write a reason and press **Yayınla** (Release). The new version does not replace the old one. ## Fields and options ### Item form | Field | Required | Description | |---|---|---| | Name | Yes | The name the player will see | | Key | Yes | Lowercase letters, digits, underscores; fixed after saving | | Kind | Yes | Consumable, Weapon / equipment, Material, Cosmetic, VIP card | | Stackable | Yes | Yes or No | | Max stack | If stackable | 1 to 2147483647 | | Tradeable | Yes | Yes or No | | NPC buy price, NPC sell price | No | If empty, trade in that direction is off; 0 is free | | 3D model | No | A published model | | Values | No | Game data rows | | Action reason | Yes | At least 8 characters | ### Character definition form | Field | Required | Description | |---|---|---| | Definition key | Yes | Fixed after saving | | Trait keys | Yes | At least one; order matters | | Values to overwrite | No | Section, name, field and value rows | | Display name | No | The name shown in the game | | 3D model | No | A published model | | Game data | No | Extra values | | Action reason | Yes | At least 8 characters | In overwrite rows the **Bölüm** (Section) is one of: Nitelikler (Attributes; fields: Default value, Min, Max), Kaynaklar (Resources; Default value, Max (expression), Regeneration per minute, When it reaches zero) and Türetilmiş değerler (Derived values; Expression). System fields used by a plugin are locked. ## Common situations | Situation | What to do | |---|---| | "Yayın için en az bir özellik anahtarı yazın" (Write at least one trait key to release) | A version with no traits empties the definition; choose at least one trait. | | The "Yeni sürüm yayınla" or "Yeni silah" button is missing | Your account's role has no related write permission; the page is read-only. | | Someone else changed the same record and a conflict warning appeared | See the latest state with **Güncel hâli yükle** (Load the current state); or deliberately overwrite with **Yine de kaydet** (Save anyway). | | "Bu anahtar zaten var" (This key already exists) | You cannot write an existing key in the new version form; edit the definition from its detail with **Bu tanımla yeni sürüm hazırla**. | | I cannot delete a record with a "Sistem" (System) badge | It is a system record used by a plugin; it is not deleted, changes become a new version. | | I cannot change traits from this page | Traits are only inspected on this screen. | | There is no "weapon" in the item kind list | Weapon is not a separate kind; it is the "Weapon / equipment" kind. | ## Related docs - [Content](content.md) - [3D models](models.md) - [Characters guide](../guides/characters.md) - [Inventory and items guide](../guides/inventory-items.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/models.html # 3D models ## Purpose This page is the library of 3D models your game will use. You upload a model; the server converts it to the **glb** format the game can read and validates it. You publish the ready model and bind it to an item or a character; the game sees the change without redeploying the game. ## When to use - To add a new vehicle, weapon or character model to the game. - To check the status of a model whose conversion is in progress or has failed. - To inspect a model's published version, the definitions that use it or its version history (click the row; see [Model profile](model-profile.md)). ## Step by step ### Upload a model 1. Press **Model yükle** (Upload model). Only admins with model write permission see the button. 2. Choose the **Yükleme türü** (Upload type): single file or zip package. 3. Choose the file. The file cannot exceed 50 MiB. 4. If you wish, write a name. For obj, stl, 3mf, dxf files and zip packages, **Birim** (Unit) and **Yukarı eksen** (Up axis) also appear; defaults are filled in by file format, check the values. 5. Press **Yükle** (Upload). A progress bar shows during upload; you can stop it with **Yüklemeyi iptal et** (Cancel upload). 6. The model passes through the **Sırada** (Queued) and **Dönüştürülüyor** (Converting) states in the list and becomes **Hazır** (Ready). The list refreshes by itself in the meantime. ### Publish a model A ready model does not go to the game immediately. Open the model row and **Yayınla** (Publish) the version from the **Sürümler** (Versions) table on the [Model profile](model-profile.md). > **Note** > > Formats that carry no animation (obj, stl, 3mf, dxf) appear in the list with the note "Bu biçim animasyon taşımaz" (This format carries no animation). Clips cannot be added to these models. ## Fields and options ### Upload form | Field | Required | Description | |---|---|---| | Upload type | Yes | Single file or zip package | | File | Yes | At most 50 MiB | | Name | No | If empty, the file name is used, at most 200 characters | | Main file | No | Only in zip mode; the path inside the zip if there is more than one model file | | Unit | Depends on format | Micron, millimeter, centimeter, inch, foot, meter | | Up axis | Depends on format | Y up or Z up | ### List columns The Name, Format, Last version (with status), Published, Versions and Added columns are shown by default. You can turn on the Last version number and Lifecycle columns from the table's column menu. Lifecycle: Active, Archived. ## Common situations | Situation | What to do | |---|---| | The model is in the "Başarısız" (Failed) state | Open the model's row; under the version the reason is written as a plain sentence (for example a reference to an external file or a 2D dxf). Fix the file and upload again. | | I uploaded a zip and it does not know which file is the main file | Write the model file's path inside the zip in the **Ana dosya** (Main file) field. | | The model looks too big or too small | You chose the wrong unit during upload; upload the model again. | | The Upload model button is missing | Your account's role has no model write permission. | | Conversion never starts, "glb only" warning | The converter runs only on amd64 servers; in this environment you can upload only glb. | | The library list is empty | Check the selected game and environment; if the Scenes module is off, the model library cannot be used in this environment. | ## Related docs - [Model profile](model-profile.md) - [Content](content.md) - [Scenes, images and 3D models guide](../guides/scenes-assets-models.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/store.html # Store ## Purpose This page manages the products players buy in the game. Products are divided into **showcases**: a showcase is a store screen in your game (for example Garage or Armory). Each product sells an item for game money or real money; you set its price, stock, badge and when it will be on sale from here. ## When to use - To add a new store screen (showcase) to your game. - To put a product in a showcase and change its price or stock (the [Product](store-product.md) page). - To filter products by status and to sort showcases and products. ## Step by step ### Preparation To sell a product you first need a [currency](currencies.md) you can price in and an item to sell (under Content or [Character templates](character-templates.md)). If something is missing, the page says so in a warning box and links to the related page. ### Create a showcase 1. Press **Yeni vitrin** (New showcase), or **İlk vitrini oluştur** (Create the first showcase) if there is no showcase. Only admins with store write permission see the button. 2. Write the name; the key is suggested from the name. The key is the ID the game uses when opening the showcase. 3. If you wish, add a description and a banner, and choose the status and the sale time. 4. Write a reason (the panel fills in a suggestion) and press **Vitrini oluştur** (Create showcase). To edit a showcase, choose the showcase tab and press **Vitrini düzenle** (Edit showcase). You can delete the showcase from the edit window; when it is deleted, the products in it are deleted too, past sales are kept. ### View and sort products 1. Choose the showcase from the showcase tabs at the top. 2. Change the view with the **Izgara** (Grid) and **Liste** (List) buttons. The list view searches and filters the table with its own toolbar. 3. While **Sırala** (Sort) is on, you can move a product up or down with the buttons next to it, and move the showcase itself with **Vitrini sola/sağa taşı** (Move showcase left/right). Filters are disabled while sorting is on. 4. To go to a product's detail, click its card or row; for a new product press **Yeni ürün** (New product). ### Add vehicles to the store If the vehicles content group has been published, the **Araçları mağazaya ekle** (Add vehicles to the store) button appears. It prepares the Garage showcase and a locked product for each vehicle in one step; the result is summarized on the page. ## Fields and options ### Showcase form | Field | Required | Description | |---|---|---| | Name | Yes | At most 120 characters | | Key | Yes | Lowercase letters, digits, hyphens, underscores; at most 64 characters | | Description | No | At most 500 characters | | Showcase banner | No | PNG, JPEG or WebP image | | Status | Yes | Draft or Live | | When on sale? | Yes | Immediately or a date range | | Start, End | In a range | An empty end is unlimited | | Action reason | Yes | 8-500 characters | ### Filters Search, Status and (on a narrow screen) Showcase. Statuses: Live, Draft, Scheduled, Expired, Sold out. ### Sections - **Ürünler** (Products): Showcases and products. - **NPC tüccarları** (NPC traders): A read-only list. Store products use the automatic "Store" trader in the background; you do not need to do anything here. - **Uygulama içi ürünler** (In-app products): A read-only list; App Store and Google Play products. You can bind a store product to one of them. ## Common situations | Situation | What to do | |---|---| | "Mağaza bu ortamda kapalı" (The store is off in this environment) | The Store module is off; turn it on from the Modules tab in Game settings. | | "Fiyat vermek için önce bir para birimi oluşturun" (Create a currency first to set prices) | Add a currency from the [Currencies](currencies.md) page. | | "Satacak bir eşya yok" (There is no item to sell) | First add an item from the Content page. | | The product is not visible in the game | Check that the product's and the showcase's status is **Yayında** (Live) and that the sale time has come. | | "Bu sırada başka biri mağazayı değiştirdi" (Someone else changed the store in the meantime) | The store catalog is written with version checking; press **Yeniden yükle** (Reload) and make the change again. | | I changed the showcase key and the game cannot find the showcase | The game opens the showcase with its key; update the call in the game with the new key too. | ## Related docs - [Product](store-product.md) - [Currencies](currencies.md) - [Store and in-app purchases guide](../guides/store-iap.md) - [Economy guide](../guides/economy.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/currencies.html # Currencies ## Purpose This page lists your game's currencies. A currency is the unit used to set prices in the store and to give money in reward tables (gold, diamonds, cents and so on). Each card shows the currency's name, icon, key, decimal places, supply cap and whether it can be sent between players. ## When to use - To create a currency before setting prices in the store or setting up a reward table. - To open an existing currency's detail (click the card; see [Currency](currency-profile.md)). ## Step by step 1. Choose the right game and environment from the environment selector at the top. 2. Press **Yeni para birimi** (New currency). Only admins with currency write permission see the button. 3. Fill in the form that opens using the instructions on the [Currency](currency-profile.md) page. 4. When you save, the currency is added to the list. > **Note** > > There is no deleting a currency on this page. Every save with the same key produces a new version; old versions do not change. ## Fields and options There is no form field to fill in on this page; the create form is described on the [Currency](currency-profile.md) page. The information shown on the card: | Information | Description | |---|---| | Name and icon | The name and image players see | | Key | The ID the game uses to find the currency | | Decimal places | How many decimal places the amount is shown with | | Supply cap | The total amount that can be minted; "Sınırsız" (Unlimited) if none | | Transferable | Whether players can send it to each other | ## Common situations | Situation | What to do | |---|---| | "Ekonomi bu ortamda kapalı" (Economy is off in this environment) | The Economy module is off; turn it on from the Modules tab in Game settings. | | "Henüz para birimi yok" (No currency yet) | Create the first one with **Yeni para birimi**. | | The New currency button is missing | Your account's role has no currency write permission. | ## Related docs - [Currency](currency-profile.md) - [Economy reports](economy.md) - [Store](store.md) - [Economy guide](../guides/economy.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/reward-tables.html # Reward tables ## Purpose A reward table defines which reward an NPC, creature, tournament or mission gives and with what chance. This page lists all the tables in the environment as cards: name, key, row count, the number of "always given" rewards and how many places the table is used in. ## When to use - To create a new reward table. - To quickly look at where a table is used or how many rows it has. - To open, edit, try or delete a table (click the card; see [Reward table](reward-table-profile.md)). ## Step by step 1. Choose the right game and environment from the environment selector at the top. 2. Press **Yeni ödül tablosu** (New reward table). Only admins with reward table write permission see the button. 3. Fill in the form using the instructions on the [Reward table](reward-table-profile.md) page. 4. When you save, the table is added to the list. > **Note** > > A table does not give rewards by itself. A reward is given when an NPC, mission or rule calls the table by its key; see the [Rewards guide](../guides/rewards.md) for details. ## Fields and options There is no form field to fill in on this page; the create form is described on the [Reward table](reward-table-profile.md) page. The information shown on the card: | Information | Description | |---|---| | Name and key | The table's name and the ID the game calls it with | | Rows | The number of chance-based reward rows | | Always given | The number of rewards given every time, independent of the draw | | Usage | How many places the table is used in; "Kullanılmıyor" (Not used) if it is not used at all | ## Common situations | Situation | What to do | |---|---| | "Ödüller bu ortamda kapalı" (Rewards are off in this environment) | The Rewards module is off; turn it on from the Modules tab in Game settings. | | "Henüz ödül tablosu yok" (No reward table yet) | Create the first one with **Yeni ödül tablosu**. | | The New table button is missing | Your account's role has no reward table write permission. | ## Related docs - [Reward table](reward-table-profile.md) - [Currencies](currencies.md) - [Rewards guide](../guides/rewards.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/economy.html # Economy reports ## Purpose This page shows the state of money in your game. It does three jobs: it queries the **wallet, inventory and items** of a player, character, clan or the system; when needed it **adds to or deducts from the balance** of a wallet; and it shows a currency's **daily summary**, the biggest balances and warnings, as well as **trades** between players. ## When to use - To investigate where a player's money went. - To fix a wrong balance (support work). - To watch whether too much money is being minted or burned in the game. - To look at a trade's status. ## Step by step ### Query a wallet 1. Choose the **Sahip türü** (Owner type): player, character, clan or system. 2. Search for the owner by name and select. For a system owner, write the ID and press **Sorgula**. 3. The result shows that owner's **Cüzdanlar** (Wallets), **Envanter** (Inventory) and **Eşyalar** (Items) sections. The **Hareketler** (Movements) button on a wallet row opens that wallet's ledger movements. > **Note** > > Game money is in the player's wallet. If you choose a character, the panel resolves the character to the player who owns it and says so on screen; a character without a player stays a character. The real place for a player is the wallet section in the player profile; the "Oyuncu profilinde aç" (Open in player profile) link in the result takes you there. ### Add to or deduct from a balance 1. Press the balance operation button on the wallet row. Only admins with the permission to give or deduct balance see the button. 2. Write the **Tutar** (Amount). 3. Write the **İşlem nedeni** (Action reason). 4. Press **Bakiye ekle** (Add balance) or **Bakiyeden düş** (Deduct from balance). > **Warning** > > A balance operation really changes money and shows permanently in the ledger; it cannot be undone, only corrected with a reverse operation. If the wallet changes through another operation while you are looking, the operation is rejected; refresh the page and try again. ### Daily report 1. Open the **Günlük raporlar** (Daily reports) section. 2. Enter the **Para birimi anahtarı** (Currency key) and the **Başlangıç** (Start) and **Bitiş** (End) days. 3. The **Günlük özet** (Daily summary), **En çok bakiyesi olanlar** (Biggest balances) and **Uyarılar** (Warnings) boxes fill in. > **Note** > > Daily reports are shown for now as raw data (key and value pairs); there is no chart yet. For a readable summary per currency, see the "Last 30 days" card on the [Currency](currency-profile.md) page. ### Trades The **Takaslar** (Trades) section lists trades between players with the total count, status, number of parties and legs (money or items given). ## Fields and options | Field | Where | Description | |---|---|---| | Owner type | Owner query | Player, character, clan or system | | Owner (search) | Owner query | Search by name and select | | Owner ID | Owner query | Only for a system owner | | Wallet ID | Owner query | If there is no permission to see wallets; UUID | | Amount | Balance window | With the currency's decimal places | | Action reason | Balance window | At least 8 characters | | Currency key | Daily reports | For example gold | | Start, End | Daily reports | Closed days | ## Common situations | Situation | What to do | |---|---| | "Henüz bir sahip seçilmedi" (No owner selected yet) | Choose the owner type above and search for the owner. | | The Wallets section shows a permission warning | Your account's role has no wallet read permission. If you have the balance permission, you can operate by pasting the wallet ID; movements still cannot be listed. | | "Geçerli bir kimlik (UUID) girin" (Enter a valid ID (UUID)) | The ID must be hyphenated in 8-4-4-4-12 format. | | The balance operation was rejected | The wallet may have changed while you were looking; refresh the page. Also check that the amount fits the currency's number of decimal places. | | The report is empty | Make sure you entered the currency key and a closed date range. Only closed days are reported. | | "Takas yok" (No trades) | No trade record was found in this environment. If the section shows a permission warning, your account's role has no permission to read trades. | ## Related docs - [Currencies](currencies.md) - [Currency](currency-profile.md) - [Economy guide](../guides/economy.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/rules.html # Rules ## Purpose This page is where you tune your game's behavior without writing code. You see two kinds of things: **game rule cards** offered by the game's plugin (for example police or city crowd values) and **automations** you write (such as "add power to the character when it reaches level 10"). Changes do not reach the game until you release them; every release can be rolled back. ## When to use - To tune the game's built-in settings (speed, duration, density and so on) without changing code. - To automatically give a character a reward, points or a flag when a game event happens. - To return a wrongly released setting to the previous version ([Rule history](./rules-history.md)). ## Step by step ### Change a game rule 1. Click a card in the list; the card's settings page opens ([Rule card](./rules-game-card.md)). 2. Change the value. The change does not reach the game yet; an "N changes not released" bar appears at the bottom of the page. 3. You can move to other cards and change their settings too. All pending changes go into a single release. 4. Press **Gözden geçir ve yayınla** (Review and release), write your reason and confirm. ### Add an automation 1. Press **Yeni kural** (New rule), or use **Şablondan başla** (Start from a template) if the list is empty. 2. In the automation editor choose the event, the conditions and the action ([Automation editor](./rules-automation-editor.md)). 3. Release. The rule is saved **off** by default; when it is ready, you turn it on with the **Aç** (Turn on) button in the list. ### Turn an automation on and off The **Aç** or **Kapat** (Turn off) button on the right of the card opens a confirmation window. Write your reason and confirm; a rule that is turned off stops running in the game, it is not deleted. ## Fields and options | Field | Required | Description | |---|---|---| | Kural ara (Search rules) | No | Searches the card title, description and automation key | | Topic buttons | No | Narrow the list to the topics you choose; if none is selected, all are shown | | Action reason (on/off window) | Yes | 8-500 characters; written to the audit log | The status strip at the top shows the live version, who released it and the reason they wrote. If nothing has been released yet, it says "the game runs with today's (code) values". ## Common situations | Situation | What to do | |---|---| | It says "this game's plugin does not offer rules adjustable from the panel" | The game's plugin does not declare any rule card. You can still add automations from this page. | | It says "you do not have permission to see rules" | Your role has no rule read permission; ask an admin. | | The **Yeni kural** button is missing | Only admins with rule write permission see the button. | | A card has an "Gözlemde" (Under observation) badge | The rule's protection is loosened; the rule is not applied to the game, only recorded. See [Safety switches](./safety-switches.md). | | A card has a "Yeniden başlatınca" (On restart) badge | The value is released but the live world takes the new value when the server restarts. | | Where are the notification rules? | Notification rules are on a separate screen; open it from the **Bildirimler** (Notifications) card at the bottom of the page. | ## Related docs - [Game rules guide](../guides/game-rules.md) - [Notifications guide](../guides/notifications.md) - [Core concepts](../getting-started/concepts.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/leaderboards.html # Leaderboards ## Purpose A leaderboard is a table where players are ranked by their scores. This page lists the leaderboards in your environment as cards: name, reset interval, whether it is visible in the game, the player count this season, the leader and the time left until the next reset. You also set up a new leaderboard from here. Your game server sends the scores; the panel does not write scores. ## When to use - To set up a leaderboard before your game server sends scores. - To see which leaderboard is visible, when it will reset and who leads it. - To move to a leaderboard's detail (ranking, past seasons, settings). ## Step by step ### Set up a new leaderboard 1. Press **Yeni tablo** (New leaderboard). Only admins with the leaderboard management permission see the button. 2. Write a name in the **Tablonun adı** (Leaderboard name) field; the key fills in automatically. If you wish, change the key yourself. 3. Choose **Hangi puan önde?** (Which score comes first?) and **Ne zaman sıfırlansın?** (When should it reset?). If you choose "Özel aralık" (Custom interval), write days and hours. 4. Choose **Puan nasıl gösterilsin?** (How should the score be shown?); write a unit suffix if needed. 5. If you want players to see the leaderboard in the game, turn on the **Oyunda görünsün** (Show in game) switch. 6. Check how the score will look in the preview row below and press **Tabloyu oluştur** (Create leaderboard). On success the leaderboard's detail page opens. > **Warning** > > The key, sort direction and reset interval cannot be changed after the leaderboard is set up. To change them you need to set up a new leaderboard; so choose carefully before setting it up. ### Open a leaderboard Click the card's name; the detail page opens ([Leaderboard detail](./leaderboard-detail.md)). ## Fields and options | Field | Required | Description | |---|---|---| | Leaderboard name | Yes | 1-400 characters | | Key | Yes | Starts with a lowercase letter or digit; lowercase letters, digits, dots, hyphens, underscores; at most 200 characters | | Which score comes first? | Yes | High score or low score | | When should it reset? | Yes | Never, every day, every week (Monday), every month or custom interval | | Days, Hours | Only for a custom interval | At least one of days or hours; total at least 1 hour, whole numbers | | How should the score be shown? | Yes | Number, money or time | | Unit suffix | No | At most 8 characters; not available for the time unit | | Show in game | No | Off by default | ## Common situations | Situation | What to do | |---|---| | "Bu anahtar kullanılıyor ya da bir eklentiye ayrılmış" (This key is in use or reserved for a plugin) | Choose another key. Some key prefixes are reserved for the game's plugin. | | There is no leaderboard in the list and it says "package-ride eklentisi 'Günlük kazanç' tablosunu ilk teslimatta kendiliğinden kurar" (the package-ride plugin sets up the 'Daily earnings' leaderboard by itself at the first delivery) | The plugin sets the leaderboard up at the first delivery; no delivery has happened yet. You only need to wait. | | "Sıralama (Competition) modülü bu ortamda kapalı" (The Leaderboard (Competition) module is off in this environment) | Turn the module on from [Game settings › Modules](./game-settings-modules.md). | | The "Yeni tablo" button is missing | You do not have the leaderboard management permission; ask an admin. | | I see only the first leaderboards | The list shows at most a certain number of leaderboards; when the limit is reached the page says so. More is not listed in this version. | | The card says "Kaynak: … eklentisi (otomatik)" (Source: ... plugin (automatic)) | A plugin set the leaderboard up and only that plugin writes scores. | ## Related docs - [Leaderboards and tournaments](../guides/competition.md) - [Notifications](../guides/notifications.md) - [Core concepts](../getting-started/concepts.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/notifications.html # Notifications ## Purpose This page is where you manage the notifications sent to players. Three things are needed for a notification to go out: a **provider** (a push notification service such as Firebase, Apple or OneSignal), a **template** (the message text) and a **rule** or a manual **test send** (when the message goes out). The page has six tabs: Overview, Templates, Rules, Providers, Delivery history and Devices. ## When to use - When setting up the notification infrastructure for the first time (follow the three steps in the Overview tab). - To send an automatic notification on an event such as a level up. - To investigate why a notification did not go out (the Delivery history and Devices tabs). ## Step by step ### 1. Connect a provider 1. In the **Sağlayıcılar** (Providers) tab, press **Bağla** (Connect) on the card of the service you use. This operation is open only to a super admin. 2. Enter the information the service asks for and choose the key file. The key is write-only; after saving it is never shown again, only its fingerprint is shown. 3. Write your reason and the 6-digit code from your authenticator app and press **Kaydet** (Save). 4. The card becomes **Hazır** (Ready) within a few seconds; the server loads the setting. If you see an error, its reason is written on the card in plain language. ### 2. Write a template 1. In the **Şablonlar** (Templates) tab press **Yeni şablon** (New template) (or **İlk şablonu oluştur** (Create the first template) if empty; **Örnekle başla** (Start with an example) brings a ready example). 2. Write the key, display name and language; enter the title and text. For player-specific values write `{{name}}` in the text and add the same name to the **Değişkenler** (Variables) list. 3. In the live preview on the right, look at the phone lock screen and the browser notification, then press **Şablonu oluştur** (Create template). 4. To add another language, use the **+ Dil ekle** (+ Add language) button in the template's detail. Each save is stored as a new **version**; you can go back to an old version from the version history. > **Note** > > If there is no version in the player's language, the fallback language, the English (en) version, is used. If there is no English version either, the notification is not sent. ### 3. Add a rule or send a test - **Rule:** In the **Kurallar** (Rules) tab, choose the event, conditions, template, language and channel with **Yeni kural** (New rule); in the **Örnek olayla dene** (Try with a sample event) section try the rule without sending any notification. - **Test:** In the **Gönderim geçmişi** (Delivery history) tab press **Test gönder** (Send test); choose the player, template, language and channel and send. The page follows the result for 60 seconds at most. Frequent tries are limited (10 tries in 15 minutes). ## Fields and options | Field | Required | Description | |---|---|---| | Template key, display name, description, language | Key, name, language required | The key does not change; name at most 120, description 500 characters | | Title, Text | Yes | Soft limit of 65 characters for the title and 240 for the text | | Variable name, type, example value, required | When a variable is added | At most 32 variables; whole number for a number variable | | Version note | No | At most 200 characters | | Rule key, rule name | Yes | The key is at most 64 characters and does not change; name at most 120 | | When, conditions | When is required | At most 20 condition rows; with no condition the rule runs every time the event happens | | Template, language, channel | Yes | Channel: in-game, push notification or both | | Provider information and key file | Yes | Firebase: service account JSON; Apple: Team ID, Key ID, Bundle ID, environment and .p8; OneSignal: App ID and REST API key | | Action reason, verification code | Yes | Reason 8-500 characters; code 6 digits | An environment can have at most 50 notification rules. ## Common situations | Situation | What to do | |---|---| | "Bu oyunda bildirimler kapalı" (Notifications are off in this game) | Turn on the Notifications module from [Game settings › Modules](./game-settings-modules.md). | | The card says "Bağlı değil" (Not connected) | The provider is not connected in this environment; enter the information with **Bağla**. Connecting needs super admin permission. | | "Sağlayıcı anahtarı reddetti" (The provider rejected the key) | Renew the service account file for Firebase, the .p8 key and the Team ID, Key ID, Bundle ID values for Apple, the REST API key for OneSignal. | | "Anahtar dosyası biçimi tanınmadı" (The key file format was not recognized) | A service account JSON file is needed for Firebase and a .p8 file for Apple; a Google OAuth client file or a .p12 certificate will not do. | | The test says "oyuncunun ulaşılabilir cihazı yok" (the player has no reachable device) | The player has not turned on notifications in the game. Choose the in-game channel or look in the Devices tab at whether the player has a device. | | The rule cannot be saved: "Şablon arşivlenmiş" (Template archived) or "bu dilde şablon yok" (no template in this language) | Unarchive the template or add the language you chose to the template. | | Rules coming from the server setting are running | When you add the first rule in the panel, panel rules replace them in this environment. | | A rule or template cannot be edited | You do not have the notification management permission; the view is read-only. Ask an admin. | ## Related docs - [Notifications guide](../guides/notifications.md) - [Core concepts](../getting-started/concepts.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/game-settings-general.html # General ## Purpose The **Genel** (General) tab of the **Game settings** page shows the identity of the selected game: the game name, short name, the studio it belongs to and the plugin it uses. Authorized admins can rename or archive the game from here. This tab sends no request to the server; the information is read from the selected game and environment. ## When to use - To verify which game and studio are selected. - To change the game's name (super admin). - To hide a game that is no longer used from lists and the selector (super admin). ## Step by step ### Rename the game 1. Choose the game from the environment selector at the top. 2. Press the **Yeniden adlandır** (Rename) button. Only a super admin sees the button; the page is read-only for everyone else. 3. Write the new name (1-160 characters), write your reason and press **Adı kaydet** (Save name). The short name does not change. ### Archive the game 1. In the **Tehlikeli işlemler** (Dangerous operations) box at the bottom of the page, press **Arşivle** (Archive). 2. Write your reason and confirm. > **Warning** > > Archiving hides the game from lists and the selector; no data is deleted. If the game has a live grant or an active API key, archiving is rejected; the window shows the reason. While the selected environment belongs to this game, the **Arşivle** button is disabled; first select another game's environment. ## Fields and options | Field | Required | Description | |---|---|---| | Game name | Read-only | Changes from the rename window | | Short name | Read-only | Given when the game is set up, does not change | | Studio | Read-only | The studio the game belongs to | | Plugin | Read-only | If there is no plugin it says "Eklenti yok" (No plugin); if you have permission, a link to the Plugins page appears | | New name (rename window) | Yes | Must differ from the current name | | Action reason (in windows) | Yes | 8-500 characters | ## Common situations | Situation | What to do | |---|---| | It says "Ortam seçilmedi" (No environment selected) | Choose a game and environment from the top bar. | | The Rename and Archive buttons are missing | These operations are open only to a super admin; you see the text "this page is read-only for you". | | It says "Önce başka bir ortam seçin" (First select another environment) | The environment of the game you want to archive is currently selected. Select another game's environment and try again. | | Archiving was rejected | The game has a live grant or an active API key. According to the reason in the window, revoke the active [API keys](./game-settings-api-keys.md) or turn the plugin's state off on the [Plugins](./plugins.md) page, then try again. | ## Related docs - [Core concepts](../getting-started/concepts.md) - [Architecture and hosting](../getting-started/architecture-and-hosting.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/plugins.html # Plugins ## Purpose A plugin is the module that runs the game's server-side logic: it offers game-specific commands, queries and panel screens. This page shows the game's plugin, the other plugins authorized in the selected environment and the state of each. You reach a plugin's admin screens from here and from the sidebar. ## When to use - To see whether a plugin is on in this environment. - To make a plugin temporarily read-only, drain it or turn it off (authorized admin). - To inspect a plugin's version, permissions, operations and dependencies. - To bind a plugin to the game or move to a new version. ## Step by step ### See a plugin's details The card's **Ayrıntılar** (Details) button lists the version, permissions, dependencies and the operations the plugin offers (query or command, who can access). The information is read from the plugin's own declaration; it cannot be changed from here. ### Change a plugin's state 1. Press the card's **Durumu değiştir** (Change state) button. Only admins with the plugin management permission see the button. 2. Choose the target from the **Yeni durum** (New state) list; the player and admin effect of the state you choose is written in the window. 3. Write your reason and press **Durumu değiştir**. If you choose **Kapalı** (Off), a confirmation is also asked. Transitions are limited: from On you can go to read-only, draining or off; from read-only and draining to on or off; from off to read-only or on. ### Bind a plugin to the game and move to a new version The game plugin card at the top of the page is for these operations: 1. If no plugin is bound, choose the plugin from the list with **Eklenti bağla** (Bind plugin) and bind it. 2. To move to a newly built version, first drain players with **Drain başlat** (Start drain); type the plugin ID to confirm. 3. When the countdown ends, deploy the new version, then press **Sürüme geç** (Switch to version). The button is disabled until the countdown ends. > **Warning** > > In the **Kapalı** (Off) state, players and admins cannot use the plugin in this environment and the plugin disappears from the sidebar. To turn it back on you must change the state separately. ## Fields and options | Field | Required | Description | |---|---|---| | New state | Yes | On, Read-only, Draining or Off | | Action reason | Yes | 8-500 characters | | Plugin (bind window) | Yes | One of the plugins installed on the server | | Plugin ID (drain window) | Yes | The ID must be typed exactly | The badge at the top of the card shows the plugin's state in this environment. If it says "Bu ortamda yetkisiz" (Not authorized in this environment), the plugin is installed but there is no grant row for this environment; the plugin cannot be used here. ## Common situations | Situation | What to do | |---|---| | "Kurulu eklenti yok" (No plugin installed) | The server was started without plugins. Plugins are added to the server build and activated by the operator. | | The card has a warning "Yetki sürümü ... kurulu sürüm ..." (Grant version ... installed version ...) | The environment's grant does not match the installed version; version activation is pending. In this case only draining and turning off can be chosen. | | "Bu durum geçişine izin verilmiyor" (This state transition is not allowed) | The state may have changed while you were looking; refresh the page and try again. | | The **Durumu değiştir** button is missing | You do not have the plugin management permission; ask an admin. | | The **Ekranları aç** (Open screens) button is missing | The plugin defines no panel screen, or it is off in this environment. | | A "Kullanımdan kalkan sorgular" (Deprecated queries) box appears | Some of the plugin's queries have been replaced by a new surface. In the box you see the old name, its replacement and since which version it was deprecated; move your game code to the new surface. | ## Related docs - [Plugins overview](../plugins/overview.md) - [Calling plugins](../plugins/calling-plugins.md) - [Core concepts](../getting-started/concepts.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/safety-switches.html # Safety switches ## Purpose A safety switch lets you loosen one of the game's protection rules. In the **Zorla** (Enforce) position the rule is applied to the game. In the loose position the rule is not applied to the game or is only recorded; its raw setting reads "put under observation", "off" or "disabled". You change a switch per environment and reversibly, without redeploying the server. ## When to use - To loosen a protection rule temporarily when it blocks players unfairly. - To see that a rule is only being recorded (under observation) and enforce it again. - To audit which protections are loose. ## Step by step 1. On each row you see the switch's name, what it does, its position (**Zorla** (Enforce), **Gözleme alındı** (Under observation), **Kapalı** (Off), **Devre dışı** (Disabled)), its source and its last change. 2. To loosen a protection that is in the Enforce position, press **Gevşet** (Loosen). In the window that opens, read the effect, write your reason and confirm with **Gevşet**. 3. To turn a loose protection back on, press **Zorla**; it does not ask for confirmation. 4. To return a switch changed from the panel to the deployment default, press **Varsayılana dön** (Reset to default). The source badge on the row shows where the switch's value comes from: **panel** (you changed it), **dağıtım varsayılanı** (deployment default) or **yalnız dağıtım** (deployment only: settings that cannot be changed from the panel). Deployment-only rows are shown read-only. > **Note** > > A change is applied on every server replica within a few seconds at most (the time stated on the page); there is no instant effect or instant push. > **Warning** > > A loose protection means the related rule is not applied to the game or is only recorded. The warning banner at the top of the page says how many protections are loose. A read error never loosens a protection; the page shows an error. ## Fields and options | Field | Required | Description | |---|---|---| | Action reason (loosen window) | Yes | 8-500 characters | The counter card at the bottom of the page shows runtime counters; only admins with that permission see it. ## Common situations | Situation | What to do | |---|---| | There are no buttons and it says "read-only" | Only a super admin can change switches; you can read the values. | | "Güvenlik şalterleri okunamadı" (Safety switches could not be read) | The list could not be loaded. Protections are not treated as loosened; read again with **Tekrar dene** (Try again). | | I see an "Gözlemde" (Under observation) badge on a rule card | That card's switch is loose. Turn it back on with **Zorla** from this page or from the card page on the [Rules](./rules.md) page. | | It changed without asking for a reason | The Force and Reset to default buttons do not ask for a reason; a fixed reason such as "operator set the switch to Enforce (panel)" is written to the audit record. | ## Related docs - [Game rules and automations](../guides/game-rules.md) - [Security](../operations/security.md) - [Core concepts](../getting-started/concepts.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/audit.html # Audit log ## Purpose Every change made from the panel leaves an unchangeable record: which admin did which action, on which record, when, and for what reason. This page shows those records as a table. Records are append-only; none is ever deleted or edited. ## When to use - To find out who changed a setting, when and why. - To answer "what had changed?" after an incident. - To compare the state of an operation before and after. ## Step by step 1. The newest records are at the top of the table. Each row shows the time, admin, action, target type, target and reason. 2. Narrow the list with the filters and search: action, target, admin, player and time range. The request ID, environment and player columns are hidden by default; you can turn them on from the column menu. 3. Press **İncele** (Inspect) on a row. The panel that opens shows the admin, target, environment, request ID, reason and the **Önce** (Before) and **Sonra** (After) states of the operation. 4. If the record is tied to a player, the panel shows a link that opens the player's page. Super admins see two tabs: **Ortam kayıtları** (Environment records: operations tied to the selected environment) and **Operatör işlemleri** (Operator operations: operations not tied to any environment, such as display name, password, session, sign-in and admin management). > **Note** > > Records in the Operator operations tab are not tied to a player; the player filter returns no results in this tab. ## Fields and options | Field | Required | Description | |---|---|---| | Filters and search | No | Action, target, admin, player and time range; applied on the server | | Columns | No | Can be hidden and pinned; the record time cannot be hidden | | Page size | No | 15, 25, 50, 100 or 500 | | Scope tab | No | Super admin only: environment records or operator operations | ## Common situations | Situation | What to do | |---|---| | There is no Operator operations tab | Only a super admin sees these records. | | I cannot see the record I am looking for | Check the selected environment and the time range. Environment records belong to the selected environment; environment-independent operations are in the Operator operations tab. | | I want to delete or fix a record | Not possible; records are unchangeable. | ## Related docs - [Security](../operations/security.md) - [Core concepts](../getting-started/concepts.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/game-settings-sign-in.html # Sign-in methods ## Purpose This tab is where you manage which providers players can sign in with in the selected environment. The provider managed from the panel today is Google; guest sign-in and email sign-in are not set here. The page also has the environment's **session policy** (single active session). Each provider's detail page has its status, scopes, client information and safe extra information. ## When to use - When turning on Google sign-in for the first time (to enter the client ID, secret and redirect base). - To temporarily turn Google sign-in off or turn it back on. - If you want a player's old sessions to close on their new sign-in. ## Step by step ### Configure Google sign-in 1. Choose the environment at the top of the tab and press **Detay** (Detail) on the **Google** row in the list. 2. On the **OAuth istemci bilgisi** (OAuth client information) card press **OAuth bilgilerini düzenle** (Edit OAuth information). Only a super admin sees the button. 3. Enter the client ID, the client secret and the redirect base. 4. Write your reason and the 6-digit code from your authenticator app and press **Kaydet** (Save). 5. In the Google Cloud console, register as the **Authorized redirect URI** your redirect base with `/google` added at the end. 6. If the provider is not **Etkin** (Enabled), press **Etkinleştir** (Enable) on the detail page. > **Warning** > > The client secret is write-only. After saving it never appears in the panel again; the card shows only a "configured" status and a short fingerprint. If you lose it, get a new one from Google and enter it again. ### Single active session 1. Turn the **Tek aktif oturum** (Single active session) switch on the **Giriş ayarları** (Sign-in settings) card on or off. Only admins with sign-in method write permission can change the switch. 2. Write your reason in the window that opens and confirm. The line under the card shows the value's source: environment setting or server default. ### Remove information entered from the panel For Google information entered from the panel, a **Kaldır** (Remove) button appears on the detail page. You confirm by typing the provider's ID and enter your reason and verification code. If the server has a default value, the environment returns to it; if not, the provider becomes unconfigured in this environment. ## Fields and options | Field | Required | Description | |---|---|---| | Single active session | No | A switch per environment; does not touch open sessions | | Client ID | Yes | At most 256 characters, visible characters without spaces | | Client secret | Yes | At least 8, at most 1024 characters; write-only | | Redirect base | Yes in live | Starts with https; contains no query, fragment or user info | | Action reason | Yes | 8-500 characters | | Verification code | Yes | The current 6-digit code | | Confirmation text (removal) | Yes | The provider ID exactly | | Running state enabled (provider settings) | No | Turns the provider on or off | | Safe extra information (JSON) | No | Field names that contain secrets are rejected | ## Common situations | Situation | What to do | |---|---| | The Google button does not appear in the game, or the start returns `403` | Google is not configured or is off in this environment. Enter the information and enable the provider. | | Google says "redirect_uri_mismatch" | The address you registered in the Google Cloud console must be exactly the same as the redirect base + `/google`. | | The "edit information" button is missing | Only a super admin can write client information. | | "Kaydet" gives a verification code error | A recovery code is not accepted; enter the current 6-digit code from your authenticator app. The code is cleared after each attempt. | | Server default appears as the source | The information comes from the server setting. A value you enter from the panel overrides it in this environment. | | A player cannot stay signed in on two devices at the same time | **Single active session** is on. If you turn it off, players can stay signed in on more than one device at the same time. | ## Related docs - [Sign in with Google](../auth/oauth.md) - [Authentication overview](../auth/overview.md) - [Sessions and CSRF](../auth/sessions-and-csrf.md) - [Account linking](../auth/account-linking.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/game-settings-modules.html # Modules ## Purpose Open Play features come as **modules**: characters, economy, store, leaderboards, notifications and so on. This tab shows which modules are on in the selected environment. The screens of an off module disappear from the panel menu and its API returns `404` (`module_disabled`); the module's data is not deleted and comes back when you turn it on again. ## When to use - When you investigate why a screen or an API is not visible. - To turn off a feature your game does not use. - To turn an off module back on. ## Step by step 1. Choose the environment from the environment selector at the top of the tab. 2. On the module's card, see its status (**Açık** (On) or **Kapalı** (Off)), what it depends on and, if any, the reason blocking it. 3. If you have permission, press **Aç** (Turn on) or **Kapat** (Turn off). 4. Write your reason in the window that opens. When turning off, a confirmation is also asked. 5. The change spreads in about two seconds. The modules are: Characters and rules, Economy, Items and inventory, Trader and trade, Store and purchases, Missions and rewards, Party and clan, Chat, Leaderboard and tournament, Notifications, Webhooks, Scene and assets, Plugin platform. > **Warning** > > API calls to an off module return `404` and `{"code":"module_disabled"}`. If your game depends on a module, make sure the game does not use it before turning it off. ## Fields and options | Field | Required | Description | |---|---|---| | Action reason | Yes | 8-500 characters | ## Common situations | Situation | What to do | |---|---| | The **Aç** and **Kapat** buttons are missing | Only a super admin can turn modules on and off; the screen is read-only for you. | | The button is disabled and says "Önce şu modülleri açın" (First turn on these modules) | The module depends on other modules; turn on the listed modules first. | | The button is disabled and says "Önce şu modülleri kapatın" (First turn off these modules) | Other modules depend on this module; turn them off first. | | It says "Etkin eklentiler kullanıyor" (Used by active plugins) | A plugin needs this module. Turn those plugins off on the [Plugins](./plugins.md) page, then turn the module off. | | Is the data of the module I turned off gone? | No. Data is kept; only the screens and the API stop. | ## Related docs - [Core concepts](../getting-started/concepts.md) - [Architecture and hosting](../getting-started/architecture-and-hosting.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/game-settings-api-keys.html # API keys ## Purpose API keys let your game's server prove its identity to Open Play. This page lists the keys of the selected game and environment, and lets you create new keys and manage their expiry and status. It is the **API anahtarları** (API keys) tab inside **Oyun ayarları** (Game settings). ## When to use - When you connect your game server to Open Play for the first time. - To revoke a key that has leaked or is no longer needed. - To extend a key's expiry or rename it. ## Step by step ### Creating a new key 1. Use the environment selector at the top to choose the game and environment the key will work in. 2. Press **Anahtar oluştur** (Create key). Only admins with key write permission see this button. 3. Give the key a name and choose the **Anahtar türü** (Key type): **Sunucu anahtarı** (Server key) for your game server, **İstemci anahtarı** (Client key) for the game client. 4. Pick a period under **Geçerlilik** (Validity). If you are unsure, a period such as 90 days that you renew regularly is safer. 5. Write a short reason in the **Not** (Note) field. 6. Press **Anahtarı oluştur** (Create the key). 7. Copy the secret value shown on the page right away and store it somewhere safe. > **Warning** > > The secret value is shown only once, when the key is created; it cannot be viewed afterwards. If you lose it, you must rotate the key. ### Using the key Your game server sends the key in the `Authorization` header with every request: ```bash curl https:///v1/ \ -H "Authorization: Bearer crx_live__" ``` Replace the placeholders with your own values. Do not write the real key into source code or the game client. ### Editing, rotating and revoking a key 1. Click the key's row in the list to open its detail page. 2. Use **Düzenle** (Edit) to change the name, expiry and notes; the secret value does not change. 3. **Rotate** revokes the old secret and shows the new value once. 4. **Devre dışı bırak** (Disable) pauses the key temporarily, **Etkinleştir** (Enable) turns it back on; **Revoke** is permanent. ## Fields and options | Field | Required | Description | |---|---|---| | Anahtar adı (Key name) | Yes | A name to recognise the key in the list; at least 2 characters | | Anahtar türü (Key type) | Yes | Server (secret) or client (can be embedded in the game) | | Geçerlilik (Validity) | Yes | No expiry, 30 days, 90 days, 1 year or a custom date; when editing there is also "Değiştirme" (Do not change) | | Not (Note) | Yes | The reason written to the audit log, 8-500 characters | | Anahtarın sahibi (Key owner) | No | Default is "Bu ortam" (This environment); it is under "Gelişmiş ayarlar" (Advanced settings) | | Sahip oyuncu / organizasyon (Owner player / organization) | Depends on the owner type | Required once you pick an owner | | Kapsam etiketleri (Scope labels) | No | Labels for notes only | | İzinler (Permissions) | No | Name-value notes; they do not restrict access | | Ek bilgiler (Extra information) | No | Name-value notes; they do not restrict access | > **Note** > > Scope labels, permissions and extra information are for your records only for now. What a key can access is decided by the **key type** (server or client). ## Common situations | Situation | What to do | |---|---| | I lost the secret value | Rotate the key with **Rotate**; save the new value and update it on your server. | | A key stopped working one day | It may have expired, been disabled or been revoked; check its status and end date on the detail page. | | The "Anahtar oluştur" button is missing | Your role has no key write permission; ask an admin. | | A server key was embedded in the client | Revoke the key immediately, create a new one and use it only on the server. | ## Related docs - [Quickstart](../getting-started/quickstart.md) - [Server-side verification](../auth/server-verification.md) - [Security](../operations/security.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/webhooks.html # Webhooks and event queue ## Purpose A webhook is a signed HTTP request Open Play sends to your own server when an event happens. This page shows the registered endpoints, the attempts to send each event to an endpoint, and the event queue (outbox) waiting to be sent. The page is **read-only**: there is no button in the panel to add or delete an endpoint, resend a failed delivery or see the secret. Endpoints are registered over REST; for details, see the [Webhooks](../guides/webhooks.md) guide. ## When to use - To verify that your server is receiving webhooks. - To understand why an event did not arrive (is it waiting, did it fail, which error). - To see whether an endpoint is enabled and which event types it listens to. > **Note** > > If the webhooks module is disabled, the webhook endpoints do not respond (`404`, `module_disabled`). Enable the module on the [Game settings › Modules](game-settings-modules.md) page. To see the page, your role also needs webhook read permission. ## Step by step 1. Make sure you selected the right game and environment. 2. In the "Webhook endpoints" table, check your endpoint's address (URL), the event types it listens to and its status. If the filter is empty, it says "tümü" (all), which means the endpoint receives all events. 3. In the "Delivery attempts / DLQ" table, filter deliveries by status. Those that are failed have used up their retries (DLQ, dead letter queue). 4. In the "Hata" (Error) column you see the class of the error and, if there is one, the HTTP code your server returned. The raw error text is not shown. 5. In the "Outbox" table you see the events waiting to be sent or being attempted. 6. Press **Yenile** (Refresh) to see new data. ## Fields and options There is no form to fill in on this page. ### Tables | Table | Columns | |---|---| | Webhook endpoints | URL, Event filtreleri (Event filters), Durum (Status; enabled or disabled), Oluşturulma (Created); the Endpoint kimliği (Endpoint ID) column is turned on from the column menu | | Delivery attempts / DLQ | Endpoint (URL, delivery ID and event ID), Durum (Status; pending, delivered, failed), Attempt (the number of attempts), Hata (Error), Oluşturma (Created), Teslim (Delivered); the delivery order and outbox ID columns are turned on from the column menu | | Outbox | Event (the type and the event ID), Durum (Status), Attempt, Retry (the time of the next attempt), Oluşturma (Created) | For a published (completed) outbox record, the next attempt time shows "—". If the event type is a hidden internal event, it says "redacted/private". The panel never shows secrets, the raw body or the raw error text. ## Common situations | Situation | What to do | |---|---| | It says "Webhook yüzeyi yetki gerektiriyor" (The webhook surface needs permission) | Your role has no webhook read permission; ask an admin. | | The tables are empty | You may not have registered an endpoint yet; also check that you selected the right environment. | | A delivery is "failed" | Your server is not returning 2xx or cannot be reached. Look at the class and code in the Hata column; fix your receiver. There is no resend from the panel. | | The same event arrived more than once | Delivery happens at least once; your receiver should drop repeats by the event ID. For details, see the guide. | | I cannot see an endpoint's secret | The secret comes back only once, in the registration response; it is not shown anywhere afterwards. To renew it, delete the endpoint and register it again. | ## Related docs - [Webhooks](../guides/webhooks.md) - [Webhook events](../reference/webhook-events.md) - [Core concepts](../getting-started/concepts.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/operations.html # Background jobs ## Purpose This page shows two things, read-only: (1) the background job records of plugins' scheduled commands, and (2) the linked sign-in accounts and device information of a player you select. From this page you cannot start or retry a job, close a session or change a password. For those actions, use the [Player profile](player-profile.md) page. ## When to use - To see whether a plugin's scheduled job is running or has failed. - To see which sign-in methods (Google, password and so on) a player is linked to the account with. - To check which devices a player signed in from. ## Step by step ### Job records 1. Make sure you selected the right game and environment. 2. In the "Worker job runs" table, filter jobs by status: pending (not yet picked up, or released for a retry), claimed (a worker picked it up), done (completed), dead (closed because of a permanent error or the retry limit). 3. For a specific time range, use the "Oluşturulma" (Created) column in the filter menu with "arasında" (between). 4. The "Failure" column shows the error code of the failed job. The job content and the raw error text are not shown. ### Player sign-in relations 1. In the "Player auth relations" card, type and select the player in the **Oyuncu** (Player) field. 2. Press **İlişkileri getir** (Fetch relations). 3. The "Provider accounts" table shows the player's linked sign-in accounts, and the "Devices" table shows device information and status. Revoked records are also listed. Long lists are paginated. > **Note** > > The job runner's health is not shown on this page: there is no persistent health signal source yet, so values such as "online" or lag are not made up. The "Worker health: gözlemlenemiyor" (Worker health: cannot be observed) card on the page says so. ## Fields and options | Field | Required | Description | |---|---|---| | Oyuncu (Player) | Yes (for İlişkileri getir) | The player whose relations will be seen | ### Job records table Plugin / mesaj (Plugin / message; the plugin ID, the message and the schedule ID), Durum (Status), Attempt (the number of attempts), Deadline (the last time), Failure (the error code) and Oluşturulma (Created) are shown by default. Sonraki deneme (Next attempt), Bitiş (End) and Zamanlama kimliği (Schedule ID) are turned on from the column menu. ## Common situations | Situation | What to do | |---|---| | It says "Operations yüzeyi yetki gerektiriyor" (The operations surface needs permission) | Your role has no job record read permission; ask an admin. | | Player relations do not load | Player read permission is needed for this card; if you do not have it, you see a note. | | A job is "dead" | The job closed with a permanent error or by reaching the retry limit. Look at the code in the Failure column together with the plugin's owner; it cannot be restarted from the panel. | | Job records are empty | There is no scheduled plugin job in the selected environment, or the filter is too narrow. | | I want to close a device | This page is read-only; close sessions from the [Player profile](player-profile.md) page. | ## Related docs - [Account linking](../auth/account-linking.md) - [Sessions and CSRF](../auth/sessions-and-csrf.md) - [Core concepts](../getting-started/concepts.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/tenancy.html # Studios and games ## Purpose This page shows the studios in the installation and each studio's games with their environments. From here you create new studios and games, rename them, archive records and go to a game's settings. The page is open only to admins who have permission to manage studios. For the concepts of studio, game and environment, see the [Core concepts](../getting-started/concepts.md) page. ## When to use - To create a new studio or game. - To fix the name of a studio or a game. - To remove (archive) a game or studio that is no longer used from the list. - To switch to the settings of another game. ## Step by step ### New studio 1. Press **Yeni stüdyo** (New studio). 2. Fill in the studio name and the **Gerekçe** (Reason) field and press **Stüdyo oluştur** (Create studio). ### New game 1. Press **Yeni oyun** (New game) on the studio's card. 2. Write the game name. If you like, also write the short name (slug); if you leave it empty, it is generated from the name. 3. Write the reason and press **Oyun oluştur** (Create game). The game is set up together with its development and live environments. ### Renaming and archiving 1. From the three-dot menu on the studio or game row, choose **Yeniden adlandır** (Rename) or **Arşivle** (Archive). 2. Write the new name, or only the reason, and confirm. 3. You can bring an archived record back into the lists with **Geri aç** (Restore) from the same menu. > **Note** > > Archiving only hides; no data is deleted. A game or studio that contains the selected environment cannot be archived; select another environment first. For records that have live permission or an active API key, the server refuses to archive. ### Opening a game's settings 1. Press **Oyun ayarları** (Game settings) on the game's row. If the game has no active environment, the button is disabled. 2. If another game's environment is selected, the panel switches to that game's active environment and opens the [Game settings](game-settings-general.md) page. ## Fields and options | Field | Window | Required | Description | |---|---|---|---| | Stüdyo adı (Studio name) | New studio | Yes | 1-160 characters | | Stüdyo (Studio) | New game | Yes | The studio the game will belong to (preselected if you opened it from the studio card) | | Oyun adı (Game name) | New game | Yes | 1-160 characters | | Kısa ad (Short name) | New game | No | Lowercase letters, digits, hyphens; at most 48 characters | | Yeni ad (New name) | Rename | Yes | 1-160 characters | | Gerekçe (Reason) | All | Yes | 8-500 characters | ## Common situations | Situation | What to do | |---|---| | I cannot see the page | The permission to manage studios belongs only to the widest role; ask a super admin. | | "Arşivle" is disabled and there is a "Seçili ortam" (Selected environment) note | The environment you are working in right now is inside this record; switch to another game's environment and try again. | | It says "Bu oyunun etkin ortamı yok" (This game has no active environment) | The game has no active (non-archived) environment left; restore the environment first. | | A short name error | Use lowercase letters, digits and hyphens; a hyphen cannot be at the start, at the end or repeated. | | Archiving was refused | The game may have live permission or an active API key; turn those off first. | ## Related docs - [Core concepts](../getting-started/concepts.md) - [Architecture and hosting](../getting-started/architecture-and-hosting.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/operators.html # Panel admins ## Purpose This page lists and manages the admin accounts that sign in to the admin panel. An admin is the person who uses the panel, not a game player; for player accounts, see the [Players](players.md) page. Every admin has a role and a list of environments they can access. The page is open only to accounts that have permission to read admins. ## When to use - To add a new admin to the team. - To change an admin's role or environment access. - To close the account of an admin who is leaving and end their sessions. - To reset the two-step verification of an admin who lost their phone. ## Step by step ### Creating an admin 1. Press **Operatör oluştur** (Create operator). Only accounts with admin write permission see this button. 2. Enter at least one of email or username, the display name, the role and the temporary password. 3. Choose the environments they can access. By default, the environment currently selected comes preselected. 4. Write an **İşlem nedeni** (Action reason) and save. The admin's page opens. ### Changing the role and environments 1. Open the admin from the list and press **Düzenle** (Edit). 2. Change the display name, the role and the environments; write the reason and save. The email, username and password cannot be changed from this form. ### Disabling the account, sessions and 2FA 1. Use the "Güvenlik eylemleri" (Security actions) card on the admin's page. 2. **Devre dışı bırak** (Disable) rejects new sign-ins; open sessions must also be closed separately with **Oturumları revoke et** (Revoke sessions). **Etkinleştir** (Enable) opens the account again. 3. **2FA'yı sıfırla** (Reset 2FA) deletes the admin's authenticator and recovery codes and closes all their sessions. The admin signs in with the password only and must set up two-step verification again. To confirm, you type **your own** password. > **Warning** > > You cannot disable your own account, close your own sessions or reset your own 2FA; this is blocked for security. ## Fields and options ### Roles | Role | What it can do | |---|---| | super_admin | Has access to all environments and all admin actions; only this role manages admin accounts, studios, plugins and safety switches | | game_manager | Manages game and player resources in the assigned environments; does most actions except admin accounts, studios, plugin management, safety switches, sign-in provider credentials and the admin details in the audit log | | support | Does support in the assigned environments: reads players, sessions and chat; updates player information, bans, closes sessions, mutes in chat | | read_only | Only reads in the assigned environments | ### Create and edit form | Field | Required | Description | |---|---|---| | E-posta (Email) | No* | *At least one of email or username; only when creating | | Kullanıcı adı (Username) | No* | 3-32 characters; only when creating | | Görünen ad (Display name) | Yes | The name shown in the panel | | Rol (Role) | Yes | One of the four roles | | Geçici parola (Temporary password) | Yes (when creating) | At least 12 characters | | Ortamlar (Environments) | Yes | The environments to be accessed | | İşlem nedeni (Action reason) | Yes | 8-500 characters | ### Columns in the list Operatör (Operator), Rol (Role), Durum (Status; Etkin (Active) or Devre dışı (Disabled)), 2FA (Açık (On) or Kapalı (Off)), Scope (the number of environments that can be accessed), Son giriş (Last sign-in) and Oluşturulma (Created) are shown by default. You can turn on the E-posta (Email), Kullanıcı adı (Username) and Güncellenme (Updated) columns from the column menu. ## Common situations | Situation | What to do | |---|---| | I cannot see the page | The permission to read admin accounts belongs only to the widest role; ask a super admin. | | An environment is missing from the environment list | The list shows only environments you are also authorised for; widen your own access first. | | An "E-posta ya da kullanıcı adı gerekli" error (Email or username required) | You cannot leave both empty. | | A "Parola en az 12 karakter olmalı" error (The password must be at least 12 characters) | Write the temporary password with 12 or more characters. | | The "2FA'yı sıfırla" button is missing | The admin's two-step verification is off, or you are looking at your own account. | | An admin I disabled is still in the panel | Disabling blocks new sign-ins; close the open session with **Oturumları revoke et**. | ## Related docs - [Core concepts](../getting-started/concepts.md) - [Authentication overview](../auth/overview.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/player-profile.html # Player profile ## Purpose This page shows a single player's account: status, account type, last sign-in, open session count, total play time and main currency balance. In the tabs below you see the player's characters, wallet, sessions, sign-in history, bans, linked accounts and action history. You get here from the [Players](players.md) list. ## When to use - To check an account's status while looking into a player's help request. - To fix the player's name, email or extra information. - To reset the password or convert a guest to a permanent account. - To ban a player who broke the rules, lift a ban or delete the account. - To close a player's open sessions or remove a linked sign-in method. ## Step by step ### Inspecting 1. Pick one of the tabs: **Genel** (General), **Karakterler** (Characters), **Cüzdan ve envanter** (Wallet and inventory), **Oyun süresi** (Play time), **Oturumlar** (Sessions), **Giriş geçmişi** (Sign-in history), **Yasaklar** (Bans), **Hesaplar** (Accounts), **Geçmiş** (History). Tabs your role cannot see, or whose module is disabled, are hidden or show a "yetki gerekir" (permission required) note. 2. On the **Genel** tab you see the profile information, recent security events and extra information (metadata). ### Editing the profile 1. Press **Profili düzenle** (Edit profile). Only admins with edit permission see this button. 2. Change the display name, the email, the email verification state, the image and the extra information. Only the fields you changed are saved. 3. Fill in the **İşlem nedeni** (Action reason) field at the bottom of the page and save. If you try to leave the page without saving, the panel warns you. ### Managing the password 1. On a registered account, from the **Diğer işlemler** (More actions) menu, give a new password with **Parola yönet** (Manage password) (at least 12 characters), or ask the system to generate one with **Rastgele parola üret** (Generate a random password). 2. On a guest account, the **Hesaba çevir** (Convert to account) action gives the guest an email and a one-time random password. 3. The generated password is shown **only once** and cannot be retrieved again. Pass it to the player through a secure channel; the **Gizle** (Hide) button, or changing the tab or page, wipes the password from memory. Generating a password and converting close the open sessions. ### Banning and lifting a ban 1. From the **Diğer işlemler** menu, choose **Banla** (Ban) (if the player is already banned, **Yasağı düzenle** (Edit ban) and **Banı kaldır** (Lift ban) appear). 2. Choose the duration, write the optional ban reason and the action reason, and press **Banla**. 3. All the player's sessions are closed and they cannot sign in again. When you lift the ban, the player can sign in again, but the sessions that were closed do not come back. ### Sessions, account links and deletion 1. On the **Oturumlar** tab you can revoke a single session, and from the **Diğer işlemler** menu you can choose **Tüm oturumları kapat** (Close all sessions). 2. On the **Hesaplar** tab you can remove the link to a sign-in method. If you remove the last sign-in method, the player cannot sign in; check the other methods first. 3. The **Oyuncuyu sil** (Delete player) action marks the account as deleted and closes it to access; the record is not destroyed completely. There is no button in the panel that undoes a deletion. To confirm, you type the player's name (or their email if there is no name). > **Warning** > > All these actions are written to the immutable audit log. Deleting and closing sessions affect the player immediately. ## Fields and options ### Profile edit form | Field | Required | Description | |---|---|---| | Görünen ad (Display name) | Yes | The player's display name | | E-posta (Email) | No | If empty, the email is removed; needs a separate permission | | E-posta doğrulandı (Email verified) | No | The address counts as verified; needs a separate permission | | Görsel URL (Image URL) | No | The address of the player's image | | Metadata JSON | No | A JSON object | | İşlem nedeni (Action reason) | Yes | 8-500 characters | ### Ban window | Field | Required | Description | |---|---|---| | Süre (Duration) | Yes | 1, 3, 7, 30 days, permanent or a custom date and time (at most 365 days) | | Yasak gerekçesi (Ban reason) | No | At most 200 characters; visible only on admin screens | | İşlem nedeni (Action reason) | Yes | 8-500 characters | ### Password windows | Field | Window | Description | |---|---|---| | Yeni parola (New password) | Parola yönet | At least 12 characters | | E-posta (Email) | Hesaba çevir | The account's sign-in email | | İşlem nedeni (Action reason) | All | At most 200 characters in the password and convert windows | ## Common situations | Situation | What to do | |---|---| | The email field is disabled | Your role has no permission to change the email and verification; ask an admin. You can still change the display name. | | "Profili düzenle", "Banla" or "Parola yönet" is missing | The matching permission is not in your role. The buttons are visible only to authorised admins. | | I lost the generated password | The password is not shown again; start the action again and generate a new password. | | A "Parola daha önce gösterildi" (The password was shown before) note appeared | The same action had already been completed; start the action again for a new password. | | A player I banned still shows a session | A ban closes all sessions; refresh the **Oturumlar** tab and check their status. | | A metadata error | Enter a valid JSON object; an array or plain text is not accepted. | ## Related docs - [Authentication overview](../auth/overview.md) - [Account linking](../auth/account-linking.md) - [Sessions and CSRF](../auth/sessions-and-csrf.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/character-profile.html # Character profile ## Purpose This page shows the details of a single character: level, XP, record version, which player it belongs to, its attributes, its wallet and its event history. At most six key facts are at the top, with section tabs below. You get here by clicking a row in the [Player characters](characters.md) list. ## When to use - To look into a complaint about a player's character state. - To see how and when a change happened from the event history. - To run a command to fix a wrong change, or to roll back to an earlier version. ## Step by step ### Inspecting 1. Pick one of the tabs: **Genel** (General), **İstatistikler** (Statistics), **Cüzdan ve envanter** (Wallet and inventory), **Geçmiş** (History), **Gelişmiş** (Advanced). A section you have no permission for, or whose module is disabled, is either hidden or shows a "yetki gerekir" (permission required) note. 2. To go to the player, use the **Oyuncu profiline git** (Go to the player profile) link in the key facts. ### Running a command 1. On the **Gelişmiş** tab, press **Komut çalıştır** (Run command). Only admins with command permission see this button. 2. Choose the command from the **Komut** (Command) list; the matching fields appear depending on your choice. 3. Enter the values, write an **İşlem nedeni** (Action reason) and apply the command. 4. The command runs on the character's current version. If the character is changed from somewhere else while you fill in the form, the action is rejected with a version conflict; refresh the profile and try again. ### Rolling back to an earlier version 1. On the **Gelişmiş** tab, press **Geri al** (Roll back). Only admins with rollback permission see this button. 2. Write the version number you want to return to in the **Hedef sürüm** (Target version) field (at least 1) and add the reason. > **Warning** > > Rolling back does not delete history. A new event is written that brings the character to the state of that version; the old events stay in place. ### Viewing a past state 1. On the **Gelişmiş** tab, enter the time in the "Geçmişteki hâli" (State in the past) section and press **Hâlini göster** (Show the state). The character's state at that moment is recalculated from the event history. ## Fields and options ### Command window | Field | Which commands | Description | |---|---|---| | Komut (Command) | All | Bayrak ayarla (Set flag), Özellik değeri ata (Set attribute value), Kaynak ekle ya da çıkar (Add or remove resource), Değiştirici ekle (Add modifier), Değiştirici kaldır (Remove modifier), Deneyim puanı (XP) ekle (Add experience points), Önceki sürüme dön (Roll back to an earlier version) | | Bayrak anahtarı, Bayrak değeri (Flag key, Flag value) | Set flag | The flag's name and its on/off state | | Anahtar (Key) | Set attribute value, Add or remove resource | The attribute's or resource's key | | Değer / Miktar / XP miktarı / Hedef sürüm (Value / Amount / XP amount / Target version) | Commands other than flag (except Remove modifier) | An integer | | Etkilenen istatistik, Değiştirici türü (Affected stat, Modifier type) | Add modifier | Which stat, and how it is affected | | Kaynak türü, Kaynak kimliği (Source kind, Source ID) | Add and remove modifier | The modifier's source | | İşlem nedeni (Action reason) | All | At least 8 characters | ### State in the past | Field | Required | Description | |---|---|---| | Tarih ve saat (Date and time) | Yes | An RFC 3339 time; a time in the future or an invalid time is rejected | ## Common situations | Situation | What to do | |---|---| | The "Komut çalıştır" button is disabled | The character's data could not be loaded; refresh the profile. | | The command was rejected with a version conflict | The character was changed from somewhere else; refresh the profile and run the command again. | | "tanımsız attribute" or "tanımsız resource" error (undefined attribute or resource) | The key does not exist in the character's definition; it must match the definition's spelling exactly. Computed fields also cannot be set by hand. | | A "min altında" or "max üstünde" error (below the minimum or above the maximum) | You are going past the lower or upper limit in the definition; choose a value within the limits. When adding a resource, the amount cannot be 0. | | A tab is not visible | Your role has no permission to see that section, or the related module is disabled. | | History is empty | It says "Bu karakter için olay kaydı yok." (There is no event record for this character.); the character has not received a command yet. | ## Related docs - [Characters](../guides/characters.md) - [Economy](../guides/economy.md) - [Core concepts](../getting-started/concepts.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/model-profile.html # Model ## Purpose This page is the detail of a single 3D model: the preview, values computed from the file, versions, the definitions that use the model and the release history. You choose here which version of the model goes to the game. ## When to use - To publish a converted model to the game. - To roll a wrong release back to a previous version. - To add animation clips to the model. - To archive, rename or permanently delete the model. ## Step by step ### Publish a version 1. In the **Sürümler** (Versions) table, press **Yayınla** (Publish) on a version whose status is **Hazır** (Ready). 2. If the release does not bring a big change to the live version's clips or dimensions, it is done immediately. 3. If the version removes a clip that is live or noticeably changes the bounding box, a confirmation window opens. The window shows the removed and added clips and the size difference. Tick **Farkı gördüm, yine de yayınla** (I saw the difference, publish anyway), write a reason and confirm. ### Roll back On a version older than the live one, the button appears as **Geri al** (Roll back) and always opens a confirmation window. You can roll back only to versions that are still retained. > **Warning** > > Versions that are no longer live are deleted after 14 days in the default setting; you cannot go back to a version whose retention period has expired. The table shows each version's retention date. ### Add clips 1. Press **Klip ekle** (Add clip). The button is disabled if there is no ready version with a skeleton and an fbx or blend source, and the reason is written next to it. 2. Choose the **Taban sürüm** (Base version), choose the clip file or zip package and press **Yükle** (Upload). 3. The server attaches the clips to the base version's skeleton and produces a new version; you can then publish the new version. ### Rename, archive, delete Done with the buttons at the top; each asks for a reason. - **Yeniden adlandır** (Rename): The new name shows in the library; existing references are not affected. - **Arşivle** (Archive): The model closes to new selections; definitions that use it keep working. **Arşivden çıkar** (Unarchive) reopens it. - **Kalıcı sil** (Delete permanently): Possible only for an archived model that no definition uses. It cannot be undone; all of the model's versions are deleted. ## Fields and options | Field | Where | Description | |---|---|---| | Previewed version | Preview | Which version is shown | | Base version | Add clip | The version that will receive the clips | | Clip file or zip | Add clip | fbx or zip | | New name | Rename | At most 200 characters | | I saw the difference, publish anyway | Release confirmation | Required checkbox | | Action reason | Rename, archive, delete, confirmed release | 8-500 characters | ### Sections on the page - **Önizleme** (Preview): A 3D viewer; you can play the selected version's clips. - **Olgular** (Facts): Triangle, vertex, bone, material and texture counts, size, glb file size and the clip list. The server computes these itself from the glb file. - **Uyarılar** (Warnings): Minor problems during conversion (for example a missing texture or a skipped clip). They do not block the release. - **Sürümler** (Versions): Each version's source, status, source files (downloadable), retention period and release button. - **Kullanan tanımlar** (Definitions using it): The item, character and content entry definitions that use this model. - **Yayın geçmişi** (Release history): Which version was released when and for what reason. ## Common situations | Situation | What to do | |---|---| | The "Kalıcı sil" button is disabled and says "N definitions use this model" | First remove the model from the definitions that use it; then you can delete it. | | A note "another release was made while you were looking" appeared when publishing | The difference was refreshed; look at the new difference and confirm again. | | "Klip ekle" is disabled | Clips can be added only to ready versions with a skeleton and an fbx or blend source. | | I cannot go back to an old version | The version's retention period has expired; upload the model again. | | The preview does not open | The preview needs a ready, retained version; conversion may not be finished or may have failed. | ## Related docs - [3D models](models.md) - [Content](content.md) - [Scenes, images and 3D models guide](../guides/scenes-assets-models.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/content-group-edit.html # Edit content group ## Purpose This screen sets up a content group in two steps: first its name, icon and description; then the group's **fields**. Fields are the values every entry in this group will fill in (top speed, color, 3D model and so on). The same screen is also used to change the schema of an existing group; in that case its title is "Grubu düzenle" (Edit group). ## When to use - When setting up a new content group. - To add a field to an existing group, change the field order or update a field's limits. ## Step by step ### Set up a new group 1. On the [Content](content.md) page press **Yeni grup** (New group); if you wish, choose a template. 2. In step **1 / 2** write the group name. In a new group the key is suggested from the name; you can change it as you like, it does not change after saving. 3. Move to the second step with **Devam: alanlar** (Continue: fields). 4. With **Alan ekle** (Add field), enter a name, kind and, if needed, limits for each field. Reorder fields with the up and down arrows. 5. Check how the entry form will look with the live preview on the page and say **Grubu oluştur** (Create group). ### Change an existing group's schema 1. Press **Şemayı düzenle** (Edit schema) in the group's entry list. 2. The key and kind of saved fields are locked. If you need a different kind, open a new field with **Yeni alan olarak ekle** (Add as new field). 3. Press **Kaydet** (Save). A schema change is also a draft, like entries; it goes to the game when you publish. > **Note** > > If you press **Vazgeç** (Cancel) while there are unsaved changes, the panel asks for confirmation before leaving. ## Fields and options ### Group information | Field | Required | Description | |---|---|---| | Group name | Yes | The name shown in the panel | | Key | Yes | The ID the game finds the group by; fixed after saving | | Icon | No | The icon shown on the card | | Short description | No | At most 500 characters | ### Field kinds | Kind | What it holds | Extra settings | |---|---|---| | Number | Whole number | Unit, decimal places, min, max, default | | Percent | Percentage value | Min, max, default | | Text | Text | Max characters, multi-line | | Yes / No | Boolean value | Default | | Option list | Choice from a list | Options (at most 64), multiple choice possible | | Color | `#rrggbb` color | None | | 3D model | A model from the model library | None; cannot be chosen if the Scenes module is off | | Image | An uploaded image | None | | Another entry | A link to another entry | Target, target group | | Table | Repeated rows, for example a level-damage table | Min rows, max rows, columns (at most 8) | ### Settings common to every field | Field | Required | Description | |---|---|---| | Field name | Yes | The name shown in the form | | Key | Yes | The ID the game reads; fixed after saving | | Kind | Yes | Does not change on a saved field | | Visibility | Yes | Player can see, or game server only | | Help text | No | Shown under the field in the form | | Required | No | If left empty, the entry cannot be saved | | Show in list | No | Also shows it in the list and summary | A group has at most 48 fields. ## Common situations | Situation | What to do | |---|---| | I wrote the key wrong and saved | The key does not change. Add a new field with the right key; if the old one is no longer needed, remove it from the entries. | | The "3D model" kind cannot be chosen | The Scenes module is off in this environment; turn the module on. | | I cannot change a field's kind | A saved field's kind is fixed. Open a separate field for the new kind with **Yeni alan olarak ekle**. | | Some fields have a lock icon | A plugin (for example Package & Ride) uses that field; it cannot be deleted and its kind does not change. | | I cannot add a required text column to a saved table | Add it as optional first; a new required column is rejected because existing rows have no value. | | "Devam: alanlar" does not move to the next step | In step 1 the name or key is missing or invalid; fix the red messages. | ## Related docs - [Content](content.md) - [Entry](content-entry-edit.md) - [Content groups guide](../guides/content-groups.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/content-entry-edit.html # Content entry ## Purpose This screen shows and edits a single entry in a content group. The group's schema decides the entry's fields; that is, the fields in the form change from group to group. A change you make here is a **draft**: it reaches the game only with **Yayınla** (Publish) in the group's entry list. ## When to use - To update an entry's values (for example a vehicle's speed). - To add a new entry to the group. - To archive or unarchive the entry. ## Step by step ### Edit an entry 1. Open the group from the [Content](content.md) page, then the entry from the list. 2. Press **Düzenle** (Edit). Only admins with content write permission see the button. 3. Change the values. Required fields are marked "(zorunlu)" ("(required)") next to their names. 4. Press **Taslağı kaydet** (Save draft) at the bottom of the page. The change is written to the draft; "Taslak değişikliği var" (Has draft changes) shows in the list and on the entry. 5. To send it to the game, use **Yayınla** in the entry list. ### Create a new entry 1. Press **Yeni kayıt** (New entry) in the group's entry list. 2. Write the **Kayıt adı** (Entry name); the key is suggested from the name, change it if you wish. 3. Fill in the fields and say **Taslağı kaydet**. The entry is added to the draft. > **Note** > > In closed groups used by a plugin (for example Package & Ride vehicles) a new entry cannot be added; the panel writes the reason on the page. ### Archive From the **Daha fazla** (More) menu choose **Arşivle** (Archive) or **Arşivden çıkar** (Unarchive). Archiving is also a draft change; it reaches the game when published. ## Fields and options ### Fields on every entry | Field | Required | Description | |---|---|---| | Entry name | Yes | The name shown in the panel | | Key | On a new entry | The ID in the game; fixed after the entry exists | ### The group's fields The remaining fields come from the group's schema. The gray text under each field is the help text written by whoever set the group up. Input by type is as follows: | Field type | Input | |---|---| | Number, Percent | A number within limits; decimal places depend on the group's setting | | Text | Free text; the upper character limit is in the group's setting | | Yes / No | Checkbox | | Option list | Single or multiple choice | | Color | Color picker | | 3D model | Choice from the model library; a preview appears above the entry | | Image | Image upload | | Another entry | Choice of an entry from the target group | | Table | Add, delete and move rows; the table data per entry cannot exceed 128 KiB in total | Fields marked "game server only" do not go to the player; they appear with a badge in the panel. ### Sections - **Özellikler** (Properties): The values. - **Bağlantılar** (Links): The entries this entry links to and the entries that link to it. - **Kullanan** (Used by): If a plugin uses this group, which feature uses it. - **Geçmiş** (History): The entry's revision count, last change time and latest releases. ## Common situations | Situation | What to do | |---|---| | "N sorun var; alanların altındaki mesajlara bakın" (N problems; see the messages under the fields) | Fix the fields with red messages; the messages say what is wrong without showing the value. | | I get a key error | The key must start with a lowercase letter or digit and contain only lowercase letters, digits, underscores and hyphens. | | The price field cannot be edited and says "Mağazada yönetilir" (Managed in the store) | If the vehicle entry has a product in the store, the product is the only source of the price; change it on the [Product](store-product.md) page. | | It asks for confirmation when leaving the page | There are unsaved changes; save with **Düzenlemeye dön** (Back to editing) or say **Değişiklikleri at** (Discard changes). | | Someone else changed the entry at the same time | The entry is written with version checking; refresh the page and make the change again. | ## Related docs - [Content](content.md) - [Edit content group](content-group-edit.md) - [Content groups guide](../guides/content-groups.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/store-product.html # Store product ## Purpose This page shows the detail of a single store product; it is also used to edit that product or create a new one. A product is a record that sits in a showcase and sells an item. Price, stock, per-person limit, badge and sale time are set here. ## When to use - To add a new product to a showcase. - To change a product's price, stock or image. - To take a product off sale, bring it back or delete it. - To look at a product's sales history and change history. ## Step by step ### Add a new product 1. On the [Store](store.md) page choose the showcase and press **Yeni ürün** (New product). 2. In the **Ne satılıyor?** (What is being sold?) field, search for the item by name and select it. 3. If you wish, write the name in the showcase, the description and the image. 4. In the **Nasıl ödenir?** (How is it paid?) section choose game money or real money; enter the price and currency, or the in-app product to bind. 5. Set the badge, the stock and the per-person limit. 6. Choose the status (draft or live), the sale time and the showcase. 7. Write a reason (the panel fills in a suggestion after the change) and press **Ürünü oluştur** (Create product). On the right there is a preview showing how the product will look in the game. ### Change the product 1. On the product's page press **Düzenle** (Edit). 2. Change the fields and press **Değişiklikleri kaydet** (Save changes). Other buttons on the product page: - **Yayından kaldır** (Take off sale) and **Yayına al** (Put on sale): Immediately turns the product into a draft or puts it live. - **Sil** (Delete): Deletes the product; it asks for a reason. Past sales are kept. > **Note** > > You do not need to delete the product to change the price, stock or time; editing updates the existing product. ## Fields and options | Field | Required | Description | |---|---|---| | What is being sold? | Yes | The item to sell | | Name in showcase | No | At most 120 characters; if empty, the item name | | Description | No | At most 2000 characters | | Product image | No | PNG, JPEG or WebP | | How is it paid? | Yes | Game money or real money | | Currency and Price | For game money | Price greater than 0 | | In-app product | For real money | App Store or Google Play product | | Badge | No | None, New, Sale, Limited, Popular | | Stock | Yes | Unlimited or limited; the quantity if limited | | How many can one player buy at most? | No | Unlimited if empty | | Status | Yes | Draft or Live | | When on sale? | Yes | Immediately or a date range | | Showcase | Yes | Does not change after creation | | Action reason | Yes | 8-500 characters | ### Tabs on the product page - **Ayrıntılar** (Details): Sale (price, stock, per-person limit, badge), publication status and time, "Ne verir" (What it gives: the item sold). - **Satışlar** (Sales): The sales table with total quantity and revenue; filtered by status, source (store or in-game) and player. - **Geçmiş** (History): The product's change events, newest on top. ## Common situations | Situation | What to do | |---|---| | "Satılacak bir eşya seçin" (Choose an item to sell) | Choose the item first; if the list is empty, add an item from the Content page. | | "Fiyat 0'dan büyük bir sayı olmalı" (The price must be a number greater than 0) | Write the price to fit the currency's decimal places; it cannot be zero or empty. | | "Stok, ayrılmış N adetten az olamaz" (Stock cannot be less than the N reserved units) | This many units are already reserved for purchases; you cannot lower the stock below that. | | "Bitiş, başlangıçtan sonra olmalı" (The end must be after the start) | Fix the date range; you must write at least one end. | | "Bu ürünün API ile verilmiş bir görünürlük koşulu var" (This product has a visibility condition given through the API) | The condition cannot be changed from the panel; it is preserved as it is when you save. | | I cannot change a vehicle product's price from Content | For a vehicle that has a product in the store, the product is the only source of the price; change the price here. | ## Related docs - [Store](store.md) - [Currencies](currencies.md) - [Store and in-app purchases guide](../guides/store-iap.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/currency-profile.html # Currency ## Purpose This page is the detail of a single currency; the same page also works as the form for creating and editing a currency. In the detail view you see the currency's icon, key, version, decimal places, supply cap and transferability, along with the amount minted and burned in the last 30 days, the biggest balances, wallets, movements and the products priced in this currency. ## When to use - To create a new currency. - To change the decimal places, the icon, the supply cap or transferability. - To look at how money circulates in the game. ## Step by step ### Create a new currency 1. On the [Currencies](currencies.md) page press **Yeni para birimi** (New currency). 2. Write the **Ad** (Name). Write the key with lowercase letters, digits, hyphens and underscores; it does not change after saving. 3. If you wish, upload an icon image. 4. Choose the **Kuruş hanesi** (Decimal places). The "Oyunda böyle görünür" (This is how it looks in the game) preview on the right shows a sample amount with these places. 5. Choose the **Arz tavanı** (Supply cap) and tick **Oyuncular arası gönderilebilir** (Transferable between players) as needed. 6. Write a reason and press **Para birimini oluştur** (Create currency). ### Edit the currency 1. On the detail page press **Düzenle** (Edit). 2. Change the fields and press **Yeni sürüm olarak kaydet** (Save as a new version). Each save is a new version; old versions do not change. > **Warning** > > Changing the decimal places changes only the display; stored balances and prices stay the same. So when the places change, the amounts shown to players may look different. Be sure before changing. ## Fields and options | Field | Required | Description | |---|---|---| | Name | Yes | At most 120 characters | | Key | When creating | Lowercase letters, digits, hyphens, underscores; does not change later | | Icon image | No | PNG, JPEG or WebP | | Decimal places | Yes | 0 (whole number), 1, 2 (cents), 3 or 4 | | Supply cap | Yes | Unlimited or limited | | Total supply at most | If limited | Amount with decimal places | | Transferable between players | No | If selected, players can send it to each other | | Action reason | Yes | 8-500 characters | ### Cards on the detail page - **Son 30 gün** (Last 30 days): Amount minted and burned daily. - **En çok bakiyesi olanlar** (Biggest balances): Balance ranking as of yesterday's close. - **Cüzdanlar** (Wallets) and **Hareketler** (Movements): The wallets in this currency (can be sorted by balance) and ledger movements. Not shown if you lack the related read permission. - **Bu parayla fiyatlanan ürünler** (Products priced in this currency): Products priced in this currency in the store; gives a link to the product page. ## Common situations | Situation | What to do | |---|---| | "Bu anahtarla bir para birimi zaten var" (A currency with this key already exists) | Write another key; to change the existing currency, open it and use **Düzenle**. | | "İkon görseli bu ortamda bulunamadı" (The icon image was not found in this environment) | Upload the image again. | | I cannot change the key | The key is written only when creating; if it is wrong, create a new currency. | | Cards that say "Bunu görme yetkiniz yok" (You do not have permission to see this) | Your account's role has no permission to read that information. | | I changed the places and amounts look broken | The places are display only; go back to the old places and save as a new version. | ## Related docs - [Currencies](currencies.md) - [Economy reports](economy.md) - [Economy guide](../guides/economy.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/reward-table-profile.html # Reward table ## Purpose This page shows a single reward table; the same page also works as the form for creating and editing the table. A table has two parts: **chance rows** (one is picked in each draw) and **always given** rewards (given every time, independent of the draw). You can try the table before saving it. ## When to use - To set up a new reward table for an NPC, creature, tournament or mission. - To change chances or amounts. - To see where the table is used and the latest rewards given. ## Step by step ### Create a new table 1. On the [Reward tables](reward-tables.md) page press **Yeni ödül tablosu** (New reward table). 2. Write the **Ad** (Name) and the **Anahtar** (Key); the key does not change after saving. 3. Choose **Kaç çekiliş** (How many draws). 4. Enter the reward rows with **Satır ekle** (Add row): kind, what it gives, minimum and maximum amount and chance. You can reorder rows with the up and down arrows. 5. Complete the sum of the chances to 100%. The total is shown live under the rows; with **Kalanı 'Ödül yok' yap** (Make the rest 'No reward') you can give the remainder to a "no reward" row. 6. If you wish, add draw-independent rewards to the **Her zaman verilenler** (Always given) section. 7. Try the table without saving from the **Dene** (Try) panel on the right. 8. Write a reason and press **Ödül tablosunu oluştur** (Create reward table). ### Edit the table 1. On the table's page press **Düzenle** (Edit). 2. Change the rows and press **Yeni sürüm olarak kaydet** (Save as a new version). Each save is a new version; old versions are not deleted and appear in the version history. ### Try the table The **Dene** button runs 100 sample draws with the table without saving and summarizes which reward came up how many times. No reward is given and the result changes with every try. If the form is incomplete (for example the chances are not 100%) the button is disabled. ### Delete the table Press **Sil** (Delete) and write a reason. If the table is used by an NPC rule, mission or content entry it cannot be deleted; the window lists the places that use it. Rewards given in the past are not deleted. ## Fields and options ### Table information | Field | Required | Description | |---|---|---| | Name | Yes | At most 120 characters | | Key | When creating | Lowercase letters, digits, hyphens, underscores; does not change later | | How many draws | Yes | 1-10 | ### Chance rows (at most 50) | Field | Description | |---|---| | Reward kind | Money, Item or No reward | | What | Currency or item | | Min, Max | Amount range; if max is empty it is the same as min | | Chance % | The row's chance of coming up; the total must be exactly 100% | ### Always given (at most 10) Made of the Reward kind (Money or Item), What, Min and Max fields; there is no chance. ### Cards on the profile page - **Şans çubukları** (Chance bars), **Her zaman verilenler** (Always given), **Kaç çekiliş** (How many draws). - **Nerede kullanılıyor** (Where it is used): NPC rules, missions and content entries that use the table. - **Son verilen ödüller** (Latest rewards given): The latest rewards given from the table; player, source and reward. - **Sürüm geçmişi** (Version history): Each version's date and row count; the current one has a badge. ## Common situations | Situation | What to do | |---|---| | "Kaydetmek için toplam %100 olmalı" (The total must be 100% to save) | Bring the sum of the chances to exactly 100%; for the missing remainder you can use a "No reward" row. | | "Kaydetmek için en az bir ödül ekleyin" (Add at least one reward to save) | At least one chance row or one "always given" reward is needed. | | "Bu anahtarla bir ödül tablosu zaten var" (A reward table with this key already exists) | Choose another key; to change the existing table, open it and use **Düzenle**. | | I cannot delete the table | The table is in use; detach it from the places that use it first. The delete window shows where it is used. | | I get an amount error | Write a money amount in the currency's decimal format; the minimum is 1 and the maximum is 1,000,000 of the smallest unit. | | "Dene" is disabled | Complete what is missing in the form; the chances must be 100%. | ## Related docs - [Reward tables](reward-tables.md) - [Currencies](currencies.md) - [Rewards guide](../guides/rewards.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/leaderboard-detail.html # Leaderboard detail ## Purpose This page shows a single leaderboard in a profile-page layout. At the top are the leaderboard's name, its copyable key and four summary boxes (player count this season, leader, last score time, next reset). Below are three tabs: **Bu sezon** (This season), **Geçmiş sezonlar** (Past seasons) and **Ayarlar** (Settings). ## When to use - To look at the top ranks of the leaderboard and a specific player's place. - To see the winners of closed seasons and the closing table. - To change the leaderboard's visibility in the game or its score format. - To copy the key to read the leaderboard with in your game code. ## Step by step ### See this season's ranking 1. The top ranks are listed in the **Bu sezon** tab. Each row has the rank, player, score and last update time; if you click the player's name, the player's page opens. 2. To see a specific player, write their name or email in the **Oyuncu bul** (Find player) field and select them. The rows around the player are shown in a separate card. Banned or deleted players are shown faded and labeled "hidden in game"; players do not see these rows in the game. ### Look at past seasons 1. Closed seasons are listed in the **Geçmiş sezonlar** tab; each row shows the date, the player count and the first place. 2. Clicking a row opens the season's closing table (first 100 rows). A closed season cannot be changed. ### Change settings 1. In the **Ayarlar** tab, change the **Oyunda görünsün** (Show in game), **Puan nasıl gösterilsin?** (How should the score be shown?) and **Birim eki** (Unit suffix) fields. 2. Press **Kaydet** (Save). Only the changed fields are sent. The **Tablo tanımı** (Leaderboard definition) card below shows the key, sort direction, reset interval and source read-only; these are chosen at setup and cannot be changed. The **Geliştirici bilgisi** (Developer info) section shows the player-session read endpoint and the SDK call. ## Fields and options | Field | Required | Description | |---|---|---| | Find player | No | View only; shows the player's surroundings | | Show in game | No | Turns players seeing the leaderboard on or off | | How should the score be shown? | Yes | Number, money or time | | Unit suffix | No | At most 8 characters; not available for the time unit | ## Common situations | Situation | What to do | |---|---| | "Bu sezon henüz puan yok" (No scores yet this season) | Your game server has not sent scores this season. The row appears when the first score arrives. | | "Tablo bulunamadı" (Leaderboard not found) | There is no leaderboard with this key or it was deleted; reopen it from the [Leaderboards](./leaderboards.md) list. | | The Settings tab is disabled | Changing fields needs the leaderboard management permission; ask an admin. | | "Bu tabloya yalnız … eklentisi puan yazar" (Only the ... plugin writes scores to this leaderboard) | A plugin set this leaderboard up. The plugin writes the scores, not your game server. | | I want to change the sort direction or the reset | These do not change after setup. Set up a new leaderboard from the [Leaderboards](./leaderboards.md) page. | | The list has only the first rows | The page shows the first rows; the rest is not listed in this version. | ## Related docs - [Leaderboards and tournaments](../guides/competition.md) - [Core concepts](../getting-started/concepts.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/rules-game-card.html # Rule card ## Purpose This page shows the detail of a single game rule: **when** the rule runs, which **conditions** it has, **what it does** and which values you tune. Changes you make here do not reach the game immediately; they pile up at the bottom of the page and are applied together with a single release. What the rule is for is written by the game's plugin; the page's title and texts come from the plugin. ## When to use - To change a rule's value (duration, distance, speed threshold and so on). - To see the result of a change before releasing with the **Ne değişir?** (What changes?) preview. - To temporarily put the rule's protection under observation or turn it back on. ## Step by step 1. On the [Rules](./rules.md) page click a card. 2. Read the **Ne zaman** (When), **Koşullar** (Conditions) and **Ne yap** (What to do) sections. Conditions with a lock icon cannot be changed. 3. In the **Ne yap** section change a value. The field shows the value with its unit (for example seconds or km/h); you can write a decimal number with a comma. Under it the allowed range and default value are written; the **Varsayılana dön** (Reset to default) button returns the value to its old state. 4. If you wish, in the **Ne değişir?** card on the right, choose a scenario and press **Önizle** (Preview). The card shows the order of events side by side with the "now" and "new" values. 5. In the bar at the bottom of the page press **Gözden geçir ve yayınla** (Review and release). In the window that opens, check the list of changes, write your reason and press **Yayınla** (Release). > **Note** > > Pending changes stay only in this browser tab. If you refresh the page or close the tab, unreleased changes are lost. Moving to other cards does not delete them. > **Warning** > > Some values are applied right after release, others when the server restarts. If you see a "Yayında: … · Bekliyor: …" (Live: ... · Pending: ...) line on the card, the value is released but the live world is still using the old value. ## Fields and options | Field | Required | Description | |---|---|---| | Setting value | No | A row for each of the card's settings; a value outside the range is not accepted | | Rule switch ("Bu kural açık", This rule is on) | No | Shown only on cards that have a switch; needs write permission | | Scenario (preview) | No | One of the sample situations the card offers | | Scenario numbers (preview) | No | Vary by scenario; limits are written under the field | | Neden değiştiriyorsunuz? (Why are you changing it?) (release window) | Yes | 8-500 characters | Badges on the card: **Açık** (On: the protection is applied), **Gözlemde** (Under observation: the rule is not applied to the game), **Değiştirildi** (Changed: the live value differs from the default), **Yayınlanmadı** (Not released: there is a pending edit), **Yeniden başlatınca** (On restart: the live world takes the value on restart). ## Common situations | Situation | What to do | |---|---| | The setting fields are disabled and say "no permission" | You can read the values but changing them needs the game rules publish permission; ask an admin. | | The release says "another change was released" | Someone else released while you were looking. Press **Son durumu yükle** (Load the latest state); your edits are kept and the difference is recomputed. | | The release says "some values were not accepted" | Click the setting name in the list; the page goes to that field. Fix it according to the limit in the message and release again. | | It says "Bu kural bulunamadı" (This rule was not found) | The card ID is not in this game's catalog; reopen it from the [Rules](./rules.md) page. | | The preview says "none" | This card has no preview scenario; check the values carefully before releasing. | ## Related docs - [Game rules and automations](../guides/game-rules.md) - [Core concepts](../getting-started/concepts.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/rules-history.html # Rule history ## Purpose Every game rule release is a new **version**. This page lists versions from newest to oldest: who released, when, for what reason and which settings changed. Going back to an older version also writes a new version; history is never deleted. ## When to use - To quickly undo a wrongly released setting. - To find out when and by whom a setting was changed. - To return all settings to the game's built-in (code) values. ## Step by step ### See what a version changed 1. Click the **Değişen ayarlar** (Changed settings) line under the version. 2. For each setting you see "previous value → new value". A version that changed no setting says so explicitly. ### Go back to an older version 1. Press **Bu sürüme dön** (Go back to this version) on the row of the version you want to return to. This button is not shown on the live version. 2. In the window that opens, write your reason and press **Geri al** (Roll back). 3. That version's values are released as a new version. Police settings take effect within 6 seconds at most. ### Return to the built-in (code) values 1. Press **Bugünkü (kod) değerlere döndür** (Reset to today's code values) at the top of the page. 2. Write your reason and confirm with **Kod değerlerine döndür** (Reset to code values). All settings return to the game's defaults; this is also a new version. > **Note** > > A rollback appears in the list with a badge such as "rolled back to version 7". No version is deleted; only a new version is added. ## Fields and options | Field | Required | Description | |---|---|---| | Action reason (rollback window) | Yes | 8-500 characters | Badges on a row: **Yayında** (Live: the version in effect now), **Geri alındı** (Rolled back) or **Sürüm N'e geri alındı** (Rolled back to version N), **Kod değerlerine döndürüldü** (Reset to code values). ## Common situations | Situation | What to do | |---|---| | There are no rollback buttons | Rolling back needs the game rules publish permission; you can still read the history. Ask an admin. | | It says "Henüz yayın yok" (No release yet) | Settings are at the game's default values; versions appear here after the first release. | | The rollback was rejected with "another change was released" | Someone else released while you were looking. The page re-reads the current state; look at the current version and confirm again. | | I cannot see older versions | The list loads page by page; press **Daha eski sürümler** (Older versions) at the end. | ## Related docs - [Game rules and automations](../guides/game-rules.md) - [Core concepts](../getting-started/concepts.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/rules-automation-editor.html # Automation editor ## Purpose An automation is a rule in the form "when this event happens, if this condition is true, do this". This page lets you set the rule up in three steps: **Ne zaman** (When: which event), **Koşullar** (Conditions: in which situation) and **Ne yap** (What to do: what to do to the character). You do not need to write code; the panel prepares the rule behind the scenes for you. ## When to use - To define automatic results such as a reward on level up, or a permanent boost when a certain ability opens. - To change an existing rule's condition or action and release a new version. - To try the rule with a character and a sample event before releasing it. ## Step by step 1. On the [Rules](./rules.md) page press **Yeni kural** (New rule). For a new rule you can first choose a ready example from the **Şablondan başla** (Start from a template) section, or start with **Boş kural** (Empty rule). 2. Write a name in the **Kural** (Rule) section; the key is generated automatically. 3. Choose the event in the **Ne zaman** section. 4. In the **Koşullar** section add a condition with **Koşul ekle** (Add condition). If you add no condition, the rule runs every time the event happens. With more than one condition, choose "all" or "any". 5. Leave at least one action in the **Ne yap** section; you can add one with **Eylem ekle** (Add action) and change the order with the arrow. 6. If you wish, with the **Kuralı dene** (Try the rule) button choose a character and a sample event and see the result. The try creates no record. 7. In the **Dene ve yayınla** (Try and release) section write your reason. For a new rule, optionally tick the **Hemen açılsın** (Turn on immediately) box and press **Yayınla** (Release). When you edit an existing rule, a **Yeni sürüm yayınla** (Release new version) button appears; the key does not change. > **Note** > > A released rule is saved **off** by default. The **Aç** button in the Rules list or the **Hemen açılsın** box makes it run. > **Warning** > > The trait, resource, flag and ability names you write in conditions and actions must be exactly the same as the game's own names. A wrongly written name prevents the rule from working as you expect; before releasing, see the result on a real character with **Kuralı dene**. ## Fields and options | Field | Required | Description | |---|---|---| | Rule name | Yes | 1-120 characters | | Key | Yes | Generated from the name, at most 64 characters; does not change after release | | Description | No | At most 500 characters | | When | Yes | Event choice (level up, resource change, flag change and so on) | | Conditions join | With more than one condition | All / any | | What is looked at, which information, name, comparison, value, state | When a condition is added | At most 20 conditions; whole numbers in number fields | | What to do, name, amount, state | Yes (at least one action) | At most 10 actions; the amount cannot be zero | | Operation, value, source kind, source name | In boost actions | In percentage operations the value is 100 times the percentage | | Priority | Yes | A whole number from 1 to 1000, default 100; in the "Gelişmiş" (Advanced) section | | Character, sample event information | In the try | Affects only the try | | Why are you changing it? | Yes | 8-500 characters | | Turn on immediately | No | Shown only on a new rule | The **Gelişmiş** section shows the CEL (condition language) equivalent of the rule's condition read-only; the panel generates it itself from the condition rows and it is not edited here. ## Common situations | Situation | What to do | |---|---| | A "Yayınlamadan önce tamamlayın" (Complete before releasing) list appears | Fill in the missing fields in the list; the release button stays disabled until they are done. | | "Bu anahtarla bir kural zaten var" (A rule with this key already exists) | Change the rule name or the key. | | "Bu arada başka bir değişiklik yayınlandı" (Another change was released in the meantime) | Your edits are kept. Press **Son durumu yükle** (Load the latest state) and release again. | | "Kural kabul edilmedi" (The rule was not accepted) | The server rejected the rule; fix it according to the messages in the list. The values you entered are kept. | | I cannot change the rule's condition, it says "written in code (CEL)" | The rule was written in code outside the panel. It is displayed in the panel, turned on and off and rolled back to an old version but cannot be edited. | | The edit fields are disabled | You do not have the rule write permission; ask an admin. | | The try says "choose a character" | To try you must choose a character from the character search. | ## Related docs - [Game rules and automations](../guides/game-rules.md) - [Core concepts](../getting-started/concepts.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/game-settings-environments.html # Environments and domains ## Purpose A game can have several **environments**: development (dev), staging and live (prod). Each environment has its own players, data and API keys. This tab lists the game's environments as cards; it lets you bind each environment to its own domains, add a new environment and archive an environment. The tab is open only to a super admin. ## When to use - To bind a new address to an environment when your game will be opened from it in a browser. - To add a staging or development environment. - To hide an environment that is not used. ## Step by step ### Bind a domain 1. In the **Alan adları** (Domains) box on the environment's card, write one domain per line (for example `oyun.example.com`). Names are lowercased; empty lines and duplicates are dropped. 2. Write your reason in the **Gerekçe** (Reason) field. 3. Press **Alan adlarını kaydet** (Save domains). ### Add an environment 1. Press **Ortam ekle** (Add environment) at the top of the tab. 2. Choose the environment kind: Development (dev), Staging or Live (prod). The window selects Staging by default. 3. Write your reason and confirm. Domains and Google sign-in are bound separately after the environment is added. ### Archive or reopen an environment The **Arşivle** (Archive) button under the card hides the environment; no data is deleted. An archived environment shows a **Geri aç** (Reopen) button and its domain fields are disabled. > **Warning** > > Keep the old address in the list until the new address works and the old one redirects. Once an address is removed, a game opened from it cannot sign in or write records at once. When you press Save, the removed addresses are listed in a dialog; the change is not applied until you choose **Yine de kaldır ve kaydet** (the confirm button). If the list becomes empty, the server's general allowlist applies. > **Note** > > Domains are used as an allowlist in the origin check of requests coming from a browser. Bind every address you serve your game from to the right environment; a domain bound to another environment is not accepted. ## Fields and options | Field | Required | Description | |---|---|---| | Domains | No | One domain per line; at most 10 | | Reason | Yes | 8-500 characters | | Environment kind (add window) | Yes | Development, Staging or Live | | Action reason (in windows) | Yes | 8-500 characters | ## Common situations | Situation | What to do | |---|---| | "Bu alan adı başka bir ortama bağlı" (This domain is bound to another environment) | The same domain cannot be bound to two environments. Remove it from the other environment first. | | "En fazla 10 alan adı bağlanabilir" (At most 10 domains can be bound) | Shorten the list; at most 10 domains can be bound to one environment. | | "Önce başka bir ortam seçin" (First select another environment) | The currently selected environment cannot be archived; select another environment from the top bar. | | The tab says "permission required" | This tab is open only to a super admin. | | The game on the old address cannot sign in | The address was removed from the list. Add it back and save, then finish moving to the new address. | | Archiving was rejected | The environment has a live grant or an active API key; the window shows the reason. | ## Related docs - [Architecture and hosting](../getting-started/architecture-and-hosting.md) - [Core concepts](../getting-started/concepts.md) --- Source: https://open-play.b4klabs.dev/docs/en/panel/plugin-page.html # Plugin page ## Purpose A game's plugin can define its own admin screens in the panel: player lists, filtered tables, detail pages, charts and action buttons. This page is the shared help for all of those screens; plugin-specific content comes from the plugin's own definition and is not described here. ## When to use - To understand what will happen when you search, paginate or perform an action on a plugin screen. - To learn what the status warnings at the top of the screen mean. ## Step by step ### Getting to know the screen 1. The page title is the plugin's name; the second heading below it is the screen's name. The plugin's screens are listed in tabs under the title. 2. The **Yenile** (Refresh) button re-reads only this screen's data. 3. The **Son okuma** (Last read) line under the title shows when the data was read. ### Moving around lists and tables - The list narrows as you type in the search box; the search text and page number stay in the address bar, so you can share the link. - In the paginator you can choose 12, 25 or 50 rows from the **Sayfa başına** (Per page) list. - If there is a **CSV indir** (Download CSV) button, it downloads the rows loaded on the screen as a file; in a paginated table it contains only the loaded page. ### Performing an action 1. Press the action button on the screen; a window opens. Only admins with the plugin operation permission see or can use the button. 2. Fill in the fields, write your reason in the **İşlem nedeni** (Action reason) field and confirm. 3. The result appears on screen; the action is written to the audit log. > **Note** > > The plugin's state appears as a warning at the top of the screen. In the **Salt okunur** (Read-only) state data is visible but changes cannot be made; in the **Boşaltılıyor** (Draining) state new operations are not accepted and open operations finish. An admin changes the state on the [Plugins](./plugins.md) page. ## Fields and options | Field | Required | Description | |---|---|---| | Search | No | Searches in the fields the plugin defines | | Per page | No | 12, 25 or 50 | | Action reason (action window) | Yes | Limits are written under the field | | Action fields | Depends on the action | Defined by the plugin; each field's description is written in the window | ## Common situations | Situation | What to do | |---|---| | "This plugin is read-only in this environment" | The plugin is open for reading only. If you need changes, ask an admin to set the state to on on the [Plugins](./plugins.md) page. | | "This plugin is being drained" | Before a version change the plugin accepts no new operations; it will return to the on state when draining ends. | | The action button is missing or disabled | You do not have the plugin operation permission, or the plugin is read-only. | | There is a "being deprecated" banner at the top of the screen | The screen is being replaced by a new screen; the link in the banner takes you to the replacement. | | The action says "another change was made" | The record changed while you were looking. See its current state and confirm again. | ## Related docs - [Plugins overview](../plugins/overview.md) - [Calling plugins](../plugins/calling-plugins.md) - [Core concepts](../getting-started/concepts.md)