Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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/ (@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:

    @ferdiunal:registry=https://npm.pkg.github.com
    //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
    
  2. Install the package:

    export GITHUB_TOKEN="<token with read:packages permission>"
    npm install @ferdiunal/[email protected]
    

    pnpm works with the same .npmrc. Do not put the token in the repository, the image, or the CI log.

  3. Import it:

    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

ImportContentsEnvironment
@ferdiunal/open-play-tsCrxClient, createPlayerClient, createGameClient, error and number helpers, catalog/store/leaderboard typesBrowser and Node
.../playerThe player client and plugin-binding creation typesBrowser and Node
.../gameGameClientBrowser and Node
.../realtimeRealtime client typesBrowser and Node
.../browserIndexedDB journal and tab coordination (createBrowserAdapter)Browser
.../codegenBinding and type generatorNode 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.

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 }.

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<bigint> {
  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<number> {
  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 GETs 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).
  • 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:

MemberWhat does it do?
sessionSign-in, session state, refresh, sign-out: Authentication
plugin(binding) · publicPlugin(binding)Plugin commands/queries/modules: Plugin bindings
realtimeThe realtime socket (below)
catalogReading 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
notificationsDevice registration, inbox, web push
dispose()Releases local resources (does not close the server session)

For first use, see the Quickstart. For session states, see Sessions and CSRF.

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).

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.

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<void> } {
  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 section.

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<unknown>) => 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<void> {
  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 and make the HTTP call yourself (the key and header rules are the same).

Module / areaTyped clientWhere
Character definitions, traits, level curves, characters, commands, history, statisticsYesCrxClient (server key)
RulesYesCrxClient (createRule, listRules, dryRunRule, …)
Webhook endpoint registration (create/list/read/delete)YesCrxClient; signature verification is your own code (Webhooks)
ClansYesCrxClient (20 operations)
Catalog (item/data/model) and content groupsYesPlayerClient.catalog (player), CrxClient.catalog (server)
Reward table registration and grantingYesCrxClient.createRewardTable, getRewardTable, grantRewardTable
Store showcase and purchasingYesPlayerClient.store
Reading a core leaderboardYesPlayerClient.leaderboard
Notifications: device registration, inbox, web pushYesPlayerClient.notifications
Plugins and realtimeYes (with a binding generated from the schema)PlayerClient.plugin, GameClient
Items and inventoryNoRaw REST
Currencies, wallets, transfers, ledgerNoRaw REST
Trading, merchantsNoRaw REST
Store administration, IAP verificationNoRaw REST
Quest, VIP, daily login, and NPC rewardsNoRaw REST
PartiesNoRaw REST
Server-side leaderboards, tournamentsNoRaw REST
ChatNoRaw REST/WebSocket
Scenes, assetsNoRaw REST
Magic link, unlinking an account, native platform sign-insNoRaw REST (Authentication)

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.

ErrorKindMeaning
HttpThe server returned an error status (status is filled)
NetworkThe connection dropped; a mutation may have been applied
TimeoutThe total time limit ran out
CancelledCancelled with signal
ContractThe input or the response does not conform to the contract
ConfigurationAn unsafe or broken setting
AuthThe player session wants re-authentication
UnknownOutcomeThe request was sent but the outcome is unknown; do not blindly repeat, query the outcome
StorageThe journal, browser coordination, or the OAuth binding store is unavailable
Realtime · Backpressure · UpgradeRequired · ScopeChanged · DisposedRealtime lifecycle, queue full, plugin version mismatch, session changed, client closed

A banned account is distinguished with error.code === ACCOUNT_BANNED_CODE (Authentication overview).

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.

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):

# 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 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).
  • 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.