@looplay/sdk
v0.8.15
Published
LooplaySDK (auth + points) for game integrations
Readme
@looplay/sdk
Client SDK for games integrating with Looplay: player tracking (views, play sessions, matches, custom actions), the game's coin store (read-only), and authentication.
API Reference: https://docs.looplay.gg/build-on-loopplay/looplay-sdk
- Install
- Core concept: one SDK integration = one game
- Tracking a game — hosted on Looplay or published anywhere else
LooplaySDK— direct integration (your own backend/auth)- Store — browsing and purchases
- Rewarded ads via parent window
- Drop-in
<script>tag (no build step required) - Auth storage tradeoff
- Errors
Install
npm install @looplay/sdkCore concept: one SDK integration = one game
Every tracking and store call is scoped to one game, identified by its
public appId (also called gameKey — the same value, sent as the
x-game-key header under the hood). There is no multi-game catalog
browsing in this SDK; register your game with a Looplay creator account
first and use that appId everywhere below.
import { LooplayIframeAuth } from '@looplay/sdk';
export const looplayAuth = new LooplayIframeAuth({
appId: 'YOUR_APP_ID', // from your creator dashboard — required
});Tracking a game — hosted on Looplay or published anywhere else
LooplayIframeAuth is the single class for tracking, in two modes that the
same game code doesn't need to branch on:
- Embedded in the Looplay web app's iframe. The host page pushes
{ type: 'LOOPLAY_AUTH', jwt, gameId, apiUrl }via postMessage once the game requests it. Tracking calls use the real user's bearer token — full tracking, eligible for quest/reward. - Standalone — published anywhere else (your own domain, itch.io, a
non-Vite build, ...), or the iframe parent never responds. The SDK falls
back to a locally-generated,
localStorage-persisted anonymous device id. Tracking still works and counts toward play/analytics stats, but never feeds the quest/reward engine — there's no real account to award.
Either way you need an appId: register your game with a Looplay
creator account first — anonymous tracking is rejected for games without one
(authenticated tracking inside the iframe still works without it, for
backward compatibility, but you should set one regardless).
import { LooplayIframeAuth } from '@looplay/sdk';
// 1. Initialize the SDK
// apiUrl and parentOrigin are automatically configured:
// - parentOrigin defaults to import.meta.env.VITE_PARENT_ORIGIN (or document.referrer)
// - apiUrl defaults to import.meta.env.VITE_API_URL (or 'https://api.looplay.gg')
export const looplayAuth = new LooplayIframeAuth({
appId: 'YOUR_APP_ID', // from your creator dashboard
// Optional: Automatic active playtime tracking (enabled by default)
autoPlaytime: {
enabled: true,
intervalMs: 30000, // auto-sync playtime to backend every 30s (0 to disable)
pauseOnHidden: true, // auto-pause when browser tab is hidden
pauseOnBlur: true, // auto-pause when browser window loses focus
onSyncError: (error, seconds) => console.warn('Playtime sync failed', seconds, error),
},
// Optional: Auto-request rewarded ads after match activity
autoRequestAds: {
enabled: true,
afterActionCount: 3, // show ad every 3 actions/matches
chance: 0.5, // 50% chance to show ad
cooldownMs: 60000, // min 1 minute between requests
maxRequestsPerSession: 5,
}
});
// 2. Start listening for auth and lifecycle events
looplayAuth.init();
looplayAuth.initLifecycleTracking(); // auto-emits GAME_STARTED once per session
// 3. Anywhere in the game — identical code whether hosted on Looplay or not:
looplayAuth.canTrack();
looplayAuth.subscribe((state) => { /* re-render when auth arrives/clears */ });
await looplayAuth.trackView();
// Call once when the user presses Play (starts auto-playtime tracking)
looplayAuth.startPlaySession();
// Optional: Pause / Resume active playtime tracking (e.g. game pause menu)
looplayAuth.pausePlay();
looplayAuth.resumePlay();
// Track / complete play session (playTimeSeconds is auto-calculated if omitted)
await looplayAuth.completePlaySession(); // or await looplayAuth.trackPlay();
// Track when a match/level ends
await looplayAuth.trackMatch(matchId, { durationSeconds, isWin: true });
// Emit custom actions for quests/analytics
await looplayAuth.emitGameEvent('CUSTOM_ACTION', { value: 1 });
// Manually request rewarded ads (returns true if watched)
const adWatched = await looplayAuth.requestAds();
if (adWatched) {
// Give reward to the player
}Every play session is backed by a short-lived, server-signed token — the SDK
fetches and caches it automatically the first time trackPlay/trackMatch/
emitGameEvent needs it after startPlaySession(). You never see or manage
this token directly.
Tracking call reference
| Call | When |
| --- | --- |
| trackView() | Once when the game view opens, before any play/match tracking |
| startPlaySession() | Once when the user presses Play. Starts auto playtime tracking |
| pausePlay() / resumePlay() | When in-game pause menu is opened/closed (pauses/resumes playtime calculation) |
| completePlaySession(playTimeSeconds?) | Ends play session and reports total active playtime (auto-computed from SDK tracker if omitted) |
| trackPlay(playTimeSeconds?) | Syncs play time (auto-computed from SDK tracker if omitted) |
| trackMatch(matchId, { durationSeconds, isCompleted?, isWin? }) | Once per round/match end. matchId is the idempotency anchor — safe to call twice |
| emitGameEvent(actionCode, { value?, refId?, payload? }) | For quest/task progress and custom analytics — e.g. 'LEVEL_UP', 'ITEM_COLLECTED' |
LooplaySDK — direct integration (your own backend/auth)
If you're not embedding via iframe (e.g. a native/Telegram Mini App
integration with your own auth flow), use LooplaySDK directly:
import { LooplaySDK } from '@looplay/sdk';
const sdk = new LooplaySDK({
baseUrl: 'https://api.looplay.gg',
auth: { provider: telegramAuthProvider }, // any AuthProvider — see auth/types.ts
});
await sdk.init({ gameId: 'YOUR_APP_ID', verifyMode: 'strict' });
// `gameId` here is the game's public appId (same as `x-game-key`/`appId` above).
await sdk.trackView();
sdk.startPlaySession();
await sdk.trackPlay(playTimeSeconds);
await sdk.trackMatch(matchId, { durationSeconds, isWin: true });
await sdk.emit('CUSTOM_ACTION', { value: 1 });
const profile = await sdk.getMyProfile();verifyMode: 'strict' resolves gameId against the backend on init() and
throws if the game doesn't exist or isn't live — a lightweight, read-only,
unauthenticated check. Use 'none' (default) to skip it.
Store — browsing and purchases
The SDK exposes the game's coin store — storefront, public asset catalog, the caller's own owned assets/purchase history, and (for a logged-in player) purchasing an offer:
const store = await sdk.api!.getStore(gameId); // sections + offers, no auth required
const assets = await sdk.api!.listGameAssets(gameId); // public asset catalog, no auth required
const owned = await sdk.api!.listMyGameAssets(gameId); // requires a logged-in user
const purchases = await sdk.api!.listMyStorePurchases(gameId); // requires a logged-in user
// Spends the player's coin balance — requires a logged-in user, nothing
// else. No backend or secret needed: safe to call directly from the game.
const purchaseAttemptId = crypto.randomUUID(); // reuse for retries of this purchase
const abortController = new AbortController();
const result = await sdk.api!.purchaseStoreOffer(gameId, 'starter_pack', {
quantity: 1,
// Keep this stable if your game retries the same logical purchase after an error.
requestId: purchaseAttemptId,
signal: abortController.signal,
});purchaseStoreOffer signs a short-lived checkout token and redeems it in
one call — the same safety mechanism as the play-session token used for
tracking, not a static secret. It generates a request ID when omitted and
retries one transient failure with that same ID. For a later retry after the
method rejects, pass the same caller-owned requestId again. A per-request
signal can cancel both HTTP calls; timeoutMs can override the SDK's
configured default. This is enough for creators with no backend of their own.
Set a default API request timeout on either SDK facade with timeoutMs (for
example, new LooplaySDK({ timeoutMs: 15000 }) or
new LooplayIframeAuth({ appId: 'YOUR_APP_ID', timeoutMs: 15000 })). Omit it
or set it to 0 to leave requests without a timeout. Pass an AbortSignal
to purchaseStoreOffer() when the caller no longer needs its result; the
lower-level HttpClient.request() also accepts a signal per request.
Real-time updates (optional, separate import)
Purchases (and any other balance change — quest rewards, referral payouts,
...) also push a WebSocket event, useful for reacting from a different tab
than the one that made the call, or after a purchase completes out-of-band
(e.g. a hosted-checkout redirect flow). This lives in a separate
@looplay/sdk/realtime subpath, not the main @looplay/sdk import — it
pulls in socket.io-client, which most integrations (tracking-only or
store-browsing-only) don't need:
npm install socket.io-client # peer dependency, only needed if you import '@looplay/sdk/realtime'import { WsClient } from '@looplay/sdk/realtime';
const ws = new WsClient({
baseUrl: 'https://api.looplay.gg',
getAccessToken: () => sdk.getAccessToken(), // reuse whatever you already pass to LooplaySDK/ApiClient
subscribeToAuthChanges: (listener) =>
sdk.auth?.on('sessionChanged', () => listener()) ?? (() => {}),
});
const unsubscribe = ws.onStorePurchase((event) => {
console.log(`Got ${event.quantity}x ${event.name}`, event.items);
});
ws.onBalanceChange((event) => {
console.log('Balance changed', event.changeAmount, event.balanceType);
});
// later
unsubscribe();The connection is opened lazily on the first onBalanceChange/
onStorePurchase call and closed once the last listener unsubscribes — a
WsClient you never subscribe on never opens a socket. Call
ws.disconnect() to close it immediately regardless of active listeners.
When subscribeToAuthChanges is supplied, the client connects after login
and reconnects with the latest token after session changes. The client also
resolves the token again for every Socket.IO handshake/reconnect.
Backend-initiated purchases (optional)
If you do have your own backend and want to initiate a purchase without
the player's browser involved (e.g. an admin grant, or a payment webhook),
use ServiceClient's creator+game secret-pair methods instead — never
embed x-creator-key/x-game-secret in client/browser code:
import { ServiceClient } from '@looplay/sdk';
const service = new ServiceClient({ baseUrl: 'https://api.looplay.gg' });
const auth = {
creatorKey: process.env.LOOPLAY_CREATOR_KEY!,
gameSecret: process.env.LOOPLAY_GAME_SECRET!,
bearerToken: playerAccessToken, // the specific player you're purchasing on behalf of
};
const intent = await service.createGameStoreCheckoutIntent(auth, {
offerCode: 'starter_pack',
quantity: 1,
});
const result = await service.purchaseGameStoreOffer(auth, {
offerCode: intent.offerCode,
requestId: intent.requestId,
quantity: intent.quantity,
checkoutToken: intent.checkoutToken,
});The player (if connected) receives the same store_purchase_completed
WebSocket event either way.
Rewarded ads via parent window
When your game runs inside an iframe (such as inside a Telegram Mini App or web portal), the game cannot or should not directly initialize mobile ad SDKs (like Adsgram). Instead, the SDK provides a postMessage bridge allowing the game in the iframe to request rewarded ads from the parent host window.
┌─────────────────────────────────┐
│ Game (inside iframe) │
│ looplayAuth.requestAds() │
└───────────────┬─────────────────┘
│ postMessage: LOOPLAY_REQUEST_ADS (requestId, gameId)
▼
┌─────────────────────────────────┐
│ Parent Window / Shell │
│ (Telegram Mini App / Adsgram) │
└───────────────┬─────────────────┘
│ postMessage: LOOPLAY_ADS_WATCHED / LOOPLAY_ADS_UNAVAILABLE
▼
┌─────────────────────────────────┐
│ SDK resolves Promise<boolean> │
│ true = ad watched (reward) │
│ false = unavailable/timed out│
└─────────────────────────────────┘1. Game Implementation (Inside iframe)
Method A: Manual Trigger (Revive, Double Coins, Bonus Items)
Call looplayAuth.requestAds() whenever a player clicks an ad button:
// Optional: customize timeout (default is 35000ms)
const adWatched = await looplayAuth.requestAds({ timeoutMs: 10000 });
if (adWatched) {
// Apply your game's reward policy after the host/ad flow completes.
givePlayerReward();
} else {
// Ad was closed early, unavailable, timed out, or running in standalone mode
showNotice("No ad available at the moment");
}Method B: Automated Trigger (autoRequestAds)
Configure autoRequestAds during SDK initialization to automatically prompt for rewarded ads after matches without manual trigger code:
import { LooplayIframeAuth } from '@looplay/sdk';
export const looplayAuth = new LooplayIframeAuth({
appId: 'YOUR_APP_ID',
autoRequestAds: {
enabled: true,
afterActionCount: 3, // Trigger check every 3 completed matches (trackMatch)
chance: 0.5, // 50% chance to request when threshold is reached (0.0 to 1.0)
cooldownMs: 60000, // Minimum 60 seconds between ad requests
maxRequestsPerSession: 5, // Cap at 5 ads per play session
},
});Note on Standalone Mode: If the game is loaded standalone outside an iframe (
window.parent === window),requestAds()immediately resolvesfalsewithout throwing an error.
true means the configured parent host sent LOOPLAY_ADS_WATCHED. This SDK
does not verify an ad-network receipt or grant a platform reward. For valuable
or account-bound rewards, confirm completion with a trusted server-side flow
before granting them in the game.
2. Message Protocol Specification
The SDK exchanges standard postMessage payloads with window.parent:
| Direction | Event type | Payload Fields | Description |
|---|---|---|---|
| Iframe $\rightarrow$ Parent | LOOPLAY_REQUEST_ADS | requestId (string)gameId (string) | Game requests parent to display a rewarded ad. |
| Parent $\rightarrow$ Iframe | LOOPLAY_ADS_WATCHED | requestId (string) | Ad finished completely. SDK resolves true. |
| Parent $\rightarrow$ Iframe | LOOPLAY_ADS_UNAVAILABLE | requestId (string) | Ad failed, closed prematurely, or none available. SDK resolves false. |
3. Parent Window Implementation (Telegram Mini App / Adsgram)
In your parent host wrapper page (e.g. Next.js, HTML shell wrapping the game iframe), listen for LOOPLAY_REQUEST_ADS and bridge to your ad network:
// Example: Parent window integrating Adsgram (Telegram Mini App)
import { AdController } from 'adsgram'; // or CDN script
// Initialize Adsgram controller
const AdControllerInstance = window.Adsgram?.init({
blockId: 'YOUR_ADSGRAM_BLOCK_ID',
debug: false,
});
window.addEventListener('message', async (event: MessageEvent) => {
const data = event.data;
if (!data || data.type !== 'LOOPLAY_REQUEST_ADS') return;
const { requestId, gameId } = data;
const iframeSource = event.source as WindowProxy | null;
const targetOrigin = event.origin;
try {
// Show rewarded ad via Adsgram
await AdControllerInstance.show();
// Notify iframe that ad was watched successfully
iframeSource?.postMessage(
{
type: 'LOOPLAY_ADS_WATCHED',
requestId,
},
targetOrigin
);
} catch (error) {
// User skipped, ad failed to load, or network error
console.warn('[Ads] Ad failed or unavailable:', error);
iframeSource?.postMessage(
{
type: 'LOOPLAY_ADS_UNAVAILABLE',
requestId,
},
targetOrigin
);
}
});Drop-in <script> tag (no build step required)
Games not built from a Looplay template — plain HTML5, Construct, GameMaker exports, a Unity WebGL wrapper page, etc. — can use the browser (IIFE) bundle directly, no bundler needed:
<script src="https://unpkg.com/@looplay/sdk/browser"></script>
<script>
const looplayAuth = new LooplaySDK.LooplayIframeAuth({
appId: 'YOUR_APP_ID',
});
looplayAuth.init();
looplayAuth.initLifecycleTracking();
// call looplayAuth.trackView() / startPlaySession() / trackPlay(...) /
// trackMatch(...) / emitGameEvent(...) from your game code
</script>This bundle does not include real-time (WsClient)/socket.io-client —
that's a separate opt-in browser bundle, only needed if you want
onBalanceChange/onStorePurchase:
<script src="https://unpkg.com/@looplay/sdk/realtime/browser"></script>
<script>
const ws = new LooplaySDKRealtime.WsClient({
baseUrl: 'https://api.looplay.gg',
getAccessToken: async () => myAccessToken,
});
ws.onStorePurchase((event) => console.log('purchased', event));
</script>Security note: the origin check relies on parentOrigin (explicit option
or import.meta.env.VITE_PARENT_ORIGIN), falling back to document.referrer.
Referrer can be stripped by Referrer-Policy, browser privacy settings, or
extensions — when that happens and no parentOrigin or VITE_PARENT_ORIGIN is configured,
the check is skipped and LooplayIframeAuth logs a console.warn. Configure
VITE_PARENT_ORIGIN in your environment or pass parentOrigin explicitly
in production embeds instead of relying solely on the referrer fallback.
Lifecycle: call dispose() when tearing down (route change, HMR, test
cleanup) to remove the message listener and clear subscribers:
looplayAuth.dispose();Auth storage tradeoff
LooplayAuth (used by TelegramAuthProvider and other explicit-login
providers) defaults to in-memory session storage — nothing persists across a
reload unless you opt in. BrowserLocalStorageAuthStorage persists the
session (including the refresh token) in localStorage, which is convenient
but readable by any script on the page (XSS exposure). Prefer it only for
games where that tradeoff is acceptable; for higher-security needs, keep the
default in-memory storage or implement an AuthStorage backed by a more
restrictive mechanism.
Errors
All SDK-thrown errors extend LooplaySDKError:
| Error | Thrown when |
| --- | --- |
| NotInitializedError | Calling LooplaySDK methods before init() |
| MissingBaseUrlError | Constructing without baseUrl |
| MissingAuthError | An operation needs auth and none was configured |
| NotAuthenticatedError | Calling an authenticated endpoint with no access token and (for tracking) no anonymous id |
import { NotAuthenticatedError } from '@looplay/sdk';
try {
await sdk.getMyProfile();
} catch (err) {
if (err instanceof NotAuthenticatedError) {
// prompt login
}
}