type Model = { id: string; name: string }; type TaskInfo = { model: Model; startedAt: number; finishedAt?: number; result?: string; error?: string; }; type VoteInfo = { voter: Model; startedAt: number; finishedAt?: number; votedFor?: Model; error?: boolean; }; type RoundState = { num: number; phase: "prompting" | "answering" | "voting" | "done"; prompter: Model; promptTask: TaskInfo; prompt?: string; contestants: [Model, Model]; answerTasks: [TaskInfo, TaskInfo]; votes: VoteInfo[]; scoreA?: number; scoreB?: number; }; type GameState = { completed: RoundState[]; active: RoundState | null; scores: Record; done: boolean; isPaused: boolean; generation: number; }; type ServerMessage = { type: "state"; data: GameState; totalRounds: number; viewerCount: number; }; const MODEL_COLORS: Record = { "Gemini 3.1 Pro": "#4285F4", "Kimi K2": "#00E599", "DeepSeek 3.2": "#4D6BFE", "GLM-5": "#1F63EC", "GPT-5.2": "#10A37F", "Opus 4.6": "#D97757", "Sonnet 4.6": "#D97757", "Grok 4.1": "#FFFFFF", "MiniMax 2.5": "#FF3B30", }; const WIDTH = 1920; const HEIGHT = 1080; const canvas = document.getElementById("broadcast-canvas") as HTMLCanvasElement; const statusEl = document.getElementById("broadcast-status") as HTMLDivElement; function get2dContext(el: HTMLCanvasElement): CanvasRenderingContext2D { const context = el.getContext("2d"); if (!context) throw new Error("2D canvas context unavailable"); return context; } const ctx = get2dContext(canvas); let state: GameState | null = null; let totalRounds: number | null = null; let viewerCount = 0; let connected = false; let ws: WebSocket | null = null; let reconnectTimer: number | null = null; let lastMessageAt = 0; function getColor(name: string): string { return MODEL_COLORS[name] ?? "#aeb6d6"; } function getLogoUrl(name: string): string | null { if (name.includes("Gemini")) return "/assets/logos/gemini.svg"; if (name.includes("Kimi")) return "/assets/logos/kimi.svg"; if (name.includes("DeepSeek")) return "/assets/logos/deepseek.svg"; if (name.includes("GLM")) return "/assets/logos/glm.svg"; if (name.includes("GPT")) return "/assets/logos/openai.svg"; if (name.includes("Opus") || name.includes("Sonnet")) return "/assets/logos/claude.svg"; if (name.includes("Grok")) return "/assets/logos/grok.svg"; if (name.includes("MiniMax")) return "/assets/logos/minimax.svg"; return null; } const logoCache: Record = {}; function drawModelLogo(name: string, x: number, y: number, size: number): boolean { const url = getLogoUrl(name); if (!url) return false; if (!logoCache[url]) { const img = new Image(); img.src = url; logoCache[url] = img; } const img = logoCache[url]; if (img.complete && img.naturalHeight !== 0) { ctx.drawImage(img, x, y, size, size); return true; } return false; } function setupWebSocket() { const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const wsUrl = `${wsProtocol}//${window.location.host}/ws`; ws = new WebSocket(wsUrl); ws.onopen = () => { connected = true; setStatus("WS connected"); }; ws.onclose = () => { connected = false; setStatus("WS reconnecting..."); if (reconnectTimer !== null) window.clearTimeout(reconnectTimer); reconnectTimer = window.setTimeout(setupWebSocket, 1_000); }; ws.onmessage = (e) => { try { const msg = JSON.parse(String(e.data)) as ServerMessage; if (msg.type === "state") { state = msg.data; totalRounds = Number.isFinite(msg.totalRounds) && msg.totalRounds >= 0 ? msg.totalRounds : null; viewerCount = msg.viewerCount; lastMessageAt = Date.now(); } } catch { // Ignore malformed spectator payloads. } }; } function setStatus(value: string) { statusEl.textContent = value; } function roundRect( x: number, y: number, w: number, h: number, r: number, fillStyle: string, ) { const p = new Path2D(); p.moveTo(x + r, y); p.lineTo(x + w - r, y); p.quadraticCurveTo(x + w, y, x + w, y + r); p.lineTo(x + w, y + h - r); p.quadraticCurveTo(x + w, y + h, x + w - r, y + h); p.lineTo(x + r, y + h); p.quadraticCurveTo(x, y + h, x, y + h - r); p.lineTo(x, y + r); p.quadraticCurveTo(x, y, x + r, y); ctx.fillStyle = fillStyle; ctx.fill(p); } function textLines( text: string, maxWidth: number, font: string, maxLines = 3, ): string[] { ctx.font = font; const words = text.split(/\s+/).filter(Boolean); const lines: string[] = []; let current = ""; for (const word of words) { const candidate = current ? `${current} ${word}` : word; if (ctx.measureText(candidate).width <= maxWidth) { current = candidate; continue; } if (current) lines.push(current); current = word; if (lines.length >= maxLines - 1) break; } if (current) { lines.push(current); } if (lines.length > maxLines) { lines.length = maxLines; } if (words.length > 0 && lines.length === maxLines) { const last = lines[maxLines - 1] ?? ""; if (ctx.measureText(last).width > maxWidth) { let trimmed = last; while (trimmed.length > 3 && ctx.measureText(`${trimmed}...`).width > maxWidth) { trimmed = trimmed.slice(0, -1); } lines[maxLines - 1] = `${trimmed}...`; } } return lines; } function drawTextBlock( text: string, x: number, y: number, maxWidth: number, lineHeight: number, font: string, color: string, maxLines: number, ) { const lines = textLines(text, maxWidth, font, maxLines); ctx.font = font; ctx.fillStyle = color; lines.forEach((line, idx) => { ctx.fillText(line, x, y + idx * lineHeight); }); } function drawHeader() { ctx.fillStyle = "#0a0a0a"; ctx.fillRect(0, 0, WIDTH, HEIGHT); ctx.font = '700 32px "Inter", sans-serif'; ctx.fillStyle = "#ededed"; ctx.fillText("quipslop", 48, 72); const viewersText = `${viewerCount} viewer${viewerCount === 1 ? "" : "s"} watching`; ctx.font = '600 14px "JetBrains Mono", monospace'; ctx.fillStyle = "#888"; const vWidth = ctx.measureText(viewersText).width; const pillW = vWidth + 40; const pillX = WIDTH - 380 - 48 - pillW; roundRect(pillX, 44, pillW, 36, 18, "rgba(255,255,255,0.02)"); ctx.fillStyle = connected ? "#22c55e" : "#ef4444"; ctx.beginPath(); ctx.arc(pillX + 16, 62, 4, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#888"; ctx.fillText(viewersText, pillX + 28, 67); } function drawScoreboard(scores: Record) { const entries = Object.entries(scores).sort((a, b) => b[1] - a[1]); roundRect(WIDTH - 380, 0, 380, HEIGHT, 0, "#111"); ctx.fillStyle = "#1c1c1c"; ctx.fillRect(WIDTH - 380, 0, 1, HEIGHT); ctx.font = '700 14px "JetBrains Mono", monospace'; ctx.fillStyle = "#888"; ctx.fillText("STANDINGS", WIDTH - 340, 72); const maxScore = entries[0]?.[1] || 1; entries.slice(0, 10).forEach(([name, score], index) => { const y = 140 + index * 60; const color = getColor(name); const pct = maxScore > 0 ? (score / maxScore) : 0; ctx.font = '600 16px "JetBrains Mono", monospace'; ctx.fillStyle = "#888"; const rank = index === 0 && score > 0 ? "👑" : String(index + 1); ctx.fillText(rank, WIDTH - 340, y + 20); ctx.font = '600 16px "Inter", sans-serif'; ctx.fillStyle = color; const nameText = name.length > 20 ? `${name.slice(0, 20)}...` : name; const drewLogo = drawModelLogo(name, WIDTH - 300, y + 4, 20); if (drewLogo) { ctx.fillText(nameText, WIDTH - 300 + 28, y + 20); } else { ctx.fillText(nameText, WIDTH - 300, y + 20); } roundRect(WIDTH - 300, y + 36, 200, 4, 2, "#1c1c1c"); if (pct > 0) { roundRect(WIDTH - 300, y + 36, Math.max(8, 200 * pct), 4, 2, color); } ctx.font = '700 16px "JetBrains Mono", monospace'; ctx.fillStyle = "#888"; const scoreText = String(score); const scoreWidth = ctx.measureText(scoreText).width; ctx.fillText(scoreText, WIDTH - 48 - scoreWidth, y + 20); }); } function drawRound(round: RoundState) { const mainW = WIDTH - 380; const phaseLabel = (round.phase === "prompting" ? "Writing prompt" : round.phase === "answering" ? "Answering" : round.phase === "voting" ? "Judges voting" : "Complete" ).toUpperCase(); ctx.font = '700 16px "JetBrains Mono", monospace'; ctx.fillStyle = "#ededed"; const totalText = totalRounds !== null ? `/${totalRounds}` : ""; ctx.fillText(`Round ${round.num}${totalText}`, 64, 150); ctx.fillStyle = "#888"; const labelWidth = ctx.measureText(phaseLabel).width; ctx.fillText(phaseLabel, mainW - 64 - labelWidth, 150); ctx.font = '600 14px "JetBrains Mono", monospace'; ctx.fillStyle = "#888"; const promptedText = "PROMPTED BY "; ctx.fillText(promptedText, 64, 210); const pTw = ctx.measureText(promptedText).width; ctx.fillStyle = getColor(round.prompter.name); const drewPLogo = drawModelLogo(round.prompter.name, 64 + pTw, 210 - 12, 16); if (drewPLogo) { ctx.fillText(round.prompter.name.toUpperCase(), 64 + pTw + 20, 210); } else { ctx.fillText(round.prompter.name.toUpperCase(), 64 + pTw, 210); } const promptText = round.prompt ?? (round.phase === "prompting" ? "Generating prompt..." : "Prompt unavailable"); ctx.fillStyle = "#D97757"; ctx.fillRect(64, 230, 4, Math.min(100, promptText.length > 100 ? 120 : 64)); drawTextBlock( promptText, 92, 260, mainW - 160, 64, '400 48px "DM Serif Display", serif', round.prompt ? "#ededed" : "#444", 2, ); if (round.phase !== "prompting") { const [taskA, taskB] = round.answerTasks; const cardW = (mainW - 160) / 2; drawContestantCard(taskA, 64, 400, cardW, 580, round); drawContestantCard(taskB, 64 + cardW + 32, 400, cardW, 580, round); } } function drawContestantCard( task: TaskInfo, x: number, y: number, w: number, h: number, round: RoundState, ) { const [a, b] = round.contestants; let votesA = 0; let votesB = 0; const taskVoters: VoteInfo[] = []; for (const vote of round.votes) { if (vote.votedFor?.name === a.name) votesA += 1; if (vote.votedFor?.name === b.name) votesB += 1; if (vote.votedFor?.name === task.model.name) taskVoters.push(vote); } const isFirst = round.answerTasks[0].model.name === task.model.name; const voteCount = isFirst ? votesA : votesB; const isWinner = round.phase === "done" && voteCount > (isFirst ? votesB : votesA); const color = getColor(task.model.name); ctx.fillStyle = color; ctx.fillRect(x, y, isWinner ? 6 : 4, h); if (isWinner) { roundRect(x, y, w, h, 0, "rgba(255,255,255,0.03)"); } ctx.font = '700 24px "Inter", sans-serif'; ctx.fillStyle = color; const drewCLogo = drawModelLogo(task.model.name, x + 24, y + 18, 24); if (drewCLogo) { ctx.fillText(task.model.name, x + 56, y + 40); } else { ctx.fillText(task.model.name, x + 24, y + 40); } if (isWinner) { ctx.font = '700 12px "JetBrains Mono", monospace'; ctx.fillStyle = "#0a0a0a"; const winW = ctx.measureText("WIN").width; roundRect(x + w - 24 - winW - 16, y + 20, winW + 16, 24, 4, "#ededed"); ctx.fillStyle = "#0a0a0a"; ctx.fillText("WIN", x + w - 24 - winW - 8, y + 36); } const answer = !task.finishedAt && !task.result ? "Writing answer..." : task.error ? task.error : task.result ?? "No answer"; drawTextBlock( task.result ? `"${answer}"` : answer, x + 24, y + 110, w - 48, 44, '400 32px "DM Serif Display", serif', isWinner ? "#ededed" : (!task.finishedAt && !task.result ? "#444" : "#888"), 6, ); const showVotes = round.phase === "voting" || round.phase === "done"; if (showVotes) { const totalVotes = votesA + votesB; const pct = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0; roundRect(x + 24, y + h - 60, w - 48, 4, 2, "#1c1c1c"); if (pct > 0) { roundRect(x + 24, y + h - 60, Math.max(8, ((w - 48) * pct) / 100), 4, 2, color); } ctx.font = '700 20px "JetBrains Mono", monospace'; ctx.fillStyle = color; ctx.fillText(String(voteCount), x + 24, y + h - 24); ctx.font = '600 14px "JetBrains Mono", monospace'; ctx.fillStyle = "#444"; const vTxt = `vote${voteCount === 1 ? "" : "s"}`; const vCountW = ctx.measureText(String(voteCount)).width; const vTxtW = ctx.measureText(vTxt).width; ctx.fillText(vTxt, x + 24 + vCountW + 8, y + h - 25); let avatarX = x + 24 + vCountW + 8 + vTxtW + 16; const avatarY = y + h - 42; const avatarSize = 24; for (const v of taskVoters) { const vColor = getColor(v.voter.name); const drewLogo = drawModelLogo(v.voter.name, avatarX, avatarY, avatarSize); if (!drewLogo) { ctx.beginPath(); ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2); ctx.fillStyle = vColor; ctx.fill(); ctx.font = '700 12px "Inter", sans-serif'; ctx.fillStyle = "#0a0a0a"; const initial = v.voter.name[0] ?? "?"; const tw = ctx.measureText(initial).width; ctx.fillText(initial, avatarX + avatarSize / 2 - tw / 2, avatarY + avatarSize / 2 + 4); } avatarX += avatarSize + 8; } } } function drawFooter() { ctx.font = '600 12px "JetBrains Mono", monospace'; ctx.fillStyle = "#444"; const ageMs = Date.now() - lastMessageAt; const freshness = lastMessageAt === 0 ? "waiting for state" : `${Math.floor(ageMs / 1000)}s old`; ctx.fillText(`viewers:${viewerCount} updates:${freshness}`, 24, HEIGHT - 24); } function drawWaiting() { const mainW = WIDTH - 380; ctx.font = '400 48px "DM Serif Display", serif'; ctx.fillStyle = "#888"; const text = "Waiting for game state..."; const tw = ctx.measureText(text).width; ctx.fillText(text, (mainW - tw) / 2, HEIGHT / 2); } function drawDone(scores: Record) { const mainW = WIDTH - 380; const winner = Object.entries(scores).sort((a, b) => b[1] - a[1])[0]; if (!winner) return; const [name, points] = winner; ctx.font = '700 20px "JetBrains Mono", monospace'; ctx.fillStyle = "#444"; const go = "GAME OVER"; const gow = ctx.measureText(go).width; ctx.fillText(go, (mainW - gow) / 2, HEIGHT / 2 - 100); ctx.font = '400 80px "DM Serif Display", serif'; ctx.fillStyle = getColor(name); const nw = ctx.measureText(name).width; ctx.fillText(name, (mainW - nw) / 2, HEIGHT / 2); ctx.font = '600 24px "Inter", sans-serif'; ctx.fillStyle = "#888"; const wins = `is the funniest AI`; const ww = ctx.measureText(wins).width; ctx.fillText(wins, (mainW - ww) / 2, HEIGHT / 2 + 60); } function draw() { drawHeader(); if (!state) { drawWaiting(); drawFooter(); return; } drawScoreboard(state.scores); const lastCompleted = state.completed[state.completed.length - 1]; const isNextPrompting = state.active?.phase === "prompting" && !state.active.prompt; const displayRound = isNextPrompting && lastCompleted ? lastCompleted : state.active; if (state.done) { drawDone(state.scores); } else if (displayRound) { drawRound(displayRound); } else { drawWaiting(); } drawFooter(); } function renderLoop() { draw(); window.requestAnimationFrame(renderLoop); } function startCanvasCaptureSink() { const params = new URLSearchParams(window.location.search); const sink = params.get("sink"); if (!sink) return; if (!("MediaRecorder" in window)) { setStatus("MediaRecorder unavailable"); return; } const fps = Number.parseInt(params.get("captureFps") ?? "30", 10); const bitRate = Number.parseInt(params.get("captureBitrate") ?? "12000000", 10); const stream = canvas.captureStream(Number.isFinite(fps) && fps > 0 ? fps : 30); const socket = new WebSocket(sink); socket.binaryType = "arraybuffer"; let recorder: MediaRecorder | null = null; const mimeCandidates = [ "video/webm;codecs=vp8", "video/webm;codecs=vp9", "video/webm", ]; const mimeType = mimeCandidates.find((value) => MediaRecorder.isTypeSupported(value)) ?? ""; socket.onopen = () => { const options: MediaRecorderOptions = { videoBitsPerSecond: Number.isFinite(bitRate) && bitRate > 0 ? bitRate : 12_000_000, }; if (mimeType) options.mimeType = mimeType; recorder = new MediaRecorder(stream, options); recorder.ondataavailable = async (event) => { if (event.data.size === 0) return; if (socket.readyState !== WebSocket.OPEN) return; if (socket.bufferedAmount > 16_000_000) return; const chunk = await event.data.arrayBuffer(); socket.send(chunk); }; recorder.onerror = () => { setStatus("Recorder error"); }; recorder.start(250); setStatus(`capture->ws ${fps}fps`); }; socket.onclose = () => { recorder?.stop(); setStatus("capture sink closed"); }; } setupWebSocket(); startCanvasCaptureSink(); renderLoop();