create-smore-game 2.2.0 → 2.3.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/README.md +159 -0
- package/package.json +1 -1
- package/templates.js +233 -61
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
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.
|
|
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 [
|
|
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.
|
|
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?.
|
|
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
|
-
|
|
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?.
|
|
1118
|
+
playerInfoEl.textContent = controller.me?.nickname ?? \`Player \${controller.myPlayerIndex}\`;
|
|
909
1119
|
});
|
|
910
1120
|
|
|
911
1121
|
controller.on('score-update', (data) => {
|
|
912
|
-
|
|
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) {
|
|
@@ -1151,20 +1358,6 @@ async function main() {
|
|
|
1151
1358
|
io.to(room.code).emit('smore:game-over', data);
|
|
1152
1359
|
});
|
|
1153
1360
|
|
|
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
1361
|
socket.on('smore:return-to-selection', () => {
|
|
1169
1362
|
room.status = 'waiting';
|
|
1170
1363
|
room.gameId = '';
|
|
@@ -1186,7 +1379,6 @@ async function main() {
|
|
|
1186
1379
|
room.gameId = '';
|
|
1187
1380
|
room.status = 'waiting';
|
|
1188
1381
|
room.readyIds = new Set();
|
|
1189
|
-
room.customStates = new Map();
|
|
1190
1382
|
console.log(' [reset] Room reset by host');
|
|
1191
1383
|
if (typeof callback === 'function') callback({ success: true });
|
|
1192
1384
|
});
|
|
@@ -1561,7 +1753,7 @@ export function devHarness(gameId) {
|
|
|
1561
1753
|
var screenSysEvents = [
|
|
1562
1754
|
'smore:player-joined', 'smore:player-left', 'smore:player-disconnected', 'smore:player-reconnected',
|
|
1563
1755
|
'smore:player-character-updated', 'smore:rate-limited', 'smore:game-over', 'smore:all-ready',
|
|
1564
|
-
'smore:self-disconnected', 'smore:self-reconnected'
|
|
1756
|
+
'smore:self-disconnected', 'smore:self-reconnected'
|
|
1565
1757
|
];
|
|
1566
1758
|
screenSysEvents.forEach(function(sysEvent) {
|
|
1567
1759
|
screenSocket.on(sysEvent, function(data) {
|
|
@@ -1643,7 +1835,7 @@ export function devHarness(gameId) {
|
|
|
1643
1835
|
'smore:game-over',
|
|
1644
1836
|
'smore:player-joined', 'smore:player-left', 'smore:player-disconnected', 'smore:player-reconnected',
|
|
1645
1837
|
'smore:player-character-updated', 'smore:rate-limited', 'smore:all-ready',
|
|
1646
|
-
'smore:self-disconnected', 'smore:self-reconnected'
|
|
1838
|
+
'smore:self-disconnected', 'smore:self-reconnected'
|
|
1647
1839
|
];
|
|
1648
1840
|
ctrlSysEvents.forEach(function(sysEvent) {
|
|
1649
1841
|
controllerSocket.on(sysEvent, function(data) {
|
|
@@ -1759,16 +1951,6 @@ export function devHarness(gameId) {
|
|
|
1759
1951
|
return;
|
|
1760
1952
|
}
|
|
1761
1953
|
|
|
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');
|
|
1769
|
-
return;
|
|
1770
|
-
}
|
|
1771
|
-
|
|
1772
1954
|
// Block smore:* events
|
|
1773
1955
|
if (event.startsWith('smore:')) return;
|
|
1774
1956
|
|
|
@@ -1982,7 +2164,7 @@ export function devControllerPage(gameId) {
|
|
|
1982
2164
|
'smore:game-over',
|
|
1983
2165
|
'smore:player-joined', 'smore:player-left', 'smore:player-disconnected', 'smore:player-reconnected',
|
|
1984
2166
|
'smore:player-character-updated', 'smore:rate-limited', 'smore:all-ready',
|
|
1985
|
-
'smore:self-disconnected', 'smore:self-reconnected'
|
|
2167
|
+
'smore:self-disconnected', 'smore:self-reconnected'
|
|
1986
2168
|
];
|
|
1987
2169
|
sysEvents.forEach(function(sysEvent) {
|
|
1988
2170
|
socket.on(sysEvent, function(data) {
|
|
@@ -2044,16 +2226,6 @@ export function devControllerPage(gameId) {
|
|
|
2044
2226
|
return;
|
|
2045
2227
|
}
|
|
2046
2228
|
|
|
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');
|
|
2054
|
-
return;
|
|
2055
|
-
}
|
|
2056
|
-
|
|
2057
2229
|
// Block smore:* events
|
|
2058
2230
|
if (event.startsWith('smore:')) return;
|
|
2059
2231
|
|