@bountyboard/arcade-sdk 1.1.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 +22 -10
- package/CHANGELOG.md +73 -0
- package/README.md +378 -85
- package/dist/{chunk-OWOESH4X.js → chunk-PYW5F45R.js} +384 -10
- package/dist/chunk-PYW5F45R.js.map +1 -0
- package/dist/global-sdk.d.ts +100 -3
- package/dist/global.js +482 -48
- package/dist/index.cjs +383 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/multiplayer.cjs +481 -48
- package/dist/multiplayer.cjs.map +1 -1
- package/dist/multiplayer.d.cts +2 -2
- package/dist/multiplayer.d.ts +2 -2
- package/dist/multiplayer.js +88 -29
- package/dist/multiplayer.js.map +1 -1
- package/dist/{types-Cq8p7ZkC.d.cts → types-B5bqWnHH.d.cts} +99 -4
- package/dist/{types-Cq8p7ZkC.d.ts → types-B5bqWnHH.d.ts} +99 -4
- package/docs/external-authoritative-servers.md +265 -0
- package/docs/llms.txt +800 -0
- package/docs/relay-rooms.md +134 -0
- package/package.json +2 -1
- package/dist/chunk-OWOESH4X.js.map +0 -1
package/AGENTS.md
CHANGED
|
@@ -19,18 +19,28 @@ 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
|
|
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.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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.
|
|
40
|
+
7. **Multiplayer: clients send inputs, never state.** The approved room authority (a Bounty Board
|
|
41
|
+
module or a registered external server) simulates and validates; design the game so all
|
|
42
|
+
authority lives in that one server. Don't trust or display any value another client sent
|
|
43
|
+
directly, and never run a second copy of a mature external simulation in a relay/Worker.
|
|
34
44
|
|
|
35
45
|
## Correct lifecycle order
|
|
36
46
|
|
|
@@ -71,10 +81,12 @@ the safe/default experience the alphabetically-first name. Don't re-randomize cl
|
|
|
71
81
|
`'unavailable'` locally is normal. Mock both a ready placement and an unavailable placement;
|
|
72
82
|
assert that `show()` is called directly by your UI handler and only final status `'viewed'`
|
|
73
83
|
grants the reward.
|
|
74
|
-
- Multiplayer: run the room server locally (`wrangler dev` with
|
|
75
|
-
`{ roomUrl, ticket }` overrides to `joinRoom
|
|
76
|
-
|
|
77
|
-
|
|
84
|
+
- Multiplayer: for Bounty-hosted modules, run the room server locally (`wrangler dev` with
|
|
85
|
+
`DEV_ALLOW_UNSIGNED=1`) and pass `{ roomUrl, ticket }` overrides to `joinRoom`. For a registered
|
|
86
|
+
external authority, exercise its isolated signed-ticket adapter endpoint. In both cases test a
|
|
87
|
+
disconnect/reconnect, assert `trySend()` returns false while offline, and verify the authority
|
|
88
|
+
validates optional `joinData` rather than treating avatar/loadout/mode selections as trusted
|
|
89
|
+
state. See `docs/external-authoritative-servers.md` before adapting an existing server.
|
|
78
90
|
|
|
79
91
|
## Design intent (read docs/game-design-playbook.md before building a NEW game)
|
|
80
92
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,78 @@
|
|
|
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
|
+
|
|
38
|
+
## 1.2.0
|
|
39
|
+
|
|
40
|
+
### Minor Changes
|
|
41
|
+
|
|
42
|
+
- c2854a7: Support curated external authoritative multiplayer servers with per-game ticket keys while preserving the existing `joinRoom()` API and Bounty-hosted room modules.
|
|
43
|
+
- 81fdada: Multiplayer: public quick match. `joinRoom({ match: true })` joins the game's
|
|
44
|
+
open public room (or starts a fresh one when no seat is free) instead of
|
|
45
|
+
requiring a share code. Rooms fill to the game's player cap (at most 64), a
|
|
46
|
+
matched room still exposes `room.code` for inviting friends, and the SDK
|
|
47
|
+
automatically re-matchmakes up to twice when it loses a last-seat race
|
|
48
|
+
(`error.detail` now carries the raw server reason, e.g. `room_full`).
|
|
49
|
+
- 387a347: Player-identity change notifications, multiplayer tuning hooks, and tighter failure semantics.
|
|
50
|
+
|
|
51
|
+
- Add `BBArcade.onPlayerChange(handler)`: fires when the host delivers a changed display identity (mid-session login/logout); returns an unsubscribe function.
|
|
52
|
+
- Add `joinRoom({ timeoutMs })` to override the 10-second welcome timeout (clamped to 1000–60000 ms, applies to reconnects too), and `room.latencyMs`, a join-handshake latency estimate refreshed on every (re)connect.
|
|
53
|
+
- `save()` now rejects oversized blobs with `too_large` immediately via a client-side precheck against the same 1 MiB cap the server enforces.
|
|
54
|
+
- A rewarded placement whose break ends before a prepared ad is ever shown now resolves `dismissed` instead of the non-terminal `ready` (the shown-but-unreported legacy edge keeps `ready`).
|
|
55
|
+
- `lockToHost()`'s opaque-origin host handshake times out after ~5 seconds instead of 15, so scraped copies are blocked sooner.
|
|
56
|
+
- `leave()` during an in-flight join/reconnect settles immediately, and a welcome arriving after `leave()` is ignored instead of resurrecting the room.
|
|
57
|
+
- Fractional scores are truncated toward zero (`Math.trunc`), matching the documented contract (previously floored, which differed for negative scores).
|
|
58
|
+
|
|
59
|
+
### Patch Changes
|
|
60
|
+
|
|
61
|
+
- fb26238: Use the declared multiplayer SDK integration when authorizing room tickets and
|
|
62
|
+
server-authoritative result reports.
|
|
63
|
+
- 8289ae5: Replace private-repository documentation links with public Bounty Board URLs and rewrite the external authoritative server guide as a vendor-neutral integration contract.
|
|
64
|
+
- 145d951: Document the built-in relay room tier: every multiplayer-approved game gets
|
|
65
|
+
hosted casual lobbies (public quick match, invite codes, host succession,
|
|
66
|
+
rate-guarded public message fan-out) with zero server code, via the existing
|
|
67
|
+
`joinRoom()` API. New `docs/relay-rooms.md` covers the wire contract — the
|
|
68
|
+
`joinData.roomSize` founding rule (2–64, default 8), `relay`/`relay_host`
|
|
69
|
+
events, the 1 KiB / 15 msg/s / 120 msg/room/s guardrails, and the
|
|
70
|
+
client-trusted outcome model (relay rooms never emit `end` or report results).
|
|
71
|
+
No runtime code changes.
|
|
72
|
+
- f55f6e6: Multiplayer: treat the server's 4005 seat-full close as terminal. A seat
|
|
73
|
+
already holding its maximum concurrent sockets now rejects newcomers, and the
|
|
74
|
+
client stops retrying instead of looping ticket requests against a full seat.
|
|
75
|
+
|
|
3
76
|
## 1.1.0
|
|
4
77
|
|
|
5
78
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
# @bountyboard/arcade-sdk
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
Zero dependencies. Every call is optional and safe standalone — off Bounty Board the SDK
|
|
7
|
-
quietly does nothing, so the same build runs anywhere.
|
|
3
|
+
Add Bounty Board leaderboards, cloud saves, player display identity, A/B variants,
|
|
4
|
+
rewarded ads, site lock, WebXR playtime, and authoritative multiplayer to an
|
|
5
|
+
HTML5 game.
|
|
8
6
|
|
|
9
|
-
|
|
7
|
+
- Zero runtime dependencies
|
|
8
|
+
- ESM, CommonJS, global, and strict TypeScript declarations
|
|
9
|
+
- Safe to import during SSR or prerendering
|
|
10
|
+
- Safe standalone behavior: the SDK never becomes a requirement for booting or playing
|
|
11
|
+
|
|
12
|
+
## Quickstart
|
|
10
13
|
|
|
11
14
|
```bash
|
|
12
15
|
npm install @bountyboard/arcade-sdk
|
|
@@ -15,55 +18,223 @@ npm install @bountyboard/arcade-sdk
|
|
|
15
18
|
```ts
|
|
16
19
|
import { BBArcade } from '@bountyboard/arcade-sdk';
|
|
17
20
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
BBArcade.
|
|
22
|
-
BBArcade.
|
|
23
|
-
|
|
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'] });
|
|
25
|
+
void BBArcade.init(); // host handshake; awaiting is optional
|
|
26
|
+
|
|
27
|
+
export function onGameReady() {
|
|
28
|
+
BBArcade.gameLoadingFinished();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function onRunStart() {
|
|
32
|
+
BBArcade.gameplayStart();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function onScoreChange(score: number) {
|
|
36
|
+
BBArcade.submitScore(Math.trunc(score));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function onPause() {
|
|
40
|
+
BBArcade.gameplayStop();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function onRunEnd(finalScore: number) {
|
|
44
|
+
BBArcade.gameplayStop();
|
|
45
|
+
BBArcade.gameOver(Math.trunc(finalScore)); // exactly once per run
|
|
46
|
+
}
|
|
24
47
|
```
|
|
25
48
|
|
|
26
|
-
|
|
27
|
-
`window.BBArcade` global and is SSR-safe; if your setup wants the global, use
|
|
28
|
-
`import '@bountyboard/arcade-sdk/global'`.
|
|
49
|
+
No build step? Load the same SDK as a global:
|
|
29
50
|
|
|
30
|
-
|
|
51
|
+
```html
|
|
52
|
+
<script src="https://www.bountyboard.gg/arcade-sdk/v1.js"></script>
|
|
53
|
+
<script>
|
|
54
|
+
void BBArcade.init();
|
|
55
|
+
|
|
56
|
+
// Call this from your game when assets and the first scene are playable.
|
|
57
|
+
function onGameReady() {
|
|
58
|
+
BBArcade.gameLoadingFinished();
|
|
59
|
+
}
|
|
60
|
+
</script>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The script tag installs `window.BBArcade`. Do not mix it with the npm package
|
|
64
|
+
in one page. Standalone declarations are available from
|
|
65
|
+
[`/arcade-sdk.d.ts`](https://www.bountyboard.gg/arcade-sdk.d.ts). If a bundler
|
|
66
|
+
specifically needs the global build, import `@bountyboard/arcade-sdk/global`.
|
|
67
|
+
|
|
68
|
+
Bundle note: importing the package attaches the host `postMessage` listeners
|
|
69
|
+
immediately (the host pushes its config at load time, so listener timing is
|
|
70
|
+
part of the protocol), so the package is honestly marked `sideEffects: true`
|
|
71
|
+
and will not tree-shake. The full script-tag build is ~55 KB unminified;
|
|
72
|
+
module consumers who only use multiplayer still get the whole core.
|
|
73
|
+
|
|
74
|
+
## Runtime support
|
|
75
|
+
|
|
76
|
+
npm versus script tag only changes how the SDK loads. The environment where
|
|
77
|
+
the game runs determines which host-backed features are available.
|
|
78
|
+
|
|
79
|
+
| Capability | Bounty-hosted upload | Approved URL embed inside Bounty Board | Standalone / own site |
|
|
80
|
+
| --------------------------------- | -------------------- | -------------------------------------- | --------------------- |
|
|
81
|
+
| Lifecycle, scores, host analytics | Supported | Supported | Safe no-op |
|
|
82
|
+
| Player identity | Player or `null` | Player or `null` | `null` |
|
|
83
|
+
| A/B variants | Stable assignment | Stable assignment | Alphabetical control |
|
|
84
|
+
| Cloud save / load | Logged-in players | Unsupported | Unsupported |
|
|
85
|
+
| Native `localStorage` | Throws (opaque) | Works on your origin | Works |
|
|
86
|
+
| Rewarded ads | Approved games | Unavailable (no payable attribution) | Unavailable |
|
|
87
|
+
| Multiplayer | Enabled authority | Enabled authority | Unsupported |
|
|
88
|
+
|
|
89
|
+
An externally hosted URL opened directly is standalone play. The same URL
|
|
90
|
+
inside the approved Bounty Board player is a URL embed. Guests are normal:
|
|
91
|
+
identity resolves `null`, and account-backed promises may reject with
|
|
92
|
+
`code: 'unauthenticated'`.
|
|
93
|
+
|
|
94
|
+
## Correct lifecycle
|
|
95
|
+
|
|
96
|
+
```text
|
|
97
|
+
lockToHost() -> init() -> [getPlayer()] -> gameLoadingFinished()
|
|
98
|
+
-> per run: gameplayStart() -> submitScore()* -> gameplayStop() -> gameOver()
|
|
99
|
+
-> save() at checkpoints or game over
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`gameplayStart()` and `gameplayStop()` bracket active play, not menus. Call
|
|
103
|
+
`gameOver()` exactly once per run and submit integer scores. Pass
|
|
104
|
+
`{ mode: 'daily' }` to both score calls for a shared-seed Daily run.
|
|
105
|
+
|
|
106
|
+
The SDK must never be load-bearing. With no host present:
|
|
107
|
+
|
|
108
|
+
- fire-and-forget signals no-op;
|
|
109
|
+
- `init()` resolves after a short grace;
|
|
110
|
+
- `getPlayer()` resolves `null`;
|
|
111
|
+
- `getVariant()` returns the alphabetically first control;
|
|
112
|
+
- save, load, ads, and multiplayer settle quickly with their documented
|
|
113
|
+
unsupported/unavailable outcomes.
|
|
114
|
+
|
|
115
|
+
Your game must still boot and remain fully playable when all of those outcomes
|
|
116
|
+
happen at once.
|
|
117
|
+
|
|
118
|
+
## Cloud saves
|
|
119
|
+
|
|
120
|
+
Store the whole progress model as one string blob (about 1 MB maximum). Save at
|
|
121
|
+
checkpoints or game over, never in a hot loop.
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import type { BBArcadeError } from '@bountyboard/arcade-sdk';
|
|
125
|
+
|
|
126
|
+
const blob = JSON.stringify(progress);
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await BBArcade.save(blob);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
const code = (error as BBArcadeError).code;
|
|
132
|
+
if (code === 'unsupported') localStorage.setItem('progress', blob);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const saved = await BBArcade.load(); // string or null when no save exists
|
|
137
|
+
if (saved) restore(JSON.parse(saved));
|
|
138
|
+
} catch {
|
|
139
|
+
restoreStandaloneProgress();
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Hosted uploads run in an opaque-origin sandbox without `localStorage`; SDK
|
|
144
|
+
cloud save is primary there. URL embeds and standalone builds need their own
|
|
145
|
+
same-origin storage. Oversized blobs reject `too_large` immediately (a
|
|
146
|
+
client-side precheck against the same 1 MiB cap the server enforces), and a
|
|
147
|
+
host that never answers rejects `error` after a 15-second request timeout —
|
|
148
|
+
the same timeout covers load, variant, and multiplayer-ticket requests. Every
|
|
149
|
+
save/load rejection is a real `Error` with a typed `code`:
|
|
150
|
+
|
|
151
|
+
```text
|
|
152
|
+
unsupported | unauthenticated | too_large | rejected | error
|
|
153
|
+
```
|
|
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:
|
|
31
165
|
|
|
32
166
|
```html
|
|
33
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();
|
|
34
180
|
```
|
|
35
181
|
|
|
36
|
-
with
|
|
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
|
+
|
|
196
|
+
## Player identity and A/B variants
|
|
37
197
|
|
|
38
|
-
|
|
198
|
+
The SDK only provides public display identity. It never exposes account ids,
|
|
199
|
+
emails, or roles.
|
|
39
200
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
| `submitScore(n, { mode?: 'daily' })` | Live integer score; server enforces plausibility caps |
|
|
47
|
-
| `gameOver(n, { mode?: 'daily' })` | Final score, once per run |
|
|
48
|
-
| `save(blob)` / `load()` | 1MB cloud save per player (hosted builds; always catch rejections) |
|
|
49
|
-
| `getPlayer()` | `{ name, avatarUrl }` or `null` — display identity only, always handle null |
|
|
50
|
-
| `getVariant(key, variants)` | Deterministic A/B split; alphabetical first variant = control |
|
|
51
|
-
| `prepareRewardedAd(opts)` | Prepare an ad; call ready `show()` directly from the player's click |
|
|
52
|
-
| `rewardedBreak(opts)` | Legacy one-call ad flow (kept for compatibility; avoid in new integrations) |
|
|
53
|
-
| `xrSessionStart()` / `xrSessionEnd()` | Keep WebXR headset time counting as playtime |
|
|
54
|
-
| `multiplayer.joinRoom(...)` | Authoritative multiplayer rooms — see below |
|
|
201
|
+
```ts
|
|
202
|
+
const player = await BBArcade.getPlayer();
|
|
203
|
+
if (player) {
|
|
204
|
+
greet(player.name);
|
|
205
|
+
if (player.avatarUrl) drawAvatar(player.avatarUrl);
|
|
206
|
+
}
|
|
55
207
|
|
|
56
|
-
|
|
57
|
-
|
|
208
|
+
// Alphabetical first is the standalone/error control: "control" here.
|
|
209
|
+
const variant = await BBArcade.getVariant('start-cta', ['control', 'short-label']);
|
|
210
|
+
renderStartButton(variant ?? 'control');
|
|
211
|
+
```
|
|
58
212
|
|
|
59
|
-
|
|
213
|
+
Always handle `getPlayer() === null` and `avatarUrl === null`. Variant
|
|
214
|
+
assignments are stable per player, game, and key. Do not re-randomize them in
|
|
215
|
+
the client.
|
|
60
216
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
inside the player's explicit Watch Ad click. Do not `await`, queue a microtask, or schedule a
|
|
64
|
-
timer before `show()`.
|
|
217
|
+
`getPlayer()` is a one-shot read of the identity at handshake time. To react
|
|
218
|
+
when the player logs in or out mid-session, subscribe to changes:
|
|
65
219
|
|
|
66
220
|
```ts
|
|
221
|
+
const unsubscribe = BBArcade.onPlayerChange(player => {
|
|
222
|
+
// Fires only when the identity actually changes; null means logged out.
|
|
223
|
+
updateGreeting(player);
|
|
224
|
+
});
|
|
225
|
+
// Later, when the UI is gone: unsubscribe();
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Rewarded ads: prepare, then show from the player gesture
|
|
229
|
+
|
|
230
|
+
New integrations should use `prepareRewardedAd()`. Prepare when a natural
|
|
231
|
+
reward panel opens, keep the Watch Ad control disabled until the result is
|
|
232
|
+
`ready`, and call its one-shot `show()` directly inside the click/tap handler.
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
const button = document.querySelector<HTMLButtonElement>('#watch-ad')!;
|
|
236
|
+
button.disabled = true;
|
|
237
|
+
|
|
67
238
|
const prepared = await BBArcade.prepareRewardedAd({
|
|
68
239
|
placement: 'death_revive',
|
|
69
240
|
reward: 'extra_life',
|
|
@@ -71,64 +242,186 @@ const prepared = await BBArcade.prepareRewardedAd({
|
|
|
71
242
|
});
|
|
72
243
|
|
|
73
244
|
if (prepared.status === 'ready') {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
245
|
+
button.disabled = false;
|
|
246
|
+
button.addEventListener(
|
|
247
|
+
'click',
|
|
248
|
+
() => {
|
|
249
|
+
// Keep first: no await, timer, microtask, or network call before show().
|
|
250
|
+
const resultPromise = prepared.show();
|
|
251
|
+
button.disabled = true;
|
|
252
|
+
|
|
253
|
+
void resultPromise.then(result => {
|
|
254
|
+
resumeGameAndAudio();
|
|
255
|
+
if (result.status === 'viewed') revivePlayer();
|
|
256
|
+
else keepNormalFallbackAvailable();
|
|
257
|
+
});
|
|
258
|
+
},
|
|
259
|
+
{ once: true }
|
|
260
|
+
);
|
|
82
261
|
} else {
|
|
83
|
-
|
|
262
|
+
keepNormalFallbackAvailable();
|
|
84
263
|
}
|
|
85
264
|
```
|
|
86
265
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
266
|
+
Only the final `status === 'viewed'` grants a reward. `dismissed`,
|
|
267
|
+
`unavailable`, and `error` are non-rewarding; a prepared ad whose break ends
|
|
268
|
+
before `show()` is ever called also resolves `dismissed`, never `ready`.
|
|
269
|
+
Never auto-trigger, loop, or auto-retry an ad. `rewardedBreak()` remains for
|
|
270
|
+
compatibility with shipped games but cannot preserve direct browser user
|
|
271
|
+
activation in every case.
|
|
272
|
+
|
|
273
|
+
## Multiplayer rooms
|
|
92
274
|
|
|
93
|
-
|
|
275
|
+
SDK 1.1 adds production room support with bounded `joinData`, observable
|
|
276
|
+
reconnect state, and explicit `trySend()` backpressure. SDK 1.2 adds public
|
|
277
|
+
quick match.
|
|
278
|
+
|
|
279
|
+
Multiplayer is enabled per game and runs on one of three rails, all behind the
|
|
280
|
+
same `joinRoom()` transport:
|
|
281
|
+
|
|
282
|
+
1. **Built-in relay rooms** (the default): every multiplayer-approved game
|
|
283
|
+
gets hosted casual lobbies with zero server code. The relay owns signed
|
|
284
|
+
admission, roster, seat capacity, host succession, and rate-guarded public
|
|
285
|
+
message fan-out; it never interprets payloads, never settles, and never
|
|
286
|
+
reports results, so relay outcomes are client-trusted. See the
|
|
287
|
+
[relay room guide](https://www.bountyboard.gg/arcade/sdk/relay-rooms.txt).
|
|
288
|
+
2. **Refereed Bounty-hosted modules**: a curated server module validates
|
|
289
|
+
inputs, runs the only simulation, and emits authoritative results.
|
|
290
|
+
3. **Registered external authorities**: a mature game keeps its own server
|
|
291
|
+
with a dedicated ticket key.
|
|
292
|
+
|
|
293
|
+
The platform assigns the rail per game slug; clients cannot select it.
|
|
94
294
|
|
|
95
295
|
```ts
|
|
96
296
|
import { joinRoom } from '@bountyboard/arcade-sdk/multiplayer';
|
|
97
297
|
|
|
98
298
|
const room = await joinRoom({
|
|
99
|
-
create: true, // or code: 'ABCD'
|
|
100
|
-
joinData: { avatar: 'golem', mode: 'ffa' }, // untrusted
|
|
299
|
+
create: true, // or code: 'ABCD', or match: true for public quick match
|
|
300
|
+
joinData: { avatar: 'golem', mode: 'ffa' }, // untrusted; server validates
|
|
101
301
|
});
|
|
102
|
-
|
|
103
|
-
room.
|
|
104
|
-
room.on('
|
|
105
|
-
|
|
302
|
+
|
|
303
|
+
showShareCode(room.code);
|
|
304
|
+
room.on('snapshot', ({ state }) => renderAuthoritativeState(state));
|
|
305
|
+
room.on('connection', ({ connected }) => setReconnecting(!connected));
|
|
306
|
+
room.on('end', ({ results }) => showResults(results)); // finite games only
|
|
307
|
+
|
|
308
|
+
onPlayerInput(input => {
|
|
309
|
+
if (!room.trySend(input)) setReconnecting(true);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
onExit(() => room.leave());
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
On the refereed and external rails, clients send inputs, never authoritative
|
|
316
|
+
state. On the relay rail, `room.send(data)` fans the payload publicly to every
|
|
317
|
+
player (including the sender) in per-tick batches of
|
|
318
|
+
`{ type: 'relay', messages: [{ from, data }] }`; the room's first joiner fixes
|
|
319
|
+
the seat count with `joinData.roomSize` (an integer 2–64, default 8), the
|
|
320
|
+
oldest retained seat is host (`state.hostId`, changes broadcast as
|
|
321
|
+
`{ type: 'relay_host', hostId }`), and traffic is capped at 1 KiB per message,
|
|
322
|
+
15 messages per player per second, and 120 per room per second.
|
|
323
|
+
|
|
324
|
+
Two tuning hooks: `joinRoom({ timeoutMs })` overrides how long a join (and
|
|
325
|
+
each reconnect attempt) waits for the server's welcome before rejecting with
|
|
326
|
+
`code: 'error'` (default 10000, clamped to 1000–60000), and `room.latencyMs`
|
|
327
|
+
reports the join-handshake latency (socket open to server welcome, refreshed
|
|
328
|
+
on every reconnect; `null` until the first welcome) — an estimate to tune
|
|
329
|
+
render-side smoothing against, not a measured RTT.
|
|
330
|
+
|
|
331
|
+
Rooms are lobby-based: each game module declares its player range (up to 64
|
|
332
|
+
per room). `match: true` is public quick match — Bounty Board fills the game's
|
|
333
|
+
open public room and starts a fresh one when no seat is free; the matched
|
|
334
|
+
room's `room.code` still works as an invite code. When the last free seat is
|
|
335
|
+
lost to a race the SDK re-matchmakes automatically (up to two retries) before
|
|
336
|
+
rejecting with `code: 'rejected'` and `detail: 'room_full'`. Quick match is
|
|
337
|
+
mutually exclusive with `code`/`create` and is unavailable on external
|
|
338
|
+
authorities, which run their own rooms.
|
|
339
|
+
|
|
340
|
+
### Latency model
|
|
341
|
+
|
|
342
|
+
Refereed and external rooms are state-sync only: the server simulates, clients receive per-viewer
|
|
343
|
+
snapshots at the room tick rate, and every input takes a network round trip
|
|
344
|
+
before its effect appears in a snapshot. There is no built-in client-side
|
|
345
|
+
prediction, interpolation, or rollback. Design for it: turn-based, timing-duel,
|
|
346
|
+
score-race, and party games feel native; twitch physics (fighting games,
|
|
347
|
+
precision platform duels) need your own render-side smoothing — interpolate
|
|
348
|
+
between snapshots and animate optimistic feedback for the local player's input,
|
|
349
|
+
but treat the next snapshot as truth. Assume 50-150 ms of input-to-snapshot
|
|
350
|
+
latency on real connections when tuning game feel. (Relay rooms differ: game
|
|
351
|
+
messages arrive as public per-tick event batches, and all game rules live in
|
|
352
|
+
your clients.) Every referee or external authority must:
|
|
353
|
+
|
|
354
|
+
- validate admission, `joinData`, and every input;
|
|
355
|
+
- clear held controls on disconnect;
|
|
356
|
+
- run the only simulation;
|
|
357
|
+
- serialize only viewer-safe state;
|
|
358
|
+
- return server-authored results for finite games;
|
|
359
|
+
- avoid fabricating `match_end` for endless rooms.
|
|
360
|
+
|
|
361
|
+
`joinData` is untrusted JSON capped at 1 KiB and must not contain credentials or
|
|
362
|
+
other secrets. External games keep their existing simulation and add only the
|
|
363
|
+
signed-ticket + SDK envelope at an isolated authenticated endpoint. Read the
|
|
364
|
+
public [external authoritative server contract](https://www.bountyboard.gg/arcade/sdk/external-authority)
|
|
365
|
+
before adapting an existing server.
|
|
366
|
+
|
|
367
|
+
## Site lock and WebXR
|
|
368
|
+
|
|
369
|
+
```ts
|
|
370
|
+
// Anti-rehosting deterrent, not DRM. Call before boot. When the embedder
|
|
371
|
+
// can't be read from browser signals (opaque-origin sandbox), the SDK asks
|
|
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.
|
|
375
|
+
BBArcade.lockToHost({
|
|
376
|
+
allow: ['play.yourgame.com', 'preview.yourgame.com'],
|
|
377
|
+
signed: true,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// WebXR only: keep immersive playtime visible to the host.
|
|
381
|
+
session.addEventListener('end', () => BBArcade.xrSessionEnd());
|
|
382
|
+
BBArcade.xrSessionStart();
|
|
106
383
|
```
|
|
107
384
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
##
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
385
|
+
## Test before submitting
|
|
386
|
+
|
|
387
|
+
- Run the production artifact without a Bounty Board parent and confirm the
|
|
388
|
+
whole game remains playable.
|
|
389
|
+
- Verify ready, active-play, pause/resume, and exactly-one game-over transitions.
|
|
390
|
+
- Test guest, no-save, unsupported, too-large, and transport-failure save paths.
|
|
391
|
+
- Test rewarded `ready`, `unavailable`, `dismissed`, `error`, and `viewed`; only
|
|
392
|
+
`viewed` may grant.
|
|
393
|
+
- Test multiplayer disconnect/reconnect, `trySend() === false`, viewer-safe
|
|
394
|
+
snapshots, and intentional `leave()`.
|
|
395
|
+
- Keep ads and multiplayer controls unavailable until their capabilities are
|
|
396
|
+
actually ready; do not make the rest of the game wait.
|
|
397
|
+
|
|
398
|
+
Give coding agents the public
|
|
399
|
+
[agent-readable verification contract](https://www.bountyboard.gg/arcade/sdk/llms.txt).
|
|
400
|
+
|
|
401
|
+
## API at a glance
|
|
402
|
+
|
|
403
|
+
| Area | Calls |
|
|
404
|
+
| ------------- | ------------------------------------------------------------------------------------- |
|
|
405
|
+
| Lifecycle | `init`, `configure`, `ready` / `gameLoadingFinished`, `gameplayStart`, `gameplayStop` |
|
|
406
|
+
| Scores | `submitScore`, `gameOver` |
|
|
407
|
+
| Player data | `save`, `load`, `storage.install` / `ready` / `flush`, `getPlayer`, `onPlayerChange`, `getVariant` |
|
|
408
|
+
| Rewarded ads | `prepareRewardedAd`, `rewardedAd`, `rewardedBreak`, `preloadRewardedAds` |
|
|
409
|
+
| Security / XR | `lockToHost`, `xrSessionStart`, `xrSessionEnd` |
|
|
410
|
+
| Multiplayer | `joinRoom`, `room.trySend`, `room.on`, `room.leave`, `room.latencyMs` |
|
|
411
|
+
|
|
412
|
+
The package declarations are the exact type reference. The script-tag build
|
|
413
|
+
exposes multiplayer at `BBArcade.multiplayer.joinRoom(...)`; module consumers
|
|
414
|
+
import `joinRoom` from `@bountyboard/arcade-sdk/multiplayer`.
|
|
415
|
+
|
|
416
|
+
## More documentation
|
|
417
|
+
|
|
418
|
+
- [Human integration guide](https://www.bountyboard.gg/arcade/sdk)
|
|
419
|
+
- [Agent-readable API and integration contract](https://www.bountyboard.gg/arcade/sdk/llms.txt)
|
|
420
|
+
- [Release changelog](https://www.bountyboard.gg/arcade/sdk/changelog.txt)
|
|
421
|
+
- [Game design playbook](https://www.bountyboard.gg/arcade/sdk/game-design-playbook.txt)
|
|
422
|
+
- [External authoritative server contract](https://www.bountyboard.gg/arcade/sdk/external-authority)
|
|
423
|
+
- [Submit a game](https://www.bountyboard.gg/arcade/submit)
|
|
131
424
|
|
|
132
425
|
## License
|
|
133
426
|
|
|
134
|
-
MIT
|
|
427
|
+
MIT licensed.
|