voting why not lol

This commit is contained in:
Theo Browne
2026-02-22 17:22:59 -08:00
parent fb24f6e198
commit 845065ac8f
7 changed files with 343 additions and 18 deletions

View File

@@ -24,6 +24,9 @@ type RoundState = {
votes: VoteInfo[]; votes: VoteInfo[];
scoreA?: number; scoreA?: number;
scoreB?: number; scoreB?: number;
viewerVotesA?: number;
viewerVotesB?: number;
viewerVotingEndsAt?: number;
}; };
type GameState = { type GameState = {
lastCompleted: RoundState | null; lastCompleted: RoundState | null;
@@ -341,7 +344,7 @@ function drawScoreboard(scores: Record<string, number>) {
function drawRound(round: RoundState) { function drawRound(round: RoundState) {
const mainW = WIDTH - 380; const mainW = WIDTH - 380;
const phaseLabel = let phaseLabel =
(round.phase === "prompting" (round.phase === "prompting"
? "Writing prompt" ? "Writing prompt"
: round.phase === "answering" : round.phase === "answering"
@@ -351,6 +354,12 @@ function drawRound(round: RoundState) {
: "Complete" : "Complete"
).toUpperCase(); ).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.font = '700 22px "JetBrains Mono", monospace';
ctx.fillStyle = "#ededed"; ctx.fillStyle = "#ededed";
const totalText = totalRounds !== null ? `/${totalRounds}` : ""; const totalText = totalRounds !== null ? `/${totalRounds}` : "";
@@ -360,6 +369,13 @@ function drawRound(round: RoundState) {
const labelWidth = ctx.measureText(phaseLabel).width; const labelWidth = ctx.measureText(phaseLabel).width;
ctx.fillText(phaseLabel, mainW - 64 - labelWidth, 150); 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.font = '600 18px "JetBrains Mono", monospace';
ctx.fillStyle = "#888"; ctx.fillStyle = "#888";
const promptedText = "PROMPTED BY "; const promptedText = "PROMPTED BY ";
@@ -483,24 +499,32 @@ function drawContestantCard(
const totalVotes = votesA + votesB; const totalVotes = votesA + votesB;
const pct = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0; 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) { 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.font = '700 28px "JetBrains Mono", monospace';
ctx.fillStyle = color; 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.font = '600 20px "JetBrains Mono", monospace';
ctx.fillStyle = "#444"; ctx.fillStyle = "#444";
const vTxt = `vote${voteCount === 1 ? "" : "s"}`; const vTxt = `vote${voteCount === 1 ? "" : "s"}`;
const vCountW = ctx.measureText(String(voteCount)).width; const vCountW = ctx.measureText(String(voteCount)).width;
const vTxtW = ctx.measureText(vTxt).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; let avatarX = x + 24 + vCountW + 8 + vTxtW + 16;
const avatarY = y + h - 48; const avatarY = modelVoteBarY + 12;
const avatarSize = 28; const avatarSize = 28;
for (const v of taskVoters) { for (const v of taskVoters) {
@@ -521,6 +545,26 @@ function drawContestantCard(
avatarX += avatarSize + 8; 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);
}
} }
} }

View File

@@ -319,6 +319,71 @@ body {
justify-content: center; 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 ──────────────────────────────────────────────────────── */
.tie-label { .tie-label {

View File

@@ -30,6 +30,9 @@ type RoundState = {
votes: VoteInfo[]; votes: VoteInfo[];
scoreA?: number; scoreA?: number;
scoreB?: number; scoreB?: number;
viewerVotesA?: number;
viewerVotesB?: number;
viewerVotingEndsAt?: number;
}; };
type GameState = { type GameState = {
lastCompleted: RoundState | null; lastCompleted: RoundState | null;
@@ -50,7 +53,8 @@ type ViewerCountMessage = {
type: "viewerCount"; type: "viewerCount";
viewerCount: number; viewerCount: number;
}; };
type ServerMessage = StateMessage | ViewerCountMessage; type VotedAckMessage = { type: "votedAck" };
type ServerMessage = StateMessage | ViewerCountMessage | VotedAckMessage;
// ── Model colors & logos ───────────────────────────────────────────────────── // ── Model colors & logos ─────────────────────────────────────────────────────
@@ -155,6 +159,10 @@ function ContestantCard({
isWinner, isWinner,
showVotes, showVotes,
voters, voters,
viewerVotes,
totalViewerVotes,
votable,
onVote,
}: { }: {
task: TaskInfo; task: TaskInfo;
voteCount: number; voteCount: number;
@@ -162,14 +170,26 @@ function ContestantCard({
isWinner: boolean; isWinner: boolean;
showVotes: boolean; showVotes: boolean;
voters: VoteInfo[]; voters: VoteInfo[];
viewerVotes?: number;
totalViewerVotes?: number;
votable?: boolean;
onVote?: () => void;
}) { }) {
const color = getColor(task.model.name); const color = getColor(task.model.name);
const pct = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0; 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 ( return (
<div <div
className={`contestant ${isWinner ? "contestant--winner" : ""}`} className={`contestant ${isWinner ? "contestant--winner" : ""} ${votable ? "contestant--votable" : ""}`}
style={{ "--accent": color } as React.CSSProperties} style={{ "--accent": color } as React.CSSProperties}
onClick={votable ? onVote : undefined}
role={votable ? "button" : undefined}
tabIndex={votable ? 0 : undefined}
onKeyDown={votable ? (e) => { if (e.key === "Enter" || e.key === " ") onVote?.(); } : undefined}
> >
<div className="contestant__head"> <div className="contestant__head">
<ModelTag model={task.model} /> <ModelTag model={task.model} />
@@ -227,6 +247,25 @@ function ContestantCard({
})} })}
</span> </span>
</div> </div>
{showViewerVotes && (
<>
<div className="vote-bar viewer-vote-bar">
<div
className="vote-bar__fill viewer-vote-bar__fill"
style={{ width: `${viewerPct}%` }}
/>
</div>
<div className="vote-meta viewer-vote-meta">
<span className="vote-meta__count viewer-vote-meta__count">
{viewerVotes ?? 0}
</span>
<span className="vote-meta__label">
viewer vote{(viewerVotes ?? 0) !== 1 ? "s" : ""}
</span>
<span className="viewer-vote-meta__icon">👥</span>
</div>
</>
)}
</div> </div>
)} )}
</div> </div>
@@ -235,7 +274,19 @@ function ContestantCard({
// ── Arena ───────────────────────────────────────────────────────────────────── // ── 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 [contA, contB] = round.contestants;
const showVotes = round.phase === "voting" || round.phase === "done"; const showVotes = round.phase === "voting" || round.phase === "done";
const isDone = 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 totalVotes = votesA + votesB;
const votersA = round.votes.filter((v) => v.votedFor?.name === contA.name); const votersA = round.votes.filter((v) => v.votedFor?.name === contA.name);
const votersB = round.votes.filter((v) => v.votedFor?.name === contB.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 = const phaseText =
round.phase === "prompting" round.phase === "prompting"
@@ -266,11 +327,22 @@ function Arena({ round, total }: { round: RoundState; total: number | null }) {
Round {round.num} Round {round.num}
{total ? <span className="dim">/{total}</span> : null} {total ? <span className="dim">/{total}</span> : null}
</span> </span>
<span className="arena__phase">{phaseText}</span> <span className="arena__phase">
{phaseText}
{showCountdown && (
<span className="vote-countdown">{viewerVotingSecondsLeft}s</span>
)}
</span>
</div> </div>
<PromptCard round={round} /> <PromptCard round={round} />
{canVote && (
<div className="vote-cta">
Pick the funnier answer!
</div>
)}
{round.phase !== "prompting" && ( {round.phase !== "prompting" && (
<div className="showdown"> <div className="showdown">
<ContestantCard <ContestantCard
@@ -280,6 +352,10 @@ function Arena({ round, total }: { round: RoundState; total: number | null }) {
isWinner={isDone && votesA > votesB} isWinner={isDone && votesA > votesB}
showVotes={showVotes} showVotes={showVotes}
voters={votersA} voters={votersA}
viewerVotes={round.viewerVotesA}
totalViewerVotes={totalViewerVotes}
votable={!!canVote}
onVote={() => onVote("A")}
/> />
<ContestantCard <ContestantCard
task={round.answerTasks[1]} task={round.answerTasks[1]}
@@ -288,6 +364,10 @@ function Arena({ round, total }: { round: RoundState; total: number | null }) {
isWinner={isDone && votesB > votesA} isWinner={isDone && votesB > votesA}
showVotes={showVotes} showVotes={showVotes}
voters={votersB} voters={votersB}
viewerVotes={round.viewerVotesB}
totalViewerVotes={totalViewerVotes}
votable={!!canVote}
onVote={() => onVote("B")}
/> />
</div> </div>
)} )}
@@ -412,6 +492,36 @@ function App() {
const [totalRounds, setTotalRounds] = useState<number | null>(null); const [totalRounds, setTotalRounds] = useState<number | null>(null);
const [viewerCount, setViewerCount] = useState(0); const [viewerCount, setViewerCount] = useState(0);
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [hasVoted, setHasVoted] = useState(false);
const [votedRound, setVotedRound] = useState<number | null>(null);
const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0);
const wsRef = React.useRef<WebSocket | null>(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(() => { useEffect(() => {
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
@@ -422,9 +532,11 @@ function App() {
let knownVersion: string | null = null; let knownVersion: string | null = null;
function connect() { function connect() {
ws = new WebSocket(wsUrl); ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => setConnected(true); ws.onopen = () => setConnected(true);
ws.onclose = () => { ws.onclose = () => {
setConnected(false); setConnected(false);
wsRef.current = null;
reconnectTimer = setTimeout(connect, 2000); reconnectTimer = setTimeout(connect, 2000);
}; };
ws.onmessage = (e) => { ws.onmessage = (e) => {
@@ -439,6 +551,8 @@ function App() {
setViewerCount(msg.viewerCount); setViewerCount(msg.viewerCount);
} else if (msg.type === "viewerCount") { } else if (msg.type === "viewerCount") {
setViewerCount(msg.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 <ConnectingScreen />; if (!connected || !state) return <ConnectingScreen />;
const isNextPrompting = const isNextPrompting =
@@ -484,7 +604,13 @@ function App() {
{state.done ? ( {state.done ? (
<GameOver scores={state.scores} /> <GameOver scores={state.scores} />
) : displayRound ? ( ) : displayRound ? (
<Arena round={displayRound} total={totalRounds} /> <Arena
round={displayRound}
total={totalRounds}
hasVoted={hasVoted}
onVote={handleVote}
viewerVotingSecondsLeft={viewerVotingSecondsLeft}
/>
) : ( ) : (
<div className="waiting"> <div className="waiting">
Starting Starting

19
game.ts
View File

@@ -64,6 +64,9 @@ export type RoundState = {
votes: VoteInfo[]; votes: VoteInfo[];
scoreA?: number; scoreA?: number;
scoreB?: number; scoreB?: number;
viewerVotesA?: number;
viewerVotesB?: number;
viewerVotingEndsAt?: number;
}; };
export type GameState = { export type GameState = {
@@ -268,6 +271,7 @@ export async function runGame(
runs: number, runs: number,
state: GameState, state: GameState,
rerender: () => void, rerender: () => void,
onViewerVotingStart?: () => void,
) { ) {
let startRound = 1; let startRound = 1;
const lastCompletedRound = state.completed.at(-1); const lastCompletedRound = state.completed.at(-1);
@@ -393,9 +397,17 @@ export async function runGame(
const answerB = round.answerTasks[1].result!; const answerB = round.answerTasks[1].result!;
const voteStart = Date.now(); const voteStart = Date.now();
round.votes = voters.map((v) => ({ voter: v, startedAt: voteStart })); 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(); rerender();
await Promise.all( await Promise.all([
// Model votes
Promise.all(
round.votes.map(async (vote) => { round.votes.map(async (vote) => {
if (state.generation !== roundGeneration) { if (state.generation !== roundGeneration) {
return; return;
@@ -436,7 +448,10 @@ export async function runGame(
} }
rerender(); rerender();
}), }),
); ),
// 30-second viewer voting window
new Promise((r) => setTimeout(r, 30_000)),
]);
if (state.generation !== roundGeneration) { if (state.generation !== roundGeneration) {
continue; continue;
} }

View File

@@ -228,6 +228,27 @@ body {
border-radius: 2px; 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 ───────────────────────────────────────────────── */
.pagination { .pagination {
display: flex; display: flex;

View File

@@ -30,6 +30,8 @@ type RoundState = {
votes: VoteInfo[]; votes: VoteInfo[];
scoreA?: number; scoreA?: number;
scoreB?: number; scoreB?: number;
viewerVotesA?: number;
viewerVotesB?: number;
}; };
// ── Shared UI Utils ───────────────────────────────────────────────────────── // ── Shared UI Utils ─────────────────────────────────────────────────────────
@@ -124,6 +126,17 @@ function HistoryContestant({
); );
} }
function ViewerVotes({ count, label }: { count: number; label: string }) {
return (
<div className="history-contestant__viewer-votes">
<span className="history-contestant__viewer-icon">👥</span>
<span className="history-contestant__viewer-count">
{count} {label}
</span>
</div>
);
}
function HistoryCard({ round }: { round: RoundState }) { function HistoryCard({ round }: { round: RoundState }) {
const [contA, contB] = round.contestants; const [contA, contB] = round.contestants;
@@ -144,6 +157,7 @@ function HistoryCard({ round }: { round: RoundState }) {
const isAWinner = votesA > votesB; const isAWinner = votesA > votesB;
const isBWinner = votesB > votesA; const isBWinner = votesB > votesA;
const totalViewerVotes = (round.viewerVotesA ?? 0) + (round.viewerVotesB ?? 0);
return ( return (
<div className="history-card"> <div className="history-card">
@@ -193,6 +207,12 @@ function HistoryCard({ round }: { round: RoundState }) {
)} )}
</div> </div>
</div> </div>
{totalViewerVotes > 0 && (
<ViewerVotes
count={round.viewerVotesA ?? 0}
label={`viewer vote${(round.viewerVotesA ?? 0) === 1 ? "" : "s"}`}
/>
)}
</div> </div>
<div <div
@@ -228,6 +248,12 @@ function HistoryCard({ round }: { round: RoundState }) {
)} )}
</div> </div>
</div> </div>
{totalViewerVotes > 0 && (
<ViewerVotes
count={round.viewerVotesB ?? 0}
label={`viewer vote${(round.viewerVotesB ?? 0) === 1 ? "" : "s"}`}
/>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -218,6 +218,16 @@ function setHistoryCache(key: string, body: string, expiresAt: number) {
// ── WebSocket clients ─────────────────────────────────────────────────────── // ── WebSocket clients ───────────────────────────────────────────────────────
const clients = new Set<ServerWebSocket<WsData>>(); const clients = new Set<ServerWebSocket<WsData>>();
const viewerVoters = new Set<ServerWebSocket<WsData>>();
let viewerVoteBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleViewerVoteBroadcast() {
if (viewerVoteBroadcastTimer) return;
viewerVoteBroadcastTimer = setTimeout(() => {
viewerVoteBroadcastTimer = null;
broadcast();
}, 5_000);
}
function getClientState() { function getClientState() {
return { return {
@@ -593,8 +603,24 @@ const server = Bun.serve<WsData>({
// Notify everyone else with just the viewer count // Notify everyone else with just the viewer count
broadcastViewerCount(); broadcastViewerCount();
}, },
message(_ws, _message) { message(ws, message) {
// Spectator-only, no client messages handled 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) { close(ws) {
clients.delete(ws); clients.delete(ws);
@@ -634,6 +660,8 @@ log("INFO", "server", `Web server started on port ${server.port}`, {
// ── Start game ────────────────────────────────────────────────────────────── // ── Start game ──────────────────────────────────────────────────────────────
runGame(runs, gameState, broadcast).then(() => { runGame(runs, gameState, broadcast, () => {
viewerVoters.clear();
}).then(() => {
console.log(`\n✅ Game complete! Log: ${LOG_FILE}`); console.log(`\n✅ Game complete! Log: ${LOG_FILE}`);
}); });