7.2 KiB
@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):
npm install @rudder/js-sdk
Quickstart
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) |
| 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:
interface GameConfig extends Record<string, unknown> {
player_speed: number;
feature_x: boolean;
}
const client = new RudderClient<GameConfig>({ /* … */ });
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.
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:
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,onConfigChangedonWait,onQuest,onBattlePass,onBattlePassLevelonScenarioCompleted,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:
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:
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:
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
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.