create-smore-game 2.3.0 → 3.0.0

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