@littlepartytime/sdk 1.0.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,981 @@
1
+ # Little Party Time SDK - Game Development Guide
2
+
3
+ This guide defines the specification for developing games on the Little Party Time platform. Follow this guide precisely to create a compatible game package.
4
+
5
+ ## Overview
6
+
7
+ A game consists of two parts:
8
+ 1. **Engine** (server-side): Pure logic, no UI. Manages game state, validates actions, determines winners.
9
+ 2. **Renderer** (client-side): React component that displays the game UI and sends player actions.
10
+
11
+ Both parts communicate through the platform via well-defined interfaces.
12
+
13
+ ## Project Structure
14
+
15
+ Each game is a standalone project:
16
+
17
+ ```
18
+ my-game/
19
+ ├── package.json
20
+ ├── lpt.config.ts # Project configuration (gameId for local dev)
21
+ ├── rules.md # Game rules (Markdown, included in upload)
22
+ ├── vite.config.ts # Build configuration
23
+ ├── vitest.config.ts # Test configuration
24
+ ├── tsconfig.json
25
+ ├── assets/ # Required game images
26
+ │ ├── icon.png # 1:1 (256x256+) - game list icon
27
+ │ ├── banner.png # 16:9 (640x360+) - lobby banner
28
+ │ ├── cover.png # 21:9 (840x360+) - store/featured cover
29
+ │ └── splash.png # 9:21 (360x840+) - loading screen
30
+ ├── src/
31
+ │ ├── config.ts # GameConfig - metadata
32
+ │ ├── engine.ts # GameEngine - server-side logic
33
+ │ ├── renderer.tsx # GameRenderer - client-side React component
34
+ │ ├── types.ts # Game-specific types (actions, state shape)
35
+ │ └── index.ts # Re-exports config, engine, renderer
36
+ ├── tests/
37
+ │ ├── engine.test.ts # Engine unit tests
38
+ │ └── e2e.e2e.test.ts # Playwright E2E tests (optional)
39
+ └── dist/ # Build output (generated)
40
+ ├── bundle.js # Client-side bundle
41
+ ├── engine.cjs # Server-side engine
42
+ └── <gameId>.zip # Upload package
43
+ ```
44
+
45
+ ## Quick Start
46
+
47
+ ```bash
48
+ # Create a new game project
49
+ npx create-littlepartytime-game my-awesome-game
50
+ cd my-awesome-game
51
+
52
+ # Install dependencies
53
+ npm install
54
+
55
+ # Run tests
56
+ npm test
57
+
58
+ # Build and package for upload
59
+ npm run pack
60
+ ```
61
+
62
+ ## Step-by-Step Implementation
63
+
64
+ ### Step 1: Define Game Config
65
+
66
+ ```typescript
67
+ // src/config.ts
68
+ import type { GameConfig } from "@littlepartytime/sdk";
69
+
70
+ const config: GameConfig = {
71
+ name: "My Game", // Display name (Chinese preferred for CN users)
72
+ description: "...", // Brief description
73
+ assets: {
74
+ icon: "assets/icon.png", // 1:1 game list icon
75
+ banner: "assets/banner.png", // 16:9 lobby banner
76
+ cover: "assets/cover.png", // 21:9 store/featured cover
77
+ splash: "assets/splash.png", // 9:21 loading screen
78
+ },
79
+ minPlayers: 2,
80
+ maxPlayers: 6,
81
+ tags: ["strategy", "card"],
82
+ version: "1.0.0",
83
+ sdkVersion: "2.0.0",
84
+ };
85
+
86
+ export default config;
87
+ ```
88
+
89
+ ### Step 2: Define Game-Specific Types
90
+
91
+ ```typescript
92
+ // src/types.ts
93
+ import type { GameAction, GameState } from "@littlepartytime/sdk";
94
+
95
+ // Define all possible actions your game supports
96
+ export type MyGameAction =
97
+ | { type: "PLAY_CARD"; payload: { cardId: string } }
98
+ | { type: "DRAW_CARD" }
99
+ | { type: "PASS" };
100
+
101
+ // Define the shape of your game's data field in GameState
102
+ export interface MyGameData {
103
+ deck: Card[];
104
+ currentPlayerIndex: number;
105
+ direction: 1 | -1;
106
+ // ... game-specific fields
107
+ }
108
+
109
+ // GameState.data will be typed as Record<string, unknown> at the SDK level.
110
+ // Cast it in your engine: const data = state.data as MyGameData;
111
+ ```
112
+
113
+ ### Step 3: Implement Game Engine
114
+
115
+ The engine is the core of your game. It MUST be a pure, deterministic state machine.
116
+
117
+ ```typescript
118
+ // src/engine.ts
119
+ import type { GameEngine, GameState, Player, GameAction, GameResult } from "@littlepartytime/sdk";
120
+
121
+ const engine: GameEngine = {
122
+ /**
123
+ * Initialize game state for the given players.
124
+ * Called once when the host starts the game.
125
+ *
126
+ * @param players - Array of players in the game (from room's online members)
127
+ * @param options - Optional game settings (e.g., difficulty, variant rules)
128
+ * @returns Initial GameState
129
+ */
130
+ init(players: Player[], options?: Record<string, unknown>): GameState {
131
+ return {
132
+ phase: "playing", // Use meaningful phase names: "playing", "voting", "scoring", etc.
133
+ players: players.map(p => ({
134
+ id: p.id,
135
+ // Add per-player state here (hand, score, etc.)
136
+ })),
137
+ data: {
138
+ // Global game state (deck, board, current turn, etc.)
139
+ },
140
+ };
141
+ },
142
+
143
+ /**
144
+ * Process a player action and return the new state.
145
+ * This is the main game logic function.
146
+ *
147
+ * IMPORTANT:
148
+ * - MUST be pure: do not mutate the input state, return a new object.
149
+ * - MUST validate the action: check if it's the player's turn, if the action is legal, etc.
150
+ * - If the action is invalid, return the state unchanged (or throw an error).
151
+ *
152
+ * @param state - Current game state
153
+ * @param playerId - ID of the player performing the action
154
+ * @param action - The action being performed
155
+ * @returns New GameState after applying the action
156
+ */
157
+ handleAction(state: GameState, playerId: string, action: GameAction): GameState {
158
+ // 1. Validate it's this player's turn
159
+ // 2. Validate the action is legal
160
+ // 3. Apply the action and return new state
161
+ // 4. Advance to next player or next phase as needed
162
+ return { ...state };
163
+ },
164
+
165
+ /**
166
+ * Check if the game has ended.
167
+ * Called after every handleAction.
168
+ *
169
+ * @param state - Current game state
170
+ * @returns true if the game is over
171
+ */
172
+ isGameOver(state: GameState): boolean {
173
+ return state.phase === "ended";
174
+ },
175
+
176
+ /**
177
+ * Compute final results/rankings.
178
+ * Called only when isGameOver returns true.
179
+ *
180
+ * @param state - Final game state
181
+ * @returns GameResult with player rankings
182
+ */
183
+ getResult(state: GameState): GameResult {
184
+ return {
185
+ rankings: state.players.map((p, i) => ({
186
+ playerId: p.id,
187
+ rank: i + 1,
188
+ score: 0,
189
+ isWinner: i === 0,
190
+ })),
191
+ };
192
+ },
193
+
194
+ /**
195
+ * Filter the state to show only what a specific player should see.
196
+ * This is critical for games with hidden information (e.g., cards in hand).
197
+ *
198
+ * For games with no hidden info, you can return the full state.
199
+ * For games with hidden info:
200
+ * - Hide other players' hands
201
+ * - Hide the deck contents
202
+ * - Only reveal public information
203
+ *
204
+ * @param state - Full game state
205
+ * @param playerId - The player requesting their view
206
+ * @returns Filtered state visible to this player
207
+ */
208
+ getPlayerView(state: GameState, playerId: string): Partial<GameState> {
209
+ return state; // Override for hidden information games
210
+ },
211
+ };
212
+
213
+ export default engine;
214
+ ```
215
+
216
+ ### Step 4: Implement Game Renderer
217
+
218
+ The renderer is a React component that receives the platform API and the player's visible state.
219
+
220
+ ```tsx
221
+ // src/renderer.tsx
222
+ "use client";
223
+
224
+ import { useState, useEffect, useCallback } from "react";
225
+ import type { GameRendererProps, GameAction } from "@littlepartytime/sdk";
226
+
227
+ export default function GameRenderer({ platform, state }: GameRendererProps) {
228
+ // Access player info
229
+ const me = platform.getLocalPlayer();
230
+ const players = platform.getPlayers();
231
+
232
+ // Listen for state updates from the server
233
+ const [gameState, setGameState] = useState(state);
234
+
235
+ useEffect(() => {
236
+ const handleStateUpdate = (newState: typeof state) => {
237
+ setGameState(newState);
238
+ };
239
+ platform.on("stateUpdate", handleStateUpdate);
240
+ return () => platform.off("stateUpdate", handleStateUpdate);
241
+ }, [platform]);
242
+
243
+ // Send actions to the server
244
+ const sendAction = useCallback(
245
+ (action: GameAction) => {
246
+ platform.send(action);
247
+ },
248
+ [platform]
249
+ );
250
+
251
+ // Render game UI
252
+ return (
253
+ <div>
254
+ {/* Your game UI here */}
255
+ {/* Use Tailwind CSS classes - the platform provides Tailwind */}
256
+ {/* Use the design tokens from the platform: bg-bg-primary, text-accent, etc. */}
257
+ </div>
258
+ );
259
+ }
260
+ ```
261
+
262
+ ### Step 5: Create Index File
263
+
264
+ ```typescript
265
+ // src/index.ts
266
+ export { default as config } from "./config";
267
+ export { default as engine } from "./engine";
268
+ export { default as GameRenderer } from "./renderer";
269
+ ```
270
+
271
+ ## Testing Your Game
272
+
273
+ The SDK provides testing utilities to help you write tests for your game engine.
274
+
275
+ ### Using createMockPlayers
276
+
277
+ Generate test players quickly:
278
+
279
+ ```typescript
280
+ import { createMockPlayers } from '@littlepartytime/sdk/testing';
281
+
282
+ // Create 3 players with default IDs (player-1, player-2, player-3)
283
+ const players = createMockPlayers(3);
284
+
285
+ // The first player is the host by default
286
+ console.log(players[0].isHost); // true
287
+
288
+ // Override specific player properties
289
+ const customPlayers = createMockPlayers(2, [
290
+ { nickname: 'Alice' },
291
+ { nickname: 'Bob', isHost: true }
292
+ ]);
293
+ ```
294
+
295
+ ### Using GameTester for Unit Tests
296
+
297
+ `GameTester` provides a simple wrapper for testing individual engine methods:
298
+
299
+ ```typescript
300
+ import { describe, it, expect } from 'vitest';
301
+ import { GameTester, createMockPlayers } from '@littlepartytime/sdk/testing';
302
+ import engine from '../src/engine';
303
+
304
+ describe('My Game Engine', () => {
305
+ it('should initialize with correct phase', () => {
306
+ const tester = new GameTester(engine);
307
+ tester.init(createMockPlayers(3));
308
+
309
+ expect(tester.phase).toBe('playing');
310
+ expect(tester.playerStates).toHaveLength(3);
311
+ });
312
+
313
+ it('should handle valid actions', () => {
314
+ const tester = new GameTester(engine);
315
+ const players = createMockPlayers(2);
316
+ tester.init(players);
317
+
318
+ // Perform an action
319
+ tester.act(players[0].id, { type: 'PLAY_CARD', payload: { cardId: '1' } });
320
+
321
+ // Assert state changes
322
+ expect(tester.state.data.cardsPlayed).toContain('1');
323
+ });
324
+
325
+ it('should reject invalid actions', () => {
326
+ const tester = new GameTester(engine);
327
+ const players = createMockPlayers(2);
328
+ tester.init(players);
329
+
330
+ const before = tester.state;
331
+ // Wrong player tries to act
332
+ tester.act(players[1].id, { type: 'PLAY_CARD', payload: { cardId: '1' } });
333
+
334
+ // State should be unchanged
335
+ expect(tester.state).toBe(before);
336
+ });
337
+
338
+ it('should filter player views correctly', () => {
339
+ const tester = new GameTester(engine);
340
+ const players = createMockPlayers(2);
341
+ tester.init(players);
342
+
343
+ const view = tester.getPlayerView(players[0].id);
344
+ expect(view.data).not.toHaveProperty('secretInfo');
345
+ });
346
+ });
347
+ ```
348
+
349
+ ### Using GameSimulator for E2E Tests
350
+
351
+ `GameSimulator` helps you simulate complete game sessions:
352
+
353
+ ```typescript
354
+ import { describe, it, expect } from 'vitest';
355
+ import { GameSimulator } from '@littlepartytime/sdk/testing';
356
+ import engine from '../src/engine';
357
+
358
+ describe('My Game E2E', () => {
359
+ it('should play a complete game', () => {
360
+ const sim = new GameSimulator(engine, { playerCount: 3 });
361
+ sim.start();
362
+
363
+ // Simulate game actions using player indices (0, 1, 2)
364
+ sim.act(0, { type: 'PLAY_CARD', payload: { cardId: '1' } });
365
+ sim.act(1, { type: 'DRAW_CARD' });
366
+ sim.act(2, { type: 'PASS' });
367
+
368
+ // Continue until game ends
369
+ while (!sim.isGameOver()) {
370
+ const turn = sim.currentTurn;
371
+ sim.act(turn, { type: 'PASS' });
372
+ }
373
+
374
+ // Verify results
375
+ expect(sim.isGameOver()).toBe(true);
376
+ const result = sim.getResult();
377
+ expect(result.rankings).toHaveLength(3);
378
+
379
+ // Access the action log for debugging
380
+ console.log(`Game finished in ${sim.actionLog.length} actions`);
381
+ });
382
+
383
+ it('should provide correct player views', () => {
384
+ const sim = new GameSimulator(engine, { playerCount: 2 });
385
+ sim.start();
386
+
387
+ // Each player sees only their own view
388
+ const view0 = sim.getView(0);
389
+ const view1 = sim.getView(1);
390
+
391
+ expect(view0).not.toEqual(view1);
392
+ });
393
+ });
394
+ ```
395
+
396
+ ## Local Development Server
397
+
398
+ The dev-kit provides a local development server for previewing and testing your game without uploading to the platform.
399
+
400
+ ### Starting the Dev Server
401
+
402
+ ```bash
403
+ # From your game project directory
404
+ npm run dev
405
+ # or directly:
406
+ npx lpt-dev-kit dev
407
+ ```
408
+
409
+ This starts two servers and provides three pages:
410
+
411
+ ```
412
+ Preview: http://localhost:4000/preview # Single-player preview with engine
413
+ Multiplayer: http://localhost:4000/play # Multi-player via Socket.IO
414
+ Debug Panel: http://localhost:4000/debug # Real-time state inspection
415
+ ```
416
+
417
+ ### Preview Page (Single-Player)
418
+
419
+ The Preview page runs your **engine locally in the browser** — no network needed.
420
+
421
+ Features:
422
+ - **Engine integration**: `platform.send()` calls `engine.handleAction()` locally, computes `getPlayerView()`, and triggers `stateUpdate` events automatically
423
+ - **Player switching**: Switch between players (2-32) via a dropdown to see each player's filtered view
424
+ - **State editor**: View and override the full game state as JSON for debugging
425
+ - **Action log**: See all actions sent by players in real-time
426
+ - **Game over detection**: Automatically detects when `isGameOver()` returns true and displays results
427
+ - **Reset**: Re-initialize the game at any time
428
+
429
+ This enables a rapid development cycle: **edit code -> refresh browser -> test immediately**.
430
+
431
+ ### Multiplayer Page
432
+
433
+ The Play page runs your game with real Socket.IO multiplayer:
434
+ 1. Open multiple browser tabs/windows to `http://localhost:4000/play`
435
+ 2. Each tab enters a different nickname and joins the lobby
436
+ 3. All players click "Ready", then the host starts the game
437
+ 4. Actions flow through Socket.IO to the engine running on the dev server
438
+
439
+ ### Debug Page
440
+
441
+ The Debug page shows the raw room state and full (unfiltered) game state in real-time. Useful for inspecting hidden information during development.
442
+
443
+ ## Playwright E2E Testing
444
+
445
+ For automated UI testing, the dev-kit provides a `GamePreview` class that orchestrates the dev server and Playwright browser.
446
+
447
+ ### Setup
448
+
449
+ ```bash
450
+ # Install playwright as a dev dependency
451
+ npm install -D playwright
452
+ ```
453
+
454
+ ### Writing E2E Tests
455
+
456
+ Create a test file (e.g., `tests/e2e.e2e.test.ts`):
457
+
458
+ ```typescript
459
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
460
+ import { GamePreview } from '@littlepartytime/dev-kit/testing';
461
+ import path from 'path';
462
+
463
+ describe('My Game E2E', () => {
464
+ let preview: GamePreview;
465
+
466
+ beforeAll(async () => {
467
+ preview = new GamePreview({
468
+ projectDir: path.resolve(__dirname, '..'),
469
+ playerCount: 3,
470
+ headless: true,
471
+ port: 4100, // Use non-default ports to avoid conflicts
472
+ socketPort: 4101,
473
+ });
474
+ await preview.start();
475
+ }, 30000); // Allow time for server startup and browser launch
476
+
477
+ afterAll(async () => {
478
+ await preview.stop();
479
+ }, 10000);
480
+
481
+ it('should show lobby with all players', async () => {
482
+ const page = preview.getPlayerPage(0);
483
+ await expect(page.locator('text=Alice')).toBeVisible();
484
+ await expect(page.locator('text=Bob')).toBeVisible();
485
+ await expect(page.locator('text=Carol')).toBeVisible();
486
+ });
487
+
488
+ it('should play through a complete game', async () => {
489
+ // All players click "Ready"
490
+ await preview.readyAll();
491
+
492
+ // Host starts the game
493
+ await preview.startGame();
494
+
495
+ // Interact with the game UI via Playwright
496
+ const hostPage = preview.getPlayerPage(0);
497
+ const player2Page = preview.getPlayerPage(1);
498
+
499
+ // Use standard Playwright assertions
500
+ await expect(hostPage.locator('.game-board')).toBeVisible({ timeout: 5000 });
501
+
502
+ // Send actions by interacting with the UI
503
+ await hostPage.click('button:has-text("Play")');
504
+
505
+ // Assert state changes on other players' screens
506
+ await expect(player2Page.locator('text=waiting')).toBeVisible();
507
+ });
508
+ });
509
+ ```
510
+
511
+ ### GamePreview API
512
+
513
+ ```typescript
514
+ import { GamePreview } from '@littlepartytime/dev-kit/testing';
515
+
516
+ const preview = new GamePreview({
517
+ projectDir: string; // Absolute path to game project
518
+ playerCount: number; // Number of players (2-8)
519
+ port?: number; // Vite server port (default: 4100)
520
+ socketPort?: number; // Socket.IO port (default: 4101)
521
+ headless?: boolean; // Headless browser (default: true)
522
+ browserType?: 'chromium' | 'firefox' | 'webkit'; // default: 'chromium'
523
+ });
524
+
525
+ await preview.start(); // Start server + browser, join all players
526
+ const page = preview.getPlayerPage(0); // Get Playwright Page for player (0 = host)
527
+ const pages = preview.getPlayerPages(); // Get all Page objects
528
+ await preview.readyAll(); // Click "Ready" for all players
529
+ await preview.startGame(); // Host clicks "Start Game"
530
+ await preview.stop(); // Clean up browser + server
531
+ ```
532
+
533
+ ### Excluding E2E Tests from Unit Test Runs
534
+
535
+ E2E tests are slower and require playwright. Exclude them from the default `vitest run`:
536
+
537
+ ```typescript
538
+ // vitest.config.ts
539
+ export default defineConfig({
540
+ test: {
541
+ include: ["tests/**/*.test.ts"],
542
+ exclude: ["tests/**/*.e2e.test.ts"],
543
+ },
544
+ });
545
+ ```
546
+
547
+ Run E2E tests separately:
548
+
549
+ ```bash
550
+ npx vitest run tests/e2e.e2e.test.ts
551
+ ```
552
+
553
+ ## Building and Packaging
554
+
555
+ ### The pack Command
556
+
557
+ Use `lpt-dev-kit pack` (or `npm run pack`) to build and package your game:
558
+
559
+ ```bash
560
+ # From your game project directory
561
+ npm run pack
562
+ ```
563
+
564
+ This command:
565
+ 1. Runs `vite build` to compile your game
566
+ 2. Validates the build output (checks for bundle.js and engine.cjs)
567
+ 3. Reads `GameConfig` from the built engine to extract metadata
568
+ 4. Validates the required image assets (format, dimensions, aspect ratio)
569
+ 5. Validates `rules.md` exists and is non-empty
570
+ 6. Generates `manifest.json` from your config
571
+ 7. Creates a `.zip` file in the `dist/` directory containing code, manifest, rules, and images
572
+
573
+ ### Build Output
574
+
575
+ After running `pack`, your `dist/` folder will contain:
576
+
577
+ ```
578
+ dist/
579
+ ├── bundle.js # Client-side bundle (React component + dependencies)
580
+ ├── engine.cjs # Server-side engine (CommonJS for Node.js)
581
+ └── <gameId>.zip # Upload package
582
+ ```
583
+
584
+ The `.zip` upload package contains:
585
+
586
+ ```
587
+ <gameId>.zip
588
+ ├── manifest.json # Auto-generated metadata (from GameConfig)
589
+ ├── rules.md # Game rules
590
+ ├── bundle.js # Client-side bundle
591
+ ├── engine.cjs # Server-side engine
592
+ ├── icon.png # 1:1 game icon
593
+ ├── banner.png # 16:9 banner
594
+ ├── cover.png # 21:9 cover
595
+ └── splash.png # 9:21 splash screen
596
+ ```
597
+
598
+ ### Configuration
599
+
600
+ The `lpt.config.ts` file configures local development. The `gameId` is a local project identifier used for the zip filename and dev server — it is NOT the platform game ID (the platform assigns that upon upload):
601
+
602
+ ```typescript
603
+ // lpt.config.ts
604
+ export default {
605
+ gameId: 'my-awesome-game', // Local project identifier (zip filename, dev server)
606
+ };
607
+ ```
608
+
609
+ ### Vite Configuration (Important)
610
+
611
+ Your `vite.config.ts` **must** use a single entry point with `fileName` mapping. Do NOT use multiple entry points, as Vite will extract shared code into separate chunk files that won't be included in the upload package.
612
+
613
+ **Recommended configuration:**
614
+
615
+ ```typescript
616
+ // vite.config.ts
617
+ import { defineConfig } from "vite";
618
+ import react from "@vitejs/plugin-react";
619
+ import path from "path";
620
+
621
+ export default defineConfig({
622
+ plugins: [react()],
623
+ build: {
624
+ lib: {
625
+ entry: path.resolve(__dirname, "src/index.ts"), // Single entry point
626
+ formats: ["es", "cjs"],
627
+ fileName: (format) => {
628
+ if (format === "es") return "bundle.js";
629
+ if (format === "cjs") return "engine.cjs";
630
+ return `bundle.${format}.js`;
631
+ },
632
+ },
633
+ rollupOptions: {
634
+ external: ["react", "react-dom", "react/jsx-runtime", "@littlepartytime/sdk"],
635
+ },
636
+ outDir: "dist",
637
+ emptyOutDir: true,
638
+ },
639
+ });
640
+ ```
641
+
642
+ **Why single entry?** With multiple entry points (e.g., separate `renderer.tsx` and `engine.ts`), Vite extracts shared code into chunk files like `types-D0Vb4wB4.js`. The `pack` command only includes `bundle.js` and `engine.cjs` in the upload package — extra chunks will be missing at runtime, causing 404 errors.
643
+
644
+ **The `pack` command will reject builds with extra chunk files** to prevent this issue.
645
+
646
+ ### Required Game Images
647
+
648
+ Every game must include 4 images in the `assets/` directory. These are validated and packaged by the `pack` command.
649
+
650
+ | Image | Aspect Ratio | Min Size | Purpose |
651
+ |-------|-------------|----------|---------|
652
+ | `icon.png` | 1:1 | 256x256 | Game list icon |
653
+ | `banner.png` | 16:9 | 640x360 | Lobby banner |
654
+ | `cover.png` | 21:9 | 840x360 | Store/featured cover |
655
+ | `splash.png` | 9:21 | 360x840 | Loading/splash screen |
656
+
657
+ **Rules:**
658
+ - **Formats**: PNG or WebP only
659
+ - **Aspect ratio**: Must match exactly (1% tolerance)
660
+ - **Minimum dimensions**: Must meet or exceed the minimum size
661
+ - **File size**: Warning if any image exceeds 2MB
662
+ - **Paths**: Referenced in `GameConfig.assets` as relative paths from the project root
663
+
664
+ The `pack` command reads these images, validates them, and includes them in the zip with canonical names (`icon.png`, `banner.png`, etc.).
665
+
666
+ ### In-Game Assets (Audio, Fonts, etc.)
667
+
668
+ For assets used inside your game UI (not the 4 required images above), use one of these approaches:
669
+
670
+ | Approach | When to Use | How |
671
+ |----------|-------------|-----|
672
+ | **Inline in bundle** | Small assets (icons, sounds < 100KB) | Vite automatically inlines assets below `assetsInlineLimit` (default 4KB) as data URLs. Increase the limit in `vite.config.ts` if needed: `build: { assetsInlineLimit: 100000 }` |
673
+ | **External URL** | Large assets (images, audio, video) | Host on a CDN or external server, reference by absolute URL in your code |
674
+ | **CSS/SVG** | UI elements, icons | Use Tailwind CSS utilities, inline SVG components, or CSS gradients/shapes |
675
+ | **Emoji/Unicode** | Simple visual indicators | Use Unicode characters directly in JSX |
676
+
677
+ > **Tip:** Keep your bundle under 5MB. The `pack` command warns if `bundle.js` exceeds this limit.
678
+
679
+ ### Submitting Your Game
680
+
681
+ 1. Run `npm run pack` to build and package
682
+ 2. Navigate to the Little Party Time admin panel
683
+ 3. Upload the generated `.zip` file from `dist/`
684
+ 4. Fill in game details and submit for review
685
+
686
+ ## SDK Interfaces Reference
687
+
688
+ ### Platform (injected by the platform into the renderer)
689
+
690
+ | Method | Description |
691
+ |--------|-------------|
692
+ | `getPlayers()` | Returns all players in the game |
693
+ | `getLocalPlayer()` | Returns the current user's Player object |
694
+ | `send(action)` | Sends a GameAction to the server |
695
+ | `on(event, handler)` | Listens for events from the server |
696
+ | `off(event, handler)` | Removes an event listener |
697
+ | `reportResult(result)` | Reports game results (called by platform, not games) |
698
+
699
+ ### Events the renderer should listen for
700
+
701
+ | Event | Payload | Description |
702
+ |-------|---------|-------------|
703
+ | `stateUpdate` | `Partial<GameState>` | Player-specific state update after any action |
704
+
705
+ ### GameState
706
+
707
+ ```typescript
708
+ interface GameState {
709
+ phase: string; // Current game phase
710
+ players: PlayerState[]; // Per-player state
711
+ data: Record<string, unknown>; // Global game data
712
+ }
713
+
714
+ interface PlayerState {
715
+ id: string;
716
+ [key: string]: unknown; // Game-specific per-player data
717
+ }
718
+ ```
719
+
720
+ ## Rules and Constraints
721
+
722
+ ### Engine Rules
723
+ 1. **Pure functions**: `handleAction` must NOT mutate the input state. Always return a new object.
724
+ 2. **Deterministic**: Same state + same action = same result. No `Math.random()` in `handleAction`. Use random values only in `init()` to set up the initial state (e.g., shuffle deck).
725
+ 3. **Validate everything**: Never trust the action payload. Validate it's the correct player's turn, the action is legal, etc.
726
+ 4. **No side effects**: No network calls, no timers, no console.log in production.
727
+ 5. **Serializable state**: `GameState` must be JSON-serializable (no functions, no class instances, no Dates - use ISO strings).
728
+
729
+ ### Renderer Rules
730
+ 1. **React functional component**: Use hooks, not class components.
731
+ 2. **Tailwind CSS only**: Use the platform's design tokens for consistent styling.
732
+ 3. **Mobile-first**: Design for phone screens (375px width). The platform is a PWA.
733
+ 4. **No direct socket access**: Only use `platform.send()` and `platform.on()`.
734
+ 5. **Chinese UI text**: The platform targets Chinese-speaking users.
735
+ 6. **Responsive touch targets**: Buttons should be at least 44x44px for mobile.
736
+
737
+ ### Design Token Reference
738
+
739
+ Use these CSS variables / Tailwind classes for consistent styling:
740
+
741
+ | Purpose | CSS Variable | Tailwind Class |
742
+ |---------|-------------|----------------|
743
+ | Page background | `--bg-primary` | `bg-bg-primary` |
744
+ | Card background | `--bg-secondary` | `bg-bg-secondary` |
745
+ | Elevated surface | `--bg-tertiary` | `bg-bg-tertiary` |
746
+ | Primary accent | `--accent-primary` | `text-accent` / `bg-accent` |
747
+ | Primary text | `--text-primary` | `text-text-primary` |
748
+ | Secondary text | `--text-secondary` | `text-text-secondary` |
749
+ | Muted text | `--text-tertiary` | `text-text-tertiary` |
750
+ | Border | `--border-default` | `border-border-default` |
751
+ | Success | `--success` | `text-success` |
752
+ | Error | `--error` | `text-error` |
753
+ | Display font | `--font-display` | `font-display` |
754
+ | Body font | `--font-body` | `font-body` |
755
+
756
+ ## Data Flow Diagram
757
+
758
+ ```
759
+ Player taps button
760
+
761
+
762
+ Renderer calls platform.send({ type: "PLAY_CARD", payload: { cardId: "3" } })
763
+
764
+
765
+ Platform sends action via Socket.IO to server
766
+
767
+
768
+ Server:
769
+ 1. Loads engine for this game
770
+ 2. Gets current GameState
771
+ 3. Calls engine.handleAction(state, playerId, action) → newState
772
+ 4. Saves newState
773
+ 5. For each player: engine.getPlayerView(newState, playerId) → playerView
774
+ 6. Emits "stateUpdate" to each player's socket with their view
775
+ 7. If engine.isGameOver(newState): triggers handleGameEnd
776
+
777
+
778
+ Renderer receives "stateUpdate" event with filtered state
779
+
780
+
781
+ React re-renders with new state
782
+ ```
783
+
784
+ ## Complete Example: Number Guessing Game
785
+
786
+ See the [`examples/number-guess`](../../examples/number-guess) directory for a complete working example.
787
+
788
+ ### config.ts
789
+ ```typescript
790
+ import type { GameConfig } from "@littlepartytime/sdk";
791
+
792
+ const config: GameConfig = {
793
+ name: "Guess the Number",
794
+ description: "Take turns guessing a secret number between 1-100. The range narrows with each guess!",
795
+ assets: {
796
+ icon: "assets/icon.png",
797
+ banner: "assets/banner.png",
798
+ cover: "assets/cover.png",
799
+ splash: "assets/splash.png",
800
+ },
801
+ minPlayers: 2,
802
+ maxPlayers: 8,
803
+ tags: ["casual", "party"],
804
+ version: "1.0.0",
805
+ sdkVersion: "2.0.0",
806
+ };
807
+
808
+ export default config;
809
+ ```
810
+
811
+ ### types.ts
812
+ ```typescript
813
+ export interface GuessGameData {
814
+ secretNumber: number;
815
+ low: number;
816
+ high: number;
817
+ currentPlayerIndex: number;
818
+ lastGuess: { playerId: string; guess: number; hint: "high" | "low" } | null;
819
+ loserId: string | null;
820
+ }
821
+
822
+ export type GuessAction = { type: "GUESS"; payload: { number: number } };
823
+ ```
824
+
825
+ ### engine.ts
826
+ ```typescript
827
+ import type { GameEngine, GameState, Player, GameAction, GameResult } from "@littlepartytime/sdk";
828
+ import type { GuessGameData } from "./types";
829
+
830
+ const engine: GameEngine = {
831
+ init(players: Player[]): GameState {
832
+ const secretNumber = Math.floor(Math.random() * 100) + 1;
833
+ return {
834
+ phase: "playing",
835
+ players: players.map(p => ({ id: p.id, nickname: p.nickname })),
836
+ data: {
837
+ secretNumber,
838
+ low: 1,
839
+ high: 100,
840
+ currentPlayerIndex: 0,
841
+ lastGuess: null,
842
+ loserId: null,
843
+ } satisfies GuessGameData as unknown as Record<string, unknown>,
844
+ };
845
+ },
846
+
847
+ handleAction(state: GameState, playerId: string, action: GameAction): GameState {
848
+ if (action.type !== "GUESS") return state;
849
+ const data = state.data as unknown as GuessGameData;
850
+
851
+ const currentPlayer = state.players[data.currentPlayerIndex];
852
+ if (currentPlayer.id !== playerId) return state; // Not your turn
853
+
854
+ const guess = (action.payload as { number: number }).number;
855
+ if (guess < data.low || guess > data.high) return state; // Out of range
856
+
857
+ if (guess === data.secretNumber) {
858
+ return {
859
+ ...state,
860
+ phase: "ended",
861
+ data: {
862
+ ...data,
863
+ loserId: playerId,
864
+ lastGuess: { playerId, guess, hint: "low" },
865
+ } as unknown as Record<string, unknown>,
866
+ };
867
+ }
868
+
869
+ const hint = guess > data.secretNumber ? "high" : "low";
870
+ const newLow = hint === "low" ? Math.max(data.low, guess + 1) : data.low;
871
+ const newHigh = hint === "high" ? Math.min(data.high, guess - 1) : data.high;
872
+
873
+ return {
874
+ ...state,
875
+ data: {
876
+ ...data,
877
+ low: newLow,
878
+ high: newHigh,
879
+ currentPlayerIndex: (data.currentPlayerIndex + 1) % state.players.length,
880
+ lastGuess: { playerId, guess, hint },
881
+ } as unknown as Record<string, unknown>,
882
+ };
883
+ },
884
+
885
+ isGameOver(state: GameState): boolean {
886
+ return state.phase === "ended";
887
+ },
888
+
889
+ getResult(state: GameState): GameResult {
890
+ const data = state.data as unknown as GuessGameData;
891
+ return {
892
+ rankings: state.players.map(p => ({
893
+ playerId: p.id,
894
+ rank: p.id === data.loserId ? state.players.length : 1,
895
+ score: p.id === data.loserId ? 0 : 1,
896
+ isWinner: p.id !== data.loserId,
897
+ })),
898
+ };
899
+ },
900
+
901
+ getPlayerView(state: GameState, playerId: string): Partial<GameState> {
902
+ const data = state.data as unknown as GuessGameData;
903
+ // Hide the secret number while game is in progress
904
+ if (state.phase !== "ended") {
905
+ return {
906
+ ...state,
907
+ data: {
908
+ low: data.low,
909
+ high: data.high,
910
+ currentPlayerIndex: data.currentPlayerIndex,
911
+ lastGuess: data.lastGuess,
912
+ loserId: data.loserId,
913
+ // secretNumber is NOT included
914
+ } as unknown as Record<string, unknown>,
915
+ };
916
+ }
917
+ // Reveal everything after game ends
918
+ return state;
919
+ },
920
+ };
921
+
922
+ export default engine;
923
+ ```
924
+
925
+ ### Test file (tests/engine.test.ts)
926
+ ```typescript
927
+ import { describe, it, expect } from 'vitest';
928
+ import { GameTester, GameSimulator, createMockPlayers } from '@littlepartytime/sdk/testing';
929
+ import engine from '../src/engine';
930
+
931
+ describe('Number Guess Engine', () => {
932
+ describe('GameTester - unit tests', () => {
933
+ it('should initialize with playing phase', () => {
934
+ const tester = new GameTester(engine);
935
+ tester.init(createMockPlayers(3));
936
+ expect(tester.phase).toBe('playing');
937
+ expect(tester.playerStates).toHaveLength(3);
938
+ });
939
+
940
+ it('should hide secretNumber in player view', () => {
941
+ const tester = new GameTester(engine);
942
+ const players = createMockPlayers(2);
943
+ tester.init(players);
944
+ const view = tester.getPlayerView(players[0].id);
945
+ expect(view.data).not.toHaveProperty('secretNumber');
946
+ });
947
+
948
+ it('should reject out-of-turn actions', () => {
949
+ const tester = new GameTester(engine);
950
+ const players = createMockPlayers(2);
951
+ tester.init(players);
952
+ const before = tester.state;
953
+ tester.act(players[1].id, { type: 'GUESS', payload: { number: 50 } });
954
+ expect(tester.state).toBe(before);
955
+ });
956
+ });
957
+
958
+ describe('GameSimulator - E2E', () => {
959
+ it('should play a complete game', () => {
960
+ const sim = new GameSimulator(engine, { playerCount: 3 });
961
+ sim.start();
962
+
963
+ // Binary search to end the game quickly
964
+ let lo = 1, hi = 100;
965
+ while (!sim.isGameOver()) {
966
+ const mid = Math.floor((lo + hi) / 2);
967
+ sim.act(sim.currentTurn, { type: 'GUESS', payload: { number: mid } });
968
+ if (!sim.isGameOver()) {
969
+ const data = sim.state.data as { low: number; high: number };
970
+ lo = data.low;
971
+ hi = data.high;
972
+ }
973
+ }
974
+
975
+ expect(sim.isGameOver()).toBe(true);
976
+ const result = sim.getResult();
977
+ expect(result.rankings).toHaveLength(3);
978
+ });
979
+ });
980
+ });
981
+ ```
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # @littlepartytime/sdk
2
+
3
+ Game SDK for the [Little Party Time](https://github.com/chesterli710/littlepartytime-sdk) platform — type definitions and testing utilities for game developers.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @littlepartytime/sdk
9
+ ```
10
+
11
+ ## What's Included
12
+
13
+ - **Type definitions** — `GameConfig`, `GameEngine`, `GameState`, `GameAction`, `GameResult`, `GameRendererProps`, etc.
14
+ - **Testing utilities** — `GameTester`, `GameSimulator`, `createMockPlayers` (import from `@littlepartytime/sdk/testing`)
15
+
16
+ ## Quick Example
17
+
18
+ ```typescript
19
+ import type { GameEngine, GameState, Player } from "@littlepartytime/sdk";
20
+
21
+ const engine: GameEngine = {
22
+ init(players: Player[]): GameState { /* ... */ },
23
+ handleAction(state, playerId, action) { /* ... */ },
24
+ isGameOver(state) { /* ... */ },
25
+ getResult(state) { /* ... */ },
26
+ getPlayerView(state, playerId) { /* ... */ },
27
+ };
28
+ ```
29
+
30
+ ## Documentation
31
+
32
+ Full development guide with step-by-step instructions, testing patterns, build configuration, and asset requirements:
33
+
34
+ **[GAME_DEV_GUIDE.md](https://github.com/chesterli710/littlepartytime-sdk/blob/main/packages/sdk/GAME_DEV_GUIDE.md)**
35
+
36
+ After installing the SDK, the guide is also available locally:
37
+
38
+ ```bash
39
+ cat node_modules/@littlepartytime/sdk/GAME_DEV_GUIDE.md
40
+ ```
41
+
42
+ ## Related Packages
43
+
44
+ | Package | Description |
45
+ |---------|-------------|
46
+ | [`@littlepartytime/dev-kit`](https://www.npmjs.com/package/@littlepartytime/dev-kit) | CLI dev server, build & pack toolchain |
47
+ | [`create-littlepartytime-game`](https://www.npmjs.com/package/create-littlepartytime-game) | Project scaffolding (`npx create-littlepartytime-game`) |
48
+
49
+ ## License
50
+
51
+ MIT
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export type { Player, GameAction, GameResult, Platform } from './types';
2
- export type { PlayerState, GameState, GameConfig, GameEngine, GameRendererProps } from './interfaces';
2
+ export type { PlayerState, GameState, GameAssets, GameConfig, GameEngine, GameRendererProps } from './interfaces';
3
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACxE,YAAY,EAAE,WAAW,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACxE,YAAY,EAAE,WAAW,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC"}
@@ -8,11 +8,20 @@ export interface GameState {
8
8
  players: PlayerState[];
9
9
  data: Record<string, unknown>;
10
10
  }
11
+ export interface GameAssets {
12
+ /** 1:1 game list icon (relative path from project root, .png or .webp) */
13
+ icon: string;
14
+ /** 16:9 lobby banner */
15
+ banner: string;
16
+ /** 21:9 store/featured cover */
17
+ cover: string;
18
+ /** 9:21 loading/splash screen */
19
+ splash: string;
20
+ }
11
21
  export interface GameConfig {
12
- id: string;
13
22
  name: string;
14
23
  description: string;
15
- coverImage: string;
24
+ assets: GameAssets;
16
25
  minPlayers: number;
17
26
  maxPlayers: number;
18
27
  tags: string[];
@@ -1 +1 @@
1
- {"version":3,"file":"interfaces.d.ts","sourceRoot":"","sources":["../src/interfaces.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAExE,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,WAAW,EAAE,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC;IACtE,YAAY,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,GAAG,SAAS,CAAC;IAChF,UAAU,CAAC,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC;IACtC,SAAS,CAAC,KAAK,EAAE,SAAS,GAAG,UAAU,CAAC;IACxC,aAAa,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;CACvE;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,QAAQ,CAAC;IACnB,KAAK,EAAE,OAAO,CAAC,SAAS,CAAC,CAAC;CAC3B"}
1
+ {"version":3,"file":"interfaces.d.ts","sourceRoot":"","sources":["../src/interfaces.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAExE,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,WAAW,EAAE,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,UAAU;IACzB,0EAA0E;IAC1E,IAAI,EAAE,MAAM,CAAC;IACb,wBAAwB;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,gCAAgC;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,iCAAiC;IACjC,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,UAAU,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC;IACtE,YAAY,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,GAAG,SAAS,CAAC;IAChF,UAAU,CAAC,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC;IACtC,SAAS,CAAC,KAAK,EAAE,SAAS,GAAG,UAAU,CAAC;IACxC,aAAa,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;CACvE;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,QAAQ,CAAC;IACnB,KAAK,EAAE,OAAO,CAAC,SAAS,CAAC,CAAC;CAC3B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlepartytime/sdk",
3
- "version": "1.0.1",
3
+ "version": "2.0.1",
4
4
  "description": "Game SDK for Little Party Time platform - type definitions and testing utilities",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -22,7 +22,7 @@
22
22
  "test": "vitest run",
23
23
  "test:watch": "vitest"
24
24
  },
25
- "files": ["dist"],
25
+ "files": ["dist", "GAME_DEV_GUIDE.md"],
26
26
  "keywords": ["boardgame", "sdk", "game-engine", "littlepartytime"],
27
27
  "license": "MIT",
28
28
  "repository": {