create-smore-game 2.2.0 → 2.4.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.
Files changed (3) hide show
  1. package/README.md +159 -0
  2. package/package.json +1 -1
  3. package/templates.js +320 -67
package/README.md ADDED
@@ -0,0 +1,159 @@
1
+ # create-smore-game
2
+
3
+ Scaffold a multiplayer party game for the S'MORE platform.
4
+
5
+ ---
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ npx create-smore-game my-game
11
+ cd my-game
12
+ npm install
13
+ npm run dev
14
+ ```
15
+
16
+ The CLI prompts you for two choices:
17
+
18
+ ```
19
+ create-smore-game
20
+
21
+ Screen (TV) template:
22
+ React + Phaser
23
+ React only
24
+ Vanilla JS
25
+
26
+ Controller (phone) template:
27
+ React
28
+ Vanilla JS
29
+ ```
30
+
31
+ After answering, your project is ready to run.
32
+
33
+ ---
34
+
35
+ ## What You Get
36
+
37
+ ```
38
+ my-game/
39
+ ├── screen/ # TV/display — game logic lives here
40
+ │ ├── src/
41
+ │ │ ├── App.tsx # Main game component
42
+ │ │ └── __tests__/ # Game tests with vitest
43
+ │ ├── package.json
44
+ │ └── vite.config.ts
45
+ ├── controller/ # Phone/player input — stateless display + input
46
+ │ ├── src/
47
+ │ │ ├── App.tsx # Controller UI
48
+ │ │ └── __tests__/ # Controller tests
49
+ │ ├── package.json
50
+ │ └── vite.config.ts
51
+ ├── dev/
52
+ │ ├── server.js # Local dev server with Socket.IO
53
+ │ ├── harness.html # Test screen + controllers together
54
+ │ └── controller-page.html
55
+ ├── game.json # Game metadata (title, player count, etc.)
56
+ ├── .env.example
57
+ └── package.json # npm workspaces root
58
+ ```
59
+
60
+ ---
61
+
62
+ ## Development
63
+
64
+ ### Commands
65
+
66
+ | Command | Description |
67
+ |---|---|
68
+ | `npm run dev` | Start dev server (screen + controller + harness) |
69
+ | `npm run dev:screen` | Screen only |
70
+ | `npm run dev:controller` | Controller only |
71
+ | `npm run build` | Production build |
72
+ | `npm run zip` | Build + package as `game.zip` for deployment |
73
+
74
+ ### Dev Harness
75
+
76
+ Running `npm run dev` starts a local Socket.IO server and opens a harness page with the screen and one or more controller iframes side by side. You can add and remove players directly in the browser to test the full game flow without a real device.
77
+
78
+ ---
79
+
80
+ ## Architecture: Stateless Controller Pattern
81
+
82
+ S'MORE games follow a strict separation of concerns:
83
+
84
+ - **Screen (TV)** — owns all game state and logic. It is the single source of truth.
85
+ - **Controller (Phone)** — a stateless input device. It only renders what the Screen tells it to render, and only sends raw user input back.
86
+
87
+ ```
88
+ Screen → "Show vote UI with options A, B, C" → Controller renders buttons
89
+ Controller → "Player tapped A" → Screen processes vote, updates state
90
+ Screen → "Show results: A won" → Controller renders results
91
+ ```
92
+
93
+ This pattern keeps game logic centralized and makes controllers trivially simple to implement. For full API details, see the `@smoregg/sdk` documentation.
94
+
95
+ ---
96
+
97
+ ## Templates
98
+
99
+ ### Screen Templates
100
+
101
+ | Template | Best for |
102
+ |---|---|
103
+ | **React + Phaser** | Graphics-heavy games — sprites, animations, physics |
104
+ | **React** | UI-based games — cards, voting, text prompts |
105
+ | **Vanilla JS** | Minimal projects with no framework dependency |
106
+
107
+ ### Controller Templates
108
+
109
+ | Template | Best for |
110
+ |---|---|
111
+ | **React** | Recommended for most games |
112
+ | **Vanilla JS** | Lightweight alternative |
113
+
114
+ ---
115
+
116
+ ## Game Metadata
117
+
118
+ `game.json` at the project root describes your game to the S'MORE platform:
119
+
120
+ ```json
121
+ {
122
+ "id": "my-game",
123
+ "title": "My Game",
124
+ "description": "",
125
+ "version": "0.1.0",
126
+ "minPlayers": 2,
127
+ "maxPlayers": 8,
128
+ "categories": ["party"]
129
+ }
130
+ ```
131
+
132
+ Edit this file before submitting. The `id` must be unique and contain only lowercase letters, numbers, and hyphens.
133
+
134
+ ---
135
+
136
+ ## Building and Deploying
137
+
138
+ ```bash
139
+ npm run zip
140
+ ```
141
+
142
+ This builds both the screen and controller workspaces, copies `game.json` into the output, and packages everything as `game.zip`. Submit this file to the S'MORE platform to deploy your game.
143
+
144
+ ---
145
+
146
+ ## SDK Documentation
147
+
148
+ The scaffolded project uses `@smoregg/sdk` for all platform communication. See the SDK package for the full API reference, including:
149
+
150
+ - Connecting screen and controller
151
+ - Sending and receiving typed events
152
+ - Managing player sessions
153
+ - Testing utilities (`createMockScreen`, `createMockController`)
154
+
155
+ ---
156
+
157
+ ## License
158
+
159
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-smore-game",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "create-smore-game": "./index.js"
package/templates.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // pnpm-workspace.yaml is no longer generated.
4
4
  // npm workspaces are configured in root package.json instead.
5
5
 
6
- const SDK_VERSION = '^2.2.0';
6
+ const SDK_VERSION = '^2.3.0';
7
7
 
8
8
  export function rootPackageJson(name) {
9
9
  return JSON.stringify(
@@ -35,6 +35,160 @@ export function envExample() {
35
35
  `;
36
36
  }
37
37
 
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);
143
+
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
+
38
192
  // ─── Screen templates ───
39
193
 
40
194
  const screenTsconfig = JSON.stringify(
@@ -130,6 +284,7 @@ export function screenReactPhaser(gameId) {
130
284
  scripts: {
131
285
  dev: "vite",
132
286
  build: "tsc && vite build",
287
+ test: "vitest run",
133
288
  },
134
289
  dependencies: {
135
290
  react: "^18.3.1",
@@ -143,6 +298,7 @@ export function screenReactPhaser(gameId) {
143
298
  "@vitejs/plugin-react": "^4.3.0",
144
299
  typescript: "^5.5.0",
145
300
  vite: "^5.4.0",
301
+ vitest: "^1.6.0",
146
302
  },
147
303
  },
148
304
  null,
@@ -163,8 +319,10 @@ import Phaser from 'phaser';
163
319
  import { GameScene } from './scenes/GameScene';
164
320
 
165
321
  interface GameEvents {
322
+ // Screen → Controller (view state)
166
323
  'score-update': { score: number };
167
324
  'personal-message': { text: string };
325
+ // Controller → Screen (input)
168
326
  'tap': { timestamp: number };
169
327
  }
170
328
 
@@ -185,6 +343,7 @@ export function App() {
185
343
  const gameRef = useRef<Phaser.Game | null>(null);
186
344
  const [roomCode, setRoomCode] = useState('');
187
345
  const [controllers, setControllers] = useState<ControllerInfo[]>([]);
346
+ const [tapCount, setTapCount] = useState(0);
188
347
 
189
348
  useEffect(() => {
190
349
  let mounted = true;
@@ -238,6 +397,12 @@ export function App() {
238
397
  console.log('Player', playerIndex, 'tapped:', data);
239
398
  // Forward input to Phaser scene
240
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
+ });
241
406
  });
242
407
 
243
408
  return () => {
@@ -305,6 +470,7 @@ export class GameScene extends Phaser.Scene {
305
470
  }
306
471
  }
307
472
  `,
473
+ "src/__tests__/game.test.ts": screenTestFile(),
308
474
  };
309
475
  }
310
476
 
@@ -321,6 +487,7 @@ export function screenReact(gameId) {
321
487
  scripts: {
322
488
  dev: "vite",
323
489
  build: "tsc && vite build",
490
+ test: "vitest run",
324
491
  },
325
492
  dependencies: {
326
493
  react: "^18.3.1",
@@ -333,6 +500,7 @@ export function screenReact(gameId) {
333
500
  "@vitejs/plugin-react": "^4.3.0",
334
501
  typescript: "^5.5.0",
335
502
  vite: "^5.4.0",
503
+ vitest: "^1.6.0",
336
504
  },
337
505
  },
338
506
  null,
@@ -351,8 +519,10 @@ import type { Screen, ControllerInfo, GameResults } from '@smoregg/sdk';
351
519
  import { useEffect, useRef, useState } from 'react';
352
520
 
353
521
  interface GameEvents {
522
+ // Screen → Controller (view state)
354
523
  'score-update': { score: number };
355
524
  'personal-message': { text: string };
525
+ // Controller → Screen (input)
356
526
  'tap': { timestamp: number };
357
527
  }
358
528
 
@@ -373,7 +543,7 @@ export function App() {
373
543
  const [roomCode, setRoomCode] = useState('');
374
544
  const [controllers, setControllers] = useState<ControllerInfo[]>([]);
375
545
  const [taps, setTaps] = useState<{ playerIndex: number; time: number }[]>([]);
376
- const [customStates, setCustomStates] = useState<Record<number, Record<string, unknown>>>({});
546
+ const [tapCount, setTapCount] = useState(0);
377
547
 
378
548
  useEffect(() => {
379
549
  let mounted = true;
@@ -413,11 +583,6 @@ export function App() {
413
583
  setControllers([...screen.controllers]);
414
584
  });
415
585
 
416
- screen.onCustomStateChange((playerIndex, state) => {
417
- if (!mounted) return;
418
- setCustomStates((prev) => ({ ...prev, [playerIndex]: state }));
419
- });
420
-
421
586
  screenRef.current = screen;
422
587
 
423
588
  // Use screen.on(event, handler) / screen.off(event, handler) for dynamic event listeners.
@@ -425,6 +590,12 @@ export function App() {
425
590
  screen.on('tap', (playerIndex, data) => {
426
591
  if (!mounted) return;
427
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
+ });
428
599
  });
429
600
 
430
601
  return () => {
@@ -434,11 +605,6 @@ export function App() {
434
605
  };
435
606
  }, []);
436
607
 
437
- // Example: send score update to all players
438
- const handleBroadcastScore = () => {
439
- screenRef.current?.broadcast('score-update', { score: 100 });
440
- };
441
-
442
608
  // Example: send to specific player
443
609
  const handleSendToPlayer = (playerIndex: number) => {
444
610
  screenRef.current?.sendToController(playerIndex, 'personal-message', { text: 'Hello!' });
@@ -476,6 +642,7 @@ export function App() {
476
642
  );
477
643
  }
478
644
  `,
645
+ "src/__tests__/game.test.ts": screenTestFile(),
479
646
  };
480
647
  }
481
648
 
@@ -492,6 +659,7 @@ export function screenVanilla(gameId) {
492
659
  scripts: {
493
660
  dev: "vite",
494
661
  build: "tsc && vite build",
662
+ test: "vitest run",
495
663
  },
496
664
  dependencies: {
497
665
  "@smoregg/sdk": SDK_VERSION, // TODO: auto-detect or update on release
@@ -499,6 +667,7 @@ export function screenVanilla(gameId) {
499
667
  devDependencies: {
500
668
  typescript: "^5.5.0",
501
669
  vite: "^5.4.0",
670
+ vitest: "^1.6.0",
502
671
  },
503
672
  },
504
673
  null,
@@ -535,8 +704,10 @@ export function screenVanilla(gameId) {
535
704
  import type { GameResults } from '@smoregg/sdk';
536
705
 
537
706
  interface GameEvents {
707
+ // Screen → Controller (view state)
538
708
  'score-update': { score: number };
539
709
  'personal-message': { text: string };
710
+ // Controller → Screen (input)
540
711
  'tap': { timestamp: number };
541
712
  }
542
713
 
@@ -590,6 +761,8 @@ screen.onAllReady(() => {
590
761
 
591
762
  // Use screen.on(event, handler) / screen.off(event, handler) for dynamic event listeners.
592
763
  // destroy() automatically removes all listeners, so explicit off() cleanup is not needed.
764
+ let tapCount = 0;
765
+
593
766
  screen.on('tap', (playerIndex, data) => {
594
767
  const line = document.createElement('div');
595
768
  line.textContent = \`Player \${playerIndex} tapped\`;
@@ -598,6 +771,9 @@ screen.on('tap', (playerIndex, data) => {
598
771
  while (logEl.children.length > 10) {
599
772
  logEl.removeChild(logEl.firstChild!);
600
773
  }
774
+ // Broadcast updated score back to controllers (Screen is source of truth)
775
+ tapCount++;
776
+ screen.broadcast('score-update', { score: tapCount });
601
777
  });
602
778
 
603
779
  function updateStatus() {
@@ -606,8 +782,7 @@ function updateStatus() {
606
782
  }
607
783
 
608
784
  // Example functions (can be called from console for testing):
609
- // screen.broadcast('score-update', { score: 100 });
610
- // screen.sendToController(0, 'message', { text: 'Hello!' });
785
+ // screen.sendToController(0, 'personal-message', { text: 'Hello!' });
611
786
  // const results: GameResults = {
612
787
  // scores: { 0: 50, 1: 75 },
613
788
  // // winner: 0,
@@ -615,6 +790,7 @@ function updateStatus() {
615
790
  // };
616
791
  // screen.gameOver(results);
617
792
  `,
793
+ "src/__tests__/game.test.ts": screenTestFileVanilla(),
618
794
  };
619
795
  }
620
796
 
@@ -666,6 +842,7 @@ export function controllerReact(gameId) {
666
842
  scripts: {
667
843
  dev: "vite",
668
844
  build: "tsc && vite build",
845
+ test: "vitest run",
669
846
  },
670
847
  dependencies: {
671
848
  react: "^18.3.1",
@@ -678,6 +855,7 @@ export function controllerReact(gameId) {
678
855
  "@vitejs/plugin-react": "^4.3.0",
679
856
  typescript: "^5.5.0",
680
857
  vite: "^5.4.0",
858
+ vitest: "^1.6.0",
681
859
  },
682
860
  },
683
861
  null,
@@ -725,11 +903,27 @@ import type { Controller, ControllerInfo } from '@smoregg/sdk';
725
903
  import { useEffect, useRef, useState } from 'react';
726
904
 
727
905
  interface GameEvents {
906
+ // Screen → Controller (view state)
728
907
  'score-update': { score: number };
729
908
  'personal-message': { text: string };
909
+ // Controller → Screen (input)
730
910
  'tap': { timestamp: number };
731
911
  }
732
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
+
733
927
  export function App() {
734
928
  const controllerRef = useRef<Controller | null>(null);
735
929
  const [myIndex, setMyIndex] = useState(-1);
@@ -776,7 +970,6 @@ export function App() {
776
970
 
777
971
  const handleTap = () => {
778
972
  controllerRef.current?.send('tap', { timestamp: Date.now() });
779
- setCount((c) => c + 1);
780
973
  };
781
974
 
782
975
  return (
@@ -787,7 +980,7 @@ export function App() {
787
980
  touchAction: 'manipulation', userSelect: 'none',
788
981
  }}>
789
982
  {isReady && (
790
- <div style={{ fontSize: '16px', opacity: 0.6 }}>{me?.name ?? \`Player \${myIndex}\`}</div>
983
+ <div style={{ fontSize: '16px', opacity: 0.6 }}>{me?.nickname ?? \`Player \${myIndex}\`}</div>
791
984
  )}
792
985
  <div style={{ fontSize: '48px', fontWeight: 'bold' }}>{count}</div>
793
986
  <button
@@ -805,6 +998,7 @@ export function App() {
805
998
  );
806
999
  }
807
1000
  `,
1001
+ "src/__tests__/game.test.ts": controllerTestFile(),
808
1002
  };
809
1003
  }
810
1004
 
@@ -819,6 +1013,7 @@ export function controllerVanilla(gameId) {
819
1013
  scripts: {
820
1014
  dev: "vite",
821
1015
  build: "tsc && vite build",
1016
+ test: "vitest run",
822
1017
  },
823
1018
  dependencies: {
824
1019
  "@smoregg/sdk": SDK_VERSION, // TODO: auto-detect or update on release
@@ -826,6 +1021,7 @@ export function controllerVanilla(gameId) {
826
1021
  devDependencies: {
827
1022
  typescript: "^5.5.0",
828
1023
  vite: "^5.4.0",
1024
+ vitest: "^1.6.0",
829
1025
  },
830
1026
  },
831
1027
  null,
@@ -884,12 +1080,26 @@ export default defineConfig({
884
1080
  "src/main.ts": `import { createController } from '@smoregg/sdk';
885
1081
 
886
1082
  interface GameEvents {
1083
+ // Screen → Controller (view state)
887
1084
  'score-update': { score: number };
888
1085
  'personal-message': { text: string };
1086
+ // Controller → Screen (input)
889
1087
  'tap': { timestamp: number };
890
1088
  }
891
1089
 
892
- let count = 0;
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
+ */
893
1103
 
894
1104
  const playerInfoEl = document.getElementById('player-info')!;
895
1105
  const countEl = document.getElementById('count')!;
@@ -905,12 +1115,11 @@ const controller = createController<GameEvents>({ debug: true });
905
1115
  // To control autoReady: createScreen({ autoReady: false }) or createController({ autoReady: false })
906
1116
 
907
1117
  controller.onAllReady(() => {
908
- playerInfoEl.textContent = controller.me?.name ?? \`Player \${controller.myPlayerIndex}\`;
1118
+ playerInfoEl.textContent = controller.me?.nickname ?? \`Player \${controller.myPlayerIndex}\`;
909
1119
  });
910
1120
 
911
1121
  controller.on('score-update', (data) => {
912
- count = data.score;
913
- countEl.textContent = String(count);
1122
+ countEl.textContent = String(data.score);
914
1123
  });
915
1124
 
916
1125
  controller.on('personal-message', (data) => {
@@ -919,10 +1128,9 @@ controller.on('personal-message', (data) => {
919
1128
 
920
1129
  tapBtn.addEventListener('pointerdown', () => {
921
1130
  controller.send('tap', { timestamp: Date.now() });
922
- count++;
923
- countEl.textContent = String(count);
924
1131
  });
925
1132
  `,
1133
+ "src/__tests__/game.test.ts": controllerTestFileVanilla(),
926
1134
  };
927
1135
  }
928
1136
 
@@ -1004,7 +1212,6 @@ const room = {
1004
1212
  gameId: '',
1005
1213
  status: 'waiting',
1006
1214
  readyIds: new Set(),
1007
- customStates: new Map(), // playerIndex -> state object
1008
1215
  };
1009
1216
 
1010
1217
  function toPlayerDTO(p) {
@@ -1055,6 +1262,16 @@ async function main() {
1055
1262
  app.get('/', servePage(join(__dirname, 'index.html')));
1056
1263
  app.get('/controller', servePage(join(__dirname, 'controller.html')));
1057
1264
 
1265
+ // Serve game.json for dev harness (minPlayers, maxPlayers, title, etc.)
1266
+ app.get('/game.json', (_req, res) => {
1267
+ try {
1268
+ const gameJson = JSON.parse(readFileSync(join(__dirname, '..', 'game.json'), 'utf-8'));
1269
+ res.json(gameJson);
1270
+ } catch (e) {
1271
+ res.json({ id: 'dev-game', title: 'Dev Game', minPlayers: 1, maxPlayers: 8 });
1272
+ }
1273
+ });
1274
+
1058
1275
  // ── Socket handling ──
1059
1276
 
1060
1277
  io.on('connection', (socket) => {
@@ -1151,20 +1368,6 @@ async function main() {
1151
1368
  io.to(room.code).emit('smore:game-over', data);
1152
1369
  });
1153
1370
 
1154
- socket.on('smore:set-custom-state', (data) => {
1155
- if (socket.role !== 'player') return;
1156
- const prev = room.customStates.get(socket.playerIndex) || {};
1157
- const next = Object.assign({}, prev, data && typeof data === 'object' ? data : {});
1158
- room.customStates.set(socket.playerIndex, next);
1159
- io.to(room.code).emit('smore:custom-state-changed', { playerIndex: socket.playerIndex, state: next });
1160
- });
1161
-
1162
- socket.on('smore:get-custom-states', () => {
1163
- const states = {};
1164
- room.customStates.forEach((state, playerIndex) => { states[playerIndex] = state; });
1165
- socket.emit('smore:custom-states', { states });
1166
- });
1167
-
1168
1371
  socket.on('smore:return-to-selection', () => {
1169
1372
  room.status = 'waiting';
1170
1373
  room.gameId = '';
@@ -1186,7 +1389,6 @@ async function main() {
1186
1389
  room.gameId = '';
1187
1390
  room.status = 'waiting';
1188
1391
  room.readyIds = new Set();
1189
- room.customStates = new Map();
1190
1392
  console.log(' [reset] Room reset by host');
1191
1393
  if (typeof callback === 'function') callback({ success: true });
1192
1394
  });
@@ -1343,6 +1545,10 @@ export function devHarness(gameId) {
1343
1545
  .btn-primary:disabled:hover { background: #4f46e5; }
1344
1546
  .btn-danger { background: #dc2626; border-color: #dc2626; color: #fff; }
1345
1547
  .btn-danger:hover { background: #b91c1c; }
1548
+ .btn-start { background: #16a34a; border-color: #16a34a; color: #fff; font-size: 13px; padding: 5px 16px; }
1549
+ .btn-start:hover { background: #15803d; }
1550
+ .btn-start:disabled { opacity: 0.35; cursor: not-allowed; }
1551
+ .btn-start:disabled:hover { background: #16a34a; }
1346
1552
 
1347
1553
  /* Main layout */
1348
1554
  .main { display: flex; height: calc(100% - 48px); }
@@ -1418,6 +1624,7 @@ export function devHarness(gameId) {
1418
1624
  <div class="badge-muted" id="playerCount">0 players</div>
1419
1625
  <div class="phase-badge phase-lobby" id="phaseBadge">Lobby</div>
1420
1626
  <div class="topbar-spacer"></div>
1627
+ <button class="btn btn-start" id="startGameBtn" disabled>▶ Start</button>
1421
1628
  <button class="btn btn-danger" id="endGameBtn" style="display:none;">End Game</button>
1422
1629
  <button class="btn" id="resetBtn">Reset</button>
1423
1630
  <button class="btn btn-primary" id="addPlayerBtn">+ Player</button>
@@ -1456,6 +1663,10 @@ export function devHarness(gameId) {
1456
1663
  var screenIframe = null;
1457
1664
  var screenReady = false;
1458
1665
  var gamePhase = 'lobby';
1666
+ var gameStarted = false;
1667
+ var pendingGameReady = []; // { socket, data }
1668
+ var minPlayers = 1;
1669
+ var gameConfig = {};
1459
1670
 
1460
1671
  // ── DOM refs ──
1461
1672
  var roomCodeEl = document.getElementById('roomCode');
@@ -1467,6 +1678,7 @@ export function devHarness(gameId) {
1467
1678
  var controllerGrid = document.getElementById('controllerGrid');
1468
1679
  var emptyState = document.getElementById('emptyState');
1469
1680
  var addPlayerBtn = document.getElementById('addPlayerBtn');
1681
+ var startGameBtn = document.getElementById('startGameBtn');
1470
1682
  var endGameBtn = document.getElementById('endGameBtn');
1471
1683
  var resetBtn = document.getElementById('resetBtn');
1472
1684
 
@@ -1478,12 +1690,19 @@ export function devHarness(gameId) {
1478
1690
  phaseBadgeEl.className = 'phase-badge phase-' + phase;
1479
1691
  addPlayerBtn.disabled = (phase === 'playing');
1480
1692
  endGameBtn.style.display = (phase === 'playing') ? 'inline-block' : 'none';
1693
+ startGameBtn.style.display = (phase === 'lobby') ? 'inline-block' : 'none';
1694
+ updateStartButton();
1695
+ }
1696
+
1697
+ function updateStartButton() {
1698
+ startGameBtn.disabled = gameStarted || serverPlayers.length < minPlayers;
1481
1699
  }
1482
1700
 
1483
1701
  // ── Helpers ──
1484
1702
  function updatePlayerCount() {
1485
1703
  var count = serverPlayers.length;
1486
1704
  playerCountEl.textContent = count + ' player' + (count !== 1 ? 's' : '');
1705
+ updateStartButton();
1487
1706
  }
1488
1707
 
1489
1708
  function updateEmptyState() {
@@ -1561,7 +1780,7 @@ export function devHarness(gameId) {
1561
1780
  var screenSysEvents = [
1562
1781
  'smore:player-joined', 'smore:player-left', 'smore:player-disconnected', 'smore:player-reconnected',
1563
1782
  'smore:player-character-updated', 'smore:rate-limited', 'smore:game-over', 'smore:all-ready',
1564
- 'smore:self-disconnected', 'smore:self-reconnected', 'smore:custom-state-changed', 'smore:custom-states'
1783
+ 'smore:self-disconnected', 'smore:self-reconnected'
1565
1784
  ];
1566
1785
  screenSysEvents.forEach(function(sysEvent) {
1567
1786
  screenSocket.on(sysEvent, function(data) {
@@ -1643,7 +1862,7 @@ export function devHarness(gameId) {
1643
1862
  'smore:game-over',
1644
1863
  'smore:player-joined', 'smore:player-left', 'smore:player-disconnected', 'smore:player-reconnected',
1645
1864
  'smore:player-character-updated', 'smore:rate-limited', 'smore:all-ready',
1646
- 'smore:self-disconnected', 'smore:self-reconnected', 'smore:custom-state-changed', 'smore:custom-states'
1865
+ 'smore:self-disconnected', 'smore:self-reconnected'
1647
1866
  ];
1648
1867
  ctrlSysEvents.forEach(function(sysEvent) {
1649
1868
  controllerSocket.on(sysEvent, function(data) {
@@ -1705,9 +1924,13 @@ export function devHarness(gameId) {
1705
1924
  return;
1706
1925
  }
1707
1926
 
1708
- // Relay game-ready to server for ready/start sync
1927
+ // Buffer game-ready until game is started (prevents auto-start)
1709
1928
  if (event === 'smore:game-ready') {
1710
- screenSocket.emit('smore:game-ready', data || {});
1929
+ if (gameStarted) {
1930
+ screenSocket.emit('smore:game-ready', data || {});
1931
+ } else {
1932
+ pendingGameReady.push({ socket: screenSocket, data: data });
1933
+ }
1711
1934
  return;
1712
1935
  }
1713
1936
 
@@ -1753,19 +1976,13 @@ export function devHarness(gameId) {
1753
1976
  setPhase('lobby');
1754
1977
  }
1755
1978
 
1756
- // Relay game-ready to server for ready/start sync
1979
+ // Buffer game-ready until game is started (prevents auto-start)
1757
1980
  if (event === 'smore:game-ready') {
1758
- player.socket.emit('smore:game-ready', data || {});
1759
- return;
1760
- }
1761
-
1762
- // Relay custom state events to server
1763
- if (event === 'smore:set-custom-state') {
1764
- player.socket.emit('smore:set-custom-state', data || {});
1765
- return;
1766
- }
1767
- if (event === 'smore:get-custom-states') {
1768
- player.socket.emit('smore:get-custom-states');
1981
+ if (gameStarted) {
1982
+ player.socket.emit('smore:game-ready', data || {});
1983
+ } else {
1984
+ pendingGameReady.push({ socket: player.socket, data: data });
1985
+ }
1769
1986
  return;
1770
1987
  }
1771
1988
 
@@ -1784,6 +2001,9 @@ export function devHarness(gameId) {
1784
2001
 
1785
2002
  // ── End Game ──
1786
2003
  function endGame() {
2004
+ gameStarted = false;
2005
+ pendingGameReady = [];
2006
+
1787
2007
  // Broadcast phase-change to ALL controllers (including phone) via server relay
1788
2008
  screenSocket.emit('phase-change', { phase: 'lobby' });
1789
2009
 
@@ -1804,6 +2024,9 @@ export function devHarness(gameId) {
1804
2024
 
1805
2025
  // ── Reset ──
1806
2026
  function resetAll() {
2027
+ gameStarted = false;
2028
+ pendingGameReady = [];
2029
+
1807
2030
  // 1. Disconnect all local iframe player sockets
1808
2031
  players.forEach(function(p) {
1809
2032
  if (p.socket) p.socket.disconnect();
@@ -1852,6 +2075,24 @@ export function devHarness(gameId) {
1852
2075
  if (!addPlayerBtn.disabled) addController();
1853
2076
  });
1854
2077
 
2078
+ startGameBtn.addEventListener('click', function() {
2079
+ if (gameStarted || serverPlayers.length < minPlayers) return;
2080
+ gameStarted = true;
2081
+
2082
+ // Tell server to start the game (triggers smore:game-selected + smore:game-started)
2083
+ screenSocket.emit('smore:select-game', { gameId: gameConfig.id || 'dev-game' });
2084
+
2085
+ // Flush all buffered game-ready messages after server processes select-game
2086
+ setTimeout(function() {
2087
+ pendingGameReady.forEach(function(entry) {
2088
+ entry.socket.emit('smore:game-ready', entry.data || {});
2089
+ });
2090
+ pendingGameReady = [];
2091
+ }, 50);
2092
+
2093
+ setPhase('playing');
2094
+ });
2095
+
1855
2096
  endGameBtn.addEventListener('click', endGame);
1856
2097
  resetBtn.addEventListener('click', resetAll);
1857
2098
 
@@ -1871,6 +2112,13 @@ export function devHarness(gameId) {
1871
2112
  });
1872
2113
 
1873
2114
  // ── Boot ──
2115
+ // Fetch game.json for minPlayers config
2116
+ fetch('/game.json').then(function(r) { return r.json(); }).then(function(cfg) {
2117
+ gameConfig = cfg;
2118
+ minPlayers = cfg.minPlayers || 1;
2119
+ updateStartButton();
2120
+ }).catch(function() {});
2121
+
1874
2122
  initScreen();
1875
2123
  setTimeout(function() { addController(); }, 500);
1876
2124
  })();
@@ -1945,6 +2193,8 @@ export function devControllerPage(gameId) {
1945
2193
  var roomCode = '';
1946
2194
  var players = [];
1947
2195
  var iframeReady = false;
2196
+ var gameStarted = false;
2197
+ var pendingGameReady = null;
1948
2198
 
1949
2199
  // Connect and join room
1950
2200
  socket = io();
@@ -1977,12 +2227,21 @@ export function devControllerPage(gameId) {
1977
2227
  }
1978
2228
  });
1979
2229
 
2230
+ // Listen for game-started to flush pending game-ready
2231
+ socket.on('smore:game-started', function() {
2232
+ gameStarted = true;
2233
+ if (pendingGameReady !== null) {
2234
+ socket.emit('smore:game-ready', pendingGameReady);
2235
+ pendingGameReady = null;
2236
+ }
2237
+ });
2238
+
1980
2239
  // Forward system events to iframe
1981
2240
  var sysEvents = [
1982
2241
  'smore:game-over',
1983
2242
  'smore:player-joined', 'smore:player-left', 'smore:player-disconnected', 'smore:player-reconnected',
1984
2243
  'smore:player-character-updated', 'smore:rate-limited', 'smore:all-ready',
1985
- 'smore:self-disconnected', 'smore:self-reconnected', 'smore:custom-state-changed', 'smore:custom-states'
2244
+ 'smore:self-disconnected', 'smore:self-reconnected'
1986
2245
  ];
1987
2246
  sysEvents.forEach(function(sysEvent) {
1988
2247
  socket.on(sysEvent, function(data) {
@@ -2038,19 +2297,13 @@ export function devControllerPage(gameId) {
2038
2297
  var data = msg.payload.data;
2039
2298
  var ackId = msg.payload.ackId;
2040
2299
 
2041
- // Relay game-ready to server for ready/start sync
2300
+ // Buffer game-ready until game is started (prevents auto-start)
2042
2301
  if (event === 'smore:game-ready') {
2043
- socket.emit('smore:game-ready', data || {});
2044
- return;
2045
- }
2046
-
2047
- // Relay custom state events to server
2048
- if (event === 'smore:set-custom-state') {
2049
- socket.emit('smore:set-custom-state', data || {});
2050
- return;
2051
- }
2052
- if (event === 'smore:get-custom-states') {
2053
- socket.emit('smore:get-custom-states');
2302
+ if (gameStarted) {
2303
+ socket.emit('smore:game-ready', data || {});
2304
+ } else {
2305
+ pendingGameReady = data || {};
2306
+ }
2054
2307
  return;
2055
2308
  }
2056
2309