@bountyboard/arcade-sdk 1.2.0 → 1.3.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/AGENTS.md CHANGED
@@ -19,15 +19,24 @@ https://www.bountyboard.gg/arcade/sdk/llms.txt — this file is about using it C
19
19
  the pattern for new integrations.
20
20
  3. **Scores are integers and `gameOver` fires exactly once per run.** Don't submit
21
21
  post-game-over corrections; the server enforces per-game plausibility caps and rejects
22
- implausible values. Call `submitScore` freely during play — the host throttles.
22
+ implausible values. Call `submitScore` freely during play — the host throttles. Wiring the
23
+ calls is only half of a leaderboard: the studio must also declare Leaderboards on the game
24
+ in its Arcade submission, or the host rejects every score and the game never learns why.
23
25
  4. **Always handle `getPlayer() === null`** (guests, standalone, off-host). The SDK never
24
26
  provides ids, emails, or roles — do not design features that need them.
25
27
  5. **One save blob.** Serialize the whole progress object into a single `save(string)` (~1MB
26
28
  cap). Save on checkpoints/game-over, never per frame. Handle all five rejection codes; keep
27
29
  localStorage as the standalone fallback (hosted builds have no localStorage — the sandbox is
28
- opaque-origin — so the SDK save IS primary there).
30
+ opaque-origin, where reading it THROWS rather than returning empty — so the SDK save IS
31
+ primary there). **Exception: engine exports.** When the game is a GameMaker/Godot/Unity/
32
+ Construct build whose storage layer can't be rewired without patching engine internals, use
33
+ `storage.install()` instead: it swaps the throwing `localStorage` for a cloud-backed shim. The
34
+ script tag installs it for you; module consumers call it before boot. `getItem` is synchronous
35
+ and the cloud read is not, so anything reading progress at boot must await `storage.ready()`.
29
36
  6. **`lockToHost()` before boot** when the studio wants anti-theft, with their own domains in
30
- `allow`. It's a deterrent, not DRM — never present it as more.
37
+ `allow`. It's a deterrent, not DRM — never present it as more. Bounty Board's own hosts and
38
+ localhost are always allowed, so a build Bounty Board hosts passes no `allow` entries; never
39
+ ship the `play.yourgame.com` placeholder from the docs as if it were a real value.
31
40
  7. **Multiplayer: clients send inputs, never state.** The approved room authority (a Bounty Board
32
41
  module or a registered external server) simulates and validates; design the game so all
33
42
  authority lives in that one server. Don't trust or display any value another client sent
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # @bountyboard/arcade-sdk
2
2
 
3
+ ## 1.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0f53ae3: Add `BBArcade.storage`, a localStorage compatibility shim for engine exports.
8
+
9
+ Bounty-Board-hosted builds run on an opaque origin where `localStorage` throws
10
+ rather than returning empty. Games that own their source can just call
11
+ `save()`/`load()`, but engine runtimes bake synchronous storage in: GameMaker
12
+ HTML5 routes `ini_open`/`ini_write_*`/`game_save` through it, as do Godot,
13
+ Unity, and Construct.
14
+
15
+ `storage.install()` swaps the throwing `localStorage` for a Storage-shaped
16
+ object backed by the SDK's cloud save, so those builds persist per player and
17
+ across devices with no engine changes. It is idempotent and a no-op wherever a
18
+ real Storage works, so standalone play and URL embeds are untouched. The
19
+ script-tag build installs it at load; module consumers keep a side-effect-free
20
+ import and call it themselves before boot.
21
+
22
+ `ready()` resolves once the cloud read lands (`getItem` is synchronous, the read
23
+ is not), `flush()` forces the debounced write out, and `mode` reports
24
+ `'native' | 'cloud' | 'memory'`. `sessionStorage` is shimmed memory-only. With
25
+ the shim active `save()`/`load()` share the player's one save slot through a
26
+ tagged envelope that `load()` unwraps; a raw blob written before the shim
27
+ existed reads back untouched.
28
+
29
+ The shim never writes over a save it has not successfully read. A read that
30
+ FAILS settles just like one that succeeds, so persisting on that basis would
31
+ serialize only the current session's keys and wipe the rest; instead a failed
32
+ read is retried on the next write, and a write that still cannot confirm what
33
+ the player had is refused rather than allowed to overwrite it blind. `save()`
34
+ is size-checked against the whole envelope before it commits, so a rejected
35
+ oversized blob cannot leave the session unable to write at all. Keys named
36
+ `__proto__` are stored and restored like any other, matching a real `Storage`.
37
+
3
38
  ## 1.2.0
4
39
 
5
40
  ### Minor Changes
package/README.md CHANGED
@@ -18,7 +18,10 @@ npm install @bountyboard/arcade-sdk
18
18
  ```ts
19
19
  import { BBArcade } from '@bountyboard/arcade-sdk';
20
20
 
21
- BBArcade.lockToHost({ allow: ['play.yourgame.com'] }); // optional; call before boot
21
+ // Optional; call before boot. 'play.yourgame.com' is a placeholder: allow lists
22
+ // the domains YOU host on, and Bounty Board plus localhost are always allowed,
23
+ // so a Bounty-hosted build needs no allow list (or no call at all).
24
+ BBArcade.lockToHost({ allow: ['play.yourgame.com'] });
22
25
  void BBArcade.init(); // host handshake; awaiting is optional
23
26
 
24
27
  export function onGameReady() {
@@ -65,7 +68,7 @@ specifically needs the global build, import `@bountyboard/arcade-sdk/global`.
65
68
  Bundle note: importing the package attaches the host `postMessage` listeners
66
69
  immediately (the host pushes its config at load time, so listener timing is
67
70
  part of the protocol), so the package is honestly marked `sideEffects: true`
68
- and will not tree-shake. The full script-tag build is ~47 KB unminified;
71
+ and will not tree-shake. The full script-tag build is ~55 KB unminified;
69
72
  module consumers who only use multiplayer still get the whole core.
70
73
 
71
74
  ## Runtime support
@@ -79,6 +82,7 @@ the game runs determines which host-backed features are available.
79
82
  | Player identity | Player or `null` | Player or `null` | `null` |
80
83
  | A/B variants | Stable assignment | Stable assignment | Alphabetical control |
81
84
  | Cloud save / load | Logged-in players | Unsupported | Unsupported |
85
+ | Native `localStorage` | Throws (opaque) | Works on your origin | Works |
82
86
  | Rewarded ads | Approved games | Unavailable (no payable attribution) | Unavailable |
83
87
  | Multiplayer | Enabled authority | Enabled authority | Unsupported |
84
88
 
@@ -148,6 +152,47 @@ save/load rejection is a real `Error` with a typed `code`:
148
152
  unsupported | unauthenticated | too_large | rejected | error
149
153
  ```
150
154
 
155
+ ## Engine exports: the localStorage shim
156
+
157
+ Prefer `save()`/`load()` when you control the source. The shim is for engine
158
+ runtimes whose storage layer can't be rewired without patching engine
159
+ internals — GameMaker HTML5 (`ini_open`, `ini_write_*`, and `game_save` all sit
160
+ on `localStorage`), Godot, Unity, Construct. It replaces the throwing
161
+ `localStorage` with a Storage-shaped object backed by your cloud save, so those
162
+ builds persist per player and across devices with no engine changes.
163
+
164
+ The script-tag build installs it at load, so the only requirement is load order:
165
+
166
+ ```html
167
+ <script src="https://www.bountyboard.gg/arcade-sdk/v1.js"></script>
168
+ <script src="html5game/YourGame.js"></script>
169
+ ```
170
+
171
+ Module consumers keep a side-effect-free import and install it themselves,
172
+ before any engine code runs:
173
+
174
+ ```ts
175
+ BBArcade.storage.install(); // idempotent; no-op where a real Storage works
176
+
177
+ // getItem() is synchronous but the cloud read that fills it is not.
178
+ const mode = await BBArcade.storage.ready(); // 'cloud' | 'memory' | 'native'
179
+ startGame();
180
+ ```
181
+
182
+ Writes made before hydration are kept and merged with the cloud read (your
183
+ write wins; a `removeItem` isn't resurrected), and nothing is pushed to the
184
+ server until that read has SUCCEEDED — a failed read is retried, and a write
185
+ that still can't confirm what the player had is refused rather than allowed to
186
+ overwrite it blind, so neither a fresh-start write at boot nor a transport blip
187
+ can wipe an existing save. `sessionStorage` is shimmed too, but memory-only. Guests and
188
+ standalone play settle in `memory` mode: storage works for the session and is
189
+ never persisted. `setItem` throws `QuotaExceededError` past the save cap, like
190
+ a real Storage.
191
+
192
+ `save()`/`load()` keep working alongside it: the player has one save slot, so
193
+ both halves share it inside a tagged envelope that `load()` unwraps. A raw blob
194
+ written before the shim existed reads back untouched.
195
+
151
196
  ## Player identity and A/B variants
152
197
 
153
198
  The SDK only provides public display identity. It never exposes account ids,
@@ -325,6 +370,8 @@ before adapting an existing server.
325
370
  // Anti-rehosting deterrent, not DRM. Call before boot. When the embedder
326
371
  // can't be read from browser signals (opaque-origin sandbox), the SDK asks
327
372
  // the embedding page and blocks if nothing answers within ~5 seconds.
373
+ // The allowed domains below are placeholders for your own; Bounty Board and
374
+ // localhost are allowed by default, so a Bounty-hosted build passes none.
328
375
  BBArcade.lockToHost({
329
376
  allow: ['play.yourgame.com', 'preview.yourgame.com'],
330
377
  signed: true,
@@ -357,7 +404,7 @@ Give coding agents the public
357
404
  | ------------- | ------------------------------------------------------------------------------------- |
358
405
  | Lifecycle | `init`, `configure`, `ready` / `gameLoadingFinished`, `gameplayStart`, `gameplayStop` |
359
406
  | Scores | `submitScore`, `gameOver` |
360
- | Player data | `save`, `load`, `getPlayer`, `onPlayerChange`, `getVariant` |
407
+ | Player data | `save`, `load`, `storage.install` / `ready` / `flush`, `getPlayer`, `onPlayerChange`, `getVariant` |
361
408
  | Rewarded ads | `prepareRewardedAd`, `rewardedAd`, `rewardedBreak`, `preloadRewardedAds` |
362
409
  | Security / XR | `lockToHost`, `xrSessionStart`, `xrSessionEnd` |
363
410
  | Multiplayer | `joinRoom`, `room.trySend`, `room.on`, `room.leave`, `room.latencyMs` |
@@ -3,6 +3,316 @@ var VERSION = 1;
3
3
  var SDK_MESSAGE_SOURCE = "bb-arcade";
4
4
  var HOST_MESSAGE_SOURCE = "bb-arcade-host";
5
5
 
6
+ // src/storage.ts
7
+ var FLUSH_DELAY_MS = 400;
8
+ var ENVELOPE_TAG = "__bbArcadeStorage";
9
+ var ENVELOPE_VERSION = 1;
10
+ function hasOwn(target, key) {
11
+ return Object.prototype.hasOwnProperty.call(target, key);
12
+ }
13
+ function emptyMap() {
14
+ return /* @__PURE__ */ Object.create(null);
15
+ }
16
+ function storageError(code) {
17
+ const err = new Error(code);
18
+ err.code = code;
19
+ return err;
20
+ }
21
+ function parseEnvelope(raw) {
22
+ if (raw == null) return { ls: {}, app: null };
23
+ if (raw.charAt(0) !== "{") return { ls: {}, app: raw };
24
+ let parsed;
25
+ try {
26
+ parsed = JSON.parse(raw);
27
+ } catch (err) {
28
+ return { ls: {}, app: raw };
29
+ }
30
+ if (!parsed || typeof parsed !== "object") return { ls: {}, app: raw };
31
+ const record = parsed;
32
+ if (record[ENVELOPE_TAG] !== ENVELOPE_VERSION) return { ls: {}, app: raw };
33
+ const ls = emptyMap();
34
+ const source = record.ls;
35
+ if (source && typeof source === "object") {
36
+ const entries = source;
37
+ for (const key in entries) {
38
+ if (hasOwn(entries, key) && typeof entries[key] === "string") {
39
+ ls[key] = entries[key];
40
+ }
41
+ }
42
+ }
43
+ return { ls, app: typeof record.app === "string" ? record.app : null };
44
+ }
45
+ function buildEnvelope(ls, app) {
46
+ const envelope = {};
47
+ envelope[ENVELOPE_TAG] = ENVELOPE_VERSION;
48
+ envelope.ls = ls;
49
+ envelope.app = app;
50
+ return JSON.stringify(envelope);
51
+ }
52
+ function quotaError() {
53
+ try {
54
+ return new DOMException("Cloud save quota exceeded", "QuotaExceededError");
55
+ } catch (err) {
56
+ const fallback = new Error("Cloud save quota exceeded");
57
+ fallback.name = "QuotaExceededError";
58
+ return fallback;
59
+ }
60
+ }
61
+ var RESERVED = {
62
+ getItem: true,
63
+ setItem: true,
64
+ removeItem: true,
65
+ clear: true,
66
+ key: true,
67
+ length: true
68
+ };
69
+ function createStorageShim(backend = {}) {
70
+ const map = emptyMap();
71
+ const localWrites = emptyMap();
72
+ function keys() {
73
+ return Object.keys(map);
74
+ }
75
+ function changed() {
76
+ if (backend.onChange) backend.onChange();
77
+ }
78
+ const api = {
79
+ getItem: function(key) {
80
+ const name = String(key);
81
+ return hasOwn(map, name) ? map[name] : null;
82
+ },
83
+ setItem: function(key, value) {
84
+ const name = String(key);
85
+ const next = String(value);
86
+ if (backend.wouldOverflow && backend.wouldOverflow(name, next)) throw quotaError();
87
+ map[name] = next;
88
+ localWrites[name] = true;
89
+ changed();
90
+ },
91
+ removeItem: function(key) {
92
+ const name = String(key);
93
+ localWrites[name] = true;
94
+ if (!hasOwn(map, name)) return;
95
+ delete map[name];
96
+ changed();
97
+ },
98
+ clear: function() {
99
+ const existing = keys();
100
+ for (let i = 0; i < existing.length; i++) {
101
+ delete map[existing[i]];
102
+ localWrites[existing[i]] = true;
103
+ }
104
+ changed();
105
+ },
106
+ key: function(index) {
107
+ const existing = keys();
108
+ const at = Number(index);
109
+ return at >= 0 && at < existing.length ? existing[at] : null;
110
+ },
111
+ get length() {
112
+ return keys().length;
113
+ }
114
+ };
115
+ const storage = new Proxy(api, {
116
+ get(target, prop, receiver) {
117
+ if (typeof prop !== "string") return Reflect.get(target, prop, receiver);
118
+ if (hasOwn(RESERVED, prop)) return Reflect.get(target, prop, receiver);
119
+ return hasOwn(map, prop) ? map[prop] : void 0;
120
+ },
121
+ set(target, prop, value) {
122
+ if (typeof prop !== "string" || hasOwn(RESERVED, prop)) return true;
123
+ api.setItem(prop, value);
124
+ return true;
125
+ },
126
+ has(target, prop) {
127
+ if (typeof prop !== "string") return Reflect.has(target, prop);
128
+ return hasOwn(RESERVED, prop) || hasOwn(map, prop);
129
+ },
130
+ deleteProperty(target, prop) {
131
+ if (typeof prop === "string" && !hasOwn(RESERVED, prop)) api.removeItem(prop);
132
+ return true;
133
+ },
134
+ ownKeys() {
135
+ return keys();
136
+ },
137
+ getOwnPropertyDescriptor(target, prop) {
138
+ if (typeof prop !== "string" || !hasOwn(map, prop)) return void 0;
139
+ return { value: map[prop], writable: true, enumerable: true, configurable: true };
140
+ }
141
+ });
142
+ return {
143
+ storage,
144
+ entries: function() {
145
+ const copy = emptyMap();
146
+ const existing = keys();
147
+ for (let i = 0; i < existing.length; i++) copy[existing[i]] = map[existing[i]];
148
+ return copy;
149
+ },
150
+ hydrate: function(values) {
151
+ for (const key in values) {
152
+ if (hasOwn(values, key) && !hasOwn(localWrites, key)) map[key] = values[key];
153
+ }
154
+ }
155
+ };
156
+ }
157
+ function settled(promise) {
158
+ return promise.then(
159
+ function() {
160
+ },
161
+ function() {
162
+ }
163
+ );
164
+ }
165
+ function installSessionStorageShim() {
166
+ if (nativeStorageWorks("sessionStorage")) return;
167
+ try {
168
+ Object.defineProperty(window, "sessionStorage", {
169
+ value: createStorageShim().storage,
170
+ configurable: true
171
+ });
172
+ } catch (err) {
173
+ }
174
+ }
175
+ function nativeStorageWorks(area) {
176
+ try {
177
+ const store = window[area];
178
+ if (!store) return false;
179
+ const probe = "__bb_arcade_probe__";
180
+ store.setItem(probe, "1");
181
+ store.removeItem(probe);
182
+ return true;
183
+ } catch (err) {
184
+ return false;
185
+ }
186
+ }
187
+ function createStorageController(options) {
188
+ let shim = null;
189
+ let mode = "native";
190
+ let appBlob = null;
191
+ let hydration = null;
192
+ let flushTimer = null;
193
+ let inFlight = Promise.resolve();
194
+ let dirty = false;
195
+ let hydrated = false;
196
+ function serialize() {
197
+ return buildEnvelope(shim ? shim.entries() : {}, appBlob);
198
+ }
199
+ function hydrateFromCloud() {
200
+ hydration = options.load().then(
201
+ function(raw) {
202
+ const envelope = parseEnvelope(raw);
203
+ if (shim) shim.hydrate(envelope.ls);
204
+ if (appBlob === null) appBlob = envelope.app;
205
+ hydrated = true;
206
+ return mode;
207
+ },
208
+ function(err) {
209
+ const code = err && err.code;
210
+ if (code === "unsupported" || code === "unauthenticated") mode = "memory";
211
+ return mode;
212
+ }
213
+ );
214
+ return hydration;
215
+ }
216
+ function wouldOverflow(key, value) {
217
+ if (!shim) return false;
218
+ const next = shim.entries();
219
+ next[key] = value;
220
+ return options.byteLength(buildEnvelope(next, appBlob)) > options.maxBytes;
221
+ }
222
+ function persist() {
223
+ return whenHydrated().then(function() {
224
+ if (mode === "memory") return;
225
+ if (hydrated) return options.save(serialize());
226
+ return hydrateFromCloud().then(function() {
227
+ if (mode === "memory") return;
228
+ if (!hydrated) throw storageError("error");
229
+ return options.save(serialize());
230
+ });
231
+ }).then(void 0, function(err) {
232
+ dirty = true;
233
+ throw err;
234
+ });
235
+ }
236
+ function flushNow() {
237
+ if (flushTimer !== null) {
238
+ clearTimeout(flushTimer);
239
+ flushTimer = null;
240
+ }
241
+ dirty = false;
242
+ const next = inFlight.then(persist, persist);
243
+ inFlight = settled(next);
244
+ return next;
245
+ }
246
+ function scheduleFlush() {
247
+ dirty = true;
248
+ if (flushTimer !== null) return;
249
+ flushTimer = setTimeout(function() {
250
+ flushTimer = null;
251
+ void settled(flushNow());
252
+ }, FLUSH_DELAY_MS);
253
+ }
254
+ function whenHydrated() {
255
+ return hydration ? hydration : Promise.resolve(mode);
256
+ }
257
+ function attachUnloadFlush() {
258
+ const flushIfDirty = function() {
259
+ if (dirty) void settled(flushNow());
260
+ };
261
+ try {
262
+ window.addEventListener("pagehide", flushIfDirty);
263
+ window.addEventListener("visibilitychange", function() {
264
+ if (document.visibilityState === "hidden") flushIfDirty();
265
+ });
266
+ } catch (err) {
267
+ }
268
+ }
269
+ function install() {
270
+ if (shim) return mode;
271
+ installSessionStorageShim();
272
+ if (nativeStorageWorks("localStorage")) {
273
+ mode = "native";
274
+ return mode;
275
+ }
276
+ const handle = createStorageShim({ onChange: scheduleFlush, wouldOverflow });
277
+ try {
278
+ Object.defineProperty(window, "localStorage", {
279
+ value: handle.storage,
280
+ configurable: true
281
+ });
282
+ } catch (err) {
283
+ return mode;
284
+ }
285
+ shim = handle;
286
+ mode = "cloud";
287
+ attachUnloadFlush();
288
+ void hydrateFromCloud();
289
+ return mode;
290
+ }
291
+ return {
292
+ install,
293
+ active: function() {
294
+ return shim !== null;
295
+ },
296
+ ready: function() {
297
+ return whenHydrated();
298
+ },
299
+ flush: function() {
300
+ if (!shim && appBlob === null) return Promise.resolve();
301
+ return flushNow();
302
+ },
303
+ writeApp: function(blob) {
304
+ if (options.byteLength(buildEnvelope(shim ? shim.entries() : {}, blob)) > options.maxBytes) {
305
+ return Promise.reject(storageError("too_large"));
306
+ }
307
+ appBlob = blob;
308
+ return flushNow();
309
+ },
310
+ get mode() {
311
+ return mode;
312
+ }
313
+ };
314
+ }
315
+
6
316
  // src/core.ts
7
317
  var BOUNTY_BOARD_URL = "https://www.bountyboard.gg/arcade";
8
318
  var ADSENSE_CLIENT_ID = "ca-pub-7727110228190547";
@@ -791,6 +1101,16 @@ function createBBArcade() {
791
1101
  if (!hostnameAllowed(event.origin, DEFAULT_ALLOW)) return;
792
1102
  applyHostConfig(event.data);
793
1103
  });
1104
+ const storage = createStorageController({
1105
+ save: function(blob) {
1106
+ return request("save", blob);
1107
+ },
1108
+ load: function() {
1109
+ return request("load");
1110
+ },
1111
+ byteLength: utf8ByteLength,
1112
+ maxBytes: MAX_SAVE_BYTES
1113
+ });
794
1114
  const sdk = {
795
1115
  lockToHost,
796
1116
  init,
@@ -827,11 +1147,15 @@ function createBBArcade() {
827
1147
  if (utf8ByteLength(data) > MAX_SAVE_BYTES) {
828
1148
  return Promise.reject(sdkError("too_large"));
829
1149
  }
1150
+ if (storage.active()) return storage.writeApp(data);
830
1151
  return request("save", data);
831
1152
  },
832
1153
  load: function() {
833
- return request("load");
1154
+ return request("load").then(function(raw) {
1155
+ return parseEnvelope(raw).app;
1156
+ });
834
1157
  },
1158
+ storage,
835
1159
  getPlayer,
836
1160
  onPlayerChange: function(handler) {
837
1161
  if (typeof handler !== "function") return function() {
@@ -907,6 +1231,14 @@ function createInertBBArcade() {
907
1231
  rewardedBreak: () => Promise.resolve(false),
908
1232
  save: () => Promise.reject(sdkError2("unsupported")),
909
1233
  load: () => Promise.reject(sdkError2("unsupported")),
1234
+ // No window to shim and nothing to persist to: report the honest mode
1235
+ // rather than pretending an install succeeded.
1236
+ storage: {
1237
+ install: () => "memory",
1238
+ ready: () => Promise.resolve("memory"),
1239
+ flush: () => Promise.resolve(),
1240
+ mode: "memory"
1241
+ },
910
1242
  getPlayer: () => Promise.resolve(null),
911
1243
  onPlayerChange: () => () => {
912
1244
  },
@@ -927,4 +1259,4 @@ export {
927
1259
  BBArcade,
928
1260
  src_default
929
1261
  };
930
- //# sourceMappingURL=chunk-KDBBR532.js.map
1262
+ //# sourceMappingURL=chunk-PYW5F45R.js.map