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

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.
@@ -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
 
@@ -401,13 +401,39 @@ and friends join it; the lobby is persistent (rounds replay, nobody is evicted):
401
401
 
402
402
  ```ts
403
403
  import { createPrivate, joinPrivate } from "@genex-ai/multiplayer";
404
- const lobby = await createPrivate<MyState>({ urls, room: slug, auth: () => getColyseusAuth() }); // live NOW
405
- showCode(lobby.code); // share this
406
- // a friend, elsewhere:
407
- const lobby = await joinPrivate<MyState>(code, { urls, room: slug, auth: () => getColyseusAuth() });
404
+ import { share, getEntry } from "@genex-ai/embed-sdk"; // 0.32.0+
405
+ const opts = { urls, room: slug, auth: () => getColyseusAuth() };
406
+
407
+ // host
408
+ const lobby = await createPrivate<MyState>(opts); // live NOW
409
+ showCode(lobby.code); // always show the code too: the way in when a link will not do
410
+ inviteBtn.onclick = async () => { // share() starts inside the click, before any await
411
+ const { status, url } = await share({ text: "Join my lobby", params: { lobby: lobby.code } });
412
+ if (status === "copied") toast("Invite link copied");
413
+ if (status === "unavailable") showLink(url); // no share sheet, no clipboard: show the link
414
+ };
415
+
416
+ // friend: ONE way in, for the code from a link and for a code typed by hand
417
+ async function enter(code: string) {
418
+ if (!/^[\w-]{1,32}$/.test(code)) return null; // a link is text anyone can write: take a code, nothing else
419
+ try { return await joinPrivate<MyState>(code, opts); }
420
+ catch { showNotice("That lobby is no longer open"); return null; }
421
+ }
422
+ // at boot, after waitForPlayer(): did they arrive on an invite link?
423
+ const invited = getEntry().params.get("lobby");
424
+ const joined = invited ? await enter(invited) : null; // null → the menu, with its "Enter code" field
408
425
  // same handle API as matchmake(): lobby.session, lobby.matchmaking, eliminated()/score()/finish(), cancel()
409
426
  ```
410
427
 
428
+ `share()` builds the link itself — the game's page on Genex plus `?lobby=<code>`, or the
429
+ address the game is on where that is a preview address or a creator's own domain — and
430
+ `getEntry()` hands the parameter back to whoever opens it. **Never build the invite from
431
+ `location.href`**: on the game's page that is the frame's bare address, not the page a friend
432
+ should land on. `lobby` is this example's name; any name works except the ones Genex keeps
433
+ (`$genex-threejs-embed-auth`, `references/shells.md`). `joinPrivate` REJECTS when the code leads
434
+ to no lobby this player can reach — it closed, or its host is on another region's relay — so
435
+ catch it and show the menu, and keep the typed code as the second way in.
436
+
411
437
  ## Connect
412
438
 
413
439
  Pick your own per-player state shape (any JSON). `room` is the **project slug**
@@ -674,10 +700,22 @@ fights (many writers). A ball on `objects` glides and has one owner. That's the
674
700
  mesh — everyone uploads to everyone, and reliability, not bandwidth, breaks first). The
675
701
  room still holds 64 players; the 7th `join()` is refused, so handle `voice:rejected`
676
702
  instead of retrying. `join()` MUST run inside a click/tap — browsers gate both the mic
677
- prompt and audio playback on a user gesture — and it REJECTS when the player denies the
678
- mic, which is an ordinary outcome to show, not an error to swallow. For a 3D game use
679
- `npx genex controller voice` (proximity audio + push-to-talk); it joins with
680
- `{ spatial: true }` and drives a `THREE.PositionalAudio` per speaker. **TURN is automatic
703
+ prompt and audio playback on a user gesture — and it REJECTS when the microphone is
704
+ refused, which is an ordinary outcome to show, not an error to swallow. **Two refusals are
705
+ not the player's.** On its Genex page a game gets the microphone only when its BUILD
706
+ declared it: `npx genex preview` / `npx genex publish` declare it when the game's own
707
+ source calls `.voice.join()` with nothing or with options written at the call, and print
708
+ what the build is given — read that line. `voice.join(opts)` with the options in a
709
+ variable is not seen: declare the microphone in `package.json` then. When the
710
+ page withheld it, `join()` rejects with an error whose `name` is `VoiceNotDelegatedError`
711
+ (SDK 0.16.2+, Chromium browsers; elsewhere it arrives as the browser's own error): show
712
+ "Voice chat is not available here", never "allow your microphone" — the player was never
713
+ asked. And the Genex app refuses the microphone for every game: hide the voice button when
714
+ `getShell()` from `@genex-ai/embed-sdk` is `"app"`. The declaration rule, and the
715
+ `package.json` line for a call the CLI cannot see, are in `$genex-threejs-embed-auth`
716
+ (`references/shells.md`).
717
+ For a 3D game use `npx genex controller voice` (proximity audio + push-to-talk); it joins
718
+ with `{ spatial: true }` and drives a `THREE.PositionalAudio` per speaker. **TURN is automatic
681
719
  — do not pass `iceServers`**: `join()` fetches short-lived relay credentials from the
682
720
  platform before opening any peer connection, which is what lets the ~15% of players behind
683
721
  symmetric NAT or carrier CGNAT be heard at all (in a mesh they fail every leg, so they
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: genex-threejs-touch-controls
3
- description: Make a Genex Three.js game playable on phones — vendored touch primitives via `npx genex controller touch` (floating/static joystick, virtual buttons, drag zone, rotate-device overlay) plus per-genre wiring recipes (movement, camera look, taps, paddles, action buttons, touch pause). Use when wiring default touch controls into a new game, whenever someone asks to make a game playable on mobile/phone/tablet, or before shipping a game whose input is keyboard/mouse-only.
3
+ description: Make a Genex Three.js game playable on phones — vendored touch primitives via `npx genex controller touch` (floating/static joystick, virtual buttons, drag zone, rotate-device overlay) plus per-genre wiring recipes (movement, camera look, taps, paddles, action buttons, touch pause, tilt). Use when wiring default touch controls into a new game, whenever someone asks to make a game playable on mobile/phone/tablet, or before shipping a game whose input is keyboard/mouse-only.
4
4
  ---
5
5
 
6
6
  # Genex Three.js Touch Controls
@@ -116,6 +116,68 @@ If the game is merely suboptimal sideways, skip the overlay and just resize.
116
116
  The web cannot force an orientation (fullscreen-only lock, none on iOS Safari)
117
117
  — the overlay asks; it never traps.
118
118
 
119
+ ## Tilt controls
120
+
121
+ Steering by tilting the phone is an extra on top of the stick, never a
122
+ replacement for it: the sensor is the one input a phone can silently not have.
123
+ Four rules, all in the block below.
124
+
125
+ 1. **Ask inside the Start tap.** An iPhone hands a page no motion events until
126
+ the player allows them, and it asks only when `requestPermission()` is
127
+ called from a tap inside the game. Called at boot it is refused without a
128
+ prompt.
129
+ 2. **A refusal, or silence, is "no sensor".** A browser can answer "granted"
130
+ and then send nothing, and a desktop can send events full of `null`. Trust
131
+ tilt only after a real reading has arrived.
132
+ 3. **Keep the joystick.** It stays on screen and wins whenever it is touched.
133
+ 4. **Hold the screen awake.** Tilt play has no touches, so the phone dims and
134
+ locks mid-game unless the game asks it not to.
135
+
136
+ ```ts
137
+ import { keepAwake } from "@genex-ai/embed-sdk"; // 0.32.0+
138
+
139
+ const tilt = { x: 0, y: 0, live: false }; // x/y in [-1, 1], same sense as joy.x / joy.y
140
+ const TILT_FULL = 25; // degrees of lean for full deflection
141
+ let rest: { x: number; y: number } | null = null; // how the phone was held at the first reading
142
+
143
+ type MotionGate = { requestPermission?: () => Promise<"granted" | "denied"> };
144
+
145
+ // Call from the Start/Play tap handler, before any await in that handler.
146
+ async function enableTilt(): Promise<void> {
147
+ void keepAwake(true); // released with keepAwake(false) on the menu / pause
148
+ const gate = (window as unknown as { DeviceOrientationEvent?: MotionGate }).DeviceOrientationEvent;
149
+ if (!gate) return; // no sensor API: the joystick is the control
150
+ try {
151
+ if (typeof gate.requestPermission === "function" && (await gate.requestPermission()) !== "granted") return;
152
+ } catch { return; } // refused, or not inside a tap
153
+ window.addEventListener("deviceorientation", (e) => {
154
+ if (e.beta == null || e.gamma == null) return; // a desktop fires events with nulls
155
+ // The reading is in the DEVICE's axes; turn it into the screen's.
156
+ const a = screen.orientation?.angle ?? 0;
157
+ const x = a === 90 ? e.beta : a === 270 ? -e.beta : a === 180 ? -e.gamma : e.gamma;
158
+ const y = a === 90 ? -e.gamma : a === 270 ? e.gamma : a === 180 ? -e.beta : e.beta;
159
+ rest ??= { x, y }; // "level" is however the player was holding it
160
+ const unit = (v: number) => Math.max(-1, Math.min(1, v / TILT_FULL));
161
+ tilt.x = unit(x - rest.x);
162
+ tilt.y = -unit(y - rest.y); // leaning the top edge away from you = up, like the stick
163
+ tilt.live = true;
164
+ });
165
+ }
166
+
167
+ // each frame, where the stick is read:
168
+ const steer = joy.x !== 0 ? joy.x : tilt.live ? tilt.x : 0;
169
+ ```
170
+
171
+ Clear `rest` when the device rotates or the player taps a "recentre" button,
172
+ so level is re-taken from the next reading. **Check the directions on a real
173
+ phone, in the orientation the game is played in** — a desktop browser has no
174
+ sensor, so nothing in a local test exercises this. Lean-right must steer
175
+ screen-right (the screen-direction contract in
176
+ `$genex-threejs-camera-direction`); a flipped feel is a sign to fix in the
177
+ mapping above, never a device quirk. What each Genex shell does with the
178
+ sensors and the wake lock is in `$genex-threejs-embed-auth`
179
+ (`references/shells.md`).
180
+
119
181
  ## Match the game's style
120
182
 
121
183
  The touch controls are UI like any other — restyle them from the same style