@sansavision/create-pulse 0.4.3 → 0.4.5

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 (102) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +1 -1
  3. package/package.json +3 -3
  4. package/templates/aurora-auth-node-demo/README.md +43 -0
  5. package/templates/aurora-auth-node-demo/aurora.config.ts +15 -0
  6. package/templates/aurora-auth-node-demo/bun.lock +679 -0
  7. package/templates/aurora-auth-node-demo/drizzle.config.ts +9 -0
  8. package/templates/aurora-auth-node-demo/package.json +39 -0
  9. package/templates/aurora-auth-node-demo/postcss.config.mjs +7 -0
  10. package/templates/aurora-auth-node-demo/server.mjs +46 -0
  11. package/templates/aurora-auth-node-demo/src/actions/createMessage.action.server.ts +31 -0
  12. package/templates/aurora-auth-node-demo/src/aurora.auth.ts +65 -0
  13. package/templates/aurora-auth-node-demo/src/lib/auth-client.ts +30 -0
  14. package/templates/aurora-auth-node-demo/src/lib/auth.server.ts +11 -0
  15. package/templates/aurora-auth-node-demo/src/lib/auth.ts +30 -0
  16. package/templates/aurora-auth-node-demo/src/lib/db.ts +6 -0
  17. package/templates/aurora-auth-node-demo/src/lib/pulse.ts +45 -0
  18. package/templates/aurora-auth-node-demo/src/lib/schema.ts +107 -0
  19. package/templates/aurora-auth-node-demo/src/queries/listMessages.server.ts +25 -0
  20. package/templates/aurora-auth-node-demo/src/routes/api/auth/[...slug]/handler.ts +14 -0
  21. package/templates/aurora-auth-node-demo/src/routes/api/pulse/verify/handler.ts +55 -0
  22. package/templates/aurora-auth-node-demo/src/routes/auth/sign-in/page.client.tsx +132 -0
  23. package/templates/aurora-auth-node-demo/src/routes/auth/sign-in/page.tsx +5 -0
  24. package/templates/aurora-auth-node-demo/src/routes/auth/sign-up/page.client.tsx +154 -0
  25. package/templates/aurora-auth-node-demo/src/routes/auth/sign-up/page.tsx +5 -0
  26. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/arena-game/page.client.tsx +640 -0
  27. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/arena-game/page.tsx +5 -0
  28. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/chat/page.client.tsx +349 -0
  29. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/chat/page.tsx +5 -0
  30. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/encrypted-chat/page.client.tsx +472 -0
  31. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/encrypted-chat/page.tsx +5 -0
  32. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/game-sync/page.client.tsx +375 -0
  33. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/game-sync/page.tsx +5 -0
  34. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/queues/page.client.tsx +423 -0
  35. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/queues/page.tsx +5 -0
  36. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/video-call/page.client.tsx +840 -0
  37. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/video-call/page.tsx +5 -0
  38. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/watch-together/page.client.tsx +722 -0
  39. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/watch-together/page.tsx +5 -0
  40. package/templates/aurora-auth-node-demo/src/routes/dashboard/layout.client.tsx +113 -0
  41. package/templates/aurora-auth-node-demo/src/routes/dashboard/layout.tsx +5 -0
  42. package/templates/aurora-auth-node-demo/src/routes/dashboard/page.client.tsx +195 -0
  43. package/templates/aurora-auth-node-demo/src/routes/dashboard/page.tsx +5 -0
  44. package/templates/aurora-auth-node-demo/src/routes/favicon.ico +0 -0
  45. package/templates/aurora-auth-node-demo/src/routes/layout.tsx +18 -0
  46. package/templates/aurora-auth-node-demo/src/routes/page.client.tsx +263 -0
  47. package/templates/aurora-auth-node-demo/src/routes/page.tsx +5 -0
  48. package/templates/aurora-auth-node-demo/src/styles/app.css +96 -0
  49. package/templates/aurora-auth-node-demo/tsconfig.json +27 -0
  50. package/templates/aurora-auth-node-demo/tsconfig.tsbuildinfo +1 -0
  51. package/templates/nextjs-auth-demo/README.md +1 -1
  52. package/templates/nextjs-auth-demo/next-env.d.ts +1 -1
  53. package/templates/nextjs-auth-demo/package.json +8 -7
  54. package/templates/nextjs-auth-demo/src/app/dashboard/demos/arena-game/page.tsx +640 -0
  55. package/templates/nextjs-auth-demo/src/app/dashboard/demos/chat/page.tsx +124 -23
  56. package/templates/nextjs-auth-demo/src/app/dashboard/demos/encrypted-chat/page.tsx +350 -76
  57. package/templates/nextjs-auth-demo/src/app/dashboard/demos/game-sync/page.tsx +232 -49
  58. package/templates/nextjs-auth-demo/src/app/dashboard/demos/queues/page.tsx +213 -87
  59. package/templates/nextjs-auth-demo/src/app/dashboard/demos/video-call/page.tsx +840 -0
  60. package/templates/nextjs-auth-demo/src/app/dashboard/demos/watch-together/page.tsx +589 -123
  61. package/templates/nextjs-auth-demo/src/app/dashboard/layout.tsx +4 -0
  62. package/templates/nextjs-auth-demo/src/app/dashboard/page.tsx +53 -5
  63. package/templates/nextjs-auth-demo/src/app/layout.tsx +1 -1
  64. package/templates/nextjs-auth-node-demo/.env.example +10 -0
  65. package/templates/nextjs-auth-node-demo/Dockerfile +19 -0
  66. package/templates/nextjs-auth-node-demo/README.md +159 -0
  67. package/templates/nextjs-auth-node-demo/_gitignore +33 -0
  68. package/templates/nextjs-auth-node-demo/drizzle.config.ts +10 -0
  69. package/templates/nextjs-auth-node-demo/eslint.config.mjs +18 -0
  70. package/templates/nextjs-auth-node-demo/next-env.d.ts +6 -0
  71. package/templates/nextjs-auth-node-demo/next.config.ts +7 -0
  72. package/templates/nextjs-auth-node-demo/package.json +38 -0
  73. package/templates/nextjs-auth-node-demo/postcss.config.mjs +7 -0
  74. package/templates/nextjs-auth-node-demo/public/file.svg +1 -0
  75. package/templates/nextjs-auth-node-demo/public/globe.svg +1 -0
  76. package/templates/nextjs-auth-node-demo/public/next.svg +1 -0
  77. package/templates/nextjs-auth-node-demo/public/vercel.svg +1 -0
  78. package/templates/nextjs-auth-node-demo/public/window.svg +1 -0
  79. package/templates/nextjs-auth-node-demo/server.mjs +45 -0
  80. package/templates/nextjs-auth-node-demo/src/app/api/auth/[...all]/route.ts +4 -0
  81. package/templates/nextjs-auth-node-demo/src/app/api/pulse/verify/route.ts +54 -0
  82. package/templates/nextjs-auth-node-demo/src/app/auth/sign-in/page.tsx +131 -0
  83. package/templates/nextjs-auth-node-demo/src/app/auth/sign-up/page.tsx +153 -0
  84. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/arena-game/page.tsx +640 -0
  85. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/chat/page.tsx +349 -0
  86. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/encrypted-chat/page.tsx +472 -0
  87. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/game-sync/page.tsx +375 -0
  88. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/queues/page.tsx +423 -0
  89. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/video-call/page.tsx +840 -0
  90. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/watch-together/page.tsx +724 -0
  91. package/templates/nextjs-auth-node-demo/src/app/dashboard/layout.tsx +113 -0
  92. package/templates/nextjs-auth-node-demo/src/app/dashboard/page.tsx +195 -0
  93. package/templates/nextjs-auth-node-demo/src/app/favicon.ico +0 -0
  94. package/templates/nextjs-auth-node-demo/src/app/globals.css +96 -0
  95. package/templates/nextjs-auth-node-demo/src/app/layout.tsx +27 -0
  96. package/templates/nextjs-auth-node-demo/src/app/page.tsx +254 -0
  97. package/templates/nextjs-auth-node-demo/src/lib/auth-client.ts +15 -0
  98. package/templates/nextjs-auth-node-demo/src/lib/auth.ts +14 -0
  99. package/templates/nextjs-auth-node-demo/src/lib/db.ts +6 -0
  100. package/templates/nextjs-auth-node-demo/src/lib/pulse.ts +45 -0
  101. package/templates/nextjs-auth-node-demo/src/lib/schema.ts +107 -0
  102. package/templates/nextjs-auth-node-demo/tsconfig.json +34 -0
@@ -3,7 +3,7 @@
3
3
  import { useState, useEffect, useRef, useCallback } from "react";
4
4
  import { useSession } from "@/lib/auth-client";
5
5
  import { connectWithAuth } from "@/lib/pulse";
6
- import { Gamepad2, Shield, Wifi, WifiOff, Loader2, RotateCcw } from "lucide-react";
6
+ import { Gamepad2, Shield, Wifi, WifiOff, Loader2, RotateCcw, Copy, Users, LogIn, AlertCircle } from "lucide-react";
7
7
  import type { PulseConnection } from "@sansavision/pulse-sdk";
8
8
 
9
9
  type Cell = "X" | "O" | null;
@@ -15,10 +15,23 @@ export default function GameSyncPage() {
15
15
  const [connecting, setConnecting] = useState(true);
16
16
  const [board, setBoard] = useState<Cell[]>(Array(9).fill(null));
17
17
  const [currentTurn, setCurrentTurn] = useState<"X" | "O">("X");
18
- const [mySymbol, setMySymbol] = useState<"X" | "O">("X");
18
+ const [mySymbol, setMySymbol] = useState<"X" | "O" | null>(null);
19
19
  const [winner, setWinner] = useState<string | null>(null);
20
20
  const [players, setPlayers] = useState<string[]>([]);
21
21
  const connRef = useRef<PulseConnection | null>(null);
22
+ // Room management
23
+ const [roomCode, setRoomCode] = useState<string>("");
24
+ const [joinCode, setJoinCode] = useState("");
25
+ const [error, setError] = useState("");
26
+ const [inRoom, setInRoom] = useState(false);
27
+ const [copied, setCopied] = useState(false);
28
+ const streamRef = useRef<ReturnType<PulseConnection["stream"]> | null>(null);
29
+ const mySymbolRef = useRef<"X" | "O" | null>(null);
30
+ const sessionRef = useRef(session);
31
+
32
+ // Keep refs in sync with state
33
+ useEffect(() => { sessionRef.current = session; }, [session]);
34
+ useEffect(() => { mySymbolRef.current = mySymbol; }, [mySymbol]);
22
35
 
23
36
  const checkWinner = useCallback((b: Cell[]): Cell => {
24
37
  const lines = [
@@ -32,6 +45,7 @@ export default function GameSyncPage() {
32
45
  return null;
33
46
  }, []);
34
47
 
48
+ // Connect to relay on mount
35
49
  useEffect(() => {
36
50
  if (!session) return;
37
51
  let cancelled = false;
@@ -49,46 +63,8 @@ export default function GameSyncPage() {
49
63
  const user = (connection as any).user;
50
64
  if (user) setAuthUser(user);
51
65
 
52
- const stream = connection.stream("game-room");
53
-
54
- stream.send(JSON.stringify({
55
- type: "join",
56
- name: session!.user.name,
57
- userId: session!.user.id,
58
- }));
59
-
60
- stream.on("data", (data: Uint8Array) => {
61
- try {
62
- const msg = JSON.parse(new TextDecoder().decode(data));
63
- if (msg.type === "move") {
64
- setBoard((prev) => {
65
- const next = [...prev];
66
- next[msg.cell] = msg.symbol;
67
- const w = checkWinner(next);
68
- if (w) setWinner(`${msg.name} (${w}) wins!`);
69
- else if (next.every((c) => c)) setWinner("Draw!");
70
- return next;
71
- });
72
- setCurrentTurn(msg.symbol === "X" ? "O" : "X");
73
- } else if (msg.type === "join") {
74
- setPlayers((prev) =>
75
- prev.includes(msg.name) ? prev : [...prev, msg.name]
76
- );
77
- if (msg.userId !== session?.user?.id) {
78
- setMySymbol("X");
79
- }
80
- } else if (msg.type === "reset") {
81
- setBoard(Array(9).fill(null));
82
- setCurrentTurn("X");
83
- setWinner(null);
84
- }
85
- } catch { /* ignore */ }
86
- });
87
-
88
66
  connection.on("disconnect", () => setConnected(false));
89
67
  connection.on("reconnected", () => setConnected(true));
90
-
91
- setMySymbol("X");
92
68
  } catch (err) {
93
69
  console.error("Failed to connect:", err);
94
70
  setConnecting(false);
@@ -100,30 +76,147 @@ export default function GameSyncPage() {
100
76
  cancelled = true;
101
77
  connRef.current?.disconnect();
102
78
  };
103
- }, [session, checkWinner]);
79
+ }, [session]);
80
+
81
+ function joinRoom(code: string) {
82
+ if (!connRef.current || !sessionRef.current) return;
83
+ const sess = sessionRef.current;
84
+
85
+ const streamName = `ttt-${code}`;
86
+ const stream = connRef.current.stream(streamName);
87
+ streamRef.current = stream;
88
+ setRoomCode(code);
89
+ setInRoom(true);
90
+
91
+ // Send join message
92
+ stream.send(JSON.stringify({
93
+ type: "join",
94
+ name: sess.user.name,
95
+ userId: sess.user.id,
96
+ }));
97
+
98
+ // Listen for game events — uses refs to avoid stale closures
99
+ stream.on("data", (data: Uint8Array) => {
100
+ try {
101
+ const msg = JSON.parse(new TextDecoder().decode(data));
102
+ const currentSession = sessionRef.current;
103
+ if (!currentSession) return;
104
+ const selfId = currentSession.user.id;
105
+ const selfName = currentSession.user.name;
106
+
107
+ if (msg.type === "move" && msg.userId !== selfId) {
108
+ setBoard((prev) => {
109
+ const next = [...prev];
110
+ next[msg.cell] = msg.symbol;
111
+ const w = checkWinner(next);
112
+ if (w) setWinner(`${msg.name} (${w}) wins!`);
113
+ else if (next.every((c) => c)) setWinner("Draw!");
114
+ return next;
115
+ });
116
+ setCurrentTurn(msg.symbol === "X" ? "O" : "X");
117
+ } else if (msg.type === "join" && msg.userId !== selfId) {
118
+ setPlayers((prev) => {
119
+ if (!prev.includes(msg.name)) {
120
+ // Announce presence back with current symbol from ref
121
+ stream.send(JSON.stringify({
122
+ type: "presence",
123
+ name: selfName,
124
+ userId: selfId,
125
+ symbol: mySymbolRef.current
126
+ }));
127
+ return [...prev, msg.name];
128
+ }
129
+ return prev;
130
+ });
131
+ } else if (msg.type === "presence" && msg.userId !== selfId) {
132
+ setPlayers((prev) => prev.includes(msg.name) ? prev : [...prev, msg.name]);
133
+ } else if (msg.type === "reset" && msg.userId !== selfId) {
134
+ setBoard(Array(9).fill(null));
135
+ setCurrentTurn("X");
136
+ setWinner(null);
137
+ }
138
+ } catch { /* ignore */ }
139
+ });
140
+
141
+ // First player is X
142
+ setMySymbol("X");
143
+ mySymbolRef.current = "X";
144
+ setPlayers([sess.user.name]);
145
+ }
146
+
147
+ function createRoom() {
148
+ const code = Math.random().toString(36).substring(2, 8).toUpperCase();
149
+ joinRoom(code);
150
+ }
151
+
152
+ function handleJoinExisting() {
153
+ const cleaned = joinCode.trim().toUpperCase();
154
+ if (cleaned) {
155
+ if (!/^[A-Z0-9]{6}$/.test(cleaned)) {
156
+ setError("Invalid room code format. Must be exactly 6 alphanumeric characters.");
157
+ return;
158
+ }
159
+ setError("");
160
+ joinRoom(cleaned);
161
+ // Second player joining gets O — set both state AND ref
162
+ setMySymbol("O");
163
+ mySymbolRef.current = "O";
164
+ }
165
+ }
166
+
167
+ function copyRoomCode() {
168
+ navigator.clipboard.writeText(roomCode);
169
+ setCopied(true);
170
+ setTimeout(() => setCopied(false), 2000);
171
+ }
104
172
 
105
173
  function handleCellClick(index: number) {
106
- if (board[index] || winner || !connRef.current) return;
174
+ if (board[index] || winner || !streamRef.current || !mySymbol) return;
107
175
  if (currentTurn !== mySymbol) return;
108
176
 
109
- const stream = connRef.current.stream("game-room");
110
- stream.send(JSON.stringify({
177
+ streamRef.current.send(JSON.stringify({
111
178
  type: "move",
112
179
  cell: index,
113
180
  symbol: mySymbol,
114
181
  name: session?.user?.name,
182
+ userId: session?.user?.id,
115
183
  }));
184
+
185
+ // Apply move locally
186
+ setBoard((prev) => {
187
+ const next = [...prev];
188
+ next[index] = mySymbol;
189
+ const w = checkWinner(next);
190
+ if (w) setWinner(`${session?.user?.name} (${mySymbol}) wins!`);
191
+ else if (next.every((c) => c)) setWinner("Draw!");
192
+ return next;
193
+ });
194
+ setCurrentTurn(mySymbol === "X" ? "O" : "X");
116
195
  }
117
196
 
118
197
  function handleReset() {
119
- if (!connRef.current) return;
120
- const stream = connRef.current.stream("game-room");
121
- stream.send(JSON.stringify({ type: "reset" }));
198
+ if (!streamRef.current) return;
199
+ streamRef.current.send(JSON.stringify({
200
+ type: "reset",
201
+ userId: session?.user?.id,
202
+ }));
122
203
  setBoard(Array(9).fill(null));
123
204
  setCurrentTurn("X");
124
205
  setWinner(null);
125
206
  }
126
207
 
208
+ function leaveRoom() {
209
+ setInRoom(false);
210
+ setRoomCode("");
211
+ setBoard(Array(9).fill(null));
212
+ setCurrentTurn("X");
213
+ setMySymbol(null);
214
+ mySymbolRef.current = null;
215
+ setWinner(null);
216
+ setPlayers([]);
217
+ streamRef.current = null;
218
+ }
219
+
127
220
  return (
128
221
  <div className="p-8">
129
222
  <div className="flex items-center justify-between mb-8">
@@ -133,7 +226,7 @@ export default function GameSyncPage() {
133
226
  </div>
134
227
  <div>
135
228
  <h1 className="text-2xl font-bold">Game State Sync</h1>
136
- <p className="text-sm text-slate-500">Real-time tic-tac-toe with state sync</p>
229
+ <p className="text-sm text-slate-500">Real-time tic-tac-toe with room-based multiplayer</p>
137
230
  </div>
138
231
  </div>
139
232
  <div className="flex items-center gap-3">
@@ -155,14 +248,96 @@ export default function GameSyncPage() {
155
248
  <div className="flex items-center justify-center py-20">
156
249
  <Loader2 className="w-6 h-6 animate-spin text-purple-500 mr-2" /><span className="text-slate-400">Connecting...</span>
157
250
  </div>
251
+ ) : !inRoom ? (
252
+ /* ── Room Lobby ── */
253
+ <div className="max-w-lg mx-auto">
254
+ <div className="glass rounded-2xl p-8 text-center mb-6">
255
+ <div className="text-5xl mb-4">🎮</div>
256
+ <h2 className="text-xl font-bold mb-2">Join or Create a Game</h2>
257
+ <p className="text-sm text-slate-400 mb-6">
258
+ Create a room and share the code with a friend, or join an existing room to play tic-tac-toe!
259
+ </p>
260
+
261
+ <button
262
+ onClick={createRoom}
263
+ disabled={!connected}
264
+ className="w-full py-3.5 bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-400 hover:to-emerald-500 rounded-xl font-semibold text-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed mb-6"
265
+ >
266
+ 🎲 Create New Room
267
+ </button>
268
+
269
+ <div className="relative mb-6">
270
+ <div className="absolute inset-0 flex items-center">
271
+ <div className="w-full border-t border-slate-700" />
272
+ </div>
273
+ <div className="relative flex justify-center text-xs">
274
+ <span className="px-3 bg-slate-800 text-slate-500 uppercase tracking-wider">Or Join Existing</span>
275
+ </div>
276
+ </div>
277
+
278
+ {error && (
279
+ <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">
280
+ <AlertCircle className="w-4 h-4 shrink-0" />
281
+ <p>{error}</p>
282
+ </div>
283
+ )}
284
+
285
+ <div className="flex gap-2">
286
+ <input
287
+ type="text"
288
+ value={joinCode}
289
+ onChange={(e) => { setJoinCode(e.target.value.toUpperCase()); setError(""); }}
290
+ placeholder="Enter room code..."
291
+ maxLength={6}
292
+ className="flex-1 px-4 py-3 rounded-xl bg-slate-800/50 border border-slate-700 focus:border-green-500 focus:ring-1 focus:ring-green-500 outline-none text-center text-lg font-mono tracking-wider placeholder:text-slate-600 transition-colors"
293
+ />
294
+ <button
295
+ onClick={handleJoinExisting}
296
+ disabled={!connected || !joinCode.trim()}
297
+ className="px-6 py-3 bg-emerald-600 hover:bg-emerald-500 rounded-xl font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
298
+ >
299
+ <LogIn className="w-4 h-4" />Join
300
+ </button>
301
+ </div>
302
+ </div>
303
+
304
+ <div className="glass rounded-xl p-4 text-center">
305
+ <p className="text-xs text-slate-400">
306
+ 💡 <span className="text-green-400 font-medium">How It Works</span> — Game state is synced in real-time via Pulse streams.
307
+ Each room gets its own stream, so multiple games can run simultaneously.
308
+ </p>
309
+ </div>
310
+ </div>
158
311
  ) : (
312
+ /* ── Game Board ── */
159
313
  <div className="max-w-lg mx-auto">
314
+ {/* Room header */}
315
+ <div className="glass rounded-xl p-4 mb-4 flex items-center justify-between">
316
+ <div className="flex items-center gap-3">
317
+ <button onClick={copyRoomCode}
318
+ className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-green-500/10 border border-green-500/20 hover:bg-green-500/20 transition-colors">
319
+ <span className="text-xs text-slate-400">Room:</span>
320
+ <span className="text-sm font-mono font-bold text-green-400">{roomCode}</span>
321
+ <Copy className="w-3 h-3 text-green-400" />
322
+ </button>
323
+ {copied && <span className="text-xs text-green-400 animate-pulse">Copied!</span>}
324
+ </div>
325
+ <div className="flex items-center gap-3">
326
+ <div className="flex items-center gap-1.5">
327
+ <Users className="w-3.5 h-3.5 text-slate-400" />
328
+ <span className="text-xs text-slate-400">{players.length} player(s)</span>
329
+ </div>
330
+ <button onClick={leaveRoom}
331
+ className="text-xs text-red-400 hover:text-red-300 transition-colors">Leave</button>
332
+ </div>
333
+ </div>
334
+
335
+ {/* Game info */}
160
336
  <div className="glass rounded-xl p-4 mb-6 flex items-center justify-between">
161
337
  <div className="text-sm">You are: <span className={`font-bold text-lg ${mySymbol === "X" ? "text-cyan-400" : "text-pink-400"}`}>{mySymbol}</span></div>
162
338
  <div className="text-sm text-slate-400">
163
339
  {winner || (<>Turn: <span className={`font-bold ${currentTurn === "X" ? "text-cyan-400" : "text-pink-400"}`}>{currentTurn}</span>{currentTurn === mySymbol && " (your turn)"}</>)}
164
340
  </div>
165
- <div className="text-sm text-slate-400">{Math.max(players.length, 1)} player(s)</div>
166
341
  </div>
167
342
 
168
343
  <div className="grid grid-cols-3 gap-3 mb-6">
@@ -185,6 +360,14 @@ export default function GameSyncPage() {
185
360
  </button>
186
361
  </div>
187
362
  )}
363
+
364
+ {players.length < 2 && !winner && (
365
+ <div className="text-center glass rounded-xl p-4 mt-4">
366
+ <Loader2 className="w-5 h-5 animate-spin text-green-400 mx-auto mb-2" />
367
+ <p className="text-sm text-slate-400">Waiting for opponent to join...</p>
368
+ <p className="text-xs text-slate-500 mt-1">Share code <span className="font-mono font-bold text-green-400">{roomCode}</span> with a friend</p>
369
+ </div>
370
+ )}
188
371
  </div>
189
372
  )}
190
373
  </div>