# Rudder Documentation Full Rudder documentation, concatenated into a single file for LLM consumption. An index of individual pages is available at https://rudder.build/llms.txt. --- # Rudder URL: https://rudder.build/docs/ Rudder is a LiveOps platform for games: player accounts, wallets, storage, and installable modules for items, stores, quests, battle passes, leaderboards, remote config, and more. Start with [Getting Started](/docs/getting-started/), explore the feature guides, or jump straight to your SDK. --- # Projects & Environments URL: https://rudder.build/docs/concepts/projects-and-environments/ ## Projects A **project** is the top-level container in Rudder. One project corresponds to one game: it holds all players, all authored content (counters, installed modules and their resource rows), and all keys. Projects are created in the [dashboard](https://app.rudder.build/) (**Projects → Create Project**). The creator becomes the project owner; other dashboard users can be added as members. Every dashboard and admin API operation checks project membership, so members of one project cannot see or modify another. ## Environments Every project has exactly **two** environments, and only two: - **`staging`** — the authoring environment and a complete sandbox. All day-to-day content work happens here, and it has its own players. - **`prod`** — the live environment your shipped builds point at. Content in `prod` is read-only in the dashboard; it changes only through a release or a promote (see [Releases & Publishing](/docs/concepts/releases-and-publishing/)). You cannot create, rename, merge, or delete environments. There are no custom environments — one sandbox and one live environment is the whole model. ### Keys Each environment has its own pair of keys, shown under **Project Settings** for whichever environment the dashboard switcher is on: - **SDK key** — identifies the project *and* the environment at player login. Safe to embed in a shipped game client; it grants access only to the player-facing `/sdk/v1` API. - **Admin key** — grants full server-side admin access for that environment (the `/game/v1` HTTP API with `X-API-Key`, called from your game servers). Treat it as a secret and never ship it in a client. The key *is* the environment selector. There is no environment parameter in the client API: the SDK key is resolved at login and the environment is stamped into the player's access and refresh tokens, and an admin key resolves to the environment it was issued for. Point a development build at the staging SDK key and a shipped build at the prod one, and give your staging game servers the staging admin key. Both keys can be rotated from Project Settings (**Generate**). Rotation takes effect immediately and the previous key is rejected from that moment — there is no grace window, so rotate only when you can deploy the new key. ### What is isolated Almost everything. An environment is a full copy of the game, not just a content branch: - **Content** — counters, module installs and resource rows each belong to one environment. Editing in `staging` never touches `prod`. - **Players** — a player belongs to the environment they logged in through. The same device ID or the same custom-auth `subject` produces two unrelated accounts under the staging key and the prod key. - **Player data** — wallets, counters, player storage and player-scoped resource rows (inventory, quest progress, battle pass progress, purchases, leaderboard entries) all hang off the player, so they are isolated too. - **Project storage and module logs** — stored per environment. - **Custom auth** — the webhook URL and secret are configured per environment, so staging can point at your test backend. Nothing crosses over. There is no way to migrate a player between environments; `staging` is where you break things without consequences. ### What is *not* per environment Project membership, billing, and your monthly active user count. MAU is measured on `prod` players only — testing in `staging` never costs you anything. See [Pricing](/docs/pricing/). ### What needs a release Module installs and resource rows are live in their environment as soon as you save them. Counters are resolved from the environment's latest completed release: an environment that has never had one has no currencies in wallets and ignores counter increments. That is not an error — publish a release in `staging` and counters start working. Scheduled module functions run in both environments, each against its own installs and rows, so you can test schedules in `staging`. See [Releases & Publishing](/docs/concepts/releases-and-publishing/). ## Typical workflow 1. Install modules and author content in `staging`; publish a staging release when counters change. 2. Test with a build using the staging SDK key and a staging player. 3. Promote `staging` → `prod` to ship it. Resource rows keep their ids and counters and installs are matched by slug, so live players keep their progress. 4. Rotate keys from Project Settings if one leaks. Next: [Releases & Publishing](/docs/concepts/releases-and-publishing/). --- # Releases & Publishing URL: https://rudder.build/docs/concepts/releases-and-publishing/ ## What is live and what needs a release A project has two environments, `staging` and `prod`. Most changes take effect in their environment as soon as you save them: - **Module installs** — installing, upgrading, configuring, enabling or ejecting a [module](/docs/guides/modules/) changes what the environment runs right away. - **Resource rows** — items, offers, quests, boards, configs and every other [resource](/docs/guides/resources/) row are read live by module functions. **Counters** are the exception. The runtime resolves counter slugs from the environment's latest release, so a new counter does nothing (an increment of an unknown slug is silently ignored) until a release that contains it has completed. Because edits are live, you author in `staging`, test there with staging players, and ship to players by [promoting](#promotion) to `prod`. The dashboard shows `prod` as read-only. ## What a release is A release belongs to one project and one environment and carries: - a **version**, incremented per project and environment (`v1`, `v2`, …); - an optional **description** and **notes**; - a **status**: `building`, then `completed` or `failed`; - a **snapshot**: a JSON document stored as `projects///v.json`, also written to `projects///latest.json`. A snapshot contains: - counters (archived counters excluded); - module installs: slug, pinned version, config, enabled and ejected flags; - project-scoped resource rows (rows of resources with scope `project`). Player data is never part of a snapshot: players, wallets, player storage and player-scoped resource rows such as inventory or quest progress. When a release completes, the backend reloads the environment's counters from the new `latest.json` and bumps the catalog revision, so connected SDKs pick up new currencies on their next sync. ## Promotion **Promote** copies `staging` into `prod` in one transaction: 1. Counters, module installs and project-scoped resource rows from `staging` are created in `prod`, or updated in place when `prod` already has them. Counters and installs are matched by slug; resource rows are matched by resource slug and row id, and keep the same id in both environments. 2. Anything in `prod` that is missing from `staging` is deleted. Deleting a row or uninstalling a module in `staging` and promoting is how you remove it from production. 3. A new `prod` release is then built asynchronously. Player-scoped rows in `prod` are not touched, so player progress that references content by slug or id keeps working after a promote. If promotion fails, `prod` is left unchanged. ## Rollback **Rollback** restores an environment to a previous release: 1. Pick a `completed` release of the same project that still has its snapshot. 2. The environment's counters, module installs and project-scoped rows are replaced with the snapshot contents in one transaction. 3. `latest.json` is overwritten and a *new* release row is created, so the rollback shows up in the history and can itself be rolled back. Rollback restores content, not player data: rewards already granted stay granted. ## Typical lifecycle 1. Install modules and author rows in `staging`; test with a staging SDK key. 2. Publish a staging release when you add or change counters. 3. Promote `staging` → `prod`. 4. If something is wrong in production, roll back `prod`, fix in `staging`, and promote again. For the dashboard walkthrough, see the [Releases guide](/docs/guides/releases/). For the environment model, see [Projects & Environments](/docs/concepts/projects-and-environments/). --- # Dashboard Overview URL: https://rudder.build/docs/dashboard/overview/ The Rudder dashboard at [app.rudder.build](https://app.rudder.build/) is where you author all of your game's content, manage players, and control releases. Everything you can do in the dashboard is also available through the platform API, but this page walks through what you see in the UI. ## Signing up Register with your email and a password (the name field is optional). After signing up you land on a **confirm your email** screen — we send a confirmation link to your inbox. Open the link to activate your account; it signs you in and takes you straight to the **Projects** list. The link is valid for 24 hours, and you can resend it from the confirmation screen. ## Projects A project is one game. The **Projects** page lists all projects you belong to, with your role on each. From here you can: - **Create Project** — give it a name and optional description, and invite team members. - **Open** — enter the project's workspace. - **Edit / Delete** — available if your role is `owner` or `admin`. Deleting a project soft-deletes it along with all associated data, so the UI asks for confirmation. ## Inside a project Opening a project takes you to its workspace: a sidebar on the left, a topbar on top. The sidebar is grouped into sections: - **Main** — Dashboard. - **LiveOps** — Counters, Modules, Calendar. - **Users & Config** — Players, Storage. - **Deployment** — Releases, SDK. - **System** — Settings. Below these, every installed module adds its own section with its pages and a **Settings** entry (for example **Leaderboards → Boards** or **Store → Offers**). The topbar holds, left to right: - **Breadcrumbs** for the current page. - **Environment selector** (see below). - **Plan badge** showing your pricing plan and current MAU usage as a percentage. It turns into a warning state as you approach the limit; click it to open project settings. - **Quick navigation** (`⌘K` on macOS, `Ctrl+K` elsewhere) — a command palette for jumping between pages. - A **light/dark theme** toggle and the **user menu** (account settings, logout). ## Environments Every project has exactly two environments: **staging** and **prod** (shown as "Production"). Content — counters, module installs, resource rows, releases — is authored per environment, and so are players, wallets and storage. The environment selector in the topbar controls which one you are looking at, including on the Players and Project Storage pages. Key behaviors: - **prod is read-only.** When the production environment is selected, authoring UI (create/edit/delete buttons) is disabled. Changes reach production only through a release promotion (see Releases below). - **No custom environments.** Environments cannot be created, merged, or deleted — two is the whole model, and `staging` is a full sandbox with its own players. - **Keys are per environment.** Project Settings shows the SDK key and the admin key of the selected environment, each regenerated independently. See [Projects and Environments](/docs/concepts/projects-and-environments/) for how environments map to the API. ## Feature areas ### Dashboard The project home page. It shows DAU/MAU stat cards with change indicators, your pricing plan and MAU quota, a retention chart, a calendar of scheduled module rows for the next 14 days, your installed modules, and quick links into the main pages. ### Counters `Sidebar → LiveOps → Counters`. Counters are named player-level integers used for currencies, XP and progress. Each has a name, a slug and a kind (`currency` or `number`); slug and kind are fixed after creation. Counters take effect in the runtime after a release. See [Counters](/docs/guides/counters/). ### Modules `Sidebar → LiveOps → Modules`. Lists installed modules and the marketplace. Installing a module also offers to install its dependencies. Each installed module gets a sidebar section built from its manifest: resource pages (tables, cards, timelines, tracks, results), detail pages with widgets, and a **Settings** page where you update, enable or disable, eject, uninstall the module and download its source. **Logs** shows module console output and failures for the last 7 days. See [Modules](/docs/guides/modules/) and [First-party modules](/docs/guides/first-party-modules/). ### Calendar `Sidebar → LiveOps → Calendar`. A timeline of the rows of enabled modules that have a schedule field, such as store offers, quests, battle pass seasons and remote config overrides. ### Players `Sidebar → Users & Config → Players`. Search players by ID or nickname, or browse the paginated list. Opening a player shows a detail page with: - Profile fields: player ID, nickname, region, language, payer status, ban status, created/ updated timestamps. - **Wallet** tab — current balances and recent transactions per currency. - **Storages** tab — the player's storage documents, with type and expiry. - **Custom Data** tab — the raw player JSON, read-only. - **Linked Accounts** tab — the identities linked to the player (device, Google, Apple, or custom providers). - Widgets added by installed modules, for example the inventory module's item table with **Grant item** and **Remove item** actions. From the detail page you can **ban** a player (with a required reason), **unban**, **adjust wallet** balances (amount plus a required reason), and **delete** the player. Deleting anonymizes the player and cannot be undone. Every adjustment asks for a reason, which is recorded for audit. ### Storage `Sidebar → Users & Config → Storage`. Global (project-level) key-value storage — typed documents shared across all players, as opposed to the per-player storages on the player detail page. Create, edit, and delete entries here. ### Releases `Sidebar → Deployment → Releases`. Releases snapshot your environment's counters, module installs and project-scoped resource rows. The page lists releases for the currently selected environment with version number, status, description, included content versions, and creation date. - **Publish** (staging only) snapshots the current environment. Only one release can be building at a time — the button is disabled while a release is being built. - **Promote** copies a completed release from one environment to another (e.g. staging → prod). After promoting, the dashboard switches you to the target environment so you see the result immediately. - **Rollback** creates a new release with the content of an older version. See [Releases and Publishing](/docs/concepts/releases-and-publishing/) for the underlying model. ### SDK `Sidebar → Deployment → SDK`. Step-by-step installation instructions for each client SDK — npm (`@rudder/sdk`), Unity Package Manager (`build.rudder.sdk`), and NuGet (`Rudder.Sdk`) — including the private registry configuration. Use this page when wiring a new game client to the project. ### Settings `Sidebar → System → Settings`. Project-level configuration: - **Project Information** — rename the project, and manage the two API keys: - **SDK Key** — authenticates game clients in the selected environment. - **Admin Key** — secret for server-side integrations in the selected environment; never ship it in a game client. - Both keys have **Copy** and **Generate** buttons. Generating a key persists immediately and invalidates the previous key on the spot — there is no grace period. - **Authentication** — configure the sign-in providers available to your players: - **Google** and **Apple** — each with an enable toggle and a list of client IDs (one per line). - **Custom Providers** — point Rudder at your own auth backend. You can add multiple named providers, each with an enabled toggle, a webhook URL, and an HMAC secret (used for the `X-Signature` header). The secret's **Generate** button only fills in a random value locally; nothing is saved until you click **Save**. - **Pricing and Usage** — your current plan and MAU consumption. - **Danger Zone** — delete the project (owner only). ## Account settings The user menu in the topbar (top right) opens `/settings`, which manages **Personal Access Tokens** for your account. PATs authenticate API access on your behalf. You can create and revoke tokens there; revoking takes effect immediately and cannot be undone. ## Good to know - Almost all content pages are scoped to the **current environment** — if you can't find an item you just created, check the environment selector first. - If authoring controls look disabled, you are probably looking at **prod**. Switch to staging to make changes, then promote a release. - The dashboard talks to the same API (`https://api.rudder.build`) that the SDKs use, so anything you do here is reproducible programmatically. --- # Getting Started URL: https://rudder.build/docs/getting-started/ import { Tabs, TabItem } from '@astrojs/starlight/components'; This guide takes you from an empty account to a signed-in player in about ten minutes: create a project in the dashboard, copy the SDK key, install the client SDK, log in with a device ID, and make your first protected call. ## Base endpoints | Endpoint | URL | | --- | --- | | API (game clients and game servers) | `https://api.rudder.build` | | Dashboard | `https://app.rudder.build/` | ## 1. Create a project 1. Sign up at [app.rudder.build](https://app.rudder.build/) with your email and password. New accounts start in a *pending* state and are unlocked once approved. 2. Open the **Projects** page and click **Create Project**. A name is all you need. A project is the top-level container for everything: players, counters, installed modules and their data, and keys. Creating a project automatically creates its two environments — `staging` and `prod` — each with its own SDK key and its own admin key. A project always has exactly these two. You author content in `staging`, where you also get a full sandbox with its own players, and promote it to `prod`, which is what your shipped build reads. See [Projects & Environments](/docs/concepts/projects-and-environments/) for the full model. ## 2. Get your SDK key Open your project and go to **Project Settings**. The **SDK Key** field shows the key for the environment currently selected in the dashboard's environment switcher — switch between `staging` and `prod` to see each key. The SDK key is safe to ship in a game client: it identifies the project and environment, and it is used only at login. If a key leaks, use **Generate** next to it to rotate it — the old key stops working immediately. Project Settings also shows an **Admin Key**, likewise per environment. That one is a secret for server-side admin calls to the `/game/v1` HTTP API — the key decides which environment your server acts on, and it must never go into a shipped client. ## 3. Install the SDK The package is published to the Rudder registry, not npmjs. Point the `@rudder` scope at it in your project's `.npmrc`: ``` @rudder:registry=https://hub.rudder.build/api/packages/rudder/npm/ ``` Then install (anonymous read access, no token needed): ```bash npm install @rudder/sdk ``` ```bash dotnet nuget add source https://hub.rudder.build/api/packages/rudder/nuget/index.json --name rudder dotnet add package Rudder.Sdk --version 0.1.0 ``` Targets `netstandard2.1`, so it works in .NET, Unity, and Xamarin. For Unity, install the `build.rudder.sdk` UPM package instead — see the [Unity SDK](/docs/sdk/unity/) page. ## 4. Initialize the client and log in ```ts import { createClient } from '@rudder/sdk'; const client = createClient({ projectKey: 'your-sdk-key', // the SDK key from Project Settings }); // Signs the player in with a device ID. The ID is generated on first call // and persisted in localStorage, so the same browser keeps the same player. await client.auth.loginWithDevice(); // optionally: loginWithDevice({ region: 'eu', language: 'de', nickname: 'Bob' }) ``` ```csharp using RudderSdk; var client = new RudderClient(new RudderClientOptions { BaseUrl = "https://api.rudder.build", ProjectKey = "your-sdk-key", // the SDK key from Project Settings }); // Signs the player in with a device ID. await client.Auth.LoginWithDeviceAsync(region: "global", language: "en"); ``` The default `DeviceIdProvider` generates a random GUID per client instance. To keep the same player across app restarts, inject your own `IDeviceIdProvider` that persists the ID. (The Unity package does this for you via PlayerPrefs.) Device login needs no player credentials: the first call with a new device ID creates the player, later calls sign that player back in. Access and refresh tokens are stored by the SDK and refreshed automatically on expiry — you don't handle tokens yourself. Other login options (a custom webhook against your own backend) are covered in [Authentication](/docs/guides/authentication/). ## 5. Make your first protected call Everything except login requires a signed-in player. Read the player's profile, wallets and counters: ```ts const profile = await client.player.load(); console.log(profile.player?.id, profile.wallets, profile.counters); // `client.player` is a state object: subscribe to updates, or read `.value` // for the cached value after login. client.player.onChange(({ value }) => console.log(value?.player?.nickname)); ``` ```csharp var profile = await client.Player.LoadAsync(); Console.WriteLine(profile.Player?.Id); // Keep state fresh: poll revisions in the background (Unity does this for you). _ = client.Sync.RunAsync(CancellationToken.None); ``` If the access token has expired, the SDK refreshes it and retries the call once, transparently. ## 6. Ship content to prod Module installs and resource rows (items, offers, quests, boards, configs) are live in their environment as soon as you save them; counters take effect after a **release**. Author and test in `staging`, then **promote** to `prod`, which is what your shipped build reads. For a first end-to-end test: install the `remote_config` module in `staging`, create a config, generate a typed client with `rudder client generate` (see [Generated module clients](/docs/guides/module-clients/)), and call `remoteConfig.get` using the staging SDK key. The full flow is described in [Releases & Publishing](/docs/concepts/releases-and-publishing/) and the [Releases guide](/docs/guides/releases/). ## Where to go next - [Projects & Environments](/docs/concepts/projects-and-environments/) — how projects, environments, and keys fit together. - [Releases & Publishing](/docs/concepts/releases-and-publishing/) — snapshots, promotion, and rollback. - [Authentication](/docs/guides/authentication/) — device and custom webhook login, bans, token lifecycle. - Feature guides: [Modules](/docs/guides/modules/), [Generated module clients](/docs/guides/module-clients/), [First-party modules](/docs/guides/first-party-modules/), [Wallet & Inventory](/docs/guides/inventory-and-wallet/), [Counters](/docs/guides/counters/). - SDK references: [TypeScript](/docs/sdk/typescript/), [C#](/docs/sdk/csharp/), [Unity](/docs/sdk/unity/). Game servers call the `/game/v1` HTTP API directly. --- # Authentication URL: https://rudder.build/docs/guides/authentication/ import { Tabs, TabItem } from '@astrojs/starlight/components'; Players authenticate against the Rudder API (`https://api.rudder.build`) and receive a JWT pair. This is separate from dashboard users — your game's players never have accounts on the platform. There are four login methods: - **Device login** — passwordless login keyed by a device identifier. The default for most games. - **Google login** — the player signs in with Google; the platform verifies the ID token. - **Apple login** — the player signs in with Apple; the platform verifies the ID token. - **Custom login** — the platform calls an HTTP webhook on your backend to verify the player's identity. Use this when you already have your own account system (or several — named providers are supported). See [Custom Auth Webhook](../../webhooks/custom-auth/) for the full contract. Every login binds an **identity** `(provider, subject)` to the player. One player can carry several identities — see [Linking and unlinking identities](#linking-and-unlinking-identities). ## SDK key Every login request carries your project's **SDK key**. The key identifies both the project and the environment — a project has exactly two, `staging` and `prod`, and each has its own key. You find them in the dashboard under **Project Settings** (https://app.rudder.build/), for whichever environment the switcher is on. The key is the only place the environment appears. Nothing in the client API takes an environment argument: the key is resolved at login and the environment is written into the tokens the player gets back. That also means a build pointing at the staging key can never touch production players — the same device or the same custom-auth subject creates two completely separate accounts under the two keys. See [Projects & Environments](/docs/concepts/projects-and-environments/). ```ts import { createClient } from '@rudder/sdk'; const client = createClient({ projectKey: 'YOUR_SDK_KEY', }); ``` ```csharp using RudderSdk; var client = new RudderClient(new RudderClientOptions { BaseUrl = "https://api.rudder.build", ProjectKey = "YOUR_SDK_KEY", }); ``` ## Device login `POST /sdk/v1/authorization/device` with body `{key, deviceId, region?, language?, nickname?}`. The first login with a given device ID creates the player and binds the identity `(device, deviceId)` to them. Subsequent logins with the same device ID return the same player. If the player is banned or deleted, login is rejected. ```ts // The device ID is auto-generated (crypto.randomUUID()) on first call and // persisted in localStorage under "rudder_device_id". const { accessToken, refreshToken } = await client.auth.loginWithDevice({ region: 'eu', // default: "global" language: 'en', // default: "en" nickname: 'Alice', // optional }); ``` ```csharp var session = await client.Auth.LoginWithDeviceAsync( region: "eu", language: "en", nickname: "Alice"); // optional ``` Behavior notes: - **Nickname** is only applied when the player doesn't have one yet — device login never overwrites an existing nickname. - Every login — new or returning player — records activity metrics. On a brand-new player, login additionally emits the `player.created` kernel event, which [module](/docs/guides/functions/#kernel-api) event functions can handle. For returning players, `lastSeen` is touched separately (throttled to one write per 5 minutes). - **C# device ID caveat:** the default `GuidDeviceIdProvider` generates a new GUID per `RudderClient` instance and does not persist it. If you don't provide your own `IDeviceIdProvider` (e.g. backed by `PlayerPrefs` or a file), every app restart creates a new player. The TypeScript SDK persists the device ID in `localStorage` out of the box. ## Google login `POST /sdk/v1/authorization/google` with body `{key, idToken, region, language, nickname?}`. The player signs in with Google on your side (Google Sign-In / Google Identity Services) and hands the resulting ID token to the SDK. The backend verifies it against Google's public keys (JWKS) — signature, expiry, issuer, and audience: the token's `aud` must match one of the client IDs you configured. The verified `sub` claim becomes the identity subject: the first login with a given Google account creates the player, later logins return the same player. ```ts const { accessToken, refreshToken } = await client.auth.loginWithGoogle({ idToken, // ID token from Google Sign-In region: 'eu', language: 'en', nickname: 'Alice', // optional }); ``` ```csharp var session = await client.Auth.LoginWithGoogleAsync( idToken, region: "eu", language: "en", nickname: "Alice"); // optional ``` Configure it per environment in the dashboard under **Project Settings → Authentication**: enable Google and list your OAuth client IDs (one per app — web, Android and iOS clients each have their own). When the provider is disabled or not configured, login fails with `401 auth provider disabled`; a token that fails verification fails with `401 invalid provider token`. ## Apple login `POST /sdk/v1/authorization/apple` with body `{key, idToken, region, language, nickname?}`. Identical to Google login, with Sign in with Apple tokens: the backend verifies the ID token against Apple's JWKS (issuer `https://appleid.apple.com`) and uses `sub` as the subject. ```ts const { accessToken, refreshToken } = await client.auth.loginWithApple({ idToken, // identity token from Sign in with Apple region: 'eu', language: 'en', }); ``` ```csharp var session = await client.Auth.LoginWithAppleAsync( idToken, region: "eu", language: "en"); ``` Configure it next to Google in **Project Settings → Authentication**: enable Apple and list the client IDs you accept (your app's bundle ID, and the services ID for web sign-in). The same `401 auth provider disabled` / `401 invalid provider token` errors apply. ## Custom login `POST /sdk/v1/authorization/custom` with body `{key, provider?, customData, region, language, nickname?}`. The platform forwards `customData` to the webhook URL configured for the environment, and your backend decides who the player is by returning a `subject`. The full request/response contract, signature verification, and retry semantics are documented on the [Custom Auth Webhook](../../webhooks/custom-auth/) page. `provider` selects which configured custom provider handles the request: - **omitted (or `"default"`)** — the default provider. The identity is bound as `(custom, subject)`. - **any other name** — a named provider. The identity is bound as `(custom:, subject)`, so each provider gets its own subject namespace. ```ts await client.auth.loginWithCustom({ customData: { sessionId: 'your-backend-session-token' }, region: 'eu', language: 'en', }); // Named provider (must be configured in the dashboard): await client.auth.loginWithCustom({ provider: 'steam', customData: { ticket: 'steam-session-ticket' }, region: 'eu', language: 'en', }); ``` ```csharp using Newtonsoft.Json.Linq; await client.Auth.LoginWithCustomAsync( new JObject { ["sessionId"] = "your-backend-session-token" }, region: "eu", language: "en"); // Named provider (must be configured in the dashboard): await client.Auth.LoginWithCustomAsync( new JObject { ["ticket"] = "steam-session-ticket" }, region: "eu", language: "en", provider: "steam"); ``` Custom login must be enabled per environment in **Project Settings → Authentication**, otherwise the call fails with `401 custom auth disabled`. ### Named custom providers You can configure several custom providers per environment — one per external account system, say `steam` and `epic`. Each provider has its own webhook URL and secret, and its own identity namespace: the same subject under `custom:steam` and `custom:epic` is two different identities, which can even be [linked](#linking-and-unlinking-identities) to the same player. Provider names must match `^[a-z0-9][a-z0-9_-]{0,31}$` (lowercase letters, digits, `_`, `-`, at most 32 characters), and `google`, `apple`, `device` are reserved. The webhook contract is the same for every provider — see [Custom Auth Webhook](../../webhooks/custom-auth/). ## Linking and unlinking identities A player account can carry several identities — one per provider. The typical flow: log the player in with any method (usually device login on first launch), then link the other providers from the same session. Later, a login through any linked identity lands on the same player — this is how an anonymous device-only account becomes a permanent one that survives reinstalls and device switches. Linking requires the player's access token (`Authorization: Bearer `): - `POST /sdk/v1/authorization/google/link` with `{idToken}` → `204` - `POST /sdk/v1/authorization/apple/link` with `{idToken}` → `204` - `POST /sdk/v1/authorization/custom/link` with `{provider?, customData}` → `204` Google and Apple links verify the token exactly like the login endpoints. A custom link calls the provider's webhook with the same contract — the returned subject is attached to the current player instead of resolving a session, and a `data` payload is still written to the player's storage. ```ts // Logged in already (any method), then link: await client.auth.linkWithGoogle(idToken); await client.auth.linkWithApple(idToken); await client.auth.linkWithCustom({ customData: { sessionId: '...' } }); await client.auth.linkWithCustom({ provider: 'steam', customData: { ticket: '...' } }); // Detach an identity: await client.auth.unlinkIdentity('google'); ``` ```csharp // Logged in already (any method), then link: await client.Auth.LinkWithGoogleAsync(idToken); await client.Auth.LinkWithAppleAsync(idToken); await client.Auth.LinkWithCustomAsync(new JObject { ["sessionId"] = "..." }); await client.Auth.LinkWithCustomAsync(new JObject { ["ticket"] = "..." }, provider: "steam"); // Detach an identity: await client.Auth.UnlinkIdentityAsync("google"); ``` Conflict rules: - `409 identity already linked` — the identity is already bound to **another** player (someone else owns that Google account or custom subject), or the current player already has that provider linked. One provider per player, one player per identity. - Unlink takes the full provider string: `device`, `google`, `apple`, `custom`, or `custom:`. - `404 identity not linked` — the player has no identity for that provider. - `400 cannot unlink last identity` — the last remaining identity cannot be removed; the player would become unreachable. Support can also unlink identities from the dashboard: the player page → **Linked Accounts** → Unlink. ## Tokens A successful login returns two HS256-signed JWTs: - **Access token** — valid for **24 hours**. Claims: `id` (player ID), `projectId`, and `environment`. Sent as `Authorization: Bearer ` on every SDK request. - **Refresh token** — valid for **7 days**. Claims: `id` and `environment`. Exchanged for a new token pair via `POST /sdk/v1/authorization/refresh` with body `{refreshToken}`. The `environment` claim is required. A token without it is rejected with `401`. Both login and refresh return a fresh pair — store both tokens from every response. ### Storage - **TypeScript:** tokens are persisted in `localStorage` under `rudder_access_token` / `rudder_refresh_token` (with a silent in-memory fallback where localStorage is unavailable, e.g. SSR or private mode — sessions then live only for the page session). Pass your own `tokenStore` in `RudderClientOptions` to use another backend. - **C#:** the default `InMemoryTokenStore` keeps tokens in memory only — **sessions do not survive an app restart**. Implement `ITokenStore` (`GetAccessToken` / `GetRefreshToken` / `SaveTokens` / `Clear`) backed by persistent storage for production builds. ### Refresh flow Both SDKs refresh automatically: when any request returns `401`, the client performs a single-flight refresh (concurrent requests share one refresh call) and retries the original request once. If the refresh fails — expired refresh token, a token with no `environment` claim, or the player was banned/deleted in the meantime — the stored tokens are cleared and the session ends. Handle the signed-out state by logging the player in again; that is the whole recovery path. ```ts // Refresh is automatic. Observe the session state instead: const unsubscribe = client.auth.onAuthStateChange((state) => { // state: 'signed-in' | 'signed-out' — fires immediately with current state if (state === 'signed-out') showLoginScreen(); }); client.auth.logout(); // clears tokens and stops the runtime ``` ```csharp // Refresh is automatic on 401. You can also force it: bool renewed = await client.Auth.RefreshAsync(); // false = session is dead, tokens cleared client.Auth.AuthStateChanged += state => { // RudderAuthState.SignedIn / RudderAuthState.SignedOut }; client.Auth.Logout(); // drops the stored session ``` ## Player profile `GET /sdk/v1/player/information` returns the profile: ```json { "player": { "id": "…", "projectId": "…", "nickname": "Alice", "region": "eu", "language": "en", "createdAt": "2026-01-01T00:00:00Z" }, "wallets": [], "identities": [ { "provider": "device", "subject": "…", "createdAt": "2026-01-01T00:00:00Z" }, { "provider": "google", "subject": "1080123456789…", "createdAt": "2026-01-02T00:00:00Z" } ] } ``` `identities` lists every identity linked to the player — `provider` is one of `device`, `google`, `apple`, `custom`, `custom:`, and `subject` is the provider-side identifier (device ID, Google/Apple `sub`, or the subject your webhook returned). ```ts // client.player is a state object, loaded automatically after login. await client.player.load(); console.log(client.player.value?.player?.nickname); ``` ```csharp var profile = await client.Player.LoadAsync(); Console.WriteLine(profile.Player.Nickname); ``` ## Bans and deleted players A player is **banned** when `bannedAt` is set and `bannedUntil` is either empty (permanent ban) or in the future. A **deleted** player is anonymized (nickname removed, data wiped, all identities unlinked) — deletion is irreversible. Bans and deletions are enforced everywhere: - Login and token refresh are rejected (`403 player banned` / `403 player deleted`). - Every authenticated SDK endpoint re-checks the player on each call, so a ban takes effect immediately for active sessions. - Both SDKs surface this as a failed refresh → signed-out state. Ban and unban players from the dashboard's Players page, or from your server via the admin API (`POST /game/v1/players/ban` with the `X-API-Key` admin key for that environment, body `{items: [{playerId, reason?, bannedUntil?}]}`; omit `bannedUntil` for a permanent ban). Your backend can also introspect a player's access token without trusting the client: `POST /game/v1/players/auth/verify` (`X-API-Key` auth, body `{accessToken}`) returns `{playerId, projectId, environment, status}` where status is `active`, `banned`, or `deleted`. The admin key you use decides the environment, and a token from the other environment does not verify. ## Error reference Login endpoints return these errors: | HTTP | Message | Cause | | --- | --- | --- | | `400` | `key is required` / `deviceId is required` / `idToken is required` / `customData is required` / … | Missing request fields (device login requires only `key` and `deviceId`; `region` and `language` are required only for Google/Apple/custom login) | | `400` | `invalid provider name` | Custom provider name doesn't match `^[a-z0-9][a-z0-9_-]{0,31}$` | | `401` | `invalid project key` | Unknown SDK key | | `401` | `auth provider disabled` | Google/Apple login not enabled or not configured for this environment | | `401` | `invalid provider token` | The Google/Apple ID token failed verification (signature, expiry, issuer, or audience) | | `401` | `custom auth disabled` | The (named) custom provider is not enabled for this environment | | `401` | `custom auth rejected` | Your webhook answered 4xx | | `401` | `custom auth invalid data` | The webhook's `data` payload breaks storage limits | | `403` | `player banned` | Player is banned | | `403` | `player deleted` | Player was deleted | | `502` | `custom auth invalid response` | Webhook returned 200 with a malformed body or empty `subject` | | `503` | `custom auth unavailable` | Webhook unreachable / 5xx after all retries | Link and unlink endpoints return the same provider errors, plus: | HTTP | Message | Cause | | --- | --- | --- | | `400` | `cannot unlink last identity` | Tried to unlink the player's last remaining identity | | `404` | `identity not linked` | No identity for that provider on this player | | `409` | `identity already linked` | Identity belongs to another player, or the player already has that provider | Token refresh returns `401 invalid refresh token` for an expired or malformed refresh token, and the same `403` errors for banned/deleted players. --- # Counters URL: https://rudder.build/docs/guides/counters/ ## What is a counter? A counter is a **named, per-player integer** identified by a stable slug like `coins`, `xp`, or `boss_kills`. Counters are part of the kernel, and modules reference them by slug: - A store offer's **price** is paid in a `currency` counter. - A quest **objective** of type `counter` adds up a counter's increments. - A **reward** (granted by the `reward` module) can credit currencies and counters. - A battle pass season maps counters to XP (`xpSources`). Crediting a counter is one server-side operation: it updates the value (or the wallet balance for currencies) and emits the `counter.incremented` event in the same transaction, which is how quests and the battle pass advance. ## Kinds: currency, number Every counter has a **kind**, chosen at creation and immutable: | Kind | Where player values live | Visible in wallet | Audit trail | | --- | --- | --- | --- | | `currency` | Player wallet | Yes (balance in player profile) | Yes, every change is logged | | `number` | Separate counter values | No (in `counters` of the player profile when visible to game clients) | No | The kind only controls storage. Pick `currency` for money the player spends; pick `number` for progress numbers such as wins or XP. A `number` counter can be marked **increment only** — a dedicated flag on the counter, next to name, slug, and kind. Any negative delta against such a counter is rejected with an error instead of debiting it. The flag has no effect on `currency` counters. ## Authoring counters In the dashboard at `https://app.rudder.build/`, open your project and go to **Counters**: - **Name** — display name used in dashboard dropdowns. - **Slug** — the identity every feature references. Derived from the name (lowercased) if left empty. Must match `^[a-z][a-z0-9_:-]{0,63}$` and is unique per project and environment. **Immutable after creation.** - **Kind** — `currency` or `number`. Also immutable after creation. - **Increment only** — `number` counters only. Rejects every debit. Editable after creation. - **Visible to game clients** — `number` counters only, on by default (`clientVisible` in the API). Visible counters appear in the player profile's `counters`; hidden ones stay available to modules only. Editable after creation. Other rules: - Counters are edited in the **staging** environment; `prod` changes through [promotion](/docs/guides/releases/#promote-to-prod). A counter only takes effect in the runtime after a [release](/docs/guides/releases/) that contains it has completed. - **Delete archives** a counter. Archived counters stay listed in the dashboard but are excluded from new releases. Existing player values (wallet balances for archived currencies) are preserved. ## How the runtime applies counters Counters change only inside module functions (`Counters.Increment` in the module SDKs, or `reward.grant`) and through admin wallet adjustments. There is no SDK endpoint that increments a counter directly; game clients call a module function that does it. When a counter is incremented, the server: 1. Looks the slug up in the environment's latest release. **Unknown slug → silent no-op** with a warning in the server logs, not an error. This is why unreleased counters "don't work" without failing. 2. Routes the write: `currency` kind updates the wallet (with an audit entry); `number` kind updates a separate per-player value. 3. On positive deltas only, emits `counter.incremented` with the slug and delta in the same transaction. Module event handlers (for example `quests.onCounter` and `battlepass.onCounter`) run in savepoints: a failing handler is logged and does not fail the increment. Debits are guarded: a debit that would take the value below zero fails with `insufficient_funds`, and a `number` counter marked increment-only rejects every debit with `counter_only_increment`. ## Reading counter values Both kinds are part of the player profile (`GET /sdk/v1/player/information`), which the SDKs keep as the `player` state: - **Currency-kind** counters are in `wallets`, where every released currency appears, zero-filled if the player never received it. See [Wallet & Inventory](/docs/guides/inventory-and-wallet/). - **Number-kind** counters are in `counters`, a map of slug to value, when **Visible to game clients** is on. Hidden counters are left out; modules read them with `Counters.Get`. ```ts const wins = client.player.value?.counters?.wins ?? 0; ``` ```csharp var profile = await client.Player.LoadAsync(); long wins = 0; profile.Counters?.TryGetValue("wins", out wins); ``` Counter changes bump the profile revision, so SDK clients pick them up within one sync interval (about 30 seconds). Call `reload()` / `ReloadAsync()` on the player state after a module call when you need the new value right away. ## Edge cases and behavior to know - **Unreleased or misspelled slugs silently do nothing** at runtime (warn log server-side). If a reward doesn't land, check that the counter exists, is active, and is in the latest release. - **Slug and kind are frozen at creation.** To "rename" a currency, create a new counter and migrate: grant the new slug, archive the old one. --- # First-party modules URL: https://rudder.build/docs/guides/first-party-modules/ These modules are published to the marketplace by Rudder and written in Go. The current version of every first-party module is **5.0.0**. Install them from **Modules** in the dashboard; each one adds its own section to the sidebar. Game clients call their `client` functions through a [generated module client](/docs/guides/module-clients/); every function has typed arguments, a typed result and declared error codes. See [Modules](/docs/guides/modules/). | Module | Depends on | Resources | Client functions | | --- | --- | --- | --- | | `items` | — | `items` (project) | — | | `inventory` | `items` | `inventory` (player) | `list` | | `reward` | `inventory` | — | — | | `remote_config` | — | `configs`, `overrides` (project) | `get` | | `leaderboards` | `reward` | `boards` (project), `entries` (player) | `submit`, `top` | | `quests` | `reward` | `quests` (project), `quest_progress` (player) | `list`, `claim` | | `store` | `reward` | `offers` (project), `purchases` (player) | `list`, `buy` | | `battlepass` | `reward` | `seasons`, `tiers` (project), `battlepass_progress` (player) | `progress`, `claim`, `buyPremium` | Rewards everywhere use one shape, granted by `reward.grant`: `Reward` is `{currencies: {slug: integer}, items: {slug: number}, counters: {slug: integer}}`, always with all three maps. `insufficient_funds` is a kernel code: any function that debits a wallet can return it, and generated clients always include it. ## items Project resource `items` (`name`, `slug`, `icon`, `rarity`, `description`, `properties`, `tags`). The slug is filled from the name while the item is new and locked afterwards. The admin function `itemsCount` backs the dashboard stat. `items` has no client functions. ## inventory Player resource `inventory` (`itemSlug`, `amount`), updated with an optimistic version. The client function `list` returns the player's inventory with item display data. | Function | Trigger | Arguments | Result | Errors | | --- | --- | --- | --- | --- | | `list` | client | `{}` | `{items: [{itemSlug, amount, item: {slug, name, icon, rarity, description, properties, tags} \| null}]}`; `properties` is JSON text | | | `grant` | module | `{playerId, itemSlug, amount}` (`amount` 0 means 1) | `{itemSlug, amount}` | | | `consume` | admin | `{playerId, itemSlug, amount}` | `{itemSlug, amount}` | `insufficient_inventory` | | `adminGrant` | admin | `{playerId, input: {item, quantity}}` | `{itemSlug, amount}` | `invalid_input` | | `adminRemove` | admin | `{playerId, input: {item, quantity}}` | `{itemSlug, amount}` | `invalid_input`, `insufficient_inventory` | ## reward | Function | Trigger | Arguments | Result | | --- | --- | --- | --- | | `grant`, `adminGrant` | module, admin | `{playerId, reward: Reward}` | `{ok}` | `grant` applies a whole reward in one call and is callable by modules that depend on `reward`; `adminGrant` is the same operation for admins. ## remote_config Resources `configs` (`key`, typed `value`) and `overrides` (`config` by key, typed `value`, `segment`, `schedule`, `priority`). | Function | Arguments | Result | Errors | | --- | --- | --- | --- | | `get` | `{}` | `{values: {[key]: {type, value}}}` | kernel only | - For each config, the override with the highest priority whose schedule is active (or empty) and whose segment matches the player (or is empty) replaces the value. - `type` is `string`, `number`, `bool` or `json`. `value` is always a string: the string itself, decimal text for `int` and `float` configs (`"3"`, `"0.25"`), `"true"`/`"false"`, or compact JSON text. - Generated clients add helpers that parse values by key: `remoteConfigString/Number/Bool/Json` in TypeScript and `RemoteConfigValues.TryGetString/Number/Bool/Json` in C#. See [remote_config helpers](/docs/guides/module-clients/#remote_config-helpers). - An override value must have its config's type; the dashboard rejects mismatches. ```ts const config = await modules(client).remoteConfig.get({}); const multiplier = remoteConfigNumber(config, 'reward_multiplier') ?? 1; ``` ## leaderboards Resources `boards` (`name`, `slug`, `resetPeriod` `never`/`daily`/`weekly`/`monthly`, `order` `desc`/`asc`, `maxEntries`, `rewards` by rank range) and player resource `entries` (`board` by slug, `score`). | Function | Arguments | Result | Errors | | --- | --- | --- | --- | | `submit` | `{slug, score: number}` | `{slug, score}` with the stored best score | `board_not_found` | | `top` | `{slug, limit?: integer 0..1000}` | `{entries: [{rank, playerId, score}]}` | `board_not_found` | - `submit` keeps the player's best score: the highest for `desc`, the lowest for `asc`. Scores are JSON numbers, so fractional scores work. A numeric string is rejected by argument validation (`invalid_parameters`). - `top` returns entries in board order with `rank` from 1. `limit` defaults to 10 and is capped by `maxEntries`. - The `reset` schedule runs hourly in both environments. A board with a reset period is reset once the next boundary (00:00 UTC; Monday for weekly; the 1st for monthly) after its last reset has passed: players whose rank falls in a reward range get that reward, then all entries are deleted. - Admins can reset a board immediately from its detail page. See [Leaderboard Tournament](/docs/use-cases/leaderboard-tournament/) for a full example. ## quests Resources `quests` (`name`, `slug`, `status` `draft`/`active`/`archived`, `position`, `schedule`, `segment`, `objectives`, `next`, `reward`) and player resource `quest_progress` (`quest`, `status` `active`/`completed`/`claimed`, `objectives`, `claimed`). | Function | Arguments | Result | Errors | | --- | --- | --- | --- | | `list` | `{}` | `{quests: [Quest]}` | kernel only | | `claim` | `{slug}` | `{slug, reward: Reward}` (the granted reward) | `quest_not_found`, `already_claimed`, `quest_not_complete` | - `Quest` is `{slug, name, position, status, objectives: [{id, type, counter, offer, item, amount, progress}], next, reward}`. A quest without progress is `active` with `progress` 0; unused `counter`/`offer`/`item` and a missing `next` are `""`. - An objective of type `counter` adds the counter's increments; `purchase_offer` adds one per store purchase of `offer`; `purchase_item` adds one per store purchase whose reward contains `item`. A quest completes when every objective reaches its amount. - A quest is offered to a player when it is `active`, its schedule is active (or empty), its segment matches (or is empty), and it is not the `next` of another quest unless it was unlocked. - `claim` grants the reward of a completed offered quest, marks it claimed and unlocks `next`. - Progress advances on `counter.incremented` and `store.purchased` events. ## store Resources `offers` (`name`, `slug`, `image`, `position`, `price`, `reward`, `schedule`, `segment`, `maxPurchases`) and player resource `purchases` (`offer`, `boughtAt`). | Function | Arguments | Result | Errors | | --- | --- | --- | --- | | `list` | `{}` | `{offers: [Offer]}` | kernel only | | `buy` | `{offerSlug}` | `{offer, boughtAt}` | `offer_not_found`, `offer_not_available`, `purchase_limit_reached` | - `Offer` is `{slug, name, image, position, price: {currency, amount} | null, reward, maxPurchases, purchases}`. `price` is null for a free offer, `maxPurchases` 0 means unlimited, `purchases` counts the player's purchases, and `image` is `""` when unset. - `list` returns the offers available to the calling player: schedule active or empty, segment matching or empty, fewer than `maxPurchases` purchases. - `buy` fails with `offer_not_available` when the schedule or segment does not match. Otherwise it debits the wallet, grants the reward, records the purchase and emits `store.purchased`. See [Currency Shop](/docs/use-cases/currency-shop/). ## battlepass Resources `seasons` (`name`, `slug`, `schedule`, `premiumPrice`, `xpSources` mapping counter to XP), `tiers` (`season`, `level`, `xp`, `reward`, `premiumReward`) and player resource `battlepass_progress` (`season`, `xp`, `premium`, `claimed`). | Function | Arguments | Result | Errors | | --- | --- | --- | --- | | `progress` | `{}` | `{season: {slug, name, premiumPrice}, progress: Progress, tiers: [{id, level, xp, reward, premiumReward}]}` | `season_not_active` | | `claim` | `{tierId}` | `Progress` after the claim | `season_not_active`, `tier_not_found`, `tier_locked`, `already_claimed` | | `buyPremium` | `{}` | `Progress` | `season_not_active` | - `Progress` is `{xp, premium, claimed: [tierId]}`. `premiumPrice` is `{currency, amount}` or null; a null `premiumReward` means premium players get `reward`. - The current season is the one whose schedule is active. - `claim` grants a tier of the current season; premium players get `premiumReward` when it is set. - `buyPremium` debits `premiumPrice` once per season. - Each `counter.incremented` event adds `xpSources[counter] × delta` XP in the current season. --- # Functions runtime URL: https://rudder.build/docs/guides/functions/ Each function a module declares is a handler registered in its `module.wasm`. The backend runs it in a [wazero](https://wazero.io/) WebAssembly sandbox with no network, filesystem or environment access. ## Transactions Every invocation runs inside one Postgres transaction. A handler that returns a result commits. A handler that returns an error, crashes, runs out of time or exceeds the memory limit rolls everything back. Kernel calls are synchronous. Event handlers run in a savepoint: a failing handler is logged and does not fail the operation that emitted the event. ## Limits These limits are enforced by the host and cannot be configured: - 200 ms per `client`, `admin` and `event` invocation, 5 s per `schedule` invocation. The budget includes nested module calls. - 128 MB of memory per instance. - Nested module call depth of 8. - Console output (stdout and stderr) capped at 64 KB per invocation. ## Triggers | Trigger | Invoked by | | --- | --- | | `client` | a player: `POST /sdk/v1/modules/{module}/{fn}` | | `admin` | the dashboard (`POST /platform/v1/projects/{projectId}/modules/{module}/{fn}`) or a server with the admin key (`POST /game/v1/modules/{module}/{fn}`); `roles` limits which roles may call it | | `event` | the kernel, when the event named in `event` is emitted | | `schedule` | the cron worker, on the function's `cron` expression | | `module` | another module that lists this module in its `deps` | ### Schedules A `schedule` function must have a valid 5-field cron expression in `cron` (minute, hour, day of month, month, day of week, in UTC). Fields accept `*`, numbers, ranges (`1-5`), lists (`1,15`) and steps (`*/15`). Publishing or dry-running a package with a missing or invalid `cron` fails with HTTP 400. Schedules run in both environments, `staging` and `prod`, for every enabled install. The worker checks once a minute. A run that was missed (for example during a deploy) is caught up on the next check, looking back at most 35 days. ## Responses and errors | Function outcome | Transaction | Player response | | --- | --- | --- | | returns a result | commit | 200 `{ result }` | | returns a business error (`Fail(code, message)`) | rollback | 422 `{ error, code, requestId }` with the module's code | | crash, panic, no output | rollback | 500 without details | | timeout | rollback | 504 without details | | memory limit | rollback | 500 without details | Module error codes are strings chosen by the module author and declared with `rudder.Errors(...)`. They go into the manifest, and [generated module clients](/docs/guides/module-clients/#errors) expose them as a union type (TypeScript) or a constants class (C#). A module that returns an undeclared code still gets it to the player, and the backend writes a warning to the module log. Client calls are idempotent: the SDKs send an `Idempotency-Key`, and the backend stores the response for 24 hours and returns it for a repeated key. A key reused for another function fails with 422 `idempotency_key_mismatch`. If a nested module call fails, the whole invocation fails and the player receives the failure of the module that was called. Failures and console output are written to the module log (dashboard **Modules → Logs**, kept 7 days). ## Dry run The dashboard and `rudder module dry-run` run a function with `dryRun: true`. Dry run is available only to allowlisted publisher accounts (early access). A dry run always rolls back and returns `result`, `error`, `console`, `log` (the kernel calls made) and `events`. The player endpoint never returns these fields. ## Kernel API Each SDK wraps the same host operations. The project, environment and calling player come from the invocation context; a module cannot pass them. | Area | Operations | | --- | --- | | Wallet | `get`, `adjust` (a negative amount debits; insufficient funds fails) | | Storage | player storage `get`, `set` | | Project storage | `get`, `set` | | Counters | `get`, `increment` (emits `counter.incremented` for positive deltas) | | Resources | `list`, `get`, `create`, `update` (optional optimistic `version`), `delete`, `count` | | Segments | `match` (player against a segment), `bucket` (stable player bucket for a salt) | | Notifications | `send` | | Events | `emit` | | Modules | `call` (target must be in `deps` and have trigger `module`) | The context also carries the trigger, the player (for `client` calls), the module config values, the module slug, the environment and the current time. Kernel events: `counter.incremented`, `player.created`, `wallet.adjusted`, `storage.updated`. Custom events use `.`; names starting with `counter.`, `player.`, `wallet.` or `storage.` are reserved. ## Module SDK Modules are written in Go with `hub.rudder.build/rudder/rudder-module-go`. Other languages are not supported for now. Go handlers must be registered in `init()`, not `main()`: ```go func init() { rudder.Register("hello", rudder.Typed(hello), rudder.Errors("name_required")) } type helloArguments struct { Name string `json:"name" rudder:"maxLength=64"` } type helloResult struct { Message string `json:"message"` } func hello(ctx *rudder.Context, args helloArguments) (helloResult, error) { if args.Name == "" { return helloResult{}, rudder.Fail("name_required", "name is required") } return helloResult{Message: "hello " + args.Name}, nil } ``` `rudder.Typed` records the argument and result types; `rudder module build` turns them into the function's JSON Schemas (see [Module manifest](/docs/guides/module-manifest/#function-schemas)). In the module SDK, optional scalar fields of kernel calls use `omitempty`, so an explicit `0`, `false` or `""` is not sent. For every such field the zero value is also the default. For example, `Resources.Update` with `Version: 0` updates without a version check. --- # Wallet & Inventory URL: https://rudder.build/docs/guides/inventory-and-wallet/ import { Tabs, TabItem } from '@astrojs/starlight/components'; Rudder tracks two kinds of player-owned goods: - **Wallet** — part of the kernel. One integer balance per currency; currencies are `currency`-kind [counters](/docs/guides/counters/). - **Inventory** — provided by the first-party [`inventory`](/docs/guides/first-party-modules/#inventory) module. One row per player and item in the player resource `inventory` (`itemSlug`, `amount`); items come from the [`items`](/docs/guides/first-party-modules/#items) module. Both are server-authoritative. Game clients only read them; every change happens in a module function or an admin operation inside a transaction, so a hacked client cannot mint currency or items. ## Set up currencies and items In the dashboard at `https://app.rudder.build/`, open your project: 1. **Counters** — create a `currency` counter for each currency (e.g. `coins`, `gems`) and ship it in a [release](/docs/guides/releases/). Wallets list only currencies from the environment's latest release. 2. **Modules** — install `inventory` (it brings `items`), then create items in **Items**. Item rows are live as soon as you save them. ## Read wallet balances Balances are part of the player profile. Every released currency-kind counter appears in the response — a player who never received that currency gets a virtual `0` balance, so you don't need to special-case missing wallets. ```ts // client.player is a state object, loaded at login and kept fresh by sync const profile = await client.player.load(); for (const wallet of profile.wallets ?? []) { console.log(wallet.currency, wallet.balance); } // Or subscribe to updates (revision sync or reload() refresh it) client.player.onChange(({ value }) => { const coins = value?.wallets?.find((w) => w.currency === 'coins'); renderBalance(coins?.balance ?? 0); }); ``` ```csharp var profile = await client.Player.LoadAsync(); foreach (var wallet in profile.Wallets ?? new()) { Console.WriteLine($"{wallet.Currency}: {wallet.Balance}"); } client.Player.Changed += p => RenderWallets(p.Wallets); ``` Number-kind counters never appear in the wallet; visible ones are in the profile's `counters` map. See [Counters](/docs/guides/counters/). ## Read the inventory Inventory rows are module data in the player resource `inventory`. Game clients read them with the `inventory.list` client function through a [generated module client](/docs/guides/module-clients/): `await modules(client).inventory.list({})` returns `{items: [{itemSlug, amount, item}]}`, where `item` carries the display data from `items` (`properties` is JSON text) and is `null` for an unknown slug. Rewards returned by `quests.claim` and offers returned by `store.list` already carry item slugs and amounts. ## How players receive currency and items There is no client-side grant call. Modules pay out through the [`reward`](/docs/guides/first-party-modules/#reward) module, which applies currencies, items and counters in one call: - **Store** — `store.buy` debits the offer's price and grants its reward in one transaction. - **Quests** — `quests.claim` grants a completed quest's reward. - **Battle pass** — `battlepass.claim` grants a tier's reward. - **Leaderboards** — the scheduled reset grants rank rewards. Your own modules can call `reward.grant` (declare `reward` in `deps`) or use the kernel wallet directly. See [Functions runtime](/docs/guides/functions/#kernel-api). ## Adjust as an admin Open **Players → player detail** in the dashboard. The wallet tab shows balances and transaction history; **Adjust** applies a signed delta with a mandatory reason. The inventory module adds its rows and **Grant item** / **Remove item** actions to the same page. From a backend, call the `/game/v1` HTTP API with the environment's admin key (`X-API-Key`): `POST /game/v1/players/wallet/adjust` (a batch with an optional idempotency key) and `GET /game/v1/players/{playerId}/wallet/history` for the wallet, `/game/v1/resources/{slug}/rows` for module data, and `POST /game/v1/modules/{module}/{fn}` for admin functions such as `inventory.adminGrant`. ## Edge cases and limits - **Balances never go negative.** A debit that would drop a balance below zero fails with `insufficient_funds` and changes nothing. - **Crediting auto-creates the wallet** — the first grant of a currency works without any setup call. - **Every wallet change is audited.** The transaction history (amount, balance before/after, reason, source) is visible in the dashboard player view and through `GET /game/v1/players/{playerId}/wallet/history`. - **Currencies come from releases.** A player's wallet list is filled with a zero balance for every currency in the latest release. - **Client state is a cache.** The SDKs refresh the profile through revision sync; call `client.player.reload()` / `client.Player.ReloadAsync()` after a module call that changes balances. The server is the source of truth. --- # Module admin UI URL: https://rudder.build/docs/guides/module-admin-ui/ A module's admin UI is declarative. `resources` declares the data, `admin` declares the screens, and functions with `"trigger": "admin"` back the interactive parts. The dashboard renders it — a module never ships UI code. See [Module manifest](/docs/guides/module-manifest/) for the rest of the package. Everything on this page requires `"palette": 2`. With palette 1 the publish endpoint rejects the newer field types, the field rules, the `timeline`/`track`/`results` layouts, the `table`/`chart`/ `alert`/`results` widgets, UI hooks and detail children. ```json "admin": { "palette": 2, "title": "UI gallery", "icon": "layout-dashboard", "order": 90, "pages": [], "dashboard": [], "player": [] } ``` | Key | Meaning | |-----|---------| | `palette` | `2`. | | `title` | Module name in the sidebar and page subtitles. | | `icon` | Lucide icon name, e.g. `sword`, `sliders-horizontal`, `layout-dashboard`. | | `order` | Sort order inside the MODULES section of the sidebar. | | `pages` | Screens, in sidebar order. | | `dashboard` | Blocks on the project dashboard. Only `stat`, `action-button`, `table`, `chart`, `alert`. | | `player` | Blocks on the player detail page. A `resource-table` there must be player-scoped. | `samples` sits next to `admin` at the top level of the manifest. ## Field rules Every field in `resources.*.fields` and in a widget `input` accepts: | Key | Applies to | Meaning | |-----|-----------|---------| | `type` | all | Required, one of the types below. | | `required` | all | Marks the editor with `*` and rejects empty values. | | `label`, `description`, `placeholder` | all | Editor label, help text, placeholder. | | `default` | all | Filled on create and for absent fields on update, recursively into objects and list items. | | `visibleIf` | fields of an object, including the top level | `{ "field": "kind", "eq": "json" }` or `{ "field": "kind", "in": ["a", "b"] }`. Exactly one of `eq`/`in`, the named field must be a sibling. A hidden field is not required and its value is dropped on save. | | `unique` | top-level `string`, `number`, `enum`, `ref` | Unique per resource. | | `min`, `max` | `number` value, `string` length, `list` item count | Inclusive, non-negative for `string` and `list`. | | `step` | `number` | Positive; the value must be a multiple of it, counted from `min` (or 0). | | `pattern` | `string` | RE2 regular expression the whole value must match. | | `slug` | `string` | `{ "from": "name" }`; implies `unique` and `^[a-z0-9_]+$`. | | `display`, `summary`, `indexLabel` | `list` | See `list` below. | The dashboard applies the rules while editing; the backend enforces them on every row write, from REST, from `resources.create/update` inside a module, and when a sample is created. Cross-field and business rules belong in the `validate` hook. ## Field types ### string Text input. `min`/`max` bound the length, `pattern` constrains the value. ![string field](/media/module-ui/field-string.webp) ### string with slug ```json { "type": "string", "required": true, "slug": { "from": "name" } } ``` Filled from the source field while the row is new, then locked. ![slug field](/media/module-ui/field-string-slug.webp) ### number `min`, `max`, `step`. ![number field](/media/module-ui/field-number.webp) ### bool ![bool field](/media/module-ui/field-bool.webp) ### enum `enum` is required. The list page shows the value as a badge and can filter by it. ![enum field](/media/module-ui/field-enum.webp) ### datetime ISO-8601 string, date and time picker. ![datetime field](/media/module-ui/field-datetime.webp) ### duration Duration string such as `24h`: a number plus a unit. ![duration field](/media/module-ui/field-duration.webp) ### markdown Write and Preview tabs. ![markdown field](/media/module-ui/field-markdown.webp) ![markdown preview](/media/module-ui/field-markdown-preview.webp) ### image URL string. Upload (PNG, JPEG, WebP, GIF up to 2 MB) or paste a URL. A field named in `detail.image` moves to the side column of the detail page. ![image field](/media/module-ui/field-image.webp) ### json Free-form object in a JSON editor. The example below also uses `visibleIf`, so it only shows up when `kind` is `json`. ![json field](/media/module-ui/field-json.webp) ### object ```json { "type": "object", "fields": { "mode": { "type": "enum", "enum": ["default", "custom"] }, "threshold": { "type": "number", "visibleIf": { "field": "mode", "eq": "custom" } } } } ``` ![object field](/media/module-ui/field-object.webp) ### list `items` is required and can be any field, including `object`. Items drag to reorder and collapse. | Key | Meaning | |-----|---------| | `items` | The item field. | | `min`, `max` | Item count bounds; `max` disables **Add**. | | `summary` | A field of the object items shown in the collapsed header. | | `indexLabel` | `number` (`#1`) or `rank` (`1st`). | | `display` | `tags`, string items only. | ![rich list](/media/module-ui/field-list-rich.webp) With `"display": "tags"` the editor becomes a creatable multi-select whose suggestions come from the values other rows use for that field. ![tags field](/media/module-ui/field-list-tags.webp) ![tags field open](/media/module-ui/field-list-tags-open.webp) ### ref ```json { "type": "ref", "ref": "items", "refKey": "slug" } ``` `ref` is the target resource, `refKey` the field stored instead of the row id. The target row lists incoming references under **Used in**. ![ref field](/media/module-ui/field-ref.webp) ### reward Stored as `{ "currencies": {}, "items": {}, "counters": {} }`. `ref`/`refKey` point the item picker at a resource. ![reward field](/media/module-ui/field-reward.webp) ### price `{ "currency": "gems", "amount": 499 }`, `amount` is non-negative. ![price field](/media/module-ui/field-price.webp) ### condition `{ "counter": "matches_won", "operator": "gte", "value": 10 }`. Operators: `gte`, `gt`, `eq`, `lte`, `lt`. ![condition field](/media/module-ui/field-condition.webp) ### value Typed value: `{ "type": "string" | "int" | "float" | "bool" | "json", "value": ... }`. ![value field](/media/module-ui/field-value.webp) ### map ```json { "type": "map", "keys": { "kind": "currency" }, "values": { "type": "number", "min": 0 } } ``` `keys.kind` is `counter`, `currency`, `item` or `ref` (with `keys.ref` and an optional `keys.refKey`). `values` is any field. ![map field](/media/module-ui/field-map.webp) ### ranges ```json { "type": "ranges", "value": { "type": "reward", "ref": "items", "refKey": "slug" } } ``` Stored as `[{ "from": 1, "to": 3, "value": ... }]`. The backend rejects `from > to`; overlapping ranges are your module's business rule. ![ranges field](/media/module-ui/field-ranges.webp) ### segment A rule builder with AND/OR groups and an **Estimate** button that counts matching players. Attributes: `player.region`, `player.lang`, `player.payer`, `player.platform`, `player.appVersion`, `player.daysSinceRegister`, `player.daysSinceLastSeen`, `counter.`, `wallet.` and `bucket` (needs a `salt`). Operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `notIn`, `between`. An empty segment matches every player. ![segment field](/media/module-ui/field-segment.webp) ### split ```json { "salt": "winter_test", "variants": [{ "key": "a", "from": 0, "to": 49 }, { "key": "b", "from": 50, "to": 99 }] } ``` Percentage sliders. Ranges outside 0–99, overlaps and totals other than 100 are rejected. A/B tests are not a separate entity: a variant is a row whose segment includes a bucket range. ![split field](/media/module-ui/field-split.webp) ### schedule ```json { "start": "2026-12-01T00:00:00Z", "end": "2026-12-15T00:00:00Z", "repeat": { "every": "weekly", "days": ["sat", "sun"], "from": "10:00", "to": "22:00" } } ``` `end` and `repeat` are optional, times are UTC. An absent schedule means "no time limit". Row listing accepts `_active=true`, and scheduled rows show up on the dashboard **Calendar**. ![schedule field](/media/module-ui/field-schedule.webp) ## Pages and layouts ```json { "slug": "showcase", "title": "Showcase", "resource": "showcase", "layout": "table", "columns": ["name", "kind", "count", "enabled", "window"], "sort": "name", "filters": ["kind", "enabled", "tags", "itemRef"], "bulk": [{ "title": "Grant bonus", "function": "grantBonus", "confirm": "Grant a bonus?" }], "detail": {}, "hooks": {} } ``` | Key | Notes | |-----|-------| | `slug` | Required, unique per module; `settings` is reserved. | | `title` | Page title and sidebar entry. | | `resource` / `widgets` | Exactly one of the two. A widgets page has no rows and no hooks. | | `layout` | `table` (default), `cards`, `timeline`, `track`, `results`. | | `columns` | Table columns. | | `sort` | Field name; `-field` sorts descending. | | `filters` | Only `enum`, `bool`, `list` and `ref` fields. | | `card` | `cards` layout: `image` (an `image` field), `title`, `subtitle`, `badges`. | | `schedule`, `color` | `timeline` only; `schedule` is required and must be a `schedule` field, `color` an `enum` field. | | `orderField`, `threshold`, `lanes` | `track` only; `orderField` is a required `number` field. | | `score`, `order`, `player` | `results` only. | | `bulk` | `{ title, function, confirm? }`, the function needs trigger `admin`. | | `detail`, `hooks` | Below. | ### table Search, filters, sortable columns, row selection, per-row menu. ![table layout](/media/module-ui/layout-table.webp) Selecting rows opens the bulk bar with the module's bulk actions plus **Delete**. ![bulk bar](/media/module-ui/bulk-actions.webp) ### cards ```json { "layout": "cards", "card": { "image": "icon", "title": "name", "subtitle": "slug", "badges": ["kind", "tier"] } } ``` ![cards layout](/media/module-ui/layout-cards.webp) ### timeline ```json { "layout": "timeline", "schedule": "window", "color": "kind" } ``` Week or month scale with a "today" marker, one bar per row. Clicking a bar opens the row. ![timeline layout](/media/module-ui/layout-timeline.webp) ### track ```json { "layout": "track", "orderField": "level", "threshold": "xp", "lanes": [{ "title": "Free", "field": "freeReward" }, { "title": "Premium", "field": "premiumReward" }] } ``` Rows become columns of a ladder ordered by `orderField`. `threshold` is editable inline, each lane shows one field. Add, reorder and inline edit are supported. ![track layout](/media/module-ui/layout-track.webp) ### results ```json { "layout": "results", "score": "score", "order": "desc", "player": true } ``` Ranked list with badges for the top three and links to the players. `order` is `asc`, `desc` or `{ "from": "" }` (detail only). `player: true` needs a player-scoped resource. ![results layout](/media/module-ui/layout-results.webp) ### widget page A page with `widgets` instead of `resource` renders widgets only. ![widget page](/media/module-ui/page-widgets.webp) ## Detail pages ```json "detail": { "key": "slug", "image": "icon", "sections": [{ "title": "General", "fields": ["name", "slug", "kind"] }], "widgets": [], "children": [], "hooks": {} } ``` | Key | Notes | |-----|-------| | `key` | Field used in the URL instead of the row id. Must be `unique` or a `slug` field. | | `image` | An `image` field, rendered in the side column. | | `sections` | `{ title, fields }`; a two-column grid, wide editors span both columns. | | `widgets` | Rendered under the form. `results` widgets and `match` are allowed only here. | | `children` | Editable child rows, below. | | `hooks` | Below. | The side column holds the image, the player picker for player-scoped rows, row metadata and **Used in**. ![detail side column](/media/module-ui/detail-aside.webp) A detail page with a preview panel and an options-driven select open: ![detail page](/media/module-ui/hook-options.webp) Creating a row from a list page opens the same editors in a drawer. ![row drawer](/media/module-ui/row-drawer.webp) ### Children ```json "children": [ { "title": "Levels", "resource": "levels", "match": { "showcase": "$row.slug" }, "layout": "track", "orderField": "level", "threshold": "xp", "lanes": [{ "title": "Free", "field": "freeReward" }, { "title": "Premium", "field": "premiumReward" }] }, { "title": "Notes", "resource": "notes", "match": { "showcase": "$row.slug" }, "layout": "list" } ] ``` - `title`, `resource` and `match` are required. `match` maps a child field to `$row.id` or `$row.`; new child rows get those values filled in. - `layout` is `table` (default), `list` or `track`. `track` requires `orderField`. - Children accept `validate`, `preview` and `options` hooks, but not `save`. - Without a `save` hook the dashboard writes the row and its children in one transaction, at most 100 child operations, all or nothing. ![track children](/media/module-ui/children-track.webp) ![list children](/media/module-ui/children-list.webp) ## UI hooks ```json "hooks": { "validate": "validate", "preview": "preview", "options": { "tier": "tierOptions", "objectives.*.counter": "counterOptions" }, "save": "saveShowcase" } ``` Every name must be a declared function with trigger `admin`. `validate`, `preview` and `options` run as a dry run — reads work, writes are rolled back. `save` runs committed, so one hook can write a row and its children in a single transaction. | Hook | Called | Arguments | Returns | |------|--------|-----------|---------| | `validate` | before save and 500 ms after the last edit | `{ values, rowId, children }` | `{ errors: { "": "" } }`, paths use `/`, e.g. `levels/3/xp` | | `preview` | on open and 500 ms after the last edit | `{ values, rowId, children }` | `{ widgets: [...] }`, rendered in the side panel | | `options` | when the select opens, and 300 ms after the search text changes | `{ field, search, values }` | `{ options: [{ value, label, icon? }] }` | | `save` | on save, replaces the default write | `{ values, rowId, children }` | `{ id }` or a business error; `invalid_parameters` with `{ errors }` maps back to fields | `options` keys are field paths where `*` matches a list index; the hook receives the concrete path in `field`. It applies to `string`, `enum`, `ref` and `number` fields. An error returned by `validate`: ![validate hook](/media/module-ui/hook-validate.webp) A select filled by `options`: ![options hook](/media/module-ui/field-options-select.webp) The panel `preview` renders (`stat`, `table`, `chart` and `alert` widgets): ![preview hook](/media/module-ui/hook-preview.webp) ## Widgets | Type | Allowed on | Keys | Function result | |------|-----------|------|-----------------| | `stat` | page, detail, dashboard, player | `title`, `function`, `input` | `{ value, hint? }` or a scalar | | `table` | page, detail, dashboard, player | `title`, `function` | `{ title?, columns, rows }` | | `chart` | page, detail, dashboard, player | `title`, `function` | `{ title?, kind: "line" or "bar", series: [{ name, points: [{ x, y }] }] }` | | `alert` | page, detail, dashboard, player | `title`, `function` | `{ level: "info", "warning" or "error", text }` | | `markdown` | page, detail, player | `title`, `body` | static | | `tabs` | page, detail, player | `title`, `tabs: [{ title, widgets }]` | — | | `action-button` | page, detail, dashboard, player | `title`, `function`, `confirm`, `input` | anything, shown as a toast | | `resource-table` | page, detail, player | `resource`, `columns`, `sort`, `match` | — | | `results` | detail only | `resource`, `score`, `order`, `player`, `match` | — | `match` (`{ "": "" }`) works only inside a detail. `input` is a nested field schema rendered as a form with the editors above. Every `function` must be declared with trigger `admin`. ![stat widget](/media/module-ui/widget-stat.webp) ![table widget](/media/module-ui/widget-table.webp) ![chart widget](/media/module-ui/widget-chart.webp) ![alert widget](/media/module-ui/widget-alert.webp) ![markdown widget](/media/module-ui/widget-markdown.webp) ![tabs widget](/media/module-ui/widget-tabs.webp) ![action button](/media/module-ui/widget-action-button.webp) An `action-button` with `input` opens a form modal: ![action button modal](/media/module-ui/widget-action-button-modal.webp) Dashboard blocks: ![dashboard](/media/module-ui/page-dashboard.webp) Player page blocks: ![player page](/media/module-ui/page-player.webp) ## Samples ```json "samples": [ { "key": "flash_sale", "title": "Flash sale", "description": "48-hour offer: 100 gems for 500 gold, once per player", "rows": { "offers": [{ "name": "Flash sale", "slug": "flash_sale", "schedule": { "start": "$now", "end": "$now+2d" } }] } } ] ``` Sample rows are validated at publish against the resource schemas. Placeholders: `$now` and `$now+d|h`; refs use the target's `refKey` value. Creating a sample writes every row in one transaction, in the order the resources are listed. An empty page offers the module's samples: ![samples](/media/module-ui/samples.webp) ## Module settings Every module also gets the reserved **Settings** page: config schema form, enable or disable, update, eject, uninstall, download source. ![module settings](/media/module-ui/page-module-settings.webp) ## What publish rejects - `admin.palette` other than `1` or `2`, or palette-2 features with palette 1. - A page slug that is empty, duplicated or `settings`. - A page with both or neither of `resource` and `widgets`, or hooks on a widgets page. - A missing resource or field, or a field of the wrong type: `filters` outside `enum`/`bool`/`list`/ `ref`, `card.image` not an `image`, `schedule` not a `schedule`, `orderField`, `threshold` and `score` not `number`, `color` not an `enum`. - `schedule`/`color` without `timeline`, `threshold`/`lanes` without `track`, `score`/`order`/`player` without `results`, `match` or a `results` widget outside a detail. - `detail.key` on a field that is neither `unique` nor a slug. - A child block without `title`, `resource` or `match`, a `match` source that is not `$row.id` or `$row.`, a `track` child without `orderField`, or a child with a `save` hook. - A hook, bulk action or widget function that is missing or does not have trigger `admin`. - `player: true` or a player `resource-table` on a resource that is not player-scoped. --- # Generated module clients URL: https://rudder.build/docs/guides/module-clients/ import { Tabs, TabItem } from '@astrojs/starlight/components'; Game features are [modules](/docs/guides/modules/). The client SDKs do not know any module; instead, the `rudder` CLI reads the manifests of the modules installed in an environment and writes one typed client file for your game. ## Install the CLI The `rudder` CLI (0.1.0) ships as binaries for macOS, Linux and Windows (amd64 and arm64) on the Gitea releases page of the [`liveops-module-sdk` repository](https://hub.rudder.build/edmand46/liveops-module-sdk/releases). Download the binary for your platform and put it on your `PATH`. ## Generate ```bash export RUDDER_URL=https://api.rudder.build export RUDDER_TOKEN=... # personal access token (Account Settings in the dashboard) export RUDDER_PROJECT=... # project id rudder client generate --lang ts --out src/rudder.modules.ts rudder client generate --lang csharp --out Assets/Rudder/RudderModules.g.cs ``` | Flag | Meaning | | --- | --- | | `--lang ts\|csharp` | Output language. | | `--out ` | Output path. Defaults to `rudder.modules.ts` or `RudderModules.g.cs`. | | `--environment staging\|prod` | Which environment's installed modules to read. Default `staging`. | | `--manifests ` | Read `/*.json` and `/*/dist/manifest.json` instead of the server. | - Only functions with trigger `client` are generated. - The file header lists every module with its version. - The file is a factory over the SDK client; nothing is patched into the SDK. - Commit the file and regenerate it after installing, upgrading or removing modules. Do not edit it by hand. There is no editor integration: Unity projects run the CLI too. ## Use ```ts import { createClient } from '@rudder/sdk'; import { modules } from './rudder.modules'; const client = createClient({ projectKey }); await client.auth.loginWithDevice(); const m = modules(client); await m.leaderboards.submit({ slug: 'weekly', score: 120 }); const { entries } = await m.leaderboards.top({ slug: 'weekly', limit: 10 }); ``` The file exports: - `modules(client)`: `{ : { (args): Promise } }`, with module and function names in camelCase (`remote_config` → `remoteConfig`); - `Args` and `Result` interfaces (nested types are named by path) and string literal unions for enums; - `Error`: the function's declared error codes plus `RudderKernelError`. ```csharp using RudderSdk; using RudderSdk.Modules; var m = new RudderModules(client); await m.Leaderboards.SubmitAsync(new LeaderboardsSubmitArgs { Slug = "weekly", Score = 120 }); var top = await m.Leaderboards.TopAsync(new LeaderboardsTopArgs { Slug = "weekly", Limit = 10 }); ``` The file (namespace `RudderSdk.Modules`, `netstandard2.1`, Newtonsoft.Json) contains: - `RudderModules` with one property per module (`RemoteConfig`, `Leaderboards`, …) and `Async(args, cancellationToken)` methods; - plain `Args` and `Result` classes; optional properties are nullable and left out of the request when null; - constants classes for string enums and `Errors` for error codes. ## What a call does Each generated function calls the SDK's `modules.call` / `Modules.CallAsync`, which sends `POST /sdk/v1/modules/{module}/{fn}` with `{ args }` and returns `result`. - **Idempotency.** The SDK generates one `Idempotency-Key` per call and sends the same key on every retry. The backend stores the response for 24 hours and returns it for a repeated key, so a retried call runs at most once. A key reused for another module or function fails with 422 `idempotency_key_mismatch`. - **Retries.** Up to 2, with jittered backoff, on network errors, 5xx and 409 `conflict`. Other 4xx responses are never retried. - **Version header.** Generated clients send `X-Rudder-Module-Version: @` with the version the file was generated from. When the major version differs from the installed one, the call still runs and the backend writes a warning to the module log (**Modules → Logs**). Regenerate the client when you see it. ## Errors A module that fails a call on purpose returns HTTP 422 `{ error, code, requestId }`. The SDKs turn it into a typed error: ```ts import { RudderModuleError } from '@rudder/sdk'; import { modules, type StoreBuyError } from './rudder.modules'; try { await modules(client).store.buy({ offerSlug: 'starter_pack' }); } catch (error) { if (error instanceof RudderModuleError) { switch (error.code as StoreBuyError) { case 'insufficient_funds': showNotEnoughFunds(); break; case 'purchase_limit_reached': showSoldOut(); break; } } } ``` ```csharp try { await m.Store.BuyAsync(new StoreBuyArgs { OfferSlug = "starter_pack" }); } catch (RudderModuleException e) when (e.Code == StoreBuyErrors.InsufficientFunds) { ShowNotEnoughFunds(); } catch (RudderModuleException e) when (e.Code == StoreBuyErrors.PurchaseLimitReached) { ShowSoldOut(); } ``` The error sets come from the module: codes declared with `rudder.Errors(...)` (see [Module manifest](/docs/guides/module-manifest/#function-schemas)) plus the kernel codes such as `invalid_parameters`, `forbidden`, `conflict` and `insufficient_funds`. A module crash is a 500 and a timeout a 504, both without details. ## remote_config helpers `remote_config.get` returns `{ values: { [key]: { type, value } } }`, where `type` is `string`, `number`, `bool` or `json` and `value` is the value as text. When `remote_config` is installed, the generated file adds helpers that parse a value by key and return nothing for a missing key or another type: ```ts import { modules, remoteConfigBool, remoteConfigJson, remoteConfigNumber } from './rudder.modules'; const config = await modules(client).remoteConfig.get({}); const multiplier = remoteConfigNumber(config, 'reward_multiplier') ?? 1; const newShop = remoteConfigBool(config, 'new_shop_enabled') ?? false; const tuning = remoteConfigJson<{ spawnRate: number }>(config, 'tuning'); ``` Helpers: `remoteConfigString`, `remoteConfigNumber`, `remoteConfigBool`, `remoteConfigJson`; each returns `undefined` when the key is missing or has another type. ```csharp var config = await m.RemoteConfig.GetAsync(new RemoteConfigGetArgs()); if (!RemoteConfigValues.TryGetNumber(config, "reward_multiplier", out var multiplier)) multiplier = 1; RemoteConfigValues.TryGetBool(config, "new_shop_enabled", out var newShop); ``` `RemoteConfigValues` has `TryGetString`, `TryGetNumber`, `TryGetBool` and `TryGetJson`, parsed with the invariant culture and Newtonsoft.Json. See [Feature Flags](/docs/use-cases/feature-flags/) for a full example. --- # Module manifest URL: https://rudder.build/docs/guides/module-manifest/ ```json { "slug": "tournament", "version": "1.0.0", "language": "go", "abi": "rudder_v1", "sdk": "0.1.0", "source": "rudder/tournament@1.0.0", "deps": ["reward"], "config": { "schema": {}, "values": {} }, "resources": { "entries": { "scope": "player", "fields": { "board": { "type": "string", "required": true }, "score": { "type": "number", "required": true } }, "indexes": ["board", "score"] } }, "functions": { "submit": { "trigger": "client" }, "reset": { "trigger": "schedule", "cron": "0 * * * *" }, "resetNow": { "trigger": "admin", "roles": ["admin"] } }, "admin": { "pages": [] } } ``` - `slug`, `version` and `sdk` are required. - `language` is `go`. `abi` must be `rudder_v1`. - `deps` is a DAG. Install fails on a missing dependency or a cycle. A module can only call functions of modules listed in `deps`. - `source` points at the marketplace package. Eject copies the version to a project-owned row; marketplace updates stop. Eject and publishing are available only to allowlisted publisher accounts (early access). - `resources` are created on install and dropped on uninstall (with confirmation). `scope` is `project` or `player`. Resources have no `permissions` block: their rows are reachable only from module functions and admin tools, never directly from game clients. See [Resources](/docs/guides/resources/). - `functions` declare `trigger`: `client`, `admin`, `event`, `schedule` or `module`. `event` names the event for event functions, and `roles` limits admin functions. Do not write `input`, `output` or `errors`: the build generates them from Go types (see [Function schemas](#function-schemas)). - A `schedule` function needs a valid 5-field cron expression in `cron`. Publish and dry run reject a package with an invalid or missing `cron` with HTTP 400. Schedules run in both `staging` and `prod`. See [Functions runtime](/docs/guides/functions/#schedules). - `admin` describes the module's dashboard section: `title`, `icon`, `order`, `palette`, `pages` (resource pages with `table`, `cards`, `timeline`, `track`, `results` or `list` layouts, detail sections, widgets and hooks), plus `player` and `dashboard` widgets. Widget types: `stat`, `action-button`, `resource-table`, `markdown`, `tabs`, `table`, `chart`, `alert`, `results`. - `samples` are optional starter rows the dashboard can create for the module. The published package is `manifest.json`, `module.wasm` and, for marketplace modules, `source.tar`. The server checks that the wasm compiles, that it imports only `rudder_v1` and the allowed WASI functions, and that the functions it registers match the manifest. ## Function schemas `rudder module build` builds the wasm, calls its `describe` export and writes `functions..input`, `output` and `errors` into `dist/manifest.json`. A `manifest.json` that declares any of these fields by hand fails the build. The backend validates call arguments against `input`. ```go type SubmitArgs struct { Slug string `json:"slug" rudder:"pattern=^[a-z0-9_]+$"` Score int `json:"score" rudder:"min=0"` Mode string `json:"mode,omitempty" rudder:"enum=best|last"` } func init() { rudder.Register("submit", rudder.Typed(submit), rudder.Errors("board_not_found")) } ``` - `rudder.Typed` records the argument and result types. `rudder.Errors` declares the module's error codes; kernel codes (`invalid_parameters`, `forbidden`, `conflict`, `internal`, …) are never declared. - The `json` tag gives the name and `-` skips a field. A field without `omitempty` (or `omitzero`) is required. Embedded structs are flattened, pointers are nullable, slices and arrays are arrays, `[]byte` is a byte string, maps are objects with `additionalProperties`, `time.Time` is a `date-time` string, and `encoding.TextMarshaler` types are strings. - `any`, interfaces, `json.RawMessage`, other `json.Marshaler` types and maps of `any` are untyped. Recursive types fail the build. - The `rudder` tag lists comma-separated constraints: `min`, `max` (numbers), `minLength`, `maxLength`, `pattern` (strings), `enum=a|b|c` (strings and numbers), `format`. `pattern` takes the rest of the tag, so put it last. An invalid tag fails the build with the field name. - Functions with trigger `client` must be fully typed: an untyped node in the arguments or the result fails the build with its path, for example `client function submit input.score is untyped`. Other triggers may stay untyped. The generated schemas are what [generated module clients](/docs/guides/module-clients/) are built from. --- # Modules URL: https://rudder.build/docs/guides/modules/ The Rudder backend is a kernel: players, authentication, wallets, counters, storage, [resources](/docs/guides/resources/) and releases. Game-design features ship as **modules** on top of it. A module package is a `manifest.json` (see [Module manifest](/docs/guides/module-manifest/)) plus a `module.wasm` binary built from Go (the only supported module language for now). The backend stores each published version, installs it per project and environment, and runs its functions in an embedded WebAssembly runtime (see [Functions runtime](/docs/guides/functions/)). Built-in packages (`items`, `inventory`, `reward`, `remote_config`, `leaderboards`, `quests`, `store`, `battlepass`) are first-party marketplace versions, described in [First-party modules](/docs/guides/first-party-modules/). A project can pin a marketplace version (updates flow in) or eject it (a project-owned copy that marketplace updates no longer touch). ## Calling a module from the game Game clients call functions with trigger `client` through `POST /sdk/v1/modules/{module}/{fn}`. Use a [generated module client](/docs/guides/module-clients/): `rudder client generate` reads the manifests of the installed modules and writes typed arguments, results and error codes. ```ts import { modules } from './rudder.modules'; const { entries } = await modules(client).leaderboards.top({ slug: 'weekly_high_score', limit: 10 }); ``` ```csharp var m = new RudderModules(client); var top = await m.Leaderboards.TopAsync(new LeaderboardsTopArgs { Slug = "weekly_high_score", Limit = 10 }); ``` The player response is `{ result }` only. Every client call carries an `Idempotency-Key`, so the SDKs can retry network errors, 5xx and 409 `conflict` without running the function twice. When a module fails a call on purpose, the response is HTTP 422 with the common error body `{ error, code, requestId }`; the SDKs raise `RudderModuleError` (TypeScript) or `RudderModuleException` (C#), and the generated client lists the codes each function can return. A crash inside the module returns 500 and a timeout returns 504, both without details. Game servers call functions with trigger `admin` through `POST /game/v1/modules/{module}/{fn}` with the environment's admin key (`X-API-Key`). There is no server SDK; call the HTTP API directly. ## Building and publishing Installing first-party modules and configuring them in the dashboard is open to every project. Publishing your own modules, eject and dry run are in early access: they are available only to allowlisted publisher accounts. Write to [hello@rudder.build](mailto:hello@rudder.build) to request access. The `rudder` CLI scaffolds, builds and publishes modules. Builds run in a Docker image: ```bash rudder module init --slug my_module rudder module build rudder module dry-run --fn hello --args '{"name":"Ada"}' rudder module publish rudder module install --slug my_module --version 0.1.0 ``` Server commands read `--url`, `--token`, `--project` and `--environment` (or `RUDDER_URL`, `RUDDER_TOKEN`, `RUDDER_PROJECT`, `RUDDER_ENVIRONMENT`). `dry-run` always runs in `staging` and never commits. --- # Releases URL: https://rudder.build/docs/guides/releases/ A release is a snapshot of one environment: its counters, module installs and project-scoped resource rows. Module installs and resource rows are live as soon as you save them; counters only take effect in the runtime after a release. See [Releases & Publishing](/docs/concepts/releases-and-publishing/) for the full model. ## Publish a release In the dashboard at `https://app.rudder.build/`, select `staging` in the environment switcher, open **Releases** and click **Publish**. Add an optional description and notes and confirm. The release moves through statuses: - **building** — the snapshot is being assembled and stored. The dashboard blocks another publish while one is building. - **completed** — the snapshot is stored and the environment's counters are reloaded from it. - **failed** — nothing changed; the previous snapshot stays in use. Each release gets a sequential version per project and environment (`v1`, `v2`, …). Every completed release keeps its snapshot, which is what makes rollback possible. ## Promote to prod On the **Releases** page, click **Promote** and confirm. Promotion copies counters, module installs and project-scoped resource rows from `staging` to `prod`: - New entries are created in `prod`; existing ones are updated in place. Resource rows keep the same id in both environments. - Entries in `prod` that are missing from `staging` are deleted. - A new `prod` release is built in the background. Watch the prod release list for it to reach **completed**. Player data in `prod`, including player-scoped resource rows, is not touched. Promotion runs in one transaction; if it fails, `prod` is unchanged. ## Roll back Open **Releases**, find the last good version and click **Rollback**: - The target must be **completed** and still have its snapshot. - The environment's counters, module installs and project-scoped rows are replaced with the snapshot contents. - A **new** release row is created for the rollback, so it can be rolled back again. Rollback does not revert player data. Rewards granted in the meantime stay granted. ## What the SDKs cache - All SDKs keep `player`, `catalog` (currencies) and `storage` in memory and poll revisions every 30 seconds (±20% jitter); the C# SDK polls when you run `client.Sync`, and the Unity component runs it for you. A completed release bumps the catalog revision, so new currencies reach connected clients within about half a minute. Call `reload()` / `ReloadAsync()` to refetch immediately. - Module calls (`client.modules.call`, `client.Modules.CallAsync` and generated clients) are never cached, so rows you change are visible to the module on the next call. ## Limits and edge cases - **Only completed releases change the runtime.** A building or failed release leaves the current counters in place. - **One build at a time** per environment in the dashboard. - **`prod` is read-only in the dashboard.** Change content in `staging` and promote. - **Scheduled module functions** run in both environments against each environment's own installs and rows. --- # Resources URL: https://rudder.build/docs/guides/resources/ A resource is a definition (fields, indexes, admin UI) plus rows. Resources are declared by [modules](/docs/guides/module-manifest/) and are module storage and admin data: module functions read and write them through the kernel API, and the dashboard and the admin API manage them. Game clients have no direct access to resource rows; they call the module's `client` functions, usually through a [generated module client](/docs/guides/module-clients/). Field types: `string`, `number`, `bool`, `enum`, `datetime`, `duration`, `markdown`, `image`, `json`, `object`, `list`, `map`, `ref`, `reward`, `price`, `condition`, `value`, `ranges`, `segment`, `split`, `schedule`. Indexes such as `["score"]` create a partial expression index on the field (numeric for number fields) for that resource. List filters: `_sort`, `_order`, `_start`, `_end`, `_eq`, `_in`, `_like`, `_q`. REST (admin only): - `/platform/v1/projects/{id}/resources` for definitions - `/platform/v1/projects/{id}/resources/{slug}/rows` (dashboard) and `/game/v1/resources/{slug}/rows` (admin key) with batch mutations --- # Storage URL: https://rudder.build/docs/guides/storage/ import { Tabs, TabItem } from '@astrojs/starlight/components'; Rudder provides two key-value storages: - **Player storage** — per-player records, read and written by the owning player through the SDK. Use it for save games, settings, and client-side progress. - **Project storage** — project-global records shared by all players. It is available to module functions, the dashboard and the `/game/v1` admin API, not to game clients. Both stores are simple key-value: the key is called `type` (max 128 characters) and the value is `data`, an opaque string (max 64 KB) that is JSON by convention. Writes are upserts: writing an existing `type` replaces the whole `data` payload and increments a per-key `version` counter. ## Player storage Each player can store up to 1000 keys, one item per `type`. Every SDK call requires a signed-in player. ```ts import { createClient } from '@rudder/sdk'; const client = createClient({ projectKey: 'your-project-key' }); await client.auth.loginWithDevice(); // Write (upsert) a key await client.storage.save('settings', JSON.stringify({ music: true, volume: 0.8 })); await client.storage.save('checkpoint', JSON.stringify({ level: 4, score: 12500 })); // Read: storage is a state object; load() fetches once, value holds the result await client.storage.load(); const settings = client.storage.value?.items?.find((item) => item.type === 'settings'); // Subscribe to changes (fires immediately with the current snapshot) const unsubscribe = client.storage.onChange(({ status, value }) => { if (status === 'ready') console.log('storage items:', value?.items); }); // Delete a key await client.storage.delete('checkpoint'); ``` ```csharp using System.Linq; using Newtonsoft.Json; using RudderSdk; var client = new RudderClient(new RudderClientOptions { BaseUrl = "https://api.rudder.build", ProjectKey = "your-project-key", }); await client.Auth.LoginWithDeviceAsync(region: "global", language: "en"); // Write (upsert) a key await client.Storage.SaveAsync("settings", JsonConvert.SerializeObject(new { music = true, volume = 0.8f })); // Read all items (the state loads every page) and react to changes var items = await client.Storage.LoadAsync(); var settings = items.FirstOrDefault(i => i.Type == "settings")?.Data; client.Storage.Changed += all => Console.WriteLine($"{all.Count} items"); // Delete a key await client.Storage.DeleteAsync("settings"); ``` Notes: - Writes refresh the storage state immediately; changes made elsewhere (modules, the admin API) arrive through the revision poll. - The TypeScript state loads the first 100 items; the C# state follows the cursor and holds all items. - The SDK write takes only `type` and `data`; clients cannot set an expiration. TTLs on player storage keys can only be set through the admin API. ## Project storage Project storage holds records that belong to the game, not to a player: global event state, server-tuned tables, module bookkeeping (the `leaderboards` module keeps its last resets there). Game clients cannot read or write it. For game-facing configuration use a module such as [`remote_config`](/docs/use-cases/feature-flags/), or expose the data through your own module's `client` function. Where it is available: - **Module functions** — the kernel API `ProjectStorage.Get` / `Set` (see [Functions runtime](/docs/guides/functions/#kernel-api)). - **Dashboard** — **Users & Config → Storage**: create, edit and delete keys with a JSON payload and an optional **Expires At**. - **Admin API** — `/game/v1/project-storage` with the environment's admin key. Items carry `version` (incremented on every overwrite), `size`, `updatedAt` and `expiresAt`. Expired keys disappear from reads immediately and are deleted later by a cleanup worker. ## Limits and edge cases - **Key count:** 1000 keys per player, 1000 keys per project. Exceeding the cap fails the write. - **Payload size:** `data` is limited to 64 KB per key; `type` to 128 characters. - **Overwrite semantics:** an upsert replaces the entire `data` string — there is no partial update or merge. Read-modify-write flows should check `version` themselves; the server does not enforce it. - **Expiration:** expired keys are hidden from all reads and deleted later by a cleanup worker; do not rely on the exact deletion time. - **Batch admin operations:** server-side management (TTL, writing player storage, deletes) goes through the batch-only `/game/v1` admin API: `PUT`/`DELETE` on `/game/v1/players/storage` and `/game/v1/project-storage` take batches of up to 100 items in one transaction. --- # Pricing URL: https://rudder.build/docs/pricing/ Plans gate **monthly active users (MAU)** and nothing else. Every feature — players, wallets, storage, modules and resources, releases, all SDKs — is available on every plan, including Free. ## Plans | Plan | MAU limit | Price / month | | --- | --- | --- | | Free | 1,000 | $0 | | Pro | 50,000 | $149 | | Pro (Early access) | 50,000 | $79 | | Scale | 250,000 | $499 | | Scale (Early access) | 250,000 | $269 | | Enterprise | Unlimited | Contact us | Billing is monthly, by invoice, agreed over email. There is no annual discount and no trial — Free is the trial. ## What counts as a MAU A MAU is a **unique player who logged in during a calendar month (UTC), in production**. Staging players are not counted. A player who logs in fifty times in a month counts once, and the count resets at the start of each UTC month. Your project's current MAU is shown in the dashboard on the project overview and under **Project Settings → Pricing and Usage**. It is recalculated hourly. ## Going over the limit **Your players are never blocked.** Nothing is rate-limited, disabled, or rejected when you pass your plan's MAU limit. Instead: - at **80%** of the limit we email the project owners once for that month; - at **100%** we email the project owners again, once for that month; - then we talk about the plan that fits. Each threshold sends at most one email per project per month. Enterprise projects have no limit and are never emailed about MAU. ## Changing plan Plan changes are manual while the product has no payment flow. Write to [hello@rudder.build](mailto:hello@rudder.build) from an address that owns the project, say which plan you want, and we move the project over and invoice you monthly. ## Early-access prices Pro and Scale have early-access prices — $79 and $269 per month. To get one, switch to it before **2026-12-31**. The price is then locked for **12 months from the day your project switches**. After those 12 months the project moves to the regular price of the same plan; nothing about the project changes except the invoice. --- # C# SDK URL: https://rudder.build/docs/sdk/csharp/ `Rudder.Sdk` is the .NET client SDK for Rudder (namespace `RudderSdk`, generated models under `RudderSdk.Models.*`). It targets `netstandard2.1`, so it runs on .NET, Xamarin and Unity, uses `Task`/`async`, and depends only on `Newtonsoft.Json`. For a Unity game use the [Unity SDK](/docs/sdk/unity/), which wraps this package with Unity adapters. The SDK covers only the kernel: authentication, the player profile (player, identities, wallets, counters), the currency catalog, player storage and [module](/docs/guides/modules/) calls. Game features are modules, called through a [generated module client](/docs/guides/module-clients/). There are no resource rows and no project storage on the client. Current version: **0.1.0**. Versions stay `0.x` until the API is stable, and any minor release may break it. ## Installation The package is published to the Rudder NuGet registry: ```bash dotnet nuget add source https://hub.rudder.build/api/packages/rudder/nuget/index.json --name rudder dotnet add package Rudder.Sdk --version 0.1.0 ``` ## Client setup Create one `RudderClient` per application lifetime and keep it. ```csharp using RudderSdk; var client = new RudderClient(new RudderClientOptions { BaseUrl = "https://api.rudder.build", ProjectKey = "your-sdk-key", // per-environment SDK key from https://app.rudder.build/ }); await client.Auth.LoginWithDeviceAsync(region: "global", language: "en"); var profile = await client.Player.LoadAsync(); using var cts = new CancellationTokenSource(); _ = client.Sync.RunAsync(cts.Token); ``` Only `BaseUrl` and `ProjectKey` are required; the constructor throws `ArgumentException` when either is missing. ## Options and pluggable components The interfaces live in `RudderSdk.Abstractions`. The defaults work, but two of them are not enough for a shipped game: | Option | Type | Default | Notes | |---|---|---|---| | `Transport` | `IRudderTransport` | `HttpClientTransport` (10 s timeout) | Pass your own HTTP stack, or `new HttpClientTransport(baseUrl, httpClient)`. Any transport is wrapped by the SDK retry layer. | | `TokenStore` | `ITokenStore` | `InMemoryTokenStore` | Tokens are lost on restart. Provide a durable store. | | `DeviceIdProvider` | `IDeviceIdProvider` | `GuidDeviceIdProvider` | A new GUID per instance, so every restart is a new player. Persist the id yourself. | | `EventDispatcher` | `Action` | runs inline | Where state `Changed` handlers run. | | `Logger` | `IRudderLogger` | none | Diagnostic sink. | | `Platform` | `string` | detected (`ios`, `android`, `web`, else `other`) | Sent on every login and token refresh; used by segment rules. | | `AppVersion` | `string` | not sent | Your build version, sent on every login and token refresh. | A durable token store: ```csharp using RudderSdk.Abstractions; public sealed class FileTokenStore : ITokenStore { public string? GetAccessToken() => /* read from disk */; public string? GetRefreshToken() => /* read from disk */; public void SaveTokens(string accessToken, string refreshToken) => /* write both */; public void Clear() => /* delete the file */; } ``` ## Authentication Sign in with a device id, with Google or Apple (pass the ID token from the provider's sign-in flow), or through your own backend with the project's custom auth webhook. Providers are configured per environment under **Project Settings → Authentication**. ```csharp await client.Auth.LoginWithDeviceAsync("global", "en", nickname: "Rook"); await client.Auth.LoginWithGoogleAsync(idToken, "global", "en"); await client.Auth.LoginWithAppleAsync(idToken, "global", "en"); await client.Auth.LoginWithCustomAsync(new JObject { ["ticket"] = ticket }, "global", "en"); await client.Auth.LoginWithCustomAsync(new JObject { ["ticket"] = ticket }, "global", "en", provider: "steam"); ``` ### Linking identities A signed-in player can attach more identities to the same account. A conflict (the identity belongs to another player, or the provider is already linked) throws `RudderApiException` with status `409`. ```csharp await client.Auth.LinkWithGoogleAsync(idToken); await client.Auth.LinkWithAppleAsync(idToken); await client.Auth.LinkWithCustomAsync(new JObject { ["ticket"] = ticket }, provider: "steam"); // The last remaining identity cannot be unlinked (400). await client.Auth.UnlinkIdentityAsync("google"); // "device" | "google" | "apple" | "custom" | "custom:" ``` ### Session lifecycle - Tokens from login are saved in your `ITokenStore` and sent as a bearer token. - A 401 triggers one shared token refresh and one transparent retry. Do not add your own retry. - If the refresh fails, tokens are cleared and `Auth.AuthStateChanged` fires `RudderAuthState.SignedOut`. A login fires `SignedIn`. - `Auth.Logout()` drops the session. `Auth.RefreshAsync()` forces a refresh and returns `false` when the session could not be renewed. - Every login and logout drops the cached state values. ```csharp client.Auth.AuthStateChanged += state => { if (state == RudderAuthState.SignedOut) ShowLoginScreen(); }; ``` ## API surface | Property | Type | Main members | |---|---|---| | `Auth` | `AuthService` | `LoginWithDevice/Google/Apple/CustomAsync`, `LinkWithGoogle/Apple/CustomAsync`, `UnlinkIdentityAsync`, `RefreshAsync`, `Logout`, `AuthStateChanged` | | `Player` | `SyncedState` | `Value` (`Player`, `Identities`, `Wallets`, `Counters`), `LoadAsync`, `ReloadAsync`, `Changed` | | `Catalog` | `SyncedState>` | currencies by slug | | `Storage` | `StorageState` | `Value` (all items), `SaveAsync(type, data)`, `DeleteAsync(type)` | | `Modules` | `ModulesService` | `CallAsync(module, fn, args[, moduleVersion])` | | `Sync` | `SyncEngine` | `RunAsync(ct)`, `TickAsync(utcNow)`, `PollAsync()`, `Paused` | Every async method takes an optional `CancellationToken` as its last parameter. ## State and sync Each state object caches its value. `LoadAsync` fetches once, `ReloadAsync` always fetches, `IsLoaded` tells whether a value is there, and every fetch raises `Changed`. `Sync` polls `GET /sdk/v1/sync` every 30 seconds (±20% jitter) and reloads the loaded states whose revision grew (`profile`, `catalog`, `storage`). It never runs on its own: start `Sync.RunAsync(ct)` on a background task or call `Sync.TickAsync(DateTime.UtcNow)` from your game loop. Set `Sync.Paused` while the app is in the background. ### Player and counters ```csharp var profile = await client.Player.LoadAsync(); var gold = profile.Wallets?.FirstOrDefault(w => w.Currency == "gold")?.Balance ?? 0; var wins = profile.Counters != null && profile.Counters.TryGetValue("wins", out var w) ? w : 0; client.Player.Changed += p => RenderWallets(p.Wallets); ``` `Counters` holds the values of `number` [counters](/docs/guides/counters/) that are visible to game clients. There is no client-side grant or spend call; call `Player.ReloadAsync()` after a module call that changes wallets or counters. ### Catalog ```csharp var currencies = await client.Catalog.LoadAsync(); if (currencies.TryGetValue("gold", out var gold)) Console.WriteLine(gold.Name); ``` ### Storage One item per player and `type`; `Data` is a string, so serialize JSON yourself. ```csharp await client.Storage.SaveAsync("settings", JsonConvert.SerializeObject(settings)); var items = await client.Storage.LoadAsync(); var raw = items.FirstOrDefault(i => i.Type == "settings")?.Data; await client.Storage.DeleteAsync("settings"); ``` The state holds all items, loaded 100 per request. See [Storage](/docs/guides/storage/) for limits. ## Module calls Generate a typed client and commit the file: ```bash RUDDER_URL=https://api.rudder.build RUDDER_TOKEN=... RUDDER_PROJECT=... \ rudder client generate --lang csharp --out RudderModules.g.cs ``` ```csharp using RudderSdk.Modules; var m = new RudderModules(client); await m.Leaderboards.SubmitAsync(new LeaderboardsSubmitArgs { Slug = "weekly", Score = 120 }); var top = await m.Leaderboards.TopAsync(new LeaderboardsTopArgs { Slug = "weekly", Limit = 10 }); ``` Generated methods call `client.Modules.CallAsync(module, fn, args, moduleVersion, ct)`. You can call it directly; use `JToken` for an untyped result: ```csharp var result = await client.Modules.CallAsync("remote_config", "get", new { }); ``` - Null `args` are sent as `{}`. - Each call carries an `Idempotency-Key` (a new GUID per call). The backend returns the stored response for a repeated key. - Network errors, 5xx and 409 `conflict` are retried up to 2 times with jittered backoff and the same key. Other 4xx are never retried. - Generated clients send `X-Rudder-Module-Version: @`. See [Generated module clients](/docs/guides/module-clients/) for error constants and `remote_config` helpers. ## Error handling Failures are exceptions in the `RudderSdk` namespace: | Exception | When | |---|---| | `RudderModuleException` | HTTP 422, a module business error; `Code` is the module's code | | `RudderAuthException` | HTTP 401 after the automatic refresh failed | | `RudderNotFoundException` | HTTP 404 | | `RudderRateLimitException` | HTTP 429 | | `RudderNetworkException` | No response (connectivity loss or timeout); `StatusCode` is 0 | | `RudderApiException` | Base class; any other non-success status | All of them carry `StatusCode`, `Code` and `RequestId` (quote it in support tickets). Kernel codes are in the generated `RudderSdk.Models.RudderErrorCodes`; module codes are in the generated `Errors` classes. Custom transports map failures with `RudderApiException.FromResponse`. ```csharp try { await m.Store.BuyAsync(new StoreBuyArgs { OfferSlug = "starter_pack" }); await client.Player.ReloadAsync(); } catch (RudderModuleException ex) when (ex.Code == StoreBuyErrors.InsufficientFunds) { ShowNotEnoughFunds(); } catch (RudderNetworkException) { ShowOfflineBanner(); } ``` A module crash is a 500 and a timeout a 504, both `RudderApiException` without details. ## Unity vs. plain C# The Unity package `build.rudder.sdk` ships `Rudder.Sdk.dll` and adds: - an HTTP transport on `UnityWebRequest`; - a `PlayerPrefs` token store and a persistent device id; - the `Rudder` component, which builds the client from a `RudderConfiguration` asset, pumps `Sync` every frame, pauses it in the background and raises `Changed` on the main thread. The client surface is the same. See the [Unity SDK](/docs/sdk/unity/) page. --- # TypeScript SDK URL: https://rudder.build/docs/sdk/typescript/ `@rudder/sdk` is the Rudder client SDK for web games. It wraps the player-facing `/sdk/v1` HTTP API behind one client and covers only the kernel: - authentication and identity linking; - the player profile (player, identities, wallets, counters); - the currency catalog; - player storage; - calls to installed [modules](/docs/guides/modules/), usually through a [generated module client](/docs/guides/module-clients/). Game features such as the store, quests, leaderboards, battle pass and remote config are modules, not SDK services. The SDK has no resource rows and no project storage access; game-facing configuration comes from modules such as `remote_config`. Current version: **0.1.0**. Versions stay `0.x` until the API is stable, and any minor release may break it. The package ships ESM and CJS builds with type declarations and has no runtime dependencies. ## Installation The package is published to the Rudder registry at hub.rudder.build, not npmjs. Point the `@rudder` scope at it in your project's `.npmrc`: ```text @rudder:registry=https://hub.rudder.build/api/packages/rudder/npm/ ``` Then install as usual (anonymous read access, no token needed): ```bash npm install @rudder/sdk ``` ## Creating a client ```ts import { createClient } from '@rudder/sdk'; const client = createClient({ projectKey: 'your-sdk-key', }); ``` `projectKey` is the SDK key of your project. Find it in the dashboard at [app.rudder.build](https://app.rudder.build/) under **Project Settings**. A project has exactly two environments, `staging` and `prod`, each with its own key; the key you pass decides which environment the player belongs to. A missing `projectKey` throws `RudderError` with `code: 'sdk/invalid-options'` (`SDK_ERROR_INVALID_OPTIONS`). Optional settings: | Option | Default | Purpose | |---|---|---| | `baseUrl` | `https://api.rudder.build` | API base URL | | `tokenStore` | localStorage with in-memory fallback | Token persistence (see below) | | `appVersion` | not sent | Game build version sent with `platform: 'web'` on every login and token refresh, for segment rules | | `requestTimeoutMs` | `10000` | Per-request timeout | | `syncIntervalMs` | `30000` (±20% jitter) | Revision poll interval | Create one client per page or session and reuse it. ## Authentication Players are anonymous by default: `loginWithDevice()` generates a device ID on the first call, keeps it in localStorage, and exchanges it for an access/refresh token pair. `loginWithGoogle()` and `loginWithApple()` take the ID token from the provider's sign-in flow. `loginWithCustom()` forwards `customData` to your own backend's auth webhook. Configure providers in the dashboard under **Project Settings → Authentication**. ```ts await client.auth.loginWithDevice(); // region 'global', language 'en' await client.auth.loginWithDevice({ region: 'eu', language: 'de', nickname: 'Bob' }); await client.auth.loginWithGoogle({ idToken, region: 'eu', language: 'de' }); await client.auth.loginWithApple({ idToken }); await client.auth.loginWithCustom({ customData: { steamTicket: '...' } }); await client.auth.loginWithCustom({ provider: 'steam', customData: { ticket: '...' } }); client.auth.isAuthenticated; // true while an access token is stored const unsubscribe = client.auth.onAuthStateChange((state) => { // 'signed-in' | 'signed-out'; fires immediately, then on every change }); client.auth.logout(); // clears tokens, stops sync, drops cached state ``` After a login the client loads `player` and `catalog` in parallel and starts the revision poll. Access tokens are refreshed automatically: a 401 triggers one single-flight refresh and one retry. If the refresh fails, tokens are cleared and listeners get `'signed-out'`. ### Linking identities A signed-in player can attach more identities to the same account, so a later login through any of them lands on the same player. Link calls resolve with no value; a conflict (the identity belongs to another player, or the provider is already linked) throws `RudderHttpError` with status `409`. ```ts await client.auth.linkWithGoogle(idToken); await client.auth.linkWithApple(idToken); await client.auth.linkWithCustom({ customData: { sessionId: '...' } }); await client.auth.linkWithCustom({ provider: 'steam', customData: { ticket: '...' } }); // The last remaining identity cannot be unlinked. await client.auth.unlinkIdentity('google'); // 'device' | 'google' | 'apple' | 'custom' | 'custom:' ``` Linked identities are in the profile: `client.player.value?.identities`. ## Token storage Tokens go through a pluggable `TokenStore`. The default store uses localStorage (keys `rudder_access_token` and `rudder_refresh_token`) and falls back to memory where localStorage is unavailable (SSR, private mode). With the fallback the session lasts only as long as the page. ```ts import { createClient, type TokenStore } from '@rudder/sdk'; const sessionStore: TokenStore = { getAccessToken: () => sessionStorage.getItem('rudder_access_token'), getRefreshToken: () => sessionStorage.getItem('rudder_refresh_token'), saveTokens: (access, refresh) => { sessionStorage.setItem('rudder_access_token', access); sessionStorage.setItem('rudder_refresh_token', refresh); }, clear: () => { sessionStorage.removeItem('rudder_access_token'); sessionStorage.removeItem('rudder_refresh_token'); }, }; const client = createClient({ projectKey, tokenStore: sessionStore }); ``` `createDefaultTokenStore()` and `createLocalStorageTokenStore()` are exported as well. ## State objects `client.player`, `client.catalog` and `client.storage` are `SyncedState` objects with the same interface: - `value`: the cached value, or `undefined` before the first load. - `status`: `'idle' | 'loading' | 'ready' | 'error'`, plus `error` when set. - `onChange(listener)`: fires immediately with `{ status, value, error }`, then on every change; returns an unsubscribe function. - `load()`: loads once and deduplicates parallel calls. - `reload()`: always refetches. `player` and `catalog` load at login; `storage` loads on first use. The client polls `GET /sdk/v1/sync` every **30 seconds (±20% jitter)** and reloads only the states whose revision grew. Polling pauses while the tab is hidden. Storage writes refresh `storage` right away. Otherwise data is eventually consistent within one poll interval; call `reload()` when you need fresh values now, for example after a module call that changes wallets or counters. `onChange` maps onto React's `useSyncExternalStore`: ```tsx import { useSyncExternalStore } from 'react'; function useSynced(state: { onChange(cb: () => void): () => void; value: T | undefined }) { return useSyncExternalStore( (onStoreChange) => state.onChange(() => onStoreChange()), () => state.value, ); } function Wallet() { const profile = useSynced(client.player); return
{profile?.wallets?.map((w) => `${w.currency}: ${w.balance}`).join(', ')}
; } ``` ### Player `client.player.value` is a `PlayerProfile`: - `player`: `id`, `projectId`, `nickname`, `region`, `language`, `createdAt`; - `identities`: `provider`, `subject`, `createdAt` per linked identity; - `wallets`: `currency` and `balance` per released currency; - `counters`: `{ [slug]: number }` with the values of `number` [counters](/docs/guides/counters/) that are visible to game clients. ```ts const profile = client.player.value; const coins = profile?.wallets?.find((w) => w.currency === 'coins')?.balance ?? 0; const wins = profile?.counters?.wins ?? 0; ``` There is no client-side grant or spend call. Balances and counters change in module functions and admin operations. ### Catalog `client.catalog.value` is a `Map` (`slug`, `name`, `icon`, `data`) built from the currency counters of the environment's latest release. ```ts const gold = client.catalog.value?.get('gold'); ``` ### Storage `client.storage` holds the player's storage items (`type`, `id`, `data`). The server keeps one item per player and `type`; `data` is a string, so serialize JSON yourself. ```ts await client.storage.save('progress', JSON.stringify({ level: 12 })); await client.storage.delete('progress'); await client.storage.load(); const progress = client.storage.value?.items?.find((item) => item.type === 'progress'); ``` The state loads the first 100 items. See [Storage](/docs/guides/storage/) for limits. ## Module calls Generate a typed client for the modules installed in an environment and commit the file: ```bash export RUDDER_URL=https://api.rudder.build RUDDER_TOKEN=... RUDDER_PROJECT=... rudder client generate --lang ts --out src/rudder.modules.ts ``` ```ts import { modules } from './rudder.modules'; const m = modules(client); await m.leaderboards.submit({ slug: 'weekly', score: 120 }); const { entries } = await m.leaderboards.top({ slug: 'weekly', limit: 10 }); ``` Each generated function calls `client.modules.call(module, fn, args, { moduleVersion })`. You can call it directly for a module that is not in the generated file: ```ts const result = await client.modules.call<{ values: Record }>( 'remote_config', 'get', {}, ); ``` - `args` is sent as `{}` when omitted; the promise resolves to `result`. - Each call gets an `Idempotency-Key` (UUID v4) that is reused on its retries, so the server runs the call at most once. - Network errors, 5xx and 409 `conflict` are retried up to 2 times with jittered backoff. Other 4xx responses are never retried. - `moduleVersion` is sent as `X-Rudder-Module-Version: @`. See [Generated module clients](/docs/guides/module-clients/) for the generator, error unions and `remote_config` helpers. ## Error handling All SDK errors extend `RudderError` and may carry a machine-readable `code`: - `RudderNetworkError`: the request failed or timed out; the original error is in `error.cause`. GET requests and module calls are retried first. - `RudderHttpError`: a non-2xx response with `status`, `statusText`, `body` and `code`. - `RudderAuthError` (extends `RudderHttpError`): a 401 whose refresh failed. Tokens are already cleared and `'signed-out'` already emitted. - `RudderModuleError` (extends `RudderHttpError`): a module call returned 422 `{ error, code, requestId }`. It exposes `code`, `message` and `requestId`. ```ts import { RudderAuthError, RudderHttpError, RudderModuleError, RudderNetworkError } from '@rudder/sdk'; import { modules, type StoreBuyError } from './rudder.modules'; try { await modules(client).store.buy({ offerSlug: 'starter_pack' }); await client.player.reload(); } catch (error) { if (error instanceof RudderModuleError) { const code = error.code as StoreBuyError; if (code === 'purchase_limit_reached') showSoldOut(); } else if (error instanceof RudderAuthError) { showLogin(); } else if (error instanceof RudderHttpError) { console.error(error.status, error.code, error.body); } else if (error instanceof RudderNetworkError) { console.error('offline?', error.cause); } } ``` A module crash returns 500 and a timeout 504, both `RudderHttpError` without details. ## Disposal Call `client.dispose()` when tearing the client down (page unmount, hot module replacement, account switch). It stops the sync poll and drops cached state. `client.auth.logout()` clears the session and cached state but keeps the client usable. ## See also - [C# SDK](/docs/sdk/csharp/) and [Unity SDK](/docs/sdk/unity/): the same surface for .NET and Unity. - [Generated module clients](/docs/guides/module-clients/). - Game servers call the `/game/v1` HTTP API with the environment's admin key; there is no server SDK. --- # Unity SDK URL: https://rudder.build/docs/sdk/unity/ The Unity SDK is the UPM package `build.rudder.sdk`. It ships `Rudder.Sdk.dll` (the [C# SDK](/docs/sdk/csharp/)) with Unity adapters: a `UnityWebRequest` transport, `PlayerPrefs` storage for tokens and the device id, and the `Rudder` component, which pumps state sync and raises state events on the main thread. Requires Unity **6000.0** or newer. Current version: **0.1.0**. Versions stay `0.x` until the API is stable, and any minor release may break it. ## Installation Add the Rudder scoped registry and both dependencies to `Packages/manifest.json`: ```json { "scopedRegistries": [ { "name": "Rudder", "url": "https://hub.rudder.build/api/packages/rudder/npm/", "scopes": ["build.rudder"] } ], "dependencies": { "build.rudder.sdk": "0.1.0", "com.unity.nuget.newtonsoft-json": "3.2.2" } } ``` `com.unity.nuget.newtonsoft-json` is required: `Rudder.Sdk` serializes with Newtonsoft.Json and the package does not bundle it. Namespaces: - `RudderSdk.Unity`: `Rudder`, `RudderConfiguration`, `RudderState`; - `RudderSdk`: `RudderClient`, `SyncedState`, exceptions; - `RudderSdk.Models.*`: `PlayerProfile`, `Wallet`, `CatalogCurrency`, `StorageItem`, …; - `RudderSdk.Modules`: your generated module client. ## Configuration 1. Create a configuration asset: **Assets > Create > Rudder > Configuration**. 2. Set **Project Key** to the SDK key from **Project Settings** in the [dashboard](https://app.rudder.build/). The key selects `staging` or `prod`. 3. Add the `Rudder` component to a GameObject in your startup scene and assign the asset. | Field | Default | Notes | | --- | --- | --- | | `ProjectKey` | — | Required. | | `BaseUrl` | `https://api.rudder.build` | API base URL. | | `TimeoutSeconds` | `10` | HTTP request timeout. | Keep the key out of version control: use a gitignored asset and commit an `.example` copy. ## Bootstrap ```csharp using RudderSdk.Unity; using UnityEngine; public class Bootstrap : MonoBehaviour { async void Start() { var client = Rudder.Initialize(); client.Player.Changed += profile => Debug.Log("wallets: " + profile.Wallets?.Count); await client.Auth.LoginWithDeviceAsync("global", "en", nickname: "Player"); await client.Player.LoadAsync(); } } ``` - `Rudder` is a `MonoBehaviour` that must already be in the scene; the SDK never creates its own GameObject. It survives scene loads, and a duplicate destroys itself. - `Rudder.Initialize()` is synchronous and idempotent. It reads the configuration and returns the `RudderClient`, and throws when the component or the asset is missing. - `Rudder.State` is `NotInitialized`, `Initializing`, `Ready` or `Failed`; `Rudder.LastError` holds the initialization error. - `Rudder.Client` returns the client and throws until `Initialize()` has run. `Rudder.Ready` (a `Task`) and the static `Rudder.Initialized` event serve add-ons that load later. The client exposes `Auth`, `Player`, `Catalog`, `Storage`, `Modules` and `Sync`, the same surface as the [C# SDK](/docs/sdk/csharp/#api-surface). ## Authentication ```csharp await client.Auth.LoginWithDeviceAsync("global", "en", nickname: "Player"); await client.Auth.LoginWithGoogleAsync(idToken, "global", "en"); await client.Auth.LoginWithAppleAsync(idToken, "global", "en"); await client.Auth.LoginWithCustomAsync(customData, "global", "en", provider: "steam"); await client.Auth.LinkWithGoogleAsync(idToken); await client.Auth.LinkWithAppleAsync(idToken); await client.Auth.LinkWithCustomAsync(customData, provider: "steam"); await client.Auth.UnlinkIdentityAsync("google"); ``` - The device id is a GUID generated once and stored in `PlayerPrefs` (`rudder_device_id`). Do not pass `SystemInfo.deviceUniqueIdentifier`. - Access and refresh tokens persist in `PlayerPrefs`. - A 401 triggers one automatic refresh and one retry. - `Auth.AuthStateChanged` fires `SignedIn` after a login and `SignedOut` after a logout or a failed refresh. `Auth.Logout()` drops the session. - Every login and token refresh sends `platform` (from `Application.platform`) and `appVersion` (default `Application.version`). See [Authentication](/docs/guides/authentication/) for providers and errors. ## State and sync `Player`, `Catalog` and `Storage` cache their values. `LoadAsync` fetches once, `ReloadAsync` always fetches, and `Changed` fires on the main thread (the next frame) after every fetch. The `Rudder` component calls `client.Sync.TickAsync` every frame: every 30 seconds (±20%) the SDK polls `/sdk/v1/sync` and reloads the loaded states whose revision grew. Polling pauses while the application is paused. Do not add your own sync timer. ```csharp var profile = await client.Player.LoadAsync(); var gold = profile.Wallets?.FirstOrDefault(w => w.Currency == "gold")?.Balance ?? 0; long wins = 0; profile.Counters?.TryGetValue("wins", out wins); var currencies = await client.Catalog.LoadAsync(); ``` `Counters` holds the values of `number` [counters](/docs/guides/counters/) that are visible to game clients. ### Storage (cloud save) ```csharp await client.Storage.SaveAsync("save", JsonUtility.ToJson(save)); var items = await client.Storage.ReloadAsync(); var raw = items.FirstOrDefault(i => i.Type == "save")?.Data; await client.Storage.DeleteAsync("save"); ``` One item per player and `type`; `Data` is an opaque string. ## Modules Game features are installed modules. Generate a typed client with the `rudder` CLI and commit it, for example under `Assets/Rudder/`: ```bash RUDDER_URL=https://api.rudder.build RUDDER_TOKEN=... RUDDER_PROJECT=... \ rudder client generate --lang csharp --out Assets/Rudder/RudderModules.g.cs ``` ```csharp using RudderSdk; using RudderSdk.Modules; var m = new RudderModules(client); try { var top = await m.Leaderboards.TopAsync(new LeaderboardsTopArgs { Slug = "weekly", Limit = 10 }); } catch (RudderModuleException e) when (e.Code == LeaderboardsTopErrors.BoardNotFound) { Debug.LogWarning(e.Message); } ``` There is no editor integration: rerun the command after installing or upgrading modules. Module calls carry an idempotency key and are retried up to 2 times on network errors, 5xx and 409 `conflict`. See [Generated module clients](/docs/guides/module-clients/). ## Errors Errors are `RudderApiException` subclasses from `RudderSdk`: `RudderModuleException` (422, module business error with `Code`), `RudderAuthException` (401, the session is over), `RudderNotFoundException` (404), `RudderRateLimitException` (429) and `RudderNetworkException` (no response). Each carries `StatusCode`, `Code` and `RequestId`. ## Unity notes - `await` SDK calls without `ConfigureAwait(false)`, and never block the main thread on SDK tasks. - There is nothing to dispose in game code. - Static state resets on domain reload, so Play Mode without domain reload works. - SDK logs go to `Debug.Log*` with a `[Rudder]` prefix. ## Samples Import **Feature Samples** from the Package Manager (select the Rudder SDK package, then Samples). Each scene shows one API: | Scene | SDK calls | | --- | --- | | Authentication | `Rudder.Initialize`, `Auth.LoginWithDeviceAsync`, `Player.ReloadAsync`, `Auth.Logout` | | Player | `Player.LoadAsync`, `Player.Changed`, `Catalog.LoadAsync` | | Storage | `Storage.ReloadAsync`, `SaveAsync`, `DeleteAsync` | | Modules | a generated `RudderModules`, `RudderModuleException` | Assign a `RudderConfiguration` on the Rudder object if the field is empty, then press Play. --- # Rudder for AI Agents URL: https://rudder.build/docs/tools/for-ai-agents/ Rudder provides two resources for working with AI coding agents (Claude Code, Cursor, and similar tools): - **`llms.txt` / `llms-full.txt`** — the documentation in agent-friendly plain text. - **Per-SDK agent skills** — packaged instructions that teach an agent how to integrate a specific Rudder client SDK correctly. Agents that write modules should target Go, the only supported module language for now; publishing modules is in early access for allowlisted publisher accounts. ## llms.txt The documentation site publishes two machine-readable files: - [`/llms.txt`](https://rudder.build/llms.txt) — an index of the documentation with links and short descriptions. Point your agent at it when it needs to find the right page. - [`/llms-full.txt`](https://rudder.build/llms-full.txt) — the full documentation in a single plain-text file. Paste it into the agent's context (or reference it as a document) when you want the agent to answer from the docs without fetching pages one by one. ## SDK agent skills Each Rudder SDK ships an agent skill — a `SKILL.md` package with integration guidance, verified code examples, and edge cases for that SDK. Install the skill for the SDK you are integrating so the agent follows Rudder's actual APIs instead of guessing them. Skills are available in two forms: - **Downloadable archives** — `/skills/.zip` on this site (for example `/skills/js-sdk.zip`). Extract the archive into your agent's skills directory (for Claude Code, `.claude/skills/` in your project or `~/.claude/skills/` globally). - **In the SDK repositories** — browse the `skills/` directory of each SDK repository to read the skill without installing it. | Skill | SDK | Covers | | --- | --- | --- | | `js-sdk` | `@rudder/sdk` 0.1.0 (TypeScript, web) | `createClient`, authentication and identity linking, player/catalog/storage state, module calls through generated clients, idempotent retries, errors | | `csharp-sdk` | `Rudder.Sdk` 0.1.0 (.NET, namespace `RudderSdk`) | The same kernel surface for .NET: client options and transports, state and sync engine, generated module clients, exceptions | | `unity-sdk` | `build.rudder.sdk` 0.1.0 (Unity, UPM) | Unity setup on top of `Rudder.Sdk`: the `Rudder` component, configuration, main-thread state events, generated module clients | There is no server SDK: game backends call the `/game/v1` HTTP API with the environment's admin key. ## Which one should I use? - Use a **skill** when you want the agent to *write integration code* in your game. - Use **`llms.txt` / `llms-full.txt`** when the agent needs reference material about concepts, limits, or dashboard workflows. A typical setup installs the SDK skill for your client platform and drops `llms-full.txt` into the agent's context for reference. --- # Currency Shop URL: https://rudder.build/docs/use-cases/currency-shop/ import { Tabs, TabItem } from '@astrojs/starlight/components'; This recipe builds an in-game shop with the first-party [`store`](/docs/guides/first-party-modules/#store) module. Players pay with a wallet currency and receive items, currencies or counters. ## Dashboard setup 1. On the **Counters** page, create a `currency` counter, for example `gold`, and ship it in a [release](/docs/guides/releases/). Wallet operations need the counter to be in the environment's latest release. 2. Open **Modules** and install `store`. Its dependencies (`reward`, `inventory`, `items`) are installed with it. 3. In **Items**, create the goods you sell, for example `health_potion`. 4. In **Store → Offers**, create an offer: - **Name** and **slug** (the slug is what the client buys by). - **Price** — a currency and an amount. Leave it empty for a free offer. - **Reward** — items, currencies and counters the player receives. - **Schedule** — when the offer is on sale; empty means always. - **Segment** — which players see it; empty means everyone. - **Max purchases** — per player; empty means unlimited. - **Position** — order in the shop, lowest first. ## Generate the client With `store` installed, generate a typed client for your game (see [Generated module clients](/docs/guides/module-clients/)): ```bash rudder client generate --lang ts --out src/rudder.modules.ts rudder client generate --lang csharp --out Assets/Rudder/RudderModules.g.cs ``` ## List offers `list` returns the offers the calling player can buy right now: on sale, matching the segment, and under the purchase limit. ```ts import { modules } from './rudder.modules'; const m = modules(client); const { offers } = await m.store.list({}); for (const offer of offers) { const price = offer.price ? `${offer.price.amount} ${offer.price.currency}` : 'free'; console.log(offer.name, price, `${offer.purchases}/${offer.maxPurchases || '∞'}`); } ``` ```csharp using RudderSdk.Modules; var m = new RudderModules(client); var list = await m.Store.ListAsync(new StoreListArgs()); foreach (var offer in list.Offers) { Console.WriteLine($"{offer.Name}: {offer.Price?.Amount} {offer.Price?.Currency}"); } ``` Each offer has `slug`, `name`, `image`, `position`, `price` (`{currency, amount}` or null for a free offer), `reward` (`{currencies, items, counters}`), `maxPurchases` (0 means unlimited) and `purchases` (the player's purchase count). ## Buy ```ts import { RudderModuleError } from '@rudder/sdk'; import { type StoreBuyError } from './rudder.modules'; try { const { offer, boughtAt } = await m.store.buy({ offerSlug: 'health_pack' }); await client.player.reload(); // refresh wallet balances now } catch (error) { if (error instanceof RudderModuleError) { const code = error.code as StoreBuyError; // offer_not_found, offer_not_available, purchase_limit_reached, // insufficient_funds or another kernel code } } ``` ```csharp try { var purchase = await m.Store.BuyAsync(new StoreBuyArgs { OfferSlug = "health_pack" }); await client.Player.ReloadAsync(); } catch (RudderModuleException ex) when (ex.Code == StoreBuyErrors.PurchaseLimitReached) { ShowSoldOut(); } catch (RudderModuleException ex) { // ex.Code: offer_not_found, offer_not_available, insufficient_funds, ... } ``` A purchase runs in one transaction: the wallet is debited, the reward is granted, the purchase is recorded and a `store.purchased` event is emitted. If any step fails, nothing is written. `buy` returns the purchased offer and `boughtAt`. The SDK sends an idempotency key with the call and retries network errors with the same key, so a purchase is never charged twice because of a retry. ## Reading what the player owns Wallet balances are on the player profile: `client.player.value?.wallets` (TypeScript) or `client.Player.Value?.Wallets` (C#). Inventory rows live in the player resource `inventory`, which game clients cannot read directly. See [Wallet & Inventory](/docs/guides/inventory-and-wallet/#read-the-inventory) for exposing it through a module. ## Quests on purchases The [`quests`](/docs/guides/first-party-modules/#quests) module listens to `store.purchased`: `purchase_offer` objectives count purchases of an offer and `purchase_item` objectives count purchases whose reward contains an item. --- # Feature Flags URL: https://rudder.build/docs/use-cases/feature-flags/ import { Tabs, TabItem } from '@astrojs/starlight/components'; This recipe uses the first-party [`remote_config`](/docs/guides/first-party-modules/#remote_config) module for feature flags and live tuning values. ## Dashboard setup 1. Open **Modules** and install `remote_config`. 2. In **Remote config → Configs**, create configs. Each has a `key` and a typed `value`, for example `new_shop_enabled` = `false` and `reward_multiplier` = `1`. 3. Add overrides (on the config's detail page or in **Remote config → Overrides**) to change a value for some players or for a time window. An override has a `value` of the config's type, an optional `segment`, an optional `schedule` and a `priority`. Rows are live as soon as you save them; you do not need a release. Configs and overrides are separate in `staging` and `prod`; promote to copy them to `prod`. ## Read flags in the game Generate a typed client with `remote_config` installed (see [Generated module clients](/docs/guides/module-clients/)). `get` returns `{ values }`: for every config key, its `type` (`string`, `number`, `bool` or `json`) and its `value` as text for the calling player. The generated file adds helpers that parse a value by key. ```ts import { modules, remoteConfigBool, remoteConfigNumber } from './rudder.modules'; const config = await modules(client).remoteConfig.get({}); if (remoteConfigBool(config, 'new_shop_enabled') ?? false) { showNewShop(); } const multiplier = remoteConfigNumber(config, 'reward_multiplier') ?? 1; ``` ```csharp using RudderSdk.Modules; var m = new RudderModules(client); var config = await m.RemoteConfig.GetAsync(new RemoteConfigGetArgs()); RemoteConfigValues.TryGetBool(config, "new_shop_enabled", out var newShop); if (!RemoteConfigValues.TryGetNumber(config, "reward_multiplier", out var multiplier)) multiplier = 1; ``` A helper returns nothing (`undefined` or `false`) when the key is missing or has another type, so a default in code acts as the fallback. `json` values are parsed with `remoteConfigJson` / `RemoteConfigValues.TryGetJson`. The SDK does not cache module results. Call `get` again when you want fresh values, for example at login and when returning to the main menu. ## How overrides resolve For each config, the module picks the override with the highest `priority` whose schedule is active (or empty) and whose segment matches the player (or is empty). If there is none, the config's own value is used. Examples: - **Weekend event.** Override `reward_multiplier` to `2` with a schedule on Saturdays and Sundays. The module's `weekend_double_rewards` sample sets this up. - **Gradual rollout.** Override `new_shop_enabled` to `true` for a segment that matches part of your players. - **Kill switch.** Set the config's value to `false`; clients pick it up on their next `get`. ## Types An override value must have its config's type. The dashboard rejects an override whose value does not match. --- # Leaderboard Tournament URL: https://rudder.build/docs/use-cases/leaderboard-tournament/ import { Tabs, TabItem } from '@astrojs/starlight/components'; This recipe builds a weekly tournament with the first-party [`leaderboards`](/docs/guides/first-party-modules/#leaderboards) module. Players submit scores during the week. When the week ends, the module grants rewards by rank and clears the board. ## Goal - A board that collects scores for a week. - A live standings screen in the game client. - Rewards by final rank: for example rank 1 gets 500 gems, ranks 2–10 get 100 gems, ranks 11–100 get 25 gems. ## Dashboard setup 1. Open **Modules** in the dashboard at `https://app.rudder.build/` and install `leaderboards`. Its dependencies (`reward`, `inventory`, `items`) are installed with it. 2. Open **Leaderboards → Boards** and create a board: - **Name** — for example `Weekly Arena`. The **slug** (`weekly_arena`) is filled from the name; the game client submits scores to it. - **Reset period** — `weekly`. Boards reset at 00:00 UTC on Mondays (`daily` resets every day, `monthly` on the first day of the month). - **Order** — `desc` when the highest score wins, `asc` for time-attack boards where the lowest wins. - **Max entries** — optional cap on how many entries `top` returns. - **Rewards** — rank ranges with a reward each: `1–1`, `2–10`, `11–100`. The dashboard rejects overlapping ranges and ranks below 1. 3. Create the board in `staging` first, test it there, then [promote](/docs/guides/releases/#promote-to-prod) to `prod`. ## Generate the client With `leaderboards` installed, generate a typed client (see [Generated module clients](/docs/guides/module-clients/)): ```bash rudder client generate --lang ts --out src/rudder.modules.ts rudder client generate --lang csharp --out Assets/Rudder/RudderModules.g.cs ``` ## Submit scores Call `submit` when a match ends. The module keeps each player's best score: the highest on `desc` boards, the lowest on `asc` boards, so players can submit as often as they like. The result is the stored best score. ```ts import { modules } from './rudder.modules'; const m = modules(client); const { score: best } = await m.leaderboards.submit({ slug: 'weekly_arena', score: finalScore }); ``` ```csharp using RudderSdk.Modules; var m = new RudderModules(client); var best = await m.Leaderboards.SubmitAsync(new LeaderboardsSubmitArgs { Slug = "weekly_arena", Score = finalScore }); ``` `submit` fails with `board_not_found` (HTTP 422, `RudderModuleError` / `RudderModuleException`) for an unknown slug. An empty slug or a score that is not a JSON number is rejected by argument validation with `invalid_parameters`. Scores can be fractional, for example times on `asc` boards. ## Show standings `top` returns `{ entries }` in rank order, each with `rank` (from 1), `playerId` and `score`. `limit` (0 to 1000) defaults to 10 and is capped by the board's max entries. ```ts const { entries } = await m.leaderboards.top({ slug: 'weekly_arena', limit: 100 }); for (const entry of entries) { console.log(entry.rank, entry.playerId, entry.score); } ``` ```csharp var top = await m.Leaderboards.TopAsync(new LeaderboardsTopArgs { Slug = "weekly_arena", Limit = 100 }); foreach (var entry in top.Entries) { Console.WriteLine($"{entry.Rank}. {entry.PlayerId} — {entry.Score}"); } ``` Entries do not carry player names; look them up in your own player data if you need them. ## Payout and reset The module's `reset` function runs hourly on a schedule, in both `staging` and `prod`. For each board with a reset period, it resets the board once the next boundary after its last reset has passed: 1. Players whose rank falls in a reward range receive that reward through the `reward` module (currencies, items and counters). 2. All entries of the board are deleted. A board that was never reset counts from its creation time. Admins can also reset a board immediately with the **Reset** button on its detail page, which grants the rewards the same way. Players do not need to claim anything: rewards land in the wallet and inventory during the reset. ## Server-side alternative To check scores before paying out, for example for anti-cheat, leave the board's rewards empty and pay out from your backend. Read the final standings from the admin resource API with the environment's admin key: ```bash curl -H "X-API-Key: $RUDDER_ADMIN_KEY" \ "https://api.rudder.build/game/v1/resources/entries/rows?board_eq=weekly_arena&_sort=score&_order=desc&_start=0&_end=100" ``` The response is `{ items, total }`; without `_start`/`_end` the endpoint returns 50 rows. Then grant rewards with `POST /game/v1/players/wallet/adjust` (one batch of up to 100 players, with an `idempotencyKey`) and reset the board from the dashboard. ## Edge cases and limits - **Ties.** Entries are sorted by score only, so the order of equal scores is not guaranteed. If tied players must get the same reward, use wider ranges or pay out from your backend. - **Late submissions.** The board accepts scores at any time. A submission just before the boundary counts until the reset runs, which can be up to an hour after the boundary. - **`never` boards** are never reset by the schedule. Use the **Reset** button to end a one-off tournament. - **Environments.** Boards, entries and resets are separate in `staging` and `prod`. --- # Custom Auth Webhook URL: https://rudder.build/docs/webhooks/custom-auth/ Custom login lets your own backend decide who a player is. When a game client calls `POST /sdk/v1/authorization/custom`, Rudder POSTs the client's `customData` to an HTTP endpoint you operate, and your response determines the player identity. The same endpoint also serves identity **linking**: when a logged-in player calls `POST /sdk/v1/authorization/custom/link`, Rudder calls the same webhook with the same request shape and attaches the returned subject to that player. The contract does not distinguish the two cases — your endpoint answers identically. This page documents the webhook side of the contract. For the client side, see [Authentication](../../guides/authentication/). ## Configuration Custom providers are configured **per project, per environment**, and there can be several of them, each under a **name**: - `default` — the provider used when the client omits `provider` in the login/link request. Its identities are bound as `(custom, subject)`. - any other name — a named provider, e.g. `steam`. Its identities are bound as `(custom:, subject)`, a separate namespace per provider. Names must match `^[a-z0-9][a-z0-9_-]{0,31}$` and cannot be `google`, `apple`, or `device` (reserved). Each provider has three settings: - `enabled` — master switch. When off (or the provider is not configured), requests for it fail with `401 custom auth disabled`. - `url` — the HTTP(S) endpoint Rudder calls. - `secret` — a shared secret used to sign requests (see below). Each provider has its own secret. Set them in the dashboard under **Project Settings → Authentication** (https://app.rudder.build/), or via the platform API: `GET` / `PUT /platform/v1/projects/{projectId}/auth-providers`. The PUT body is `{environment, authProviders: {custom: {"": {enabled, url, secret}}, ...}}` (the same object also carries `google` and `apple` blocks). Changes take effect immediately — no deploy or restart needed. ## Request Rudder sends: ``` POST {url} Content-Type: application/json X-Signature: {"customData": { ... }} ``` - The body is exactly `{"customData": }` where `` is the JSON object the game client passed to `loginWithCustom` / `LoginWithCustomAsync`. The platform does not inspect or augment it. - `X-Signature` is the lowercase hex-encoded **HMAC-SHA256 of the raw request body**, keyed with the **provider's** `secret`. Always verify it before trusting the payload — compute HMAC over the exact bytes received, not a re-serialized JSON document. - `customData` comes from the game client, so treat its contents as untrusted input even with a valid signature. Don't put secrets into it on the client. If your backend is written in Go, the server SDK ships a helper: `rudder.VerifyWebhookSignature(secret, body, signature)` returns `true` when the signature matches. ## Response Your endpoint must answer `200 OK` with a JSON body: ```json { "subject": "user-12345", "nickname": "Alice", "avatarUrl": "https://cdn.example.com/avatars/alice.png", "data": { "profile": { "tier": "gold", "level": 42 }, "entitlements": ["season-pass"] } } ``` - `subject` (**required**) — your stable, unique identifier for the player. Rudder binds the identity `(project, environment, provider, subject)` to a player account, where `provider` is `custom` for the default provider or `custom:` for a named one: the first login with a given subject creates the player, later logins with the same subject return the same player. The environment comes from the SDK key, so the same subject under the staging key and the prod key yields two separate players. Never reuse or change a subject — that would merge or orphan accounts. - `nickname` (optional) — when non-empty, it **overrides** the nickname the client sent, and on later logins it also updates the existing player's nickname. Omit it (or send an empty string) to leave nickname handling to the client. - `avatarUrl` (optional) — when non-empty, it is stored as the player's avatar URL: set on creation and updated on later logins whenever you send it. Omit it (or send an empty string) to leave the avatar alone. - `data` (optional) — an object written straight into the player's [storage](../../guides/storage/) on every successful login. Each top-level key becomes a storage key and its JSON value is stored verbatim, so the example above writes the key `profile` with value `{"tier":"gold","level":42}` and the key `entitlements` with value `["season-pass"]`. A `200` with an unparseable body or an empty `subject` is treated as `502 custom auth invalid response` on the client. ### Writing player storage from the webhook `data` is the only trusted way to seed or refresh player storage at login time — it comes from your backend, not from the game client. - **Merge, not replace.** Only the keys present in `data` are touched. Keys the player already has and that `data` omits are left alone. - **Same namespace as the SDK.** Keys land in the player's storage root, alongside anything the client wrote with `setStorage`. A key present in both wins from the webhook — pick key names deliberately if your game also writes storage from the client. - **Same limits as the storage API:** key at most 128 characters, value at most 64 KiB, 1000 keys per player. Exceeding any of them fails the login with `401 custom auth invalid data` — nothing is written. - **The write is part of the login.** If storage cannot be written, the player does not get a token. Omit `data` (or send an empty object) when there is nothing to write. - Values are stored as raw JSON, so the client reads them back exactly as you sent them. ## Error semantics What your endpoint answers decides what the game client gets: | Your response | Retried? | Client receives | | --- | --- | --- | | `200` + valid body | — | Token pair (login succeeds) | | `200` + bad JSON / empty `subject` | No | `502 custom auth invalid response` | | `200` + `data` breaking storage limits | No | `401 custom auth invalid data` | | Any `4xx` | No | `401 custom auth rejected` | | Any `5xx` | Yes | `503 custom auth unavailable` after retries are exhausted | | Timeout / connection error | Yes | `503 custom auth unavailable` after retries are exhausted | Use a `4xx` (typically `401` or `403`) to deny login — for example when the credentials in `customData` are invalid. A `4xx` is final: Rudder does not retry it and the player sees an authentication failure immediately. ## Timeouts and retries - Each attempt has a **3 second** timeout (connect + response). - Rudder makes up to **3 attempts**, waiting **300 ms** between them. - Only network errors, timeouts, and `5xx` responses are retried. `4xx` responses fail immediately. - Worst case, a failed login blocks the client for roughly 10 seconds (3 × 3s + 2 × 300ms). Keep your endpoint fast — it sits on the player's login path. If your user lookup is slow, the whole game's logins are slow. ## After a successful webhook call Once your endpoint vouches for the subject, the standard login flow runs: 1. Identity lookup by `(projectId, environment, provider, subject)` — `provider` is `custom` for the default provider or `custom:` for a named one. 2. If the player exists but is banned or deleted, login still fails (`403 player banned` / `403 player deleted`) — your webhook approving the credentials does not override a ban. 3. If the subject is new, the player and identity are created in one transaction, the `player.created` kernel event is emitted, and the nickname (webhook's, falling back to the client's) plus the client's `region` / `language` are stored on the profile. 4. If the response carried `data`, its keys are written to the player's storage — for both new and returning players. 5. The client receives an access token (24 h) and refresh token (7 days) — see [Authentication](../../guides/authentication/#tokens). On a **link** call (`POST /sdk/v1/authorization/custom/link`, with the player's bearer token) steps differ: the returned subject is attached to the already-authenticated player — no player is created, no tokens are issued (the response is `204`), and if the subject is already bound to another player the call fails with `409 identity already linked`. A `data` payload is still written to that player's storage.