@genex-ai/cli-demo 1.36.5 → 1.36.6-dev.862

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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: genex-threejs-embed-auth
3
- description: Wire up player identity AND durable game state for a Genex game via @genex-ai/embed-sdk. Load this UNCONDITIONALLY for every game, multiplayer or not, BEFORE writing any boot code — every player gets an identity (signed-in account or guest); per-player saves, shared world state, and leaderboards are one-line SDK calls; multiplayer requires the SDK's token either way.
3
+ description: Wire up player identity AND durable game state for a Genex game via @genex-ai/embed-sdk. Load this UNCONDITIONALLY for every game, multiplayer or not, BEFORE writing any boot code — every player gets an identity (signed-in account or guest); per-player saves, shared world state, and leaderboards are one-line SDK calls; multiplayer requires the SDK's token either way. Also holds the shells table: what a game may do on its Genex page, in the app and on its own link.
4
4
  ---
5
5
 
6
6
  # Genex Three.js Embed Auth
@@ -18,11 +18,13 @@ for both, using the SDK's token. The SDK handles every context with one
18
18
  straight into guest play. No login screen either way.
19
19
  - **Standalone (someone opens the game's link directly):** the visit bounces
20
20
  once through Genex (at the CDN edge in production, so the game only loads
21
- AFTER identity is resolved; the SDK does the same bounce itself in local
22
- dev) — signed-in visitors arrive already authenticated on ANY game with
23
- zero clicks; everyone else arrives as a guest with a small dismissible
24
- "sign in to save progress" popover (rendered by the SDK — don't build your
25
- own). Nobody ever hits a login wall on a published game.
21
+ AFTER identity is resolved; where the edge did not, the SDK makes the same
22
+ bounce itself) — signed-in visitors arrive already authenticated on ANY
23
+ game with zero clicks; everyone else arrives as a guest with a small
24
+ dismissible "sign in to save progress" popover (rendered by the SDK — don't
25
+ build your own). Nobody ever hits a login wall on a published game.
26
+ - **Inside the Genex mobile app:** the app hands the game its player — the
27
+ signed-in account or a guest — with no bounce and no login screen.
26
28
  - **Local test mode (your own self-testing):** `?genex_local_test=1` on the
27
29
  local dev server boots a credential-less local session — see "Self-testing
28
30
  a draft" below.
@@ -31,6 +33,32 @@ While identity is resolving (or blocked) the SDK shows its own full-screen
31
33
  overlay over the game, so never build a separate "connecting" screen for auth.
32
34
  Guest sessions have **no** overlay — the game just plays.
33
35
 
36
+ ## The four shells — read before you call a browser or device API
37
+
38
+ Those four contexts are the **shells** a game runs in, and they differ in far
39
+ more than identity. What a game may open, save, share, read from its URL or
40
+ ask of the player's device is different in each one, and a call the shell
41
+ refuses usually fails with no error at all.
42
+ **Find the row in [references/shells.md](references/shells.md) before you
43
+ write `navigator.*`, `window.open`, a download link, a dialog, or anything
44
+ that reads the URL.** The digest:
45
+
46
+ - **Which shell:** `getShell()` → `"page"` (the frame on the game's Genex
47
+ page, where most players are, and any other frame), `"app"`,
48
+ `"standalone"` or `"local"`.
49
+ - **Share, copy, open a link, save a file, fullscreen, keep the screen on,
50
+ vibrate:** `share()`, `copyText()`, `openExternal()`, `saveFile()`,
51
+ `setFullscreen()`, `keepAwake()`, `haptic()`, from a click or tap handler,
52
+ before any `await`. They resolve with a reason and never throw.
53
+ - **Your own URL parameters, and where the visitor came from:** `getEntry()`,
54
+ never `location` or `document.referrer`.
55
+ - **Microphone, camera, location:** the build declares them at
56
+ `npx genex preview` / `npx genex publish`; the app refuses all three.
57
+ - **Never:** `alert` / `confirm` / `prompt`, `window.open`, a link or form
58
+ that leaves the game, a service worker, reading the clipboard.
59
+ - **Local test mode is a plain browser tab, not the frame.** A call that works
60
+ there can still be dead on the game's Genex page.
61
+
34
62
  ## Install
35
63
 
36
64
  ```bash
@@ -86,8 +114,9 @@ const { user, guest } = await waitForPlayer();
86
114
  // ./assets/avatar.vrm behind it (absent on old APIs / local test mode).
87
115
  // Peers resolve the same way via the multiplayer SDK's player.avatarUrl.
88
116
 
89
- // ACCOUNT gate — resolves ONLY for signed-in players (stays pending for
90
- // guests; resolves later if they sign in mid-game). Use ONLY for /state
117
+ // ACCOUNT gate — resolves ONLY for signed-in players. For a guest it stays
118
+ // pending for as long as this page is loaded: signing in loads the game
119
+ // again, and the gate resolves on that load. Use ONLY for /state
91
120
  // saving/loading and other account-bound features.
92
121
  const { user: account } = await waitForAuth();
93
122
  ```
@@ -104,9 +133,10 @@ boot-path gate; `waitForAuth()` guards saves only.
104
133
  and player-name UI; resolves for guests and accounts alike, rejects only if
105
134
  the session ends up blocked.
106
135
  - `waitForAuth()` → `Promise<{ user }>` — the gate for `/state` calls and
107
- account-bound features. Stays PENDING for guests (resolves live if they
108
- sign in); rejects if blocked. Catch rejections and let the SDK's overlay
109
- handle the UX (don't build your own sign-in UI).
136
+ account-bound features. Stays PENDING for a guest for the whole page load
137
+ (signing in loads the game again, and it resolves on that next load);
138
+ rejects if blocked. Catch rejections and let the SDK's overlay handle the
139
+ UX (don't build your own sign-in UI).
110
140
  - `isEmbedded()` → `boolean` — structural "is in an iframe" check; NOT the same
111
141
  question as "is signed in".
112
142
  - `getAuthState()` → `"pending" | "authenticated" | "guest" | "blocked"` —
@@ -123,8 +153,44 @@ boot-path gate; `waitForAuth()` guards saves only.
123
153
  automatic re-seat reads a fresh token. Tokens rotate automatically (~every
124
154
  10 minutes). Never cache the value across joins and never log it.
125
155
  - `on(event, cb)` → unsubscribe fn. Events: `"authenticated"`, `"guest"`,
126
- `"blocked"`, `"error"`. A mid-game sign-in fires `"authenticated"` after
127
- `"guest"` — progress saving can start right then, no reload.
156
+ `"blocked"`, `"error"`. A page load ends in one of the first three. Signing
157
+ in loads the game again, so the account arrives as `"authenticated"` on
158
+ the next load, not as a second event on this one.
159
+
160
+ Where the game is running and what it was opened with (`@genex-ai/embed-sdk`
161
+ 0.32.0+; all three are safe to call before `initEmbed()`):
162
+
163
+ - `getShell()` → `"page" | "app" | "standalone" | "local"` — which shell this
164
+ is. A fact about the page: not who is signed in, and not a permission. Any
165
+ frame is `"page"`, whoever frames it, and the whole page of any React
166
+ Native web view is `"app"`.
167
+ - `getEntry()` → `{ search, params, hash, referrer }` — the link the game was
168
+ opened with: its own query (`search` as text, `params` as
169
+ `URLSearchParams`), its own fragment, and the origin of the site the visitor
170
+ came from, or `null`.
171
+ - `onShellClosing(cb)` → unsubscribe fn — `cb` runs when the page is being
172
+ taken away: the tab closed or reloaded, the app closing the game. The last
173
+ chance for a small `savePlayerState()`, never the only one.
174
+
175
+ Platform calls (0.32.0+): one call each that does the right thing in
176
+ whichever shell the game is in. **Call them from a click or tap handler,
177
+ before any `await`.** They resolve and never throw. What each one does in
178
+ each shell is in [references/shells.md](references/shells.md).
179
+
180
+ - `share({ text?, params? })` → `Promise<{ status, url }>`; `status` is
181
+ `"shared" | "copied" | "dismissed" | "unavailable"`. The SDK builds the
182
+ link; the game supplies only its own `params`.
183
+ - `copyText(text)`, `keepAwake(on)`, `openExternal(url)`,
184
+ `saveFile(blob, name)`, `setFullscreen(on)` →
185
+ `Promise<{ ok: true } | { ok: false, error }>`; `error` is `unsupported` |
186
+ `needs_gesture` | `dismissed` | `busy` | `rate_limited` | `invalid_request`
187
+ | `too_large` | `timeout`. `unsupported` and `timeout` are ordinary answers,
188
+ not bugs.
189
+ - `haptic(kind)` → nothing; `kind` is `"selection" | "light" | "medium" |
190
+ "heavy" | "success" | "warning" | "error"`.
191
+ - `supports(name)` → `boolean`, synchronous: whether to draw the button at
192
+ all. `name` is `"share" | "copy" | "awake" | "haptic" | "open" | "save" |
193
+ "fullscreen"`.
128
194
 
129
195
  Durable state + leaderboards (all six are safe to call from boot — they wait
130
196
  for identity internally, never throw for guests, and reject only when the
@@ -135,8 +201,9 @@ session is blocked):
135
201
  overwrite it). `{ data: null, version: 0 }` when they never saved.
136
202
  - `savePlayerState(data, { ifVersion? }?)` → `Promise<{ saved, version?,
137
203
  conflict?, guest?, queued?, staging? }>` — save any JSON ≤ 256KB to their
138
- slot. For guests the value is QUEUED in memory and auto-flushed if they sign
139
- in mid-game — no extra code. Small saves survive tab close automatically.
204
+ slot. For a guest nothing is saved: the value is held in memory for this
205
+ page load only (`{ saved: false, guest: true, queued: true }`), and signing
206
+ in loads the game again. Small saves survive tab close automatically.
140
207
  - `loadWorldState()` / `saveWorldState(data, { ifVersion? }?)` — the game's
141
208
  ONE shared world slot (≤ 1MB, every player reads/writes the same blob) —
142
209
  level layouts, persistent-world object positions. Multiplayer games: only
@@ -145,7 +212,8 @@ session is blocked):
145
212
  `{ conflict: true, version }` — reload, merge, retry.
146
213
  - `submitScore(score, { board?, mode? }?)` → `Promise<{ submitted, best?,
147
214
  improved?, guest?, queued? }>` — keep-best leaderboard submit (`mode:
148
- "min"` for lap-time boards). Guests: best value queues, flushes on sign-in.
215
+ "min"` for lap-time boards). A guest's score is not posted: the best value
216
+ is held in memory for this page load only.
149
217
  - `getLeaderboard({ board?, limit?, order? }?)` → `Promise<{ items, me }>` —
150
218
  top entries (verified display names — never trust client-side name input
151
219
  for this) + the signed-in player's own `{ rank, score }`. Works for guests
@@ -162,10 +230,11 @@ and saves there by design (a staging build with a changed save schema must
162
230
  never overwrite the save of the game people are playing). The helpers resolve
163
231
  `{ staging: true }` — `loadPlayerState()` → `{ data: null, version: 0,
164
232
  staging: true }`, `savePlayerState()` → `{ saved: false, staging: true }` —
165
- and never throw or fire `"error"` for it. Keep a localStorage fallback for
166
- progress, and if the game says anything about it, say "Preview build —
167
- progress isn't saved to your account". Never "offline" or "unavailable": the
168
- owner is testing their own game, and Genex is up.
233
+ and never throw or fire `"error"` for it. Keep a local copy of progress
234
+ (`localStorage`) so a preview stays playable — a cache for this device and
235
+ this shell, never the save — and if the game says anything about it, say
236
+ "Preview build — progress isn't saved to your account". Never "offline" or
237
+ "unavailable": the owner is testing their own game, and Genex is up.
169
238
 
170
239
 
171
240
  ## Saving progress (per-player — every player has their own slot)
@@ -186,11 +255,35 @@ applyProgress(data ?? defaultProgress());
186
255
  void savePlayerState(progress);
187
256
  ```
188
257
 
189
- Guests need zero special handling: their saves queue in memory and auto-flush
190
- the moment they sign in mid-game (so a guest's progress follows them into
191
- their new account), and `waitForAuth()` resolving is the signal that a real
192
- account now exists. The SDK already tells guests to sign in (its popover / the
193
- dashboard's card) — don't add another prompt.
258
+ Guests need no gating: the helpers resolve for them instead of failing. But
259
+ **a guest's progress is not saved, and it does not follow them into an
260
+ account.** The SDK holds a guest's latest save in memory for this page load
261
+ only, and signing in on the game's page or its own address loads the game
262
+ again (the SDK's popover and the Genex page both leave for the sign-in and
263
+ come back to a fresh load), so that value is gone when the account arrives.
264
+ Signing in from another tab changes nothing until the game is reloaded. If a
265
+ guest's run should survive signing in, keep your own copy on the device and
266
+ start from it when the account has no save of its own:
267
+
268
+ ```ts
269
+ // at every checkpoint: the device's copy, then the save
270
+ try { localStorage.setItem("progress", JSON.stringify(progress)); } catch {}
271
+ void savePlayerState(progress);
272
+
273
+ // at boot, in place of the plain load above. loadPlayerState() waits for the
274
+ // player, so on the load after a sign-in this is already the account.
275
+ const { data, guest, staging } = await loadPlayerState();
276
+ let kept: unknown = null;
277
+ try { kept = JSON.parse(localStorage.getItem("progress") ?? "null"); } catch {}
278
+ applyProgress(data ?? kept ?? defaultProgress());
279
+ // An account with no save of its own yet keeps what this device had.
280
+ if (data === null && kept !== null && !guest && !staging) void savePlayerState(kept);
281
+ ```
282
+
283
+ That copy is a cache for this device and this shell, never the save: an
284
+ account that already has a save keeps it, and the copy does not exist on
285
+ another device or on the game's other address. The SDK already tells guests
286
+ to sign in (its popover / the dashboard's card) — don't add another prompt.
194
287
 
195
288
  **Per-player vs world:** `savePlayerState` is each player's own progress;
196
289
  `saveWorldState` is the game's ONE shared world (persistent-world layouts —
@@ -213,8 +306,9 @@ const { items, me } = await getLeaderboard({ limit: 10 });
213
306
 
214
307
  Scores are per-account (one row per player per board, real display names from
215
308
  their Genex account) and keep-best — submitting a worse score changes nothing
216
- (`improved: false`). Guests can READ leaderboards; their submits queue and
217
- post when they sign in. Send a consistent `mode` per board. Scores are
309
+ (`improved: false`). Guests can READ leaderboards; a guest's submit is not
310
+ posted, and signing in loads the game again, so submit when a signed-in
311
+ player finishes a run. Send a consistent `mode` per board. Scores are
218
312
  client-reported (arcade-style trust) — don't present them as anti-cheat.
219
313
 
220
314
  ## NEVER log the tokens
@@ -252,8 +346,19 @@ carries a one-time pass (or an inert guest marker) in the URL that the SDK
252
346
  consumes and removes immediately. Unpublished drafts are the exception:
253
347
  strangers can't play them, so a draft link shows the SDK's sign-in gate
254
348
  instead (for self-testing, see local test mode below). Don't code around any
255
- of this: no `?`/`#` URL params of yours will be affected, and `isEmbedded()`
256
- / the return-trip handling are internal SDK concerns.
349
+ of this: `isEmbedded()` and the return-trip handling are internal SDK
350
+ concerns.
351
+
352
+ Your own `?query` comes through that round trip on every published game. Your
353
+ `#fragment` and the site the visitor came from need `@genex-ai/embed-sdk`
354
+ 0.32.0+, written in `package.json` as a range that starts there (`^0.32.0`)
355
+ when the build was shipped.
356
+ Read all three with `getEntry()`, never from `location` or
357
+ `document.referrer`: until `initEmbed()` has run the address bar still holds
358
+ Genex's own fragment, and `document.referrer` is Genex's address or empty.
359
+ `embed` and every key starting `genex_` are reserved. Sizes, the other
360
+ reserved names and what each shell passes on are in
361
+ [references/shells.md](references/shells.md).
257
362
 
258
363
  ### Self-testing a draft: local test mode
259
364
 
@@ -284,7 +389,10 @@ is ever minted): real sign-in (`waitForAuth()` stays pending, exactly like a
284
389
  guest), saves (they queue in memory), leaderboards (`getLeaderboard()`
285
390
  resolves `{ items: [], me: null }` locally), score submits, and multiplayer
286
391
  (`getColyseusAuth()` is `undefined`, so `connect()` fails at the relay —
287
- expected in this mode, not a bug to chase).
392
+ expected in this mode, not a bug to chase). Nor anything the game's Genex
393
+ page or the app refuses: this is a plain browser tab, so `window.open`, a
394
+ download link and a browser dialog all work here and are dead there
395
+ ([references/shells.md](references/shells.md)).
288
396
 
289
397
  Rules:
290
398
 
@@ -299,8 +407,12 @@ Rules:
299
407
  local test mode cannot open the hosted draft; hosted draft access stays
300
408
  owner-only. Do NOT work around that gate: no undocumented URL fragments,
301
409
  no auth mocks, and never drive the user's own signed-in browser.
302
- - Opening localhost WITHOUT the marker keeps the normal real-auth flow (the
303
- identity bounce) — that's for testing real sign-in, not for self-testing.
410
+ - Opening localhost WITHOUT the marker boots local test mode too on
411
+ `@genex-ai/embed-sdk` 0.14.0+ (an older SDK runs the identity bounce
412
+ there). Keep the marker in every URL you open: it says what you meant and
413
+ works on every SDK since 0.5.0. `?genex_auth=1` opts that dev-server tab
414
+ back into the real identity flow — that's for debugging real sign-in, not
415
+ for self-testing.
304
416
  - The owner's draft run-through stays a required beat, not a fallback: push
305
417
  `genex preview` at each playable milestone and hand it off plainly ("check
306
418
  the draft — you can now X; ping me if something feels off"), then keep
@@ -329,12 +441,21 @@ Rules:
329
441
  overlay and the dashboard own all of that UX.
330
442
  - [ ] Self-test evidence captured in local test mode is labeled as such in the
331
443
  handoff ("local test mode — auth, saves, and multiplayer not exercised").
444
+ - [ ] Every browser or device API the game calls has its row in
445
+ [references/shells.md](references/shells.md): sharing, copying, links
446
+ out, file saves and fullscreen go through the SDK calls, from a click
447
+ or tap handler; the game's own URL parameters come from `getEntry()`.
332
448
 
333
449
  ## Troubleshooting
334
450
 
335
- - **Game loads then immediately navigates away (standalone/local dev)** — the
451
+ - **Game loads then immediately navigates away (the game's own link)** — the
336
452
  identity bounce, working as designed for EVERY standalone visit. It returns
337
- to the game automatically (signed-in or as a guest) within a second.
453
+ to the game automatically (signed-in or as a guest) within a second. On a
454
+ dev server it only happens with `?genex_auth=1` or an SDK older than
455
+ 0.14.0: open with `?genex_local_test=1`.
456
+ - **Works locally or on the game's own link, does nothing on its Genex page
457
+ or in the app** — that shell refuses the call, with no error. Find the row
458
+ in [references/shells.md](references/shells.md) and make the call it names.
338
459
  - **`waitForAuth()` never resolves** — the player is a GUEST; that's the
339
460
  designed behavior. Anything that must run for guests belongs behind
340
461
  `waitForPlayer()` instead.
@@ -362,8 +483,9 @@ Rules:
362
483
  switch to `savePlayerState`/`saveWorldState`, which handle every case.
363
484
  - **Saves/loads resolve `{ staging: true }`** — the build is on the preview
364
485
  channel (the draft workspace, `preview--<slug>`), where server saves are off
365
- by design. Not an outage: keep the localStorage fallback and tell the player
366
- "Preview build — progress isn't saved to your account", never "offline".
486
+ by design. Not an outage: keep the local copy (a cache for this device and
487
+ this shell, never the save) and tell the player "Preview build — progress
488
+ isn't saved to your account", never "offline".
367
489
  - **Saves or score submits return 429** — writing too often (per-player limits:
368
490
  60 player-saves/min, 120 world-saves/min, 30 score submits/min). Debounce to
369
491
  ~1 write/sec at checkpoints and never save in the render loop; back off a
@@ -0,0 +1,207 @@
1
+ # Genex shells: what a game can do where, and what to call instead
2
+
3
+ One build of a Genex game runs in four places, and the browser gives a page
4
+ different powers in each. A call that works in one is refused, dropped or wrong
5
+ in another, usually with no error at all. This page is the one place that says
6
+ which. **Find the row before you write a browser or device API**: `navigator.*`,
7
+ `window.open`, a download link, a dialog, anything that reads the URL.
8
+
9
+ | `getShell()` | Where the game is | What that is |
10
+ | --- | --- | --- |
11
+ | `"page"` | its page on Genex (and the owner's draft workspace) | a sandboxed frame inside a Genex page. **Most players are here**: it is the link you hand out. Any frame reads as `"page"`, whoever frames it: inside a page that is not Genex's, `openExternal()` and `saveFile()` answer `unsupported` and the "On its Genex page" column does not describe it. |
12
+ | `"app"` | the Genex mobile app | one web view that shows the game and never leaves it. A game opened as the whole page of any React Native web view reads as `"app"`. |
13
+ | `"standalone"` | its own link: the game's address, a preview address, a creator's own domain | an ordinary browser tab |
14
+ | `"local"` | a dev server in local test mode | an ordinary browser tab on `localhost` |
15
+
16
+ **Local test mode behaves like the game's own link, never like its Genex
17
+ page.** Nothing you run locally is inside the frame, so a call that passes your
18
+ own test can still be dead for players. `npx genex preview` reads the game's
19
+ source and warns about the calls it recognises; this page covers the rest.
20
+
21
+ The SDK calls below are `@genex-ai/embed-sdk` **0.32.0+**
22
+ (`npm i @genex-ai/embed-sdk@latest` if an import is missing). Three rules hold
23
+ for all of them:
24
+
25
+ - **Call them from a click or tap handler, before any `await`.** A browser lets
26
+ a page share, copy, open a tab or go fullscreen only while a click is being
27
+ handled, and one click buys one action.
28
+ - **They resolve; they never throw.** `share()` resolves `{ status, url }`, the
29
+ others `{ ok: true }` or `{ ok: false, error }`, and `haptic()` returns
30
+ nothing. The `error` words are listed under "What a call answers".
31
+ - **`supports(name)` says whether to draw the button at all**: `"share"`,
32
+ `"copy"`, `"awake"`, `"haptic"`, `"open"`, `"save"`, `"fullscreen"`. On the
33
+ Genex page ask after `waitForPlayer()`. It is a hint; the call's own answer
34
+ is the truth.
35
+
36
+ A game that cannot carry the SDK still lives by the three shell columns: they
37
+ say what the raw browser API does there. Where the last column is an SDK call
38
+ and there is no raw way, the fallback is always the same one: show the text,
39
+ the link or the code on screen.
40
+
41
+ ## Works for every game — use the browser API
42
+
43
+ | The game wants | On its Genex page | In the Genex app | On its own link | Do this |
44
+ | --- | --- | --- | --- | --- |
45
+ | Pointer lock: mouse look, a hidden cursor | yes, from a click inside the game; Escape always releases it | a touch screen: nothing to lock | yes | `canvas.requestPointerLock()` in the click handler, or the bundled `FollowCamera`, which owns the lock. Pause when the lock is lost (`pointerlockchange`): Escape ends it, and so does the SDK before it asks Genex for a link, a file, a purchase or a model-call approval, because the player needs the cursor for that sheet. |
46
+ | A gamepad | yes | not checked: keep the touch controls | yes | `navigator.getGamepads()` each frame. A pad shows up after its first button press. |
47
+ | Tilt and motion: `deviceorientation`, `devicemotion` | passed to every game, but the browser has the last word: an iPhone asks the player, and only from a tap inside the game, and a browser may send a frame no sensor events at all | no Genex gate; the same rules | the same rules | Ask from the Start tap, treat a refusal or silence as "no sensor", keep the on-screen controls: the tilt recipe in `$genex-threejs-touch-controls`. |
48
+ | WebXR | passed to every game; not tried on a headset | do not rely on it | the browser's own | `navigator.xr`. A refused session is an ordinary answer. |
49
+ | A `<form>`: chat box, name entry, Enter to send | yes, `submit` fires | yes | yes | Handle `submit` and call `preventDefault()`. A form that is allowed to navigate replaces the game. |
50
+ | One orientation | the page does not refuse `screen.orientation.lock()`; whether a lock takes is the browser's and the device's | the app lets the device rotate freely while a game is open | the same as the page | Never depend on a lock. Design for one orientation and show the rotate overlay (`$genex-threejs-touch-controls`). |
51
+ | Sound | the page lets a game start sound without a tap of its own once the visitor has clicked anywhere on Genex; not every browser honours that | media may play inline, without a second tap | no click has happened yet when the game opens | Start audio on the first tap or key inside the game, in every shell. Muted inline video (`muted`, `playsInline`) may start by itself. |
52
+ | The keyboard | keys reach the game only while its frame has focus. The page gives it focus when the game starts and when a Genex sheet closes, unless the visitor is using something else on the page; a click in the game always does | no hardware keyboard to count on | yes | Start on a click or tap as well as a key: listen for `pointerdown` and `keydown`, and write "Click or press any key". Key labels for the player's layout (`navigator.keyboard.getLayoutMap()`) are passed too, where the browser has them. |
53
+ | The player picking a file: `<input type="file">` | yes | do not rely on it: Android refuses the picker | yes | The player chooses; the game reads what it is handed. |
54
+ | Device storage: `localStorage`, IndexedDB | works, in a store of its own: not the one the same game has on its own link | another store again, gone when the app is removed | one store per address: the live game, a preview and a creator's domain each have their own | Settings only (volume, quality, key binds). Progress goes through `savePlayerState()`; a local copy is a cache for this device and this shell, never the save. |
55
+
56
+ ## Needs a declaration — microphone, camera, location
57
+
58
+ | The game wants | On its Genex page | In the Genex app | On its own link | Do this |
59
+ | --- | --- | --- | --- | --- |
60
+ | The microphone: voice chat, `getUserMedia({ audio })`, speech recognition | only a build that declared `microphone`. An undeclared build is refused at once and the player is never asked | refused for every game, declared or not | the browser's own permission prompt | Declare it (below). Voice chat is `room.voice.join()` in `$genex-threejs-multiplayer`. Hide the button when `getShell() === "app"`. |
61
+ | The camera: `getUserMedia({ video })` | only a build that declared `camera` | refused for every game | the browser's own permission prompt | Declare it. The game must play without it. |
62
+ | The player's location: `navigator.geolocation` | only a build that declared `geolocation` | refused for every game | the browser's own permission prompt | Declare it. Offer a way to play without it, such as picking a place. |
63
+
64
+ **The declaration is made when the build ships, never at run time.**
65
+ `npx genex preview` and `npx genex publish` read the game's own source and
66
+ declare what they find: `.voice.join()` called with nothing or with an options
67
+ object written at the call, `SpeechRecognition` or `getUserMedia` with `audio`
68
+ for the microphone (the voice pack from `npx genex controller voice` declares
69
+ it by itself); `facingMode` or `getUserMedia` with `video` for the camera;
70
+ `navigator.geolocation` for location. A call the scan cannot see
71
+ (`voice.join(opts)` with the options in a variable, `getUserMedia(constraints)`)
72
+ is declared by hand, in the game's `package.json`:
73
+
74
+ ```jsonc
75
+ "genex": {
76
+ "capabilities": ["microphone", "camera", "geolocation"] // only the ones the game uses
77
+ }
78
+ ```
79
+
80
+ The names are exactly those three. A misspelt name or a value that is not a
81
+ list declares nothing, and the command says so. Both commands print the result
82
+ before they upload (`On its Genex page this build is given: microphone.`), so
83
+ a missing declaration shows before a player meets it. The declaration belongs
84
+ to the build: preview or publish again after changing it.
85
+
86
+ A declaration lets the Genex page pass the request on to the browser. What the
87
+ player is asked, and when, is the browser's business, and the answer can be no
88
+ in every shell: the player refuses, the app always does, and Genex can withdraw
89
+ a capability from one game. **So every one of these needs a path that works
90
+ without it.**
91
+
92
+ ## Goes through an SDK call
93
+
94
+ The raw API is in the shell columns; the call that is right in all of them is
95
+ in the last one.
96
+
97
+ | The game wants | On its Genex page | In the Genex app | On its own link | Call this |
98
+ | --- | --- | --- | --- | --- |
99
+ | To share itself, or an invite | `navigator.share` is passed to every game, but `location.href` there is the frame's bare address, never the page to send people to | a share sheet only where the app's web view has one | `navigator.share` after a tap, where the browser has a sheet | `share({ text, params })`. It builds the link itself, never from the current query or fragment: the game's page on Genex where Genex serves the live build or frames the game, otherwise the address the game is on (a preview address, a creator's own domain, a dev server), plus your `params`. It opens the sheet where there is one, copies the link where there is not (`copied`), and hands the link back as `unavailable` when neither works: show it. |
100
+ | To copy text: a room code, a seed | `navigator.clipboard.writeText` is passed to every game, after a tap. Reading the clipboard is not | depends on the app's web view | yes, after a tap | `copyText(text)`: the clipboard, then the older selection copy. To take text IN, let the player paste into an `<input>`; the `paste` event needs no permission. |
101
+ | The screen to stay on: tilt play, a cutscene | `navigator.wakeLock` is passed to every game; the browser drops the lock whenever the tab is hidden | the screen already stays on while a game is open | yes, with the same drop | `keepAwake(true)`, later `keepAwake(false)`. It takes the lock again when the player returns, and resolves `{ ok: true }` in the app. |
102
+ | A vibration | `navigator.vibrate` where the browser has it: Android browsers, after the first tap. An iPhone has none | nothing | the same as the page | `haptic(kind)`: `"selection"`, `"light"`, `"medium"`, `"heavy"`, `"success"`, `"warning"`, `"error"`. Never the only signal that something happened. |
103
+ | To open a link: Discord, a creator's site, credits | `window.open()` returns `null` and a `target="_blank"` click does nothing. A plain link to another site loads that site inside the frame, replacing the game | blocked; the app shows "An external link was blocked." | a new tab, from a click | `openExternal(url)`: an absolute `https://` address of at most 2048 characters. On the Genex page Genex shows the player where the link goes and opens it on their yes; `dismissed` is their no. In the app it answers `unsupported`: show the address as text. |
104
+ | To save a file: a screenshot, a replay, an export | a download started inside the game is dropped, with no error | not available | a download link works | `saveFile(blob, name)`: PNG, JPEG, WebP, WebM, MP4, JSON or plain text, up to 50 MB. `name` is a file name of 1 to 128 characters, never a path (no `/` or `\`); the saved extension follows the Blob's type. On the Genex page Genex asks the player first. In the app it answers `unsupported`. |
105
+ | Fullscreen | the game's own `requestFullscreen()` covers the frame only, without the page's controls, and does nothing on an iPhone, whose browser has no fullscreen for a page element | nothing to toggle: the game already has the whole screen | the browser's fullscreen | `setFullscreen(true)` / `setFullscreen(false)`. On the Genex page it asks for the PAGE's fullscreen, the one the page's own button gives. Entering needs the player's click, tap or key press inside the game, and a click on the page around the game in the five seconds before does not count; leaving does not. Nothing stays fullscreen while the page shows the player a sheet (a link, a file, a purchase): asked in the same tick as one, fullscreen is left again and answers `busy`. In the app it answers `unsupported`. |
106
+ | Its own URL parameters, and where the visitor came from | the page passes its own link on: `location.search` holds Genex's keys and then your pairs, `location.hash` is your fragment. `document.referrer` is Genex | the link the app was opened with; no referrer | your query survives the sign-in round trip. Until `initEmbed()` runs, `location.hash` holds Genex's sign-in fragment, and yours is put back with no `hashchange`. `document.referrer` is Genex | `getEntry()` → `{ search, params, hash, referrer }`: the same answer in every shell, before and after `initEmbed()`. Details under "The link a game was opened with". |
107
+ | A last save as the game closes | `pagehide` fires when the tab closes or reloads. A "Leave site?" prompt is never shown | the app tells the game before it closes it | `pagehide` fires | `onShellClosing(cb)`. The last chance to save, not the only one: save as the game goes, and never rely on `beforeunload`. |
108
+ | To sell an item | Genex draws the confirmation above the game and leaves fullscreen first; the SDK lets go of the pointer lock before it asks | no purchase surface yet: `buy()` resolves `failed` | a Genex popup, so `buy()` must start in the click | `buy()`, in `$genex-monetization`. Hide the buy buttons when `getShell() === "app"`. |
109
+ | A model call during play | Genex draws the approval above the game | no approval surface yet: the call resolves `failed` with `native_unsupported` | a Genex popup, so the call must start in the click | `generate()` and its family, in `$genex-llm-in-games`. Hide the entry point when `getShell() === "app"`. |
110
+
111
+ ## Never
112
+
113
+ Nothing here has a call that makes it work. Each row names what to build
114
+ instead.
115
+
116
+ | Do not use | On its Genex page | In the Genex app | On its own link | Instead |
117
+ | --- | --- | --- | --- | --- |
118
+ | `alert()`, `confirm()`, `prompt()`, `print()` | nothing is shown: `alert()` returns at once, `confirm()` answers `false`, `prompt()` answers `null`, with no error | a blocking system dialog | a blocking system dialog | Draw the dialog in the game's own interface (`$genex-threejs-game-ui`). |
119
+ | Navigating away: `top.location`, `target="_top"`, a link or form that leaves the game | the page around the game cannot be navigated; a link inside the frame replaces the game | the app refuses to leave the game's address | the player has simply left the game | Keep every screen inside the game. Links out are `openExternal()`. |
120
+ | Reading the clipboard | not passed to any game | do not rely on it | a browser permission prompt | An `<input>` and the `paste` event. |
121
+ | Keyboard Lock: `navigator.keyboard.lock()` | not available: browsers give it to the top-level page only, so Escape leaves fullscreen and frees the cursor | no keyboard to lock | Chromium browsers, in fullscreen | Pause when pointer lock is lost. The lock is an extra for the game's own link (`$genex-threejs-game-ui`). |
122
+ | Notifications, web push | not available to a frame | not available | would prompt under the game's own address | Nothing. Do not ask. |
123
+ | A service worker, an installable web app | it registers, and one that caches then keeps serving its copy: an older build after an update, a game that has since been taken down | do not rely on it | the same as the page | Never register one. Genex serves the game and updates it. |
124
+ | `SharedArrayBuffer`, threaded WebAssembly | not available | not available | not available | Single-threaded builds. |
125
+ | MIDI, Bluetooth, USB, serial, HID, screen capture, browser payments, passkeys, sign-in popups for other services | not passed to any game | not available | behind the browser's own prompts | Do not build on them. Sign-in and money are Genex's: `waitForPlayer()` and `buy()`. |
126
+ | `history.pushState()` for in-game screens | its entries join the tab's history, so Back walks the game's screens before it leaves the page | Back belongs to the app | Back walks the game's screens first | Keep screens in memory, or use `replaceState`. |
127
+ | `document.title`, a favicon | never shown: the tab is the Genex page's | never shown | shown | Nothing to do. |
128
+
129
+ Also not for games: reaching `localhost` or devices on the player's network,
130
+ and the browser's built-in AI models. And a hidden game gets no frames: pause
131
+ on `visibilitychange`, never assume `requestAnimationFrame` keeps running.
132
+
133
+ ## What a call answers
134
+
135
+ `{ ok: false, error }` from `copyText()`, `keepAwake()`, `openExternal()`,
136
+ `saveFile()` and `setFullscreen()`:
137
+
138
+ | `error` | It means | What to do |
139
+ | --- | --- | --- |
140
+ | `unsupported` | this shell or this browser cannot do it | hide the button, or show the link or the data another way |
141
+ | `needs_gesture` | the browser wants a click or a tap behind the call. On the Genex page `setFullscreen(true)` also answers it when no click, tap or key press of the player's INSIDE the game stands behind it, which includes the five seconds after they clicked or typed on the page around the game | call it straight from the handler, before any `await`, and keep the button up: the player's next click in the game works |
142
+ | `dismissed` | the player said no | nothing; it is not an error |
143
+ | `busy` | another request is still waiting for the player's answer, or a `setFullscreen(true)` the browser has not answered yet | ignore the extra click |
144
+ | `rate_limited` | the game asked too often | never call these on a timer |
145
+ | `invalid_request` | the arguments are outside the bounds in the call's row | fix the call |
146
+ | `too_large` | the file is over the size limit | save something smaller |
147
+ | `timeout` | Genex did not answer in time | treat it like `unsupported` for this click |
148
+
149
+ `unsupported` and `timeout` are ordinary answers, not bugs. Genex is updated
150
+ separately from a game, so a Genex page that does not take these requests yet
151
+ answers `unsupported` to `openExternal()` and `saveFile()` without asking
152
+ anyone, and `setFullscreen()` fullscreens the frame's own document there.
153
+
154
+ ## The link a game was opened with
155
+
156
+ A link to a game can carry the game's own parameters: an invite
157
+ (`?lobby=abc`), a seed, a route (`#level-3`). Read them with `getEntry()`,
158
+ never from `location` or `document.referrer`:
159
+
160
+ ```ts
161
+ import { getEntry } from "@genex-ai/embed-sdk";
162
+
163
+ const entry = getEntry(); // safe before initEmbed()
164
+ const lobby = entry.params.get("lobby"); // ?lobby=abc -> "abc"
165
+ const route = entry.hash; // #level-3 -> "#level-3"
166
+ const from = entry.referrer; // "https://example.com", or null
167
+ ```
168
+
169
+ - **The query** reaches every published game, in every shell, with no
170
+ republish: it is in `location.search`, beside Genex's own keys. `getEntry()`
171
+ returns just your pairs, in order and byte for byte (`?film` stays `?film`,
172
+ `%20` stays `%20`). Up to 1024 characters, not counting the `?`; a longer
173
+ query is dropped whole, never cut.
174
+ - **The fragment and the referrer** need the build shipped on
175
+ `@genex-ai/embed-sdk` 0.32.0+. On the game's own link Genex hands them back
176
+ from the sign-in round trip only to a build that recorded that: it reads
177
+ the range in the game's own `package.json` as `npx genex preview` /
178
+ `npx genex publish` stored it for the build. `^0.32.0` counts; `^0.31.0`
179
+ does not, even when 0.32 is what got installed; `workspace:*` and `latest`
180
+ do not. The fragment carries up to 4096 characters. The referrer is the
181
+ ORIGIN of the site the visitor came from, never a path, and `null` when
182
+ there is none or when they came from Genex itself. It is kept for the tab,
183
+ so a reload answers the same; where the browser keeps no storage for the
184
+ game, a reload no longer names that site. Any link can claim any origin:
185
+ use it for attribution, never to grant a reward.
186
+ - **Reserved names.** `embed` and every key starting `genex_` belong to Genex,
187
+ in the query and in the fragment; a fragment that contains `genex_` is not
188
+ carried at all. On a link to the game's Genex page and in the Genex app
189
+ `ref`, `s`, `from`, `remix` and `slug` are Genex's too, and on the Genex
190
+ page so is the fragment `#credits`. Pick other names. `share({ params })`
191
+ leaves the reserved ones out and warns once in the console.
192
+ - **In the app** a query or a fragment holding a raw `@`, or a character that
193
+ is not plain URL text, is dropped, and the two together are capped at 1024
194
+ characters. Keep invite values to letters, digits, `-` and `_`.
195
+ - **A value a shell did not pass is `""` or `null`, never an error.** Keep the
196
+ second way in: an invite link next to a code the player can type.
197
+ - **A value from a link is text anyone can write.** Check its shape before you
198
+ use it.
199
+
200
+ ## The frame, exactly
201
+
202
+ What the Genex page gives the frame a game runs in, by its attribute names:
203
+
204
+ - Sandbox: `allow-scripts` `allow-same-origin` `allow-pointer-lock` `allow-orientation-lock` `allow-forms`
205
+ - Every game, for its own address only: `fullscreen` `gamepad` `xr-spatial-tracking` `accelerometer` `gyroscope` `magnetometer` `web-share` `clipboard-write` `screen-wake-lock` `keyboard-map` `autoplay`
206
+ - Only a build that declared it: `microphone` `camera` `geolocation`
207
+ - No game: `clipboard-read` `display-capture` `midi` `payment` `publickey-credentials-get` `usb` `hid` `serial` `bluetooth` `allow-popups` `allow-top-navigation` `allow-modals` `allow-downloads`
@@ -214,21 +214,25 @@ pointer bucket in `$genex-threejs-camera-direction`), pressing Escape does two
214
214
  **browser-reserved** things you CANNOT stop with `preventDefault`: it exits
215
215
  fullscreen and releases pointer lock, and the player sees the window "shrink".
216
216
  The real fix is the **Keyboard Lock API**, which routes Escape to your code
217
- instead — Chromium only, and only in fullscreen. Enter fullscreen on a gesture
218
- (the Deploy/Resume click), capture Escape, and own the pause:
217
+ instead — Chromium only, only in fullscreen, and only on the game's own link.
218
+ Enter fullscreen on a gesture (the Deploy/Resume click), capture Escape where
219
+ the browser lets you, and own the pause:
219
220
 
220
221
  ```ts
222
+ import { setFullscreen } from "@genex-ai/embed-sdk"; // 0.32.0+
223
+
221
224
  // call ONLY from a user gesture (the Deploy click, the Resume click) — never at boot
222
225
  async function enterImmersive(): Promise<void> {
223
- try {
224
- if (!document.fullscreenElement)
225
- await document.documentElement.requestFullscreen({ navigationUI: "hide" });
226
- } catch { /* sandboxed iframe or no gesture — the pointer-lock path below still pauses */ }
227
- // Chromium + fullscreen only: deliver Escape to us instead of exiting fullscreen.
226
+ // FIRST, before any await: the click is what lets fullscreen happen. It
227
+ // resolves { ok: false, error } instead of throwing — no gesture, a shell
228
+ // with no fullscreen — and the pointer-lock path below still pauses.
229
+ await setFullscreen(true);
230
+ // Chromium + fullscreen + the game's own link only: deliver Escape to us
231
+ // instead of exiting fullscreen. Everywhere else this rejects or is absent.
228
232
  try {
229
233
  await (navigator as unknown as { keyboard?: { lock?: (k: string[]) => Promise<void> } })
230
234
  .keyboard?.lock?.(["Escape"]);
231
- } catch { /* Keyboard Lock unsupported — fine */ }
235
+ } catch { /* no Keyboard Lock here — fine */ }
232
236
  }
233
237
 
234
238
  document.addEventListener("keydown", (e) => {
@@ -263,10 +267,13 @@ document.addEventListener("keydown", (e) => {
263
267
  it, Esc frees the cursor while the game keeps running. Keyboard-only games may
264
268
  still skip Keyboard Lock + fullscreen; the lock + pause/resume path is the
265
269
  non-negotiable part.
266
- - **Dashboard embed:** no setup needed — the platform's game frame grants keyboard
267
- lock (and pointer lock + fullscreen), so Escape-to-pause works the same inside
268
- the game's page (`<dashboard>/<slug>`, `/draft/<slug>`) as it does standalone
269
- (`<slug>.genex.technology`).
270
+ - **On the game's Genex page there is no Keyboard Lock.** Browsers give it to
271
+ the top-level page only, and there the game is in a frame — so Escape leaves
272
+ fullscreen and frees the cursor, and the `pointerlockchange` → pause path
273
+ above is what pauses. That is the path most players are on; the lock is an
274
+ extra for the game's own link. `setFullscreen(true)` is the call for every
275
+ shell: what it does in each is in `$genex-threejs-embed-auth`
276
+ (`references/shells.md`).
270
277
 
271
278
  A mouse-aim game earns its keep with a **look-sensitivity slider** in the pause
272
279
  menu — trackpads feel slower than mice, so let the player tune it. On the bundled
@@ -296,8 +303,9 @@ canvas.requestPointerLock?.(); // hides the cursor, focuses the game
296
303
  // more — no reticle, no aim code; the lock just parks the cursor.
297
304
  ```
298
305
 
299
- If the lock is genuinely unavailable (a third-party embed without
300
- `allow="pointer-lock"`), fall back to hiding the idle cursor over the canvas:
306
+ If the lock is genuinely unavailable (a sandboxed embed without the
307
+ `allow-pointer-lock` sandbox token), fall back to hiding the idle cursor over
308
+ the canvas:
301
309
  `canvas.style.cursor = "none"` after ~2s without `pointermove`, restored
302
310
  instantly on move. Menus and pause screens always keep their cursor.
303
311