@genex-ai/cli-demo 1.2.4-dev.363 → 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-dev.363",
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
+ }
@@ -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
@@ -46,7 +46,8 @@ npm i @genex-ai/multiplayer@^0.12.0
46
46
  > landed in 0.10; confirmed object controls, snaps, host-tick teardown, and reconnect rebasing
47
47
  > in 0.9. An older resolve does not have those.
48
48
 
49
- This skill targets `@genex-ai/multiplayer` **≥ 0.13.0** (`objects`/`host` since 0.4; verified per-player `avatarUrl` since 0.12;
49
+ This skill targets `@genex-ai/multiplayer` **≥ 0.15.0** (`objects`/`host` since 0.4; verified per-player `avatarUrl` since 0.12;
50
+ room text chat via `room.chat` since 0.14; mesh voice via `room.voice` since 0.15;
50
51
  `matchmake()` since 0.5; private lobbies since 0.7; auto-reconnect + `inputs`/`onHostTick`
51
52
  since 0.8; soft ownership handoff since 0.8.4; confirmed controls, snap epochs, and host-tick
52
53
  lifecycle guarantees since 0.9; regional relay selection via `getColyseusUrls()` since 0.10; host-only `setRoomOpen()` since 0.13;
@@ -536,9 +537,11 @@ of five kinds — put each on its channel and the game just works:
536
537
  | Your own avatar (position, rotation, anim) | `me.set` → others read `players` | you (each player their own) |
537
538
  | A moving thing nobody owns (**ball**, puck, NPC) | `objects` (claim + set) | the one current **owner** |
538
539
  | Slow agreed facts (score, round, wave, seed) | `shared` | the **host** (`isHost`) |
539
- | One-off actions (shot, emote, hit, chat) | `send` + `on` | whoever did it |
540
+ | One-off actions (shot, emote, hit) | `send` + `on` | whoever did it |
540
541
  | Discrete per-player values (hp, ammo, flags) | in `me.set`, read via `stateRaw` | you |
541
542
  | Which avatar MODEL a player is (VRM look) | already on `players` as `p.avatarUrl` — sync nothing | the **relay** (verified identity) |
543
+ | **Text chat** | `room.chat` — its own channel, NOT `send` | the **relay** (stamps the sender) |
544
+ | **Voice chat** | `room.voice` — peer-to-peer audio, max 6 | nobody (the relay carries no audio) |
542
545
 
543
546
  Getting the channel right is the whole game. A ball on `shared` stutters (not smoothed) and
544
547
  fights (many writers). A ball on `objects` glides and has one owner. That's the difference.
@@ -651,6 +654,28 @@ fights (many writers). A ball on `objects` glides and has one owner. That's the
651
654
  you**, so apply your own action's local effect directly (draw your own tracer at fire time),
652
655
  not inside `on(...)`. Relay-internal names (`state`, `shared`, `claim`, `obj`, `release`,
653
656
  `destroy`, `match:*`, `__*`) are refused — pick your own event names. `room.leave()`.
657
+ - `room.chat` — room text chat, **its own channel, not `send`**: `chat.send(text)`,
658
+ `chat.on(m => …)`, `chat.history`, `chat.mute(id)` / `chat.unmute(id)` / `chat.muted`.
659
+ The relay stamps `m.from`/`m.name` from the verified session (nobody can post under
660
+ another player's name), sanitizes `m.text`, caps it at 500 chars, and replays the last
661
+ ~50 lines so a late joiner sees the conversation. **Your own lines echo back to you** —
662
+ render only what arrives in `on`, or every message appears twice. UI:
663
+ `npx genex controller chat`, and you MUST pass its `onTypingChange` (or check
664
+ `chat.isTyping`) so the game stops reading the keyboard while someone types — otherwise
665
+ typing "was that a wall?" walks and jumps the player. Render `m.text` with `textContent`,
666
+ never `innerHTML`. See [references/realtime-patterns.md](references/realtime-patterns.md).
667
+ - `room.voice` — mesh voice chat: `voice.join(opts)` / `leave()` / `setMicEnabled(on)` /
668
+ `mute(id)` / `participants` / `active` / `speaking`, plus `room.on('voice', …)` and
669
+ `room.on('voice:rejected', …)`. **Party-sized: the relay caps it at 6** (audio is a full
670
+ mesh — everyone uploads to everyone, and reliability, not bandwidth, breaks first). The
671
+ room still holds 64 players; the 7th `join()` is refused, so handle `voice:rejected`
672
+ instead of retrying. `join()` MUST run inside a click/tap — browsers gate both the mic
673
+ prompt and audio playback on a user gesture — and it REJECTS when the player denies the
674
+ mic, which is an ordinary outcome to show, not an error to swallow. For a 3D game use
675
+ `npx genex controller voice` (proximity audio + push-to-talk); it joins with
676
+ `{ spatial: true }` and drives a `THREE.PositionalAudio` per speaker. **Configure TURN
677
+ for anything real**: ~10–20% of players are behind symmetric NAT and connect to nobody
678
+ without it, while everyone else works — a miserable bug to diagnose from a report.
654
679
  - `room.inputs.send(payload)` / `room.inputs.on((fromId, payload) => …)` — the host-routed
655
680
  input channel for host-authoritative physics: anyone sends, ONLY the current host receives.
656
681
  See [references/host-physics.md](references/host-physics.md).
@@ -912,8 +937,8 @@ from any still capture whether movement feels smooth.** Don't try — it leads t
912
937
 
913
938
  1. **Trust the SDK's smoothing.** Draw `state` directly; don't add your own.
914
939
  2. **Verify it *runs*:** two clients, distinct meshes, both move, no console errors, each sees the
915
- other (and the ball, if any). That's all a capture can prove. Local test mode (the embed-auth
916
- skill's `?genex_local_test=1`) can NOT do this: it mints no relay credential, so `connect()`
940
+ other (and the ball, if any). That's all a capture can prove. Local test mode (what the dev
941
+ server boots into — the embed-auth skill) can NOT do this: it mints no relay credential, so `connect()`
917
942
  fails there by design and two local-test tabs never see each other — run the two-client check
918
943
  on the published game (or have the owner open their draft), and if multiplayer wasn't
919
944
  exercised, say exactly that in your handoff instead of implying it was.