From 845065ac8ffcd18950f1a8e7c27e17da96189e47 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 22 Feb 2026 17:22:59 -0800 Subject: [PATCH] voting why not lol --- broadcast.ts | 60 ++++++++++++++++++++--- frontend.css | 65 ++++++++++++++++++++++++ frontend.tsx | 136 +++++++++++++++++++++++++++++++++++++++++++++++++-- game.ts | 19 ++++++- history.css | 21 ++++++++ history.tsx | 26 ++++++++++ server.ts | 34 +++++++++++-- 7 files changed, 343 insertions(+), 18 deletions(-) diff --git a/broadcast.ts b/broadcast.ts index 9fedb94..ced2938 100644 --- a/broadcast.ts +++ b/broadcast.ts @@ -24,6 +24,9 @@ type RoundState = { votes: VoteInfo[]; scoreA?: number; scoreB?: number; + viewerVotesA?: number; + viewerVotesB?: number; + viewerVotingEndsAt?: number; }; type GameState = { lastCompleted: RoundState | null; @@ -341,7 +344,7 @@ function drawScoreboard(scores: Record) { function drawRound(round: RoundState) { const mainW = WIDTH - 380; - const phaseLabel = + let phaseLabel = (round.phase === "prompting" ? "Writing prompt" : round.phase === "answering" @@ -351,6 +354,12 @@ function drawRound(round: RoundState) { : "Complete" ).toUpperCase(); + // Append countdown during voting phase + let countdownSeconds = 0; + if (round.phase === "voting" && round.viewerVotingEndsAt) { + countdownSeconds = Math.max(0, Math.ceil((round.viewerVotingEndsAt - Date.now()) / 1000)); + } + ctx.font = '700 22px "JetBrains Mono", monospace'; ctx.fillStyle = "#ededed"; const totalText = totalRounds !== null ? `/${totalRounds}` : ""; @@ -360,6 +369,13 @@ function drawRound(round: RoundState) { const labelWidth = ctx.measureText(phaseLabel).width; ctx.fillText(phaseLabel, mainW - 64 - labelWidth, 150); + if (countdownSeconds > 0) { + const countdownText = `${countdownSeconds}S`; + ctx.fillStyle = "#ededed"; + const cdWidth = ctx.measureText(countdownText).width; + ctx.fillText(countdownText, mainW - 64 - labelWidth - cdWidth - 12, 150); + } + ctx.font = '600 18px "JetBrains Mono", monospace'; ctx.fillStyle = "#888"; const promptedText = "PROMPTED BY "; @@ -483,30 +499,38 @@ function drawContestantCard( 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"); + const viewerVoteCount = isFirst ? (round.viewerVotesA ?? 0) : (round.viewerVotesB ?? 0); + const totalViewerVotes = (round.viewerVotesA ?? 0) + (round.viewerVotesB ?? 0); + const hasViewerVotes = totalViewerVotes > 0; + + // Shift model votes up when viewer votes are present + const modelVoteBarY = hasViewerVotes ? y + h - 110 : y + h - 60; + const modelVoteTextY = hasViewerVotes ? y + h - 74 : y + h - 24; + + roundRect(x + 24, modelVoteBarY, w - 48, 4, 2, "#1c1c1c"); if (pct > 0) { - roundRect(x + 24, y + h - 60, Math.max(8, ((w - 48) * pct) / 100), 4, 2, color); + roundRect(x + 24, modelVoteBarY, Math.max(8, ((w - 48) * pct) / 100), 4, 2, color); } ctx.font = '700 28px "JetBrains Mono", monospace'; ctx.fillStyle = color; - ctx.fillText(String(voteCount), x + 24, y + h - 24); + ctx.fillText(String(voteCount), x + 24, modelVoteTextY); ctx.font = '600 20px "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); + ctx.fillText(vTxt, x + 24 + vCountW + 8, modelVoteTextY - 1); let avatarX = x + 24 + vCountW + 8 + vTxtW + 16; - const avatarY = y + h - 48; + const avatarY = modelVoteBarY + 12; const avatarSize = 28; 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); @@ -518,9 +542,29 @@ function drawContestantCard( const tw = ctx.measureText(initial).width; ctx.fillText(initial, avatarX + avatarSize / 2 - tw / 2, avatarY + avatarSize / 2 + 4); } - + avatarX += avatarSize + 8; } + + // Viewer votes + if (hasViewerVotes) { + const viewerPct = Math.round((viewerVoteCount / totalViewerVotes) * 100); + + roundRect(x + 24, y + h - 56, w - 48, 4, 2, "#1c1c1c"); + if (viewerPct > 0) { + roundRect(x + 24, y + h - 56, Math.max(8, ((w - 48) * viewerPct) / 100), 4, 2, "#666"); + } + + ctx.font = '700 22px "JetBrains Mono", monospace'; + ctx.fillStyle = "#999"; + ctx.fillText(String(viewerVoteCount), x + 24, y + h - 22); + + const vvCountW = ctx.measureText(String(viewerVoteCount)).width; + ctx.font = '600 16px "JetBrains Mono", monospace'; + ctx.fillStyle = "#444"; + const vvTxt = `viewer vote${viewerVoteCount === 1 ? "" : "s"}`; + ctx.fillText(vvTxt, x + 24 + vvCountW + 8, y + h - 23); + } } } diff --git a/frontend.css b/frontend.css index ed778c3..c46bc83 100644 --- a/frontend.css +++ b/frontend.css @@ -319,6 +319,71 @@ body { justify-content: center; } +/* ── Votable Contestant ──────────────────────────────────────── */ + +.contestant--votable { + cursor: pointer; + transition: background 0.2s, border-color 0.2s; +} + +.contestant--votable:hover { + background: rgba(255, 255, 255, 0.04); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); +} + +.contestant--votable:active { + background: rgba(255, 255, 255, 0.06); +} + +/* ── Viewer Votes ────────────────────────────────────────────── */ + +.viewer-vote-bar { + margin-top: 4px; +} + +.viewer-vote-bar__fill { + background: #666 !important; +} + +.viewer-vote-meta { + color: var(--text-muted); +} + +.viewer-vote-meta__count { + color: #999 !important; +} + +.viewer-vote-meta__icon { + margin-left: auto; + font-size: 14px; +} + +/* ── Vote CTA ────────────────────────────────────────────────── */ + +.vote-cta { + text-align: center; + font-family: var(--mono); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--text-dim); + padding: 10px 0; + margin-bottom: 8px; + animation: vote-cta-pulse 2s ease-in-out infinite; +} + +@keyframes vote-cta-pulse { + 0%, 100% { opacity: 0.7; } + 50% { opacity: 1; } +} + +.vote-countdown { + color: var(--text); + font-weight: 700; + margin-left: 8px; +} + /* ── Tie ──────────────────────────────────────────────────────── */ .tie-label { diff --git a/frontend.tsx b/frontend.tsx index 9357f83..01eb14b 100644 --- a/frontend.tsx +++ b/frontend.tsx @@ -30,6 +30,9 @@ type RoundState = { votes: VoteInfo[]; scoreA?: number; scoreB?: number; + viewerVotesA?: number; + viewerVotesB?: number; + viewerVotingEndsAt?: number; }; type GameState = { lastCompleted: RoundState | null; @@ -50,7 +53,8 @@ type ViewerCountMessage = { type: "viewerCount"; viewerCount: number; }; -type ServerMessage = StateMessage | ViewerCountMessage; +type VotedAckMessage = { type: "votedAck" }; +type ServerMessage = StateMessage | ViewerCountMessage | VotedAckMessage; // ── Model colors & logos ───────────────────────────────────────────────────── @@ -155,6 +159,10 @@ function ContestantCard({ isWinner, showVotes, voters, + viewerVotes, + totalViewerVotes, + votable, + onVote, }: { task: TaskInfo; voteCount: number; @@ -162,14 +170,26 @@ function ContestantCard({ isWinner: boolean; showVotes: boolean; voters: VoteInfo[]; + viewerVotes?: number; + totalViewerVotes?: number; + votable?: boolean; + onVote?: () => void; }) { const color = getColor(task.model.name); const pct = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0; + const showViewerVotes = showVotes && totalViewerVotes !== undefined && totalViewerVotes > 0; + const viewerPct = showViewerVotes && totalViewerVotes > 0 + ? Math.round(((viewerVotes ?? 0) / totalViewerVotes) * 100) + : 0; return (
{ if (e.key === "Enter" || e.key === " ") onVote?.(); } : undefined} >
@@ -227,6 +247,25 @@ function ContestantCard({ })}
+ {showViewerVotes && ( + <> +
+
+
+
+ + {viewerVotes ?? 0} + + + viewer vote{(viewerVotes ?? 0) !== 1 ? "s" : ""} + + 👥 +
+ + )}
)}
@@ -235,7 +274,19 @@ function ContestantCard({ // ── Arena ───────────────────────────────────────────────────────────────────── -function Arena({ round, total }: { round: RoundState; total: number | null }) { +function Arena({ + round, + total, + hasVoted, + onVote, + viewerVotingSecondsLeft, +}: { + round: RoundState; + total: number | null; + hasVoted: boolean; + onVote: (side: "A" | "B") => void; + viewerVotingSecondsLeft: number; +}) { const [contA, contB] = round.contestants; const showVotes = round.phase === "voting" || round.phase === "done"; const isDone = round.phase === "done"; @@ -249,6 +300,16 @@ function Arena({ round, total }: { round: RoundState; total: number | null }) { const totalVotes = votesA + votesB; const votersA = round.votes.filter((v) => v.votedFor?.name === contA.name); const votersB = round.votes.filter((v) => v.votedFor?.name === contB.name); + const totalViewerVotes = (round.viewerVotesA ?? 0) + (round.viewerVotesB ?? 0); + + const canVote = + round.phase === "voting" && + !hasVoted && + viewerVotingSecondsLeft > 0 && + round.answerTasks[0].finishedAt && + round.answerTasks[1].finishedAt; + + const showCountdown = round.phase === "voting" && viewerVotingSecondsLeft > 0; const phaseText = round.phase === "prompting" @@ -266,11 +327,22 @@ function Arena({ round, total }: { round: RoundState; total: number | null }) { Round {round.num} {total ? /{total} : null} - {phaseText} + + {phaseText} + {showCountdown && ( + {viewerVotingSecondsLeft}s + )} + + {canVote && ( +
+ Pick the funnier answer! +
+ )} + {round.phase !== "prompting" && (
votesB} showVotes={showVotes} voters={votersA} + viewerVotes={round.viewerVotesA} + totalViewerVotes={totalViewerVotes} + votable={!!canVote} + onVote={() => onVote("A")} /> votesA} showVotes={showVotes} voters={votersB} + viewerVotes={round.viewerVotesB} + totalViewerVotes={totalViewerVotes} + votable={!!canVote} + onVote={() => onVote("B")} />
)} @@ -412,6 +492,36 @@ function App() { const [totalRounds, setTotalRounds] = useState(null); const [viewerCount, setViewerCount] = useState(0); const [connected, setConnected] = useState(false); + const [hasVoted, setHasVoted] = useState(false); + const [votedRound, setVotedRound] = useState(null); + const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0); + const wsRef = React.useRef(null); + + // Reset hasVoted when round changes + useEffect(() => { + const currentRound = state?.active?.num ?? null; + if (currentRound !== null && currentRound !== votedRound) { + setHasVoted(false); + setVotedRound(null); + } + }, [state?.active?.num, votedRound]); + + // Countdown timer for viewer voting + useEffect(() => { + const endsAt = state?.active?.viewerVotingEndsAt; + if (!endsAt || state?.active?.phase !== "voting") { + setViewerVotingSecondsLeft(0); + return; + } + + function tick() { + const remaining = Math.max(0, Math.ceil((endsAt! - Date.now()) / 1000)); + setViewerVotingSecondsLeft(remaining); + } + tick(); + const interval = setInterval(tick, 1000); + return () => clearInterval(interval); + }, [state?.active?.viewerVotingEndsAt, state?.active?.phase]); useEffect(() => { const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; @@ -422,9 +532,11 @@ function App() { let knownVersion: string | null = null; function connect() { ws = new WebSocket(wsUrl); + wsRef.current = ws; ws.onopen = () => setConnected(true); ws.onclose = () => { setConnected(false); + wsRef.current = null; reconnectTimer = setTimeout(connect, 2000); }; ws.onmessage = (e) => { @@ -439,6 +551,8 @@ function App() { setViewerCount(msg.viewerCount); } else if (msg.type === "viewerCount") { setViewerCount(msg.viewerCount); + } else if (msg.type === "votedAck") { + setHasVoted(true); } }; } @@ -450,6 +564,12 @@ function App() { }; }, []); + const handleVote = (side: "A" | "B") => { + if (hasVoted || !wsRef.current) return; + wsRef.current.send(JSON.stringify({ type: "vote", votedFor: side })); + setVotedRound(state?.active?.num ?? null); + }; + if (!connected || !state) return ; const isNextPrompting = @@ -484,7 +604,13 @@ function App() { {state.done ? ( ) : displayRound ? ( - + ) : (
Starting diff --git a/game.ts b/game.ts index 87d10b7..90e589b 100644 --- a/game.ts +++ b/game.ts @@ -64,6 +64,9 @@ export type RoundState = { votes: VoteInfo[]; scoreA?: number; scoreB?: number; + viewerVotesA?: number; + viewerVotesB?: number; + viewerVotingEndsAt?: number; }; export type GameState = { @@ -268,6 +271,7 @@ export async function runGame( runs: number, state: GameState, rerender: () => void, + onViewerVotingStart?: () => void, ) { let startRound = 1; const lastCompletedRound = state.completed.at(-1); @@ -393,9 +397,17 @@ export async function runGame( const answerB = round.answerTasks[1].result!; const voteStart = Date.now(); round.votes = voters.map((v) => ({ voter: v, startedAt: voteStart })); + + // Initialize viewer voting + round.viewerVotesA = 0; + round.viewerVotesB = 0; + round.viewerVotingEndsAt = Date.now() + 30_000; + onViewerVotingStart?.(); rerender(); - await Promise.all( + await Promise.all([ + // Model votes + Promise.all( round.votes.map(async (vote) => { if (state.generation !== roundGeneration) { return; @@ -436,7 +448,10 @@ export async function runGame( } rerender(); }), - ); + ), + // 30-second viewer voting window + new Promise((r) => setTimeout(r, 30_000)), + ]); if (state.generation !== roundGeneration) { continue; } diff --git a/history.css b/history.css index d096744..4a33261 100644 --- a/history.css +++ b/history.css @@ -228,6 +228,27 @@ body { border-radius: 2px; } +/* ── Viewer Votes ────────────────────────────────────────────── */ + +.history-contestant__viewer-votes { + display: flex; + align-items: center; + gap: 6px; + padding-top: 8px; + border-top: 1px dashed var(--border); +} + +.history-contestant__viewer-icon { + font-size: 13px; +} + +.history-contestant__viewer-count { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); +} + /* ── Pagination ───────────────────────────────────────────────── */ .pagination { display: flex; diff --git a/history.tsx b/history.tsx index 801ad6c..6d2864d 100644 --- a/history.tsx +++ b/history.tsx @@ -30,6 +30,8 @@ type RoundState = { votes: VoteInfo[]; scoreA?: number; scoreB?: number; + viewerVotesA?: number; + viewerVotesB?: number; }; // ── Shared UI Utils ───────────────────────────────────────────────────────── @@ -124,6 +126,17 @@ function HistoryContestant({ ); } +function ViewerVotes({ count, label }: { count: number; label: string }) { + return ( +
+ 👥 + + {count} {label} + +
+ ); +} + function HistoryCard({ round }: { round: RoundState }) { const [contA, contB] = round.contestants; @@ -144,6 +157,7 @@ function HistoryCard({ round }: { round: RoundState }) { const isAWinner = votesA > votesB; const isBWinner = votesB > votesA; + const totalViewerVotes = (round.viewerVotesA ?? 0) + (round.viewerVotesB ?? 0); return (
@@ -193,6 +207,12 @@ function HistoryCard({ round }: { round: RoundState }) { )}
+ {totalViewerVotes > 0 && ( + + )}
+ {totalViewerVotes > 0 && ( + + )} diff --git a/server.ts b/server.ts index 5a9cbad..c207c86 100644 --- a/server.ts +++ b/server.ts @@ -218,6 +218,16 @@ function setHistoryCache(key: string, body: string, expiresAt: number) { // ── WebSocket clients ─────────────────────────────────────────────────────── const clients = new Set>(); +const viewerVoters = new Set>(); +let viewerVoteBroadcastTimer: ReturnType | null = null; + +function scheduleViewerVoteBroadcast() { + if (viewerVoteBroadcastTimer) return; + viewerVoteBroadcastTimer = setTimeout(() => { + viewerVoteBroadcastTimer = null; + broadcast(); + }, 5_000); +} function getClientState() { return { @@ -593,8 +603,24 @@ const server = Bun.serve({ // Notify everyone else with just the viewer count broadcastViewerCount(); }, - message(_ws, _message) { - // Spectator-only, no client messages handled + message(ws, message) { + try { + const msg = JSON.parse(String(message)); + if (msg.type !== "vote") return; + + const round = gameState.active; + if (!round || round.phase !== "voting") return; + if (!round.viewerVotingEndsAt || Date.now() > round.viewerVotingEndsAt) return; + if (viewerVoters.has(ws)) return; + if (msg.votedFor !== "A" && msg.votedFor !== "B") return; + + viewerVoters.add(ws); + if (msg.votedFor === "A") round.viewerVotesA = (round.viewerVotesA ?? 0) + 1; + else round.viewerVotesB = (round.viewerVotesB ?? 0) + 1; + + ws.send(JSON.stringify({ type: "votedAck" })); + scheduleViewerVoteBroadcast(); + } catch {} }, close(ws) { clients.delete(ws); @@ -634,6 +660,8 @@ log("INFO", "server", `Web server started on port ${server.port}`, { // ── Start game ────────────────────────────────────────────────────────────── -runGame(runs, gameState, broadcast).then(() => { +runGame(runs, gameState, broadcast, () => { + viewerVoters.clear(); +}).then(() => { console.log(`\n✅ Game complete! Log: ${LOG_FILE}`); });