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;
active: RoundState | null;
scores: Record<string, number>;
viewerScores: Record<string, number>;
done: boolean;
isPaused: boolean;
generation: number;
@@ -294,9 +295,61 @@ function drawHeader() {
}
function drawScoreboard(scores: Record<string, number>) {
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<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");
ctx.fillStyle = "#1c1c1c";
ctx.fillRect(WIDTH - 380, 0, 1, HEIGHT);
@@ -305,40 +358,11 @@ function drawScoreboard(scores: Record<string, number>) {
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);

View File

@@ -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;
}
}

View File

@@ -38,6 +38,7 @@ type GameState = {
lastCompleted: RoundState | null;
active: RoundState | null;
scores: Record<string, number>;
viewerScores: Record<string, number>;
done: boolean;
isPaused: boolean;
generation: number;
@@ -382,16 +383,63 @@ function GameOver({ scores }: { scores: Record<string, number> }) {
// ── Standings ────────────────────────────────────────────────────────────────
function Standings({
function LeaderboardSection({
label,
scores,
activeRound,
competing,
}: {
label: string;
scores: Record<string, number>;
activeRound: RoundState | null;
competing: Set<string>;
}) {
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[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
? new Set([
activeRound.contestants[0].name,
@@ -415,31 +463,16 @@ function Standings({
</a>
</div>
</div>
<div className="standings__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={`standing ${active ? "standing--active" : ""}`}
>
<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>
<LeaderboardSection
label="AI Judges"
scores={scores}
competing={competing}
/>
<LeaderboardSection
label="Viewers"
scores={viewerScores}
competing={competing}
/>
</aside>
);
}
@@ -578,7 +611,7 @@ function App() {
)}
</main>
<Standings scores={state.scores} activeRound={state.active} />
<Standings scores={state.scores} viewerScores={state.viewerScores ?? {}} activeRound={state.active} />
</div>
</div>
);

View File

@@ -73,6 +73,7 @@ export type GameState = {
completed: RoundState[];
active: RoundState | null;
scores: Record<string, number>;
viewerScores: Record<string, number>;
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));

View File

@@ -223,11 +223,12 @@ function Game({ runs }: { runs: number }) {
const stateRef = useRef<GameState>({
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), []);

View File

@@ -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<WsData>({
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;