diff --git a/broadcast.ts b/broadcast.ts index ced2938..1978889 100644 --- a/broadcast.ts +++ b/broadcast.ts @@ -16,6 +16,7 @@ type VoteInfo = { type RoundState = { num: number; phase: "prompting" | "answering" | "voting" | "done"; + phaseChangedAt?: number; prompter: Model; promptTask: TaskInfo; prompt?: string; @@ -63,6 +64,7 @@ const MODEL_COLORS: Record = { const WIDTH = 1920; const HEIGHT = 1080; +const DONE_PHASE_DURATION_MS = 5000; const canvas = document.getElementById("broadcast-canvas") as HTMLCanvasElement; const statusEl = document.getElementById("broadcast-status") as HTMLDivElement; @@ -341,7 +343,69 @@ function drawScoreboard(scores: Record) { }); } -function drawRound(round: RoundState) { +function drawPhaseTimer(round: RoundState, mainW: number, isShowingPrevious: boolean) { + const barY = 174; + const barH = 3; + const barX = 64; + const labelGap = 16; + + let label = ""; + let isCountdown = false; + let countdownProgress = 0; + + if (isShowingPrevious) { + label = "NEXT PROMPT LOADING"; + } else if (round.phase === "done" && round.phaseChangedAt) { + const elapsed = Date.now() - round.phaseChangedAt; + countdownProgress = Math.min(elapsed / DONE_PHASE_DURATION_MS, 1); + const remaining = Math.max(0, Math.ceil((DONE_PHASE_DURATION_MS - elapsed) / 1000)); + label = `NEXT ROUND IN ${remaining}S`; + isCountdown = true; + } else if (round.phase === "answering" || round.phase === "voting") { + const elapsed = round.phaseChangedAt ? Math.max(0, Math.floor((Date.now() - round.phaseChangedAt) / 1000)) : 0; + label = round.phase === "answering" + ? `WAITING FOR ANSWERS — ${elapsed}S` + : `JUDGES VOTING — ${elapsed}S`; + } else if (round.phase === "prompting") { + const elapsed = round.phaseChangedAt ? Math.max(0, Math.floor((Date.now() - round.phaseChangedAt) / 1000)) : 0; + label = `WRITING PROMPT — ${elapsed}S`; + } else { + return; + } + + ctx.font = '700 14px "JetBrains Mono", monospace'; + const labelW = ctx.measureText(label).width; + const barW = mainW - barX - labelGap - labelW - 64; + + // Background + roundRect(barX, barY, barW, barH, 2, "#1c1c1c"); + + if (isCountdown) { + const fillW = Math.max(0, barW * (1 - countdownProgress)); + if (fillW > 0) roundRect(barX, barY, fillW, barH, 2, "#D97757"); + } else { + // Indeterminate sliding bar + const period = 1500; + const t = (Date.now() % period) / period; + const fillW = barW * 0.3; + const totalTravel = barW + fillW; + const offset = -fillW + totalTravel * t; + + ctx.save(); + ctx.beginPath(); + ctx.rect(barX, barY - 1, barW, barH + 2); + ctx.clip(); + roundRect(barX + offset, barY, fillW, barH, 2, "#444"); + ctx.restore(); + } + + // Label + ctx.font = '700 14px "JetBrains Mono", monospace'; + ctx.fillStyle = "#444"; + ctx.fillText(label, barX + barW + labelGap, barY + 10); +} + +function drawRound(round: RoundState, isShowingPrevious: boolean) { const mainW = WIDTH - 380; let phaseLabel = @@ -376,6 +440,8 @@ function drawRound(round: RoundState) { ctx.fillText(countdownText, mainW - 64 - labelWidth - cdWidth - 12, 150); } + drawPhaseTimer(round, mainW, isShowingPrevious); + ctx.font = '600 18px "JetBrains Mono", monospace'; ctx.fillStyle = "#888"; const promptedText = "PROMPTED BY "; @@ -616,7 +682,7 @@ function draw() { if (state.done) { drawDone(state.scores); } else if (displayRound) { - drawRound(displayRound); + drawRound(displayRound, isNextPrompting && !!state.lastCompleted); } else { drawWaiting(); } diff --git a/frontend.css b/frontend.css index e78a5a4..8fa62d3 100644 --- a/frontend.css +++ b/frontend.css @@ -138,6 +138,56 @@ body { color: var(--text-dim); } +/* ── Phase Timer ─────────────────────────────────────────────── */ + +.phase-timer { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; + flex-shrink: 0; +} + +.phase-timer__bar { + flex: 1; + height: 3px; + background: var(--border); + border-radius: 2px; + overflow: hidden; +} + +.phase-timer__fill { + height: 100%; + border-radius: 2px; + background: var(--text-muted); +} + +.phase-timer__fill--countdown { + background: var(--accent); + transition: width 0.1s linear; +} + +.phase-timer__fill--indeterminate { + width: 30%; + background: var(--text-muted); + animation: timer-indeterminate 1.5s ease-in-out infinite; +} + +@keyframes timer-indeterminate { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(433%); } +} + +.phase-timer__label { + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--text-muted); + white-space: nowrap; + flex-shrink: 0; +} + .vote-hint { margin: -10px 0 22px; font-family: var(--mono); diff --git a/frontend.tsx b/frontend.tsx index 9e112ab..137edc8 100644 --- a/frontend.tsx +++ b/frontend.tsx @@ -22,6 +22,7 @@ type VoteInfo = { type RoundState = { num: number; phase: "prompting" | "answering" | "voting" | "done"; + phaseChangedAt?: number; prompter: Model; promptTask: TaskInfo; prompt?: string; @@ -98,6 +99,97 @@ function Dots() { ); } +const DONE_PHASE_DURATION_MS = 5000; + +function useElapsed(startTime: number | undefined) { + const [elapsed, setElapsed] = useState(0); + useEffect(() => { + if (!startTime) { setElapsed(0); return; } + const tick = () => setElapsed(Math.max(0, Math.floor((Date.now() - startTime) / 1000))); + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [startTime]); + return elapsed; +} + +function PhaseTimer({ round, isShowingPrevious }: { round: RoundState; isShowingPrevious: boolean }) { + const [progress, setProgress] = useState(0); + const elapsed = useElapsed(round.phaseChangedAt); + + useEffect(() => { + if (round.phase !== "done" || !round.phaseChangedAt) { + setProgress(0); + return; + } + const startTime = round.phaseChangedAt; + function tick() { + const pct = Math.min((Date.now() - startTime) / DONE_PHASE_DURATION_MS, 1); + setProgress(pct); + } + tick(); + const id = setInterval(tick, 50); + return () => clearInterval(id); + }, [round.phase, round.phaseChangedAt]); + + // Showing previous round's results while next prompt loads — no rush + if (isShowingPrevious) { + return ( +
+
+
+
+ Next prompt loading — no rush +
+ ); + } + + // Done phase: 5-second countdown before next round + if (round.phase === "done" && round.phaseChangedAt) { + const remaining = Math.max(0, Math.ceil((DONE_PHASE_DURATION_MS - (Date.now() - round.phaseChangedAt)) / 1000)); + return ( +
+
+
+
+ Next round in {remaining}s +
+ ); + } + + // Answering / voting: open-ended, show elapsed time + if (round.phase === "answering" || round.phase === "voting") { + const label = round.phase === "answering" + ? `Waiting for answers — ${elapsed}s` + : `Judges voting — ${elapsed}s`; + return ( +
+
+
+
+ {label} +
+ ); + } + + // Prompting (showing current round's loading state) + if (round.phase === "prompting") { + return ( +
+
+
+
+ Writing prompt — {elapsed}s +
+ ); + } + + return null; +} + function ModelTag({ model, small }: { model: Model; small?: boolean }) { const logo = getLogo(model.name); const color = getColor(model.name); @@ -269,10 +361,12 @@ function Arena({ round, total, viewerVotingSecondsLeft, + isShowingPrevious, }: { round: RoundState; total: number | null; viewerVotingSecondsLeft: number; + isShowingPrevious?: boolean; }) { const [contA, contB] = round.contestants; const showVotes = round.phase === "voting" || round.phase === "done"; @@ -320,6 +414,8 @@ function Arena({
)} + + {round.phase !== "prompting" && ( @@ -561,6 +657,7 @@ function App() { round={displayRound} total={totalRounds} viewerVotingSecondsLeft={viewerVotingSecondsLeft} + isShowingPrevious={isNextPrompting && !!state.lastCompleted} /> ) : (
diff --git a/game.ts b/game.ts index 90e589b..52141ac 100644 --- a/game.ts +++ b/game.ts @@ -56,6 +56,7 @@ export type VoteInfo = { export type RoundState = { num: number; phase: "prompting" | "answering" | "voting" | "done"; + phaseChangedAt: number; prompter: Model; promptTask: TaskInfo; prompt?: string; @@ -305,6 +306,7 @@ export async function runGame( const round: RoundState = { num: r, phase: "prompting", + phaseChangedAt: now, prompter, promptTask: { model: prompter, startedAt: now }, contestants: [contA, contB], @@ -352,6 +354,7 @@ export async function runGame( // ── Answer phase ── round.phase = "answering"; + round.phaseChangedAt = Date.now(); const answerStart = Date.now(); round.answerTasks[0].startedAt = answerStart; round.answerTasks[1].startedAt = answerStart; @@ -393,6 +396,7 @@ export async function runGame( // ── Vote phase ── round.phase = "voting"; + round.phaseChangedAt = Date.now(); const answerA = round.answerTasks[0].result!; const answerB = round.answerTasks[1].result!; const voteStart = Date.now(); @@ -466,6 +470,7 @@ export async function runGame( round.scoreA = votesA * 100; round.scoreB = votesB * 100; round.phase = "done"; + round.phaseChangedAt = Date.now(); if (votesA > votesB) { state.scores[contA.name] = (state.scores[contA.name] || 0) + 1; } else if (votesB > votesA) {