@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.
- package/dist/{blender-mcp-QVEGHE5L.js → blender-mcp-WSURPZET.js} +2 -2
- package/dist/{blender-serve-66OHC4ML.js → blender-serve-EH2MOW3U.js} +1 -1
- package/dist/{chunk-4BBTPRJW.js → chunk-EABIVAGT.js} +2 -2
- package/dist/{chunk-QI7FIYBY.js → chunk-K4EHXOZK.js} +1 -1
- package/dist/index.js +1301 -576
- package/package.json +1 -1
- package/templates/controllers/character/aim-cue.ts +2 -1
- package/templates/controllers/character/follow-camera.ts +4 -3
- package/templates/skills/genex-ai-video/SKILL.md +8 -3
- package/templates/skills/genex-game-director/SKILL.md +2 -1
- package/templates/skills/genex-getting-started/SKILL.md +2 -2
- package/templates/skills/genex-llm-in-games/SKILL.md +219 -73
- package/templates/skills/genex-llm-in-games/references/pricing.md +126 -85
- package/templates/skills/genex-monetization/SKILL.md +26 -0
- package/templates/skills/genex-threejs-camera-direction/SKILL.md +3 -3
- package/templates/skills/genex-threejs-character-controller/references/wiring.md +2 -2
- package/templates/skills/genex-threejs-embed-auth/SKILL.md +158 -36
- package/templates/skills/genex-threejs-embed-auth/references/shells.md +207 -0
- package/templates/skills/genex-threejs-game-ui/SKILL.md +22 -14
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +46 -8
- package/templates/skills/genex-threejs-touch-controls/SKILL.md +63 -1
- package/templates/skills/genex-tool-llm/SKILL.md +11 -9
- package/templates/skills/genex-updates/SKILL.md +1 -1
|
@@ -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
|
|
22
|
-
|
|
23
|
-
zero clicks; everyone else arrives as a guest with a small
|
|
24
|
-
"sign in to save progress" popover (rendered by the SDK — don't
|
|
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
|
|
90
|
-
//
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
127
|
-
|
|
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
|
|
139
|
-
|
|
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).
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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;
|
|
217
|
-
|
|
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:
|
|
256
|
-
|
|
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
|
|
303
|
-
|
|
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 (
|
|
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
|
|
366
|
-
|
|
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,
|
|
218
|
-
(the Deploy/Resume click), capture Escape
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
// Chromium + fullscreen only: deliver Escape to us
|
|
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
|
|
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
|
-
- **
|
|
267
|
-
|
|
268
|
-
the
|
|
269
|
-
|
|
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
|
|
300
|
-
`allow
|
|
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
|
|