@sansavision/create-pulse 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist/index.js +2 -0
  2. package/package.json +2 -2
  3. package/templates/aurora-auth-node-demo/README.md +43 -0
  4. package/templates/aurora-auth-node-demo/aurora.config.ts +15 -0
  5. package/templates/aurora-auth-node-demo/bun.lock +679 -0
  6. package/templates/aurora-auth-node-demo/drizzle.config.ts +9 -0
  7. package/templates/aurora-auth-node-demo/package.json +39 -0
  8. package/templates/aurora-auth-node-demo/postcss.config.mjs +7 -0
  9. package/templates/aurora-auth-node-demo/server.mjs +46 -0
  10. package/templates/aurora-auth-node-demo/src/actions/createMessage.action.server.ts +31 -0
  11. package/templates/aurora-auth-node-demo/src/aurora.auth.ts +65 -0
  12. package/templates/aurora-auth-node-demo/src/lib/auth-client.ts +30 -0
  13. package/templates/aurora-auth-node-demo/src/lib/auth.server.ts +11 -0
  14. package/templates/aurora-auth-node-demo/src/lib/auth.ts +30 -0
  15. package/templates/aurora-auth-node-demo/src/lib/db.ts +6 -0
  16. package/templates/aurora-auth-node-demo/src/lib/pulse.ts +45 -0
  17. package/templates/aurora-auth-node-demo/src/lib/schema.ts +107 -0
  18. package/templates/aurora-auth-node-demo/src/queries/listMessages.server.ts +25 -0
  19. package/templates/aurora-auth-node-demo/src/routes/api/auth/[...slug]/handler.ts +14 -0
  20. package/templates/aurora-auth-node-demo/src/routes/api/pulse/verify/handler.ts +55 -0
  21. package/templates/aurora-auth-node-demo/src/routes/auth/sign-in/page.client.tsx +132 -0
  22. package/templates/aurora-auth-node-demo/src/routes/auth/sign-in/page.tsx +5 -0
  23. package/templates/aurora-auth-node-demo/src/routes/auth/sign-up/page.client.tsx +154 -0
  24. package/templates/aurora-auth-node-demo/src/routes/auth/sign-up/page.tsx +5 -0
  25. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/arena-game/page.client.tsx +640 -0
  26. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/arena-game/page.tsx +5 -0
  27. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/chat/page.client.tsx +349 -0
  28. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/chat/page.tsx +5 -0
  29. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/encrypted-chat/page.client.tsx +472 -0
  30. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/encrypted-chat/page.tsx +5 -0
  31. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/game-sync/page.client.tsx +375 -0
  32. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/game-sync/page.tsx +5 -0
  33. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/queues/page.client.tsx +423 -0
  34. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/queues/page.tsx +5 -0
  35. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/video-call/page.client.tsx +840 -0
  36. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/video-call/page.tsx +5 -0
  37. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/watch-together/page.client.tsx +722 -0
  38. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/watch-together/page.tsx +5 -0
  39. package/templates/aurora-auth-node-demo/src/routes/dashboard/layout.client.tsx +113 -0
  40. package/templates/aurora-auth-node-demo/src/routes/dashboard/layout.tsx +5 -0
  41. package/templates/aurora-auth-node-demo/src/routes/dashboard/page.client.tsx +195 -0
  42. package/templates/aurora-auth-node-demo/src/routes/dashboard/page.tsx +5 -0
  43. package/templates/aurora-auth-node-demo/src/routes/favicon.ico +0 -0
  44. package/templates/aurora-auth-node-demo/src/routes/layout.tsx +18 -0
  45. package/templates/aurora-auth-node-demo/src/routes/page.client.tsx +263 -0
  46. package/templates/aurora-auth-node-demo/src/routes/page.tsx +5 -0
  47. package/templates/aurora-auth-node-demo/src/styles/app.css +96 -0
  48. package/templates/aurora-auth-node-demo/tsconfig.json +27 -0
  49. package/templates/aurora-auth-node-demo/tsconfig.tsbuildinfo +1 -0
  50. package/templates/nextjs-auth-demo/next-env.d.ts +1 -1
  51. package/templates/nextjs-auth-demo/package.json +8 -7
  52. package/templates/nextjs-auth-demo/src/app/dashboard/demos/arena-game/page.tsx +20 -3
  53. package/templates/nextjs-auth-demo/src/app/dashboard/demos/chat/page.tsx +108 -23
  54. package/templates/nextjs-auth-demo/src/app/dashboard/demos/encrypted-chat/page.tsx +278 -217
  55. package/templates/nextjs-auth-demo/src/app/dashboard/demos/game-sync/page.tsx +66 -35
  56. package/templates/nextjs-auth-demo/src/app/dashboard/demos/queues/page.tsx +213 -87
  57. package/templates/nextjs-auth-demo/src/app/dashboard/demos/video-call/page.tsx +106 -6
  58. package/templates/nextjs-auth-demo/src/app/dashboard/demos/watch-together/page.tsx +415 -262
  59. package/templates/nextjs-auth-node-demo/.env.example +10 -0
  60. package/templates/nextjs-auth-node-demo/Dockerfile +19 -0
  61. package/templates/nextjs-auth-node-demo/README.md +159 -0
  62. package/templates/nextjs-auth-node-demo/_gitignore +33 -0
  63. package/templates/nextjs-auth-node-demo/drizzle.config.ts +10 -0
  64. package/templates/nextjs-auth-node-demo/eslint.config.mjs +18 -0
  65. package/templates/nextjs-auth-node-demo/next-env.d.ts +6 -0
  66. package/templates/nextjs-auth-node-demo/next.config.ts +7 -0
  67. package/templates/nextjs-auth-node-demo/package.json +38 -0
  68. package/templates/nextjs-auth-node-demo/postcss.config.mjs +7 -0
  69. package/templates/nextjs-auth-node-demo/public/file.svg +1 -0
  70. package/templates/nextjs-auth-node-demo/public/globe.svg +1 -0
  71. package/templates/nextjs-auth-node-demo/public/next.svg +1 -0
  72. package/templates/nextjs-auth-node-demo/public/vercel.svg +1 -0
  73. package/templates/nextjs-auth-node-demo/public/window.svg +1 -0
  74. package/templates/nextjs-auth-node-demo/server.mjs +45 -0
  75. package/templates/nextjs-auth-node-demo/src/app/api/auth/[...all]/route.ts +4 -0
  76. package/templates/nextjs-auth-node-demo/src/app/api/pulse/verify/route.ts +54 -0
  77. package/templates/nextjs-auth-node-demo/src/app/auth/sign-in/page.tsx +131 -0
  78. package/templates/nextjs-auth-node-demo/src/app/auth/sign-up/page.tsx +153 -0
  79. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/arena-game/page.tsx +640 -0
  80. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/chat/page.tsx +349 -0
  81. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/encrypted-chat/page.tsx +472 -0
  82. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/game-sync/page.tsx +375 -0
  83. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/queues/page.tsx +423 -0
  84. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/video-call/page.tsx +840 -0
  85. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/watch-together/page.tsx +724 -0
  86. package/templates/nextjs-auth-node-demo/src/app/dashboard/layout.tsx +113 -0
  87. package/templates/nextjs-auth-node-demo/src/app/dashboard/page.tsx +195 -0
  88. package/templates/nextjs-auth-node-demo/src/app/favicon.ico +0 -0
  89. package/templates/nextjs-auth-node-demo/src/app/globals.css +96 -0
  90. package/templates/nextjs-auth-node-demo/src/app/layout.tsx +27 -0
  91. package/templates/nextjs-auth-node-demo/src/app/page.tsx +254 -0
  92. package/templates/nextjs-auth-node-demo/src/lib/auth-client.ts +15 -0
  93. package/templates/nextjs-auth-node-demo/src/lib/auth.ts +14 -0
  94. package/templates/nextjs-auth-node-demo/src/lib/db.ts +6 -0
  95. package/templates/nextjs-auth-node-demo/src/lib/pulse.ts +45 -0
  96. package/templates/nextjs-auth-node-demo/src/lib/schema.ts +107 -0
  97. package/templates/nextjs-auth-node-demo/tsconfig.json +34 -0
@@ -0,0 +1,640 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useRef, useCallback } from "react";
4
+ import { useSession } from "@/lib/auth-client";
5
+ import { connectWithAuth } from "@/lib/pulse";
6
+ import {
7
+ Gamepad2,
8
+ Shield,
9
+ Wifi,
10
+ WifiOff,
11
+ Loader2,
12
+ Users,
13
+ Copy,
14
+ Check,
15
+ Swords,
16
+ Trophy,
17
+ Zap,
18
+ Activity,
19
+ AlertCircle,
20
+ } from "lucide-react";
21
+ import type { PulseConnection } from "@sansavision/pulse-sdk";
22
+
23
+ interface PlayerState {
24
+ x: number;
25
+ y: number;
26
+ color: string;
27
+ label: string;
28
+ score: number;
29
+ }
30
+
31
+ interface Collectible {
32
+ x: number;
33
+ y: number;
34
+ color: string;
35
+ collected: boolean;
36
+ id: number;
37
+ }
38
+
39
+ export default function ArenaGamePage() {
40
+ const { data: session } = useSession();
41
+ const [connected, setConnected] = useState(false);
42
+ const [connecting, setConnecting] = useState(true);
43
+ const [authUser, setAuthUser] = useState<{
44
+ id: string;
45
+ claims: Record<string, string>;
46
+ } | null>(null);
47
+
48
+ // Room management
49
+ const [roomId, setRoomId] = useState("");
50
+ const [inputRoomId, setInputRoomId] = useState("");
51
+ const [error, setError] = useState("");
52
+ const [inGame, setInGame] = useState(false);
53
+ const [copied, setCopied] = useState(false);
54
+ const [playerCount, setPlayerCount] = useState(1);
55
+ const [opponent, setOpponent] = useState<string | null>(null);
56
+
57
+ // Game state
58
+ const canvasRef = useRef<HTMLCanvasElement>(null);
59
+ const localPlayerRef = useRef<PlayerState>({
60
+ x: 0.3, y: 0.5, color: "#7C3AED", label: "P1", score: 0,
61
+ });
62
+ const remotePlayerRef = useRef<PlayerState>({
63
+ x: 0.7, y: 0.5, color: "#10B981", label: "P2", score: 0,
64
+ });
65
+ const collectiblesRef = useRef<Collectible[]>(
66
+ Array.from({ length: 5 }).map((_, i) => ({
67
+ x: 0.1 + Math.random() * 0.8,
68
+ y: 0.1 + Math.random() * 0.8,
69
+ color: "#F59E0B",
70
+ collected: false,
71
+ id: i,
72
+ }))
73
+ );
74
+
75
+ const keysRef = useRef<{ [key: string]: boolean }>({});
76
+ const connRef = useRef<PulseConnection | null>(null);
77
+ const animationIdRef = useRef<number>(0);
78
+ const [scores, setScores] = useState({ p1: 0, p2: 0 });
79
+ const [syncMetrics, setSyncMetrics] = useState({ ups: 0, delay: "—" });
80
+ const statsRef = useRef({ updatesThisSecond: 0, lastDelay: 0 });
81
+
82
+ // Keyboard listeners
83
+ useEffect(() => {
84
+ const handleKeyDown = (e: KeyboardEvent) => {
85
+ keysRef.current[e.key.toLowerCase()] = true;
86
+ };
87
+ const handleKeyUp = (e: KeyboardEvent) => {
88
+ keysRef.current[e.key.toLowerCase()] = false;
89
+ };
90
+ window.addEventListener("keydown", handleKeyDown);
91
+ window.addEventListener("keyup", handleKeyUp);
92
+ return () => {
93
+ window.removeEventListener("keydown", handleKeyDown);
94
+ window.removeEventListener("keyup", handleKeyUp);
95
+ };
96
+ }, []);
97
+
98
+ // Connect to Pulse
99
+ useEffect(() => {
100
+ if (!session) return;
101
+ let cancelled = false;
102
+
103
+ async function init() {
104
+ try {
105
+ const connection = await connectWithAuth();
106
+ if (cancelled) return;
107
+ connRef.current = connection;
108
+ setConnected(true);
109
+ setConnecting(false);
110
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
111
+ const user = (connection as any).user;
112
+ if (user) setAuthUser(user);
113
+ connection.on("disconnect", () => setConnected(false));
114
+ connection.on("reconnected", () => setConnected(true));
115
+ } catch (err) {
116
+ console.error("Failed to connect:", err);
117
+ setConnecting(false);
118
+ }
119
+ }
120
+
121
+ init();
122
+ return () => {
123
+ cancelled = true;
124
+ connRef.current?.disconnect();
125
+ };
126
+ }, [session]);
127
+
128
+ // Drawing helpers
129
+ const drawPlayer = (
130
+ ctx: CanvasRenderingContext2D,
131
+ x: number,
132
+ y: number,
133
+ color: string,
134
+ label: string
135
+ ) => {
136
+ // Glow
137
+ ctx.beginPath();
138
+ ctx.arc(x, y, 24, 0, Math.PI * 2);
139
+ ctx.fillStyle = color + "30";
140
+ ctx.fill();
141
+
142
+ // Body
143
+ ctx.beginPath();
144
+ ctx.arc(x, y, 18, 0, Math.PI * 2);
145
+ ctx.fillStyle = color + "50";
146
+ ctx.strokeStyle = color;
147
+ ctx.lineWidth = 2;
148
+ ctx.fill();
149
+ ctx.stroke();
150
+
151
+ ctx.shadowColor = color;
152
+ ctx.shadowBlur = 15;
153
+ ctx.stroke();
154
+ ctx.shadowBlur = 0;
155
+
156
+ // Label
157
+ ctx.fillStyle = "white";
158
+ ctx.font = "bold 11px 'Inter', sans-serif";
159
+ ctx.textAlign = "center";
160
+ ctx.textBaseline = "middle";
161
+ ctx.fillText(label, x, y);
162
+ ctx.textAlign = "start";
163
+ };
164
+
165
+ const drawArena = useCallback(
166
+ (
167
+ ctx: CanvasRenderingContext2D,
168
+ canvas: HTMLCanvasElement,
169
+ p1: PlayerState,
170
+ p2: PlayerState
171
+ ) => {
172
+ const w = canvas.width;
173
+ const h = canvas.height;
174
+
175
+ ctx.fillStyle = "#0a0a14";
176
+ ctx.fillRect(0, 0, w, h);
177
+
178
+ // Grid
179
+ ctx.strokeStyle = "rgba(255,255,255,0.04)";
180
+ ctx.lineWidth = 1;
181
+ for (let x = 0; x < w; x += 40) {
182
+ ctx.beginPath();
183
+ ctx.moveTo(x, 0);
184
+ ctx.lineTo(x, h);
185
+ ctx.stroke();
186
+ }
187
+ for (let y = 0; y < h; y += 40) {
188
+ ctx.beginPath();
189
+ ctx.moveTo(0, y);
190
+ ctx.lineTo(w, y);
191
+ ctx.stroke();
192
+ }
193
+
194
+ // Collectibles
195
+ collectiblesRef.current.forEach((c) => {
196
+ if (c.collected) return;
197
+ ctx.beginPath();
198
+ ctx.arc(c.x * w, c.y * h, 8, 0, Math.PI * 2);
199
+ ctx.fillStyle = c.color;
200
+ ctx.fill();
201
+ ctx.shadowColor = c.color;
202
+ ctx.shadowBlur = 12;
203
+ ctx.fill();
204
+ ctx.shadowBlur = 0;
205
+ });
206
+
207
+ // Players
208
+ drawPlayer(ctx, p1.x * w, p1.y * h, p1.color, p1.label);
209
+ drawPlayer(ctx, p2.x * w, p2.y * h, p2.color, p2.label);
210
+
211
+ // Scores
212
+ ctx.fillStyle = "rgba(255,255,255,0.5)";
213
+ ctx.font = "12px monospace";
214
+ ctx.fillText(
215
+ `${p1.label}: ${p1.score} ${p2.label}: ${p2.score}`,
216
+ 10,
217
+ 20
218
+ );
219
+
220
+ // Synced badge
221
+ ctx.fillStyle = "rgba(6,182,212,0.5)";
222
+ ctx.fillText("🔗 Synced via Pulse", w - 150, 20);
223
+ },
224
+ []
225
+ );
226
+
227
+ // Join a game room
228
+ const joinGame = useCallback(
229
+ (room: string) => {
230
+ if (!connRef.current || !session) return;
231
+
232
+ const stream = connRef.current.stream(`arena:${room}`);
233
+ const playerName = session.user.name || "Anonymous";
234
+
235
+ // Announce join
236
+ stream.send(
237
+ JSON.stringify({
238
+ type: "join",
239
+ name: playerName,
240
+ userId: session.user.id,
241
+ })
242
+ );
243
+
244
+ // Listen for game messages
245
+ stream.on("data", (data: Uint8Array) => {
246
+ try {
247
+ const msg = JSON.parse(new TextDecoder().decode(data));
248
+
249
+ if (msg.type === "join" && msg.userId !== session.user.id) {
250
+ setOpponent(msg.name);
251
+ setPlayerCount(2);
252
+ // Assign remote label
253
+ remotePlayerRef.current.label = msg.name.charAt(0).toUpperCase();
254
+ } else if (msg.type === "state" && msg.userId !== session.user.id) {
255
+ // Remote player position update
256
+ remotePlayerRef.current.x +=
257
+ (msg.x - remotePlayerRef.current.x) * 0.7;
258
+ remotePlayerRef.current.y +=
259
+ (msg.y - remotePlayerRef.current.y) * 0.7;
260
+ remotePlayerRef.current.score = msg.score || 0;
261
+ if (msg.ts) {
262
+ const delay = Date.now() - msg.ts;
263
+ statsRef.current.lastDelay = delay;
264
+ }
265
+ } else if (msg.type === "collect" && msg.userId !== session.user.id) {
266
+ // Remote player collected a collectible
267
+ const c = collectiblesRef.current.find(
268
+ (c) => c.id === msg.collectibleId
269
+ );
270
+ if (c) c.collected = true;
271
+ } else if (msg.type === "respawn") {
272
+ // Respawn collectibles
273
+ collectiblesRef.current.forEach((c, i) => {
274
+ c.x = msg.positions[i].x;
275
+ c.y = msg.positions[i].y;
276
+ c.collected = false;
277
+ });
278
+ }
279
+ } catch {
280
+ /* ignore */
281
+ }
282
+ });
283
+
284
+ // Set local player label
285
+ localPlayerRef.current.label = playerName.charAt(0).toUpperCase();
286
+ localPlayerRef.current.score = 0;
287
+ remotePlayerRef.current.score = 0;
288
+
289
+ // Game loop
290
+ const SPEED = 0.006;
291
+ let frameCount = 0;
292
+
293
+ const tick = () => {
294
+ if (!canvasRef.current) return;
295
+ const ctx = canvasRef.current.getContext("2d");
296
+ if (!ctx) return;
297
+
298
+ const keys = keysRef.current;
299
+ const p = localPlayerRef.current;
300
+
301
+ if (keys["w"] || keys["arrowup"]) p.y = Math.max(0.05, p.y - SPEED);
302
+ if (keys["s"] || keys["arrowdown"]) p.y = Math.min(0.95, p.y + SPEED);
303
+ if (keys["a"] || keys["arrowleft"]) p.x = Math.max(0.05, p.x - SPEED);
304
+ if (keys["d"] || keys["arrowright"])
305
+ p.x = Math.min(0.95, p.x + SPEED);
306
+
307
+ // Check collectible collisions
308
+ collectiblesRef.current.forEach((c) => {
309
+ if (c.collected) return;
310
+ const dist = Math.hypot(p.x - c.x, p.y - c.y);
311
+ if (dist < 0.04) {
312
+ c.collected = true;
313
+ p.score++;
314
+ setScores((prev) => ({
315
+ ...prev,
316
+ p1: p.score,
317
+ }));
318
+ stream.send(
319
+ JSON.stringify({
320
+ type: "collect",
321
+ userId: session.user.id,
322
+ collectibleId: c.id,
323
+ })
324
+ );
325
+ }
326
+ });
327
+
328
+ // Respawn collectibles
329
+ if (collectiblesRef.current.every((c) => c.collected)) {
330
+ const newPositions = collectiblesRef.current.map(() => ({
331
+ x: 0.1 + Math.random() * 0.8,
332
+ y: 0.1 + Math.random() * 0.8,
333
+ }));
334
+ collectiblesRef.current.forEach((c, i) => {
335
+ c.x = newPositions[i].x;
336
+ c.y = newPositions[i].y;
337
+ c.collected = false;
338
+ });
339
+ stream.send(
340
+ JSON.stringify({ type: "respawn", positions: newPositions })
341
+ );
342
+ }
343
+
344
+ // Draw
345
+ drawArena(ctx, canvasRef.current, p, remotePlayerRef.current);
346
+
347
+ // Send state at ~30fps
348
+ frameCount++;
349
+ if (frameCount % 2 === 0) {
350
+ statsRef.current.updatesThisSecond++;
351
+ stream.send(
352
+ JSON.stringify({
353
+ type: "state",
354
+ userId: session.user.id,
355
+ x: p.x,
356
+ y: p.y,
357
+ score: p.score,
358
+ ts: Date.now(),
359
+ })
360
+ );
361
+ }
362
+
363
+ animationIdRef.current = requestAnimationFrame(tick);
364
+ };
365
+
366
+ animationIdRef.current = requestAnimationFrame(tick);
367
+
368
+ // Stats interval
369
+ const statsInterval = setInterval(() => {
370
+ setSyncMetrics({
371
+ ups: statsRef.current.updatesThisSecond,
372
+ delay:
373
+ statsRef.current.lastDelay > 0
374
+ ? `${statsRef.current.lastDelay}ms`
375
+ : "—",
376
+ });
377
+ setScores({
378
+ p1: localPlayerRef.current.score,
379
+ p2: remotePlayerRef.current.score,
380
+ });
381
+ statsRef.current.updatesThisSecond = 0;
382
+ }, 1000);
383
+
384
+ setInGame(true);
385
+ setRoomId(room);
386
+
387
+ // Return cleanup
388
+ return () => {
389
+ cancelAnimationFrame(animationIdRef.current);
390
+ clearInterval(statsInterval);
391
+ };
392
+ },
393
+ [session, drawArena]
394
+ );
395
+
396
+ // Create room
397
+ function createRoom() {
398
+ const id = crypto.randomUUID().slice(0, 8);
399
+ setRoomId(id);
400
+ joinGame(id);
401
+ }
402
+
403
+ // Join existing room
404
+ function handleJoinRoom(e: React.FormEvent) {
405
+ e.preventDefault();
406
+ const cleaned = inputRoomId.trim();
407
+ if (!cleaned) return;
408
+
409
+ if (!/^[a-zA-Z0-9-]{6,12}$/.test(cleaned)) {
410
+ setError("Invalid room format. Must be 6-12 characters (letters, numbers, hyphens).");
411
+ return;
412
+ }
413
+
414
+ setError("");
415
+ joinGame(cleaned);
416
+ }
417
+
418
+ // Copy room ID
419
+ function copyRoomId() {
420
+ navigator.clipboard.writeText(roomId);
421
+ setCopied(true);
422
+ setTimeout(() => setCopied(false), 2000);
423
+ }
424
+
425
+ // Cleanup
426
+ useEffect(() => {
427
+ return () => {
428
+ cancelAnimationFrame(animationIdRef.current);
429
+ };
430
+ }, []);
431
+
432
+ return (
433
+ <div className="p-8">
434
+ {/* Header */}
435
+ <div className="flex items-center justify-between mb-8">
436
+ <div className="flex items-center gap-3">
437
+ <div className="w-12 h-12 rounded-xl bg-gradient-to-br from-orange-500 to-red-600 flex items-center justify-center">
438
+ <Swords className="w-6 h-6 text-white" />
439
+ </div>
440
+ <div>
441
+ <h1 className="text-2xl font-bold">Arena Game</h1>
442
+ <p className="text-sm text-slate-500">
443
+ Real-time multiplayer collectible arena via Pulse
444
+ </p>
445
+ </div>
446
+ </div>
447
+ <div className="flex items-center gap-3">
448
+ {authUser && (
449
+ <div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-green-500/10 border border-green-500/20">
450
+ <Shield className="w-3.5 h-3.5 text-green-400" />
451
+ <span className="text-xs text-green-400">
452
+ {authUser.claims.name || authUser.id}
453
+ </span>
454
+ </div>
455
+ )}
456
+ {connected ? (
457
+ <div className="flex items-center gap-1.5">
458
+ <Wifi className="w-4 h-4 text-green-400" />
459
+ <span className="text-xs text-green-400">Connected</span>
460
+ </div>
461
+ ) : (
462
+ <div className="flex items-center gap-1.5">
463
+ <WifiOff className="w-4 h-4 text-red-400" />
464
+ <span className="text-xs text-red-400">Disconnected</span>
465
+ </div>
466
+ )}
467
+ </div>
468
+ </div>
469
+
470
+ {connecting ? (
471
+ <div className="flex items-center justify-center py-20">
472
+ <Loader2 className="w-6 h-6 animate-spin text-purple-500 mr-2" />
473
+ <span className="text-slate-400">Connecting...</span>
474
+ </div>
475
+ ) : !inGame ? (
476
+ /* Room selection */
477
+ <div className="max-w-xl mx-auto space-y-6">
478
+ <div className="glass rounded-2xl p-8 text-center">
479
+ <Gamepad2 className="w-16 h-16 text-orange-400 mx-auto mb-4" />
480
+ <h2 className="text-xl font-bold mb-2">Join or Create a Game</h2>
481
+ <p className="text-sm text-slate-400 mb-8">
482
+ Create a room and share the code with a friend, or join
483
+ an existing room. Move your player with WASD or Arrow keys
484
+ and collect golden orbs to score!
485
+ </p>
486
+
487
+ <button
488
+ onClick={createRoom}
489
+ disabled={!connected}
490
+ className="w-full px-6 py-3 bg-gradient-to-r from-orange-500 to-red-500 hover:from-orange-400 hover:to-red-400 rounded-xl font-semibold text-lg transition-all disabled:opacity-50 mb-6"
491
+ >
492
+ 🎮 Create New Room
493
+ </button>
494
+
495
+ <div className="relative mb-6">
496
+ <div className="absolute inset-0 flex items-center">
497
+ <div className="w-full border-t border-slate-700" />
498
+ </div>
499
+ <div className="relative flex justify-center text-xs uppercase">
500
+ <span className="bg-slate-900 px-3 text-slate-500">
501
+ or join existing
502
+ </span>
503
+ </div>
504
+ </div>
505
+
506
+ {error && (
507
+ <div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 flex items-center gap-2 text-red-400 text-sm text-left">
508
+ <AlertCircle className="w-4 h-4 shrink-0" />
509
+ <p>{error}</p>
510
+ </div>
511
+ )}
512
+
513
+ <form onSubmit={handleJoinRoom} className="flex gap-3">
514
+ <input
515
+ type="text"
516
+ value={inputRoomId}
517
+ onChange={(e) => { setInputRoomId(e.target.value); setError(""); }}
518
+ placeholder="Enter room code..."
519
+ className="flex-1 px-4 py-2.5 rounded-xl bg-slate-800/50 border border-slate-700 focus:border-orange-500 focus:ring-1 focus:ring-orange-500 outline-none text-sm transition-colors placeholder:text-slate-600"
520
+ />
521
+ <button
522
+ type="submit"
523
+ disabled={!connected || !inputRoomId.trim()}
524
+ className="px-6 py-2.5 bg-orange-600 hover:bg-orange-500 rounded-xl font-medium transition-all disabled:opacity-50"
525
+ >
526
+ Join
527
+ </button>
528
+ </form>
529
+ </div>
530
+
531
+ {/* How it works */}
532
+ <div className="glass rounded-xl p-5 border-l-4 border-orange-500">
533
+ <h3 className="text-sm font-semibold text-orange-400 mb-1">
534
+ 💡 How It Works
535
+ </h3>
536
+ <p className="text-sm text-slate-400">
537
+ Player positions are synced ~30 times per second via Pulse
538
+ streams. Collectible spawns and collections are broadcast to
539
+ all players. The relay ensures consistent game state across
540
+ all connected players with sub-50ms latency.
541
+ </p>
542
+ </div>
543
+ </div>
544
+ ) : (
545
+ /* In Game */
546
+ <div className="space-y-4">
547
+ {/* Room info bar */}
548
+ <div className="flex items-center justify-between glass rounded-xl px-5 py-3">
549
+ <div className="flex items-center gap-4">
550
+ <div className="flex items-center gap-2">
551
+ <Swords className="w-4 h-4 text-orange-400" />
552
+ <span className="text-sm font-medium">Room:</span>
553
+ <code className="text-xs bg-slate-800 px-2 py-1 rounded font-mono text-orange-300">
554
+ {roomId}
555
+ </code>
556
+ <button
557
+ onClick={copyRoomId}
558
+ className="p-1.5 rounded-lg hover:bg-slate-700 transition-colors"
559
+ title="Copy room code"
560
+ >
561
+ {copied ? (
562
+ <Check className="w-3.5 h-3.5 text-green-400" />
563
+ ) : (
564
+ <Copy className="w-3.5 h-3.5 text-slate-400" />
565
+ )}
566
+ </button>
567
+ </div>
568
+ <div className="flex items-center gap-2 text-sm text-slate-400">
569
+ <Users className="w-4 h-4" />
570
+ {playerCount} player(s)
571
+ {opponent && (
572
+ <span className="text-green-400 ml-1">
573
+ vs {opponent}
574
+ </span>
575
+ )}
576
+ </div>
577
+ </div>
578
+ <div className="text-xs text-slate-500">
579
+ Use <kbd className="px-1.5 py-0.5 rounded bg-slate-700 text-slate-300 font-mono">WASD</kbd>{" "}
580
+ or <kbd className="px-1.5 py-0.5 rounded bg-slate-700 text-slate-300 font-mono">Arrow keys</kbd>{" "}
581
+ to move
582
+ </div>
583
+ </div>
584
+
585
+ {/* Game canvas */}
586
+ <div className="flex gap-4">
587
+ <div className="flex-1">
588
+ <canvas
589
+ ref={canvasRef}
590
+ width={900}
591
+ height={500}
592
+ className="w-full bg-black/40 border border-slate-800 rounded-2xl shadow-lg ring-1 ring-white/5"
593
+ />
594
+ </div>
595
+ </div>
596
+
597
+ {/* Metrics */}
598
+ <div className="grid grid-cols-4 gap-4">
599
+ <div className="glass rounded-xl p-4 text-center">
600
+ <Trophy className="w-5 h-5 text-purple-400 mx-auto mb-1" />
601
+ <div className="text-2xl font-bold font-mono text-purple-400">
602
+ {scores.p1}
603
+ </div>
604
+ <div className="text-[10px] text-slate-500 uppercase tracking-widest mt-1">
605
+ Your Score
606
+ </div>
607
+ </div>
608
+ <div className="glass rounded-xl p-4 text-center">
609
+ <Trophy className="w-5 h-5 text-emerald-400 mx-auto mb-1" />
610
+ <div className="text-2xl font-bold font-mono text-emerald-400">
611
+ {scores.p2}
612
+ </div>
613
+ <div className="text-[10px] text-slate-500 uppercase tracking-widest mt-1">
614
+ Opponent
615
+ </div>
616
+ </div>
617
+ <div className="glass rounded-xl p-4 text-center">
618
+ <Zap className="w-5 h-5 text-cyan-400 mx-auto mb-1" />
619
+ <div className="text-2xl font-bold font-mono text-cyan-400">
620
+ {syncMetrics.ups}
621
+ </div>
622
+ <div className="text-[10px] text-slate-500 uppercase tracking-widest mt-1">
623
+ Updates/sec
624
+ </div>
625
+ </div>
626
+ <div className="glass rounded-xl p-4 text-center">
627
+ <Activity className="w-5 h-5 text-amber-400 mx-auto mb-1" />
628
+ <div className="text-2xl font-bold font-mono text-amber-400">
629
+ {syncMetrics.delay}
630
+ </div>
631
+ <div className="text-[10px] text-slate-500 uppercase tracking-widest mt-1">
632
+ Latency
633
+ </div>
634
+ </div>
635
+ </div>
636
+ </div>
637
+ )}
638
+ </div>
639
+ );
640
+ }