create-usion-game 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # create-usion-game
2
+
3
+ Scaffold a new multiplayer game for the [Usion](https://usions.com) platform.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ npx create-usion-game my-game
9
+ ```
10
+
11
+ Follow the interactive prompts to choose:
12
+
13
+ 1. **Game type** — Turn-based (platform mode) or Real-time (direct mode)
14
+ 2. **Max players** — 2, 2-4, or 2-8
15
+ 3. **Server port**
16
+
17
+ ## What You Get
18
+
19
+ ### Turn-based template (platform mode)
20
+
21
+ ```
22
+ my-game/
23
+ ├── app/page.tsx # Game UI (tic-tac-toe starter)
24
+ ├── lib/types.ts # Game state types
25
+ ├── lib/constants.ts # Board size, win conditions
26
+ ├── package.json # Next.js + @usion/sdk
27
+ └── README.md
28
+ ```
29
+
30
+ Games relay through Usion's backend via Socket.IO. No server needed.
31
+
32
+ ```bash
33
+ cd my-game && npm install && npm run dev
34
+ ```
35
+
36
+ ### Real-time template (direct mode)
37
+
38
+ ```
39
+ my-game/
40
+ ├── server.js # Game server (UsionGameServer)
41
+ ├── server/game-logic.js # Pure game rules (testable)
42
+ ├── app/page.tsx # Canvas client
43
+ ├── lib/types.ts
44
+ ├── lib/constants.ts
45
+ ├── .env.example
46
+ ├── package.json # Next.js + @usion/game-server
47
+ └── README.md
48
+ ```
49
+
50
+ You run your own WebSocket server. Token validation and result submission are handled by `@usion/game-server`.
51
+
52
+ ```bash
53
+ cd my-game && npm install
54
+ cp .env.example .env # Add your SERVICE_ID and secret
55
+ npm run dev:server # Start game server
56
+ npm run dev # Start client (in another terminal)
57
+ ```
58
+
59
+ ## Related Packages
60
+
61
+ - [`@usion/sdk`](https://www.npmjs.com/package/@usion/sdk) — Client SDK for mini-apps and games
62
+ - [`@usion/game-server`](https://www.npmjs.com/package/@usion/game-server) — Server SDK for direct-mode games
63
+
64
+ ## License
65
+
66
+ MIT
package/index.js ADDED
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * create-usion-game — Scaffold a new Usion multiplayer game project.
5
+ *
6
+ * Usage:
7
+ * npx create-usion-game my-game
8
+ * node packages/create-usion-game/index.js my-game
9
+ */
10
+
11
+ import { createInterface } from 'readline';
12
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, statSync, copyFileSync } from 'fs';
13
+ import { join, dirname, resolve } from 'path';
14
+ import { fileURLToPath } from 'url';
15
+
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = dirname(__filename);
18
+ const TEMPLATES_DIR = join(__dirname, 'templates');
19
+
20
+ // ─── Helpers ───────────────────────────────────────────────────
21
+
22
+ function prompt(rl, question) {
23
+ return new Promise((resolve) => rl.question(question, resolve));
24
+ }
25
+
26
+ async function choose(rl, question, options) {
27
+ console.log(`\n${question}`);
28
+ options.forEach((opt, i) => {
29
+ console.log(` ${i + 1}) ${opt.label}${opt.description ? ` — ${opt.description}` : ''}`);
30
+ });
31
+
32
+ while (true) {
33
+ const answer = await prompt(rl, `\nChoice (1-${options.length}): `);
34
+ const idx = parseInt(answer, 10) - 1;
35
+ if (idx >= 0 && idx < options.length) return options[idx].value;
36
+ console.log(` Please enter a number between 1 and ${options.length}`);
37
+ }
38
+ }
39
+
40
+ function copyDirRecursive(src, dest, replacements) {
41
+ if (!existsSync(dest)) mkdirSync(dest, { recursive: true });
42
+
43
+ for (const entry of readdirSync(src)) {
44
+ const srcPath = join(src, entry);
45
+ const destPath = join(dest, entry);
46
+ const stat = statSync(srcPath);
47
+
48
+ if (stat.isDirectory()) {
49
+ copyDirRecursive(srcPath, destPath, replacements);
50
+ } else {
51
+ let content = readFileSync(srcPath, 'utf-8');
52
+ for (const [key, value] of Object.entries(replacements)) {
53
+ content = content.replaceAll(key, value);
54
+ }
55
+ writeFileSync(destPath, content);
56
+ }
57
+ }
58
+ }
59
+
60
+ // ─── Main ──────────────────────────────────────────────────────
61
+
62
+ async function main() {
63
+ const projectName = process.argv[2];
64
+
65
+ if (!projectName) {
66
+ console.log('\nUsage: create-usion-game <project-name>\n');
67
+ console.log('Example:');
68
+ console.log(' npx create-usion-game my-game\n');
69
+ process.exit(1);
70
+ }
71
+
72
+ const projectDir = resolve(process.cwd(), projectName);
73
+
74
+ if (existsSync(projectDir)) {
75
+ console.error(`\nError: Directory "${projectName}" already exists.\n`);
76
+ process.exit(1);
77
+ }
78
+
79
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
80
+
81
+ console.log(`\n Creating a new Usion game: ${projectName}\n`);
82
+
83
+ const gameType = await choose(rl, 'What type of game?', [
84
+ { label: 'Turn-based', value: 'turn-based', description: 'Board games, card games (platform mode)' },
85
+ { label: 'Real-time', value: 'real-time', description: 'Arena, racing, action (direct mode + server)' },
86
+ ]);
87
+
88
+ const maxPlayers = await choose(rl, 'Max players per room?', [
89
+ { label: '2 players', value: '2' },
90
+ { label: '2-4 players', value: '4' },
91
+ { label: '2-8 players', value: '8' },
92
+ ]);
93
+
94
+ const port = await prompt(rl, '\nServer port (default 3005): ');
95
+ const finalPort = port.trim() || '3005';
96
+
97
+ rl.close();
98
+
99
+ console.log(`\n Scaffolding project...\n`);
100
+
101
+ const replacements = {
102
+ '{{PROJECT_NAME}}': projectName,
103
+ '{{MAX_PLAYERS}}': maxPlayers,
104
+ '{{PORT}}': finalPort,
105
+ };
106
+
107
+ // Copy base files first
108
+ const baseDir = join(TEMPLATES_DIR, 'base');
109
+ if (existsSync(baseDir)) {
110
+ copyDirRecursive(baseDir, projectDir, replacements);
111
+ }
112
+
113
+ // Copy template-specific files (overrides base if conflict)
114
+ const templateDir = join(TEMPLATES_DIR, gameType);
115
+ copyDirRecursive(templateDir, projectDir, replacements);
116
+
117
+ // Success message
118
+ console.log(` Done! Created ${projectName}\n`);
119
+ console.log(' Next steps:\n');
120
+ console.log(` cd ${projectName}`);
121
+ console.log(' npm install');
122
+ if (gameType === 'real-time') {
123
+ console.log(' # Configure .env with your USION_SHARED_SECRET and SERVICE_ID');
124
+ console.log(' npm run dev:server # Start game server');
125
+ console.log(' npm run dev # Start Next.js client (in another terminal)');
126
+ } else {
127
+ console.log(' npm run dev # Start Next.js dev server');
128
+ }
129
+ console.log('\n Open your game in the Usion app to test.\n');
130
+ }
131
+
132
+ main().catch((err) => {
133
+ console.error(err);
134
+ process.exit(1);
135
+ });
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "create-usion-game",
3
+ "version": "1.0.0",
4
+ "description": "Create a new Usion multiplayer game project",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-usion-game": "./index.js"
8
+ },
9
+ "files": [
10
+ "index.js",
11
+ "templates/"
12
+ ],
13
+ "keywords": [
14
+ "usion",
15
+ "game",
16
+ "multiplayer",
17
+ "create",
18
+ "scaffold",
19
+ "template"
20
+ ],
21
+ "homepage": "https://github.com/usion-platform/create-usion-game#readme",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/usion-platform/create-usion-game.git"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/usion-platform/create-usion-game/issues"
28
+ },
29
+ "license": "MIT"
30
+ }
@@ -0,0 +1,7 @@
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {
3
+ reactStrictMode: false,
4
+ output: 'export',
5
+ };
6
+
7
+ export default nextConfig;
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": false,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "preserve",
15
+ "incremental": true,
16
+ "paths": {
17
+ "@/*": ["./*"]
18
+ }
19
+ },
20
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
21
+ "exclude": ["node_modules", "server", "server.js"]
22
+ }
@@ -0,0 +1,14 @@
1
+ # Usion API URL
2
+ API_URL=http://localhost:8089
3
+
4
+ # Your service ID from the Usion platform
5
+ SERVICE_ID=your-service-id
6
+
7
+ # HMAC shared secret (from service registration)
8
+ USION_SHARED_SECRET=your-shared-secret
9
+
10
+ # Signing key ID (from service realtime.signing.key_id)
11
+ USION_KEY_ID=default
12
+
13
+ # Server port
14
+ PORT={{PORT}}
@@ -0,0 +1,68 @@
1
+ # {{PROJECT_NAME}}
2
+
3
+ A real-time multiplayer game built on the Usion platform with direct-mode connection.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ npm install
9
+
10
+ # Configure your environment
11
+ cp .env.example .env
12
+ # Edit .env with your SERVICE_ID and USION_SHARED_SECRET
13
+
14
+ # Start the game server (WebSocket)
15
+ npm run dev:server
16
+
17
+ # In another terminal, start the Next.js client
18
+ npm run dev
19
+ ```
20
+
21
+ ## Architecture
22
+
23
+ ```
24
+ Usion App ──token──> Your Server (server.js)
25
+ │ │
26
+ └── WebSocket ───────┘
27
+ (direct mode)
28
+ ```
29
+
30
+ - **Server** (`server.js`): Runs `@usion/game-server`, validates RS256 tokens, manages rooms
31
+ - **Game Logic** (`server/game-logic.js`): Pure game rules — no networking, testable
32
+ - **Client** (`app/page.tsx`): Canvas rendering, input handling, SDK connection
33
+
34
+ ## Key Files
35
+
36
+ | File | Purpose |
37
+ |------|---------|
38
+ | `server.js` | UsionGameServer setup with event handlers |
39
+ | `server/game-logic.js` | Pure game logic (movement, collision, scoring) |
40
+ | `app/page.tsx` | Client-side rendering and input |
41
+ | `lib/types.ts` | Shared type definitions |
42
+ | `lib/constants.ts` | Arena size, speed, colors |
43
+
44
+ ## How It Works
45
+
46
+ 1. Player opens game in Usion app
47
+ 2. Usion backend issues RS256 access token
48
+ 3. Client connects to your server via WebSocket with token
49
+ 4. `@usion/game-server` validates token automatically
50
+ 5. Server manages room lifecycle and broadcasts state
51
+ 6. Client sends real-time input, server validates and relays
52
+
53
+ ## Customization
54
+
55
+ 1. **Game rules**: Edit `server/game-logic.js` — add your own `createPlayer`, `applyInput`, scoring
56
+ 2. **Server events**: Edit `server.js` — add `onAction('your-action', ...)` handlers
57
+ 3. **Client rendering**: Edit `app/page.tsx` — replace canvas drawing with your visuals
58
+ 4. **Constants**: Edit `lib/constants.ts` — arena size, speed, colors
59
+
60
+ ## Environment Variables
61
+
62
+ | Variable | Description |
63
+ |----------|-------------|
64
+ | `SERVICE_ID` | Your game's service ID on Usion |
65
+ | `USION_SHARED_SECRET` | HMAC secret for result submission |
66
+ | `USION_KEY_ID` | Signing key ID (default: 'default') |
67
+ | `API_URL` | Usion backend URL |
68
+ | `PORT` | Server port |
@@ -0,0 +1,12 @@
1
+ export const metadata = {
2
+ title: '{{PROJECT_NAME}}',
3
+ description: 'Usion real-time multiplayer game',
4
+ };
5
+
6
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
7
+ return (
8
+ <html lang="en">
9
+ <body>{children}</body>
10
+ </html>
11
+ );
12
+ }
@@ -0,0 +1,255 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * {{PROJECT_NAME}} — Real-Time Game Client (Direct Mode)
5
+ *
6
+ * This client connects directly to your game server's WebSocket.
7
+ * The Usion SDK handles token acquisition and connection setup.
8
+ *
9
+ * Flow: Usion.init → game.connectDirect → game.join → game.realtime → game.onRealtime
10
+ *
11
+ * Replace the canvas rendering with your own game visuals.
12
+ */
13
+
14
+ import Script from 'next/script';
15
+
16
+ export default function GamePage() {
17
+ return (
18
+ <>
19
+ <Script src="/usion-sdk.js" strategy="beforeInteractive" />
20
+
21
+ <Script id="game-logic" strategy="afterInteractive">{`
22
+ (function() {
23
+ 'use strict';
24
+
25
+ const ARENA_W = 800;
26
+ const ARENA_H = 600;
27
+ const PLAYER_R = 15;
28
+ const MOVE_SPEED = 5;
29
+ const INPUT_RATE_MS = 50;
30
+ const COLORS = ['#4FC3F7','#FF7043','#66BB6A','#AB47BC','#FFCA28','#EF5350'];
31
+
32
+ const state = {
33
+ phase: 'connecting',
34
+ myId: null,
35
+ players: {},
36
+ keys: { up: false, down: false, left: false, right: false },
37
+ error: null,
38
+ };
39
+
40
+ let canvas, ctx;
41
+ let lastInputSent = 0;
42
+
43
+ // ─── Input ───────────────────────────────────────────────
44
+ const KEY_MAP = {
45
+ ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right',
46
+ w: 'up', s: 'down', a: 'left', d: 'right',
47
+ W: 'up', S: 'down', A: 'left', D: 'right',
48
+ };
49
+
50
+ window.addEventListener('keydown', function(e) {
51
+ const dir = KEY_MAP[e.key];
52
+ if (dir) { state.keys[dir] = true; e.preventDefault(); }
53
+ });
54
+ window.addEventListener('keyup', function(e) {
55
+ const dir = KEY_MAP[e.key];
56
+ if (dir) { state.keys[dir] = false; e.preventDefault(); }
57
+ });
58
+
59
+ // ─── Render Loop ─────────────────────────────────────────
60
+ function render() {
61
+ if (!ctx) return;
62
+ ctx.fillStyle = '#0a0a0a';
63
+ ctx.fillRect(0, 0, ARENA_W, ARENA_H);
64
+
65
+ // Draw grid
66
+ ctx.strokeStyle = '#1a1a1a';
67
+ ctx.lineWidth = 1;
68
+ for (let x = 0; x < ARENA_W; x += 40) {
69
+ ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, ARENA_H); ctx.stroke();
70
+ }
71
+ for (let y = 0; y < ARENA_H; y += 40) {
72
+ ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(ARENA_W, y); ctx.stroke();
73
+ }
74
+
75
+ // Draw players
76
+ const players = Object.values(state.players);
77
+ players.forEach(function(p, i) {
78
+ ctx.fillStyle = p.color || COLORS[i % COLORS.length];
79
+ ctx.beginPath();
80
+ ctx.arc(p.x, p.y, PLAYER_R, 0, Math.PI * 2);
81
+ ctx.fill();
82
+
83
+ // Name label
84
+ ctx.fillStyle = '#fff';
85
+ ctx.font = '11px system-ui';
86
+ ctx.textAlign = 'center';
87
+ ctx.fillText(p.name || 'Player', p.x, p.y - PLAYER_R - 6);
88
+
89
+ // "You" indicator
90
+ if (p.id === state.myId) {
91
+ ctx.strokeStyle = '#fff';
92
+ ctx.lineWidth = 2;
93
+ ctx.beginPath();
94
+ ctx.arc(p.x, p.y, PLAYER_R + 4, 0, Math.PI * 2);
95
+ ctx.stroke();
96
+ }
97
+ });
98
+
99
+ // Phase overlay
100
+ if (state.phase === 'waiting') {
101
+ ctx.fillStyle = 'rgba(0,0,0,0.7)';
102
+ ctx.fillRect(0, 0, ARENA_W, ARENA_H);
103
+ ctx.fillStyle = '#fff';
104
+ ctx.font = '24px system-ui';
105
+ ctx.textAlign = 'center';
106
+ ctx.fillText('Waiting for opponent...', ARENA_W / 2, ARENA_H / 2);
107
+ } else if (state.phase === 'finished') {
108
+ ctx.fillStyle = 'rgba(0,0,0,0.7)';
109
+ ctx.fillRect(0, 0, ARENA_W, ARENA_H);
110
+ ctx.fillStyle = '#fff';
111
+ ctx.font = '28px system-ui';
112
+ ctx.textAlign = 'center';
113
+ ctx.fillText('Game Over', ARENA_W / 2, ARENA_H / 2 - 20);
114
+ ctx.font = '16px system-ui';
115
+ ctx.fillText('Tap rematch to play again', ARENA_W / 2, ARENA_H / 2 + 20);
116
+ }
117
+
118
+ // Error
119
+ if (state.error) {
120
+ ctx.fillStyle = '#ff4444';
121
+ ctx.font = '13px system-ui';
122
+ ctx.textAlign = 'center';
123
+ ctx.fillText(state.error, ARENA_W / 2, 20);
124
+ }
125
+ }
126
+
127
+ // ─── Game Loop ───────────────────────────────────────────
128
+ function gameLoop() {
129
+ const now = Date.now();
130
+
131
+ // Send input at fixed rate
132
+ if (state.phase === 'playing' && now - lastInputSent >= INPUT_RATE_MS) {
133
+ const dx = (state.keys.right ? 1 : 0) - (state.keys.left ? 1 : 0);
134
+ const dy = (state.keys.down ? 1 : 0) - (state.keys.up ? 1 : 0);
135
+
136
+ if (dx !== 0 || dy !== 0) {
137
+ Usion.game.realtime('move', { dx: dx, dy: dy });
138
+
139
+ // Local prediction
140
+ var me = state.players[state.myId];
141
+ if (me) {
142
+ var len = Math.sqrt(dx*dx + dy*dy);
143
+ me.x = Math.max(PLAYER_R, Math.min(ARENA_W - PLAYER_R, me.x + (dx/len)*MOVE_SPEED));
144
+ me.y = Math.max(PLAYER_R, Math.min(ARENA_H - PLAYER_R, me.y + (dy/len)*MOVE_SPEED));
145
+ }
146
+
147
+ lastInputSent = now;
148
+ }
149
+ }
150
+
151
+ render();
152
+ requestAnimationFrame(gameLoop);
153
+ }
154
+
155
+ // ─── SDK Init ────────────────────────────────────────────
156
+ Usion.init(function(config) {
157
+ state.myId = config.userId;
158
+
159
+ Usion.game.connectDirect()
160
+ .then(function() { return Usion.game.join(config.roomId); })
161
+ .then(function(joinData) {
162
+ state.phase = 'waiting';
163
+ render();
164
+ })
165
+ .catch(function(err) {
166
+ state.error = 'Connection failed: ' + err.message;
167
+ render();
168
+ });
169
+
170
+ // Another player joined
171
+ Usion.game.onPlayerJoined(function(data) {
172
+ state.phase = 'playing';
173
+ render();
174
+ });
175
+
176
+ // State snapshot from server
177
+ Usion.game.onStateUpdate(function(data) {
178
+ var gs = data.game_state || data.payload?.game_state || data;
179
+ if (gs.players) state.players = gs.players;
180
+ if (gs.phase) state.phase = gs.phase;
181
+ render();
182
+ });
183
+
184
+ // Real-time position update from another player
185
+ Usion.game.onRealtime(function(data) {
186
+ var d = data.action_data || data;
187
+ if (d.player_id && d.x !== undefined) {
188
+ if (state.players[d.player_id]) {
189
+ state.players[d.player_id].x = d.x;
190
+ state.players[d.player_id].y = d.y;
191
+ }
192
+ }
193
+ });
194
+
195
+ Usion.game.onGameFinished(function(data) {
196
+ state.phase = 'finished';
197
+ render();
198
+ });
199
+
200
+ Usion.game.onPlayerLeft(function(data) {
201
+ delete state.players[data.player_id];
202
+ render();
203
+ });
204
+
205
+ Usion.game.onError(function(data) {
206
+ state.error = data.message || 'Error occurred';
207
+ render();
208
+ });
209
+
210
+ Usion.game.onDisconnect(function() {
211
+ state.error = 'Disconnected';
212
+ render();
213
+ });
214
+
215
+ Usion.game.onReconnect(function() {
216
+ state.error = null;
217
+ render();
218
+ });
219
+ });
220
+
221
+ // ─── Canvas Setup ────────────────────────────────────────
222
+ document.addEventListener('DOMContentLoaded', function() {
223
+ var app = document.getElementById('app');
224
+ app.innerHTML =
225
+ '<canvas id="game-canvas" width="' + ARENA_W + '" height="' + ARENA_H + '"></canvas>';
226
+ canvas = document.getElementById('game-canvas');
227
+ ctx = canvas.getContext('2d');
228
+ gameLoop();
229
+ });
230
+ })();
231
+ `}</Script>
232
+
233
+ <div id="app" />
234
+
235
+ <style jsx global>{`
236
+ * { margin: 0; padding: 0; box-sizing: border-box; }
237
+ html, body { height: 100%; background: #000; overflow: hidden; }
238
+
239
+ #app {
240
+ display: flex;
241
+ align-items: center;
242
+ justify-content: center;
243
+ min-height: 100vh;
244
+ }
245
+
246
+ canvas {
247
+ max-width: 100vw;
248
+ max-height: 100vh;
249
+ border: 1px solid #222;
250
+ border-radius: 4px;
251
+ }
252
+ `}</style>
253
+ </>
254
+ );
255
+ }
@@ -0,0 +1,8 @@
1
+ /** Game constants */
2
+
3
+ export const ARENA_WIDTH = 800;
4
+ export const ARENA_HEIGHT = 600;
5
+ export const PLAYER_RADIUS = 15;
6
+ export const MOVE_SPEED = 5;
7
+ export const MAX_PLAYERS = {{MAX_PLAYERS}};
8
+ export const COLORS = ['#4FC3F7', '#FF7043', '#66BB6A', '#AB47BC', '#FFCA28', '#EF5350', '#26C6DA', '#8D6E63'];
@@ -0,0 +1,23 @@
1
+ /** Game-specific types */
2
+
3
+ export interface PlayerState {
4
+ id: string;
5
+ name: string;
6
+ x: number;
7
+ y: number;
8
+ score: number;
9
+ color: string;
10
+ }
11
+
12
+ export interface GameState {
13
+ players: Record<string, PlayerState>;
14
+ phase: 'waiting' | 'playing' | 'finished';
15
+ winner: string | null;
16
+ }
17
+
18
+ export type InputData = {
19
+ x: number;
20
+ y: number;
21
+ dx: number;
22
+ dy: number;
23
+ };
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "{{PROJECT_NAME}}",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "next dev -p {{PORT}} -H 0.0.0.0",
8
+ "dev:server": "node server.js",
9
+ "build": "next build",
10
+ "start": "node server.js",
11
+ "lint": "next lint"
12
+ },
13
+ "dependencies": {
14
+ "@usion/game-server": "^1.0.0",
15
+ "next": "^14.2.0",
16
+ "react": "^18.2.0",
17
+ "react-dom": "^18.2.0"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^20.0.0",
21
+ "@types/react": "^18.2.0",
22
+ "typescript": "^5.3.0"
23
+ }
24
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Pure game logic — no networking, no side effects.
3
+ *
4
+ * This file is importable and testable independently.
5
+ * Replace these functions with your own game rules.
6
+ */
7
+
8
+ const ARENA_WIDTH = 800;
9
+ const ARENA_HEIGHT = 600;
10
+ const PLAYER_RADIUS = 15;
11
+ const MOVE_SPEED = 5;
12
+ const COLORS = ['#4FC3F7', '#FF7043', '#66BB6A', '#AB47BC', '#FFCA28', '#EF5350', '#26C6DA', '#8D6E63'];
13
+
14
+ /**
15
+ * Create initial state for a new player.
16
+ */
17
+ export function createPlayer(userId, name, index) {
18
+ return {
19
+ id: userId,
20
+ name: name,
21
+ x: 100 + (index * 150) % (ARENA_WIDTH - 200),
22
+ y: 100 + Math.floor(index / 4) * 150,
23
+ score: 0,
24
+ color: COLORS[index % COLORS.length],
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Apply a movement input to a player.
30
+ * Returns the updated player object.
31
+ */
32
+ export function applyInput(player, input) {
33
+ const dx = typeof input.dx === 'number' ? input.dx : 0;
34
+ const dy = typeof input.dy === 'number' ? input.dy : 0;
35
+
36
+ // Normalize diagonal movement
37
+ const len = Math.sqrt(dx * dx + dy * dy);
38
+ const ndx = len > 0 ? (dx / len) * MOVE_SPEED : 0;
39
+ const ndy = len > 0 ? (dy / len) * MOVE_SPEED : 0;
40
+
41
+ return {
42
+ ...player,
43
+ x: Math.max(PLAYER_RADIUS, Math.min(ARENA_WIDTH - PLAYER_RADIUS, player.x + ndx)),
44
+ y: Math.max(PLAYER_RADIUS, Math.min(ARENA_HEIGHT - PLAYER_RADIUS, player.y + ndy)),
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Check if two players are colliding.
50
+ */
51
+ export function checkCollision(p1, p2) {
52
+ const dx = p1.x - p2.x;
53
+ const dy = p1.y - p2.y;
54
+ return Math.sqrt(dx * dx + dy * dy) < PLAYER_RADIUS * 2;
55
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * {{PROJECT_NAME}} — Game Server (Direct Mode)
3
+ *
4
+ * This server handles WebSocket connections from Usion game clients.
5
+ * Token validation and result submission are handled automatically
6
+ * by @usion/game-server.
7
+ *
8
+ * Start: node server.js
9
+ */
10
+ import { UsionGameServer } from '@usion/game-server';
11
+ import { createPlayer, applyInput } from './server/game-logic.js';
12
+
13
+ const PORT = Number(process.env.PORT || {{PORT}});
14
+ const MAX_PLAYERS = {{MAX_PLAYERS}};
15
+
16
+ const server = new UsionGameServer({
17
+ serviceId: process.env.SERVICE_ID || 'my-game',
18
+ sharedSecret: process.env.USION_SHARED_SECRET || 'dev-secret',
19
+ keyId: process.env.USION_KEY_ID || 'default',
20
+ apiUrl: process.env.API_URL || 'http://localhost:8089',
21
+ port: PORT,
22
+ maxPlayersPerRoom: MAX_PLAYERS,
23
+ });
24
+
25
+ // ─── Player Join ─────────────────────────────────────────────
26
+
27
+ server.onPlayerJoin((player, room) => {
28
+ console.log(`[${room.id}] Player joined: ${player.name} (${player.id})`);
29
+
30
+ // Create game player state
31
+ const playerState = createPlayer(player.id, player.name, room.playerCount - 1);
32
+
33
+ // Add to game state
34
+ const players = room.getState().players || {};
35
+ players[player.id] = playerState;
36
+ room.setState({ players, phase: 'waiting' });
37
+
38
+ // Start game when enough players join
39
+ if (room.playerCount >= 2) {
40
+ room.setState({ phase: 'playing' });
41
+ room.broadcast('state_snapshot', {
42
+ room_id: room.id,
43
+ game_state: room.getState(),
44
+ });
45
+ }
46
+ });
47
+
48
+ // ─── Player Leave ────────────────────────────────────────────
49
+
50
+ server.onPlayerLeave((player, room) => {
51
+ console.log(`[${room.id}] Player left: ${player.name}`);
52
+
53
+ const state = room.getState();
54
+ if (state.players) {
55
+ delete state.players[player.id];
56
+ room.setState({ players: state.players });
57
+ }
58
+
59
+ // If only one player left, they win
60
+ if (room.playerCount === 1 && state.phase === 'playing') {
61
+ const winnerId = room.playerIds[0];
62
+ room.setState({ phase: 'finished', winner: winnerId });
63
+ room.broadcast('match_end', {
64
+ room_id: room.id,
65
+ winner_ids: [winnerId],
66
+ reason: 'opponent_left',
67
+ });
68
+
69
+ // Submit result to Usion backend
70
+ server.submitResult(room.id, {
71
+ winnerIds: [winnerId],
72
+ reason: 'opponent_left',
73
+ }).catch(err => console.error('Result submission failed:', err.message));
74
+ }
75
+ });
76
+
77
+ // ─── Real-time Input (fire-and-forget, high frequency) ───────
78
+
79
+ server.onRealtime('move', (data, player, room) => {
80
+ const state = room.getState();
81
+ if (!state.players || state.phase !== 'playing') return;
82
+
83
+ const playerState = state.players[player.id];
84
+ if (!playerState) return;
85
+
86
+ // Apply movement with server-side validation
87
+ state.players[player.id] = applyInput(playerState, data);
88
+ room.setState({ players: state.players });
89
+
90
+ // Broadcast updated position to other players
91
+ room.broadcastExcept(player.id, 'player_position', {
92
+ player_id: player.id,
93
+ x: state.players[player.id].x,
94
+ y: state.players[player.id].y,
95
+ });
96
+ });
97
+
98
+ // ─── Turn-based Actions (validated, stored) ──────────────────
99
+
100
+ server.onAction('attack', (data, player, room) => {
101
+ const state = room.getState();
102
+ if (!state.players || state.phase !== 'playing') return;
103
+
104
+ // Example: handle an attack action
105
+ console.log(`[${room.id}] ${player.name} attacks!`);
106
+
107
+ // Broadcast to all players
108
+ room.broadcast('state_snapshot', {
109
+ room_id: room.id,
110
+ game_state: state,
111
+ });
112
+ });
113
+
114
+ // ─── Forfeit ─────────────────────────────────────────────────
115
+
116
+ server.onForfeit((player, room) => {
117
+ console.log(`[${room.id}] ${player.name} forfeited`);
118
+
119
+ const winnerIds = room.playerIds.filter(id => id !== player.id);
120
+ room.setState({ phase: 'finished', winner: winnerIds[0] || null });
121
+
122
+ room.broadcast('match_end', {
123
+ room_id: room.id,
124
+ winner_ids: winnerIds,
125
+ reason: 'forfeit',
126
+ });
127
+
128
+ server.submitResult(room.id, {
129
+ winnerIds,
130
+ reason: 'forfeit',
131
+ }).catch(err => console.error('Result submission failed:', err.message));
132
+ });
133
+
134
+ // ─── Rematch ─────────────────────────────────────────────────
135
+
136
+ server.onRematch((player, room) => {
137
+ console.log(`[${room.id}] ${player.name} requests rematch`);
138
+ // The SDK already broadcasts rematch_request to other players.
139
+ // You can add custom logic here (e.g., reset game state when both agree).
140
+ });
141
+
142
+ // ─── Start Server ────────────────────────────────────────────
143
+
144
+ server.listen(() => {
145
+ console.log(`Game server ready on port ${PORT}`);
146
+ });
@@ -0,0 +1,5 @@
1
+ # Usion API URL (backend server)
2
+ # In development, this is typically http://localhost:8089
3
+ # The game loads the SDK from the parent app, so this is only needed
4
+ # if you're testing standalone.
5
+ NEXT_PUBLIC_API_URL=http://localhost:8089
@@ -0,0 +1,41 @@
1
+ # {{PROJECT_NAME}}
2
+
3
+ A turn-based multiplayer game built on the Usion platform.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ npm install
9
+ npm run dev
10
+ ```
11
+
12
+ Open the game inside the Usion app (mobile or web) to test with other players.
13
+
14
+ ## Architecture
15
+
16
+ - **Connection**: Platform mode — game relays through Usion's Socket.IO backend
17
+ - **Actions**: `Usion.game.action('move', { cell })` sends validated, sequenced moves
18
+ - **State**: Listen to `onAction` and `onStateUpdate` for opponent moves
19
+
20
+ ## Key Files
21
+
22
+ - `app/page.tsx` — Game UI and logic
23
+ - `lib/types.ts` — Game state types
24
+ - `lib/constants.ts` — Board size, win conditions, etc.
25
+
26
+ ## How It Works
27
+
28
+ 1. SDK initializes with player info from the parent Usion app
29
+ 2. Connects to Usion's Socket.IO server
30
+ 3. Joins the game room
31
+ 4. Players take turns sending actions
32
+ 5. Each client validates moves locally and updates the board
33
+
34
+ ## Customization
35
+
36
+ Replace the tic-tac-toe logic with your own game:
37
+
38
+ 1. Change `BOARD_SIZE` and `WIN_LINES` in `lib/constants.ts`
39
+ 2. Update the board rendering in `page.tsx`
40
+ 3. Modify the `_cellClick` handler for your game's input
41
+ 4. Update `checkWinner` with your win conditions
@@ -0,0 +1,12 @@
1
+ export const metadata = {
2
+ title: '{{PROJECT_NAME}}',
3
+ description: 'Usion multiplayer game',
4
+ };
5
+
6
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
7
+ return (
8
+ <html lang="en">
9
+ <body>{children}</body>
10
+ </html>
11
+ );
12
+ }
@@ -0,0 +1,322 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * {{PROJECT_NAME}} — Turn-Based Game (Platform Mode)
5
+ *
6
+ * This is a starter template for a turn-based multiplayer game.
7
+ * It connects through the Usion platform via Socket.IO.
8
+ *
9
+ * Flow: Usion.init → game.connect → game.join → game.action → game.onStateUpdate
10
+ *
11
+ * Replace the board rendering and game logic with your own game.
12
+ */
13
+
14
+ import Script from 'next/script';
15
+
16
+ export default function GamePage() {
17
+ return (
18
+ <>
19
+ {/* Load Usion SDK from the shared design system server */}
20
+ <Script src="/usion-sdk.js" strategy="beforeInteractive" />
21
+
22
+ <Script id="game-logic" strategy="afterInteractive">{`
23
+ (function() {
24
+ 'use strict';
25
+
26
+ // ─── Game State ────────────────────────────────────────────
27
+ const BOARD_SIZE = 9;
28
+ const WIN_LINES = [
29
+ [0,1,2],[3,4,5],[6,7,8],
30
+ [0,3,6],[1,4,7],[2,5,8],
31
+ [0,4,8],[2,4,6],
32
+ ];
33
+
34
+ const state = {
35
+ phase: 'connecting', // connecting | waiting | playing | finished
36
+ board: Array(BOARD_SIZE).fill(null),
37
+ myId: null,
38
+ roomId: null,
39
+ playerIds: [],
40
+ currentTurn: null,
41
+ winner: null,
42
+ error: null,
43
+ };
44
+
45
+ // ─── DOM Refs ──────────────────────────────────────────────
46
+ const $ = (sel) => document.querySelector(sel);
47
+
48
+ // ─── Rendering ─────────────────────────────────────────────
49
+ function render() {
50
+ // Phase display
51
+ const statusEl = $('#status');
52
+ const boardEl = $('#board');
53
+ const errorEl = $('#error');
54
+ const overlayEl = $('#overlay');
55
+
56
+ if (!statusEl) return; // DOM not ready
57
+
58
+ // Error banner
59
+ if (state.error) {
60
+ errorEl.textContent = state.error;
61
+ errorEl.style.display = 'block';
62
+ setTimeout(() => { errorEl.style.display = 'none'; state.error = null; }, 4000);
63
+ }
64
+
65
+ // Status text
66
+ if (state.phase === 'connecting') {
67
+ statusEl.textContent = 'Connecting...';
68
+ } else if (state.phase === 'waiting') {
69
+ statusEl.textContent = 'Waiting for opponent...';
70
+ } else if (state.phase === 'finished') {
71
+ statusEl.textContent = state.winner === state.myId ? 'You win!' :
72
+ state.winner === 'draw' ? 'Draw!' : 'You lose!';
73
+ } else {
74
+ const isMyTurn = state.currentTurn === state.myId;
75
+ statusEl.textContent = isMyTurn ? 'Your turn' : "Opponent's turn";
76
+ }
77
+
78
+ // Board cells
79
+ const cells = boardEl.querySelectorAll('.cell');
80
+ state.board.forEach((val, i) => {
81
+ const cell = cells[i];
82
+ if (!cell) return;
83
+ cell.textContent = val || '';
84
+ cell.className = 'cell' + (val ? ' taken' : '') +
85
+ (state.phase === 'playing' && !val && state.currentTurn === state.myId ? ' clickable' : '');
86
+ });
87
+
88
+ // Overlay for waiting/finished
89
+ if (state.phase === 'waiting') {
90
+ overlayEl.style.display = 'flex';
91
+ overlayEl.innerHTML = '<div class="overlay-text">Waiting for opponent...</div>';
92
+ } else if (state.phase === 'finished') {
93
+ overlayEl.style.display = 'flex';
94
+ const msg = state.winner === state.myId ? 'You Win!' :
95
+ state.winner === 'draw' ? "It's a Draw!" : 'You Lose!';
96
+ overlayEl.innerHTML =
97
+ '<div class="overlay-text">' + msg + '</div>' +
98
+ '<button class="rematch-btn" onclick="window._requestRematch()">Rematch</button>';
99
+ } else {
100
+ overlayEl.style.display = 'none';
101
+ }
102
+ }
103
+
104
+ // ─── Game Logic ────────────────────────────────────────────
105
+ function checkWinner(board) {
106
+ for (const [a, b, c] of WIN_LINES) {
107
+ if (board[a] && board[a] === board[b] && board[b] === board[c]) {
108
+ return board[a]; // 'X' or 'O'
109
+ }
110
+ }
111
+ if (board.every(cell => cell !== null)) return 'draw';
112
+ return null;
113
+ }
114
+
115
+ function getSymbol(playerId) {
116
+ const idx = state.playerIds.indexOf(playerId);
117
+ return idx === 0 ? 'X' : 'O';
118
+ }
119
+
120
+ // ─── Cell Click Handler ────────────────────────────────────
121
+ window._cellClick = function(index) {
122
+ if (state.phase !== 'playing') return;
123
+ if (state.currentTurn !== state.myId) return;
124
+ if (state.board[index] !== null) return;
125
+
126
+ Usion.game.action('move', { cell: index }).catch(function(err) {
127
+ state.error = 'Failed to send move: ' + err.message;
128
+ render();
129
+ });
130
+ };
131
+
132
+ window._requestRematch = function() {
133
+ Usion.game.requestRematch();
134
+ };
135
+
136
+ // ─── SDK Init & Connection ─────────────────────────────────
137
+ Usion.init(function(config) {
138
+ state.myId = config.userId;
139
+ state.roomId = config.roomId;
140
+
141
+ Usion.game.connect()
142
+ .then(function() { return Usion.game.join(config.roomId); })
143
+ .then(function(joinData) {
144
+ state.playerIds = joinData.player_ids || [];
145
+ state.phase = state.playerIds.length >= 2 ? 'playing' : 'waiting';
146
+
147
+ if (joinData.game_state) {
148
+ state.board = joinData.game_state.board || Array(BOARD_SIZE).fill(null);
149
+ state.currentTurn = joinData.game_state.currentTurn || state.playerIds[0];
150
+ } else {
151
+ state.currentTurn = state.playerIds[0];
152
+ }
153
+
154
+ render();
155
+ })
156
+ .catch(function(err) {
157
+ state.error = 'Connection failed: ' + err.message;
158
+ render();
159
+ });
160
+
161
+ // ─── Event Handlers ────────────────────────────────────
162
+ Usion.game.onPlayerJoined(function(data) {
163
+ state.playerIds = data.player_ids || state.playerIds;
164
+ if (state.playerIds.length >= 2 && state.phase === 'waiting') {
165
+ state.phase = 'playing';
166
+ if (!state.currentTurn) state.currentTurn = state.playerIds[0];
167
+ }
168
+ render();
169
+ });
170
+
171
+ Usion.game.onStateUpdate(function(data) {
172
+ if (data.game_state) {
173
+ state.board = data.game_state.board || state.board;
174
+ state.currentTurn = data.game_state.currentTurn || state.currentTurn;
175
+ }
176
+ render();
177
+ });
178
+
179
+ Usion.game.onAction(function(data) {
180
+ if (data.action_type === 'move' && data.action_data) {
181
+ const cell = data.action_data.cell;
182
+ const symbol = getSymbol(data.player_id);
183
+ state.board[cell] = symbol;
184
+
185
+ // Check for winner
186
+ const result = checkWinner(state.board);
187
+ if (result) {
188
+ state.phase = 'finished';
189
+ state.winner = result === 'draw' ? 'draw' :
190
+ state.playerIds.find(function(id) { return getSymbol(id) === result; });
191
+ } else {
192
+ // Switch turn
193
+ const currentIdx = state.playerIds.indexOf(data.player_id);
194
+ state.currentTurn = state.playerIds[(currentIdx + 1) % state.playerIds.length];
195
+ }
196
+ }
197
+ render();
198
+ });
199
+
200
+ Usion.game.onGameFinished(function(data) {
201
+ state.phase = 'finished';
202
+ state.winner = data.winner_ids && data.winner_ids[0] ? data.winner_ids[0] : 'draw';
203
+ render();
204
+ });
205
+
206
+ Usion.game.onGameRestarted(function() {
207
+ state.board = Array(BOARD_SIZE).fill(null);
208
+ state.winner = null;
209
+ state.currentTurn = state.playerIds[0];
210
+ state.phase = 'playing';
211
+ render();
212
+ });
213
+
214
+ Usion.game.onError(function(data) {
215
+ state.error = data.message || 'An error occurred';
216
+ render();
217
+ });
218
+
219
+ Usion.game.onDisconnect(function() {
220
+ state.error = 'Disconnected from server';
221
+ render();
222
+ });
223
+
224
+ Usion.game.onReconnect(function() {
225
+ state.error = null;
226
+ Usion.game.requestSync();
227
+ render();
228
+ });
229
+ });
230
+
231
+ // ─── Build DOM ─────────────────────────────────────────────
232
+ document.addEventListener('DOMContentLoaded', function() {
233
+ const app = document.getElementById('app');
234
+ if (!app) return;
235
+
236
+ app.innerHTML =
237
+ '<div id="error" class="error-banner" style="display:none"></div>' +
238
+ '<div id="status" class="status">Connecting...</div>' +
239
+ '<div id="board" class="board">' +
240
+ Array(BOARD_SIZE).fill(0).map(function(_, i) {
241
+ return '<div class="cell" onclick="window._cellClick(' + i + ')"></div>';
242
+ }).join('') +
243
+ '</div>' +
244
+ '<div id="overlay" class="overlay" style="display:none"></div>';
245
+
246
+ render();
247
+ });
248
+ })();
249
+ `}</Script>
250
+
251
+ <div id="app" />
252
+
253
+ <style jsx global>{`
254
+ * { margin: 0; padding: 0; box-sizing: border-box; }
255
+ html, body { height: 100%; background: #000; color: #fff; font-family: -apple-system, system-ui, sans-serif; }
256
+
257
+ #app {
258
+ display: flex;
259
+ flex-direction: column;
260
+ align-items: center;
261
+ justify-content: center;
262
+ min-height: 100vh;
263
+ padding: 20px;
264
+ }
265
+
266
+ .error-banner {
267
+ position: fixed; top: 12px; left: 50%; transform: translateX(-50%);
268
+ background: #ff4444; color: #fff; padding: 8px 16px; border-radius: 8px;
269
+ font-size: 13px; z-index: 100; max-width: 90%;
270
+ }
271
+
272
+ .status {
273
+ font-size: 18px; font-weight: 600; margin-bottom: 24px;
274
+ color: #aaa; letter-spacing: 0.5px;
275
+ }
276
+
277
+ .board {
278
+ display: grid;
279
+ grid-template-columns: repeat(3, 1fr);
280
+ gap: 4px;
281
+ width: min(80vw, 300px);
282
+ aspect-ratio: 1;
283
+ }
284
+
285
+ .cell {
286
+ background: #111;
287
+ border-radius: 8px;
288
+ display: flex;
289
+ align-items: center;
290
+ justify-content: center;
291
+ font-size: 36px;
292
+ font-weight: 700;
293
+ color: #fff;
294
+ transition: background 0.15s;
295
+ cursor: default;
296
+ user-select: none;
297
+ }
298
+
299
+ .cell.clickable { cursor: pointer; background: #1a1a1a; }
300
+ .cell.clickable:hover { background: #222; }
301
+ .cell.taken { color: #fff; }
302
+
303
+ .overlay {
304
+ position: fixed; inset: 0;
305
+ background: rgba(0,0,0,0.85);
306
+ display: flex; flex-direction: column;
307
+ align-items: center; justify-content: center;
308
+ z-index: 50;
309
+ }
310
+
311
+ .overlay-text { font-size: 28px; font-weight: 700; margin-bottom: 20px; }
312
+
313
+ .rematch-btn {
314
+ background: #fff; color: #000; border: none;
315
+ padding: 12px 32px; border-radius: 8px;
316
+ font-size: 16px; font-weight: 600; cursor: pointer;
317
+ }
318
+ .rematch-btn:hover { background: #ddd; }
319
+ `}</style>
320
+ </>
321
+ );
322
+ }
@@ -0,0 +1,13 @@
1
+ /** Game constants — tune these for your game */
2
+
3
+ export const BOARD_SIZE = 9;
4
+
5
+ export const WIN_LINES = [
6
+ [0, 1, 2], [3, 4, 5], [6, 7, 8], // rows
7
+ [0, 3, 6], [1, 4, 7], [2, 5, 8], // columns
8
+ [0, 4, 8], [2, 4, 6], // diagonals
9
+ ];
10
+
11
+ export const SYMBOLS = ['X', 'O', '△', '□'];
12
+
13
+ export const MAX_PLAYERS = {{MAX_PLAYERS}};
@@ -0,0 +1,16 @@
1
+ /** Game-specific types */
2
+
3
+ export interface GameState {
4
+ board: (string | null)[];
5
+ currentTurn: string | null;
6
+ winner: string | null;
7
+ isDraw: boolean;
8
+ }
9
+
10
+ export interface PlayerInfo {
11
+ id: string;
12
+ name: string;
13
+ symbol: string;
14
+ }
15
+
16
+ export type GamePhase = 'connecting' | 'waiting' | 'playing' | 'finished';
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "{{PROJECT_NAME}}",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev -p {{PORT}} -H 0.0.0.0",
7
+ "build": "next build",
8
+ "start": "next start -p {{PORT}}",
9
+ "lint": "next lint"
10
+ },
11
+ "dependencies": {
12
+ "next": "^14.2.0",
13
+ "react": "^18.2.0",
14
+ "react-dom": "^18.2.0"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^20.0.0",
18
+ "@types/react": "^18.2.0",
19
+ "typescript": "^5.3.0"
20
+ }
21
+ }