new layout for model rankings

This commit is contained in:
Theo Browne
2026-02-22 18:44:49 -08:00
parent f33277a095
commit 79f9dab7fb
6 changed files with 213 additions and 101 deletions

View File

@@ -32,6 +32,7 @@ type GameState = {
lastCompleted: RoundState | null; lastCompleted: RoundState | null;
active: RoundState | null; active: RoundState | null;
scores: Record<string, number>; scores: Record<string, number>;
viewerScores: Record<string, number>;
done: boolean; done: boolean;
isPaused: boolean; isPaused: boolean;
generation: number; generation: number;
@@ -294,8 +295,60 @@ function drawHeader() {
} }
function drawScoreboard(scores: Record<string, number>) { function drawScoreboardSection(
const entries = Object.entries(scores).sort((a, b) => b[1] - a[1]); 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<string, number>, viewerScores: Record<string, number>) {
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"); roundRect(WIDTH - 380, 0, 380, HEIGHT, 0, "#111");
ctx.fillStyle = "#1c1c1c"; ctx.fillStyle = "#1c1c1c";
@@ -305,40 +358,11 @@ function drawScoreboard(scores: Record<string, number>) {
ctx.fillStyle = "#888"; ctx.fillStyle = "#888";
ctx.fillText("STANDINGS", WIDTH - 348, 76); 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 viewerStartY = 110 + 28 + modelEntries.length * entryHeight + 16;
const y = 140 + index * 68; drawScoreboardSection(viewerEntries, "VIEWERS", viewerStartY, entryHeight);
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);
});
} }
function drawRound(round: RoundState) { function drawRound(round: RoundState) {
@@ -608,7 +632,7 @@ function draw() {
return; return;
} }
drawScoreboard(state.scores); drawScoreboard(state.scores, state.viewerScores ?? {});
const isNextPrompting = state.active?.phase === "prompting" && !state.active.prompt; const isNextPrompting = state.active?.phase === "prompting" && !state.active.prompt;
const displayRound = isNextPrompting && state.lastCompleted ? state.lastCompleted : (state.active ?? state.lastCompleted ?? null); const displayRound = isNextPrompting && state.lastCompleted ? state.lastCompleted : (state.active ?? state.lastCompleted ?? null);

View File

@@ -417,8 +417,8 @@ body {
padding: 16px 20px; padding: 16px 20px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 20px;
max-height: 220px; max-height: 50vh;
overflow-y: auto; overflow-y: auto;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -455,56 +455,87 @@ body {
color: var(--text); color: var(--text);
} }
.standings__list { /* ── Leaderboard Section ─────────────────────────────────────── */
.lb-section {
display: flex; display: flex;
flex-direction: column; 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; display: flex;
align-items: center; flex-direction: column;
gap: 10px;
padding: 5px 0;
} }
.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; opacity: 1;
} }
.standing__rank { .lb-entry__top {
width: 22px; display: flex;
align-items: center;
gap: 6px;
margin-bottom: 5px;
}
.lb-entry__rank {
width: 20px;
flex-shrink: 0;
text-align: center; text-align: center;
font-family: var(--mono); font-family: var(--mono);
font-size: 12px; font-size: 11px;
color: var(--text-muted); color: var(--text-muted);
flex-shrink: 0;
} }
.standing__bar { .lb-entry__score {
flex: 1; margin-left: auto;
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 {
font-family: var(--mono); font-family: var(--mono);
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
color: var(--text-dim); color: var(--text-dim);
min-width: 16px; min-width: 14px;
text-align: right; 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 ───────────────────────────────────────────────── */
.connecting { .connecting {
@@ -720,5 +751,6 @@ body {
max-height: none; max-height: none;
overflow-y: auto; overflow-y: auto;
padding: 24px; padding: 24px;
gap: 24px;
} }
} }

View File

@@ -38,6 +38,7 @@ type GameState = {
lastCompleted: RoundState | null; lastCompleted: RoundState | null;
active: RoundState | null; active: RoundState | null;
scores: Record<string, number>; scores: Record<string, number>;
viewerScores: Record<string, number>;
done: boolean; done: boolean;
isPaused: boolean; isPaused: boolean;
generation: number; generation: number;
@@ -382,16 +383,63 @@ function GameOver({ scores }: { scores: Record<string, number> }) {
// ── Standings ──────────────────────────────────────────────────────────────── // ── Standings ────────────────────────────────────────────────────────────────
function Standings({ function LeaderboardSection({
label,
scores, scores,
activeRound, competing,
}: { }: {
label: string;
scores: Record<string, number>; scores: Record<string, number>;
activeRound: RoundState | null; competing: Set<string>;
}) { }) {
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]); const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
const maxScore = sorted[0]?.[1] || 1; const maxScore = sorted[0]?.[1] || 1;
return (
<div className="lb-section">
<div className="lb-section__head">
<span className="lb-section__label">{label}</span>
</div>
<div className="lb-section__list">
{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 (
<div
key={name}
className={`lb-entry ${active ? "lb-entry--active" : ""}`}
>
<div className="lb-entry__top">
<span className="lb-entry__rank">
{i === 0 && score > 0 ? "👑" : i + 1}
</span>
<ModelTag model={{ id: name, name }} small />
<span className="lb-entry__score">{score}</span>
</div>
<div className="lb-entry__bar">
<div
className="lb-entry__fill"
style={{ width: `${pct}%`, background: color }}
/>
</div>
</div>
);
})}
</div>
</div>
);
}
function Standings({
scores,
viewerScores,
activeRound,
}: {
scores: Record<string, number>;
viewerScores: Record<string, number>;
activeRound: RoundState | null;
}) {
const competing = activeRound const competing = activeRound
? new Set([ ? new Set([
activeRound.contestants[0].name, activeRound.contestants[0].name,
@@ -415,31 +463,16 @@ function Standings({
</a> </a>
</div> </div>
</div> </div>
<div className="standings__list"> <LeaderboardSection
{sorted.map(([name, score], i) => { label="AI Judges"
const pct = maxScore > 0 ? Math.round((score / maxScore) * 100) : 0; scores={scores}
const color = getColor(name); competing={competing}
const active = competing.has(name); />
return ( <LeaderboardSection
<div label="Viewers"
key={name} scores={viewerScores}
className={`standing ${active ? "standing--active" : ""}`} competing={competing}
>
<span className="standing__rank">
{i === 0 && score > 0 ? "👑" : i + 1}
</span>
<ModelTag model={{ id: name, name }} small />
<div className="standing__bar">
<div
className="standing__fill"
style={{ width: `${pct}%`, background: color }}
/> />
</div>
<span className="standing__score">{score}</span>
</div>
);
})}
</div>
</aside> </aside>
); );
} }
@@ -578,7 +611,7 @@ function App() {
)} )}
</main> </main>
<Standings scores={state.scores} activeRound={state.active} /> <Standings scores={state.scores} viewerScores={state.viewerScores ?? {}} activeRound={state.active} />
</div> </div>
</div> </div>
); );

View File

@@ -73,6 +73,7 @@ export type GameState = {
completed: RoundState[]; completed: RoundState[];
active: RoundState | null; active: RoundState | null;
scores: Record<string, number>; scores: Record<string, number>;
viewerScores: Record<string, number>;
done: boolean; done: boolean;
isPaused: boolean; isPaused: boolean;
generation: number; generation: number;
@@ -471,6 +472,14 @@ export async function runGame(
} else if (votesB > votesA) { } else if (votesB > votesA) {
state.scores[contB.name] = (state.scores[contB.name] || 0) + 1; 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(); rerender();
await new Promise((r) => setTimeout(r, 5000)); await new Promise((r) => setTimeout(r, 5000));

View File

@@ -224,6 +224,7 @@ function Game({ runs }: { runs: number }) {
completed: [], completed: [],
active: null, active: null,
scores: Object.fromEntries(MODELS.map((m) => [m.name, 0])), scores: Object.fromEntries(MODELS.map((m) => [m.name, 0])),
viewerScores: Object.fromEntries(MODELS.map((m) => [m.name, 0])),
done: false, done: false,
isPaused: false, isPaused: false,
generation: 0, generation: 0,

View File

@@ -30,6 +30,7 @@ if (!process.env.OPENROUTER_API_KEY) {
const allRounds = getAllRounds(); const allRounds = getAllRounds();
const initialScores = Object.fromEntries(MODELS.map((m) => [m.name, 0])); const initialScores = Object.fromEntries(MODELS.map((m) => [m.name, 0]));
const initialViewerScores = Object.fromEntries(MODELS.map((m) => [m.name, 0]));
let initialCompleted: RoundState[] = []; let initialCompleted: RoundState[] = [];
if (allRounds.length > 0) { if (allRounds.length > 0) {
@@ -43,6 +44,15 @@ if (allRounds.length > 0) {
(initialScores[round.contestants[1].name] || 0) + 1; (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]; const lastRound = allRounds[allRounds.length - 1];
if (lastRound) { if (lastRound) {
@@ -54,6 +64,7 @@ const gameState: GameState = {
completed: initialCompleted, completed: initialCompleted,
active: null, active: null,
scores: initialScores, scores: initialScores,
viewerScores: initialViewerScores,
done: false, done: false,
isPaused: false, isPaused: false,
generation: 0, generation: 0,
@@ -349,6 +360,7 @@ function getClientState() {
active: gameState.active, active: gameState.active,
lastCompleted: gameState.completed.at(-1) ?? null, lastCompleted: gameState.completed.at(-1) ?? null,
scores: gameState.scores, scores: gameState.scores,
viewerScores: gameState.viewerScores,
done: gameState.done, done: gameState.done,
isPaused: gameState.isPaused, isPaused: gameState.isPaused,
generation: gameState.generation, generation: gameState.generation,
@@ -635,6 +647,7 @@ const server = Bun.serve<WsData>({
gameState.completed = []; gameState.completed = [];
gameState.active = null; gameState.active = null;
gameState.scores = Object.fromEntries(MODELS.map((m) => [m.name, 0])); gameState.scores = Object.fromEntries(MODELS.map((m) => [m.name, 0]));
gameState.viewerScores = Object.fromEntries(MODELS.map((m) => [m.name, 0]));
gameState.done = false; gameState.done = false;
gameState.isPaused = true; gameState.isPaused = true;
gameState.generation += 1; gameState.generation += 1;