@sansavision/create-pulse 0.4.3 → 0.4.4

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