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-tsversion 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.
-
Declare the registry. In the
.npmrcfile at the root of your project, write the scope and the token variable. The token is a GitHub classic token withread:packagespermission (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} -
Install the package:
export GITHUB_TOKEN="<token with read:packages permission>" npm install @ferdiunal/[email protected]pnpmworks with the same.npmrc. Do not put the token in the repository, the image, or the CI log. -
Import it:
import { CrxClient, createPlayerClient } from "@ferdiunal/open-play-ts";
Warning
The package name is
@ferdiunal/open-play-ts. The name@open-play/sdkthat 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.
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
baseUrlmust 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
Authorizationheader; it does not appear in error objects or debug output. - Retries are made only for read
GETs and for commands that carry anIdempotency-Keythat was actually provided. Transient408,429,500,502,503,504and, for a safe operation, a network error are retried with bounded exponential backoff;Retry-Afteris within the total time limit.409and422are 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) andIdempotency-Keyheaders (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:
| Member | What does it do? |
|---|---|
session | Sign-in, session state, refresh, sign-out: Authentication |
plugin(binding) · publicPlugin(binding) | Plugin commands/queries/modules: Plugin bindings |
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 |
notifications | Device 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 / 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) |
| 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) |
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).
Numbers and JSON
i64/u64fields arebigint; 32-bit fields are bounds-checkednumber.9007199254740993nis carried losslessly.- Do not round-trip the SDK payload through
JSON.parse/JSON.stringify; useparseJson,stringifyJson,encodeModel,decodeModel. Decimal numbers becomeLosslessNumber. - Default limits: JSON 2 MiB, depth 96, node count 200,000.
- In the plugin protocol,
i64values 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.