"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import Board from "@/components/Board"; import { BoardState, GameEntry, ParsedMessage, cmd, createEmptyBoard, parseMessage, placeToken, replayMoves, } from "@/lib/protocol"; type ConnStatus = "idle" | "connecting" | "connected" | "disconnected"; interface WatchState { matchId: number; player1: string; // Red player2: string; // Yellow } type GameResult = | { kind: "win"; winner: string } | { kind: "draw" } | { kind: "terminated" }; const DEFAULT_URL = "wss://connect4.abunchofknowitalls.com"; export default function SpectatePage() { const [wsUrl, setWsUrl] = useState(DEFAULT_URL); const [status, setStatus] = useState("idle"); const [log, setLog] = useState([]); const [gameList, setGameList] = useState([]); const [watching, setWatching] = useState(null); const [board, setBoard] = useState(createEmptyBoard()); const [lastMove, setLastMove] = useState<{ column: number; row: number } | null>(null); const [currentTurnColor, setCurrentTurnColor] = useState<1 | 2>(1); const [moveCount, setMoveCount] = useState(0); const [gameResult, setGameResult] = useState(null); const wsRef = useRef(null); const pollRef = useRef | null>(null); const watchingRef = useRef(null); const addLog = (msg: string) => setLog((prev) => [`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev.slice(0, 49)]); const send = useCallback((msg: string) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(msg); } }, []); const handleMessage = useCallback( (raw: string) => { const msg: ParsedMessage = parseMessage(raw); switch (msg.type) { case "GAME_LIST": setGameList(msg.games); break; case "GAME_WATCH_ACK": { const { board: replayed, lastMove: lm } = replayMoves(msg.moves, msg.player1); const watchState: WatchState = { matchId: msg.matchId, player1: msg.player1, player2: msg.player2, }; setWatching(watchState); watchingRef.current = watchState; setBoard(replayed); setLastMove(lm); setMoveCount(msg.moves.length); setCurrentTurnColor(msg.moves.length % 2 === 0 ? 1 : 2); setGameResult(null); addLog(`Watching match ${msg.matchId}: ${msg.player1} (🔴) vs ${msg.player2} (🟡)`); if (msg.moves.length > 0) addLog(`Replayed ${msg.moves.length} existing move(s)`); break; } case "GAME_MOVE": { const w = watchingRef.current; if (!w) break; const color: 1 | 2 = msg.username === w.player1 ? 1 : 2; setBoard((prev) => { const { board: next, row } = placeToken(prev, color, msg.column); setLastMove({ column: msg.column, row }); return next; }); setMoveCount((n) => n + 1); setCurrentTurnColor((c) => (c === 1 ? 2 : 1)); addLog(`${msg.username} played column ${msg.column}`); break; } case "GAME_WIN": setGameResult({ kind: "win", winner: msg.winner }); setCurrentTurnColor(1); // reset addLog(`🏆 ${msg.winner} wins!`); break; case "GAME_DRAW": setGameResult({ kind: "draw" }); addLog("🤝 Draw!"); break; case "GAME_TERMINATED": setGameResult({ kind: "terminated" }); addLog("⛔ Match terminated"); break; case "ERROR": addLog(`Error: ${msg.message}`); break; default: break; } }, [] ); const connect = useCallback(() => { if (wsRef.current) wsRef.current.close(); setStatus("connecting"); setLog([]); setGameList([]); setWatching(null); watchingRef.current = null; setBoard(createEmptyBoard()); setLastMove(null); setGameResult(null); const ws = new WebSocket(wsUrl); wsRef.current = ws; ws.onopen = () => { setStatus("connected"); addLog("Connected as observer"); ws.send(cmd.gameList()); }; ws.onmessage = (e) => handleMessage(e.data as string); ws.onclose = () => { setStatus("disconnected"); addLog("Disconnected"); if (pollRef.current) clearInterval(pollRef.current); }; ws.onerror = () => addLog("WebSocket error"); // Poll game list every 4s pollRef.current = setInterval(() => { if (ws.readyState === WebSocket.OPEN) ws.send(cmd.gameList()); }, 4000); }, [wsUrl, handleMessage]); const disconnect = useCallback(() => { if (pollRef.current) clearInterval(pollRef.current); wsRef.current?.close(); }, []); const watchGame = useCallback( (id: number) => { setBoard(createEmptyBoard()); setLastMove(null); setGameResult(null); setMoveCount(0); setCurrentTurnColor(1); send(cmd.gameWatch(id)); }, [send] ); useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current); wsRef.current?.close(); }, []); return (

👁 Spectate Matches

Watch live Connect4 games in real time

{/* Connection bar */}
setWsUrl(e.target.value)} placeholder="wss://..." disabled={status === "connected" || status === "connecting"} />
{status !== "connected" ? ( ) : ( )}
{/* Left: game list + log */}
{/* Game list */}

Live Matches

{gameList.length} game{gameList.length !== 1 ? "s" : ""}
{status !== "connected" ? (

Connect to see matches

) : gameList.length === 0 ? (

No active matches

) : (
{gameList.map((g) => ( ))}
)}
{/* Event log */}

Event Log

{log.length === 0 ? (

No events yet

) : ( log.map((entry, i) => (

{entry}

)) )}
{/* Right: board */}
{!watching ? (
👁

{status === "connected" ? "Select a match from the list to start watching" : "Connect to the server to see live matches"}

) : ( <> {/* Game result banner */} {gameResult && (
{gameResult.kind === "win" ? `🏆 ${gameResult.winner} wins!` : gameResult.kind === "draw" ? "🤝 Draw!" : "⛔ Match Terminated"}
)}
Match #{watching.matchId} · {moveCount} move {moveCount !== 1 ? "s" : ""}
)}
); } function StatusBadge({ status }: { status: ConnStatus }) { const colors: Record = { idle: "bg-gray-700 text-gray-400", connecting: "bg-yellow-900/60 text-yellow-300 animate-pulse", connected: "bg-green-900/60 text-green-300", disconnected: "bg-red-900/60 text-red-300", }; const labels: Record = { idle: "Not connected", connecting: "Connecting…", connected: "Connected", disconnected: "Disconnected", }; return ( {labels[status]} ); }