@indiegems/gem-web-sdk 7.3.1 → 7.4.1
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 +54 -0
- package/dist/game.d.ts +156 -22
- package/dist/gem-web-sdk.bridge.js +1 -1
- package/dist/gem-web-sdk.esm.js +2 -2
- package/dist/gem-web-sdk.game.js +2 -2
- package/dist/gem-web-sdk.min.js +2 -2
- package/dist/gem-web-sdk.sdk.js +2 -2
- package/dist/gem-web-sdk.server.js +2 -2
- package/dist/index.d.ts +89 -6
- package/dist/sdk.d.ts +156 -22
- package/docs/achievements.md +232 -0
- package/docs/analytics.md +271 -0
- package/docs/api/api-reference-game.md +15308 -0
- package/docs/api/api-reference-server.md +4397 -0
- package/docs/api/reference.json +24130 -0
- package/docs/arcade-economy.md +263 -0
- package/docs/async-opponents.md +396 -0
- package/docs/brand-art.md +334 -0
- package/docs/cross-promotion.md +137 -0
- package/docs/daily-challenges.md +530 -0
- package/docs/dedicated-server-flow.md +879 -0
- package/docs/deprecation-policy.md +124 -0
- package/docs/friends.md +233 -0
- package/docs/game-economy-tutorial.md +326 -0
- package/docs/getting-started.md +552 -0
- package/docs/inventory-and-catalogs.md +1035 -0
- package/docs/leaderboards.md +277 -0
- package/docs/manifest.json +135 -0
- package/docs/match-session.md +288 -0
- package/docs/p2p-and-parties.md +783 -0
- package/docs/platform-rules.md +372 -0
- package/docs/player-sign-in.md +89 -0
- package/docs/room-shapes.md +195 -0
- package/docs/rooms-and-matches.md +779 -0
- package/docs/saving-player-progress.md +457 -0
- package/docs/server-bundles.md +1041 -0
- package/docs/serverless-functions.md +748 -0
- package/docs/trading-and-gifting.md +98 -0
- package/docs/what-a-game-must-do.md +1231 -0
- package/package.json +7 -2
- package/schema/bridge.v1.json +440 -7
- package/schema/capabilities.public.json +189 -0
|
@@ -0,0 +1,1231 @@
|
|
|
1
|
+
# What a game still has to do for itself
|
|
2
|
+
|
|
3
|
+
The SDK's promise is that a browser game reaches a dedicated multiplayer match without implementing
|
|
4
|
+
WebRTC, server allocation, gem-api auth, or the platform's own handshakes. This page is the other
|
|
5
|
+
half of that promise: **what is left**, stated plainly, so nobody has to discover it during an
|
|
6
|
+
integration.
|
|
7
|
+
|
|
8
|
+
It is written from two audits, one from each direction. The first game to complete this path in
|
|
9
|
+
production hand-rolled the whole platform layer in a single 854-line file; what that file did which
|
|
10
|
+
every game needs now lives in the SDK. A second game then audited its own platform layer against
|
|
11
|
+
this SDK **row by row, measured in a browser**, and two more things moved here because of it: the
|
|
12
|
+
early-handshake capture, and the backend routing. That audit's "checked and clean" list — what a game
|
|
13
|
+
correctly does _not_ delegate — is the last section of this page, because the half of an ownership
|
|
14
|
+
audit that is easy to skip is the half that says where the line was drawn correctly.
|
|
15
|
+
|
|
16
|
+
Its companion is [`platform-rules.md`](platform-rules.md): what the _platform_ does that no client-side
|
|
17
|
+
signature states — how a match is sized, when a room disappears, where a room's seats come from and why
|
|
18
|
+
a create cannot ask for its own, how the compatibility line derives from your version number. This page
|
|
19
|
+
is what you own; that one is what is done to you.
|
|
20
|
+
|
|
21
|
+
## The shortest correct integration
|
|
22
|
+
|
|
23
|
+
Nothing goes in your HTML. The SDK is a dependency your game bundles — see
|
|
24
|
+
[getting-started](getting-started.md#3-add-the-sdk), which owns the install.
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { createGem, text } from '@indiegems/gem-web-sdk/sdk';
|
|
28
|
+
import server from './server.js'; // your server bundle's default export — the one you publish
|
|
29
|
+
|
|
30
|
+
const gem = createGem({
|
|
31
|
+
onPlayer: (player) => showName(text.toPlain(player.displayName)),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
gem.frame.ready(); // held until the bridge is up, then delivered
|
|
35
|
+
await gem.awaitIdentity(); // the play token, which everything is scoped by
|
|
36
|
+
await gem.rooms.quickJoin({ templateSlug: 'skirmish' });
|
|
37
|
+
await gem.match.serve(server); // your server bundle's default export; runs HERE only if this seat hosts
|
|
38
|
+
const link = await gem.match.connect(); // one link to the authority, whoever the template made that
|
|
39
|
+
// The server has already accepted this player. Start playing. `connect()` dials the
|
|
40
|
+
// transports the room template lists, in its order — no transport is named here;
|
|
41
|
+
// `gem.dedicatedServer` and `gem.p2p` stay underneath for a game that wants their
|
|
42
|
+
// specifics — dedicated-server-flow.md, p2p-and-parties.md.
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
No `baseUrl`. The Arcade names the backend on every token and the SDK routes there — see
|
|
46
|
+
"which backend" below.
|
|
47
|
+
|
|
48
|
+
## The five things that are yours
|
|
49
|
+
|
|
50
|
+
### 1. The embedder allowlist
|
|
51
|
+
|
|
52
|
+
**This one is done for you, and most games write nothing.** A page may embed your game and hand it
|
|
53
|
+
tokens only if its origin is allowed, and the pages that matter are always allowed without a word
|
|
54
|
+
from you:
|
|
55
|
+
|
|
56
|
+
- **the Gem Arcade website and app** — the SDK carries their origins as `ARCADE_EMBEDDERS`, on every
|
|
57
|
+
platform the app runs on;
|
|
58
|
+
- **a dev host on your own machine** — `gemctl serve` and the Gem Editor frame a locally-served game
|
|
59
|
+
from a loopback origin whose port changes every run, and the SDK admits that shape by rule, only
|
|
60
|
+
while your game itself is served from loopback. Once published, nothing about the rule applies.
|
|
61
|
+
|
|
62
|
+
`allowedEmbedders` is for a page **beyond** those, and what you list is **added** to the arcade's
|
|
63
|
+
origins — you cannot drop the arcade by writing your own list:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
// Only when a site of your own frames your game.
|
|
67
|
+
const gem = createGem({ allowedEmbedders: ['https://my-own-site.example'] });
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Name the whole origin — scheme, host and optional port, with nothing after it. `createGem` throws on
|
|
71
|
+
an entry that is not one (a trailing slash, a path, a bare hostname), and on `undefined`, which is
|
|
72
|
+
what `[import.meta.env.VITE_EMBEDDER]` becomes when nothing is set. A well-formed origin that is
|
|
73
|
+
simply wrong fails at the handshake instead, silently on the wire by design: the refusal reaches
|
|
74
|
+
`onRefused` and the SDK warns on the console after eight seconds naming this as one of three causes.
|
|
75
|
+
|
|
76
|
+
**Bake entries in. Never read them from the URL**: `?embedder=` hands the frame the allowlist that was
|
|
77
|
+
supposed to constrain it. Do not add a `localhost:<port>` entry for a dev host either; the rule above
|
|
78
|
+
already covers it, and an older SDK that refuses that page is fixed by upgrading, not by an entry.
|
|
79
|
+
|
|
80
|
+
### 2. Your version number, which you no longer declare
|
|
81
|
+
|
|
82
|
+
**Which number you publish under is yours. Telling the SDK what it is stopped being.** The arcade
|
|
83
|
+
routed this player to one of your builds, so the arcade knows its version, and it now says so over
|
|
84
|
+
the bridge. Every room and match call reports that answer. Pass nothing.
|
|
85
|
+
|
|
86
|
+
What is still yours is deciding when to bump it, because it is what keeps incompatible builds out of
|
|
87
|
+
one another's matches: bump when you ship something that changes what a match expects.
|
|
88
|
+
|
|
89
|
+
`clientVersion.gameVersion` is **deprecated** and still accepted. It is the only answer available
|
|
90
|
+
from an arcade too old to send one, so a build that has to run under an older arcade may still pass
|
|
91
|
+
it — and where both exist, the platform's wins and nothing in your running game says so.
|
|
92
|
+
[The deprecation policy](deprecation-policy.md) is how long it has.
|
|
93
|
+
|
|
94
|
+
Why it went: the number in your source had to be kept in step by hand with the number the build was
|
|
95
|
+
uploaded under, and a live game shipped with the two disagreeing. Every room call was refused for a
|
|
96
|
+
version that named no published build, the refusal landed somewhere that only warns, and for three
|
|
97
|
+
days players earned nothing while the win screen kept promising rewards that were never granted.
|
|
98
|
+
Both halves of the upload agreed with each other, so everything that checks an upload looked correct.
|
|
99
|
+
|
|
100
|
+
Do not supply `engineVersion`. A browser game is not an engine export and has none; the SDK reports
|
|
101
|
+
one, and a plausible-looking value derives an engine line that matches no fleet and fails at
|
|
102
|
+
allocation — long after the call that decided it, and as a capacity refusal that will never clear.
|
|
103
|
+
[`dedicated-server-flow.md`](dedicated-server-flow.md#two-version-lines-two-derivation-rules-and-a-refusal-that-names-neither)
|
|
104
|
+
is how to tell that one from a fleet that is genuinely full.
|
|
105
|
+
|
|
106
|
+
Publishing a new version also starts a clock on the one it replaces, and retiring a bad build is
|
|
107
|
+
something you do rather than something the SDK does.
|
|
108
|
+
[`rooms-and-matches.md`](rooms-and-matches.md#what-ages-a-version-out-and-the-three-levers-the-games-owner-has)
|
|
109
|
+
has the expiry window, the blacklist, how a rollback is performed, and what a player's client is
|
|
110
|
+
told when their build ages out.
|
|
111
|
+
|
|
112
|
+
### 3. The handshake is caught, not awaited
|
|
113
|
+
|
|
114
|
+
The handshake is **parent-initiated**, and `postMessage` queues for nobody: an offer is delivered to
|
|
115
|
+
the listeners that exist at that instant. **The Arcade re-offers on a steady cadence — one offer
|
|
116
|
+
every two seconds — for as long as the page holds your frame, and never gives up.** So a game that
|
|
117
|
+
boots through a module graph attaches its listener after some offers have passed and connects on the
|
|
118
|
+
next one: usually well under a second, and up to about two in the worst case. That wait is normal
|
|
119
|
+
and is not a retry.
|
|
120
|
+
|
|
121
|
+
An embedder that is not the Arcade need not behave this way — one that posts a single offer and
|
|
122
|
+
stops is a parent a late listener can miss entirely. `gem-web-sdk.capture.js`, loaded first in the
|
|
123
|
+
document, holds offers that arrive before your bundle does; it is an **optional latency
|
|
124
|
+
optimisation** against the Arcade and insurance against a one-shot parent. It is not part of any
|
|
125
|
+
install, and you do not need it to ship. Where one is used it holds offers untouched until
|
|
126
|
+
`createGem` adopts it — newest first, because an old offer's port may already be superseded and
|
|
127
|
+
answering it wedges the frame.
|
|
128
|
+
|
|
129
|
+
Either way `createGem` can be called whenever your code is ready, and `ready()` can go wherever it
|
|
130
|
+
reads best; it is held and delivered on connect.
|
|
131
|
+
|
|
132
|
+
Once it does connect, [the developer panel](getting-started.md#the-developer-panel) will confirm
|
|
133
|
+
it from your side — the bridge's state, the negotiated protocol version and what the parent
|
|
134
|
+
advertised. It cannot help you while the handshake is failing, though: the panel is opened by a
|
|
135
|
+
token grant, and there is no grant without a bridge. A frame that never connects has nothing to
|
|
136
|
+
open.
|
|
137
|
+
|
|
138
|
+
**What you own is the boot phase, below.** With the parent re-offering, keeping the event loop free
|
|
139
|
+
is the whole of what decides how fast your game connects — a main thread blocked through boot delays
|
|
140
|
+
delivery of an offer that already arrived, and every second of that is a second the player spends
|
|
141
|
+
watching the Arcade's loading state.
|
|
142
|
+
|
|
143
|
+
### 4. Everything on the channel after the welcome
|
|
144
|
+
|
|
145
|
+
The platform's envelope is two message types and it is finished before `connect()` resolves. What
|
|
146
|
+
goes on the channel afterwards — JSON, binary, a schema of your own — is opaque to everything above
|
|
147
|
+
the transport, and nothing above the transport bounds its size either, which is a reason for your
|
|
148
|
+
server to bound what it will accept. See [`server-bundles.md`](server-bundles.md).
|
|
149
|
+
|
|
150
|
+
### 5. What to do about the failures the SDK reports
|
|
151
|
+
|
|
152
|
+
The SDK reports; it does not decide. A stale build (`versionUpdateAvailable`), a match that failed to
|
|
153
|
+
allocate, a token the parent refused — what a game does about each is a game decision, and a platform
|
|
154
|
+
layer that acted would be making it for you.
|
|
155
|
+
|
|
156
|
+
## The boot phase: settle the bridge before the heavy bundle
|
|
157
|
+
|
|
158
|
+
The invariant, stated plainly, because it is the hardest-won lesson in this document:
|
|
159
|
+
|
|
160
|
+
> **Offers are delivered, not polled — and delivery needs the event loop.** Registering a listener
|
|
161
|
+
> early is worthless if the main thread then executes your application bundle. The offer sits queued
|
|
162
|
+
> behind that work, and by the time it dispatches, the port it carried may already be dead. The
|
|
163
|
+
> parent's re-offer solves the _listening_ half — it keeps offering until you answer. The _delivery_
|
|
164
|
+
> half is yours, and with the capture gone from the default path it is the whole of what you own
|
|
165
|
+
> here: keep the event loop idle until the bridge settles.
|
|
166
|
+
|
|
167
|
+
The pattern is a two-file boot. The real entry stays small — `createGem` and your splash, nothing
|
|
168
|
+
heavy — and everything expensive sits behind a dynamic import that is not issued until the bridge
|
|
169
|
+
has settled:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
// boot.ts — the real entry point. Do not add imports here without asking what they pull in.
|
|
173
|
+
import { createGem } from '@indiegems/gem-web-sdk/sdk';
|
|
174
|
+
|
|
175
|
+
const gem = createGem();
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
await gem.awaitBridge(); // the loop is idle through the offer window
|
|
179
|
+
gem.frame?.progress(1, 4);
|
|
180
|
+
const { runApp } = await import('./app.ts'); // NOW fetch, parse and execute the megabytes
|
|
181
|
+
gem.frame?.progress(2, 4);
|
|
182
|
+
await runApp(gem);
|
|
183
|
+
} finally {
|
|
184
|
+
gem.frame?.ready(); // even on failure — see the loading contract
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`awaitBridge()` resolves when the bridge leaves `waiting` — connected, refused or closed — or at a
|
|
189
|
+
bounded timeout (default 10s), with the state it settled on. **Proceed on every outcome.**
|
|
190
|
+
`connected` is the Arcade; anything else is a boot without a parent, which your game must survive
|
|
191
|
+
anyway. The wait costs nothing when the handshake is quick: it settles the moment the frame
|
|
192
|
+
connects. Unframed it is never answered, because no parent ever pushes one.
|
|
193
|
+
|
|
194
|
+
The measured stakes, from the first game to hit this on an iPhone: listener up at ~300ms, then a
|
|
195
|
+
main-thread block — the parent's offer, queued behind the block, was delivered twenty seconds later
|
|
196
|
+
into a port closed ten seconds earlier. Same page, connect or time out, decided by nothing but
|
|
197
|
+
whether the loop was idle. The entry split also buys the listening half for free: a boot module of
|
|
198
|
+
"params + SDK" registers at ~300ms where a full bundle takes seconds.
|
|
199
|
+
|
|
200
|
+
## The loading contract
|
|
201
|
+
|
|
202
|
+
The parent shows its loading state until your frame says otherwise. The whole contract is four
|
|
203
|
+
sentences:
|
|
204
|
+
|
|
205
|
+
- **`ready()` means "there is something worth looking at"** — your boot screen, not your
|
|
206
|
+
world-ready. Measured on the Arcade: the parent's loading state is a plain page it abandons after
|
|
207
|
+
~10s regardless, so holding `ready` until world-ready hides your own, more informative screen and
|
|
208
|
+
buys nothing.
|
|
209
|
+
- **`ready()` is sent exactly once per session, and the SDK enforces it.** It is held while the
|
|
210
|
+
bridge is down and delivered on connect, so call order cannot lose it; a call after one has gone
|
|
211
|
+
out is suppressed and reported through `onDropped`. This is what makes the `finally` backstop
|
|
212
|
+
above safe to write.
|
|
213
|
+
- **Send it even when the boot failed.** A frame that never says ready sits behind the parent's
|
|
214
|
+
loading state forever, with your error message underneath where nobody can read it. The `finally`
|
|
215
|
+
is not decoration.
|
|
216
|
+
- **`progress(n, total)` at coarse milestones** — bridge, app module, engine, world is plenty. It is
|
|
217
|
+
rate-limited per message type like everything else on the bridge; a per-asset progress loop will
|
|
218
|
+
be throttled, and a progress call after `ready` is meaningless to a parent that has already
|
|
219
|
+
unveiled you.
|
|
220
|
+
|
|
221
|
+
## Boot diagnostics: where the seconds went
|
|
222
|
+
|
|
223
|
+
`gem.boot` is a timeline of named marks, one greppable line out of `gem.boot.timeline()`:
|
|
224
|
+
|
|
225
|
+
```
|
|
226
|
+
document parsed@294ms → sdk created@336ms (+42ms) → bridge connected@388ms (+52ms) →
|
|
227
|
+
token requested@389ms (+1ms) → token ready@913ms (+524ms) → room joined@1109ms (+196ms) → …
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
The SDK marks what it owns — `document parsed`, `sdk created`, `bridge connected`,
|
|
231
|
+
`token requested`, `token ready`/`token lost`, `room created`/`room joined`, `allocation started`,
|
|
232
|
+
`match ready`/`match failed`, `welcome` — so the platform's share of a slow boot is individually
|
|
233
|
+
attributable in any player's console capture. Mark the steps you own (`gem.boot.mark('world
|
|
234
|
+
ready')`) and both halves land in the same line. A name marks once; boot happens once.
|
|
235
|
+
|
|
236
|
+
Two lines print themselves: at the eight-second mark the SDK logs the boot report — connected or
|
|
237
|
+
not — because **field captures attach to a frame's console late, and boot-time logs never appear in
|
|
238
|
+
them**; and when a connection completes — the dedicated-server welcome, or a P2P peer reaching a
|
|
239
|
+
usable state — the finished line prints once. Both respect `log: null`. Every timing mystery in the
|
|
240
|
+
incident that motivated this instrument was solved the day the numbers existed; when a player
|
|
241
|
+
capture lands on your desk, this line is the first thing to grep for.
|
|
242
|
+
|
|
243
|
+
The arcade will also render this timeline for you, over a running game, without a capture and
|
|
244
|
+
without any code — see
|
|
245
|
+
[the developer panel](getting-started.md#the-developer-panel).
|
|
246
|
+
|
|
247
|
+
### The two multiplayer journeys report differently, because they are different
|
|
248
|
+
|
|
249
|
+
Everything above the match is shared. After it the paths have nothing in common — there is no
|
|
250
|
+
allocation to wait for on a P2P match, no server the platform vouches for, and "ready" means
|
|
251
|
+
something different at each end of the star — so the marks name what actually happens rather than
|
|
252
|
+
being forced into one vocabulary:
|
|
253
|
+
|
|
254
|
+
```
|
|
255
|
+
… → match ready@1250ms (+50ms) → turn leased@1402ms (+152ms) → signaling joined@1421ms (+19ms) →
|
|
256
|
+
signaling hello@1502ms (+81ms) → offer sent@1530ms (+28ms) → answer received@1655ms (+125ms) →
|
|
257
|
+
credential ready@3702ms (+2047ms) → host verified@3703ms (+1ms) → channel open@3980ms (+277ms) →
|
|
258
|
+
admitted@4020ms (+40ms)
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
- **`turn leased`, `turn lease failed` or `turn skipped`** — whether this peer got a relay. The
|
|
262
|
+
connection proceeds either way, which is why this is worth a mark: without one, peers on the same
|
|
263
|
+
network connect fine and peers behind a symmetric NAT never do. A network that blocks direct
|
|
264
|
+
connections outright is a known gap, and this is what makes it legible in a capture.
|
|
265
|
+
- **`signaling joined`, then `signaling hello`** — the join went to the parent, and the platform
|
|
266
|
+
answered with who this peer is, who the host is and at what epoch. A line that ends at the first
|
|
267
|
+
is a parent not putting frames on the socket, which is a different problem from a connection that
|
|
268
|
+
failed to form.
|
|
269
|
+
- **A joiner** then marks `offer sent`, `answer received`, `credential ready`, `host verified`,
|
|
270
|
+
`channel open` and `admitted`. The answer and the credential race by design, in either order, and
|
|
271
|
+
`host verified` waits on whichever lands second — the first credential request is refused until
|
|
272
|
+
the host has answered somebody, so a multi-second gap before `credential ready` is that refusal
|
|
273
|
+
being retried, not a slow network.
|
|
274
|
+
- **A host** marks `host listening` and stops: it is usable before anyone has arrived. What follows
|
|
275
|
+
— `offer received`, `answer sent`, `channel open`, `peer admitted` — is the first joiner's
|
|
276
|
+
arrival, which may be minutes later and is the only thing in a host's own capture that says
|
|
277
|
+
somebody got through.
|
|
278
|
+
|
|
279
|
+
A line that stops at `host verified` with nothing after it is ICE and DTLS never completing: the
|
|
280
|
+
peers could not reach each other directly, and either no relay was leased or the relay could not
|
|
281
|
+
help. That is the shape a corporate or campus network produces.
|
|
282
|
+
|
|
283
|
+
## The environment your game actually runs in
|
|
284
|
+
|
|
285
|
+
Facts about the Arcade's runtime that are invisible locally and expensive to discover in
|
|
286
|
+
production:
|
|
287
|
+
|
|
288
|
+
- **A game frame is never cross-origin isolated**, so `performance.now()` is coarsened to about a
|
|
289
|
+
millisecond. Anything that spin-waits on clock transitions multiplies its cost by the coarsening:
|
|
290
|
+
the probe that motivated this list demanded 20,000 transitions, cost 100ms on an isolated page,
|
|
291
|
+
and froze the Arcade's main thread for **twenty seconds** — which blocked delivery of every
|
|
292
|
+
handshake offer and was the root cause of the outage the boot-phase section exists to prevent.
|
|
293
|
+
Naive timer-granularity probes and some engine FPS limiters have this shape.
|
|
294
|
+
Bound such loops by wall-clock time, never by transition count alone.
|
|
295
|
+
- **WebKit persists no compiled-WASM cache.** Your engine recompiles on every load, warm or cold.
|
|
296
|
+
Keep WASM modules small; do not attribute the cost to the network.
|
|
297
|
+
- **The edge serves the whole bundle with revalidation** (`Cache-Control: no-cache` semantics). Warm
|
|
298
|
+
loads still pay a conditional request per asset — fine, but hash your assets so those requests
|
|
299
|
+
can answer 304.
|
|
300
|
+
- **Game origins carry no `script-src` policy**, so build the page the ordinary way: inline
|
|
301
|
+
`<script>`, import maps, a CDN-hosted engine and `new Worker(blobURL)` all run. Worth stating
|
|
302
|
+
because a `script-src` of `'self'` refuses `<script type="importmap">` for **being** inline script
|
|
303
|
+
whatever URLs it names, which leaves an entry module resolving no imports and nothing rendering —
|
|
304
|
+
so a game that assumes one and works around it is working around a rule that is not there. What
|
|
305
|
+
_is_ enforced is `frame-ancestors`, and the containment the whole decision rests on is that **every
|
|
306
|
+
game is served on its own origin**. The SDK's embedder allowlist is the frame's side of that same
|
|
307
|
+
header; [the embedder allowlist](#1-the-embedder-allowlist) above says what it admits and when you
|
|
308
|
+
would add to it.
|
|
309
|
+
|
|
310
|
+
## Which way the device is held — declare it, do not draw it
|
|
311
|
+
|
|
312
|
+
A phone and a tablet get held two ways round, and the Arcade tells the player when
|
|
313
|
+
they are holding it a way your game cannot use. **That message is the platform's,
|
|
314
|
+
not yours.** Do not ship a full-screen "please rotate your device" screen of your
|
|
315
|
+
own: it will not be translated into the twelve Arcade locales, it cannot pause the
|
|
316
|
+
Arcade's chrome around it, and it will say something different from every other
|
|
317
|
+
game on the storefront.
|
|
318
|
+
|
|
319
|
+
What you do instead is declare it on your store page, per device, both keys:
|
|
320
|
+
|
|
321
|
+
```yaml
|
|
322
|
+
orientations:
|
|
323
|
+
phone:
|
|
324
|
+
portrait: full
|
|
325
|
+
landscape: none
|
|
326
|
+
tablet:
|
|
327
|
+
portrait: full
|
|
328
|
+
landscape: partial
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
| Value | What the player gets |
|
|
332
|
+
| --- | --- |
|
|
333
|
+
| `full` | nothing — the game plays this way round |
|
|
334
|
+
| `partial` | a dismissible hint, once, if the other way is `full` |
|
|
335
|
+
| `none` | a panel over the paused game asking them to rotate, with "play anyway" |
|
|
336
|
+
|
|
337
|
+
Three consequences worth knowing before you hit them:
|
|
338
|
+
|
|
339
|
+
- **The panel reaches your game as `onPause(true)`**, exactly like any other Arcade
|
|
340
|
+
dialog, and unpauses when the player rotates or chooses "play anyway". If you
|
|
341
|
+
already honour pause on both edges — [the pause contract](#what-the-parent-says-about-the-session-prefs-pause-and-terminate) — there is no
|
|
342
|
+
code to write for this at all.
|
|
343
|
+
- **"Play anyway" always exists**, so a `none` declaration is not a guarantee your
|
|
344
|
+
game will never be rendered that way. A player whose device is in a mount or whose
|
|
345
|
+
rotation lock is on can decline, and your game still has to not crash.
|
|
346
|
+
- **Declaring nothing means no message**, which is what every game did before this
|
|
347
|
+
existed. It is not a neutral state so much as an invisible one: a portrait-only
|
|
348
|
+
game with no declaration reads, to a player, exactly like a game that plays fine
|
|
349
|
+
sideways.
|
|
350
|
+
|
|
351
|
+
The Arcade also holds the orientation for the player where the browser allows it —
|
|
352
|
+
one refused orientation, in fullscreen, once they are already the right way round.
|
|
353
|
+
That is best effort (iOS does not implement it, and the native app's webview declines
|
|
354
|
+
it), which is why the panel exists and why it always offers a way past.
|
|
355
|
+
|
|
356
|
+
## A television, if a player puts you on one
|
|
357
|
+
|
|
358
|
+
The Arcade runs in TV browsers and on TV hardware, so a game can find itself ten feet from its
|
|
359
|
+
player, driven by a remote — and **nothing tells you that it happened**. No form-factor signal
|
|
360
|
+
crosses the bridge; that is an owner decision, not an oversight. Form factor is fungible, so a game
|
|
361
|
+
does the best it can from its own signals — its viewport geometry, `devicePixelRatio`, pointer
|
|
362
|
+
coarseness, and the arrival of gamepad or keyboard-class input — rather than branch on a
|
|
363
|
+
classification the parent declared. The bar below is written to be satisfiable with no parent signal
|
|
364
|
+
at all.
|
|
365
|
+
|
|
366
|
+
It is also the meaning of your store page's **`form_factors.tv`** declaration: `full` claims this
|
|
367
|
+
bar, `partial` claims it with limitations the page says out loud, and `none` says the game is not
|
|
368
|
+
for a television. `form_factors` declares all four of `phone` / `tablet` / `desktop` / `tv`,
|
|
369
|
+
because which devices a game is built for cannot be inferred from anything else you publish. There
|
|
370
|
+
is no floor: `none` is a perfectly good answer, and a deliberate `none` is far better for a player
|
|
371
|
+
than a `full` you did not earn.
|
|
372
|
+
|
|
373
|
+
**Adding it is one block, on any store page you already have.** Every schema version accepts
|
|
374
|
+
`form_factors`; the newest one requires it, which is the whole difference between v2 and v3. So
|
|
375
|
+
there is nothing to migrate and no `schema_version` to change — `gemctl store init` writes that
|
|
376
|
+
field, and it is a fact about the backend you publish to rather than a statement about your game.
|
|
377
|
+
|
|
378
|
+
A page that has not declared it is served as `none` on every factor. That shows **no device
|
|
379
|
+
badges** — it is the platform declining to claim on your behalf, not a statement that your game
|
|
380
|
+
works nowhere — so there is no rush and nothing degrades. It does mean a TV-ready game gets no
|
|
381
|
+
credit for it until you say so.
|
|
382
|
+
|
|
383
|
+
- **Legibility at ten feet.** Effective body and HUD text at least ~24px at 1080p, and nothing
|
|
384
|
+
informational below ~18px effective. The practical shape is a UI driven by rem or a single scale
|
|
385
|
+
factor, so one root multiplier — 1.5x is the number the Arcade's own TV mode uses — rescales the
|
|
386
|
+
whole interface instead of needing a per-screen audit. A 14px HUD that reads fine on a monitor is
|
|
387
|
+
decoration from a couch, and there is no zoom to rescue it.
|
|
388
|
+
- **Gamepad-complete menus, with a focus state visible at distance.** Every menu, pause screen,
|
|
389
|
+
inventory and dialog operable on the standard gamepad mapping with the pointer unused, and a focus
|
|
390
|
+
cue thicker than a hairline — a 1px outline disappears at ten feet. TV remotes arrive as
|
|
391
|
+
**keyboard-class events** (arrows, Enter, Back), so a game that calls itself a keyboard game is a
|
|
392
|
+
D-pad game on a TV whether it planned to be or not.
|
|
393
|
+
- **Title-safe layout.** HUD and critical UI inset at least ~5% of the frame — roughly 48–96px at
|
|
394
|
+
1080p. `env(safe-area-inset-*)` resolves to **0 on every TV**: overscan is a hardware crop the
|
|
395
|
+
browser cannot see, so no platform can hand you the number and your game must apply its own
|
|
396
|
+
margin. The arcade's safe-area insets (`gem.viewport`, below) are **also 0 on a TV**, by
|
|
397
|
+
design — they carry the cutouts the browser can see, never the overscan allowance, so they do
|
|
398
|
+
not double with the margin you apply here. Keep both: `max(your margin, var(--gem-safe-top))`.
|
|
399
|
+
- **No hover-only affordances, and no pointer-precision dependencies.** There may be no pointer at
|
|
400
|
+
all. Anything that only reveals on hover, or demands accurate pointing, is simply absent on a TV.
|
|
401
|
+
- **Text entry the on-screen keyboard can serve.** Focusing a field summons the TV's OSK, so keep
|
|
402
|
+
entry fields few and ordinary — and never gate first play behind one. A player made to type on a
|
|
403
|
+
remote before they may play mostly does not.
|
|
404
|
+
- **A TV performance budget.** TV-browser SoCs are weak next to any desktop: ship a player-settable
|
|
405
|
+
render-scale option (render at DPR 1 and upscale), bound memory, assume a fixed 16:9 frame — there
|
|
406
|
+
is no `orientationchange` rescue coming — and keep your heights `dvh`-safe.
|
|
407
|
+
- **Reduced motion.** TV browsers rarely expose `prefers-reduced-motion`, so the pushed
|
|
408
|
+
`gem.prefs.reducedMotion` is the only signal a TV player has; honor it. It arrives with
|
|
409
|
+
[the prefs the parent pushes](#what-the-parent-says-about-the-session-prefs-pause-and-terminate)
|
|
410
|
+
below.
|
|
411
|
+
|
|
412
|
+
## What the parent says about the session: prefs, pause and terminate
|
|
413
|
+
|
|
414
|
+
Five messages arrive that a game cannot ask for — the parent pushes them on its own schedule, and
|
|
415
|
+
there is no route by which the frame could request one. `createGem` forwards all five:
|
|
416
|
+
|
|
417
|
+
- **`onPrefs`** — `reducedMotion`, `muted`, `locale`, `showFrameStats`, sent once after the
|
|
418
|
+
handshake and again on every change. Also **held**, as `gem.prefs`, because a component mounted
|
|
419
|
+
after the push would otherwise have missed it and cannot ask again. Read it where you need it
|
|
420
|
+
rather than caching it — the locale changes mid-session. `muted` is the Arcade's mute: the SDK
|
|
421
|
+
silences your page itself, and your own mute control follows it — see
|
|
422
|
+
[sound](#sound-your-own-mute-control-and-the-arcades) below. `showFrameStats` you need not act
|
|
423
|
+
on: the SDK counts your frames itself — see [the Arcade's FPS overlay](#the-arcades-fps-overlay). See the language section for how to combine
|
|
424
|
+
`locale` with your own detection.
|
|
425
|
+
- **`onPause`** — **pause gameplay and release the cursor.** Cooperative; nothing enforces it.
|
|
426
|
+
|
|
427
|
+
```js
|
|
428
|
+
onPause: (paused) => {
|
|
429
|
+
if (paused) document.exitPointerLock();
|
|
430
|
+
// ...stop your loop
|
|
431
|
+
};
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
Releasing the cursor is yours to do: pointer lock belongs to the document that took it, so the
|
|
435
|
+
arcade cannot release yours from outside the frame. Keep it captured and the player cannot click
|
|
436
|
+
the dialog sitting on top of your game.
|
|
437
|
+
|
|
438
|
+
**Save on pause.** When the player taps the Arcade's Back tab, your game is **parked**, not closed:
|
|
439
|
+
it receives `onPause(true)` and stays loaded while the player browses. They may come back and
|
|
440
|
+
press Continue — `onPause(false)`, and play resumes where it stopped — or the parked game may be
|
|
441
|
+
ended up to five minutes later. That end usually comes with `onWillTerminate` first, **but not
|
|
442
|
+
always**: a player who leaves the Arcade page while your game is parked takes it down with
|
|
443
|
+
nothing after the pause. So the pause edge is your last guaranteed moment. Write anything the
|
|
444
|
+
player would mind losing there, and never hold it back for a terminate warning that may not come.
|
|
445
|
+
|
|
446
|
+
- **`onWillTerminate`** — persist now. `graceMs` is a promise about how long the parent will wait.
|
|
447
|
+
- **`onViewport`** — where your frame's own edges are under a cutout. **Most games need no
|
|
448
|
+
handler**: the SDK has already written the same numbers to `--gem-safe-top` and friends on your
|
|
449
|
+
document root before this fires. Held as `gem.viewport`. The next section is the whole story.
|
|
450
|
+
- **`onDeviceSettings`** — the settings the Arcade keeps for your game **on this device**, or
|
|
451
|
+
`null` when it keeps none. Sent once per session. Held as `gem.deviceSettings.current`, and
|
|
452
|
+
written back with `gem.deviceSettings.put`. [Device settings](#device-settings-what-this-device-remembers-about-your-game)
|
|
453
|
+
below says what belongs there and, more importantly, what does not.
|
|
454
|
+
|
|
455
|
+
Neither `onPause` nor `onWillTerminate` touches a match. A match whose link dropped while the phone was locked
|
|
456
|
+
comes back through the SDK's own reconnect on the next visible edge, and one that ended meanwhile
|
|
457
|
+
raises `matchEnded` on the same edge — [match-session.md](match-session.md) §7. Pause the loop
|
|
458
|
+
on `onPause`; do not leave the room.
|
|
459
|
+
|
|
460
|
+
🔴 **The assembly used to drop all three.** `GemFrame` parsed them and `createGem` wired none, so a
|
|
461
|
+
game built the one-call way could not hear them — and could not add a handler afterwards either,
|
|
462
|
+
because the frame's callbacks are fixed at construction and `gem.frame` hands back the one already
|
|
463
|
+
built. A game that wanted them had to assemble the parts itself.
|
|
464
|
+
|
|
465
|
+
✅ **The Arcade now sends all three.** `gem.prefs` on connect and on every change,
|
|
466
|
+
`gem.pause` on tab visibility and when the player taps Back — both edges, and on connect into an
|
|
467
|
+
already-backgrounded tab — and `gem.willTerminate` before a teardown the page itself decides,
|
|
468
|
+
followed by an actual wait for the grace it promised — including the end of a **parked** game (the
|
|
469
|
+
player tapped Back) when its five minutes run out. Some endings get no warning. A player who leaves
|
|
470
|
+
the Arcade page while your game is parked ends it with nothing after the pause it already had —
|
|
471
|
+
save on that pause, as above. And browser Back, a closing tab and a reload cannot be warned about
|
|
472
|
+
at all: the page hears about them only once the frame is already going, and a port message posted
|
|
473
|
+
then is queued into a document that is destroyed before it can be delivered.
|
|
474
|
+
|
|
475
|
+
⚠️ **Other embedders may still send none of them.** The native Arcade app has no preferences sender
|
|
476
|
+
today, and a third-party embedder need not have one either. So these handlers must be correct when
|
|
477
|
+
they never fire: a game that blocks on `gem.prefs` renders nothing on those surfaces. Treat every one
|
|
478
|
+
of the three as an upgrade to behaviour that already works without it.
|
|
479
|
+
|
|
480
|
+
## Where your edges are unsafe: the arcade's safe-area insets
|
|
481
|
+
|
|
482
|
+
A phone has a camera cutout at the top and a home indicator at the bottom, and inside the arcade
|
|
483
|
+
your frame may or may not start where the phone's screen does: on a phone your game fills the
|
|
484
|
+
screen, edge to edge, while on a tablet or a desktop it sits inside the arcade's page. The arcade
|
|
485
|
+
also floats a small control of its own over your game — see
|
|
486
|
+
[reserved areas](#the-arcades-own-controls-reserved-areas) below. The question "how far from my top
|
|
487
|
+
edge is it safe to draw?" has a different answer in each of those layouts, and **your frame cannot
|
|
488
|
+
find it out from the browser.**
|
|
489
|
+
|
|
490
|
+
`env(safe-area-inset-*)` inside an iframe is unspecified and wrong in both directions
|
|
491
|
+
([w3c/csswg-drafts#4670](https://github.com/w3c/csswg-drafts/issues/4670)): Chrome resolves it to
|
|
492
|
+
**0** in an embedded document, and WebKit hands the frame **the whole phone's values**, unadjusted
|
|
493
|
+
for where the frame sits. So a HUD padded by `env(safe-area-inset-top)` lands under the cutout on
|
|
494
|
+
Android and, wherever the arcade's page already cleared the notch above your frame, sits **twice**
|
|
495
|
+
as far from it as it should. Every game that followed the old advice had exactly this bug, and none
|
|
496
|
+
could fix it locally.
|
|
497
|
+
|
|
498
|
+
**The arcade measures your frame and tells you.** `gem.viewport` arrives once after the handshake
|
|
499
|
+
and again on every change — a rotation, a fullscreen transition, the controls hiding or showing —
|
|
500
|
+
with four integers in CSS pixels, measured from **your frame's** edges:
|
|
501
|
+
|
|
502
|
+
```json
|
|
503
|
+
{ "insets": { "top": 0, "right": 0, "bottom": 34, "left": 0 } }
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
**The SDK writes them to your document root as custom properties before anything else hears
|
|
507
|
+
them**, so the whole migration for a stylesheet is one declaration per edge:
|
|
508
|
+
|
|
509
|
+
```css
|
|
510
|
+
/* before */
|
|
511
|
+
#hud { top: env(safe-area-inset-top, 0px); }
|
|
512
|
+
|
|
513
|
+
/* after — right in the arcade, and unchanged everywhere else */
|
|
514
|
+
#hud { top: var(--gem-safe-top, env(safe-area-inset-top, 0px)); }
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
The `env()` fallback is not decoration. Under a parent that has not sent the message — an older
|
|
518
|
+
arcade, a third-party embedder, your game opened in a plain tab during development — the
|
|
519
|
+
properties are never set and the browser's own answer stands, which is what your game did before.
|
|
520
|
+
Under a parent that has, the variable wins. One line, correct in both worlds. The four names are
|
|
521
|
+
`--gem-safe-top`, `--gem-safe-right`, `--gem-safe-bottom` and `--gem-safe-left`.
|
|
522
|
+
|
|
523
|
+
**For a HUD you position from script** — a canvas you draw into, an engine's own UI layer — read
|
|
524
|
+
`gem.viewport.insets` at the moment you lay out, or take `onViewport` to re-lay out when it
|
|
525
|
+
changes. It is `null` until the arcade has said, and stays `null` under an embedder that never
|
|
526
|
+
does; treat that as "use `env()`", exactly as the stylesheet fallback does. Do not wait for it
|
|
527
|
+
before drawing the first frame.
|
|
528
|
+
|
|
529
|
+
**What it is not.** It is not the TV overscan margin — `env()` is 0 on every television and so is
|
|
530
|
+
this; the title-safe margin in the section above is still yours to apply, and the two compose with
|
|
531
|
+
`max()`. It carries no form factor, no orientation and nothing about what the arcade's chrome is
|
|
532
|
+
doing: you learn that part of your box is unusable, not why. And it does not move anything — where
|
|
533
|
+
your HUD sits is still your business; this is the arcade telling you where the edge is, not
|
|
534
|
+
drawing a border.
|
|
535
|
+
|
|
536
|
+
**Never read `env(safe-area-inset-*)` on its own in a game that runs in the arcade.** Wrap it in
|
|
537
|
+
the `var()` above, or read `gem.viewport`. The release checklist looks for the bare form.
|
|
538
|
+
|
|
539
|
+
### The arcade's own controls: reserved areas
|
|
540
|
+
|
|
541
|
+
On a phone your game is full-bleed, and the arcade keeps one cluster of its own on top of it, in the
|
|
542
|
+
**top-left corner** of your frame: a small **Back** tab — the Gem Arcade wordmark — and, beside it,
|
|
543
|
+
the player's wallets (Gems, Tickets, their achievement score) while they are
|
|
544
|
+
[shown](#the-arcades-wallet-show-it-never-draw-it). The cluster stays in the top-left and runs along
|
|
545
|
+
the top row at most about three quarters of the way across on the narrowest phone — but do not
|
|
546
|
+
hard-code a size: read it, as below. Those rectangles of your frame are **reserved**: keep essential
|
|
547
|
+
UI and every touch target out of them.
|
|
548
|
+
|
|
549
|
+
**The arcade never takes the right side.** Put your own settings, pause or menu button **top-right**
|
|
550
|
+
— that corner is yours. And **do not put a back arrow of your own top-left**: it would sit under the
|
|
551
|
+
arcade's Back tab, which leaves the game, while yours would mean something else.
|
|
552
|
+
|
|
553
|
+
An inset cannot say that — as a top inset, a tab a few dozen pixels wide would take your whole top
|
|
554
|
+
strip. So `gem.viewport` also carries the rectangles, in the same CSS pixels of your frame, measured
|
|
555
|
+
from its top-left corner:
|
|
556
|
+
|
|
557
|
+
```json
|
|
558
|
+
{
|
|
559
|
+
"insets": { "top": 47, "right": 0, "bottom": 34, "left": 0 },
|
|
560
|
+
"reserved": [{ "x": 8, "y": 51, "width": 104, "height": 34 }]
|
|
561
|
+
}
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
**The SDK turns them into one pair of custom properties per corner**, written beside
|
|
565
|
+
`--gem-safe-*` before anything else hears the push: `--gem-reserved-tl-width` and
|
|
566
|
+
`--gem-reserved-tl-height`, and the same pair for `tr`, `bl` and `br` — how far that corner's
|
|
567
|
+
reserved rectangles reach in from its two edges. Push the corner's UI clear of it:
|
|
568
|
+
|
|
569
|
+
```css
|
|
570
|
+
/* a score in the top-left corner, below the cluster */
|
|
571
|
+
#score {
|
|
572
|
+
top: max(var(--gem-safe-top, env(safe-area-inset-top, 0px)), var(--gem-reserved-tl-height, 0px));
|
|
573
|
+
left: var(--gem-safe-left, 0px);
|
|
574
|
+
}
|
|
575
|
+
/* or a row along the top, starting after it */
|
|
576
|
+
#top-bar { padding-left: var(--gem-reserved-tl-width, 0px); }
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
All eight are written on every push, `0px` for a clear corner, so a rotation never leaves a stale
|
|
580
|
+
value; they are never set under an embedder that sends no viewport, which is what the `0px`
|
|
581
|
+
fallback is for. A rectangle belongs to the corner its centre lies nearest to. For a HUD laid out
|
|
582
|
+
from script, read `gem.viewport.reserved` — `[]` when nothing is covered.
|
|
583
|
+
|
|
584
|
+
**Lay out against it whenever it changes.** Shown wallets widen the reserved area beside the tab;
|
|
585
|
+
hiding them shrinks it back to the tab alone. A wallet the arcade pops up for a moment while they
|
|
586
|
+
are hidden never reserves anything — it floats over your game and moves nothing.
|
|
587
|
+
|
|
588
|
+
**Older SDKs get it folded into the top inset.** A game on an SDK from before reserved areas cannot
|
|
589
|
+
read the rectangles, so the arcade sends it none and instead raises `insets.top` — and so
|
|
590
|
+
`--gem-safe-top` — past the bottom of the tab. That game keeps working, at the cost of a full-width
|
|
591
|
+
band; upgrading gives the band back.
|
|
592
|
+
|
|
593
|
+
## Which store-page experiment brought this player in: cohorts
|
|
594
|
+
|
|
595
|
+
**You need this only if you run an A/B experiment on your store page and want the game to match
|
|
596
|
+
it.** The Arcade can show two versions of a game's store page — a different title, tagline or key
|
|
597
|
+
art — and split new players between them. The game inside never sees the page, so a "cozy farm"
|
|
598
|
+
page can lead straight into a first run that opens on a boss fight. `gem.cohorts` is how the game
|
|
599
|
+
learns which page this player came through.
|
|
600
|
+
|
|
601
|
+
It is a fourth message the parent pushes and the frame cannot ask for, and it follows the same rules
|
|
602
|
+
as the three above. `createGem` forwards it as `onCohorts` and **holds** it as `gem.cohorts`;
|
|
603
|
+
`gem.awaitCohorts()` waits for it, with a bound.
|
|
604
|
+
|
|
605
|
+
```js
|
|
606
|
+
const gem = createGem({
|
|
607
|
+
onCohorts: (cohorts) => {
|
|
608
|
+
/* on connect, and again if it changes */
|
|
609
|
+
},
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
// At the one point you branch — the first run's opening. Short budget.
|
|
613
|
+
const cohorts = await gem.awaitCohorts(1_500); // GemCohorts | null
|
|
614
|
+
const variant = cohorts?.find((c) => c.experimentId === COZY_EXPERIMENT)?.variantKey;
|
|
615
|
+
startFirstRun(variant === 'cozy' ? 'orchard' : 'arena');
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
What the value means, and the two absences that are not the same thing:
|
|
619
|
+
|
|
620
|
+
- **Each entry is `{ experimentId, variantKey }`, and both are opaque.** The id is the one the
|
|
621
|
+
store-page tooling showed you when you created the experiment; the key is the variant name you gave
|
|
622
|
+
it (`control`, `cozy`). Compare against the ones you set up, and nothing else — the message does
|
|
623
|
+
not say how the split was made, what the other variants are, or which one is winning.
|
|
624
|
+
- **An empty array is an answer: this player is in no experiment.** The Arcade sends
|
|
625
|
+
`{ cohorts: [] }` on connect whenever that is so.
|
|
626
|
+
- **`null` is not an answer.** `gem.cohorts` is `null` until the message arrives, and stays `null`
|
|
627
|
+
under an embedder that never sends one. `awaitCohorts()` resolves `null` rather than rejecting,
|
|
628
|
+
exactly as `awaitPlayer()` does.
|
|
629
|
+
- **The list converges to empty.** When you conclude an experiment its entry stops arriving — a
|
|
630
|
+
returning player sees `[]` next session, whichever variant they were in. If the variant decided
|
|
631
|
+
something durable, record it yourself at the time (a save, a setting) rather than reading the cohort
|
|
632
|
+
again later and expecting the same answer.
|
|
633
|
+
|
|
634
|
+
⚠️ **Never block your first frame on it.** Other embedders may send none of these messages, and a
|
|
635
|
+
game that waits for `gem.cohorts` before rendering renders nothing there. Branch at the one point your
|
|
636
|
+
game differs, with a short budget, and let `null` take the default path — a player who missed the
|
|
637
|
+
experiment sees a game, and a player who was in it sees the experiment. **The default branch must be
|
|
638
|
+
a complete game**, because it is what every player outside the experiment plays.
|
|
639
|
+
|
|
640
|
+
## Device settings: what this device remembers about your game
|
|
641
|
+
|
|
642
|
+
**You need this only if your game has settings that are about the device rather than the player** —
|
|
643
|
+
a volume, a graphics quality, a resolution scale, a dismissed tip. Your game cannot keep those in
|
|
644
|
+
its own browser storage across builds: every build of a web game runs on its own origin, so a value
|
|
645
|
+
in `localStorage` is gone the moment you publish again. The Arcade keeps one small object for your
|
|
646
|
+
game on the device instead, and the bridge carries it both ways.
|
|
647
|
+
|
|
648
|
+
It is a fifth message the parent pushes and the frame cannot ask for, plus one the frame sends.
|
|
649
|
+
`createGem` forwards the push as `onDeviceSettings`, **holds** it as `gem.deviceSettings.current`,
|
|
650
|
+
waits for it with `gem.deviceSettings.wait()`, and writes with `gem.deviceSettings.put()`.
|
|
651
|
+
|
|
652
|
+
```js
|
|
653
|
+
// On boot. Short budget; `null` means "use your defaults" and is the common case.
|
|
654
|
+
const stored = (await gem.deviceSettings.wait(1_500)) ?? {};
|
|
655
|
+
audio.setMasterVolume(stored.volume ?? 1);
|
|
656
|
+
|
|
657
|
+
// When the player moves the slider: the WHOLE object, every time, debounced.
|
|
658
|
+
await gem.deviceSettings.put({ v: 1, volume: 0.4, quality: 'high' });
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
What the value means, and what it is not:
|
|
662
|
+
|
|
663
|
+
- **It is a plain JSON object of your own design**, read back after one JSON round trip. Version it
|
|
664
|
+
(`v: 1`) so a newer build reads what an older one wrote, and treat any shape as possible on read.
|
|
665
|
+
- **`null` means "nothing stored" and "the embedder never said" alike.** Both take your defaults.
|
|
666
|
+
`wait()` resolves at once on the first and only at its deadline on the second, and never
|
|
667
|
+
rejects — like `awaitCohorts()`.
|
|
668
|
+
- **You never name the game or the channel.** The Arcade files the object by the game and channel
|
|
669
|
+
it is running, so a `dev` build's settings never reach the `release` build of the same game.
|
|
670
|
+
- **`put` resolves for every outcome** — `stored`, `too_large` (over 16 KiB; nothing stored), or
|
|
671
|
+
`unavailable` (the Arcade could not store it just now; nothing stored). None is an error to a
|
|
672
|
+
game. Keep running on what you hold.
|
|
673
|
+
|
|
674
|
+
⚠️ **This is not a save, and progression must never go here.** It does not follow the player to
|
|
675
|
+
another device or browser, every account on the device shares it, and it can vanish at any time —
|
|
676
|
+
cleared site data, a private window, the Arcade evicting it. Anything a player would mind losing,
|
|
677
|
+
and anything that must follow them, is a save or `gem.me.settings`:
|
|
678
|
+
[saving-player-progress.md](saving-player-progress.md#device-settings-local-to-this-device-and-only-that)
|
|
679
|
+
draws the line in full. Never block your first frame on it either: apply defaults, then apply what
|
|
680
|
+
arrives.
|
|
681
|
+
|
|
682
|
+
## Sound: your own mute control, and the Arcade's
|
|
683
|
+
|
|
684
|
+
**Every game needs its own mute control**, in its settings or its pause menu, because on a phone
|
|
685
|
+
the Arcade shows **no** mute button: your game fills the screen and the Arcade's only control over
|
|
686
|
+
it is the Back tab. A player on a phone who wants silence has your control and nothing else.
|
|
687
|
+
|
|
688
|
+
Wire it both ways, so the player has one mute rather than two that disagree:
|
|
689
|
+
|
|
690
|
+
```js
|
|
691
|
+
const gem = createGem({
|
|
692
|
+
// ...
|
|
693
|
+
// The Arcade's mute changed (desktop, tablet, TV): follow it.
|
|
694
|
+
onPrefs: (prefs) => sound.setMuted(prefs.muted),
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
// Tell the Arcade what you saved, and every change the player makes.
|
|
698
|
+
gem.reportMuted(sound.muted);
|
|
699
|
+
muteButton.onclick = () => {
|
|
700
|
+
sound.setMuted(!sound.muted);
|
|
701
|
+
gem.reportMuted(sound.muted);
|
|
702
|
+
};
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
`gem.reportMuted` is safe to call at any time. Before the connection exists the value is held and
|
|
706
|
+
sent when the Arcade connects; an embedder with no mute button is simply not told; and calling it from
|
|
707
|
+
`onPrefs` is fine, because a report that matches what the Arcade just said is not sent.
|
|
708
|
+
|
|
709
|
+
**Do both halves.** A game that reports its mute but ignores `prefs.muted` leaves the Arcade's
|
|
710
|
+
button saying one thing while the game does the other; a game that follows `prefs.muted` but never
|
|
711
|
+
reports leaves the Arcade's button stale after the player uses yours.
|
|
712
|
+
|
|
713
|
+
### The Arcade's mute button, where there is one
|
|
714
|
+
|
|
715
|
+
On a desktop, a tablet and a television the Arcade does keep a mute button beside your game, for
|
|
716
|
+
games whose SDK can honour it — an SDK that silences your game says so when it connects, and a game
|
|
717
|
+
built on an older one gets no button rather than one that does nothing. When the player presses it,
|
|
718
|
+
`gem.prefs.muted` arrives as `true` and the SDK silences your page itself — every Web Audio graph and
|
|
719
|
+
every `<audio>`/`<video>` element — then gives the sound back when they press it again. With the two
|
|
720
|
+
calls above, the Arcade's button and yours stay in step whichever the player uses.
|
|
721
|
+
|
|
722
|
+
- **It turns the volume down; it does not stop your clock.** Your `AudioContext` keeps running, so
|
|
723
|
+
anything scheduled against `currentTime` stays in time. Do not answer a mute with `ctx.suspend()` in
|
|
724
|
+
your own code, for the same reason.
|
|
725
|
+
- **It leaves your own settings alone.** Your own volume is exactly where you left it when the
|
|
726
|
+
Arcade unmutes: the SDK sits after everything you built.
|
|
727
|
+
- **Create your audio after `createGem`.** The SDK reaches sound connected to the speakers after it
|
|
728
|
+
starts, and nothing earlier. The boot order on this page already does that; an `AudioContext` made
|
|
729
|
+
in a module that runs before `createGem` is out of reach.
|
|
730
|
+
|
|
731
|
+
### Starting silent: your game may start before the player touches it
|
|
732
|
+
|
|
733
|
+
On a desktop the Arcade may start your game on its own information page, before the player has
|
|
734
|
+
clicked anything in it. Browsers keep a frame's audio off until the player interacts with that
|
|
735
|
+
frame, so **your game must cope with starting silent**: an `AudioContext` created then is
|
|
736
|
+
`suspended`, and a `play()` on a media element rejects.
|
|
737
|
+
|
|
738
|
+
```js
|
|
739
|
+
const resume = () => {
|
|
740
|
+
if (ctx.state === 'suspended') void ctx.resume();
|
|
741
|
+
};
|
|
742
|
+
window.addEventListener('pointerdown', resume);
|
|
743
|
+
window.addEventListener('keydown', resume);
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
Resume on the first input and carry on — never block the first frame on audio, never show an error
|
|
747
|
+
for it, and do not ask the player to "click to start" just to unlock sound.
|
|
748
|
+
|
|
749
|
+
### Doing it yourself instead
|
|
750
|
+
|
|
751
|
+
`createGem({ manageAudio: false })` turns the SDK's silencing off — for a game that would rather fade
|
|
752
|
+
its music out than cut it, say. The Arcade still shows its button for your game where it shows one,
|
|
753
|
+
so this is a promise: your `onPrefs` handler must go quiet on `muted: true` and come back on `false`,
|
|
754
|
+
and the release checklist checks that it does.
|
|
755
|
+
|
|
756
|
+
## The Arcade's wallet: show it, never draw it
|
|
757
|
+
|
|
758
|
+
The player's platform balances — Gems, Tickets (`ARCADE_TICKETS`) and their achievement score —
|
|
759
|
+
belong to the Arcade. **Your game never draws one**, and never shows a pending state for one: the
|
|
760
|
+
Arcade takes every figure from the platform and draws the wallets itself, beside its Back tab.
|
|
761
|
+
|
|
762
|
+
**They are shown unless your game hides them.** A game that does nothing has every wallet on show
|
|
763
|
+
the whole time. The one rule: **hide them during gameplay, show them on your menus.**
|
|
764
|
+
|
|
765
|
+
```js
|
|
766
|
+
level.onStart = () => gem.setWalletVisible(false);
|
|
767
|
+
pauseMenu.onOpen = () => gem.setWalletVisible(true);
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
- **While hidden, the Arcade still pops a wallet up** for a moment when something happens to it —
|
|
771
|
+
a reward you cued with `gem.cueReward`, a spend, a landing, a daily-maximum note, a score change
|
|
772
|
+
— then hides it again. Your state never cancels that, so a reward mid-level still shows.
|
|
773
|
+
- **It is screen state, not a toggle.** Each call is the whole answer and replaces the last. Call it
|
|
774
|
+
as often as you like: before the Arcade connects the state is held and sent when it does, and
|
|
775
|
+
again after any reconnect; a repeat of the current state is not re-sent.
|
|
776
|
+
- **Shown wallets take space; a pop does not.** Shown, they widen the
|
|
777
|
+
[reserved area](#the-arcades-own-controls-reserved-areas) beside the Back tab, so lay your menu
|
|
778
|
+
out against `--gem-reserved-tl-*`; hidden, it shrinks back to the tab. A pop floats over your game
|
|
779
|
+
and never moves its layout.
|
|
780
|
+
- **Narrowing is optional.** On a screen that sells in one currency — a ticket store — you may show
|
|
781
|
+
only that one: `gem.setWalletVisible(true, { only: ['ARCADE_TICKETS'] })`, with `'score'` for the
|
|
782
|
+
achievement score. Use it only there; leaving it out shows every wallet, which is always safe.
|
|
783
|
+
- **Where the Arcade docks its wallets beside your frame** — a desktop page — they are always on
|
|
784
|
+
show, and this changes nothing. Call it anyway; the same code is right everywhere.
|
|
785
|
+
- **`false` means nobody is listening** — no connection yet, or an embedder that draws no wallet.
|
|
786
|
+
Do nothing in its place.
|
|
787
|
+
|
|
788
|
+
## The Arcade's FPS overlay
|
|
789
|
+
|
|
790
|
+
The Arcade has a frame-rate toggle beside your game, and **you do not have to write anything for it
|
|
791
|
+
to work**. When a player switches it on, `gem.prefs` arrives with `showFrameStats: true`, the SDK
|
|
792
|
+
starts counting the frames the browser delivers to your
|
|
793
|
+
document, and about once a second it reports the rate and the 95th-percentile frame time to the
|
|
794
|
+
Arcade, which draws a small readout in its own chrome. **Your game draws nothing.**
|
|
795
|
+
|
|
796
|
+
- **It costs you nothing until a player asks.** No `requestAnimationFrame` is registered and nothing
|
|
797
|
+
is sent until `showFrameStats` arrives `true`; the counter stops when it goes `false`, while the
|
|
798
|
+
Arcade has your game paused (`onPause`), and when the bridge closes. A game whose player never
|
|
799
|
+
presses the toggle never runs any of it.
|
|
800
|
+
- **What the number measures.** The `requestAnimationFrame` ticks the browser delivers to your
|
|
801
|
+
game's document. That is exactly your render rate if you render on that callback — three.js,
|
|
802
|
+
Phaser and Babylon.js do, and so does any engine's web export — and it is measured inside your
|
|
803
|
+
frame on purpose:
|
|
804
|
+
the Arcade page cannot count your frames from outside, because a cross-origin iframe renders on
|
|
805
|
+
its own schedule, and a page-side counter would read a steady 60 over a game hitching at 25.
|
|
806
|
+
- **If you render in a worker.** A game that draws through an `OffscreenCanvas` in a worker is
|
|
807
|
+
timed on its main thread here, which is not its renderer. There is no hook yet for such a game to
|
|
808
|
+
report its own figure; if you need one, ask.
|
|
809
|
+
- **Do not draw an FPS counter of your own over the Arcade's.** Two readouts that disagree — and
|
|
810
|
+
they will, since they count different things — teach a player that one of them is lying. A debug
|
|
811
|
+
overlay in your own builds is fine; keep it off in an Arcade build, or at least off by default.
|
|
812
|
+
- **The toggle appears only for games that support it.** An SDK that can count says so when it
|
|
813
|
+
connects, and a game built on an older one gets no toggle rather than a readout that never moves.
|
|
814
|
+
Upgrading the SDK is the whole of what a game has to do.
|
|
815
|
+
|
|
816
|
+
`showFrameStats` is also on `gem.prefs` for you to read, held like the rest, but the only reason to
|
|
817
|
+
read it is to know the Arcade is already showing one.
|
|
818
|
+
|
|
819
|
+
## The player's language: prefer what the page sends, detect the rest
|
|
820
|
+
|
|
821
|
+
**`gem.prefs.locale` is the language the Arcade page is RENDERING in.** That is the one fact a frame
|
|
822
|
+
cannot obtain for itself — `<html lang>` inside the frame answers for what your game shipped, and
|
|
823
|
+
nothing in a cross-origin iframe can see the parent's state. Honour it and your strings match the
|
|
824
|
+
page around them; ignore it and a player reading the Arcade in Japanese clicks Play and lands in
|
|
825
|
+
English.
|
|
826
|
+
|
|
827
|
+
**It is not the only source, and it is not always the best one.** `navigator.languages` is readable
|
|
828
|
+
inside a cross-origin iframe exactly as it is on a top-level page, and the Arcade's value is
|
|
829
|
+
**clamped to the dozen languages the Arcade itself ships** — so a game that has a Dutch bundle would
|
|
830
|
+
be told `en`, because the Arcade has no Dutch. Prefer the page's locale when you ship that language;
|
|
831
|
+
otherwise fall back to your own detection.
|
|
832
|
+
|
|
833
|
+
```js
|
|
834
|
+
const supported = ['en', 'fr', 'ja']; // what YOU ship
|
|
835
|
+
const wanted = gem.prefs?.locale; // what the page is rendering in
|
|
836
|
+
const lng = wanted && supported.includes(wanted) ? wanted : undefined;
|
|
837
|
+
// `undefined` -> let your detector read navigator.languages
|
|
838
|
+
```
|
|
839
|
+
|
|
840
|
+
**Do not wait for it.** `gem.prefs` is parent-initiated and not every embedder sends one — the native
|
|
841
|
+
Arcade app does not today. A game whose string loading blocks on it renders nothing on those
|
|
842
|
+
surfaces. Start from detection, and re-render when the message arrives.
|
|
843
|
+
|
|
844
|
+
Whichever path you take, set **`supportedLngs`** to the languages you actually ship and
|
|
845
|
+
**`fallbackLng`** to your default. Detection on its own hands you `en-GB`, which matches no bundle if
|
|
846
|
+
you shipped `en` — the single most common locale bug in shipped games, and the reason this sentence
|
|
847
|
+
is here rather than left as an exercise. Do **not** reach for `nonExplicitSupportedLngs` or
|
|
848
|
+
`load: 'languageOnly'` to fix it: measured against i18next 26, both collapse every region- or
|
|
849
|
+
script-qualified locale you ship (`pt-BR`, `zh-Hans`) to the fallback, because they test membership
|
|
850
|
+
on the language part alone. `supportedLngs` already resolves `en-GB` to `en`.
|
|
851
|
+
|
|
852
|
+
## Showing a player: one identity on every surface
|
|
853
|
+
|
|
854
|
+
Every surface that shows a player hands you **the same fields, under the same names**: the signed-in
|
|
855
|
+
player (`gem.player`), room members, leaderboard rows, friends, profiles, showcase rows — and an
|
|
856
|
+
async opponent you read through the raw client, once you pass the row through `mapIdentity`. One
|
|
857
|
+
renderer serves all of them.
|
|
858
|
+
|
|
859
|
+
| Field | What it is | Type |
|
|
860
|
+
| ----- | ---------- | ---- |
|
|
861
|
+
| `playerId` | The player's id. **Absent on an anonymous row** | `string` |
|
|
862
|
+
| `displayName` | The name the player chose | `PlayerText` |
|
|
863
|
+
| `discriminator` | The digits that tell two same-named players apart — `0001`, no `#` | `string` |
|
|
864
|
+
| `displayTag` | The precomposed `Name#0001` | `PlayerText` |
|
|
865
|
+
| `avatarIconId` | The portrait item's id | `string` |
|
|
866
|
+
| `avatarIconUrl` | The portrait's thumbnail | `string` |
|
|
867
|
+
| `badges` | Equipped badges in display order, each `{ itemId, iconUrl }` — always a list, empty for now | array |
|
|
868
|
+
|
|
869
|
+
**The name and the tag are player-authored, so both are `PlayerText`** on every surface, not only
|
|
870
|
+
some. Convert with `text.toPlain()` where you render, never before.
|
|
871
|
+
|
|
872
|
+
**Show the discriminator beside the name, and never drop it to make a name fit.** Two players may
|
|
873
|
+
choose the same name, and the digits are what keeps one from passing as the other. Truncate the
|
|
874
|
+
name, not the digits.
|
|
875
|
+
|
|
876
|
+
**A row with no `playerId` is an anonymous player** — erased, or on a board that shows no players.
|
|
877
|
+
It keeps its rank, so render it as "Anonymous player" rather than skipping it.
|
|
878
|
+
|
|
879
|
+
**A row with a `playerId` but no name** is a player who has not chosen one, or one whose identity
|
|
880
|
+
is withheld from you (a player who blocked you, say). The id stays because it keys a seat, a
|
|
881
|
+
friendship or an opponent you already hold; the name, tag and portrait are absent. Render it as
|
|
882
|
+
"Player", and never fall back to showing the id.
|
|
883
|
+
|
|
884
|
+
**Put an icon URL through `platformIconUrl` before it reaches an `<img>`.** Loading an image is a
|
|
885
|
+
request that carries your page's address and the moment it happened, so an icon from anywhere but
|
|
886
|
+
the platform's own image host would be a beacon on your player. `platformIconUrl` answers the URL
|
|
887
|
+
only when the platform serves it, and `undefined` otherwise — draw your placeholder, as for a
|
|
888
|
+
player with no portrait.
|
|
889
|
+
|
|
890
|
+
```js
|
|
891
|
+
import { mapIdentity, platformIconUrl, text } from '@indiegems/gem-web-sdk/sdk';
|
|
892
|
+
|
|
893
|
+
function renderPlayer(el, who) {
|
|
894
|
+
const name = el.querySelector('.name');
|
|
895
|
+
const digits = el.querySelector('.digits');
|
|
896
|
+
name.textContent = who.displayName ? text.toPlain(who.displayName) : 'Anonymous player';
|
|
897
|
+
digits.textContent = who.discriminator ? `#${who.discriminator}` : '';
|
|
898
|
+
const src = platformIconUrl(who.avatarIconUrl);
|
|
899
|
+
if (src) el.querySelector('img').src = src;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
for (const member of gem.rooms.currentRoom?.members ?? []) renderPlayer(rowFor(member), member);
|
|
903
|
+
for (const row of opponents) renderPlayer(rowFor(row), mapIdentity(row)); // a raw-client row
|
|
904
|
+
```
|
|
905
|
+
|
|
906
|
+
**What changed, if your game was written before these fields.** `gem.player.displayTag` used to be
|
|
907
|
+
the discriminator digits and is now the whole `Name#0001` as `PlayerText`, the same as a room
|
|
908
|
+
member's; the digits are `gem.player.discriminator`. The names on friends, leaderboard, profile
|
|
909
|
+
and showcase rows used to be plain strings and are now `PlayerText`, as they already were on room
|
|
910
|
+
members. `gem.player.displayHandle` and `gem.player.avatarUrl` still work, and are
|
|
911
|
+
[deprecated](deprecation-policy.md) in favour of `displayTag` and `avatarIconUrl`.
|
|
912
|
+
|
|
913
|
+
### The signed-in player's Boost: show it, never gate on it
|
|
914
|
+
|
|
915
|
+
`gem.player` also carries **`boost`** — the player's Boost, the Arcade's one real-money product.
|
|
916
|
+
While it lasts the player earns Gems and Tickets faster (never achievement points), and the
|
|
917
|
+
platform raises their earning limits to match. The platform does all of that on its own side when
|
|
918
|
+
it pays; your game does nothing to make a boost work.
|
|
919
|
+
|
|
920
|
+
| Field | What it is | Type |
|
|
921
|
+
| ----- | ---------- | ---- |
|
|
922
|
+
| `boost.active` | Whether any currency earns faster than 1× right now | `boolean` |
|
|
923
|
+
| `boost.multipliers` | One `{ currencyId, currencySlug, multiplier }` per boostable currency — `multiplier` is `2` for 2×, `1` when not boosted; a currency not listed is 1× | array |
|
|
924
|
+
| `boost.expiresInDays` | Whole days until the soonest boosted currency drops back, rounded up and **capped at 7** — `7` means "7 or more"; absent when nothing is boosted | `number` |
|
|
925
|
+
|
|
926
|
+
`boost` is always present on `gem.player`. When the Arcade has not said — an older Arcade, a
|
|
927
|
+
backend without boosts, a read that has not landed yet — it reads as **not boosted**, exactly like a
|
|
928
|
+
player who has no boost.
|
|
929
|
+
|
|
930
|
+
```js
|
|
931
|
+
const tickets = gem.player?.boost.multipliers.find((m) => m.currencySlug === 'ARCADE_TICKETS');
|
|
932
|
+
if (tickets && tickets.multiplier > 1) rewardScreen.showBadge(`${tickets.multiplier}× Tickets`);
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
⚠️ **It is for display, and only for display** — a "2×" badge on a reward screen. Never gate
|
|
936
|
+
content, difficulty, matchmaking, prices or power on it: it is advisory, it may lag (it is pushed on
|
|
937
|
+
connect and when it changes, not on the instant a boost starts or ends), and it can be forged inside
|
|
938
|
+
your own page. It carries a day count and never a date, and nothing about what the player paid.
|
|
939
|
+
This is a platform rule, not advice — see
|
|
940
|
+
[platform rules §8](platform-rules.md#8-a-players-boost-is-for-display-never-a-gate).
|
|
941
|
+
|
|
942
|
+
## Which backend — no longer yours, and what the override is for
|
|
943
|
+
|
|
944
|
+
The Arcade names the API root on every `gem.token`, and the SDK sends there. Your game never types
|
|
945
|
+
an API address and never has to know which one it is running against; if the token says nothing, the
|
|
946
|
+
SDK uses `https://api.gemarcade.com`.
|
|
947
|
+
|
|
948
|
+
Two things the SDK refuses rather than working around, because both are silent when they go wrong:
|
|
949
|
+
|
|
950
|
+
- **An address it cannot use** — one carrying a query string, a fragment, credentials, or plaintext
|
|
951
|
+
`http` anywhere but your own machine. The whole token is refused and the reason reaches `onError`.
|
|
952
|
+
- **An address that changes mid-session.** The first token of a session fixes where requests go, and
|
|
953
|
+
a later one naming somewhere else is refused. Nothing legitimate moves it, so a game that ever
|
|
954
|
+
sees this has an embedder problem rather than a game problem.
|
|
955
|
+
|
|
956
|
+
`baseUrl` survives as a **developer override** for hosts the Arcade cannot name — a tunnel, a staging
|
|
957
|
+
rig, a LAN address a phone can reach. It pins the transport, and what the token says stops moving it
|
|
958
|
+
(the token's value is still checked, and a bad one still refuses the token). Do not use it to choose
|
|
959
|
+
a backend: that is the Arcade's job, and a hand-configured value is how a game ships pointing at the
|
|
960
|
+
wrong one and reads the result as authentication failures.
|
|
961
|
+
|
|
962
|
+
## Saving a player's progress — if your game has any
|
|
963
|
+
|
|
964
|
+
Games do not need saves, so this is not on the list above. It is a capability, and it has a page
|
|
965
|
+
of its own: [`saving-player-progress.md`](saving-player-progress.md).
|
|
966
|
+
|
|
967
|
+
Read it if your game has progress worth keeping, because the shape is not the one most games
|
|
968
|
+
assume. There are **two roles**: you declare a save slot once, in your game config, the way you
|
|
969
|
+
declare a room template; then your game's running code writes and reads that player's save with
|
|
970
|
+
`gem.me.saves`. Game code cannot create a slot, and a save against one you never declared is
|
|
971
|
+
refused every time.
|
|
972
|
+
|
|
973
|
+
## An economy — if your game has one
|
|
974
|
+
|
|
975
|
+
Currencies, items, shops and loot are not on the list above either, for the same reason: a game
|
|
976
|
+
without them is complete. If yours has any, they have their own pages:
|
|
977
|
+
[`inventory-and-catalogs.md`](inventory-and-catalogs.md) for the concepts, and
|
|
978
|
+
[`game-economy-tutorial.md`](game-economy-tutorial.md) to build a shop step by step.
|
|
979
|
+
|
|
980
|
+
The shape rhymes with saves. There are **two roles**: you author the definitions — the currencies,
|
|
981
|
+
items and shops that exist — once, in your game config; then your game's running code reads and
|
|
982
|
+
changes what a player holds. The one thing to carry away before you read further: a **recipe**,
|
|
983
|
+
run on the server, is the only path a modified client cannot rewrite, so anything of value goes
|
|
984
|
+
through one.
|
|
985
|
+
|
|
986
|
+
## Async opponents — if your game has nobody live to play against
|
|
987
|
+
|
|
988
|
+
Not on the list above either: a game with no competitive mode is complete without one. If yours
|
|
989
|
+
wants one and has no server of its own to run a live match on, the platform will hold a snapshot of
|
|
990
|
+
one player's run — a deck, a build, a ghost lap — and hand it to a different player's game hours
|
|
991
|
+
later, with nothing live between them.
|
|
992
|
+
|
|
993
|
+
The shape is a loop rather than two roles: your game pushes a record after a match, draws a few at
|
|
994
|
+
the next match's start, and fetches the full data for the one your player picks.
|
|
995
|
+
[`async-opponents.md`](async-opponents.md) is the page — read it before you conclude that a game
|
|
996
|
+
running entirely in the browser cannot offer competition, and read
|
|
997
|
+
[its safety rule](async-opponents.md#7-draw-a-set-at-match-start) before you render anything a
|
|
998
|
+
drawn record carries, because those strings were typed by another player.
|
|
999
|
+
|
|
1000
|
+
## A break in play, if you want one to be worth something
|
|
1001
|
+
|
|
1002
|
+
Not on the list above either, and a game that never asks is never interrupted. If yours wants a
|
|
1003
|
+
break to carry a promotion for another game on the Arcade, `gem.ads.requestAd` asks for one and the
|
|
1004
|
+
Arcade draws it in its own chrome over your paused game.
|
|
1005
|
+
|
|
1006
|
+
Three things before you reach for it: the answer is **usually no**, and no is the ordinary path
|
|
1007
|
+
rather than a failure; nothing rejects, so there is no error branch to write; and `shown` grants
|
|
1008
|
+
nothing, so a reward must never be handed out on the strength of it.
|
|
1009
|
+
[`cross-promotion.md`](cross-promotion.md) is the page.
|
|
1010
|
+
|
|
1011
|
+
## An account upsell, if your game has something to offer one
|
|
1012
|
+
|
|
1013
|
+
Not on the list above either. When a player reaches for something only an account gives them,
|
|
1014
|
+
`gem.account.requestSignIn` asks the Arcade to sign them in, in its own chrome. Your game draws no
|
|
1015
|
+
form and passes nothing.
|
|
1016
|
+
|
|
1017
|
+
Two things before you reach for it: **save first**, because the Arcade may sign the player in by
|
|
1018
|
+
taking the whole page away, and your game then starts again rather than hearing an answer; and on
|
|
1019
|
+
`signed_in`, **re-read what you loaded**, because the player underneath your game may not be the
|
|
1020
|
+
one who was there a moment ago. [`player-sign-in.md`](player-sign-in.md) is the page.
|
|
1021
|
+
|
|
1022
|
+
## A Share button, if your game has something worth sending a friend
|
|
1023
|
+
|
|
1024
|
+
Not on the list above either. If a player can hand a friend a link to a puzzle, a board or a
|
|
1025
|
+
level, `gem.share.request` asks the Arcade to share it: the Arcade builds the link, which unfurls
|
|
1026
|
+
with your game's card, and shares it through its own sheet, copy or Share panel. Show the button
|
|
1027
|
+
only where `gem.share.available` is true.
|
|
1028
|
+
|
|
1029
|
+
Two things before you reach for it: **call it from the click**, synchronously, never from a timer
|
|
1030
|
+
or after an `await`; and **never gate a reward on it**, because your game is told nothing back,
|
|
1031
|
+
not even whether the player shared.
|
|
1032
|
+
[Linking to your game](getting-started.md#linking-to-your-game) is the section.
|
|
1033
|
+
|
|
1034
|
+
## A decision the player must not make, if your game has one
|
|
1035
|
+
|
|
1036
|
+
Not on the list above either: most games trust the browser with everything they do and lose nothing
|
|
1037
|
+
by it. If yours has a decision a player must not be able to fake, such as a run that earns a reward
|
|
1038
|
+
or a claim only your game's rules can judge, and no server of yours running to make it, the platform
|
|
1039
|
+
will run a small piece of your code for it, out of the player's reach, one call at a time.
|
|
1040
|
+
|
|
1041
|
+
Two things before you reach for it. A verdict the function **returns** to the browser is still
|
|
1042
|
+
advice to a client you do not trust, so whatever it decides it must also carry out itself. And
|
|
1043
|
+
spending currency or granting an item is a recipe, which the platform already runs where a client
|
|
1044
|
+
cannot change it. [`serverless-functions.md`](serverless-functions.md) is the page.
|
|
1045
|
+
|
|
1046
|
+
## Bug reports players send you, and the one WebGL flag that decides them
|
|
1047
|
+
|
|
1048
|
+
The arcade has a **Feedback** control. A player taps it, the platform asks your game for a picture
|
|
1049
|
+
of itself, the player draws on it and writes what happened, and it arrives on your game's feedback
|
|
1050
|
+
list. You get this for nothing — there is no option to set.
|
|
1051
|
+
|
|
1052
|
+
There is one thing to get right, and it is easy to miss because getting it wrong produces no error
|
|
1053
|
+
anywhere.
|
|
1054
|
+
|
|
1055
|
+
### ⚠️ WebGL games: `preserveDrawingBuffer: true`
|
|
1056
|
+
|
|
1057
|
+
```js
|
|
1058
|
+
const gl = canvas.getContext('webgl2', { preserveDrawingBuffer: true });
|
|
1059
|
+
```
|
|
1060
|
+
|
|
1061
|
+
Without it the browser is free to clear your drawing buffer once the frame is composited. Read it
|
|
1062
|
+
after that and you get **a fully black image and no error** — `createImageBitmap` resolves, the
|
|
1063
|
+
transfer succeeds, every layer reports success.
|
|
1064
|
+
|
|
1065
|
+
The SDK checks the flag before it captures and **refuses** rather than sending you black. That is
|
|
1066
|
+
deliberate: a black screenshot costs the player their whole report and costs you the time to work
|
|
1067
|
+
out why the platform sent you a rectangle of nothing. The refusal reaches you instead, as
|
|
1068
|
+
`unsupported` in the report's diagnostics.
|
|
1069
|
+
|
|
1070
|
+
The flag has a real cost — it can stop the browser discarding the buffer after compositing — so it
|
|
1071
|
+
is your call. But the default is to get no screenshots, silently, which is rarely what anyone wants.
|
|
1072
|
+
|
|
1073
|
+
2D canvas games need nothing. `OffscreenCanvas` games need `onScreenshot`, below.
|
|
1074
|
+
|
|
1075
|
+
### When the default capture is not right
|
|
1076
|
+
|
|
1077
|
+
By default the SDK captures your largest visible canvas. Override it when that cannot work — an
|
|
1078
|
+
`OffscreenCanvas` driven from a worker, several layered canvases you want composited — or when you
|
|
1079
|
+
want a say per moment:
|
|
1080
|
+
|
|
1081
|
+
```js
|
|
1082
|
+
createGem({
|
|
1083
|
+
onScreenshot: async ({ maxWidth, maxHeight }) => {
|
|
1084
|
+
if (inCutscene) return null; // decline; the report still sends
|
|
1085
|
+
return createImageBitmap(myCanvas);
|
|
1086
|
+
},
|
|
1087
|
+
});
|
|
1088
|
+
```
|
|
1089
|
+
|
|
1090
|
+
`null` is a normal answer, not an error — for a cutscene, for art you may not redistribute, or to
|
|
1091
|
+
opt out of screenshots entirely. Fit the caps if you can; the SDK downscales if you overshoot.
|
|
1092
|
+
|
|
1093
|
+
### Attach your own debug state
|
|
1094
|
+
|
|
1095
|
+
This is the part worth spending effort on, because it is the difference between _"the boss is stuck
|
|
1096
|
+
in the wall"_ and a report you can act on without a reproduction:
|
|
1097
|
+
|
|
1098
|
+
```js
|
|
1099
|
+
createGem({
|
|
1100
|
+
onFeedbackContext: () => ({
|
|
1101
|
+
level: currentLevel.id,
|
|
1102
|
+
seed: currentLevel.seed,
|
|
1103
|
+
build: BUILD_ID,
|
|
1104
|
+
entities: world.entities.length,
|
|
1105
|
+
recentEvents: eventLog.slice(-20),
|
|
1106
|
+
}),
|
|
1107
|
+
});
|
|
1108
|
+
```
|
|
1109
|
+
|
|
1110
|
+
**Called once, when a player actually files a report** — never on a timer, never in the background.
|
|
1111
|
+
So the expensive gather is fine here: dump what you would actually want to read. May be `async`;
|
|
1112
|
+
return `null` to add nothing.
|
|
1113
|
+
|
|
1114
|
+
**Answer promptly.** You get about a second, then the report is sent without you — a player waiting
|
|
1115
|
+
to file a bug is not made to wait on the game they are filing it about. If your dump is genuinely
|
|
1116
|
+
slow, keep a cheap snapshot updated and return that.
|
|
1117
|
+
|
|
1118
|
+
The platform never tells your game what the report says, what category the player chose, or whether
|
|
1119
|
+
they went on to send it. That is on purpose.
|
|
1120
|
+
|
|
1121
|
+
### What must not go in it
|
|
1122
|
+
|
|
1123
|
+
⚠️ **Nothing about a person.** No other player's name, no chat text, no email, no account id.
|
|
1124
|
+
|
|
1125
|
+
The player filing the report does not write this and cannot see it, and both your team and platform
|
|
1126
|
+
support read it, for 180 days. It is also not a telemetry channel: it runs only when a player
|
|
1127
|
+
chooses to send a report, and nothing is uploaded on your behalf at any other time.
|
|
1128
|
+
|
|
1129
|
+
## Still open, and worth knowing before you hit it
|
|
1130
|
+
|
|
1131
|
+
- **A quick-join that hands back a live room whose match is dead.** The SDK now leaves the room on
|
|
1132
|
+
unload and clears leftover memberships that refuse a quick-join, which removes the common cause.
|
|
1133
|
+
What it does not do is abandon a room it is _in_ and quick-join a fresh one when the connect fails
|
|
1134
|
+
— that spans matchmaking policy the SDK does not own, and the same recovery is wrong for a game
|
|
1135
|
+
with a lobby, where the player chose the room. If you quick-join and you see a match status 404,
|
|
1136
|
+
this is the shape: leave, quick-join again, and do it **once**, and not at all for a room your own
|
|
1137
|
+
attempt just created — a brand-new room cannot fix a backend that will not allocate.
|
|
1138
|
+
- **Who starts the match.** `connect()` allocates when this player is the host and the room shows no
|
|
1139
|
+
match. If your room template sets `auto_start_host`, both are true for the instant after
|
|
1140
|
+
`quickJoin` returns, so the client wins the race — harmlessly, because gem-api's `start_match` is
|
|
1141
|
+
idempotent per room and returns the existing match. If you would rather the template always be the
|
|
1142
|
+
one that starts it, `await rooms.awaitMatch()` before connecting.
|
|
1143
|
+
- **Starting a match can wait for a server to exist.** A Start pressed when no server is available is
|
|
1144
|
+
refused until the platform has one. `startMatch()` and `connect()` absorb that inside a
|
|
1145
|
+
**30-second** budget and report each wait as **`matchStartWaiting`**; render it, or your Start
|
|
1146
|
+
button stalls silently for half a minute. That budget is the longest a player is expected to
|
|
1147
|
+
tolerate rather than the longest the platform might take, so it will sometimes give up on a start that
|
|
1148
|
+
a longer wait would have caught — `retry: { budgetMs }` raises it if your players will wait, and
|
|
1149
|
+
`0` hands the refusal straight to you. If the budget runs out the error is `no_server_ready`, which
|
|
1150
|
+
says how long it waited and is not the same thing as a request gem-api refused. See
|
|
1151
|
+
[`rooms-and-matches.md`](rooms-and-matches.md) §4.4.
|
|
1152
|
+
- **A peer-to-peer room does not wait at all.** There is no server being allocated for it, so a
|
|
1153
|
+
refusal there means the platform has no P2P rather than no capacity, and it arrives immediately as
|
|
1154
|
+
`p2p_unavailable`. Nothing to render and nothing to retry: it is a platform configuration, and the
|
|
1155
|
+
message carries what gem-api said.
|
|
1156
|
+
- **Cluster targets.** The parent measures the regions and the SDK fills them in: a room is created
|
|
1157
|
+
in this player's best cluster, quick-join puts players only in rooms in clusters they can all play
|
|
1158
|
+
well in, and a match starts where its room is. A game that wants to move a room passes
|
|
1159
|
+
`clusterTargetId` to `startMatch()`. The measurement is a browser measurement — the page times the
|
|
1160
|
+
STUN probes gem-api publishes per region for exactly that purpose — and it stays on the page for
|
|
1161
|
+
three reasons: `GET /v1/regions` takes an account-scoped token the frame does not hold; the page
|
|
1162
|
+
measures once per player session and every game it loads inherits the answer, where the frame would
|
|
1163
|
+
repeat it per game and per reload; and the per-region round-trip vector is coarse geolocation plus a
|
|
1164
|
+
network fingerprint, so what crosses the bridge is at most three ids, never a timing. See
|
|
1165
|
+
[`dedicated-server-flow.md`](dedicated-server-flow.md) §5.
|
|
1166
|
+
- **An automatic start can be refused, and the host's SDK then starts it.** A template that starts
|
|
1167
|
+
the match when the room fills tells the host's game through `matchAutoStartFailed` when the platform
|
|
1168
|
+
could not, and the room stays open. For a region the platform stopped running, or no server free,
|
|
1169
|
+
the SDK starts the match from the host's game itself — once per room until a match begins in it —
|
|
1170
|
+
and the event says `handled: true`. `handled: false` is yours to act on, and
|
|
1171
|
+
`createGem({ retryAutoStart: false })` leaves every one to you; calling `startMatch()` from the
|
|
1172
|
+
handler is then the usual answer. The SDK's 5.11.0 and 5.12.0 releases started nothing, so a game
|
|
1173
|
+
written for them that holds the start back for the host to decide needs that switch.
|
|
1174
|
+
[rooms-and-matches.md](rooms-and-matches.md) §4.4.
|
|
1175
|
+
- **Reconnect.** Since the SDK's 4.0.0 release (2026-09-10) a dropped link is the SDK's to get
|
|
1176
|
+
back: it suspends the match, raises `matchSuspended`, and retries on its own — bounded, backed
|
|
1177
|
+
off, parked while the page is hidden — with `matchReconnecting` and `matchReconnected` as the
|
|
1178
|
+
render hooks. `connect()` again on a suspended session _rejoins the held match_; it never
|
|
1179
|
+
reallocates. A new match is allocated only after `matchEnded`, and only by the host. What the
|
|
1180
|
+
player sees while it happens is the game's; whether to reconnect is yours only if you turn the
|
|
1181
|
+
policy off (`autoReconnect: false`). [match-session.md](match-session.md) §4.
|
|
1182
|
+
- **Letting go when the match ends.** The SDK closes the peer connection when the platform says the
|
|
1183
|
+
match is over, and does _not_ suspend the match — there is nothing to reconnect to. It has to: a
|
|
1184
|
+
browser sitting on a finished match keeps answering ICE consent checks, which the server cannot
|
|
1185
|
+
distinguish from a player still there, and a tab nobody closed used to hold its game-server pod for
|
|
1186
|
+
two hours. What the game decides is what the player sees, via the room's `matchEnded` event or
|
|
1187
|
+
`connect({ onMatchEnded })` — on `gem.match` and `gem.dedicatedServer` alike, on every seat —
|
|
1188
|
+
the distinction those carry is "the match ended" versus "the connection dropped", which are the
|
|
1189
|
+
same `onClose` otherwise. The reason vocabulary and what each means to the player:
|
|
1190
|
+
[match-session.md](match-session.md) §3.
|
|
1191
|
+
|
|
1192
|
+
## What the SDK will not do, on purpose
|
|
1193
|
+
|
|
1194
|
+
- **Guess when you have disconnected.** `visibilitychange` fires when a player alt-tabs to read a
|
|
1195
|
+
wiki. Only the data channel closing is unambiguous, and only that is wired.
|
|
1196
|
+
- **Act on a stale-build push.** It is forwarded and nothing else, which is also what a native game
|
|
1197
|
+
observes for the same event.
|
|
1198
|
+
- **Invent a display name.** `awaitPlayer()` resolves null rather than
|
|
1199
|
+
burning your timeout: there is no parent, so `gem.player` can never arrive.
|
|
1200
|
+
|
|
1201
|
+
## What a game correctly keeps — the other half of the audit
|
|
1202
|
+
|
|
1203
|
+
The game that audited its platform layer row by row also recorded what it deliberately does **not**
|
|
1204
|
+
delegate, so nobody spends that trip again. Each of these looked like possible duplication of the
|
|
1205
|
+
SDK and is not:
|
|
1206
|
+
|
|
1207
|
+
- **Your per-tick wire budget.** A budget your game puts on its own data-channel traffic is yours
|
|
1208
|
+
and does not conflict with the platform's 16 KB cap on a **signaling** frame — different paths,
|
|
1209
|
+
different owners. Do not copy the platform's number into game code; hold your own.
|
|
1210
|
+
- **Player saves are the exception on this list, and an earlier version of it said otherwise.**
|
|
1211
|
+
Progress a player would be upset to lose does not belong in browser storage, which they lose by
|
|
1212
|
+
changing device or browser; the platform holds it, and
|
|
1213
|
+
[`saving-player-progress.md`](saving-player-progress.md) is how. Something genuinely local still
|
|
1214
|
+
belongs in the browser — a keybinding, which device this is, a cached asset.
|
|
1215
|
+
- **`openExternal` and fullscreen.** The bridge carries both requests, and the parent decides. If
|
|
1216
|
+
your game opens no links and never goes fullscreen, there is nothing to route — absence of these
|
|
1217
|
+
calls in a game is not a gap.
|
|
1218
|
+
- **An offline stub, if you want one.** The SDK no longer ships a seeded mock — it was a quarter
|
|
1219
|
+
of the bundle every player downloaded, and a game must be framed to reach
|
|
1220
|
+
the platform at all. A game may keep its own offline
|
|
1221
|
+
rooms implementation — the audited game argues a stub that is merely convenient lets the game
|
|
1222
|
+
pass offline and fail online — but that is a choice about test honesty, and it drifts with the
|
|
1223
|
+
SDK's room semantics; know that you are signing up to track them.
|
|
1224
|
+
- **Debug affordances in your own vocabulary.** Per-step logging, a `?cluster=` override aimed at
|
|
1225
|
+
one region — a developer override outranks a platform-measured value _because a person typed it_,
|
|
1226
|
+
and those belong to the game. Ship nothing with one set.
|
|
1227
|
+
- **Capability advertising is not yours to tune.** `createGem` advertises what the SDK
|
|
1228
|
+
_implements_, not what your game intends to use — an unused capability costs nothing, because the
|
|
1229
|
+
parent only relays what its socket delivers. A game assembling the parts by hand instead inherits
|
|
1230
|
+
the duty to advertise only what it wired: a capability advertised and not wired makes the page do
|
|
1231
|
+
its half and drop the answer, silently, one document above you.
|