2026-08-12 14:02:25 +03:00
|
|
|
# @rudder/js-sdk
|
|
|
|
|
|
|
|
|
|
LiveOps Web SDK for browser games — device auth, observable player state,
|
|
|
|
|
stores, remote config, leaderboards, quests, battle pass, and a scenario runtime
|
|
|
|
|
surfaced as typed effects.
|
|
|
|
|
|
|
|
|
|
## Install
|
|
|
|
|
|
|
|
|
|
The package is published to a private 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 as usual (anonymous read access, no token needed):
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
npm install @rudder/js-sdk
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Quickstart
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
import { RudderClient } from '@rudder/js-sdk';
|
|
|
|
|
|
|
|
|
|
const client = new RudderClient({
|
|
|
|
|
baseUrl: 'https://api.rudder.build',
|
|
|
|
|
projectKey: 'your-project-key',
|
|
|
|
|
// tokenStore is optional: defaults to localStorage with an in-memory
|
|
|
|
|
// fallback where localStorage is unavailable (SSR, private mode).
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await client.auth.loginWithDevice(); // region 'global', language 'en'
|
|
|
|
|
// or: await client.auth.loginWithDevice({ region: 'eu', language: 'de', nickname: 'Bob' });
|
|
|
|
|
|
|
|
|
|
// Observable domains — read `.data`, subscribe with `onChange`, force `reload()`.
|
|
|
|
|
const profile = client.player.data;
|
|
|
|
|
const speed = client.remoteConfig.get('player_speed', 200);
|
|
|
|
|
|
|
|
|
|
client.stores.onChange(({ data: stores }) => renderStores(stores ?? []));
|
|
|
|
|
|
|
|
|
|
// Scenario-driven UI arrives through effects.
|
|
|
|
|
client.effects.onStoreOffer((offer) => showOffer(offer));
|
|
|
|
|
client.effects.onScenarioFailed(({ error }) => console.error('scenario failed', error));
|
|
|
|
|
|
|
|
|
|
// Tear down background work (sync poll, wait timers) on unmount / HMR.
|
|
|
|
|
client.dispose();
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Surface
|
|
|
|
|
|
|
|
|
|
| Area | Access |
|
|
|
|
|
|---|---|
|
|
|
|
|
| Auth | `client.auth.loginWithDevice()`, `client.auth.logout()`, `client.auth.isAuthenticated`, `client.auth.onAuthStateChange(cb)` |
|
|
|
|
|
| Player / wallets | `client.player` (observable) |
|
|
|
|
|
| Inventory | `client.inventory` (observable, catalog-merged) |
|
|
|
|
|
| Catalog | `client.catalog` (observable) |
|
|
|
|
|
| Stores | `client.stores` (observable) + `client.stores.purchase(slug, offerId)` |
|
|
|
|
|
| Remote config | `client.remoteConfig.get(key, default)` (observable, typed) |
|
|
|
|
|
| Storage | `client.storage` (observable) + `.save(items)` / `.delete(type)` |
|
|
|
|
|
| Leaderboards | `client.leaderboards.findBySlug(slug)` → `handle.submit(score)` / `handle.list(limit?)` |
|
|
|
|
|
| Battle pass | `client.battlePass` (getProgress / addXp / claimReward / purchasePremium) |
|
2026-08-25 16:25:32 +03:00
|
|
|
| Quests | `client.quests.list()` / `client.quests.claim(id)` / `client.quests.reportProgress(metric, amount)` |
|
2026-08-12 14:02:25 +03:00
|
|
|
| Scenario effects | `client.effects.on*` |
|
|
|
|
|
|
|
|
|
|
Type the remote config for `client.remoteConfig`:
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
interface GameConfig extends Record<string, unknown> {
|
|
|
|
|
player_speed: number;
|
|
|
|
|
feature_x: boolean;
|
|
|
|
|
}
|
|
|
|
|
const client = new RudderClient<GameConfig>({ /* … */ });
|
|
|
|
|
client.remoteConfig.get('player_speed', 200); // number
|
|
|
|
|
```
|
|
|
|
|
|
2026-08-25 16:25:32 +03:00
|
|
|
## Quests
|
|
|
|
|
|
|
|
|
|
`client.quests` covers the player's global quests — list with per-objective
|
|
|
|
|
progress, claim, and metric reports. These are distinct from scenario quest
|
|
|
|
|
nodes, which advance through the `onQuest` effect's `QuestSession`. Global
|
|
|
|
|
quests have no live sync: re-list after a claim or report.
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
const quests = await client.quests.list();
|
|
|
|
|
for (const quest of quests) {
|
|
|
|
|
if (quest.status === 'completed' && quest.id) {
|
|
|
|
|
await client.quests.claim(quest.id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Custom metrics advance matching objectives server-side; the call returns
|
|
|
|
|
// the ids of quests completed by this report.
|
|
|
|
|
const completedIds = await client.quests.reportProgress('kills', 1);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Purchase metrics are reported automatically when a purchase goes through
|
|
|
|
|
`client.stores`; the `QuestMetrics` helpers name the format so quest configs
|
|
|
|
|
and client code agree on it:
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
import { QuestMetrics } from '@rudder/js-sdk';
|
|
|
|
|
|
|
|
|
|
QuestMetrics.purchaseOffer('starter-pack'); // "purchase.offer:starter-pack"
|
|
|
|
|
QuestMetrics.purchaseItem('moonberry'); // "purchase.item:moonberry"
|
|
|
|
|
```
|
|
|
|
|
|
2026-08-12 14:02:25 +03:00
|
|
|
## Scenario effects
|
|
|
|
|
|
|
|
|
|
The scenario runtime is not exposed directly; scenario nodes surface through
|
|
|
|
|
`client.effects`:
|
|
|
|
|
|
|
|
|
|
- `onNotification`, `onStoreOffer`, `onLeaderboard`, `onConfigChanged`
|
|
|
|
|
- `onWait`, `onQuest`, `onBattlePass`, `onBattlePassLevel`
|
|
|
|
|
- `onScenarioCompleted`, `onScenarioFailed`
|
|
|
|
|
|
|
|
|
|
The scenario engine (and its IndexedDB persistence) loads lazily on first
|
|
|
|
|
login — a client that only reads domains never pulls it into the page.
|
|
|
|
|
|
|
|
|
|
Errors thrown inside effect handlers are reported via the `onEffectError`
|
|
|
|
|
client option (default: `console.error`).
|
|
|
|
|
|
|
|
|
|
## Error handling
|
|
|
|
|
|
|
|
|
|
All SDK errors extend `RudderError` and carry an optional machine-readable
|
|
|
|
|
`code`:
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
import {
|
|
|
|
|
RudderError,
|
|
|
|
|
RudderNetworkError,
|
|
|
|
|
RudderHttpError,
|
|
|
|
|
RudderAuthError,
|
|
|
|
|
RudderErrorCodes,
|
|
|
|
|
} from '@rudder/js-sdk';
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await client.player.reload();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error instanceof RudderAuthError) {
|
|
|
|
|
// 401 — session expired and the refresh failed; the client already
|
|
|
|
|
// cleared tokens and emitted 'signed-out' via onAuthStateChange.
|
|
|
|
|
showLogin();
|
|
|
|
|
} else if (error instanceof RudderHttpError) {
|
|
|
|
|
if (error.code === RudderErrorCodes.runExpired) {
|
|
|
|
|
// typed server error code
|
|
|
|
|
}
|
|
|
|
|
console.error(error.status, error.code, error.body);
|
|
|
|
|
} else if (error instanceof RudderNetworkError) {
|
|
|
|
|
// fetch failed / timeout — the underlying error is in error.cause
|
|
|
|
|
console.error('offline?', error.cause);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Invalid client options (missing `baseUrl` / `projectKey`) throw `RudderError`
|
|
|
|
|
with `code: 'sdk/invalid-options'` from the constructor.
|
|
|
|
|
|
|
|
|
|
Track the session lifecycle without polling the token store:
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
client.auth.onAuthStateChange((state) => {
|
|
|
|
|
// fires immediately with the current state, then on every change
|
|
|
|
|
setSignedIn(state === 'signed-in');
|
|
|
|
|
});
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Data liveness model
|
|
|
|
|
|
|
|
|
|
Observable domains (`player`, `inventory`, `catalog`, `stores`, `remoteConfig`,
|
|
|
|
|
`storage`) are warmed once at login and then kept fresh by a revision poll:
|
|
|
|
|
the client asks the gateway for entity revisions every **30 s (±20% jitter)**
|
|
|
|
|
and reloads only the entities whose revision grew. Polling **pauses while the
|
|
|
|
|
tab is hidden** and resumes on `visibilitychange`. Mutations (purchases,
|
|
|
|
|
storage writes, scenario callbacks) invalidate the affected domains
|
|
|
|
|
immediately, so subscribers see fresh data without waiting for the next poll.
|
|
|
|
|
Expect data to be eventually consistent within one poll interval; call
|
|
|
|
|
`reload()` when you need a guarantee.
|
|
|
|
|
|
|
|
|
|
## React recipe
|
|
|
|
|
|
|
|
|
|
`SyncedState.onChange` fires immediately with the current snapshot, which maps
|
|
|
|
|
straight onto `useSyncExternalStore`:
|
|
|
|
|
|
|
|
|
|
```tsx
|
|
|
|
|
import { useSyncExternalStore } from 'react';
|
|
|
|
|
|
|
|
|
|
function useSynced<T>(state: { onChange(cb: () => void): () => void; data: T | undefined }) {
|
|
|
|
|
return useSyncExternalStore(
|
|
|
|
|
(onStoreChange) => state.onChange(() => onStoreChange()),
|
|
|
|
|
() => state.data,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Wallet() {
|
|
|
|
|
const profile = useSynced(client.player);
|
|
|
|
|
return <div>{profile?.wallets?.map((w) => `${w.currency}: ${w.balance}`).join(', ')}</div>;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
The same pattern works for `client.remoteConfig`, `client.stores`, and
|
|
|
|
|
`client.auth.onAuthStateChange`.
|
|
|
|
|
|
|
|
|
|
## Development
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
npm run typecheck # tsc over src and tests
|
|
|
|
|
npm test # unit + local e2e (jsdom)
|
|
|
|
|
npm run build # tsup → dist (ESM + CJS + d.ts)
|
|
|
|
|
npm run test:e2e-prod # drives a seeded project on the live gateway
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Generated wire types (`src/generated`) come from the gateway's `apigen`; run
|
|
|
|
|
`npm run generate` to refresh them. Do not edit them by hand.
|