@genex-ai/cli-demo 1.2.4 → 1.3.0-dev.372

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "1.2.4",
3
+ "version": "1.3.0-dev.372",
4
4
  "description": "Set up your project's agent workspace (.claude/.codex/.cursor in the game folder), authorize, create a game project, generate AI assets, and publish (genex CLI).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,305 @@
1
+ // Room text chat overlay — the DOM half of the Genex chat lane. Zero dependencies,
2
+ // zero framework, no CSS file: it builds its own elements and styles them inline, the
3
+ // same way the touch kit does, so it drops into any game.
4
+ //
5
+ // The transport is `room.chat` from @genex-ai/multiplayer (the relay stamps the sender,
6
+ // sanitizes the text, and replays the recent history on connect). This file only renders
7
+ // it and collects input.
8
+ //
9
+ // THE ONE THING YOU MUST WIRE: while the player is typing, the GAME MUST NOT READ THE
10
+ // KEYBOARD. Otherwise "w" walks, "s" reverses and space jumps while someone types "was
11
+ // space needed?". Either check `chat.isTyping` in your input code, or use the
12
+ // `onTypingChange` callback to pause your input system:
13
+ //
14
+ // const chat = new ChatOverlay({
15
+ // room,
16
+ // onTypingChange: (typing) => { keyboard.enabled = !typing; },
17
+ // });
18
+ //
19
+ // Rendering is `textContent` ONLY, never innerHTML — every line is another player's raw
20
+ // input. The relay strips control and bidi characters, but it does not and cannot escape
21
+ // markup for you.
22
+
23
+ /** The slice of the multiplayer session this overlay needs. Structural on purpose — the
24
+ * vendored file never imports the SDK, so it cannot drift out of sync with its version. */
25
+ export interface ChatOverlayRoom {
26
+ id: string;
27
+ chat: {
28
+ send(text: string): void;
29
+ on(cb: (message: ChatLine) => void): () => void;
30
+ readonly history: ChatLine[];
31
+ };
32
+ }
33
+
34
+ export interface ChatLine {
35
+ id: number;
36
+ from: string;
37
+ name: string;
38
+ text: string;
39
+ at: number;
40
+ }
41
+
42
+ export interface ChatOverlayOptions {
43
+ room: ChatOverlayRoom;
44
+ /** Called when typing starts/stops. Pause the game's keyboard input here. */
45
+ onTypingChange?: (typing: boolean) => void;
46
+ /** Lines kept on screen. Default 8. */
47
+ maxLines?: number;
48
+ /** Milliseconds a line stays visible after the chat closes; 0 keeps them forever. Default 9000. */
49
+ fadeAfterMs?: number;
50
+ /** Key that opens the composer. Default "Enter". */
51
+ openKey?: string;
52
+ /** Mount point. Default document.body. */
53
+ parent?: HTMLElement;
54
+ /** Placeholder text in the composer. */
55
+ placeholder?: string;
56
+ /** Colour used for your own name. Default a soft blue. */
57
+ selfColor?: string;
58
+ }
59
+
60
+ const ROOT_STYLE: Partial<CSSStyleDeclaration> = {
61
+ position: "fixed",
62
+ left: "calc(env(safe-area-inset-left, 0px) + 14px)",
63
+ bottom: "calc(env(safe-area-inset-bottom, 0px) + 14px)",
64
+ zIndex: "9000",
65
+ display: "flex",
66
+ flexDirection: "column",
67
+ alignItems: "flex-start",
68
+ gap: "6px",
69
+ maxWidth: "min(46vw, 420px)",
70
+ fontFamily: "system-ui, sans-serif",
71
+ fontSize: "13px",
72
+ lineHeight: "1.45",
73
+ // The log must never eat clicks meant for the game; the composer re-enables them.
74
+ pointerEvents: "none",
75
+ userSelect: "none",
76
+ };
77
+
78
+ const LOG_STYLE: Partial<CSSStyleDeclaration> = {
79
+ display: "flex",
80
+ flexDirection: "column",
81
+ gap: "3px",
82
+ width: "100%",
83
+ maxHeight: "34vh",
84
+ overflow: "hidden",
85
+ transition: "opacity 260ms ease",
86
+ };
87
+
88
+ const LINE_STYLE: Partial<CSSStyleDeclaration> = {
89
+ margin: "0",
90
+ padding: "3px 8px",
91
+ borderRadius: "6px",
92
+ background: "rgba(6, 9, 18, 0.55)",
93
+ color: "rgba(255, 255, 255, 0.92)",
94
+ textShadow: "0 1px 2px rgba(0, 0, 0, 0.6)",
95
+ wordBreak: "break-word",
96
+ // A single line must not be able to push the game off screen.
97
+ overflowWrap: "anywhere",
98
+ };
99
+
100
+ const INPUT_STYLE: Partial<CSSStyleDeclaration> = {
101
+ display: "none",
102
+ width: "min(46vw, 420px)",
103
+ boxSizing: "border-box",
104
+ padding: "7px 10px",
105
+ border: "1px solid rgba(255, 255, 255, 0.28)",
106
+ borderRadius: "7px",
107
+ background: "rgba(6, 9, 18, 0.88)",
108
+ color: "rgba(255, 255, 255, 0.95)",
109
+ font: "inherit",
110
+ outline: "none",
111
+ pointerEvents: "auto",
112
+ };
113
+
114
+ const HINT_STYLE: Partial<CSSStyleDeclaration> = {
115
+ padding: "3px 8px",
116
+ borderRadius: "6px",
117
+ background: "rgba(6, 9, 18, 0.45)",
118
+ color: "rgba(255, 255, 255, 0.6)",
119
+ fontSize: "12px",
120
+ cursor: "pointer",
121
+ pointerEvents: "auto",
122
+ };
123
+
124
+ export class ChatOverlay {
125
+ readonly root: HTMLDivElement;
126
+ private readonly log: HTMLDivElement;
127
+ private readonly input: HTMLInputElement;
128
+ private readonly hint: HTMLDivElement;
129
+ private readonly opts: Required<Omit<ChatOverlayOptions, "room" | "onTypingChange" | "parent">> & {
130
+ room: ChatOverlayRoom;
131
+ onTypingChange?: (typing: boolean) => void;
132
+ };
133
+ private readonly offChat: () => void;
134
+ private readonly onKeyDown: (e: KeyboardEvent) => void;
135
+ private typing = false;
136
+ private fadeTimer: ReturnType<typeof setTimeout> | null = null;
137
+ private disposed = false;
138
+
139
+ constructor(options: ChatOverlayOptions) {
140
+ const parent = options.parent ?? document.body;
141
+ this.opts = {
142
+ room: options.room,
143
+ onTypingChange: options.onTypingChange,
144
+ maxLines: options.maxLines ?? 8,
145
+ fadeAfterMs: options.fadeAfterMs ?? 9000,
146
+ openKey: options.openKey ?? "Enter",
147
+ placeholder: options.placeholder ?? "Say something…",
148
+ selfColor: options.selfColor ?? "rgb(132, 176, 255)",
149
+ };
150
+
151
+ this.root = document.createElement("div");
152
+ Object.assign(this.root.style, ROOT_STYLE);
153
+
154
+ this.log = document.createElement("div");
155
+ Object.assign(this.log.style, LOG_STYLE);
156
+ this.root.appendChild(this.log);
157
+
158
+ this.hint = document.createElement("div");
159
+ Object.assign(this.hint.style, HINT_STYLE);
160
+ // Touch devices have no Enter key to discover, so they get a tappable affordance and
161
+ // pointer users get the shortcut spelled out.
162
+ this.hint.textContent =
163
+ navigator.maxTouchPoints > 0 ? "💬 Tap to chat" : `Press ${this.opts.openKey} to chat`;
164
+ this.hint.addEventListener("click", () => this.open());
165
+ this.root.appendChild(this.hint);
166
+
167
+ this.input = document.createElement("input");
168
+ this.input.type = "text";
169
+ // The relay clamps at 500 anyway; stopping at the browser avoids typing into a void.
170
+ this.input.maxLength = 500;
171
+ this.input.placeholder = this.opts.placeholder;
172
+ this.input.autocomplete = "off";
173
+ this.input.setAttribute("aria-label", "Chat message");
174
+ Object.assign(this.input.style, INPUT_STYLE);
175
+ this.root.appendChild(this.input);
176
+
177
+ parent.appendChild(this.root);
178
+
179
+ // Existing history first (a late joiner opens into a conversation, not a blank pane),
180
+ // then live lines. `chat.on` also fires for history the client hadn't seen, so this is
181
+ // additive rather than a race.
182
+ for (const line of this.opts.room.chat.history) this.append(line);
183
+ this.offChat = this.opts.room.chat.on((line) => this.append(line));
184
+
185
+ this.onKeyDown = (e: KeyboardEvent) => {
186
+ if (this.disposed) return;
187
+ if (!this.typing) {
188
+ if (e.key !== this.opts.openKey) return;
189
+ // Don't steal the key from another text field the game may own.
190
+ if (isTextEntry(e.target)) return;
191
+ e.preventDefault();
192
+ this.open();
193
+ return;
194
+ }
195
+ // While typing, keys belong to the composer. stopPropagation keeps game listeners
196
+ // bound to window/document from seeing them at all — belt to onTypingChange's braces.
197
+ e.stopPropagation();
198
+ if (e.key === "Escape") {
199
+ e.preventDefault();
200
+ this.close();
201
+ } else if (e.key === "Enter") {
202
+ e.preventDefault();
203
+ this.submit();
204
+ }
205
+ };
206
+ // Capture phase: the game's own key handlers usually bind on window/document in the
207
+ // bubble phase, so intercepting here is what actually keeps "w" out of the movement code.
208
+ window.addEventListener("keydown", this.onKeyDown, true);
209
+
210
+ this.scheduleFade();
211
+ }
212
+
213
+ /** True while the composer has focus — the game must ignore keyboard input meanwhile. */
214
+ get isTyping(): boolean {
215
+ return this.typing;
216
+ }
217
+
218
+ open(): void {
219
+ if (this.disposed || this.typing) return;
220
+ this.typing = true;
221
+ this.input.style.display = "block";
222
+ this.hint.style.display = "none";
223
+ this.log.style.opacity = "1";
224
+ if (this.fadeTimer) {
225
+ clearTimeout(this.fadeTimer);
226
+ this.fadeTimer = null;
227
+ }
228
+ this.input.focus();
229
+ this.opts.onTypingChange?.(true);
230
+ }
231
+
232
+ close(): void {
233
+ if (this.disposed || !this.typing) return;
234
+ this.typing = false;
235
+ this.input.value = "";
236
+ this.input.blur();
237
+ this.input.style.display = "none";
238
+ this.hint.style.display = "block";
239
+ this.scheduleFade();
240
+ this.opts.onTypingChange?.(false);
241
+ }
242
+
243
+ /** Send whatever is in the composer and close it. */
244
+ submit(): void {
245
+ const text = this.input.value;
246
+ // Close first: the line arrives back from the relay, and the game should be taking
247
+ // keyboard input again by the time it renders.
248
+ this.close();
249
+ if (text.trim()) this.opts.room.chat.send(text);
250
+ }
251
+
252
+ dispose(): void {
253
+ if (this.disposed) return;
254
+ this.disposed = true;
255
+ if (this.typing) this.opts.onTypingChange?.(false);
256
+ window.removeEventListener("keydown", this.onKeyDown, true);
257
+ this.offChat();
258
+ if (this.fadeTimer) clearTimeout(this.fadeTimer);
259
+ this.root.remove();
260
+ }
261
+
262
+ private append(line: ChatLine): void {
263
+ const el = document.createElement("p");
264
+ Object.assign(el.style, LINE_STYLE);
265
+
266
+ const who = document.createElement("span");
267
+ who.style.fontWeight = "600";
268
+ if (line.from === this.opts.room.id) who.style.color = this.opts.selfColor;
269
+ // textContent, not innerHTML — both of these are other players' input.
270
+ who.textContent = `${line.name}: `;
271
+
272
+ const body = document.createElement("span");
273
+ body.textContent = line.text;
274
+
275
+ el.appendChild(who);
276
+ el.appendChild(body);
277
+ this.log.appendChild(el);
278
+
279
+ while (this.log.childElementCount > this.opts.maxLines) {
280
+ this.log.firstElementChild?.remove();
281
+ }
282
+ this.log.style.opacity = "1";
283
+ this.scheduleFade();
284
+ }
285
+
286
+ private scheduleFade(): void {
287
+ if (this.fadeTimer) {
288
+ clearTimeout(this.fadeTimer);
289
+ this.fadeTimer = null;
290
+ }
291
+ if (this.typing || this.opts.fadeAfterMs <= 0) return;
292
+ this.fadeTimer = setTimeout(() => {
293
+ this.fadeTimer = null;
294
+ if (!this.typing) this.log.style.opacity = "0";
295
+ }, this.opts.fadeAfterMs);
296
+ }
297
+ }
298
+
299
+ /** Is the event target already a text-entry surface the game owns? */
300
+ function isTextEntry(target: EventTarget | null): boolean {
301
+ if (!(target instanceof HTMLElement)) return false;
302
+ if (target.isContentEditable) return true;
303
+ const tag = target.tagName;
304
+ return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
305
+ }
@@ -0,0 +1,247 @@
1
+ // Proximity voice — the spatial half of the Genex voice lane.
2
+ //
3
+ // The SDK (`room.voice`) owns the microphone, the WebRTC mesh and the roster. It hands you
4
+ // a `MediaStream` per participant and deliberately does NOT play it when you join with
5
+ // `{ spatial: true }`. This file is what turns those streams into voices that come from
6
+ // where the speaker actually is: one THREE.PositionalAudio per participant, moved to that
7
+ // player's position every frame.
8
+ //
9
+ // Why proximity and not a flat room mix: in a 3D game, distance-attenuated voice is the
10
+ // entire point. It also softens the 6-participant cap — with falloff, six audible
11
+ // neighbours in a bigger room reads as a populated world rather than as a hard limit.
12
+ //
13
+ // Wiring (three lines plus your per-frame update):
14
+ //
15
+ // const voice = new ProximityVoice({ room, listener, camera });
16
+ // await voice.join(); // MUST be inside a click/tap handler
17
+ // // per frame: voice.update((id) => remoteBodies.get(id)?.position ?? null);
18
+ //
19
+ // `join()` asks for the microphone. Browsers gate that prompt — and audio playback — on a
20
+ // user gesture, so calling it at boot silently fails. Put it behind a button.
21
+ import * as THREE from "three";
22
+
23
+ /** The slice of the multiplayer session this needs. Structural so the vendored file never
24
+ * imports the SDK and cannot drift out of sync with its version. */
25
+ export interface ProximityVoiceRoom {
26
+ id: string;
27
+ voice: {
28
+ join(options?: {
29
+ iceServers?: RTCIceServer[];
30
+ startMuted?: boolean;
31
+ spatial?: boolean;
32
+ }): Promise<void>;
33
+ leave(): void;
34
+ readonly active: boolean;
35
+ readonly participants: ReadonlyArray<{
36
+ id: string;
37
+ name: string;
38
+ stream: MediaStream | null;
39
+ speaking: boolean;
40
+ muted: boolean;
41
+ }>;
42
+ setMicEnabled(on: boolean): void;
43
+ readonly micEnabled: boolean;
44
+ readonly speaking: boolean;
45
+ mute(playerId: string): void;
46
+ unmute(playerId: string): void;
47
+ };
48
+ on(event: string, cb: (payload: never) => void): () => void;
49
+ }
50
+
51
+ export interface ProximityVoiceOptions {
52
+ room: ProximityVoiceRoom;
53
+ /** Your THREE.AudioListener — normally attached to the camera. */
54
+ listener: THREE.AudioListener;
55
+ /** Scene node the voice sources are parented to. Defaults to the listener's parent chain root. */
56
+ scene?: THREE.Object3D;
57
+ /** Distance (world units) at which a voice starts falling off. Default 6. */
58
+ refDistance?: number;
59
+ /** Beyond this, a voice is effectively inaudible. Default 45. */
60
+ maxDistance?: number;
61
+ /** Falloff steepness. Default 1.4 — a little sharper than linear reads as "nearby". */
62
+ rolloffFactor?: number;
63
+ /** TURN servers. Without one, players behind symmetric NAT never connect (~10–20%). */
64
+ iceServers?: RTCIceServer[];
65
+ /** Push-to-talk key. Default "KeyV"; pass null for open-mic. */
66
+ pushToTalkKey?: string | null;
67
+ }
68
+
69
+ type Voice = {
70
+ id: string;
71
+ audio: THREE.PositionalAudio;
72
+ holder: THREE.Object3D;
73
+ stream: MediaStream;
74
+ };
75
+
76
+ export class ProximityVoice {
77
+ private readonly room: ProximityVoiceRoom;
78
+ private readonly listener: THREE.AudioListener;
79
+ private readonly scene: THREE.Object3D;
80
+ private readonly opts: Required<
81
+ Pick<ProximityVoiceOptions, "refDistance" | "maxDistance" | "rolloffFactor">
82
+ >;
83
+ private readonly iceServers: RTCIceServer[] | undefined;
84
+ private readonly pttKey: string | null;
85
+ private readonly voices = new Map<string, Voice>();
86
+ private readonly offVoice: () => void;
87
+ private onKeyDown: ((e: KeyboardEvent) => void) | null = null;
88
+ private onKeyUp: ((e: KeyboardEvent) => void) | null = null;
89
+ private disposed = false;
90
+
91
+ constructor(options: ProximityVoiceOptions) {
92
+ this.room = options.room;
93
+ this.listener = options.listener;
94
+ this.scene = options.scene ?? topLevelParent(options.listener);
95
+ this.opts = {
96
+ refDistance: options.refDistance ?? 6,
97
+ maxDistance: options.maxDistance ?? 45,
98
+ rolloffFactor: options.rolloffFactor ?? 1.4,
99
+ };
100
+ this.iceServers = options.iceServers;
101
+ this.pttKey = options.pushToTalkKey === undefined ? "KeyV" : options.pushToTalkKey;
102
+ // The SDK re-emits the whole participant list on every change (join, leave, connect,
103
+ // speaking, mute), so reconciling from scratch is both simplest and always correct.
104
+ this.offVoice = this.room.on("voice", () => this.sync());
105
+ // Declares "this game uses voice" to the publish-time bundle scan, which is what earns
106
+ // the game a scoped `allow="microphone …"` on the dashboard frame. A global on purpose:
107
+ // it survives minification, exactly like the quality kit's __GENEX_MODEL_RUNGS__.
108
+ // Rolling your own voice UI instead of this class? Set it yourself, or the embedded
109
+ // build will be denied the microphone by Permissions Policy.
110
+ (window as Window & { __GENEX_VOICE__?: boolean }).__GENEX_VOICE__ = true;
111
+ }
112
+
113
+ /** True while this client is transmitting. */
114
+ get transmitting(): boolean {
115
+ return this.room.voice.active && this.room.voice.micEnabled;
116
+ }
117
+
118
+ get participants(): ProximityVoiceRoom["voice"]["participants"] {
119
+ return this.room.voice.participants;
120
+ }
121
+
122
+ /** Ask for the mic and join. MUST be called from a click/tap handler. Rejects when the
123
+ * player denies permission — show that, don't swallow it. */
124
+ async join(): Promise<void> {
125
+ // spatial: true is what stops the SDK from playing the audio flat; this class plays it.
126
+ await this.room.voice.join({
127
+ spatial: true,
128
+ iceServers: this.iceServers,
129
+ startMuted: this.pttKey !== null,
130
+ });
131
+ if (this.pttKey !== null) this.bindPushToTalk(this.pttKey);
132
+ this.sync();
133
+ }
134
+
135
+ leave(): void {
136
+ this.room.voice.leave();
137
+ for (const id of [...this.voices.keys()]) this.dropVoice(id);
138
+ }
139
+
140
+ /** Per frame: move every voice to its speaker. Return null for a player you cannot place
141
+ * (not spawned yet, culled) — their voice is parked rather than left at a stale spot. */
142
+ update(positionOf: (playerId: string) => THREE.Vector3 | null | undefined): void {
143
+ if (this.disposed) return;
144
+ for (const voice of this.voices.values()) {
145
+ const at = positionOf(voice.id);
146
+ // A voice left at the last known position is worse than a silent one: it sounds like
147
+ // someone is standing where nobody is. Mute rather than lie about where they are.
148
+ const placed = at != null;
149
+ if (placed) voice.holder.position.copy(at);
150
+ if (voice.audio.getVolume() !== (placed ? 1 : 0)) voice.audio.setVolume(placed ? 1 : 0);
151
+ }
152
+ }
153
+
154
+ dispose(): void {
155
+ if (this.disposed) return;
156
+ this.disposed = true;
157
+ this.unbindPushToTalk();
158
+ this.offVoice();
159
+ this.leave();
160
+ }
161
+
162
+ // ---- internals ----
163
+
164
+ private sync(): void {
165
+ if (this.disposed) return;
166
+ const live = new Set<string>();
167
+ for (const p of this.room.voice.participants) {
168
+ if (!p.stream) continue; // still negotiating — nothing to attach yet
169
+ live.add(p.id);
170
+ const existing = this.voices.get(p.id);
171
+ // A reconnect can hand back a NEW stream for the same player; rebuild in that case.
172
+ if (existing && existing.stream !== p.stream) this.dropVoice(p.id);
173
+ if (!this.voices.has(p.id)) this.addVoice(p.id, p.stream);
174
+ const voice = this.voices.get(p.id);
175
+ if (voice) voice.audio.setVolume(p.muted ? 0 : voice.audio.getVolume() || 1);
176
+ }
177
+ for (const id of [...this.voices.keys()]) if (!live.has(id)) this.dropVoice(id);
178
+ }
179
+
180
+ private addVoice(id: string, stream: MediaStream): void {
181
+ const holder = new THREE.Object3D();
182
+ holder.name = `voice:${id}`;
183
+ this.scene.add(holder);
184
+ const audio = new THREE.PositionalAudio(this.listener);
185
+ audio.setRefDistance(this.opts.refDistance);
186
+ audio.setMaxDistance(this.opts.maxDistance);
187
+ audio.setRolloffFactor(this.opts.rolloffFactor);
188
+ audio.setDistanceModel("exponential");
189
+ audio.setMediaStreamSource(stream);
190
+ holder.add(audio);
191
+ this.voices.set(id, { id, audio, holder, stream });
192
+ }
193
+
194
+ private dropVoice(id: string): void {
195
+ const voice = this.voices.get(id);
196
+ if (!voice) return;
197
+ this.voices.delete(id);
198
+ try {
199
+ voice.audio.disconnect();
200
+ } catch {
201
+ /* already disconnected */
202
+ }
203
+ voice.holder.removeFromParent();
204
+ }
205
+
206
+ private bindPushToTalk(code: string): void {
207
+ this.unbindPushToTalk();
208
+ this.onKeyDown = (e: KeyboardEvent) => {
209
+ if (e.code !== code || e.repeat) return;
210
+ // Never grab the key while the player is typing — chat's composer owns it then.
211
+ if (isTextEntry(e.target)) return;
212
+ this.room.voice.setMicEnabled(true);
213
+ };
214
+ this.onKeyUp = (e: KeyboardEvent) => {
215
+ if (e.code !== code) return;
216
+ this.room.voice.setMicEnabled(false);
217
+ };
218
+ window.addEventListener("keydown", this.onKeyDown);
219
+ window.addEventListener("keyup", this.onKeyUp);
220
+ // A key held while the tab loses focus never fires keyup — without this the mic
221
+ // stays open after the player alt-tabs away, which is the worst possible bug here.
222
+ window.addEventListener("blur", this.onKeyUp as unknown as EventListener);
223
+ }
224
+
225
+ private unbindPushToTalk(): void {
226
+ if (this.onKeyDown) window.removeEventListener("keydown", this.onKeyDown);
227
+ if (this.onKeyUp) {
228
+ window.removeEventListener("keyup", this.onKeyUp);
229
+ window.removeEventListener("blur", this.onKeyUp as unknown as EventListener);
230
+ }
231
+ this.onKeyDown = null;
232
+ this.onKeyUp = null;
233
+ }
234
+ }
235
+
236
+ function topLevelParent(node: THREE.Object3D): THREE.Object3D {
237
+ let current: THREE.Object3D = node;
238
+ while (current.parent) current = current.parent;
239
+ return current;
240
+ }
241
+
242
+ function isTextEntry(target: EventTarget | null): boolean {
243
+ if (!(target instanceof HTMLElement)) return false;
244
+ if (target.isContentEditable) return true;
245
+ const tag = target.tagName;
246
+ return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
247
+ }
@@ -116,7 +116,7 @@ scene.background = texture; // keep the raw texture for the visible sky
116
116
 
117
117
  ## Troubleshooting
118
118
 
119
- - **"Not authorized"** — run `npx @genex-ai/cli-demo@latest init` first (it writes your `GENEX_TOKEN`).
119
+ - **"Not authorized"** — run `npx @genex-ai/cli-demo@dev init` first (it writes your `GENEX_TOKEN`).
120
120
  - **"Out of credits" (`insufficient_credits`)** — the account has no credits left for
121
121
  this skybox generation. Tell the user the facts the CLI printed: their balance, this
122
122
  generation's cost, and when their credits refill. Then offer to continue the build
@@ -225,7 +225,7 @@ first one is the one a screenshot of the whole arena will not show you.
225
225
 
226
226
  ## Troubleshooting
227
227
 
228
- - **"Not authorized"** — run `npx @genex-ai/cli-demo@latest init` first (it writes your `GENEX_TOKEN`).
228
+ - **"Not authorized"** — run `npx @genex-ai/cli-demo@dev init` first (it writes your `GENEX_TOKEN`).
229
229
  - **"Out of credits" (`insufficient_credits`)** — the account has no credits left for
230
230
  this texture generation. Tell the user the facts the CLI printed: their balance,
231
231
  this generation's cost, and when their credits refill. Then offer to continue the
@@ -151,7 +151,7 @@ set belongs to `$genex-ai-hud` — both build on `npx genex image`/`video`.
151
151
 
152
152
  ## Troubleshooting
153
153
 
154
- - **"Not authorized"** — run `npx @genex-ai/cli-demo@latest init` first (it writes your `GENEX_TOKEN`).
154
+ - **"Not authorized"** — run `npx @genex-ai/cli-demo@dev init` first (it writes your `GENEX_TOKEN`).
155
155
  - **"Prompt rejected"** — the provider's content-safety filter blocked the prompt.
156
156
  This is non-retryable; retrying the same wording fails again. Rewrite the prompt.
157
157
  - **Nothing plays / black surface** — the first `video.play()` must run inside a user
@@ -63,6 +63,8 @@ copy demo architecture.
63
63
  | drawn HUD chrome the game's style wants—one element or a matched set of frames, masks, and icons | `$genex-ai-hud` |
64
64
  | the game works but feels flat, floaty, or unresponsive: input response, camera, impacts, cooldowns, difficulty, fail/retry | `$genex-threejs-game-feel` |
65
65
  | realtime multiplayer: movement sync, shared ball/NPC, host-run scores/enemies, shots/emotes, persistence | `$genex-threejs-multiplayer` |
66
+ | players talking to each other by TEXT — chat, room chat, "let them type to each other" (`room.chat` + `npx genex controller chat`) | `$genex-threejs-multiplayer` |
67
+ | players TALKING — voice chat, mic, "hear each other", proximity/positional voice (`room.voice` + `npx genex controller voice`; party-sized, max 6 per room — say that number out loud before building the feature) | `$genex-threejs-multiplayer` |
66
68
  | player identity, sign-in, guests, saves/progress, per-player state, shared persistent world, leaderboards—mandatory for every game | `$genex-threejs-embed-auth` |
67
69
 
68
70
  ## Request-sized execution
@@ -159,7 +159,7 @@ and re-link the clone to the same live game:
159
159
  ```bash
160
160
  git clone <the game's repo url> my-game && cd my-game
161
161
  npm install
162
- npx @genex-ai/cli-demo@latest link <slug> # slug = the name in the play URL
162
+ npx @genex-ai/cli-demo@dev link <slug> # slug = the name in the play URL
163
163
  ```
164
164
 
165
165
  Don't know the slug? **`npx genex list`** prints every game on your account —
@@ -186,7 +186,7 @@ Safe to run any time — genex-owned skills are refreshed to the latest version,
186
186
  and your own files are never touched:
187
187
 
188
188
  ```bash
189
- npx @genex-ai/cli-demo@latest init
189
+ npx @genex-ai/cli-demo@dev init
190
190
  ```
191
191
 
192
192
  Use `--force` only if you intentionally want your own existing files overwritten