# @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.loginWithCustom({ customData })`, `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) | | Quests | `client.quests.list()` / `client.quests.claim(id)` / `client.quests.reportProgress(metric, amount)` | | Scenario effects | `client.effects.on*` | Type the remote config for `client.remoteConfig`: ```ts interface GameConfig extends Record { player_speed: number; feature_x: boolean; } const client = new RudderClient({ /* … */ }); client.remoteConfig.get('player_speed', 200); // number ``` ## 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" ``` ## 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(state: { onChange(cb: () => void): () => void; data: T | undefined }) { return useSyncExternalStore( (onStoreChange) => state.onChange(() => onStoreChange()), () => state.data, ); } function Wallet() { const profile = useSynced(client.player); return
{profile?.wallets?.map((w) => `${w.currency}: ${w.balance}`).join(', ')}
; } ``` 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.