create-smore-game 2.1.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 -17
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,6 +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 }[]>([]);
|
|
546
|
+
const [tapCount, setTapCount] = useState(0);
|
|
376
547
|
|
|
377
548
|
useEffect(() => {
|
|
378
549
|
let mounted = true;
|
|
@@ -419,6 +590,12 @@ export function App() {
|
|
|
419
590
|
screen.on('tap', (playerIndex, data) => {
|
|
420
591
|
if (!mounted) return;
|
|
421
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
|
+
});
|
|
422
599
|
});
|
|
423
600
|
|
|
424
601
|
return () => {
|
|
@@ -428,11 +605,6 @@ export function App() {
|
|
|
428
605
|
};
|
|
429
606
|
}, []);
|
|
430
607
|
|
|
431
|
-
// Example: send score update to all players
|
|
432
|
-
const handleBroadcastScore = () => {
|
|
433
|
-
screenRef.current?.broadcast('score-update', { score: 100 });
|
|
434
|
-
};
|
|
435
|
-
|
|
436
608
|
// Example: send to specific player
|
|
437
609
|
const handleSendToPlayer = (playerIndex: number) => {
|
|
438
610
|
screenRef.current?.sendToController(playerIndex, 'personal-message', { text: 'Hello!' });
|
|
@@ -470,6 +642,7 @@ export function App() {
|
|
|
470
642
|
);
|
|
471
643
|
}
|
|
472
644
|
`,
|
|
645
|
+
"src/__tests__/game.test.ts": screenTestFile(),
|
|
473
646
|
};
|
|
474
647
|
}
|
|
475
648
|
|
|
@@ -486,6 +659,7 @@ export function screenVanilla(gameId) {
|
|
|
486
659
|
scripts: {
|
|
487
660
|
dev: "vite",
|
|
488
661
|
build: "tsc && vite build",
|
|
662
|
+
test: "vitest run",
|
|
489
663
|
},
|
|
490
664
|
dependencies: {
|
|
491
665
|
"@smoregg/sdk": SDK_VERSION, // TODO: auto-detect or update on release
|
|
@@ -493,6 +667,7 @@ export function screenVanilla(gameId) {
|
|
|
493
667
|
devDependencies: {
|
|
494
668
|
typescript: "^5.5.0",
|
|
495
669
|
vite: "^5.4.0",
|
|
670
|
+
vitest: "^1.6.0",
|
|
496
671
|
},
|
|
497
672
|
},
|
|
498
673
|
null,
|
|
@@ -529,8 +704,10 @@ export function screenVanilla(gameId) {
|
|
|
529
704
|
import type { GameResults } from '@smoregg/sdk';
|
|
530
705
|
|
|
531
706
|
interface GameEvents {
|
|
707
|
+
// Screen → Controller (view state)
|
|
532
708
|
'score-update': { score: number };
|
|
533
709
|
'personal-message': { text: string };
|
|
710
|
+
// Controller → Screen (input)
|
|
534
711
|
'tap': { timestamp: number };
|
|
535
712
|
}
|
|
536
713
|
|
|
@@ -584,6 +761,8 @@ screen.onAllReady(() => {
|
|
|
584
761
|
|
|
585
762
|
// Use screen.on(event, handler) / screen.off(event, handler) for dynamic event listeners.
|
|
586
763
|
// destroy() automatically removes all listeners, so explicit off() cleanup is not needed.
|
|
764
|
+
let tapCount = 0;
|
|
765
|
+
|
|
587
766
|
screen.on('tap', (playerIndex, data) => {
|
|
588
767
|
const line = document.createElement('div');
|
|
589
768
|
line.textContent = \`Player \${playerIndex} tapped\`;
|
|
@@ -592,6 +771,9 @@ screen.on('tap', (playerIndex, data) => {
|
|
|
592
771
|
while (logEl.children.length > 10) {
|
|
593
772
|
logEl.removeChild(logEl.firstChild!);
|
|
594
773
|
}
|
|
774
|
+
// Broadcast updated score back to controllers (Screen is source of truth)
|
|
775
|
+
tapCount++;
|
|
776
|
+
screen.broadcast('score-update', { score: tapCount });
|
|
595
777
|
});
|
|
596
778
|
|
|
597
779
|
function updateStatus() {
|
|
@@ -600,8 +782,7 @@ function updateStatus() {
|
|
|
600
782
|
}
|
|
601
783
|
|
|
602
784
|
// Example functions (can be called from console for testing):
|
|
603
|
-
// screen.
|
|
604
|
-
// screen.sendToController(0, 'message', { text: 'Hello!' });
|
|
785
|
+
// screen.sendToController(0, 'personal-message', { text: 'Hello!' });
|
|
605
786
|
// const results: GameResults = {
|
|
606
787
|
// scores: { 0: 50, 1: 75 },
|
|
607
788
|
// // winner: 0,
|
|
@@ -609,6 +790,7 @@ function updateStatus() {
|
|
|
609
790
|
// };
|
|
610
791
|
// screen.gameOver(results);
|
|
611
792
|
`,
|
|
793
|
+
"src/__tests__/game.test.ts": screenTestFileVanilla(),
|
|
612
794
|
};
|
|
613
795
|
}
|
|
614
796
|
|
|
@@ -660,6 +842,7 @@ export function controllerReact(gameId) {
|
|
|
660
842
|
scripts: {
|
|
661
843
|
dev: "vite",
|
|
662
844
|
build: "tsc && vite build",
|
|
845
|
+
test: "vitest run",
|
|
663
846
|
},
|
|
664
847
|
dependencies: {
|
|
665
848
|
react: "^18.3.1",
|
|
@@ -672,6 +855,7 @@ export function controllerReact(gameId) {
|
|
|
672
855
|
"@vitejs/plugin-react": "^4.3.0",
|
|
673
856
|
typescript: "^5.5.0",
|
|
674
857
|
vite: "^5.4.0",
|
|
858
|
+
vitest: "^1.6.0",
|
|
675
859
|
},
|
|
676
860
|
},
|
|
677
861
|
null,
|
|
@@ -715,18 +899,35 @@ import { App } from './App';
|
|
|
715
899
|
createRoot(document.getElementById('root')!).render(<App />);
|
|
716
900
|
`,
|
|
717
901
|
"src/App.tsx": `import { createController } from '@smoregg/sdk';
|
|
718
|
-
import type { Controller } from '@smoregg/sdk';
|
|
902
|
+
import type { Controller, ControllerInfo } from '@smoregg/sdk';
|
|
719
903
|
import { useEffect, useRef, useState } from 'react';
|
|
720
904
|
|
|
721
905
|
interface GameEvents {
|
|
906
|
+
// Screen → Controller (view state)
|
|
722
907
|
'score-update': { score: number };
|
|
723
908
|
'personal-message': { text: string };
|
|
909
|
+
// Controller → Screen (input)
|
|
724
910
|
'tap': { timestamp: number };
|
|
725
911
|
}
|
|
726
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
|
+
|
|
727
927
|
export function App() {
|
|
728
928
|
const controllerRef = useRef<Controller | null>(null);
|
|
729
929
|
const [myIndex, setMyIndex] = useState(-1);
|
|
930
|
+
const [me, setMe] = useState<ControllerInfo | null>(null);
|
|
730
931
|
const [count, setCount] = useState(0);
|
|
731
932
|
const [isReady, setIsReady] = useState(false);
|
|
732
933
|
|
|
@@ -745,6 +946,7 @@ export function App() {
|
|
|
745
946
|
controller.onAllReady(() => {
|
|
746
947
|
if (!mounted) return;
|
|
747
948
|
setMyIndex(controller.myPlayerIndex);
|
|
949
|
+
setMe(controller.me);
|
|
748
950
|
setIsReady(true);
|
|
749
951
|
});
|
|
750
952
|
|
|
@@ -768,7 +970,6 @@ export function App() {
|
|
|
768
970
|
|
|
769
971
|
const handleTap = () => {
|
|
770
972
|
controllerRef.current?.send('tap', { timestamp: Date.now() });
|
|
771
|
-
setCount((c) => c + 1);
|
|
772
973
|
};
|
|
773
974
|
|
|
774
975
|
return (
|
|
@@ -779,7 +980,7 @@ export function App() {
|
|
|
779
980
|
touchAction: 'manipulation', userSelect: 'none',
|
|
780
981
|
}}>
|
|
781
982
|
{isReady && (
|
|
782
|
-
<div style={{ fontSize: '16px', opacity: 0.6 }}>Player {myIndex}</div>
|
|
983
|
+
<div style={{ fontSize: '16px', opacity: 0.6 }}>{me?.nickname ?? \`Player \${myIndex}\`}</div>
|
|
783
984
|
)}
|
|
784
985
|
<div style={{ fontSize: '48px', fontWeight: 'bold' }}>{count}</div>
|
|
785
986
|
<button
|
|
@@ -797,6 +998,7 @@ export function App() {
|
|
|
797
998
|
);
|
|
798
999
|
}
|
|
799
1000
|
`,
|
|
1001
|
+
"src/__tests__/game.test.ts": controllerTestFile(),
|
|
800
1002
|
};
|
|
801
1003
|
}
|
|
802
1004
|
|
|
@@ -811,6 +1013,7 @@ export function controllerVanilla(gameId) {
|
|
|
811
1013
|
scripts: {
|
|
812
1014
|
dev: "vite",
|
|
813
1015
|
build: "tsc && vite build",
|
|
1016
|
+
test: "vitest run",
|
|
814
1017
|
},
|
|
815
1018
|
dependencies: {
|
|
816
1019
|
"@smoregg/sdk": SDK_VERSION, // TODO: auto-detect or update on release
|
|
@@ -818,6 +1021,7 @@ export function controllerVanilla(gameId) {
|
|
|
818
1021
|
devDependencies: {
|
|
819
1022
|
typescript: "^5.5.0",
|
|
820
1023
|
vite: "^5.4.0",
|
|
1024
|
+
vitest: "^1.6.0",
|
|
821
1025
|
},
|
|
822
1026
|
},
|
|
823
1027
|
null,
|
|
@@ -876,12 +1080,26 @@ export default defineConfig({
|
|
|
876
1080
|
"src/main.ts": `import { createController } from '@smoregg/sdk';
|
|
877
1081
|
|
|
878
1082
|
interface GameEvents {
|
|
1083
|
+
// Screen → Controller (view state)
|
|
879
1084
|
'score-update': { score: number };
|
|
880
1085
|
'personal-message': { text: string };
|
|
1086
|
+
// Controller → Screen (input)
|
|
881
1087
|
'tap': { timestamp: number };
|
|
882
1088
|
}
|
|
883
1089
|
|
|
884
|
-
|
|
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
|
+
*/
|
|
885
1103
|
|
|
886
1104
|
const playerInfoEl = document.getElementById('player-info')!;
|
|
887
1105
|
const countEl = document.getElementById('count')!;
|
|
@@ -897,12 +1115,11 @@ const controller = createController<GameEvents>({ debug: true });
|
|
|
897
1115
|
// To control autoReady: createScreen({ autoReady: false }) or createController({ autoReady: false })
|
|
898
1116
|
|
|
899
1117
|
controller.onAllReady(() => {
|
|
900
|
-
playerInfoEl.textContent = \`Player \${controller.myPlayerIndex}\`;
|
|
1118
|
+
playerInfoEl.textContent = controller.me?.nickname ?? \`Player \${controller.myPlayerIndex}\`;
|
|
901
1119
|
});
|
|
902
1120
|
|
|
903
1121
|
controller.on('score-update', (data) => {
|
|
904
|
-
|
|
905
|
-
countEl.textContent = String(count);
|
|
1122
|
+
countEl.textContent = String(data.score);
|
|
906
1123
|
});
|
|
907
1124
|
|
|
908
1125
|
controller.on('personal-message', (data) => {
|
|
@@ -911,10 +1128,9 @@ controller.on('personal-message', (data) => {
|
|
|
911
1128
|
|
|
912
1129
|
tapBtn.addEventListener('pointerdown', () => {
|
|
913
1130
|
controller.send('tap', { timestamp: Date.now() });
|
|
914
|
-
count++;
|
|
915
|
-
countEl.textContent = String(count);
|
|
916
1131
|
});
|
|
917
1132
|
`,
|
|
1133
|
+
"src/__tests__/game.test.ts": controllerTestFileVanilla(),
|
|
918
1134
|
};
|
|
919
1135
|
}
|
|
920
1136
|
|