From 1ab21883fb9f99d6cae5e54e4366d75dac84cb4d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 22 Feb 2026 00:11:20 -0800 Subject: [PATCH] View counter --- frontend.css | 35 +++++++++++++++++++++++++++++++++++ frontend.tsx | 8 +++++++- server.ts | 15 ++++++++++++--- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/frontend.css b/frontend.css index 385bb93..0c61c0b 100644 --- a/frontend.css +++ b/frontend.css @@ -55,6 +55,10 @@ body { .header { flex-shrink: 0; margin-bottom: 28px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; } .logo { @@ -67,6 +71,37 @@ body { width: auto; } +.viewer-pill { + display: inline-flex; + align-items: center; + gap: 8px; + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.4px; + text-transform: uppercase; + color: var(--text-dim); + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.02); + border-radius: 999px; + padding: 6px 10px; + white-space: nowrap; +} + +.viewer-pill__dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #22c55e; + box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.45); + animation: viewer-pulse 1.8s ease-out infinite; +} + +@keyframes viewer-pulse { + 0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.45); } + 70% { box-shadow: 0 0 0 7px rgba(34, 197, 94, 0); } + 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); } +} + /* ── Arena ────────────────────────────────────────────────────── */ .arena { diff --git a/frontend.tsx b/frontend.tsx index f0a7edb..9719130 100644 --- a/frontend.tsx +++ b/frontend.tsx @@ -20,7 +20,7 @@ type RoundState = { scoreB?: number; }; type GameState = { completed: RoundState[]; active: RoundState | null; scores: Record; done: boolean }; -type ServerMessage = { type: "state"; data: GameState; totalRounds: number }; +type ServerMessage = { type: "state"; data: GameState; totalRounds: number; viewerCount: number }; // ── Model colors & logos ───────────────────────────────────────────────────── @@ -298,6 +298,7 @@ function ConnectingScreen() { function App() { const [state, setState] = useState(null); const [totalRounds, setTotalRounds] = useState(null); + const [viewerCount, setViewerCount] = useState(0); const [connected, setConnected] = useState(false); useEffect(() => { @@ -318,6 +319,7 @@ function App() { if (msg.type === "state") { setState(msg.data); setTotalRounds(msg.totalRounds); + setViewerCount(msg.viewerCount); } }; } @@ -343,6 +345,10 @@ function App() { Qwipslop +
+ + {viewerCount} viewer{viewerCount === 1 ? "" : "s"} watching +
{state.done ? ( diff --git a/server.ts b/server.ts index a2f186d..2b4a58a 100644 --- a/server.ts +++ b/server.ts @@ -36,7 +36,10 @@ if (allRounds.length > 0) { } } } - initialCompleted = [allRounds[allRounds.length - 1]]; + const lastRound = allRounds[allRounds.length - 1]; + if (lastRound) { + initialCompleted = [lastRound]; + } } const gameState: GameState = { @@ -52,7 +55,12 @@ const gameState: GameState = { const clients = new Set>(); function broadcast() { - const msg = JSON.stringify({ type: "state", data: gameState, totalRounds: runs }); + const msg = JSON.stringify({ + type: "state", + data: gameState, + totalRounds: runs, + viewerCount: clients.size, + }); for (const ws of clients) { ws.send(msg); } @@ -111,13 +119,14 @@ const server = Bun.serve({ websocket: { open(ws) { clients.add(ws); - ws.send(JSON.stringify({ type: "state", data: gameState, totalRounds: runs })); + broadcast(); }, message(_ws, _message) { // Spectator-only, no client messages handled }, close(ws) { clients.delete(ws); + broadcast(); }, }, development: process.env.NODE_ENV === "production" ? false : {