@clocktone/game-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +402 -0
  2. package/android/build.gradle +69 -0
  3. package/android/src/main/kotlin/com/playloop/plugins/applovinmax/PlayLoopAppLovinMaxPlugin.kt +316 -0
  4. package/android/src/main/kotlin/com/playloop/plugins/deviceid/PlayLoopDeviceIdPlugin.kt +30 -0
  5. package/dist/loader/cjs/index.js +72 -0
  6. package/dist/loader/esm/index.js +69 -0
  7. package/dist/types/CashableSDK.d.ts +46 -0
  8. package/dist/types/adapters/CapacitorAdsAdapter.d.ts +25 -0
  9. package/dist/types/adapters/WebViewAdsAdapter.d.ts +13 -0
  10. package/dist/types/babylon/BabylonPlugin.d.ts +17 -0
  11. package/dist/types/babylon/index.d.ts +17 -0
  12. package/dist/types/index.d.ts +481 -0
  13. package/dist/types/loader/index.d.ts +300 -0
  14. package/dist/types/modules/AnalyticsModule.d.ts +17 -0
  15. package/dist/types/modules/EmbeddedAdsModule.d.ts +30 -0
  16. package/dist/types/modules/FeatureFlagModule.d.ts +36 -0
  17. package/dist/types/modules/RewardsModule.d.ts +19 -0
  18. package/dist/types/modules/SessionModule.d.ts +16 -0
  19. package/dist/types/modules/StandaloneAdsModule.d.ts +51 -0
  20. package/dist/types/transport/HttpTransport.d.ts +19 -0
  21. package/dist/types/transport/WebViewTransport.d.ts +17 -0
  22. package/dist/types/types.d.ts +147 -0
  23. package/dist/types/ui/UIModule.d.ts +64 -0
  24. package/docs/ads.md +210 -0
  25. package/docs/analytics.md +45 -0
  26. package/docs/babylon.md +88 -0
  27. package/docs/feature-flags.md +109 -0
  28. package/docs/game-integration-guide.md +449 -0
  29. package/docs/loader.md +113 -0
  30. package/docs/rewards.md +57 -0
  31. package/docs/session.md +43 -0
  32. package/docs/ui.md +248 -0
  33. package/docs/wire-protocol.md +194 -0
  34. package/package.json +81 -0
@@ -0,0 +1,449 @@
1
+ # Game Integration Guide
2
+
3
+ How to add the PlayLoop SDK to your game using the CDN loader — the only supported integration path.
4
+
5
+ ## Overview
6
+
7
+ The `@clocktone/game-sdk` npm package provides:
8
+
9
+ - **CDN loader** (`loadPlayLoopSDK`) — thin bootstrap that fetches the SDK from CDN at runtime
10
+ - **TypeScript types** — `PlayLoopSDK`, `PlayLoopSDKConfig`, `UIConfig`, etc.
11
+ - **Native Capacitor plugins** (Android) — `PlayLoopDeviceIdPlugin` (App Set ID) and `PlayLoopAppLovinMaxPlugin` (ads)
12
+
13
+ The full SDK runtime (~2.8 MB, includes Statsig) is **not bundled** into your game. Instead, the loader injects it from CDN at startup. This allows SDK updates without game rebuilds and enables Statsig-gated version rollouts.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install @clocktone/game-sdk
19
+ ```
20
+
21
+ ## Architecture: How SDK Loading Works
22
+
23
+ ```
24
+ Game bundle CDN
25
+ ┌──────────────────┐ ┌─────────────────────────────┐
26
+ │ loadPlayLoopSDK │───>│ GET /api/games/sdk-version │
27
+ │ (~2 KB loader) │ │ → { version, url, enabled } │
28
+ │ │ └─────────────────────────────┘
29
+ │ │ │
30
+ │ │ ┌────────────▼────────────────┐
31
+ │ │<───│ <script> playloop-sdk.js │
32
+ │ │ │ (~2.8 MB IIFE from CDN) │
33
+ │ │ └─────────────────────────────┘
34
+ │ │
35
+ │ window.PlayLoopSDK.PlayLoopSDK ← typed constructor
36
+ └──────────────────┘
37
+ ```
38
+
39
+ 1. Game imports `loadPlayLoopSDK` from `@clocktone/game-sdk/loader`
40
+ 2. Loader calls `GET {apiBaseUrl}/api/games/sdk-version` — backend returns a CDN URL (version controlled via Statsig dynamic config)
41
+ 3. Loader injects a `<script>` tag for the IIFE bundle
42
+ 4. `window.PlayLoopSDK` becomes available — loader returns the typed constructor
43
+ 5. If the version fetch fails, the loader falls back to a hardcoded CDN URL
44
+
45
+ The type re-exports (`PlayLoopSDK`, `PlayLoopSDKConfig`, `UIConfig`) are **type-only** — zero SDK runtime code ends up in your game bundle.
46
+
47
+ See the [Loader API Reference](loader.md) for full details.
48
+
49
+ ## Creating an Integration Wrapper
50
+
51
+ We recommend wrapping all SDK interactions in a game-specific class. This keeps SDK concerns out of core game logic and provides a single place to manage lifecycle, ads, and rewards.
52
+
53
+ Block Blast's `PlayLoopIntegration.ts` is the canonical reference. The key pattern:
54
+
55
+ ```typescript
56
+ import { loadPlayLoopSDK } from '@clocktone/game-sdk/loader';
57
+ import type {
58
+ PlayLoopSDK as PlayLoopSDKInstance,
59
+ PlayLoopSDKConfig,
60
+ } from '@clocktone/game-sdk/loader';
61
+
62
+ type SDKConstructor = new (config: PlayLoopSDKConfig) => PlayLoopSDKInstance;
63
+
64
+ export class PlayLoopIntegration {
65
+ private sdk: PlayLoopSDKInstance;
66
+ private disposeCallbacks: (() => void)[] = [];
67
+
68
+ /** Factory: load SDK from CDN, detect mode, construct instance */
69
+ static async create(): Promise<PlayLoopIntegration> {
70
+ const embeddedCfg = PlayLoopIntegration.getEmbeddedConfig();
71
+ const apiBaseUrl = embeddedCfg?.apiBaseUrl ?? 'https://api.vbackendapi.com';
72
+ const SDK = await loadPlayLoopSDK({ apiBaseUrl });
73
+ return new PlayLoopIntegration(SDK as unknown as SDKConstructor, embeddedCfg);
74
+ }
75
+
76
+ /** Read host-injected config (set by React Native's usePlayLoopGameBridge) */
77
+ static getEmbeddedConfig() {
78
+ const cfg = (window as any).__CASHABLE_CONFIG__;
79
+ return cfg?.clientId && cfg?.apiBaseUrl ? cfg : null;
80
+ }
81
+
82
+ private constructor(SDK: SDKConstructor, embeddedCfg: { clientId: string; apiBaseUrl: string } | null) {
83
+ const isEmbedded = !!embeddedCfg;
84
+ const config: PlayLoopSDKConfig = {
85
+ gameId: 'your-game-id',
86
+ apiBaseUrl: embeddedCfg?.apiBaseUrl ?? 'https://api.vbackendapi.com',
87
+ debug: true,
88
+ ...(embeddedCfg ? { clientId: embeddedCfg.clientId } : {}),
89
+ ...(!isEmbedded ? {
90
+ mode: 'standalone' as const,
91
+ appLovin: { /* your ad unit IDs */ },
92
+ } : {}),
93
+ ui: { /* see Dual-Mode Configuration below */ },
94
+ };
95
+ this.sdk = new SDK(config);
96
+ }
97
+
98
+ async initialize(): Promise<void> {
99
+ await this.sdk.init();
100
+ }
101
+
102
+ dispose(): void {
103
+ for (const unsub of this.disposeCallbacks) unsub();
104
+ this.disposeCallbacks = [];
105
+ this.sdk.dispose();
106
+ }
107
+ }
108
+ ```
109
+
110
+ Your game code only ever calls `PlayLoopIntegration.create()` and methods on the returned instance — never the SDK directly.
111
+
112
+ ## Loading the SDK
113
+
114
+ ```typescript
115
+ import { loadPlayLoopSDK } from '@clocktone/game-sdk/loader';
116
+ import type { PlayLoopSDK, PlayLoopSDKConfig } from '@clocktone/game-sdk/loader';
117
+
118
+ const SDK = await loadPlayLoopSDK({ apiBaseUrl: 'https://api.vbackendapi.com' });
119
+ ```
120
+
121
+ `loadPlayLoopSDK` returns the `PlayLoopSDK` constructor (not an instance). You then create an instance with your config and call `init()`.
122
+
123
+ ## Dual-Mode Configuration (Embedded + Standalone)
124
+
125
+ Every PlayLoop game runs in two contexts:
126
+
127
+ | | Embedded | Standalone |
128
+ |---|---|---|
129
+ | **Context** | WebView inside PlayLoop app | Capacitor-wrapped APK |
130
+ | **How it starts** | PlayLoop app loads your game URL in a WebView | User opens your standalone APK |
131
+ | **Auth** | Host injects `window.__CASHABLE_CONFIG__` with `clientId` | SDK uses App Set ID via Capacitor plugin |
132
+ | **Ads** | Host app shows ads via native bridge | SDK uses AppLovin MAX via Capacitor plugin |
133
+
134
+ ### Detecting the Mode
135
+
136
+ The host PlayLoop app injects `window.__CASHABLE_CONFIG__` before your game loads. Check for it:
137
+
138
+ ```typescript
139
+ const hostCfg = (window as any).__CASHABLE_CONFIG__;
140
+ const isEmbedded = !!hostCfg?.clientId;
141
+ ```
142
+
143
+ `__CASHABLE_CONFIG__` shape: `{ gameId: string, clientId: string, apiBaseUrl: string, debug?: boolean }`
144
+
145
+ ### Embedded Mode Config
146
+
147
+ When running inside the PlayLoop app, pass the host-provided `clientId`. Don't set `mode` (auto-detected via `window.ReactNativeWebView`). Don't configure `appLovin` (host handles ads via native bridge).
148
+
149
+ ```typescript
150
+ const config: PlayLoopSDKConfig = {
151
+ gameId: 'your-game-id',
152
+ apiBaseUrl: hostCfg.apiBaseUrl,
153
+ clientId: hostCfg.clientId,
154
+ debug: hostCfg.debug ?? false,
155
+ ui: {
156
+ showPlayLoopButton: true,
157
+ showClose: true,
158
+ showMenu: true,
159
+ cashableButton: { position: 'top-left' },
160
+ },
161
+ };
162
+ ```
163
+
164
+ ### Standalone Mode Config
165
+
166
+ Set `mode: 'standalone'` explicitly, provide your AppLovin ad unit IDs, and wire up `onClose`:
167
+
168
+ ```typescript
169
+ const config: PlayLoopSDKConfig = {
170
+ gameId: 'your-game-id',
171
+ mode: 'standalone',
172
+ apiBaseUrl: 'https://api.vbackendapi.com',
173
+ debug: true,
174
+ appLovin: {
175
+ sdkKey: 'YOUR_APPLOVIN_SDK_KEY',
176
+ rewardedAdUnitId: 'YOUR_REWARDED_AD_UNIT_ID',
177
+ interstitialAdUnitId: 'YOUR_INTERSTITIAL_AD_UNIT_ID',
178
+ bannerAdUnitId: 'YOUR_BANNER_AD_UNIT_ID',
179
+ },
180
+ ui: {
181
+ showPlayLoopButton: true,
182
+ showClose: true,
183
+ showMenu: true,
184
+ cashableButton: { position: 'top-left' },
185
+ onClose: () => {
186
+ // Use dynamic import — @capacitor/core is not available in WebView
187
+ import('@capacitor/core').then(({ Capacitor }) => {
188
+ if (Capacitor.isNativePlatform()) {
189
+ import('@capacitor/app').then(({ App }) => App.exitApp());
190
+ }
191
+ }).catch(() => {});
192
+ },
193
+ },
194
+ };
195
+ ```
196
+
197
+ > **Important**: Always use dynamic `import('@capacitor/core')` — never a static import. Static imports fail in the WebView (embedded mode) where Capacitor is not available.
198
+
199
+ ### Putting It Together
200
+
201
+ ```typescript
202
+ const hostCfg = (window as any).__CASHABLE_CONFIG__;
203
+ const isEmbedded = !!hostCfg?.clientId;
204
+
205
+ const config: PlayLoopSDKConfig = {
206
+ gameId: 'your-game-id',
207
+ apiBaseUrl: isEmbedded ? hostCfg.apiBaseUrl : 'https://api.vbackendapi.com',
208
+ debug: true,
209
+ // Embedded: pass clientId from host
210
+ ...(isEmbedded ? { clientId: hostCfg.clientId } : {}),
211
+ // Standalone: set mode + appLovin
212
+ ...(!isEmbedded ? {
213
+ mode: 'standalone' as const,
214
+ appLovin: {
215
+ sdkKey: 'YOUR_APPLOVIN_SDK_KEY',
216
+ rewardedAdUnitId: 'YOUR_REWARDED_AD_UNIT_ID',
217
+ interstitialAdUnitId: 'YOUR_INTERSTITIAL_AD_UNIT_ID',
218
+ bannerAdUnitId: 'YOUR_BANNER_AD_UNIT_ID',
219
+ },
220
+ } : {}),
221
+ ui: {
222
+ showPlayLoopButton: true,
223
+ showClose: true,
224
+ showMenu: true,
225
+ cashableButton: { position: 'top-left' },
226
+ onClose: () => {
227
+ if (isEmbedded) return; // Host handles close via bridge
228
+ import('@capacitor/core').then(({ Capacitor }) => {
229
+ if (Capacitor.isNativePlatform()) {
230
+ import('@capacitor/app').then(({ App }) => App.exitApp());
231
+ }
232
+ }).catch(() => {});
233
+ },
234
+ },
235
+ };
236
+ ```
237
+
238
+ ## Initialization
239
+
240
+ ```typescript
241
+ const cashable = new SDK(config);
242
+ await cashable.init();
243
+ ```
244
+
245
+ `init()` is async — it sets up transports, authenticates, initializes feature flags and ads, and renders the UI. Wait for it to resolve before using any SDK module.
246
+
247
+ After init, show a banner ad:
248
+
249
+ ```typescript
250
+ cashable.ads.showBanner('bottom').catch(() => {});
251
+ ```
252
+
253
+ ## Wiring Lifecycle Events
254
+
255
+ The PlayLoop app (and standalone ad overlays) freeze/resume your game. You **must** handle these:
256
+
257
+ ```typescript
258
+ const unsubFreeze = cashable.onFreeze(() => {
259
+ // Pause game logic, audio, timers, animations
260
+ game.pause();
261
+ });
262
+
263
+ const unsubResume = cashable.onResume(() => {
264
+ // Resume game logic
265
+ game.resume();
266
+ });
267
+ ```
268
+
269
+ ### Babylon.js
270
+
271
+ If your game uses Babylon.js, bind the scene for automatic render loop freeze/resume:
272
+
273
+ ```typescript
274
+ cashable.babylon.bindScene(scene, engine);
275
+ ```
276
+
277
+ This handles `engine.stopRenderLoop()` / `engine.runRenderLoop()` for you. If your game uses a custom RAF loop instead of `engine.runRenderLoop`, you'll need to wire freeze/resume manually (as shown above).
278
+
279
+ ## Wiring Game Events
280
+
281
+ ### Awarding Coins
282
+
283
+ Call `rewards.awardCoins()` whenever the player earns something (score, level clear, line clear, etc.):
284
+
285
+ ```typescript
286
+ cashable.rewards.awardCoins().catch((e) => {
287
+ console.warn('awardCoins failed:', e);
288
+ });
289
+ ```
290
+
291
+ The SDK handles multipliers (2x boost), coin animations, and host notification automatically.
292
+
293
+ ### Sponsored Interstitials
294
+
295
+ Trigger sponsored interstitials on gameplay milestones (e.g., piece placed, level completed). The SDK manages cooldowns — safe to call frequently:
296
+
297
+ ```typescript
298
+ cashable.ui?.showSponsoredInterstitial().catch(() => {});
299
+ ```
300
+
301
+ This shows a 3-second countdown modal, then the ad, then awards coins on success. `cashable.ui` is `null` when the user isn't authenticated, so always use optional chaining.
302
+
303
+ ### Banner Ads
304
+
305
+ Show a banner after initialization:
306
+
307
+ ```typescript
308
+ cashable.ads.showBanner('bottom').catch(() => {});
309
+ ```
310
+
311
+ Hide or destroy it when needed:
312
+
313
+ ```typescript
314
+ await cashable.ads.hideBanner(); // Hides (can show again)
315
+ await cashable.ads.destroyBanner(); // Destroys (must call showBanner again)
316
+ ```
317
+
318
+ ## TopBar Offset
319
+
320
+ The SDK renders a floating UI (PlayLoopButton panel) that overlaps your game canvas. You need to offset your HUD elements below it.
321
+
322
+ ### Before `init()` — computed estimate
323
+
324
+ `getWidgetHeight()` computes the height from `window.innerWidth` with no DOM or `init()` required. Use it at script load time so your layout is correct from the first frame:
325
+
326
+ ```typescript
327
+ // From loader (works before CDN script loads)
328
+ import { getWidgetHeight } from '@clocktone/game-sdk/loader';
329
+ const cssPx = getWidgetHeight();
330
+ game.setTopOffset(cssPx);
331
+ ```
332
+
333
+ Or from the SDK constructor (CDN users):
334
+
335
+ ```typescript
336
+ const cssPx = PlayLoopSDK.getWidgetHeight();
337
+ ```
338
+
339
+ ### After `init()` — exact DOM value
340
+
341
+ Register `onWidgetReady` before calling `init()` to receive the real DOM-measured height:
342
+
343
+ ```typescript
344
+ const cashable = new SDK(config);
345
+ cashable.onWidgetReady(({ height }) => {
346
+ game.setTopOffset(height); // refine with real value
347
+ });
348
+ await cashable.init();
349
+ ```
350
+
351
+ You can also read it synchronously after init:
352
+
353
+ ```typescript
354
+ const cssPx = cashable.getTopBarHeight();
355
+ ```
356
+
357
+ ### Converting to Babylon.js GUI Coordinates
358
+
359
+ If your game uses Babylon.js GUI with an ideal height (e.g., 960):
360
+
361
+ ```typescript
362
+ const idealHeight = 960;
363
+ const idealPx = Math.round((cssPx / window.innerHeight) * idealHeight);
364
+ // Use idealPx as the top offset for your HUD elements
365
+ ```
366
+
367
+ ## Build Configuration (Vite)
368
+
369
+ ### `base: './'` (Required)
370
+
371
+ Games are served from a subpath on the CDN (`vbackendapi.com/games/<game-id>/`). Using the default `base: '/'` causes asset URLs to resolve to the CDN root instead of the game directory.
372
+
373
+ ```typescript
374
+ // vite.config.ts
375
+ export default defineConfig({
376
+ base: './',
377
+ // ...
378
+ });
379
+ ```
380
+
381
+ ### Externalize `@capacitor/core`
382
+
383
+ Capacitor is only available in standalone mode (native APK). In embedded mode (WebView), it doesn't exist. Always use dynamic `import()` in your code and externalize the package so Vite doesn't try to bundle it:
384
+
385
+ ```typescript
386
+ // vite.config.ts
387
+ export default defineConfig({
388
+ base: './',
389
+ build: {
390
+ rollupOptions: {
391
+ external: ['@clocktone/game-sdk'],
392
+ output: {
393
+ inlineDynamicImports: true,
394
+ },
395
+ },
396
+ },
397
+ });
398
+ ```
399
+
400
+ > The SDK itself is loaded from CDN, so it should be external. Dynamic imports of `@capacitor/core` and `@capacitor/app` are resolved at runtime by Capacitor's native layer.
401
+
402
+ ### Relative Fetch URLs
403
+
404
+ All `fetch()` calls for game assets must use **relative** paths:
405
+
406
+ ```typescript
407
+ // Correct — resolves relative to game's index.html
408
+ fetch('./config/data.json')
409
+
410
+ // Wrong — resolves to CDN root, not game subpath
411
+ fetch('/config/data.json')
412
+ ```
413
+
414
+ ## Cleanup
415
+
416
+ Always dispose the SDK when your game shuts down:
417
+
418
+ ```typescript
419
+ cashable.dispose();
420
+ ```
421
+
422
+ This stops analytics tracking, removes event listeners, aborts pending requests, tears down the UI, and cleans up the Babylon.js plugin.
423
+
424
+ ## Testing
425
+
426
+ ### Standalone (Capacitor APK)
427
+
428
+ ```bash
429
+ # 1. Build your game's web assets
430
+ npm run build
431
+
432
+ # 2. Sync to Capacitor
433
+ npx cap sync android
434
+
435
+ # 3. Build the APK
436
+ cd android
437
+ JAVA_HOME=$(/usr/libexec/java_home -v 21) ./gradlew assembleDebug
438
+ cd ..
439
+
440
+ # 4. Install on device
441
+ adb install -r android/app/build/outputs/apk/debug/app-debug.apk
442
+ ```
443
+
444
+ ### Embedded (WebView in PlayLoop App)
445
+
446
+ 1. Build your game's web assets (`npm run build`)
447
+ 2. Upload the `dist/` directory (including `config/` and any other asset directories) to R2 CDN
448
+ 3. Bump the `?v=N` cache parameter on the game's play URL in the backend
449
+ 4. Open the game from within the PlayLoop app
package/docs/loader.md ADDED
@@ -0,0 +1,113 @@
1
+ # CDN Loader
2
+
3
+ Thin bootstrap that loads the full PlayLoop SDK from CDN at runtime. Games bundle only the loader (~2 KB) — the SDK itself is fetched dynamically.
4
+
5
+ **Import path**: `@clocktone/game-sdk/loader`
6
+
7
+ ## `loadPlayLoopSDK(options): Promise<typeof PlayLoopSDK>`
8
+
9
+ Loads the SDK from CDN and returns the constructor.
10
+
11
+ ```typescript
12
+ import { loadPlayLoopSDK } from '@clocktone/game-sdk/loader';
13
+ import type { PlayLoopSDK, PlayLoopSDKConfig } from '@clocktone/game-sdk/loader';
14
+
15
+ const SDK = await loadPlayLoopSDK({ apiBaseUrl: 'https://api.vbackendapi.com' });
16
+ const cashable = new SDK({ gameId: 'my-game' });
17
+ await cashable.init();
18
+ ```
19
+
20
+ ### Steps
21
+
22
+ 1. **Shims `window.CapacitorCore`** — the IIFE's UMD globals wrapper expects `CapacitorCore` to exist. In standalone mode (Capacitor available), the shim maps to the real `Capacitor` object. In embedded mode (WebView), it's an empty stub so the IIFE doesn't crash.
23
+
24
+ 2. **Fetches SDK version** — `GET {apiBaseUrl}/s/arcade/sdk-ver` returns `{ version, url, enabled }`. The backend uses Statsig dynamic config (`sdk_version_config`) for targeted version rollouts and a kill switch gate (`sdk_cdn_disabled`) to disable CDN delivery entirely.
25
+
26
+ 3. **Injects `<script>` tag** — appends the IIFE bundle URL to `document.head`.
27
+
28
+ 4. **Returns typed constructor** — reads `window.PlayLoopSDK.PlayLoopSDK` and returns it with full TypeScript types.
29
+
30
+ ## `LoaderOptions`
31
+
32
+ ```typescript
33
+ interface LoaderOptions {
34
+ /** Backend base URL (e.g. "https://api.vbackendapi.com") */
35
+ apiBaseUrl: string;
36
+ /** Hardcoded fallback CDN URL if version fetch fails */
37
+ fallbackUrl?: string;
38
+ /** Timeout for the version-fetch request (default: 5000ms) */
39
+ timeoutMs?: number;
40
+ }
41
+ ```
42
+
43
+ | Property | Type | Required | Default | Description |
44
+ |----------|------|----------|---------|-------------|
45
+ | `apiBaseUrl` | `string` | Yes | — | Backend base URL used to fetch the SDK version |
46
+ | `fallbackUrl` | `string` | No | `https://getplayloop.com/sdk/v0.7.0/playloop-sdk.js` | CDN URL used when the version fetch fails |
47
+ | `timeoutMs` | `number` | No | `5000` | Timeout in milliseconds for the version-fetch request |
48
+
49
+ ## Version Resolution
50
+
51
+ The loader calls `GET {apiBaseUrl}/s/arcade/sdk-ver`. The backend:
52
+
53
+ 1. Identifies the user via `X-Device-Token` or `X-Install-Token` headers
54
+ 2. Checks the `sdk_cdn_disabled` Statsig gate — if true, returns `enabled: false`
55
+ 3. Reads the `sdk_version_config` Statsig dynamic config for the `version` field
56
+ 4. Returns `{ version, url, enabled }` where `url` is `{SDK_CDN_BASE_URL}/v{version}/playloop-sdk.js`
57
+
58
+ If the version fetch fails (network error, timeout, non-JSON response), the loader silently falls back to the hardcoded `fallbackUrl`.
59
+
60
+ ## CapacitorCore Shim
61
+
62
+ The SDK's IIFE bundle uses a UMD/globals wrapper that expects `window.CapacitorCore` for `@capacitor/core` imports. The loader creates this shim before loading the script:
63
+
64
+ - **Standalone mode** (`window.Capacitor` exists): shim maps `CapacitorCore.Capacitor` and `CapacitorCore.registerPlugin` to the real Capacitor runtime
65
+ - **Embedded mode** (WebView, no Capacitor): shim is an empty object `{}` — SDK code that checks for Capacitor gracefully handles its absence
66
+
67
+ This happens automatically — game code doesn't need to do anything.
68
+
69
+ ## `getWidgetHeight(): number`
70
+
71
+ Computes the widget top-bar height from `window.innerWidth` alone — no SDK load or `init()` required. The formula mirrors the CSS layout of the PlayLoopButton trigger row. Use it at script load time to offset your game canvas before the CDN script even loads:
72
+
73
+ ```typescript
74
+ import { getWidgetHeight } from '@clocktone/game-sdk/loader';
75
+
76
+ const cssPx = getWidgetHeight();
77
+ game.setTopOffset(cssPx);
78
+ ```
79
+
80
+ ## Type Re-exports
81
+
82
+ The loader re-exports types from the parent package for convenience. These are **type-only** — zero runtime cost, nothing added to your bundle:
83
+
84
+ | Export | Description |
85
+ |--------|-------------|
86
+ | `PlayLoopSDK` | SDK instance type (constructor signature, all module accessors) |
87
+ | `PlayLoopSDKConfig` | Configuration object passed to `new PlayLoopSDK(config)` |
88
+ | `UIConfig` | UI configuration subset (`onClose`, `showPlayLoopButton`, etc.) |
89
+
90
+ ```typescript
91
+ import type { PlayLoopSDK, PlayLoopSDKConfig, UIConfig } from '@clocktone/game-sdk/loader';
92
+ ```
93
+
94
+ ## Error Handling
95
+
96
+ | Scenario | Behavior |
97
+ |----------|----------|
98
+ | Version fetch fails (network error, timeout) | Silently falls back to `fallbackUrl`. Warning logged to console. |
99
+ | Version fetch returns `enabled: false` | Uses `fallbackUrl` (CDN URL from response is ignored). |
100
+ | Script tag fails to load (`onerror`) | **Throws** `Error: Failed to load SDK from {url}` |
101
+ | Script loads but `window.PlayLoopSDK.PlayLoopSDK` is missing | **Throws** `Error: window.PlayLoopSDK.PlayLoopSDK not found after script load` |
102
+
103
+ Wrap `loadPlayLoopSDK` in a try-catch if you want to handle load failures gracefully:
104
+
105
+ ```typescript
106
+ try {
107
+ const SDK = await loadPlayLoopSDK({ apiBaseUrl: 'https://api.vbackendapi.com' });
108
+ // SDK loaded successfully
109
+ } catch (err) {
110
+ console.error('Failed to load PlayLoop SDK:', err);
111
+ // Start game without SDK integration
112
+ }
113
+ ```
@@ -0,0 +1,57 @@
1
+ # Rewards Module
2
+
3
+ Handles coin awarding and reward notifications.
4
+
5
+ **Accessor**: `cashable.rewards`
6
+
7
+ ## Methods
8
+
9
+ ### `awardCoins(opts?): Promise<{ coins: number }>`
10
+
11
+ Awards coins to the current user. Typically called after a rewarded video ad completes.
12
+
13
+ ```typescript
14
+ const { coins } = await cashable.rewards.awardCoins();
15
+ console.log(`Awarded! New balance: ${coins}`);
16
+ ```
17
+
18
+ **Parameters**:
19
+
20
+ | Name | Type | Description |
21
+ |---|---|---|
22
+ | `opts.multiplier` | `number` (optional) | Coin multiplier (e.g. `2` for double coins) |
23
+
24
+ ```typescript
25
+ // Award with multiplier
26
+ const { coins } = await cashable.rewards.awardCoins({ multiplier: 2 });
27
+ ```
28
+
29
+ **Returns**: `{ coins: number }` - the updated coin balance.
30
+
31
+ **Throws**: Error with `code: 'USER_NOT_LINKED'` if the user isn't authenticated.
32
+
33
+ ### `onCoinsAwarded(handler): () => void`
34
+
35
+ Subscribes to coin award notifications. Fired both when `awardCoins()` completes and when the host pushes a `rewards.coinsAwarded` event (e.g., bonus awards).
36
+
37
+ ```typescript
38
+ const unsub = cashable.rewards.onCoinsAwarded(({ coins }) => {
39
+ updateUI(coins);
40
+ });
41
+
42
+ // Later: stop listening
43
+ unsub();
44
+ ```
45
+
46
+ ## Typical Flow
47
+
48
+ ```typescript
49
+ // 1. Show an ad
50
+ const adSuccess = await cashable.ads.showRewardedVideo();
51
+
52
+ // 2. If the user watched it, award coins
53
+ if (adSuccess) {
54
+ const { coins } = await cashable.rewards.awardCoins();
55
+ showCoinAnimation(coins);
56
+ }
57
+ ```
@@ -0,0 +1,43 @@
1
+ # Session Module
2
+
3
+ Handles user identity and balance retrieval.
4
+
5
+ **Accessor**: `cashable.session`
6
+
7
+ ## Methods
8
+
9
+ ### `getBalance(): Promise<{ coins: number }>`
10
+
11
+ Retrieves the current coin balance for the authenticated user.
12
+
13
+ ```typescript
14
+ const { coins } = await cashable.session.getBalance();
15
+ console.log(`Balance: ${coins} coins`);
16
+ ```
17
+
18
+ **Throws**: Error with `code: 'USER_NOT_LINKED'` in standalone mode if the user hasn't been linked yet.
19
+
20
+ ## Internal Behavior
21
+
22
+ ### Embedded mode
23
+
24
+ The host app manages identity. The session module is a passthrough - `getBalance()` sends a `session.getBalance` request through the WebView bridge.
25
+
26
+ ### Standalone mode
27
+
28
+ On `init()`, the session module:
29
+
30
+ 1. Dynamically imports `@clocktone/capacitor-device-id`
31
+ 2. Calls `getAppSetId()` to get the device's App Set ID
32
+ 3. Sets the `X-Install-Token` header on all outgoing HTTP requests
33
+
34
+ The backend resolves this header to a known user via a Redis mapping. If no mapping exists, requests that require authentication will return 401.
35
+
36
+ ## Auth Headers
37
+
38
+ For advanced use, you can access the auth headers that the session module generates:
39
+
40
+ ```typescript
41
+ // Returns { 'X-Install-Token': '...' } in standalone, {} in embedded
42
+ const headers = cashable.session.getAuthHeaders();
43
+ ```