web version

This commit is contained in:
Theo Browne
2026-02-19 22:46:41 -08:00
parent f577126081
commit bca390ae75
9 changed files with 1077 additions and 419 deletions

View File

@@ -9,10 +9,12 @@
"ai": "^6.0.94",
"ink": "^6.8.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
},
"devDependencies": {
"@types/bun": "latest",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
},
"peerDependencies": {
"typescript": "^5",
@@ -40,6 +42,8 @@
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"ai": ["ai@6.0.94", "", { "dependencies": { "@ai-sdk/gateway": "3.0.52", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/F9wh262HbK05b/5vILh38JvPiheonT+kBj1L97712E7VPchqmcx7aJuZN3QSk5Pj6knxUJLm2FFpYJI1pHXUA=="],
@@ -98,6 +102,8 @@
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="],
"restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],

262
frontend.css Normal file
View File

@@ -0,0 +1,262 @@
:root {
--bg: #0d1117;
--surface: #161b22;
--border: #30363d;
--text: #e6edf3;
--text-dim: #7d8590;
--accent: #58a6ff;
--cyan: #56d4dd;
--green: #3fb950;
--magenta: #db61a2;
--green-bright: #7ee787;
--cyan-bright: #79dafa;
--yellow: #e3b341;
--blue: #58a6ff;
--red: #f85149;
--white: #e6edf3;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: var(--bg);
color: var(--text);
font-family: "SF Mono", "Fira Code", "JetBrains Mono", "Cascadia Code", monospace;
font-size: 14px;
line-height: 1.6;
padding: 24px;
min-height: 100vh;
}
#root {
max-width: 720px;
margin: 0 auto;
}
/* ── Header ─────────────────────────────────────────────────────── */
.header {
margin-bottom: 24px;
}
.header-title {
display: inline-block;
background: #8b5cf6;
color: #000;
font-weight: 700;
font-size: 18px;
padding: 4px 12px;
letter-spacing: 1px;
}
.header-sub {
color: var(--text-dim);
margin-top: 4px;
font-size: 13px;
}
/* ── Round ──────────────────────────────────────────────────────── */
.round {
margin-bottom: 24px;
}
.round-header {
display: inline-block;
background: #1f6feb;
color: #fff;
font-weight: 700;
padding: 2px 10px;
font-size: 13px;
}
.divider {
color: var(--border);
margin: 4px 0;
user-select: none;
}
/* ── Phase badges ──────────────────────────────────────────────── */
.badge {
display: inline-block;
font-weight: 700;
padding: 1px 8px;
font-size: 12px;
margin-right: 8px;
}
.badge-prompt { background: #a855f7; color: #000; }
.badge-answers { background: #22d3ee; color: #000; }
.badge-votes { background: #eab308; color: #000; }
.badge-scores { background: #f43f5e; color: #fff; }
/* ── Phase sections ────────────────────────────────────────────── */
.phase {
margin-top: 12px;
}
.phase-row {
display: flex;
align-items: baseline;
gap: 8px;
padding: 2px 0 2px 16px;
}
.prompt-text {
color: var(--yellow);
font-weight: 700;
padding: 4px 0 4px 16px;
font-size: 15px;
}
.dim { color: var(--text-dim); }
.bold { font-weight: 700; }
.error { color: var(--red); }
.answer-text { font-weight: 700; }
.vote-arrow { color: var(--text-dim); }
/* ── Result ────────────────────────────────────────────────────── */
.round-result {
margin-top: 8px;
padding: 8px 16px;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.result-winner { font-weight: 700; }
.result-detail { color: var(--text-dim); font-size: 13px; padding-left: 16px; }
/* ── Timer ─────────────────────────────────────────────────────── */
.timer {
color: var(--text-dim);
font-size: 12px;
}
/* ── Scoreboard ────────────────────────────────────────────────── */
.scoreboard {
margin-top: 24px;
}
.scoreboard-title {
display: inline-block;
background: #a855f7;
color: #000;
font-weight: 700;
padding: 2px 10px;
font-size: 14px;
margin-bottom: 12px;
}
.score-row {
display: flex;
align-items: center;
gap: 10px;
padding: 3px 0 3px 16px;
}
.score-rank {
color: var(--text-dim);
min-width: 24px;
text-align: right;
}
.score-name {
font-weight: 700;
min-width: 160px;
}
.score-bar-track {
width: 200px;
height: 14px;
background: var(--surface);
border-radius: 2px;
overflow: hidden;
}
.score-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.4s ease;
}
.score-value {
font-weight: 700;
min-width: 40px;
}
.score-medal { font-size: 16px; }
.winner-banner {
margin-top: 12px;
padding-left: 16px;
font-size: 16px;
}
/* ── Model colors ──────────────────────────────────────────────── */
.model-gemini-3-1-pro { color: var(--cyan); }
.model-kimi-k2 { color: var(--green); }
.model-kimi-k2-5 { color: var(--magenta); }
.model-deepseek-3-2 { color: var(--green-bright); }
.model-glm-5 { color: var(--cyan-bright); }
.model-gpt-5-2 { color: var(--yellow); }
.model-opus-4-6 { color: var(--blue); }
.model-sonnet-4-6 { color: var(--red); }
.model-grok-4-1 { color: var(--white); }
/* Bar fill colors */
.bar-gemini-3-1-pro { background: var(--cyan); }
.bar-kimi-k2 { background: var(--green); }
.bar-kimi-k2-5 { background: var(--magenta); }
.bar-deepseek-3-2 { background: var(--green-bright); }
.bar-glm-5 { background: var(--cyan-bright); }
.bar-gpt-5-2 { background: var(--yellow); }
.bar-opus-4-6 { background: var(--blue); }
.bar-sonnet-4-6 { background: var(--red); }
.bar-grok-4-1 { background: var(--white); }
/* ── Connecting state ──────────────────────────────────────────── */
.connecting {
color: var(--text-dim);
padding: 40px 0;
text-align: center;
}
.connecting-dot {
animation: blink 1s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* ── Spinner ───────────────────────────────────────────────────── */
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
display: inline-block;
color: var(--text-dim);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}

274
frontend.tsx Normal file
View File

@@ -0,0 +1,274 @@
import React, { useState, useEffect, useRef } from "react";
import { createRoot } from "react-dom/client";
import "./frontend.css";
// ── Types (mirrors game.ts) ─────────────────────────────────────────────────
type Model = { id: string; name: string };
type TaskInfo = {
model: Model;
startedAt: number;
finishedAt?: number;
result?: string;
error?: string;
};
type VoteInfo = {
voter: Model;
startedAt: number;
finishedAt?: number;
votedFor?: Model;
error?: boolean;
};
type RoundState = {
num: number;
phase: "prompting" | "answering" | "voting" | "done";
prompter: Model;
promptTask: TaskInfo;
prompt?: string;
contestants: [Model, Model];
answerTasks: [TaskInfo, TaskInfo];
votes: VoteInfo[];
scoreA?: number;
scoreB?: number;
};
type GameState = {
completed: RoundState[];
active: RoundState | null;
scores: Record<string, number>;
done: boolean;
};
type ServerMessage = {
type: "state";
data: GameState;
totalRounds: number;
};
// ── Helpers ──────────────────────────────────────────────────────────────────
function modelClass(name: string): string {
return "model-" + name.toLowerCase().replace(/[\s.]+/g, "-");
}
function barClass(name: string): string {
return "bar-" + name.toLowerCase().replace(/[\s.]+/g, "-");
}
// ── Components ──────────────────────────────────────────────────────────────
function Timer({ startedAt, finishedAt }: { startedAt: number; finishedAt?: number }) {
const [now, setNow] = useState(Date.now());
useEffect(() => {
if (finishedAt) return;
const id = setInterval(() => setNow(Date.now()), 100);
return () => clearInterval(id);
}, [finishedAt]);
const elapsed = ((finishedAt ?? now) - startedAt) / 1000;
return <span className="timer">({elapsed.toFixed(1)}s)</span>;
}
function MName({ model }: { model: Model }) {
return <span className={`bold ${modelClass(model.name)}`}>{model.name}</span>;
}
function RoundView({ round, total }: { round: RoundState; total: number }) {
const [contA, contB] = round.contestants;
return (
<div className="round">
<span className="round-header">ROUND {round.num}/{total}</span>
<div className="divider">{"─".repeat(50)}</div>
{/* Prompt */}
<div className="phase">
<div className="phase-row">
<span className="badge badge-prompt">PROMPT</span>
<MName model={round.prompter} />
{!round.prompt && !round.promptTask.error && (
<span className="spinner">writing a prompt...</span>
)}
<Timer startedAt={round.promptTask.startedAt} finishedAt={round.promptTask.finishedAt} />
</div>
{round.promptTask.error && (
<div className="phase-row"><span className="error"> {round.promptTask.error}</span></div>
)}
{round.prompt && (
<div className="prompt-text">"{round.prompt}"</div>
)}
</div>
{/* Answers */}
{round.phase !== "prompting" && (
<div className="phase">
<span className="badge badge-answers">ANSWERS</span>
{round.answerTasks.map((task, i) => (
<div key={i} className="phase-row">
<MName model={task.model} />
{!task.finishedAt ? (
<span className="spinner">thinking...</span>
) : task.error ? (
<span className="error"> {task.error}</span>
) : (
<span className="answer-text">"{task.result}"</span>
)}
{task.startedAt > 0 && (
<Timer startedAt={task.startedAt} finishedAt={task.finishedAt} />
)}
</div>
))}
</div>
)}
{/* Votes */}
{(round.phase === "voting" || round.phase === "done") && (
<div className="phase">
<span className="badge badge-votes">VOTES</span>
{round.votes.map((vote, i) => (
<div key={i} className="phase-row">
<MName model={vote.voter} />
{!vote.finishedAt ? (
<span className="spinner">voting...</span>
) : vote.error || !vote.votedFor ? (
<span className="error"> failed</span>
) : (
<span><span className="vote-arrow"> </span><MName model={vote.votedFor} /></span>
)}
<Timer startedAt={vote.startedAt} finishedAt={vote.finishedAt} />
</div>
))}
</div>
)}
{/* Round result */}
{round.phase === "done" && round.scoreA !== undefined && round.scoreB !== undefined && (
<div className="round-result">
<div>
{round.scoreA > round.scoreB ? (
<span className="result-winner">
<MName model={contA} /> wins! ({round.scoreA / 100} vs {round.scoreB / 100} votes)
</span>
) : round.scoreB > round.scoreA ? (
<span className="result-winner">
<MName model={contB} /> wins! ({round.scoreB / 100} vs {round.scoreA / 100} votes)
</span>
) : (
<span className="result-winner">TIE! ({round.scoreA / 100} - {round.scoreB / 100})</span>
)}
</div>
<div className="result-detail">
<MName model={contA} /> <span className="dim">+{round.scoreA}</span>
{" | "}
<MName model={contB} /> <span className="dim">+{round.scoreB}</span>
</div>
</div>
)}
</div>
);
}
function Scoreboard({ scores }: { scores: Record<string, number> }) {
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
const maxScore = sorted[0]?.[1] || 1;
const medals = ["👑", "🥈", "🥉"];
return (
<div className="scoreboard">
<span className="scoreboard-title">FINAL SCORES</span>
{sorted.map(([name, score], i) => {
const pct = Math.round((score / maxScore) * 100);
return (
<div key={name} className="score-row">
<span className="score-rank">{i + 1}.</span>
<span className={`score-name ${modelClass(name)}`}>{name}</span>
<div className="score-bar-track">
<div className={`score-bar-fill ${barClass(name)}`} style={{ width: `${pct}%` }} />
</div>
<span className="score-value">{score}</span>
{i < 3 && <span className="score-medal">{medals[i]}</span>}
</div>
);
})}
{sorted[0] && sorted[0][1] > 0 && (
<div className="winner-banner">
🏆 <span className={`bold ${modelClass(sorted[0][0])}`}>{sorted[0][0]}</span>
<span className="bold"> is the funniest AI!</span>
</div>
)}
</div>
);
}
function App() {
const [state, setState] = useState<GameState | null>(null);
const [totalRounds, setTotalRounds] = useState(5);
const [connected, setConnected] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const wsUrl = `ws://${window.location.host}/ws`;
let ws: WebSocket;
let reconnectTimer: ReturnType<typeof setTimeout>;
function connect() {
ws = new WebSocket(wsUrl);
ws.onopen = () => setConnected(true);
ws.onclose = () => {
setConnected(false);
reconnectTimer = setTimeout(connect, 2000);
};
ws.onmessage = (e) => {
const msg: ServerMessage = JSON.parse(e.data);
if (msg.type === "state") {
setState(msg.data);
setTotalRounds(msg.totalRounds);
}
};
}
connect();
return () => {
clearTimeout(reconnectTimer);
ws?.close();
};
}, []);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [state]);
if (!connected || !state) {
return (
<div className="connecting">
<span className="connecting-dot"></span> Connecting to Quipslop...
</div>
);
}
return (
<div>
<div className="header">
<span className="header-title">QUIPSLOP</span>
<div className="header-sub">AI vs AI comedy showdown {totalRounds} rounds</div>
</div>
{state.completed.map((round) => (
<RoundView key={round.num} round={round} total={totalRounds} />
))}
{state.active && <RoundView round={state.active} total={totalRounds} />}
{state.done && <Scoreboard scores={state.scores} />}
<div ref={bottomRef} />
</div>
);
}
// ── Mount ───────────────────────────────────────────────────────────────────
const root = createRoot(document.getElementById("root")!);
root.render(<App />);

398
game.ts Normal file
View File

@@ -0,0 +1,398 @@
import { generateText } from "ai";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { mkdirSync, appendFileSync } from "node:fs";
import { join } from "node:path";
// ── Models ──────────────────────────────────────────────────────────────────
export const MODELS = [
{ id: "google/gemini-3.1-pro-preview", name: "Gemini 3.1 Pro" },
{ id: "moonshotai/kimi-k2", name: "Kimi K2" },
// { id: "moonshotai/kimi-k2.5", name: "Kimi K2.5" },
{ id: "deepseek/deepseek-v3.2", name: "DeepSeek 3.2" },
{ id: "z-ai/glm-5", name: "GLM-5" },
{ id: "openai/gpt-5.2", name: "GPT-5.2" },
{ id: "anthropic/claude-opus-4.6", name: "Opus 4.6" },
{ id: "anthropic/claude-sonnet-4.6", name: "Sonnet 4.6" },
{ id: "x-ai/grok-4.1-fast", name: "Grok 4.1" },
] as const;
export type Model = (typeof MODELS)[number];
export const MODEL_COLORS: Record<string, string> = {
"Gemini 3.1 Pro": "cyan",
"Kimi K2": "green",
"Kimi K2.5": "magenta",
"DeepSeek 3.2": "greenBright",
"GLM-5": "cyanBright",
"GPT-5.2": "yellow",
"Opus 4.6": "blue",
"Sonnet 4.6": "red",
"Grok 4.1": "white",
};
export const NAME_PAD = 16;
// ── Types ───────────────────────────────────────────────────────────────────
export type TaskInfo = {
model: Model;
startedAt: number;
finishedAt?: number;
result?: string;
error?: string;
};
export type VoteInfo = {
voter: Model;
startedAt: number;
finishedAt?: number;
votedFor?: Model;
error?: boolean;
};
export type RoundState = {
num: number;
phase: "prompting" | "answering" | "voting" | "done";
prompter: Model;
promptTask: TaskInfo;
prompt?: string;
contestants: [Model, Model];
answerTasks: [TaskInfo, TaskInfo];
votes: VoteInfo[];
scoreA?: number;
scoreB?: number;
};
export type GameState = {
completed: RoundState[];
active: RoundState | null;
scores: Record<string, number>;
done: boolean;
};
// ── OpenRouter ──────────────────────────────────────────────────────────────
const openrouter = createOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
});
// ── Logger ──────────────────────────────────────────────────────────────────
const LOGS_DIR = join(import.meta.dir, "logs");
mkdirSync(LOGS_DIR, { recursive: true });
const LOG_FILE = join(
LOGS_DIR,
`game-${new Date().toISOString().replace(/[:.]/g, "-")}.log`,
);
export { LOG_FILE };
export function log(
level: "INFO" | "WARN" | "ERROR",
category: string,
message: string,
data?: Record<string, unknown>,
) {
const ts = new Date().toISOString();
let line = `[${ts}] ${level} [${category}] ${message}`;
if (data) {
line += "\n " + JSON.stringify(data, null, 2).replace(/\n/g, "\n ");
}
appendFileSync(LOG_FILE, line + "\n");
}
// ── Helpers ─────────────────────────────────────────────────────────────────
export function shuffle<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j]!, a[i]!];
}
return a;
}
export async function withRetry<T>(
fn: () => Promise<T>,
validate: (result: T) => boolean,
retries = 3,
label = "unknown",
): Promise<T> {
let lastErr: unknown;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const result = await fn();
if (validate(result)) {
log("INFO", label, `Success on attempt ${attempt}`, {
result: typeof result === "string" ? result : String(result),
});
return result;
}
const msg = `Validation failed (attempt ${attempt}/${retries})`;
log("WARN", label, msg, {
result: typeof result === "string" ? result : String(result),
});
lastErr = new Error(`${msg}: ${JSON.stringify(result).slice(0, 100)}`);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
log("WARN", label, `Error on attempt ${attempt}/${retries}: ${errMsg}`, {
error: errMsg,
stack: err instanceof Error ? err.stack : undefined,
});
lastErr = err;
}
if (attempt < retries) {
await new Promise((r) => setTimeout(r, 1000 * attempt));
}
}
log("ERROR", label, `All ${retries} attempts failed`, {
lastError: lastErr instanceof Error ? lastErr.message : String(lastErr),
});
throw lastErr;
}
export function isRealString(s: string, minLength = 5): boolean {
return s.length >= minLength;
}
export function cleanResponse(text: string): string {
return text.trim().replace(/^["']|["']$/g, "");
}
// ── AI functions ────────────────────────────────────────────────────────────
const PROMPT_SYSTEM = `You are a comedy writer for the game Quiplash. Generate a single funny fill-in-the-blank prompt that players will try to answer. The prompt should be surprising and designed to elicit hilarious responses. Return ONLY the prompt text, nothing else. Keep it short (under 15 words).
Use a wide VARIETY of prompt formats. Do NOT always use "The worst thing to..." — mix it up! Here are examples of the range of styles:
- The worst thing to hear from your GPS
- A terrible name for a dog
- A rejected name for a new fast food restaurant
- The worst thing to hear during surgery
- A bad name for a superhero
- A terrible name for a new perfume
- The worst thing to find in your sandwich
- A rejected slogan for a toothpaste brand
- The worst thing to say during a job interview
- A bad name for a country
- The worst thing to say when meeting your partner's parents
- A terrible name for a retirement home
- A rejected title for a romantic comedy
- The world's least popular ice cream flavor
- A terrible fortune cookie message
- What you don't want to hear from your dentist
- The worst name for a band
- A rejected Hallmark card message
- Something you shouldn't yell in a library
- The least intimidating martial arts move
Come up with something ORIGINAL — don't copy these examples.`;
export async function callGeneratePrompt(model: Model): Promise<string> {
log("INFO", `prompt:${model.name}`, "Calling API", { modelId: model.id });
const { text, usage } = await generateText({
model: openrouter.chat(model.id),
system: PROMPT_SYSTEM,
prompt:
"Generate a single original Quiplash prompt. Be creative and don't repeat common patterns.",
});
log("INFO", `prompt:${model.name}`, "Raw response", {
rawText: text,
usage,
});
return cleanResponse(text);
}
export async function callGenerateAnswer(
model: Model,
prompt: string,
): Promise<string> {
log("INFO", `answer:${model.name}`, "Calling API", {
modelId: model.id,
prompt,
});
const { text, usage } = await generateText({
model: openrouter.chat(model.id),
system: `You are playing Quiplash! You'll be given a fill-in-the-blank prompt. Give the FUNNIEST possible answer. Be creative, edgy, unexpected, and concise. Reply with ONLY your answer — no quotes, no explanation, no preamble. Keep it short (under 12 words). Keep it concise and witty.`,
prompt: `Fill in the blank: ${prompt}`,
});
log("INFO", `answer:${model.name}`, "Raw response", {
rawText: text,
usage,
});
return cleanResponse(text);
}
export async function callVote(
voter: Model,
prompt: string,
a: { answer: string },
b: { answer: string },
): Promise<"A" | "B"> {
log("INFO", `vote:${voter.name}`, "Calling API", {
modelId: voter.id,
prompt,
answerA: a.answer,
answerB: b.answer,
});
const { text, usage } = await generateText({
model: openrouter.chat(voter.id),
system: `You are a judge in a comedy game. You'll see a fill-in-the-blank prompt and two answers. Pick which answer is FUNNIER. You MUST respond with exactly "A" or "B" — nothing else.`,
prompt: `Prompt: "${prompt}"\n\nAnswer A: "${a.answer}"\nAnswer B: "${b.answer}"\n\nWhich is funnier? Reply with just A or B.`,
});
log("INFO", `vote:${voter.name}`, "Raw response", { rawText: text, usage });
const cleaned = text.trim().toUpperCase();
if (!cleaned.startsWith("A") && !cleaned.startsWith("B")) {
throw new Error(`Invalid vote: "${text.trim()}"`);
}
return cleaned.startsWith("A") ? "A" : "B";
}
// ── Game loop ───────────────────────────────────────────────────────────────
export async function runGame(
runs: number,
state: GameState,
rerender: () => void,
) {
for (let r = 1; r <= runs; r++) {
const shuffled = shuffle([...MODELS]);
const prompter = shuffled[0]!;
const contA = shuffled[1]!;
const contB = shuffled[2]!;
const voters = shuffled.slice(3);
const now = Date.now();
const round: RoundState = {
num: r,
phase: "prompting",
prompter,
promptTask: { model: prompter, startedAt: now },
contestants: [contA, contB],
answerTasks: [
{ model: contA, startedAt: 0 },
{ model: contB, startedAt: 0 },
],
votes: [],
};
state.active = round;
log("INFO", "round", `=== Round ${r}/${runs} ===`, {
prompter: prompter.name,
contestants: [contA.name, contB.name],
voters: voters.map((v) => v.name),
});
rerender();
// ── Prompt phase ──
try {
const prompt = await withRetry(
() => callGeneratePrompt(prompter),
(s) => isRealString(s, 10),
3,
`R${r}:prompt:${prompter.name}`,
);
round.promptTask.finishedAt = Date.now();
round.promptTask.result = prompt;
round.prompt = prompt;
rerender();
} catch {
round.promptTask.finishedAt = Date.now();
round.promptTask.error = "Failed after 3 attempts";
round.phase = "done";
state.completed = [...state.completed, round];
state.active = null;
rerender();
continue;
}
// ── Answer phase ──
round.phase = "answering";
const answerStart = Date.now();
round.answerTasks[0].startedAt = answerStart;
round.answerTasks[1].startedAt = answerStart;
rerender();
await Promise.all(
round.answerTasks.map(async (task) => {
try {
const answer = await withRetry(
() => callGenerateAnswer(task.model, round.prompt!),
(s) => isRealString(s, 3),
3,
`R${r}:answer:${task.model.name}`,
);
task.result = answer;
} catch {
task.error = "Failed to answer";
task.result = "[no answer]";
}
task.finishedAt = Date.now();
rerender();
}),
);
// ── Vote phase ──
round.phase = "voting";
const answerA = round.answerTasks[0].result!;
const answerB = round.answerTasks[1].result!;
const voteStart = Date.now();
round.votes = voters.map((v) => ({ voter: v, startedAt: voteStart }));
rerender();
await Promise.all(
round.votes.map(async (vote) => {
try {
const showAFirst = Math.random() > 0.5;
const first = showAFirst ? { answer: answerA } : { answer: answerB };
const second = showAFirst ? { answer: answerB } : { answer: answerA };
const result = await withRetry(
() => callVote(vote.voter, round.prompt!, first, second),
(v) => v === "A" || v === "B",
3,
`R${r}:vote:${vote.voter.name}`,
);
const votedFor = showAFirst
? result === "A"
? contA
: contB
: result === "A"
? contB
: contA;
vote.finishedAt = Date.now();
vote.votedFor = votedFor;
} catch {
vote.finishedAt = Date.now();
vote.error = true;
}
rerender();
}),
);
// ── Score ──
let votesA = 0;
let votesB = 0;
for (const v of round.votes) {
if (v.votedFor === contA) votesA++;
else if (v.votedFor === contB) votesB++;
}
round.scoreA = votesA * 100;
round.scoreB = votesB * 100;
round.phase = "done";
state.scores[contA.name] = (state.scores[contA.name] || 0) + round.scoreA;
state.scores[contB.name] = (state.scores[contB.name] || 0) + round.scoreB;
rerender();
await new Promise((r) => setTimeout(r, 2000));
// Archive round
state.completed = [...state.completed, round];
state.active = null;
rerender();
}
state.done = true;
rerender();
}

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Quipslop — AI vs AI Comedy Showdown</title>
<link rel="stylesheet" href="./frontend.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>

View File

@@ -3,9 +3,14 @@
"module": "index.ts",
"type": "module",
"private": true,
"scripts": {
"start:cli": "bun quipslop.tsx",
"start:web": "bun --hot server.ts"
},
"devDependencies": {
"@types/bun": "latest",
"@types/react": "^19.2.14"
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3"
},
"peerDependencies": {
"typescript": "^5"
@@ -14,6 +19,7 @@
"@openrouter/ai-sdk-provider": "^2.2.3",
"ai": "^6.0.94",
"ink": "^6.8.0",
"react": "^19.2.4"
"react": "^19.2.4",
"react-dom": "^19.2.4"
}
}

View File

@@ -1,406 +1,18 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { render, Box, Text, Static, useApp } from "ink";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { generateText } from "ai";
import { mkdirSync, appendFileSync } from "node:fs";
import { join } from "node:path";
// ── Models ──────────────────────────────────────────────────────────────────
const MODELS = [
{ id: "google/gemini-3.1-pro-preview", name: "Gemini 3.1 Pro" },
{ id: "moonshotai/kimi-k2", name: "Kimi K2" },
// { id: "moonshotai/kimi-k2.5", name: "Kimi K2.5" },
{ id: "deepseek/deepseek-v3.2", name: "DeepSeek 3.2" },
{ id: "z-ai/glm-5", name: "GLM-5" },
{ id: "openai/gpt-5.2", name: "GPT-5.2" },
{ id: "anthropic/claude-opus-4.6", name: "Opus 4.6" },
{ id: "anthropic/claude-sonnet-4.6", name: "Sonnet 4.6" },
{ id: "x-ai/grok-4.1-fast", name: "Grok 4.1" },
] as const;
type Model = (typeof MODELS)[number];
const MODEL_COLORS: Record<string, string> = {
"Gemini 3.1 Pro": "cyan",
"Kimi K2": "green",
"Kimi K2.5": "magenta",
"DeepSeek 3.2": "greenBright",
"GLM-5": "cyanBright",
"GPT-5.2": "yellow",
"Opus 4.6": "blue",
"Sonnet 4.6": "red",
"Grok 4.1": "white",
};
const NAME_PAD = 16;
// ── OpenRouter ──────────────────────────────────────────────────────────────
const openrouter = createOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
});
// ── Types ───────────────────────────────────────────────────────────────────
type TaskInfo = {
model: Model;
startedAt: number;
finishedAt?: number;
result?: string;
error?: string;
};
type VoteInfo = {
voter: Model;
startedAt: number;
finishedAt?: number;
votedFor?: Model;
error?: boolean;
};
type RoundState = {
num: number;
phase: "prompting" | "answering" | "voting" | "done";
prompter: Model;
promptTask: TaskInfo;
prompt?: string;
contestants: [Model, Model];
answerTasks: [TaskInfo, TaskInfo];
votes: VoteInfo[];
scoreA?: number;
scoreB?: number;
};
type GameState = {
completed: RoundState[];
active: RoundState | null;
scores: Record<string, number>;
done: boolean;
};
// ── Logger ──────────────────────────────────────────────────────────────────
const LOGS_DIR = join(import.meta.dir, "logs");
mkdirSync(LOGS_DIR, { recursive: true });
const LOG_FILE = join(
LOGS_DIR,
`game-${new Date().toISOString().replace(/[:.]/g, "-")}.log`,
);
function log(
level: "INFO" | "WARN" | "ERROR",
category: string,
message: string,
data?: Record<string, unknown>,
) {
const ts = new Date().toISOString();
let line = `[${ts}] ${level} [${category}] ${message}`;
if (data) {
line += "\n " + JSON.stringify(data, null, 2).replace(/\n/g, "\n ");
}
appendFileSync(LOG_FILE, line + "\n");
}
// ── Helpers ─────────────────────────────────────────────────────────────────
function shuffle<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j]!, a[i]!];
}
return a;
}
async function withRetry<T>(
fn: () => Promise<T>,
validate: (result: T) => boolean,
retries = 3,
label = "unknown",
): Promise<T> {
let lastErr: unknown;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const result = await fn();
if (validate(result)) {
log("INFO", label, `Success on attempt ${attempt}`, {
result: typeof result === "string" ? result : String(result),
});
return result;
}
const msg = `Validation failed (attempt ${attempt}/${retries})`;
log("WARN", label, msg, {
result: typeof result === "string" ? result : String(result),
});
lastErr = new Error(`${msg}: ${JSON.stringify(result).slice(0, 100)}`);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
log("WARN", label, `Error on attempt ${attempt}/${retries}: ${errMsg}`, {
error: errMsg,
stack: err instanceof Error ? err.stack : undefined,
});
lastErr = err;
}
if (attempt < retries) {
await new Promise((r) => setTimeout(r, 1000 * attempt));
}
}
log("ERROR", label, `All ${retries} attempts failed`, {
lastError: lastErr instanceof Error ? lastErr.message : String(lastErr),
});
throw lastErr;
}
// Minimum length for a real response (not junk like "The" or "")
function isRealString(s: string, minLength = 5): boolean {
return s.length >= minLength;
}
function cleanResponse(text: string): string {
return text.trim().replace(/^["']|["']$/g, "");
}
// ── AI functions ────────────────────────────────────────────────────────────
const PROMPT_SYSTEM = `You are a comedy writer for the game Quiplash. Generate a single funny fill-in-the-blank prompt that players will try to answer. The prompt should be surprising and designed to elicit hilarious responses. Return ONLY the prompt text, nothing else. Keep it short (under 15 words).
Use a wide VARIETY of prompt formats. Do NOT always use "The worst thing to..." — mix it up! Here are examples of the range of styles:
- The worst thing to hear from your GPS
- A terrible name for a dog
- A rejected name for a new fast food restaurant
- The worst thing to hear during surgery
- A bad name for a superhero
- A terrible name for a new perfume
- The worst thing to find in your sandwich
- A rejected slogan for a toothpaste brand
- The worst thing to say during a job interview
- A bad name for a country
- The worst thing to say when meeting your partner's parents
- A terrible name for a retirement home
- A rejected title for a romantic comedy
- The world's least popular ice cream flavor
- A terrible fortune cookie message
- What you don't want to hear from your dentist
- The worst name for a band
- A rejected Hallmark card message
- Something you shouldn't yell in a library
- The least intimidating martial arts move
Come up with something ORIGINAL — don't copy these examples.`;
async function callGeneratePrompt(model: Model): Promise<string> {
log("INFO", `prompt:${model.name}`, "Calling API", { modelId: model.id });
const { text, usage } = await generateText({
model: openrouter.chat(model.id),
system: PROMPT_SYSTEM,
prompt:
"Generate a single original Quiplash prompt. Be creative and don't repeat common patterns.",
// temperature: 1.2,
// maxOutputTokens: 80,
});
log("INFO", `prompt:${model.name}`, "Raw response", {
rawText: text,
usage,
});
return cleanResponse(text);
}
async function callGenerateAnswer(
model: Model,
prompt: string,
): Promise<string> {
log("INFO", `answer:${model.name}`, "Calling API", {
modelId: model.id,
prompt,
});
const { text, usage } = await generateText({
model: openrouter.chat(model.id),
system: `You are playing Quiplash! You'll be given a fill-in-the-blank prompt. Give the FUNNIEST possible answer. Be creative, edgy, unexpected, and concise. Reply with ONLY your answer — no quotes, no explanation, no preamble. Keep it short (under 12 words).`,
prompt: `Fill in the blank: ${prompt}`,
// temperature: 1.2,
// maxOutputTokens: 60,
});
log("INFO", `answer:${model.name}`, "Raw response", {
rawText: text,
usage,
});
return cleanResponse(text);
}
async function callVote(
voter: Model,
prompt: string,
a: { answer: string },
b: { answer: string },
): Promise<"A" | "B"> {
log("INFO", `vote:${voter.name}`, "Calling API", {
modelId: voter.id,
prompt,
answerA: a.answer,
answerB: b.answer,
});
const { text, usage } = await generateText({
model: openrouter.chat(voter.id),
system: `You are a judge in a comedy game. You'll see a fill-in-the-blank prompt and two answers. Pick which answer is FUNNIER. You MUST respond with exactly "A" or "B" — nothing else.`,
prompt: `Prompt: "${prompt}"\n\nAnswer A: "${a.answer}"\nAnswer B: "${b.answer}"\n\nWhich is funnier? Reply with just A or B.`,
// temperature: 0.3,
// maxOutputTokens: 5,
});
log("INFO", `vote:${voter.name}`, "Raw response", { rawText: text, usage });
const cleaned = text.trim().toUpperCase();
if (!cleaned.startsWith("A") && !cleaned.startsWith("B")) {
throw new Error(`Invalid vote: "${text.trim()}"`);
}
return cleaned.startsWith("A") ? "A" : "B";
}
// ── Game loop ───────────────────────────────────────────────────────────────
async function runGame(runs: number, state: GameState, rerender: () => void) {
for (let r = 1; r <= runs; r++) {
const shuffled = shuffle([...MODELS]);
const prompter = shuffled[0]!;
const contA = shuffled[1]!;
const contB = shuffled[2]!;
const voters = shuffled.slice(3);
const now = Date.now();
// Initialize round
const round: RoundState = {
num: r,
phase: "prompting",
prompter,
promptTask: { model: prompter, startedAt: now },
contestants: [contA, contB],
answerTasks: [
{ model: contA, startedAt: 0 },
{ model: contB, startedAt: 0 },
],
votes: [],
};
state.active = round;
log("INFO", "round", `=== Round ${r}/${runs} ===`, {
prompter: prompter.name,
contestants: [contA.name, contB.name],
voters: voters.map((v) => v.name),
});
rerender();
// ── Prompt phase ──
try {
const prompt = await withRetry(
() => callGeneratePrompt(prompter),
(s) => isRealString(s, 10),
3,
`R${r}:prompt:${prompter.name}`,
);
round.promptTask.finishedAt = Date.now();
round.promptTask.result = prompt;
round.prompt = prompt;
rerender();
} catch {
round.promptTask.finishedAt = Date.now();
round.promptTask.error = "Failed after 3 attempts";
round.phase = "done";
state.completed = [...state.completed, round];
state.active = null;
rerender();
continue;
}
// ── Answer phase ──
round.phase = "answering";
const answerStart = Date.now();
round.answerTasks[0].startedAt = answerStart;
round.answerTasks[1].startedAt = answerStart;
rerender();
await Promise.all(
round.answerTasks.map(async (task) => {
try {
const answer = await withRetry(
() => callGenerateAnswer(task.model, round.prompt!),
(s) => isRealString(s, 3),
3,
`R${r}:answer:${task.model.name}`,
);
task.result = answer;
} catch {
task.error = "Failed to answer";
task.result = "[no answer]";
}
task.finishedAt = Date.now();
rerender();
}),
);
// ── Vote phase ──
round.phase = "voting";
const answerA = round.answerTasks[0].result!;
const answerB = round.answerTasks[1].result!;
const voteStart = Date.now();
round.votes = voters.map((v) => ({ voter: v, startedAt: voteStart }));
rerender();
await Promise.all(
round.votes.map(async (vote) => {
try {
const showAFirst = Math.random() > 0.5;
const first = showAFirst ? { answer: answerA } : { answer: answerB };
const second = showAFirst ? { answer: answerB } : { answer: answerA };
const result = await withRetry(
() => callVote(vote.voter, round.prompt!, first, second),
(v) => v === "A" || v === "B",
3,
`R${r}:vote:${vote.voter.name}`,
);
const votedFor = showAFirst
? result === "A"
? contA
: contB
: result === "A"
? contB
: contA;
vote.finishedAt = Date.now();
vote.votedFor = votedFor;
} catch {
vote.finishedAt = Date.now();
vote.error = true;
}
rerender();
}),
);
// ── Score ──
let votesA = 0;
let votesB = 0;
for (const v of round.votes) {
if (v.votedFor === contA) votesA++;
else if (v.votedFor === contB) votesB++;
}
round.scoreA = votesA * 100;
round.scoreB = votesB * 100;
round.phase = "done";
state.scores[contA.name] = (state.scores[contA.name] || 0) + round.scoreA;
state.scores[contB.name] = (state.scores[contB.name] || 0) + round.scoreB;
rerender();
// Brief pause so the user can see the result
await new Promise((r) => setTimeout(r, 2000));
// Archive round
state.completed = [...state.completed, round];
state.active = null;
rerender();
}
state.done = true;
rerender();
}
import {
MODELS,
MODEL_COLORS,
NAME_PAD,
LOG_FILE,
log,
runGame,
type Model,
type TaskInfo,
type VoteInfo,
type RoundState,
type GameState,
} from "./game.ts";
// ── Components ──────────────────────────────────────────────────────────────
@@ -437,7 +49,7 @@ function RoundView({ round, total }: { round: RoundState; total: number }) {
<Box flexDirection="column" marginBottom={1}>
{/* Header */}
<Box>
<Text bold inverse backgroundColor="blue">
<Text bold backgroundColor="blueBright" color="black">
{` ROUND ${round.num}/${total} `}
</Text>
</Box>
@@ -445,7 +57,7 @@ function RoundView({ round, total }: { round: RoundState; total: number }) {
{/* Prompt */}
<Box marginTop={1} gap={1}>
<Text bold inverse backgroundColor="magenta">
<Text bold backgroundColor="magentaBright" color="black">
{" PROMPT "}
</Text>
<MName model={round.prompter} />
@@ -471,7 +83,7 @@ function RoundView({ round, total }: { round: RoundState; total: number }) {
{/* Answers */}
{round.phase !== "prompting" && (
<Box flexDirection="column" marginTop={1}>
<Text bold inverse backgroundColor="cyan">
<Text bold backgroundColor="cyanBright" color="black">
{" ANSWERS "}
</Text>
{round.answerTasks.map((task, i) => (
@@ -498,7 +110,7 @@ function RoundView({ round, total }: { round: RoundState; total: number }) {
{/* Votes */}
{(round.phase === "voting" || round.phase === "done") && (
<Box flexDirection="column" marginTop={1}>
<Text bold inverse backgroundColor="yellow" color="red">
<Text bold backgroundColor="yellowBright" color="black">
{" VOTES "}
</Text>
{round.votes.map((vote, i) => (
@@ -568,7 +180,7 @@ function Scoreboard({ scores }: { scores: Record<string, number> }) {
return (
<Box flexDirection="column" marginTop={1}>
<Text bold inverse backgroundColor="magenta">
<Text bold backgroundColor="magentaBright" color="black">
{" FINAL SCORES "}
</Text>
<Box flexDirection="column" marginTop={1}>
@@ -625,16 +237,6 @@ function Game({ runs }: { runs: number }) {
return (
<Box flexDirection="column">
<Box marginBottom={1} gap={1}>
<Text bold inverse backgroundColor="magenta">
{" QUIPSLOP "}
</Text>
<Text dimColor>AI vs AI comedy showdown {runs} rounds</Text>
</Box>
<Box marginBottom={1}>
<Text dimColor>Models: {MODELS.map((m) => m.name).join(", ")}</Text>
</Box>
<Static items={state.completed}>
{(round: RoundState) => (
<RoundView key={round.num} round={round} total={runs} />
@@ -667,4 +269,11 @@ log("INFO", "startup", `Game starting: ${runs} rounds`, {
models: MODELS.map((m) => m.id),
});
console.log(
`\n\x1b[1m\x1b[45m\x1b[30m QUIPSLOP \x1b[0m \x1b[2mAI vs AI comedy showdown — ${runs} rounds\x1b[0m`,
);
console.log(
`\x1b[2mModels: ${MODELS.map((m) => m.name).join(", ")}\x1b[0m\n`,
);
render(<Game runs={runs} />);

90
server.ts Normal file
View File

@@ -0,0 +1,90 @@
import type { ServerWebSocket } from "bun";
import index from "./index.html";
import {
MODELS,
LOG_FILE,
log,
runGame,
type GameState,
} from "./game.ts";
// ── Game state ──────────────────────────────────────────────────────────────
const runsArg = process.argv.find((a) => a.startsWith("runs="));
const runs = runsArg ? parseInt(runsArg.split("=")[1] ?? "5", 10) : 5;
if (!process.env.OPENROUTER_API_KEY) {
console.error("Error: Set OPENROUTER_API_KEY environment variable");
process.exit(1);
}
const gameState: GameState = {
completed: [],
active: null,
scores: Object.fromEntries(MODELS.map((m) => [m.name, 0])),
done: false,
};
// ── WebSocket clients ───────────────────────────────────────────────────────
const clients = new Set<ServerWebSocket<unknown>>();
function broadcast() {
const msg = JSON.stringify({ type: "state", data: gameState, totalRounds: runs });
for (const ws of clients) {
ws.send(msg);
}
}
// ── Server ──────────────────────────────────────────────────────────────────
const port = parseInt(process.env.PORT ?? "3000", 10);
const server = Bun.serve({
port,
routes: {
"/": index,
},
fetch(req, server) {
const url = new URL(req.url);
if (url.pathname === "/ws") {
const upgraded = server.upgrade(req);
if (!upgraded) {
return new Response("WebSocket upgrade failed", { status: 400 });
}
return undefined;
}
return new Response("Not found", { status: 404 });
},
websocket: {
open(ws) {
clients.add(ws);
ws.send(JSON.stringify({ type: "state", data: gameState, totalRounds: runs }));
},
message(_ws, _message) {
// Spectator-only, no client messages handled
},
close(ws) {
clients.delete(ws);
},
},
development: {
hmr: true,
console: true,
},
});
console.log(`\n🎮 Quipslop Web — http://localhost:${server.port}`);
console.log(`📡 WebSocket — ws://localhost:${server.port}/ws`);
console.log(`🎯 ${runs} rounds with ${MODELS.length} models\n`);
log("INFO", "server", `Web server started on port ${server.port}`, {
runs,
models: MODELS.map((m) => m.id),
});
// ── Start game ──────────────────────────────────────────────────────────────
runGame(runs, gameState, broadcast).then(() => {
console.log(`\n✅ Game complete! Log: ${LOG_FILE}`);
});

View File

@@ -1,7 +1,7 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",