create-smore-game 0.2.0 → 0.2.1

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.
Files changed (3) hide show
  1. package/index.js +30 -15
  2. package/package.json +1 -1
  3. package/templates.js +1517 -133
package/templates.js CHANGED
@@ -1,21 +1,27 @@
1
1
  // ─── Shared ───
2
2
 
3
- export const pnpmWorkspace = `packages:
4
- - screen
5
- - player
6
- `;
3
+ // pnpm-workspace.yaml is no longer generated.
4
+ // npm workspaces are configured in root package.json instead.
5
+
6
+ const SDK_VERSION = '^1.0.0';
7
7
 
8
8
  export function rootPackageJson(name) {
9
9
  return JSON.stringify(
10
10
  {
11
11
  name,
12
12
  private: true,
13
+ workspaces: ["screen", "controller"],
13
14
  scripts: {
14
- "dev": "pnpm -r --parallel dev",
15
- "dev:screen": "pnpm --filter screen dev",
16
- "dev:player": "pnpm --filter player dev",
17
- "build": "pnpm -r build",
18
- "zip": "pnpm build && node -e \"const{execSync:e}=require('child_process');e('cd dist && zip -r ../'+process.env.npm_package_name+'.zip .');\""
15
+ dev: "node dev/server.js",
16
+ "dev:screen": "npm run dev -w screen",
17
+ "dev:controller": "npm run dev -w controller",
18
+ build: "npm run build --workspaces --if-present",
19
+ zip: 'npm run build --workspaces --if-present && cp game.json dist/ && cd dist && zip -r ../game.zip .',
20
+ },
21
+ devDependencies: {
22
+ "socket.io": "^4.7.0",
23
+ express: "^4.18.0",
24
+ cors: "^2.8.5",
19
25
  },
20
26
  },
21
27
  null,
@@ -23,6 +29,12 @@ export function rootPackageJson(name) {
23
29
  );
24
30
  }
25
31
 
32
+ export function envExample() {
33
+ return `# Server URL for development (optional)
34
+ # VITE_SERVER_URL=http://localhost:3001
35
+ `;
36
+ }
37
+
26
38
  // ─── Screen templates ───
27
39
 
28
40
  const screenTsconfig = JSON.stringify(
@@ -66,8 +78,9 @@ function screenViteConfig(isReact) {
66
78
  import react from '@vitejs/plugin-react';
67
79
 
68
80
  export default defineConfig({
81
+ base: './',
69
82
  plugins: [react()],
70
- server: { port: 5173 },
83
+ server: { port: 5173, host: true },
71
84
  build: { outDir: '../dist/screen' },
72
85
  });
73
86
  `;
@@ -75,7 +88,8 @@ export default defineConfig({
75
88
  return `import { defineConfig } from 'vite';
76
89
 
77
90
  export default defineConfig({
78
- server: { port: 5173 },
91
+ base: './',
92
+ server: { port: 5173, host: true },
79
93
  build: { outDir: '../dist/screen' },
80
94
  });
81
95
  `;
@@ -104,6 +118,8 @@ function screenIndexHtml(title, isReact) {
104
118
  }
105
119
 
106
120
  // Screen: React + Phaser
121
+ // Test utilities: createMockScreen(), createMockController() can be imported in test files
122
+ // Example: import { createMockScreen } from '@smoregg/sdk';
107
123
  export function screenReactPhaser(gameId) {
108
124
  return {
109
125
  "package.json": JSON.stringify(
@@ -119,7 +135,7 @@ export function screenReactPhaser(gameId) {
119
135
  react: "^18.3.1",
120
136
  "react-dom": "^18.3.1",
121
137
  phaser: "^3.80.1",
122
- "@smoregg/sdk": "^0.1.0",
138
+ "@smoregg/sdk": SDK_VERSION, // TODO: auto-detect or update on release
123
139
  },
124
140
  devDependencies: {
125
141
  "@types/react": "^18.3.0",
@@ -136,38 +152,108 @@ export function screenReactPhaser(gameId) {
136
152
  "vite.config.ts": screenViteConfig(true),
137
153
  "index.html": screenIndexHtml(gameId, true),
138
154
  "src/main.tsx": `import { createRoot } from 'react-dom/client';
139
- import { IframeRoomProvider } from '@smoregg/sdk/iframe';
140
155
  import { App } from './App';
141
156
 
142
- createRoot(document.getElementById('root')!).render(
143
- <IframeRoomProvider>
144
- <App />
145
- </IframeRoomProvider>
146
- );
157
+ createRoot(document.getElementById('root')!).render(<App />);
147
158
  `,
148
- "src/App.tsx": `import { useGameHost } from '@smoregg/sdk/iframe';
149
- import { useEffect, useRef } from 'react';
159
+ "src/App.tsx": `import { createScreen } from '@smoregg/sdk';
160
+ import type { Screen, ControllerInfo, GameResults } from '@smoregg/sdk';
161
+ import { useEffect, useRef, useState } from 'react';
150
162
  import Phaser from 'phaser';
151
163
  import { GameScene } from './scenes/GameScene';
152
164
 
165
+ // Type-safe events example:
166
+ // type MyEvents = { 'player-move': { x: number; y: number } };
167
+ // const screen = await createScreen<MyEvents>({ ... });
168
+
169
+ // Testing: Use mock utilities for unit tests
170
+ // import { createMockScreen, createMockController } from '@smoregg/sdk';
171
+ // const mockScreen = createMockScreen();
172
+ // mockScreen.simulateControllerJoin({ playerIndex: 0, name: 'Test' });
173
+
174
+ // Error handling:
175
+ // screen/controller onError callback handles initialization and runtime errors.
176
+ // Wrap critical game logic in try/catch for graceful error recovery.
177
+
178
+ // Import GameMetadata type for game.json schema:
179
+ // import type { GameMetadata } from '@smoregg/sdk';
180
+ // game.json fields: { id, title, description, minPlayers, maxPlayers, version }
181
+ // See docs for full GameMetadata schema.
182
+
153
183
  export function App() {
184
+ const screenRef = useRef<Screen | null>(null);
154
185
  const gameRef = useRef<Phaser.Game | null>(null);
155
- const { room, broadcast } = useGameHost({
156
- gameId: '${gameId}',
157
- onInput: {
158
- tap: (playerId, data) => {
159
- console.log('Player tapped:', playerId, data);
186
+ const [roomCode, setRoomCode] = useState('');
187
+ const [controllers, setControllers] = useState<ControllerInfo[]>([]);
188
+
189
+ useEffect(() => {
190
+ let mounted = true;
191
+
192
+ const init = async () => {
193
+ // Alternative: register listeners in config (instead of screen.on())
194
+ // const screen = await createScreen({
195
+ // listeners: {
196
+ // 'tap': (playerIndex, data) => console.log('Player', playerIndex, 'tapped:', data),
197
+ // },
198
+ // });
199
+ // Note: listeners in config are set during initialization and cannot be removed.
200
+ // Use screen.on(event, handler) / screen.off(event, handler) for dynamic listeners.
201
+
202
+ const screen = await createScreen({
203
+ debug: true,
204
+ onControllerJoin: (playerIndex, info) => {
205
+ console.log('Player joined:', playerIndex);
206
+ // Access player character data:
207
+ // const { nickname, appearance } = info;
208
+ // console.log(nickname, appearance?.style, appearance?.seed);
209
+ setControllers([...screen.controllers]);
210
+ },
211
+ onControllerLeave: (playerIndex) => {
212
+ console.log('Player left:', playerIndex);
213
+ setControllers([...screen.controllers]);
214
+ },
215
+ // Advanced callbacks (uncomment to use):
216
+ onControllerDisconnect: (playerIndex) => {
217
+ console.log(\`Player \${playerIndex} disconnected\`);
218
+ },
219
+ // onControllerReconnect: (playerIndex: number, info: ControllerInfo) => void — called when a disconnected player reconnects
220
+ // onCharacterUpdated: (playerIndex: number, appearance: CharacterAppearance | null) => void — called when a player updates their character appearance
221
+ // onRateLimited: (event: string) => void — called when client is rate-limited by server
222
+ onError: (error) => {
223
+ console.error('SDK Error:', error.message);
224
+ // Show user-friendly error UI
225
+ },
226
+ });
227
+ if (!mounted) {
228
+ screen.destroy();
229
+ return;
230
+ }
231
+
232
+ screenRef.current = screen;
233
+ setRoomCode(screen.roomCode);
234
+ setControllers([...screen.controllers]);
235
+
236
+ // Note: listeners in config are set during initialization (onControllerJoin, etc. above).
237
+ // Use screen.on(event, handler) / screen.off(event, handler) for dynamic event listeners.
238
+ // Cleanup example (useful in React useEffect):
239
+ // const handler = (playerIndex, data) => { ... };
240
+ // screen.on('my-event', handler);
241
+ // Later: screen.off('my-event', handler);
242
+ screen.on('tap', (playerIndex, data) => {
243
+ console.log('Player', playerIndex, 'tapped:', data);
160
244
  // Forward input to Phaser scene
161
- gameRef.current?.events.emit('player-tap', { playerId, ...data });
162
- },
163
- },
164
- onPlayerJoin: (playerId) => {
165
- console.log('Player joined:', playerId);
166
- },
167
- onPlayerLeave: (playerId) => {
168
- console.log('Player left:', playerId);
169
- },
170
- });
245
+ gameRef.current?.events.emit('player-tap', { playerIndex, ...data });
246
+ });
247
+ };
248
+
249
+ init();
250
+
251
+ return () => {
252
+ mounted = false;
253
+ screenRef.current?.destroy();
254
+ screenRef.current = null;
255
+ };
256
+ }, []);
171
257
 
172
258
  useEffect(() => {
173
259
  if (gameRef.current) return;
@@ -194,6 +280,11 @@ export function App() {
194
280
  return (
195
281
  <div style={{ width: '100%', height: '100%' }}>
196
282
  <div id="phaser-container" style={{ width: '100%', height: '100%' }} />
283
+ {roomCode && (
284
+ <div style={{ position: 'absolute', top: '20px', left: '20px', fontSize: '24px' }}>
285
+ Room: {roomCode} | Players: {controllers.length}
286
+ </div>
287
+ )}
197
288
  </div>
198
289
  );
199
290
  }
@@ -216,8 +307,8 @@ export class GameScene extends Phaser.Scene {
216
307
  .setOrigin(0.5);
217
308
 
218
309
  // Listen for player taps forwarded from React
219
- this.game.events.on('player-tap', (data: { playerId: string }) => {
220
- this.label.setText(\`Player \${data.playerId.slice(0, 6)} tapped!\`);
310
+ this.game.events.on('player-tap', (data: { playerIndex: number }) => {
311
+ this.label.setText(\`Player \${data.playerIndex} tapped!\`);
221
312
  });
222
313
  }
223
314
  }
@@ -226,6 +317,8 @@ export class GameScene extends Phaser.Scene {
226
317
  }
227
318
 
228
319
  // Screen: React only
320
+ // Test utilities: createMockScreen(), createMockController() can be imported in test files
321
+ // Example: import { createMockScreen } from '@smoregg/sdk';
229
322
  export function screenReact(gameId) {
230
323
  return {
231
324
  "package.json": JSON.stringify(
@@ -240,7 +333,7 @@ export function screenReact(gameId) {
240
333
  dependencies: {
241
334
  react: "^18.3.1",
242
335
  "react-dom": "^18.3.1",
243
- "@smoregg/sdk": "^0.1.0",
336
+ "@smoregg/sdk": SDK_VERSION, // TODO: auto-detect or update on release
244
337
  },
245
338
  devDependencies: {
246
339
  "@types/react": "^18.3.0",
@@ -257,44 +350,130 @@ export function screenReact(gameId) {
257
350
  "vite.config.ts": screenViteConfig(true),
258
351
  "index.html": screenIndexHtml(gameId, true),
259
352
  "src/main.tsx": `import { createRoot } from 'react-dom/client';
260
- import { IframeRoomProvider } from '@smoregg/sdk/iframe';
261
353
  import { App } from './App';
262
354
 
263
- createRoot(document.getElementById('root')!).render(
264
- <IframeRoomProvider>
265
- <App />
266
- </IframeRoomProvider>
267
- );
355
+ createRoot(document.getElementById('root')!).render(<App />);
268
356
  `,
269
- "src/App.tsx": `import { useGameHost } from '@smoregg/sdk/iframe';
270
- import { useState } from 'react';
357
+ "src/App.tsx": `import { createScreen } from '@smoregg/sdk';
358
+ import type { Screen, ControllerInfo, GameResults } from '@smoregg/sdk';
359
+ import { useEffect, useRef, useState } from 'react';
360
+
361
+ // Type-safe events example:
362
+ // type MyEvents = { 'player-move': { x: number; y: number } };
363
+ // const screen = await createScreen<MyEvents>({ ... });
364
+
365
+ // Testing: Use mock utilities for unit tests
366
+ // import { createMockScreen, createMockController } from '@smoregg/sdk';
367
+ // const mockScreen = createMockScreen();
368
+ // mockScreen.simulateControllerJoin({ playerIndex: 0, name: 'Test' });
369
+
370
+ // Error handling:
371
+ // screen/controller onError callback handles initialization and runtime errors.
372
+ // Wrap critical game logic in try/catch for graceful error recovery.
373
+
374
+ // Import GameMetadata type for game.json schema:
375
+ // import type { GameMetadata } from '@smoregg/sdk';
376
+ // game.json fields: { id, title, description, minPlayers, maxPlayers, version }
377
+ // See docs for full GameMetadata schema.
378
+
379
+ // Advanced: Validate custom event names before sending
380
+ // import { validateEventName } from '@smoregg/sdk';
381
+ // validateEventName('my-event'); // throws if contains ':'
271
382
 
272
383
  export function App() {
273
- const [taps, setTaps] = useState<{ playerId: string; time: number }[]>([]);
384
+ const screenRef = useRef<Screen | null>(null);
385
+ const [roomCode, setRoomCode] = useState('');
386
+ const [controllers, setControllers] = useState<ControllerInfo[]>([]);
387
+ const [taps, setTaps] = useState<{ playerIndex: number; time: number }[]>([]);
274
388
 
275
- const { room, broadcast } = useGameHost({
276
- gameId: '${gameId}',
277
- onInput: {
278
- tap: (playerId, data) => {
279
- setTaps((prev) => [...prev.slice(-9), { playerId, time: Date.now() }]);
280
- },
281
- },
282
- onPlayerJoin: (playerId) => {
283
- console.log('Player joined:', playerId);
284
- },
285
- onPlayerLeave: (playerId) => {
286
- console.log('Player left:', playerId);
287
- },
288
- });
389
+ useEffect(() => {
390
+ let mounted = true;
391
+
392
+ const init = async () => {
393
+ const screen = await createScreen({
394
+ debug: true,
395
+ onControllerJoin: (playerIndex, info) => {
396
+ console.log('Player joined:', playerIndex);
397
+ setControllers([...screen.controllers]);
398
+ },
399
+ onControllerLeave: (playerIndex) => {
400
+ console.log('Player left:', playerIndex);
401
+ setControllers([...screen.controllers]);
402
+ },
403
+ // Advanced callbacks (uncomment to use):
404
+ onControllerDisconnect: (playerIndex) => {
405
+ console.log(\`Player \${playerIndex} disconnected\`);
406
+ },
407
+ // onControllerReconnect: (playerIndex: number, info: ControllerInfo) => void — called when a disconnected player reconnects
408
+ // onCharacterUpdated: (playerIndex: number, appearance: CharacterAppearance | null) => void — called when a player updates their character appearance
409
+ // onRateLimited: (event: string) => void — called when client is rate-limited by server
410
+ onError: (error) => {
411
+ console.error('SDK Error:', error.message);
412
+ // Show user-friendly error UI
413
+ },
414
+ });
415
+ if (!mounted) {
416
+ screen.destroy();
417
+ return;
418
+ }
419
+
420
+ screenRef.current = screen;
421
+ setRoomCode(screen.roomCode);
422
+ setControllers([...screen.controllers]);
423
+
424
+ // Note: listeners in config are set during initialization (onControllerJoin, etc. above).
425
+ // Use screen.on(event, handler) / screen.off(event, handler) for dynamic event listeners.
426
+ // destroy() automatically removes all listeners, so explicit off() cleanup is not needed.
427
+ screen.on('tap', (playerIndex) => {
428
+ setTaps((prev) => [...prev.slice(-9), { playerIndex, time: Date.now() }]);
429
+ });
430
+ };
431
+
432
+ init();
433
+
434
+ return () => {
435
+ mounted = false;
436
+ screenRef.current?.destroy();
437
+ screenRef.current = null;
438
+ };
439
+ }, []);
440
+
441
+ // Example: send score update to all players
442
+ const handleBroadcastScore = () => {
443
+ screenRef.current?.broadcast('score-update', { score: 100 });
444
+ };
445
+
446
+ // Example: send to specific player
447
+ const handleSendToPlayer = (playerIndex: number) => {
448
+ screenRef.current?.sendToController(playerIndex, 'personal-message', { text: 'Hello!' });
449
+ };
450
+
451
+ // Example: end game with GameResults type
452
+ const handleGameOver = () => {
453
+ const scores: Record<number, number> = {};
454
+ controllers.forEach((_, idx) => {
455
+ scores[idx] = Math.floor(Math.random() * 100);
456
+ });
457
+ // GameResults fields: scores (required), winner (optional), reason (optional)
458
+ const results: GameResults = {
459
+ scores,
460
+ // winner: 0, // optional — playerIndex of winner
461
+ // reason: 'time-up', // optional — custom game-over reason
462
+ };
463
+ screenRef.current?.gameOver(results);
464
+ };
289
465
 
290
466
  return (
291
467
  <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: '2vmin' }}>
292
468
  <h1 style={{ fontSize: '5vmin' }}>
293
- {room?.players?.length ? \`\${room.players.length} player(s) connected\` : 'Waiting for players...'}
469
+ {controllers.length ? \`\${controllers.length} player(s) connected\` : 'Waiting for players...'}
294
470
  </h1>
471
+ {roomCode && (
472
+ <div style={{ fontSize: '3vmin', opacity: 0.8 }}>Room Code: {roomCode}</div>
473
+ )}
295
474
  <div style={{ fontSize: '3vmin', opacity: 0.6 }}>
296
475
  {taps.map((t, i) => (
297
- <div key={i}>Player {t.playerId.slice(0, 6)} tapped</div>
476
+ <div key={i}>Player {t.playerIndex} tapped</div>
298
477
  ))}
299
478
  </div>
300
479
  </div>
@@ -305,6 +484,8 @@ export function App() {
305
484
  }
306
485
 
307
486
  // Screen: Vanilla JS
487
+ // Test utilities: createMockScreen(), createMockController() can be imported in test files
488
+ // Example: import { createMockScreen } from '@smoregg/sdk';
308
489
  export function screenVanilla(gameId) {
309
490
  return {
310
491
  "package.json": JSON.stringify(
@@ -317,7 +498,7 @@ export function screenVanilla(gameId) {
317
498
  build: "tsc && vite build",
318
499
  },
319
500
  dependencies: {
320
- "@smoregg/sdk": "^0.1.0",
501
+ "@smoregg/sdk": SDK_VERSION, // TODO: auto-detect or update on release
321
502
  },
322
503
  devDependencies: {
323
504
  typescript: "^5.5.0",
@@ -340,43 +521,111 @@ export function screenVanilla(gameId) {
340
521
  html, body { width: 100%; height: 100%; background: #0f0f0f; color: #fff; font-family: sans-serif; overflow: hidden; }
341
522
  #app { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 2vmin; }
342
523
  h1 { font-size: 5vmin; }
524
+ #room-code { font-size: 3vmin; opacity: 0.8; }
343
525
  #log { font-size: 3vmin; opacity: 0.6; }
344
526
  </style>
345
527
  </head>
346
528
  <body>
347
529
  <div id="app">
348
530
  <h1 id="status">Waiting for players...</h1>
531
+ <div id="room-code"></div>
349
532
  <div id="log"></div>
350
533
  </div>
351
534
  <script type="module" src="/src/main.ts"></script>
352
535
  </body>
353
536
  </html>
354
537
  `,
355
- "src/main.ts": `import { createIframeHost } from '@smoregg/sdk/iframe';
356
-
357
- const host = createIframeHost({
358
- gameId: '${gameId}',
359
- onInput: {
360
- tap: (playerId: string, data: unknown) => {
361
- const log = document.getElementById('log')!;
362
- const line = document.createElement('div');
363
- line.textContent = \`Player \${playerId.slice(0, 6)} tapped\`;
364
- log.appendChild(line);
365
- // Keep last 10
366
- while (log.children.length > 10) log.removeChild(log.firstChild!);
538
+ "src/main.ts": `import { createScreen } from '@smoregg/sdk';
539
+ import type { Screen, ControllerInfo, GameResults } from '@smoregg/sdk';
540
+
541
+ // Type-safe events example:
542
+ // type MyEvents = { 'player-move': { x: number; y: number } };
543
+ // const screen = await createScreen<MyEvents>({ ... });
544
+
545
+ // Testing: Use mock utilities for unit tests
546
+ // import { createMockScreen, createMockController } from '@smoregg/sdk';
547
+ // const mockScreen = createMockScreen();
548
+ // mockScreen.simulateControllerJoin({ playerIndex: 0, name: 'Test' });
549
+
550
+ // Error handling:
551
+ // screen/controller onError callback handles initialization and runtime errors.
552
+ // Wrap critical game logic in try/catch for graceful error recovery.
553
+
554
+ // Import GameMetadata type for game.json schema:
555
+ // import type { GameMetadata } from '@smoregg/sdk';
556
+ // game.json fields: { id, title, description, minPlayers, maxPlayers, version }
557
+ // See docs for full GameMetadata schema.
558
+
559
+ const statusEl = document.getElementById('status')!;
560
+ const roomCodeEl = document.getElementById('room-code')!;
561
+ const logEl = document.getElementById('log')!;
562
+
563
+ let screen: Screen;
564
+
565
+ async function init() {
566
+ screen = await createScreen({
567
+ debug: true,
568
+ onControllerJoin: (playerIndex) => {
569
+ console.log('Player joined:', playerIndex);
570
+ updateStatus();
367
571
  },
368
- },
369
- onPlayerJoin: (playerId: string) => {
370
- document.getElementById('status')!.textContent = 'Player connected!';
371
- },
372
- });
572
+ onControllerLeave: (playerIndex) => {
573
+ console.log('Player left:', playerIndex);
574
+ updateStatus();
575
+ },
576
+ // Advanced callbacks (uncomment to use):
577
+ onControllerDisconnect: (playerIndex) => {
578
+ console.log(\`Player \${playerIndex} disconnected\`);
579
+ },
580
+ // onControllerReconnect: (playerIndex, info) => { console.log('Player reconnected:', playerIndex); },
581
+ // onCharacterUpdated: (playerIndex, appearance) => { console.log('Character updated:', playerIndex); },
582
+ // onRateLimited: (event: string) => { console.warn('Rate limited:', event); },
583
+ onError: (error) => {
584
+ console.error('SDK Error:', error.message);
585
+ // Show user-friendly error UI
586
+ },
587
+ });
588
+
589
+ roomCodeEl.textContent = \`Room Code: \${screen.roomCode}\`;
590
+ updateStatus();
591
+
592
+ // Note: listeners in config are set during initialization (onControllerJoin, etc. above).
593
+ // Use screen.on(event, handler) / screen.off(event, handler) for dynamic event listeners.
594
+ // destroy() automatically removes all listeners, so explicit off() cleanup is not needed.
595
+ screen.on('tap', (playerIndex: number, data: unknown) => {
596
+ const line = document.createElement('div');
597
+ line.textContent = \`Player \${playerIndex} tapped\`;
598
+ logEl.appendChild(line);
599
+ // Keep last 10 entries
600
+ while (logEl.children.length > 10) {
601
+ logEl.removeChild(logEl.firstChild!);
602
+ }
603
+ });
604
+ }
605
+
606
+ function updateStatus() {
607
+ const count = screen.controllers.length;
608
+ statusEl.textContent = count > 0 ? \`\${count} player(s) connected\` : 'Waiting for players...';
609
+ }
610
+
611
+ init();
612
+
613
+ // Example functions (can be called from console for testing):
614
+ // screen.broadcast('score-update', { score: 100 });
615
+ // screen.sendToController(0, 'message', { text: 'Hello!' });
616
+ // const results: GameResults = {
617
+ // scores: { 0: 50, 1: 75 },
618
+ // // winner: 0,
619
+ // // reason: 'time-up',
620
+ // };
621
+ // screen.gameOver(results);
373
622
  `,
374
623
  };
375
624
  }
376
625
 
377
- // ─── Player templates ───
626
+ // ─── Controller templates ───
378
627
 
379
- const playerTsconfig = JSON.stringify(
628
+ const controllerTsconfig = JSON.stringify(
380
629
  {
381
630
  compilerOptions: {
382
631
  target: "ES2020",
@@ -394,7 +643,7 @@ const playerTsconfig = JSON.stringify(
394
643
  2,
395
644
  );
396
645
 
397
- const playerTsconfigVanilla = JSON.stringify(
646
+ const controllerTsconfigVanilla = JSON.stringify(
398
647
  {
399
648
  compilerOptions: {
400
649
  target: "ES2020",
@@ -411,12 +660,12 @@ const playerTsconfigVanilla = JSON.stringify(
411
660
  2,
412
661
  );
413
662
 
414
- // Player: React
415
- export function playerReact(gameId) {
663
+ // Controller: React
664
+ export function controllerReact(gameId) {
416
665
  return {
417
666
  "package.json": JSON.stringify(
418
667
  {
419
- name: "player",
668
+ name: "controller",
420
669
  private: true,
421
670
  type: "module",
422
671
  scripts: {
@@ -426,7 +675,7 @@ export function playerReact(gameId) {
426
675
  dependencies: {
427
676
  react: "^18.3.1",
428
677
  "react-dom": "^18.3.1",
429
- "@smoregg/sdk": "^0.1.0",
678
+ "@smoregg/sdk": SDK_VERSION, // TODO: auto-detect or update on release
430
679
  },
431
680
  devDependencies: {
432
681
  "@types/react": "^18.3.0",
@@ -439,14 +688,15 @@ export function playerReact(gameId) {
439
688
  null,
440
689
  2,
441
690
  ),
442
- "tsconfig.json": playerTsconfig,
691
+ "tsconfig.json": controllerTsconfig,
443
692
  "vite.config.ts": `import { defineConfig } from 'vite';
444
693
  import react from '@vitejs/plugin-react';
445
694
 
446
695
  export default defineConfig({
696
+ base: './',
447
697
  plugins: [react()],
448
- server: { port: 5174 },
449
- build: { outDir: '../dist/player' },
698
+ server: { port: 5174, host: true },
699
+ build: { outDir: '../dist/controller' },
450
700
  });
451
701
  `,
452
702
  "index.html": `<!DOCTYPE html>
@@ -454,7 +704,7 @@ export default defineConfig({
454
704
  <head>
455
705
  <meta charset="UTF-8" />
456
706
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
457
- <title>${gameId} - Player</title>
707
+ <title>${gameId} - Controller</title>
458
708
  <style>
459
709
  * { margin: 0; padding: 0; box-sizing: border-box; }
460
710
  html, body, #root {
@@ -471,32 +721,65 @@ export default defineConfig({
471
721
  </html>
472
722
  `,
473
723
  "src/main.tsx": `import { createRoot } from 'react-dom/client';
474
- import { IframeRoomProvider } from '@smoregg/sdk/iframe';
475
724
  import { App } from './App';
476
725
 
477
- createRoot(document.getElementById('root')!).render(
478
- <IframeRoomProvider>
479
- <App />
480
- </IframeRoomProvider>
481
- );
726
+ createRoot(document.getElementById('root')!).render(<App />);
482
727
  `,
483
- "src/App.tsx": `import { useGamePlayer, TapButton } from '@smoregg/sdk/iframe';
484
- import { useState } from 'react';
728
+ "src/App.tsx": `import { createController } from '@smoregg/sdk';
729
+ import type { Controller, ControllerInfo } from '@smoregg/sdk';
730
+ import { useEffect, useRef, useState } from 'react';
731
+
732
+ // You can also register listeners in config:
733
+ // const controller = await createController({
734
+ // listeners: { 'game-state': (data) => { /* handle state */ } },
735
+ // });
485
736
 
486
737
  export function App() {
738
+ const controllerRef = useRef<Controller | null>(null);
739
+ const [myIndex, setMyIndex] = useState(-1);
487
740
  const [count, setCount] = useState(0);
741
+ const [isReady, setIsReady] = useState(false);
742
+
743
+ useEffect(() => {
744
+ let mounted = true;
745
+
746
+ const init = async () => {
747
+ const controller = await createController({
748
+ debug: true,
749
+ // Lifecycle callbacks (uncomment to use):
750
+ // onControllerJoin: (playerIndex: number, info: ControllerInfo) => void — called when any player joins
751
+ // onControllerLeave: (playerIndex: number) => void — called when any player leaves
752
+ // onError: (error: SmoreError) => void — called on SDK errors
753
+ });
754
+ if (!mounted) {
755
+ controller.destroy();
756
+ return;
757
+ }
758
+
759
+ controllerRef.current = controller;
760
+ setMyIndex(controller.myIndex);
761
+ setIsReady(true);
488
762
 
489
- const { emit, room } = useGamePlayer({
490
- gameId: '${gameId}',
491
- listeners: {
492
- 'score-update': (data: { score: number }) => {
763
+ controller.on('score-update', (data: { score: number }) => {
493
764
  setCount(data.score);
494
- },
495
- },
496
- });
765
+ });
766
+
767
+ controller.on('personal-message', (data: { text: string }) => {
768
+ console.log('Received message:', data.text);
769
+ });
770
+ };
771
+
772
+ init();
773
+
774
+ return () => {
775
+ mounted = false;
776
+ controllerRef.current?.destroy();
777
+ controllerRef.current = null;
778
+ };
779
+ }, []);
497
780
 
498
781
  const handleTap = () => {
499
- emit('tap', { timestamp: Date.now() });
782
+ controllerRef.current?.send('tap', { timestamp: Date.now() });
500
783
  setCount((c) => c + 1);
501
784
  };
502
785
 
@@ -507,14 +790,21 @@ export function App() {
507
790
  padding: 'env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)',
508
791
  touchAction: 'manipulation', userSelect: 'none',
509
792
  }}>
793
+ {isReady && (
794
+ <div style={{ fontSize: '16px', opacity: 0.6 }}>Player {myIndex}</div>
795
+ )}
510
796
  <div style={{ fontSize: '48px', fontWeight: 'bold' }}>{count}</div>
511
- <TapButton onTap={handleTap} style={{
512
- width: '200px', height: '200px', borderRadius: '50%',
513
- background: '#4f46e5', border: 'none', color: '#fff',
514
- fontSize: '24px', fontWeight: 'bold', cursor: 'pointer',
515
- }}>
797
+ <button
798
+ onPointerDown={handleTap}
799
+ style={{
800
+ width: '200px', height: '200px', borderRadius: '50%',
801
+ background: '#4f46e5', border: 'none', color: '#fff',
802
+ fontSize: '24px', fontWeight: 'bold', cursor: 'pointer',
803
+ WebkitTapHighlightColor: 'transparent',
804
+ }}
805
+ >
516
806
  TAP
517
- </TapButton>
807
+ </button>
518
808
  </div>
519
809
  );
520
810
  }
@@ -522,12 +812,12 @@ export function App() {
522
812
  };
523
813
  }
524
814
 
525
- // Player: Vanilla JS
526
- export function playerVanilla(gameId) {
815
+ // Controller: Vanilla JS
816
+ export function controllerVanilla(gameId) {
527
817
  return {
528
818
  "package.json": JSON.stringify(
529
819
  {
530
- name: "player",
820
+ name: "controller",
531
821
  private: true,
532
822
  type: "module",
533
823
  scripts: {
@@ -535,7 +825,7 @@ export function playerVanilla(gameId) {
535
825
  build: "tsc && vite build",
536
826
  },
537
827
  dependencies: {
538
- "@smoregg/sdk": "^0.1.0",
828
+ "@smoregg/sdk": SDK_VERSION, // TODO: auto-detect or update on release
539
829
  },
540
830
  devDependencies: {
541
831
  typescript: "^5.5.0",
@@ -545,12 +835,13 @@ export function playerVanilla(gameId) {
545
835
  null,
546
836
  2,
547
837
  ),
548
- "tsconfig.json": playerTsconfigVanilla,
838
+ "tsconfig.json": controllerTsconfigVanilla,
549
839
  "vite.config.ts": `import { defineConfig } from 'vite';
550
840
 
551
841
  export default defineConfig({
552
- server: { port: 5174 },
553
- build: { outDir: '../dist/player' },
842
+ base: './',
843
+ server: { port: 5174, host: true },
844
+ build: { outDir: '../dist/controller' },
554
845
  });
555
846
  `,
556
847
  "index.html": `<!DOCTYPE html>
@@ -558,7 +849,7 @@ export default defineConfig({
558
849
  <head>
559
850
  <meta charset="UTF-8" />
560
851
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
561
- <title>${gameId} - Player</title>
852
+ <title>${gameId} - Controller</title>
562
853
  <style>
563
854
  * { margin: 0; padding: 0; box-sizing: border-box; }
564
855
  html, body {
@@ -573,6 +864,7 @@ export default defineConfig({
573
864
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 24px;
574
865
  padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
575
866
  }
867
+ #player-info { font-size: 16px; opacity: 0.6; }
576
868
  #count { font-size: 48px; font-weight: bold; }
577
869
  #tap-btn {
578
870
  width: 200px; height: 200px; border-radius: 50%;
@@ -585,6 +877,7 @@ export default defineConfig({
585
877
  </head>
586
878
  <body>
587
879
  <div id="app">
880
+ <div id="player-info"></div>
588
881
  <div id="count">0</div>
589
882
  <button id="tap-btn">TAP</button>
590
883
  </div>
@@ -592,27 +885,1118 @@ export default defineConfig({
592
885
  </body>
593
886
  </html>
594
887
  `,
595
- "src/main.ts": `import { createIframePlayer } from '@smoregg/sdk/iframe';
888
+ "src/main.ts": `import { createController } from '@smoregg/sdk';
889
+ import type { Controller, ControllerInfo } from '@smoregg/sdk';
890
+
891
+ // You can also register listeners in config:
892
+ // const controller = await createController({
893
+ // listeners: { 'game-state': (data) => { /* handle state */ } },
894
+ // });
596
895
 
597
896
  let count = 0;
897
+ let controller: Controller;
898
+
899
+ const playerInfoEl = document.getElementById('player-info')!;
598
900
  const countEl = document.getElementById('count')!;
599
901
  const tapBtn = document.getElementById('tap-btn')!;
600
902
 
601
- const player = createIframePlayer({
602
- gameId: '${gameId}',
603
- listeners: {
604
- 'score-update': (data: { score: number }) => {
605
- count = data.score;
606
- countEl.textContent = String(count);
607
- },
608
- },
609
- });
903
+ async function init() {
904
+ controller = await createController({
905
+ debug: true,
906
+ // Lifecycle callbacks (uncomment to use):
907
+ // onControllerJoin: (playerIndex, info) => { console.log('Player joined:', playerIndex); },
908
+ // onControllerLeave: (playerIndex) => { console.log('Player left:', playerIndex); },
909
+ // onError: (error) => { console.error('SDK Error:', error.message); },
910
+ });
911
+
912
+ playerInfoEl.textContent = \`Player \${controller.myIndex}\`;
913
+
914
+ controller.on('score-update', (data: { score: number }) => {
915
+ count = data.score;
916
+ countEl.textContent = String(count);
917
+ });
918
+
919
+ controller.on('personal-message', (data: { text: string }) => {
920
+ console.log('Received message:', data.text);
921
+ });
922
+ }
923
+
924
+ init();
610
925
 
611
926
  tapBtn.addEventListener('pointerdown', () => {
612
- player.emit('tap', { timestamp: Date.now() });
927
+ controller.send('tap', { timestamp: Date.now() });
613
928
  count++;
614
929
  countEl.textContent = String(count);
615
930
  });
616
931
  `,
617
932
  };
618
933
  }
934
+
935
+ // ─── Dev Server templates ───
936
+
937
+ export function devServer(gameId) {
938
+ return {
939
+ "dev/server.js": `/**
940
+ * S'MORE Dev Server - minimal Socket.IO relay for local development.
941
+ *
942
+ * Replicates the production genericRelay protocol:
943
+ * Player -> Host: attach playerIndex, forward to host socket
944
+ * Host -> All: broadcast to all player sockets (no targetPlayerIndex)
945
+ * Host -> One: route to specific player, strip targetPlayerIndex
946
+ *
947
+ * System events (smore:*) are handled explicitly and never relayed.
948
+ */
949
+
950
+ import express from 'express';
951
+ import { createServer } from 'node:http';
952
+ import { createServer as createTcpServer } from 'node:net';
953
+ import { spawn } from 'node:child_process';
954
+ import { readFileSync } from 'node:fs';
955
+ import { Server } from 'socket.io';
956
+ import { fileURLToPath } from 'node:url';
957
+ import { dirname, join } from 'node:path';
958
+ import { networkInterfaces } from 'node:os';
959
+
960
+ const __dirname = dirname(fileURLToPath(import.meta.url));
961
+
962
+ // ── Port detection ──
963
+
964
+ function isPortFree(port) {
965
+ return new Promise((resolve) => {
966
+ const srv = createTcpServer();
967
+ srv.once('error', () => resolve(false));
968
+ srv.once('listening', () => srv.close(() => resolve(true)));
969
+ srv.listen(port);
970
+ });
971
+ }
972
+
973
+ async function findPort(preferred) {
974
+ for (let p = preferred; p < preferred + 100; p++) {
975
+ if (await isPortFree(p)) return p;
976
+ }
977
+ return new Promise((resolve) => {
978
+ const srv = createTcpServer();
979
+ srv.listen(0, () => {
980
+ const port = srv.address().port;
981
+ srv.close(() => resolve(port));
982
+ });
983
+ });
984
+ }
985
+
986
+ function getLocalIP() {
987
+ const nets = networkInterfaces();
988
+ for (const name of Object.keys(nets)) {
989
+ for (const net of nets[name]) {
990
+ if (net.family === 'IPv4' && !net.internal) return net.address;
991
+ }
992
+ }
993
+ return 'localhost';
994
+ }
995
+
996
+ // ── Room state ──
997
+
998
+ function generateCode() {
999
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
1000
+ let code = '';
1001
+ for (let i = 0; i < 4; i++) code += chars[Math.floor(Math.random() * chars.length)];
1002
+ return code;
1003
+ }
1004
+
1005
+ const room = {
1006
+ code: generateCode(),
1007
+ hostSocketId: null,
1008
+ players: [], // { socketId, playerIndex, nickname, sessionId }
1009
+ nextIndex: 0,
1010
+ gameId: '',
1011
+ status: 'waiting',
1012
+ };
1013
+
1014
+ function toPlayerDTO(p) {
1015
+ return { playerIndex: p.playerIndex, name: p.nickname, connected: p.connected !== undefined ? p.connected : true, character: null };
1016
+ }
1017
+
1018
+ function toRoomDTO() {
1019
+ return {
1020
+ code: room.code,
1021
+ players: room.players.map(toPlayerDTO),
1022
+ maxPlayers: 12,
1023
+ gameId: room.gameId,
1024
+ status: room.status,
1025
+ gameSelectionMode: false,
1026
+ };
1027
+ }
1028
+
1029
+ // ── Main ──
1030
+
1031
+ async function main() {
1032
+ const SERVER_PORT = await findPort(3000);
1033
+ const SCREEN_PORT = await findPort(5173);
1034
+ const CONTROLLER_PORT = await findPort(5174);
1035
+
1036
+ const app = express();
1037
+ const server = createServer(app);
1038
+ const io = new Server(server, {
1039
+ cors: {
1040
+ origin: true,
1041
+ methods: ['GET', 'POST'],
1042
+ },
1043
+ });
1044
+
1045
+ // ── Serve dev harness & controller page ──
1046
+
1047
+ function servePage(filePath) {
1048
+ return (_req, res) => {
1049
+ let html = readFileSync(filePath, 'utf-8');
1050
+ html = html.replace(/__LOCAL_IP__/g, localIP);
1051
+ html = html.replace(/__SERVER_PORT__/g, String(SERVER_PORT));
1052
+ html = html.replace(/__SCREEN_PORT__/g, String(SCREEN_PORT));
1053
+ html = html.replace(/__CONTROLLER_PORT__/g, String(CONTROLLER_PORT));
1054
+ res.type('html').send(html);
1055
+ };
1056
+ }
1057
+
1058
+ const localIP = getLocalIP();
1059
+ app.get('/', servePage(join(__dirname, 'index.html')));
1060
+ app.get('/controller', servePage(join(__dirname, 'controller.html')));
1061
+
1062
+ // ── Socket handling ──
1063
+
1064
+ io.on('connection', (socket) => {
1065
+ console.log(' [connect] ' + socket.id);
1066
+
1067
+ // Screen (host) connects
1068
+ // Note: smore:create matches production server's EVENT_NAMES.ROOM.CREATE
1069
+ socket.on('smore:create', (data, callback) => {
1070
+ // Reset room state for fresh start
1071
+ room.players = [];
1072
+ room.nextIndex = 0;
1073
+ room.gameId = '';
1074
+ room.status = 'waiting';
1075
+ room.hostSocketId = socket.id;
1076
+ socket.role = 'host';
1077
+ socket.join(room.code);
1078
+ console.log(' [host] Screen connected, room ' + room.code);
1079
+ if (typeof callback === 'function') {
1080
+ callback({ code: room.code, room: toRoomDTO() });
1081
+ }
1082
+ });
1083
+
1084
+ // Controller (player) joins
1085
+ socket.on('smore:join', (data, callback) => {
1086
+ const playerIndex = room.nextIndex++;
1087
+ const sessionId = 'session-' + playerIndex;
1088
+ const nickname = (data && data.nickname) || ('Player ' + (playerIndex + 1));
1089
+
1090
+ const player = { socketId: socket.id, playerIndex, nickname, sessionId };
1091
+ room.players.push(player);
1092
+
1093
+ socket.role = 'player';
1094
+ socket.playerIndex = playerIndex;
1095
+ socket.sessionId = sessionId;
1096
+ socket.join(room.code);
1097
+
1098
+ console.log(' [join] ' + nickname + ' (index ' + playerIndex + ')');
1099
+
1100
+ // Notify entire room (host + all controllers)
1101
+ io.to(room.code).emit('smore:player-joined', {
1102
+ player: toPlayerDTO(player),
1103
+ room: toRoomDTO(),
1104
+ });
1105
+
1106
+ if (typeof callback === 'function') {
1107
+ callback({
1108
+ success: true,
1109
+ playerIndex,
1110
+ sessionId,
1111
+ roomCode: room.code,
1112
+ player: toPlayerDTO(player),
1113
+ room: toRoomDTO(),
1114
+ });
1115
+ }
1116
+ });
1117
+
1118
+ // Game lifecycle
1119
+ // NOTE: Dev server combines select-game + start-game for simplicity.
1120
+ // Production server handles these as separate events.
1121
+ socket.on('smore:select-game', (data) => {
1122
+ room.gameId = (data && data.gameId) || '';
1123
+ room.status = 'playing';
1124
+ console.log(' [game] Selected: ' + room.gameId);
1125
+ io.to(room.code).emit('smore:game-selected', { gameId: room.gameId, room: toRoomDTO() });
1126
+ io.to(room.code).emit('smore:game-started', { gameId: room.gameId, room: toRoomDTO() });
1127
+ });
1128
+
1129
+ socket.on('smore:start-game', (data) => {
1130
+ room.status = 'playing';
1131
+ console.log(' [game] Started');
1132
+ io.to(room.code).emit('smore:game-started', { gameId: room.gameId, room: toRoomDTO() });
1133
+ });
1134
+
1135
+ socket.on('smore:game-over', (data) => {
1136
+ room.status = 'finished';
1137
+ console.log(' [game] Game over');
1138
+ io.to(room.code).emit('smore:game-over', data);
1139
+ });
1140
+
1141
+ socket.on('smore:return-to-selection', () => {
1142
+ room.status = 'waiting';
1143
+ room.gameId = '';
1144
+ console.log(' [game] Return to selection');
1145
+ io.to(room.code).emit('smore:selection-returned', { room: toRoomDTO() });
1146
+ });
1147
+
1148
+ // Dashboard reset
1149
+ socket.on('smore:reset-room', (data, callback) => {
1150
+ if (socket.role !== 'host') return;
1151
+ // Emit kicked to all players (they will disconnect themselves)
1152
+ for (const p of room.players) {
1153
+ const ps = io.sockets.sockets.get(p.socketId);
1154
+ if (ps) ps.emit('smore:kicked');
1155
+ }
1156
+ // Clear room state immediately (no setTimeout!)
1157
+ room.players = [];
1158
+ room.nextIndex = 0;
1159
+ room.gameId = '';
1160
+ room.status = 'waiting';
1161
+ console.log(' [reset] Room reset by host');
1162
+ if (typeof callback === 'function') callback({ success: true });
1163
+ });
1164
+
1165
+ // ── Generic relay (the core protocol) ──
1166
+ // Note: Rate limiting is not implemented in dev server. Production server applies rate limits.
1167
+ // Note: Event name validation (EVENT_NAME_REGEX) is not implemented in dev server. SDK client-side validateEventName() provides primary validation.
1168
+ socket.onAny((event, ...args) => {
1169
+ // Skip system events - they are handled above
1170
+ if (event.startsWith('smore:')) return;
1171
+
1172
+ const data = args[0];
1173
+ const callback = typeof args[args.length - 1] === 'function' ? args[args.length - 1] : undefined;
1174
+
1175
+ if (socket.role === 'player') {
1176
+ // Player -> Host: inject playerIndex, forward to host
1177
+ if (!room.hostSocketId) {
1178
+ if (callback) callback({ success: false, error: 'No host' });
1179
+ return;
1180
+ }
1181
+
1182
+ const payload = data && typeof data === 'object' ? data : (data !== undefined ? { data: data } : {});
1183
+ io.to(room.hostSocketId).emit(event, {
1184
+ ...payload,
1185
+ playerIndex: socket.playerIndex,
1186
+ });
1187
+
1188
+ if (callback) callback({ success: true });
1189
+
1190
+ } else if (socket.role === 'host') {
1191
+ if (data && data.targetPlayerIndex !== undefined) {
1192
+ // Host -> Specific Player: route by playerIndex, strip targetPlayerIndex
1193
+ const target = room.players.find((p) => p.playerIndex === data.targetPlayerIndex);
1194
+ if (target) {
1195
+ const { targetPlayerIndex, ...rest } = data;
1196
+ io.to(target.socketId).emit(event, rest);
1197
+ }
1198
+ } else {
1199
+ // Host -> All Players: broadcast to room (excludes host)
1200
+ socket.to(room.code).emit(event, data);
1201
+ }
1202
+ }
1203
+ });
1204
+
1205
+ // Disconnect
1206
+ // Dev server limitation: no reconnection support.
1207
+ // Production server has a grace period + sessionId-based reconnect.
1208
+ // Here, disconnect immediately removes the player (simplified for dev).
1209
+ // This means we emit smore:player-left instead of smore:player-disconnected,
1210
+ // because the player is permanently removed from the room.
1211
+ socket.on('disconnect', () => {
1212
+ if (socket.role === 'player') {
1213
+ const idx = room.players.findIndex((p) => p.socketId === socket.id);
1214
+ if (idx !== -1) {
1215
+ const player = room.players[idx];
1216
+ room.players.splice(idx, 1);
1217
+ console.log(' [leave] Player ' + player.playerIndex + ' left');
1218
+
1219
+ // Notify entire room (host + all controllers)
1220
+ // Dev server immediately removes players, so emit smore:player-left.
1221
+ io.to(room.code).emit('smore:player-left', {
1222
+ player: toPlayerDTO(player),
1223
+ room: toRoomDTO(),
1224
+ });
1225
+ }
1226
+ } else if (socket.role === 'host') {
1227
+ console.log(' [host] Screen disconnected');
1228
+ room.hostSocketId = null;
1229
+ }
1230
+ });
1231
+ });
1232
+
1233
+ // ── Start ──
1234
+
1235
+ server.listen(SERVER_PORT, () => {
1236
+ console.log('');
1237
+ console.log(" \\x1b[1m\\x1b[35mS'MORE Dev Server\\x1b[0m");
1238
+ console.log('');
1239
+ console.log(' Room Code: \\x1b[1m\\x1b[36m' + room.code + '\\x1b[0m');
1240
+ console.log('');
1241
+ console.log(' Dashboard: \\x1b[36mhttp://localhost:' + SERVER_PORT + '\\x1b[0m');
1242
+ console.log(' Controller: \\x1b[36mhttp://' + localIP + ':' + SERVER_PORT + '/controller\\x1b[0m (open on phone)');
1243
+ console.log('');
1244
+ console.log(' Screen app: \\x1b[36mhttp://localhost:' + SCREEN_PORT + '\\x1b[0m');
1245
+ console.log(' Controller: \\x1b[36mhttp://localhost:' + CONTROLLER_PORT + '\\x1b[0m');
1246
+ console.log('');
1247
+ });
1248
+
1249
+ const ROOT = join(__dirname, '..');
1250
+ const screenProc = spawn('npx', ['vite', '--port', String(SCREEN_PORT), '--host'], {
1251
+ cwd: join(ROOT, 'screen'), stdio: ['ignore', 'pipe', 'pipe'], shell: true,
1252
+ });
1253
+ const controllerProc = spawn('npx', ['vite', '--port', String(CONTROLLER_PORT), '--host'], {
1254
+ cwd: join(ROOT, 'controller'), stdio: ['ignore', 'pipe', 'pipe'], shell: true,
1255
+ });
1256
+ screenProc.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) console.error(' \\x1b[33m[screen]\\x1b[0m ' + s); });
1257
+ controllerProc.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) console.error(' \\x1b[33m[controller]\\x1b[0m ' + s); });
1258
+ function cleanup() { screenProc.kill(); controllerProc.kill(); process.exit(); }
1259
+ process.on('SIGINT', cleanup);
1260
+ process.on('SIGTERM', cleanup);
1261
+ }
1262
+
1263
+ main();
1264
+ `,
1265
+ };
1266
+ }
1267
+
1268
+ export function devHarness(gameId) {
1269
+ return {
1270
+ "dev/index.html": `<!DOCTYPE html>
1271
+ <html lang="en">
1272
+ <head>
1273
+ <meta charset="UTF-8" />
1274
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
1275
+ <title>S'MORE Dev Server</title>
1276
+ <script src="/socket.io/socket.io.js"><\/script>
1277
+ <style>
1278
+ * { margin: 0; padding: 0; box-sizing: border-box; }
1279
+ html, body { height: 100%; background: #0f0f0f; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; overflow: hidden; }
1280
+
1281
+ /* Top bar */
1282
+ .topbar {
1283
+ height: 48px; display: flex; align-items: center; gap: 10px;
1284
+ padding: 0 16px; background: #181818; border-bottom: 1px solid #2a2a2a;
1285
+ }
1286
+ .topbar-title { font-weight: 700; font-size: 14px; color: #fff; letter-spacing: -0.02em; white-space: nowrap; }
1287
+ .topbar-title span { color: #a78bfa; }
1288
+ .badge {
1289
+ background: #a78bfa; color: #0f0f0f; font-weight: 700; font-size: 12px;
1290
+ padding: 2px 8px; border-radius: 5px; letter-spacing: 0.06em; white-space: nowrap;
1291
+ }
1292
+ .badge-muted {
1293
+ background: #2a2a2a; color: #888; font-weight: 600; font-size: 11px;
1294
+ padding: 2px 8px; border-radius: 5px; white-space: nowrap;
1295
+ }
1296
+ .phase-badge {
1297
+ font-weight: 600; font-size: 11px; padding: 2px 8px; border-radius: 5px;
1298
+ white-space: nowrap; text-transform: uppercase; letter-spacing: 0.04em;
1299
+ }
1300
+ .phase-lobby { background: #1a3a1a; color: #4ade80; }
1301
+ .phase-playing { background: #3a2a1a; color: #fbbf24; }
1302
+ .phase-results { background: #1a1a3a; color: #818cf8; }
1303
+ .topbar-spacer { flex: 1; }
1304
+ .btn {
1305
+ background: #2a2a2a; color: #e0e0e0; border: 1px solid #3a3a3a;
1306
+ padding: 5px 12px; border-radius: 6px; font-size: 12px; cursor: pointer;
1307
+ font-weight: 600; transition: background 0.15s; white-space: nowrap;
1308
+ }
1309
+ .btn:hover { background: #3a3a3a; }
1310
+ .btn:disabled { opacity: 0.35; cursor: not-allowed; }
1311
+ .btn:disabled:hover { background: #2a2a2a; }
1312
+ .btn-primary { background: #4f46e5; border-color: #4f46e5; color: #fff; }
1313
+ .btn-primary:hover { background: #4338ca; }
1314
+ .btn-primary:disabled:hover { background: #4f46e5; }
1315
+ .btn-danger { background: #dc2626; border-color: #dc2626; color: #fff; }
1316
+ .btn-danger:hover { background: #b91c1c; }
1317
+
1318
+ /* Main layout */
1319
+ .main { display: flex; height: calc(100% - 48px); }
1320
+ .screen-panel {
1321
+ flex: 1; position: relative; border-right: 1px solid #2a2a2a;
1322
+ display: flex; align-items: center; justify-content: center; background: #111;
1323
+ overflow: hidden;
1324
+ }
1325
+ .screen-panel iframe {
1326
+ border: none; border-radius: 8px;
1327
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255,255,255,0.05);
1328
+ }
1329
+ .controller-panel {
1330
+ width: 320px; min-width: 320px; max-width: 320px;
1331
+ display: flex; flex-direction: column;
1332
+ padding: 8px; overflow-y: auto; background: #141414;
1333
+ }
1334
+ .controller-grid {
1335
+ display: grid; grid-template-columns: 1fr 1fr; gap: 6px;
1336
+ }
1337
+ .controller-slot {
1338
+ position: relative; border: 1px solid #2a2a2a; border-radius: 8px;
1339
+ overflow: hidden; background: #0f0f0f; aspect-ratio: 9 / 16;
1340
+ }
1341
+ .controller-slot iframe { width: 100%; height: 100%; border: none; display: block; }
1342
+ .controller-label {
1343
+ position: absolute; top: 4px; left: 4px; font-size: 10px;
1344
+ background: rgba(0,0,0,0.75); color: #888; padding: 1px 6px; border-radius: 3px;
1345
+ pointer-events: none; z-index: 1; white-space: nowrap;
1346
+ }
1347
+ .status-dot {
1348
+ width: 6px; height: 6px; border-radius: 50%; display: inline-block;
1349
+ margin-right: 4px; vertical-align: middle;
1350
+ }
1351
+ .status-connected { background: #22c55e; }
1352
+ .status-disconnected { background: #ef4444; }
1353
+ .status-pending { background: #eab308; }
1354
+ .empty-state {
1355
+ display: flex; align-items: center; justify-content: center;
1356
+ height: 200px; color: #444; font-size: 12px; text-align: center;
1357
+ padding: 24px; line-height: 1.5;
1358
+ }
1359
+ .placeholder {
1360
+ display: flex; align-items: center; justify-content: center;
1361
+ height: 100%; color: #555; font-size: 14px;
1362
+ }
1363
+
1364
+ /* Modal */
1365
+ .modal-overlay {
1366
+ position: fixed; inset: 0; background: rgba(0,0,0,0.75);
1367
+ display: flex; align-items: center; justify-content: center; z-index: 100;
1368
+ }
1369
+ .modal-overlay.hidden { display: none; }
1370
+ .modal {
1371
+ background: #1e1e1e; border: 1px solid #3a3a3a; border-radius: 16px;
1372
+ padding: 32px; max-width: 400px; width: 90%; text-align: center;
1373
+ }
1374
+ .modal h2 { font-size: 18px; margin-bottom: 16px; color: #fff; }
1375
+ .modal img { margin: 16px auto; display: block; border-radius: 8px; }
1376
+ .modal .url-text {
1377
+ font-size: 13px; color: #a78bfa; word-break: break-all;
1378
+ background: #2a2a2a; padding: 8px 12px; border-radius: 8px; margin: 12px 0;
1379
+ user-select: all;
1380
+ }
1381
+ .modal .btn { margin-top: 12px; }
1382
+ </style>
1383
+ </head>
1384
+ <body>
1385
+
1386
+ <div class="topbar">
1387
+ <div class="topbar-title"><span>S'MORE</span> Dev</div>
1388
+ <div class="badge" id="roomCode">----</div>
1389
+ <div class="badge-muted" id="playerCount">0 players</div>
1390
+ <div class="phase-badge phase-lobby" id="phaseBadge">Lobby</div>
1391
+ <div class="topbar-spacer"></div>
1392
+ <button class="btn btn-danger" id="endGameBtn" style="display:none;">End Game</button>
1393
+ <button class="btn" id="resetBtn">Reset</button>
1394
+ <button class="btn btn-primary" id="addPlayerBtn">+ Player</button>
1395
+ <button class="btn" id="phoneBtn">Phone</button>
1396
+ </div>
1397
+
1398
+ <div class="main">
1399
+ <div class="screen-panel" id="screenPanel">
1400
+ <div class="placeholder" id="screenPlaceholder">Waiting for screen app (localhost:5173)...</div>
1401
+ </div>
1402
+ <div class="controller-panel" id="controllerPanel">
1403
+ <div class="empty-state" id="emptyState">Press '+ Player' to add controllers</div>
1404
+ <div class="controller-grid" id="controllerGrid"></div>
1405
+ </div>
1406
+ </div>
1407
+
1408
+ <div class="modal-overlay hidden" id="phoneModal">
1409
+ <div class="modal">
1410
+ <h2>Open on Phone</h2>
1411
+ <p style="color:#888;font-size:13px;margin-bottom:8px;">Scan the QR code or enter the URL on your phone.</p>
1412
+ <img id="qrImage" width="200" height="200" alt="QR Code" />
1413
+ <div class="url-text" id="phoneUrl"></div>
1414
+ <button class="btn" id="closeModalBtn">Close</button>
1415
+ </div>
1416
+ </div>
1417
+
1418
+ <script>
1419
+ (function() {
1420
+ // ── State ──
1421
+ var SCREEN_URL = 'http://' + location.hostname + ':' + __SCREEN_PORT__;
1422
+ var CONTROLLER_URL = 'http://' + location.hostname + ':' + __CONTROLLER_PORT__;
1423
+ var roomCode = '';
1424
+ var players = []; // Local iframe controllers only
1425
+ var serverPlayers = []; // Authoritative full list from server (includes phone players)
1426
+ var screenSocket = null;
1427
+ var screenIframe = null;
1428
+ var screenReady = false;
1429
+ var gamePhase = 'lobby';
1430
+
1431
+ // ── DOM refs ──
1432
+ var roomCodeEl = document.getElementById('roomCode');
1433
+ var playerCountEl = document.getElementById('playerCount');
1434
+ var phaseBadgeEl = document.getElementById('phaseBadge');
1435
+ var screenPanel = document.getElementById('screenPanel');
1436
+ var screenPlaceholder = document.getElementById('screenPlaceholder');
1437
+ var controllerPanel = document.getElementById('controllerPanel');
1438
+ var controllerGrid = document.getElementById('controllerGrid');
1439
+ var emptyState = document.getElementById('emptyState');
1440
+ var addPlayerBtn = document.getElementById('addPlayerBtn');
1441
+ var endGameBtn = document.getElementById('endGameBtn');
1442
+ var resetBtn = document.getElementById('resetBtn');
1443
+
1444
+ // ── Phase management ──
1445
+ function setPhase(phase) {
1446
+ if (phase !== 'lobby' && phase !== 'playing' && phase !== 'results') return;
1447
+ gamePhase = phase;
1448
+ phaseBadgeEl.textContent = phase.charAt(0).toUpperCase() + phase.slice(1);
1449
+ phaseBadgeEl.className = 'phase-badge phase-' + phase;
1450
+ addPlayerBtn.disabled = (phase === 'playing');
1451
+ endGameBtn.style.display = (phase === 'playing') ? 'inline-block' : 'none';
1452
+ }
1453
+
1454
+ // ── Helpers ──
1455
+ function updatePlayerCount() {
1456
+ var count = serverPlayers.length;
1457
+ playerCountEl.textContent = count + ' player' + (count !== 1 ? 's' : '');
1458
+ }
1459
+
1460
+ function updateEmptyState() {
1461
+ emptyState.style.display = players.length === 0 ? 'flex' : 'none';
1462
+ }
1463
+
1464
+ function getPlayerList() {
1465
+ return serverPlayers;
1466
+ }
1467
+
1468
+ function broadcastUpdate() {
1469
+ var payload = { players: serverPlayers };
1470
+ if (screenIframe && screenReady) {
1471
+ screenIframe.contentWindow.postMessage({ type: '_bridge:update', payload: payload }, '*');
1472
+ }
1473
+ players.forEach(function(p) {
1474
+ if (p.iframe && p.ready) {
1475
+ p.iframe.contentWindow.postMessage({ type: '_bridge:update', payload: payload }, '*');
1476
+ }
1477
+ });
1478
+ }
1479
+
1480
+ // ── Screen resize (16:9 fit) ──
1481
+ function resizeScreen() {
1482
+ if (!screenIframe) return;
1483
+ var rect = screenPanel.getBoundingClientRect();
1484
+ var pw = rect.width - 48;
1485
+ var ph = rect.height - 48;
1486
+ if (pw <= 0 || ph <= 0) return;
1487
+ var w, h;
1488
+ if (pw / ph > 16 / 9) {
1489
+ h = ph; w = Math.round(h * 16 / 9);
1490
+ } else {
1491
+ w = pw; h = Math.round(w * 9 / 16);
1492
+ }
1493
+ screenIframe.style.width = w + 'px';
1494
+ screenIframe.style.height = h + 'px';
1495
+ }
1496
+
1497
+ window.addEventListener('resize', resizeScreen);
1498
+
1499
+ // ── Screen setup ──
1500
+ function initScreen(onRoomCreated) {
1501
+ screenSocket = io();
1502
+
1503
+ screenSocket.on('connect', function() {
1504
+ screenSocket.emit('smore:create', {}, function(res) {
1505
+ roomCode = res.code;
1506
+ roomCodeEl.textContent = roomCode;
1507
+ if (onRoomCreated) onRoomCreated();
1508
+ });
1509
+ });
1510
+
1511
+ screenIframe = document.createElement('iframe');
1512
+ screenIframe.src = SCREEN_URL;
1513
+ screenIframe.sandbox = 'allow-scripts allow-same-origin';
1514
+ screenPlaceholder.style.display = 'none';
1515
+ screenPanel.appendChild(screenIframe);
1516
+
1517
+ setTimeout(resizeScreen, 100);
1518
+
1519
+ // Bridge: screen socket.onAny -> iframe smore:event (game events only)
1520
+ screenSocket.onAny(function(event) {
1521
+ if (event.startsWith('smore:')) return;
1522
+ var data = arguments[1];
1523
+ if (screenIframe && screenReady) {
1524
+ screenIframe.contentWindow.postMessage({
1525
+ type: '_bridge:event',
1526
+ payload: { event: event, data: data },
1527
+ }, '*');
1528
+ }
1529
+ });
1530
+
1531
+ // Forward specific smore: system events to screen iframe
1532
+ var screenSysEvents = [
1533
+ 'smore:player-joined', 'smore:player-left', 'smore:player-disconnected', 'smore:player-reconnected',
1534
+ 'smore:player-character-updated', 'smore:rate-limited', 'smore:game-over'
1535
+ ];
1536
+ screenSysEvents.forEach(function(sysEvent) {
1537
+ screenSocket.on(sysEvent, function(data) {
1538
+ if (screenIframe && screenReady) {
1539
+ screenIframe.contentWindow.postMessage({
1540
+ type: '_bridge:event',
1541
+ payload: { event: sysEvent, data: data },
1542
+ }, '*');
1543
+ }
1544
+
1545
+ // Sync authoritative player list from server (production format: data.room.players)
1546
+ if (data && data.room && data.room.players) {
1547
+ serverPlayers = data.room.players;
1548
+ updatePlayerCount();
1549
+ broadcastUpdate();
1550
+ }
1551
+ });
1552
+ });
1553
+ }
1554
+
1555
+ // ── Controller setup ──
1556
+ function addController() {
1557
+ var controllerSocket = io({ forceNew: true });
1558
+ var idx = players.length;
1559
+ var nickname = 'Player ' + (idx + 1);
1560
+
1561
+ var slot = document.createElement('div');
1562
+ slot.className = 'controller-slot';
1563
+
1564
+ var label = document.createElement('div');
1565
+ label.className = 'controller-label';
1566
+ label.innerHTML = '<span class="status-dot status-pending"></span>P' + (idx + 1);
1567
+ slot.appendChild(label);
1568
+
1569
+ var iframe = document.createElement('iframe');
1570
+ iframe.src = CONTROLLER_URL;
1571
+ iframe.sandbox = 'allow-scripts allow-same-origin';
1572
+ slot.appendChild(iframe);
1573
+ controllerGrid.appendChild(slot);
1574
+
1575
+ var playerData = {
1576
+ playerIndex: -1, nickname: nickname, sessionId: '',
1577
+ socket: controllerSocket, iframe: iframe, label: label, slot: slot,
1578
+ connected: false, ready: false,
1579
+ };
1580
+ players.push(playerData);
1581
+ updatePlayerCount();
1582
+ updateEmptyState();
1583
+
1584
+ controllerSocket.on('connect', function() {
1585
+ controllerSocket.emit('smore:join', { nickname: nickname, roomCode: roomCode }, function(res) {
1586
+ if (!res || !res.success) return;
1587
+ playerData.playerIndex = res.playerIndex;
1588
+ playerData.sessionId = res.sessionId;
1589
+ playerData.connected = true;
1590
+ label.innerHTML = '<span class="status-dot status-connected"></span>P' + (res.playerIndex + 1);
1591
+ });
1592
+ });
1593
+
1594
+ controllerSocket.on('disconnect', function() {
1595
+ playerData.connected = false;
1596
+ label.innerHTML = '<span class="status-dot status-disconnected"></span>P' + (playerData.playerIndex >= 0 ? playerData.playerIndex + 1 : '?');
1597
+ });
1598
+
1599
+ // Bridge: controller socket.onAny -> iframe smore:event (game events only)
1600
+ controllerSocket.onAny(function(event) {
1601
+ if (event.startsWith('smore:')) return;
1602
+ var data = arguments[1];
1603
+ if (iframe && playerData.ready) {
1604
+ iframe.contentWindow.postMessage({
1605
+ type: '_bridge:event',
1606
+ payload: { event: event, data: data },
1607
+ }, '*');
1608
+ }
1609
+ });
1610
+
1611
+ // Forward system events to controller iframe
1612
+ var ctrlSysEvents = [
1613
+ 'smore:game-over',
1614
+ 'smore:player-joined', 'smore:player-left', 'smore:player-disconnected', 'smore:player-reconnected',
1615
+ 'smore:player-character-updated', 'smore:rate-limited'
1616
+ ];
1617
+ ctrlSysEvents.forEach(function(sysEvent) {
1618
+ controllerSocket.on(sysEvent, function(data) {
1619
+ if (iframe && playerData.ready) {
1620
+ iframe.contentWindow.postMessage({
1621
+ type: '_bridge:event',
1622
+ payload: { event: sysEvent, data: data },
1623
+ }, '*');
1624
+ }
1625
+ });
1626
+ });
1627
+ }
1628
+
1629
+ // ── postMessage listener (from all iframes) ──
1630
+ window.addEventListener('message', function(e) {
1631
+ var msg = e.data;
1632
+ if (!msg || typeof msg !== 'object' || typeof msg.type !== 'string') return;
1633
+ if (!msg.type.startsWith('_bridge:')) return;
1634
+
1635
+ if (screenIframe && e.source === screenIframe.contentWindow) {
1636
+ handleScreenMessage(msg);
1637
+ } else {
1638
+ var player = players.find(function(p) { return p.iframe && e.source === p.iframe.contentWindow; });
1639
+ if (player) handleControllerMessage(msg, player);
1640
+ }
1641
+ });
1642
+
1643
+ function handleScreenMessage(msg) {
1644
+ if (msg.type === '_bridge:ready') {
1645
+ screenReady = true;
1646
+ screenIframe.contentWindow.postMessage({
1647
+ type: '_bridge:init',
1648
+ payload: {
1649
+ side: 'host',
1650
+ roomCode: roomCode,
1651
+ players: getPlayerList(),
1652
+ },
1653
+ }, '*');
1654
+ return;
1655
+ }
1656
+
1657
+ if (msg.type === '_bridge:emit') {
1658
+ var event = msg.payload.event;
1659
+ var data = msg.payload.data;
1660
+ var ackId = msg.payload.ackId;
1661
+
1662
+ // Phase tracking from screen messages
1663
+ if (data && typeof data === 'object' && data.phase) {
1664
+ if (data.phase === 'lobby' || data.phase === 'playing' || data.phase === 'results') {
1665
+ setPhase(data.phase);
1666
+ }
1667
+ }
1668
+
1669
+ // Intercept game-over to broadcast via system event and update phase
1670
+ if (event === 'smore:game-over') {
1671
+ setPhase('results');
1672
+ screenSocket.emit('smore:game-over', data);
1673
+ return;
1674
+ }
1675
+
1676
+ // Block smore:* events from crossing the bridge
1677
+ if (event.startsWith('smore:')) return;
1678
+
1679
+ if (ackId) {
1680
+ screenSocket.emit(event, data, function(response) {
1681
+ screenIframe.contentWindow.postMessage({ type: '_bridge:ack', payload: { ackId: ackId, data: response } }, '*');
1682
+ });
1683
+ } else {
1684
+ screenSocket.emit(event, data);
1685
+ }
1686
+ }
1687
+ }
1688
+
1689
+ function handleControllerMessage(msg, player) {
1690
+ if (msg.type === '_bridge:ready') {
1691
+ player.ready = true;
1692
+ player.iframe.contentWindow.postMessage({
1693
+ type: '_bridge:init',
1694
+ payload: {
1695
+ side: 'player',
1696
+ roomCode: roomCode,
1697
+ players: getPlayerList(),
1698
+ myIndex: player.playerIndex,
1699
+ },
1700
+ }, '*');
1701
+ return;
1702
+ }
1703
+
1704
+ if (msg.type === '_bridge:emit') {
1705
+ var event = msg.payload.event;
1706
+ var data = msg.payload.data;
1707
+ var ackId = msg.payload.ackId;
1708
+
1709
+ // Phase tracking from controller messages
1710
+ if (event === 'start-game' || event === 'start') {
1711
+ setPhase('playing');
1712
+ }
1713
+ if (event === 'play-again' || event === 'restart') {
1714
+ setPhase('lobby');
1715
+ }
1716
+
1717
+ // Block smore:* events
1718
+ if (event.startsWith('smore:')) return;
1719
+
1720
+ if (ackId) {
1721
+ player.socket.emit(event, data, function(response) {
1722
+ player.iframe.contentWindow.postMessage({ type: '_bridge:ack', payload: { ackId: ackId, data: response } }, '*');
1723
+ });
1724
+ } else {
1725
+ player.socket.emit(event, data);
1726
+ }
1727
+ }
1728
+ }
1729
+
1730
+ // ── End Game ──
1731
+ function endGame() {
1732
+ // Broadcast phase-change to ALL controllers (including phone) via server relay
1733
+ screenSocket.emit('phase-change', { phase: 'lobby' });
1734
+
1735
+ // Reload screen iframe
1736
+ if (screenIframe) {
1737
+ screenReady = false;
1738
+ screenIframe.src = SCREEN_URL;
1739
+ }
1740
+ // Reload local controller iframes (phone controllers get the broadcast above)
1741
+ players.forEach(function(p) {
1742
+ p.ready = false;
1743
+ if (p.iframe) {
1744
+ p.iframe.src = CONTROLLER_URL;
1745
+ }
1746
+ });
1747
+ setPhase('lobby');
1748
+ }
1749
+
1750
+ // ── Reset ──
1751
+ function resetAll() {
1752
+ // 1. Disconnect all local iframe player sockets
1753
+ players.forEach(function(p) {
1754
+ if (p.socket) p.socket.disconnect();
1755
+ });
1756
+ players = [];
1757
+ serverPlayers = [];
1758
+
1759
+ // 2. Clear UI
1760
+ updatePlayerCount();
1761
+ controllerGrid.innerHTML = '';
1762
+ updateEmptyState();
1763
+ roomCode = '';
1764
+ roomCodeEl.textContent = '----';
1765
+ setPhase('lobby');
1766
+
1767
+ // 3. Remove old screen iframe
1768
+ if (screenIframe) {
1769
+ screenIframe.remove();
1770
+ screenIframe = null;
1771
+ screenReady = false;
1772
+ }
1773
+
1774
+ // 4. Tell dev server to reset room (disconnects all phone player sockets)
1775
+ // Then disconnect host and re-initialize fresh
1776
+ var oldSocket = screenSocket;
1777
+ screenSocket = null;
1778
+ if (oldSocket) {
1779
+ oldSocket.emit('smore:reset-room', {}, function() {
1780
+ oldSocket.disconnect();
1781
+ initScreen(function() { addController(); });
1782
+ });
1783
+ // Fallback if callback doesn't fire
1784
+ setTimeout(function() {
1785
+ if (!screenSocket) {
1786
+ oldSocket.disconnect();
1787
+ initScreen(function() { addController(); });
1788
+ }
1789
+ }, 1000);
1790
+ } else {
1791
+ initScreen(function() { addController(); });
1792
+ }
1793
+ }
1794
+
1795
+ // ── Button handlers ──
1796
+ addPlayerBtn.addEventListener('click', function() {
1797
+ if (!addPlayerBtn.disabled) addController();
1798
+ });
1799
+
1800
+ endGameBtn.addEventListener('click', endGame);
1801
+ resetBtn.addEventListener('click', resetAll);
1802
+
1803
+ document.getElementById('phoneBtn').addEventListener('click', function() {
1804
+ var phoneUrl = 'http://__LOCAL_IP__:' + __SERVER_PORT__ + '/controller';
1805
+ document.getElementById('phoneUrl').textContent = phoneUrl;
1806
+ // Note: QR code generation requires external service (api.qrserver.com)
1807
+ // If offline, the URL text below can be manually entered on the phone
1808
+ document.getElementById('qrImage').src =
1809
+ 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=' + encodeURIComponent(phoneUrl);
1810
+ document.getElementById('qrImage').onerror = function() { this.style.display='none'; };
1811
+ document.getElementById('phoneModal').classList.remove('hidden');
1812
+ });
1813
+
1814
+ document.getElementById('closeModalBtn').addEventListener('click', function() {
1815
+ document.getElementById('phoneModal').classList.add('hidden');
1816
+ });
1817
+
1818
+ // ── Boot ──
1819
+ initScreen();
1820
+ setTimeout(function() { addController(); }, 500);
1821
+ })();
1822
+ <\/script>
1823
+ </body>
1824
+ </html>
1825
+ `,
1826
+ };
1827
+ }
1828
+
1829
+ export function devControllerPage(gameId) {
1830
+ return {
1831
+ "dev/controller.html": `<!DOCTYPE html>
1832
+ <html lang="en">
1833
+ <head>
1834
+ <meta charset="UTF-8" />
1835
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
1836
+ <title>S'MORE Controller</title>
1837
+ <script src="/socket.io/socket.io.js"><\/script>
1838
+ <style>
1839
+ * { margin: 0; padding: 0; box-sizing: border-box; }
1840
+ html, body {
1841
+ width: 100%; height: 100vh; height: 100dvh;
1842
+ background: #0f0f0f; color: #fff; font-family: sans-serif;
1843
+ overflow: hidden; overscroll-behavior: none;
1844
+ }
1845
+ .container {
1846
+ position: fixed; inset: 0;
1847
+ padding: env(safe-area-inset-top) env(safe-area-inset-right)
1848
+ env(safe-area-inset-bottom) env(safe-area-inset-left);
1849
+ touch-action: manipulation; user-select: none;
1850
+ -webkit-user-select: none; -webkit-touch-callout: none;
1851
+ -webkit-tap-highlight-color: transparent;
1852
+ }
1853
+ .loading {
1854
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
1855
+ height: 100%; gap: 16px;
1856
+ }
1857
+ .loading-text { font-size: 16px; color: #888; }
1858
+ .loading .spinner {
1859
+ width: 32px; height: 32px; border: 3px solid #333; border-top-color: #a78bfa;
1860
+ border-radius: 50%; animation: spin 0.8s linear infinite;
1861
+ }
1862
+ @keyframes spin { to { transform: rotate(360deg); } }
1863
+ iframe {
1864
+ position: fixed; inset: 0; width: 100%; height: 100%;
1865
+ border: none; background: #0f0f0f;
1866
+ }
1867
+ iframe.hidden { display: none; }
1868
+ </style>
1869
+ </head>
1870
+ <body>
1871
+
1872
+ <div class="container" id="loadingScreen">
1873
+ <div class="loading">
1874
+ <div class="spinner"></div>
1875
+ <div class="loading-text">Connecting...</div>
1876
+ </div>
1877
+ </div>
1878
+
1879
+ <iframe id="controllerFrame" class="hidden" sandbox="allow-scripts allow-same-origin"></iframe>
1880
+
1881
+ <script>
1882
+ (function() {
1883
+ var CONTROLLER_URL = 'http://' + location.hostname + ':' + __CONTROLLER_PORT__;
1884
+ var loadingScreen = document.getElementById('loadingScreen');
1885
+ var iframe = document.getElementById('controllerFrame');
1886
+
1887
+ var socket = null;
1888
+ var playerIndex = -1;
1889
+ var sessionId = '';
1890
+ var roomCode = '';
1891
+ var players = [];
1892
+ var iframeReady = false;
1893
+
1894
+ // Connect and join room
1895
+ socket = io();
1896
+
1897
+ socket.on('connect', function() {
1898
+ var nickname = 'Phone Player';
1899
+ socket.emit('smore:join', { nickname: nickname }, function(res) {
1900
+ if (!res || !res.success) return;
1901
+ playerIndex = res.playerIndex;
1902
+ sessionId = res.sessionId;
1903
+ roomCode = res.roomCode;
1904
+ players = (res.room && res.room.players) || [];
1905
+
1906
+ // Show iframe
1907
+ iframe.src = CONTROLLER_URL;
1908
+ iframe.classList.remove('hidden');
1909
+ loadingScreen.style.display = 'none';
1910
+ });
1911
+ });
1912
+
1913
+ // Bridge: socket -> iframe (game events only)
1914
+ socket.onAny(function(event) {
1915
+ if (event.startsWith('smore:')) return;
1916
+ var data = arguments[1];
1917
+ if (iframe && iframeReady) {
1918
+ iframe.contentWindow.postMessage({
1919
+ type: '_bridge:event',
1920
+ payload: { event: event, data: data },
1921
+ }, '*');
1922
+ }
1923
+ });
1924
+
1925
+ // Forward system events to iframe
1926
+ var sysEvents = [
1927
+ 'smore:game-over',
1928
+ 'smore:player-joined', 'smore:player-left', 'smore:player-disconnected', 'smore:player-reconnected',
1929
+ 'smore:player-character-updated', 'smore:rate-limited'
1930
+ ];
1931
+ sysEvents.forEach(function(sysEvent) {
1932
+ socket.on(sysEvent, function(data) {
1933
+ if (iframe && iframeReady) {
1934
+ iframe.contentWindow.postMessage({
1935
+ type: '_bridge:event',
1936
+ payload: { event: sysEvent, data: data },
1937
+ }, '*');
1938
+ }
1939
+ // Update local player list (production format: data.room.players)
1940
+ if (data && data.room && data.room.players) {
1941
+ players = data.room.players;
1942
+ }
1943
+ });
1944
+ });
1945
+
1946
+ // Handle kicked (room reset by host)
1947
+ socket.on('smore:kicked', function() {
1948
+ // Stop reconnection
1949
+ socket.io.opts.reconnection = false;
1950
+ socket.disconnect();
1951
+ // Replace page with disconnected screen
1952
+ document.body.innerHTML = '<div style="position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#0f0f0f;color:#fff;font-family:sans-serif;gap:16px;padding:24px;text-align:center;">' +
1953
+ '<div style="font-size:48px;">👋</div>' +
1954
+ '<div style="font-size:20px;font-weight:700;">Room Closed</div>' +
1955
+ '<div style="font-size:14px;color:#888;">The host has reset the room.</div>' +
1956
+ '</div>';
1957
+ });
1958
+
1959
+ // Bridge: iframe -> socket (postMessage)
1960
+ window.addEventListener('message', function(e) {
1961
+ if (e.source !== iframe.contentWindow) return;
1962
+ var msg = e.data;
1963
+ if (!msg || typeof msg !== 'object' || typeof msg.type !== 'string') return;
1964
+ if (!msg.type.startsWith('_bridge:')) return;
1965
+
1966
+ if (msg.type === '_bridge:ready') {
1967
+ iframeReady = true;
1968
+ iframe.contentWindow.postMessage({
1969
+ type: '_bridge:init',
1970
+ payload: {
1971
+ side: 'player',
1972
+ roomCode: roomCode,
1973
+ players: players,
1974
+ myIndex: playerIndex,
1975
+ },
1976
+ }, '*');
1977
+ }
1978
+
1979
+ if (msg.type === '_bridge:emit') {
1980
+ var event = msg.payload.event;
1981
+ var data = msg.payload.data;
1982
+ var ackId = msg.payload.ackId;
1983
+
1984
+ // Block smore:* events
1985
+ if (event.startsWith('smore:')) return;
1986
+
1987
+ if (ackId) {
1988
+ socket.emit(event, data, function(response) {
1989
+ iframe.contentWindow.postMessage({ type: '_bridge:ack', payload: { ackId: ackId, data: response } }, '*');
1990
+ });
1991
+ } else {
1992
+ socket.emit(event, data);
1993
+ }
1994
+ }
1995
+ });
1996
+ })();
1997
+ <\/script>
1998
+ </body>
1999
+ </html>
2000
+ `,
2001
+ };
2002
+ }