diff --git a/broadcast.ts b/broadcast.ts index ced2938..a76f2c7 100644 --- a/broadcast.ts +++ b/broadcast.ts @@ -32,6 +32,7 @@ type GameState = { lastCompleted: RoundState | null; active: RoundState | null; scores: Record; + viewerScores: Record; done: boolean; isPaused: boolean; generation: number; @@ -294,9 +295,61 @@ function drawHeader() { } -function drawScoreboard(scores: Record) { - const entries = Object.entries(scores).sort((a, b) => b[1] - a[1]); - +function drawScoreboardSection( + entries: [string, number][], + label: string, + startY: number, + entryHeight: number, +) { + const maxScore = entries[0]?.[1] || 1; + + // Section label + ctx.font = '700 13px "JetBrains Mono", monospace'; + ctx.fillStyle = "#555"; + ctx.fillText(label, WIDTH - 348, startY); + + // Divider line under label + ctx.fillStyle = "#1c1c1c"; + ctx.fillRect(WIDTH - 348, startY + 8, 296, 1); + + entries.forEach(([name, score], index) => { + const y = startY + 20 + index * entryHeight; + const color = getColor(name); + const pct = maxScore > 0 ? score / maxScore : 0; + + ctx.font = '600 16px "JetBrains Mono", monospace'; + ctx.fillStyle = "#555"; + const rank = index === 0 && score > 0 ? "👑" : String(index + 1); + ctx.fillText(rank, WIDTH - 348, y + 18); + + ctx.font = '600 16px "Inter", sans-serif'; + ctx.fillStyle = color; + const nameText = name.length > 18 ? `${name.slice(0, 18)}...` : name; + + const drewLogo = drawModelLogo(name, WIDTH - 310, y + 4, 20); + if (drewLogo) { + ctx.fillText(nameText, WIDTH - 310 + 26, y + 18); + } else { + ctx.fillText(nameText, WIDTH - 310, y + 18); + } + + roundRect(WIDTH - 310, y + 30, 216, 3, 2, "#1c1c1c"); + if (pct > 0) { + roundRect(WIDTH - 310, y + 30, Math.max(6, 216 * pct), 3, 2, color); + } + + ctx.font = '700 16px "JetBrains Mono", monospace'; + ctx.fillStyle = "#666"; + const scoreText = String(score); + const scoreWidth = ctx.measureText(scoreText).width; + ctx.fillText(scoreText, WIDTH - 48 - scoreWidth, y + 18); + }); +} + +function drawScoreboard(scores: Record, viewerScores: Record) { + const modelEntries = Object.entries(scores).sort((a, b) => b[1] - a[1]) as [string, number][]; + const viewerEntries = Object.entries(viewerScores).sort((a, b) => b[1] - a[1]) as [string, number][]; + roundRect(WIDTH - 380, 0, 380, HEIGHT, 0, "#111"); ctx.fillStyle = "#1c1c1c"; ctx.fillRect(WIDTH - 380, 0, 1, HEIGHT); @@ -305,40 +358,11 @@ function drawScoreboard(scores: Record) { ctx.fillStyle = "#888"; ctx.fillText("STANDINGS", WIDTH - 348, 76); - const maxScore = entries[0]?.[1] || 1; + const entryHeight = 52; + drawScoreboardSection(modelEntries, "AI JUDGES", 110, entryHeight); - entries.slice(0, 10).forEach(([name, score], index) => { - const y = 140 + index * 68; - const color = getColor(name); - const pct = maxScore > 0 ? (score / maxScore) : 0; - - ctx.font = '600 20px "JetBrains Mono", monospace'; - ctx.fillStyle = "#888"; - const rank = index === 0 && score > 0 ? "👑" : String(index + 1); - ctx.fillText(rank, WIDTH - 348, y + 24); - - ctx.font = '600 20px "Inter", sans-serif'; - ctx.fillStyle = color; - const nameText = name.length > 18 ? `${name.slice(0, 18)}...` : name; - - const drewLogo = drawModelLogo(name, WIDTH - 304, y + 6, 24); - if (drewLogo) { - ctx.fillText(nameText, WIDTH - 304 + 32, y + 24); - } else { - ctx.fillText(nameText, WIDTH - 304, y + 24); - } - - roundRect(WIDTH - 304, y + 42, 208, 4, 2, "#1c1c1c"); - if (pct > 0) { - roundRect(WIDTH - 304, y + 42, Math.max(8, 208 * pct), 4, 2, color); - } - - ctx.font = '700 20px "JetBrains Mono", monospace'; - ctx.fillStyle = "#888"; - const scoreText = String(score); - const scoreWidth = ctx.measureText(scoreText).width; - ctx.fillText(scoreText, WIDTH - 48 - scoreWidth, y + 24); - }); + const viewerStartY = 110 + 28 + modelEntries.length * entryHeight + 16; + drawScoreboardSection(viewerEntries, "VIEWERS", viewerStartY, entryHeight); } function drawRound(round: RoundState) { @@ -608,7 +632,7 @@ function draw() { return; } - drawScoreboard(state.scores); + drawScoreboard(state.scores, state.viewerScores ?? {}); const isNextPrompting = state.active?.phase === "prompting" && !state.active.prompt; const displayRound = isNextPrompting && state.lastCompleted ? state.lastCompleted : (state.active ?? state.lastCompleted ?? null); diff --git a/frontend.css b/frontend.css index e78a5a4..f54b5f1 100644 --- a/frontend.css +++ b/frontend.css @@ -417,8 +417,8 @@ body { padding: 16px 20px; display: flex; flex-direction: column; - gap: 12px; - max-height: 220px; + gap: 20px; + max-height: 50vh; overflow-y: auto; flex-shrink: 0; } @@ -455,56 +455,87 @@ body { color: var(--text); } -.standings__list { +/* ── Leaderboard Section ─────────────────────────────────────── */ + +.lb-section { display: flex; flex-direction: column; - gap: 4px; + gap: 2px; } -.standing { +.lb-section__head { + padding-bottom: 6px; + border-bottom: 1px solid var(--border); + margin-bottom: 2px; +} + +.lb-section__label { + font-family: var(--mono); + font-size: 10px; + font-weight: 700; + letter-spacing: 1.5px; + text-transform: uppercase; + color: var(--text-muted); +} + +.lb-section__list { display: flex; - align-items: center; - gap: 10px; - padding: 5px 0; + flex-direction: column; } -.standing--active { +/* ── Leaderboard Entry ───────────────────────────────────────── */ + +.lb-entry { + padding: 6px 0 4px; + opacity: 0.55; + transition: opacity 0.3s; +} + +.lb-entry--active, +.lb-entry:hover { opacity: 1; } -.standing__rank { - width: 22px; +.lb-entry__top { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 5px; +} + +.lb-entry__rank { + width: 20px; + flex-shrink: 0; text-align: center; font-family: var(--mono); - font-size: 12px; + font-size: 11px; color: var(--text-muted); - flex-shrink: 0; } -.standing__bar { - flex: 1; - height: 3px; - background: var(--border); - border-radius: 2px; - overflow: hidden; - min-width: 40px; -} - -.standing__fill { - height: 100%; - border-radius: 2px; - transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1); -} - -.standing__score { +.lb-entry__score { + margin-left: auto; font-family: var(--mono); font-size: 12px; font-weight: 700; color: var(--text-dim); - min-width: 16px; + min-width: 14px; text-align: right; } +.lb-entry__bar { + margin-left: 26px; + height: 3px; + background: var(--border); + border-radius: 2px; + overflow: hidden; +} + +.lb-entry__fill { + height: 100%; + border-radius: 2px; + transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1); +} + /* ── Connecting ───────────────────────────────────────────────── */ .connecting { @@ -720,5 +751,6 @@ body { max-height: none; overflow-y: auto; padding: 24px; + gap: 24px; } } diff --git a/frontend.tsx b/frontend.tsx index 9e112ab..8043446 100644 --- a/frontend.tsx +++ b/frontend.tsx @@ -38,6 +38,7 @@ type GameState = { lastCompleted: RoundState | null; active: RoundState | null; scores: Record; + viewerScores: Record; done: boolean; isPaused: boolean; generation: number; @@ -382,16 +383,63 @@ function GameOver({ scores }: { scores: Record }) { // ── Standings ──────────────────────────────────────────────────────────────── -function Standings({ +function LeaderboardSection({ + label, scores, - activeRound, + competing, }: { + label: string; scores: Record; - activeRound: RoundState | null; + competing: Set; }) { const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]); const maxScore = sorted[0]?.[1] || 1; + return ( +
+
+ {label} +
+
+ {sorted.map(([name, score], i) => { + const pct = maxScore > 0 ? Math.round((score / maxScore) * 100) : 0; + const color = getColor(name); + const active = competing.has(name); + return ( +
+
+ + {i === 0 && score > 0 ? "👑" : i + 1} + + + {score} +
+
+
+
+
+ ); + })} +
+
+ ); +} + +function Standings({ + scores, + viewerScores, + activeRound, +}: { + scores: Record; + viewerScores: Record; + activeRound: RoundState | null; +}) { const competing = activeRound ? new Set([ activeRound.contestants[0].name, @@ -415,31 +463,16 @@ function Standings({
-
- {sorted.map(([name, score], i) => { - const pct = maxScore > 0 ? Math.round((score / maxScore) * 100) : 0; - const color = getColor(name); - const active = competing.has(name); - return ( -
- - {i === 0 && score > 0 ? "👑" : i + 1} - - -
-
-
- {score} -
- ); - })} -
+ + ); } @@ -578,7 +611,7 @@ function App() { )} - +
); diff --git a/game.ts b/game.ts index 90e589b..044e3f9 100644 --- a/game.ts +++ b/game.ts @@ -73,6 +73,7 @@ export type GameState = { completed: RoundState[]; active: RoundState | null; scores: Record; + viewerScores: Record; done: boolean; isPaused: boolean; generation: number; @@ -471,6 +472,14 @@ export async function runGame( } else if (votesB > votesA) { state.scores[contB.name] = (state.scores[contB.name] || 0) + 1; } + // Viewer vote scoring + const vvA = round.viewerVotesA ?? 0; + const vvB = round.viewerVotesB ?? 0; + if (vvA > vvB) { + state.viewerScores[contA.name] = (state.viewerScores[contA.name] || 0) + 1; + } else if (vvB > vvA) { + state.viewerScores[contB.name] = (state.viewerScores[contB.name] || 0) + 1; + } rerender(); await new Promise((r) => setTimeout(r, 5000)); diff --git a/quipslop.tsx b/quipslop.tsx index 3d0fdc8..31008d8 100644 --- a/quipslop.tsx +++ b/quipslop.tsx @@ -223,11 +223,12 @@ function Game({ runs }: { runs: number }) { const stateRef = useRef({ completed: [], active: null, - scores: Object.fromEntries(MODELS.map((m) => [m.name, 0])), - done: false, - isPaused: false, - generation: 0, - }); + scores: Object.fromEntries(MODELS.map((m) => [m.name, 0])), + viewerScores: Object.fromEntries(MODELS.map((m) => [m.name, 0])), + done: false, + isPaused: false, + generation: 0, + }); const [, setTick] = useState(0); const rerender = useCallback(() => setTick((t) => t + 1), []); diff --git a/server.ts b/server.ts index e679dd3..7f26f0f 100644 --- a/server.ts +++ b/server.ts @@ -30,6 +30,7 @@ if (!process.env.OPENROUTER_API_KEY) { const allRounds = getAllRounds(); const initialScores = Object.fromEntries(MODELS.map((m) => [m.name, 0])); +const initialViewerScores = Object.fromEntries(MODELS.map((m) => [m.name, 0])); let initialCompleted: RoundState[] = []; if (allRounds.length > 0) { @@ -43,6 +44,15 @@ if (allRounds.length > 0) { (initialScores[round.contestants[1].name] || 0) + 1; } } + const vvA = round.viewerVotesA ?? 0; + const vvB = round.viewerVotesB ?? 0; + if (vvA > vvB) { + initialViewerScores[round.contestants[0].name] = + (initialViewerScores[round.contestants[0].name] || 0) + 1; + } else if (vvB > vvA) { + initialViewerScores[round.contestants[1].name] = + (initialViewerScores[round.contestants[1].name] || 0) + 1; + } } const lastRound = allRounds[allRounds.length - 1]; if (lastRound) { @@ -54,6 +64,7 @@ const gameState: GameState = { completed: initialCompleted, active: null, scores: initialScores, + viewerScores: initialViewerScores, done: false, isPaused: false, generation: 0, @@ -349,6 +360,7 @@ function getClientState() { active: gameState.active, lastCompleted: gameState.completed.at(-1) ?? null, scores: gameState.scores, + viewerScores: gameState.viewerScores, done: gameState.done, isPaused: gameState.isPaused, generation: gameState.generation, @@ -635,6 +647,7 @@ const server = Bun.serve({ gameState.completed = []; gameState.active = null; gameState.scores = Object.fromEntries(MODELS.map((m) => [m.name, 0])); + gameState.viewerScores = Object.fromEntries(MODELS.map((m) => [m.name, 0])); gameState.done = false; gameState.isPaused = true; gameState.generation += 1;