@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.
- package/README.md +402 -0
- package/android/build.gradle +69 -0
- package/android/src/main/kotlin/com/playloop/plugins/applovinmax/PlayLoopAppLovinMaxPlugin.kt +316 -0
- package/android/src/main/kotlin/com/playloop/plugins/deviceid/PlayLoopDeviceIdPlugin.kt +30 -0
- package/dist/loader/cjs/index.js +72 -0
- package/dist/loader/esm/index.js +69 -0
- package/dist/types/CashableSDK.d.ts +46 -0
- package/dist/types/adapters/CapacitorAdsAdapter.d.ts +25 -0
- package/dist/types/adapters/WebViewAdsAdapter.d.ts +13 -0
- package/dist/types/babylon/BabylonPlugin.d.ts +17 -0
- package/dist/types/babylon/index.d.ts +17 -0
- package/dist/types/index.d.ts +481 -0
- package/dist/types/loader/index.d.ts +300 -0
- package/dist/types/modules/AnalyticsModule.d.ts +17 -0
- package/dist/types/modules/EmbeddedAdsModule.d.ts +30 -0
- package/dist/types/modules/FeatureFlagModule.d.ts +36 -0
- package/dist/types/modules/RewardsModule.d.ts +19 -0
- package/dist/types/modules/SessionModule.d.ts +16 -0
- package/dist/types/modules/StandaloneAdsModule.d.ts +51 -0
- package/dist/types/transport/HttpTransport.d.ts +19 -0
- package/dist/types/transport/WebViewTransport.d.ts +17 -0
- package/dist/types/types.d.ts +147 -0
- package/dist/types/ui/UIModule.d.ts +64 -0
- package/docs/ads.md +210 -0
- package/docs/analytics.md +45 -0
- package/docs/babylon.md +88 -0
- package/docs/feature-flags.md +109 -0
- package/docs/game-integration-guide.md +449 -0
- package/docs/loader.md +113 -0
- package/docs/rewards.md +57 -0
- package/docs/session.md +43 -0
- package/docs/ui.md +248 -0
- package/docs/wire-protocol.md +194 -0
- 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
|
+
```
|
package/docs/rewards.md
ADDED
|
@@ -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
|
+
```
|
package/docs/session.md
ADDED
|
@@ -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
|
+
```
|