diff --git a/.gitignore b/.gitignore
index a14702c..66edbf5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,3 +32,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config
.DS_Store
+*.sqlite
diff --git a/db.ts b/db.ts
new file mode 100644
index 0000000..91778ba
--- /dev/null
+++ b/db.ts
@@ -0,0 +1,32 @@
+import { Database } from "bun:sqlite";
+import type { RoundState } from "./game.ts";
+
+export const db = new Database("quipslop.sqlite", { create: true });
+
+db.exec(`
+ CREATE TABLE IF NOT EXISTS rounds (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ num INTEGER,
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
+ data TEXT
+ );
+`);
+
+export function saveRound(round: RoundState) {
+ const insert = db.prepare("INSERT INTO rounds (num, data) VALUES ($num, $data)");
+ insert.run({ $num: round.num, $data: JSON.stringify(round) });
+}
+
+export function getRounds(page: number = 1, limit: number = 10) {
+ const offset = (page - 1) * limit;
+ const countQuery = db.query("SELECT COUNT(*) as count FROM rounds").get() as { count: number };
+ const rows = db.query("SELECT data FROM rounds ORDER BY id DESC LIMIT $limit OFFSET $offset")
+ .all({ $limit: limit, $offset: offset }) as { data: string }[];
+ return {
+ rounds: rows.map(r => JSON.parse(r.data) as RoundState),
+ total: countQuery.count,
+ page,
+ limit,
+ totalPages: Math.ceil(countQuery.count / limit)
+ };
+}
diff --git a/frontend.css b/frontend.css
index c4f0d59..81a3d8b 100644
--- a/frontend.css
+++ b/frontend.css
@@ -93,13 +93,38 @@ body {
.sidebar__section {
display: flex;
flex-direction: column;
+}
+
+.sidebar__section--standings {
+ flex: 1;
+ min-height: 0;
+}
+
+.sidebar__section--link {
+ border-top: 1px solid var(--border);
+ padding: 24px 32px;
flex-shrink: 0;
}
-.sidebar__section--history {
- flex: 1;
- border-top: 1px solid var(--border);
- min-height: 0;
+.history-link {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px 20px;
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ color: var(--text);
+ text-decoration: none;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 13px;
+ font-weight: 700;
+ transition: all 0.2s;
+}
+
+.history-link:hover {
+ background: var(--border);
+ border-color: var(--border-light);
}
.sidebar__header {
@@ -117,15 +142,8 @@ body {
display: flex;
flex-direction: column;
gap: 4px;
-}
-
-.sidebar__history-list {
flex: 1;
overflow-y: auto;
- padding: 0 16px 24px;
- display: flex;
- flex-direction: column;
- gap: 8px;
}
.standing {
@@ -219,67 +237,6 @@ body {
color: var(--text-muted);
}
-/* ── Past Rounds Mini ─────────────────────────────────────────── */
-
-.past-round-mini {
- background: var(--surface-2);
- border: 1px solid var(--border);
- padding: 12px;
- border-radius: 8px;
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-
-.past-round-mini__top {
- display: flex;
- gap: 12px;
- align-items: flex-start;
-}
-
-.past-round-mini__num {
- font-family: 'JetBrains Mono', monospace;
- font-size: 12px;
- font-weight: 700;
- color: var(--text-muted);
- flex-shrink: 0;
-}
-
-.past-round-mini__prompt {
- font-family: 'Inter', sans-serif;
- font-size: 13px;
- color: var(--text);
- line-height: 1.4;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-
-.past-round-mini__winner {
- font-size: 12px;
- color: var(--text-muted);
- display: flex;
- align-items: center;
- gap: 6px;
- padding-left: 28px;
-}
-
-.small-model-name {
- font-size: 12px;
-}
-
-.small-model-name .model-logo {
- width: 14px;
- height: 14px;
-}
-
-.past-round-mini__tie {
- font-family: 'JetBrains Mono', monospace;
- font-weight: 700;
- color: var(--text-muted);
-}
-
/* ── Arena ─────────────────────────────────────────────────────── */
.arena {
diff --git a/frontend.tsx b/frontend.tsx
index 60060c3..37328ab 100644
--- a/frontend.tsx
+++ b/frontend.tsx
@@ -252,28 +252,6 @@ function Arena({ round, total }: { round: RoundState; total: number }) {
);
}
-function PastRoundMini({ round }: { round: RoundState }) {
- const [contA, contB] = round.contestants;
- let votesA = 0, votesB = 0;
- for (const v of round.votes) {
- if (v.votedFor?.name === contA.name) votesA++;
- else if (v.votedFor?.name === contB.name) votesB++;
- }
- const winner = votesA > votesB ? contA : votesB > votesA ? contB : null;
-
- return (
-
-
- R{round.num}
- "{round.prompt}"
-
-
- {winner ? <> won> : Tie}
-
-
- );
-}
-
function GameOver({ scores }: { scores: Record }) {
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
const champion = sorted[0];
@@ -350,16 +328,12 @@ function Sidebar({ scores, activeRound, completed }: { scores: Record
- {completed.length > 0 && (
-
-
PAST ROUNDS
-
- {[...completed].reverse().map(round => (
-
- ))}
-
-
- )}
+
);
}
diff --git a/game.ts b/game.ts
index cbb1eac..96e134e 100644
--- a/game.ts
+++ b/game.ts
@@ -10,7 +10,7 @@ export const MODELS = [
{ 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: "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" },
@@ -249,6 +249,8 @@ export async function callVote(
return cleaned.startsWith("A") ? "A" : "B";
}
+import { saveRound } from "./db.ts";
+
// ── Game loop ───────────────────────────────────────────────────────────────
export async function runGame(
@@ -391,6 +393,7 @@ export async function runGame(
await new Promise((r) => setTimeout(r, 2000));
// Archive round
+ saveRound(round);
state.completed = [...state.completed, round];
state.active = null;
rerender();
diff --git a/history.css b/history.css
new file mode 100644
index 0000000..ade4652
--- /dev/null
+++ b/history.css
@@ -0,0 +1,300 @@
+/* ── Base ─────────────────────────────────────────────── */
+* { margin: 0; padding: 0; box-sizing: border-box; }
+
+:root {
+ --bg: #050505;
+ --surface: #0a0a0a;
+ --surface-2: #141414;
+ --border: #222222;
+ --border-light: #333333;
+ --text: #ffffff;
+ --text-dim: #a1a1a1;
+ --text-muted: #555555;
+ --primary: #4285F4;
+}
+
+body {
+ background: var(--bg);
+ color: var(--text);
+ font-family: 'Inter', -apple-system, sans-serif;
+ font-size: 15px;
+ line-height: 1.5;
+ min-height: 100vh;
+ -webkit-font-smoothing: antialiased;
+}
+
+/* ── Header ───────────────────────────────────────────────────── */
+.header {
+ padding: 24px 48px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border-bottom: 1px solid var(--border);
+ background: var(--bg);
+ position: sticky;
+ top: 0;
+ z-index: 10;
+}
+
+.header__left {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+}
+
+.header__logo {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 24px;
+ font-weight: 700;
+ letter-spacing: -1px;
+ color: var(--text);
+ text-decoration: none;
+}
+
+.header__tagline {
+ color: var(--text-muted);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+
+.header__nav a {
+ color: var(--text-dim);
+ text-decoration: none;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 13px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ transition: color 0.2s;
+}
+
+.header__nav a:hover {
+ color: var(--text);
+}
+
+/* ── Main Layout ──────────────────────────────────────────────── */
+.main {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 64px 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 48px;
+}
+
+.page-title {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 14px;
+ font-weight: 700;
+ letter-spacing: 2px;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ border-bottom: 1px solid var(--border);
+ padding-bottom: 16px;
+ margin-bottom: 16px;
+}
+
+/* ── Loading / Error ──────────────────────────────────────────── */
+.loading, .error, .empty {
+ text-align: center;
+ padding: 64px 0;
+ font-family: 'JetBrains Mono', monospace;
+ color: var(--text-muted);
+ font-size: 14px;
+ letter-spacing: 1px;
+}
+
+.error { color: #ef4444; }
+
+/* ── History Card ─────────────────────────────────────────────── */
+.history-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 32px;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+.history-card__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 24px;
+}
+
+.history-card__prompt-section {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.history-card__prompter {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 12px;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.history-card__prompt {
+ font-family: 'DM Serif Display', serif;
+ font-size: 32px;
+ line-height: 1.2;
+ color: var(--text);
+ letter-spacing: -0.5px;
+}
+
+.history-card__meta {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 12px;
+ color: var(--text-dim);
+ text-align: right;
+ flex-shrink: 0;
+}
+
+.history-card__showdown {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 24px;
+ margin-top: 16px;
+ border-top: 1px solid var(--border);
+ padding-top: 24px;
+}
+
+.history-contestant {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 16px;
+ border-radius: 8px;
+ background: var(--surface-2);
+ border: 1px solid transparent;
+}
+
+.history-contestant--winner {
+ border-color: var(--border-light);
+ background: var(--bg);
+ box-shadow: inset 0 0 0 1px var(--border);
+}
+
+.history-contestant__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.history-contestant__winner-badge {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 10px;
+ font-weight: 700;
+ padding: 2px 6px;
+ background: var(--text);
+ color: var(--bg);
+ border-radius: 4px;
+ letter-spacing: 1px;
+}
+
+.history-contestant__answer {
+ font-family: 'DM Serif Display', serif;
+ font-size: 20px;
+ line-height: 1.3;
+ color: var(--text-dim);
+}
+
+.history-contestant--winner .history-contestant__answer {
+ color: var(--text);
+}
+
+.history-contestant__votes {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: auto;
+ padding-top: 16px;
+ border-top: 1px dashed var(--border);
+}
+
+.history-contestant__score {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--text);
+}
+
+.history-contestant__voters {
+ display: flex;
+ gap: 6px;
+}
+
+.voter-mini-logo {
+ width: 14px;
+ height: 14px;
+ border-radius: 2px;
+}
+
+/* ── Pagination ───────────────────────────────────────────────── */
+.pagination {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 16px;
+ margin-top: 32px;
+}
+
+.pagination__btn {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ color: var(--text);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 13px;
+ font-weight: 700;
+ padding: 8px 16px;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.pagination__btn:hover:not(:disabled) {
+ background: var(--surface-2);
+ border-color: var(--border-light);
+}
+
+.pagination__btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.pagination__info {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 13px;
+ color: var(--text-dim);
+}
+
+/* ── Utility ──────────────────────────────────────────────────── */
+.model-name {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-family: 'Inter', sans-serif;
+ font-weight: 600;
+ font-size: 14px;
+}
+
+.model-logo {
+ width: 16px;
+ height: 16px;
+ object-fit: contain;
+}
+
+@media (max-width: 768px) {
+ .history-card__showdown {
+ grid-template-columns: 1fr;
+ }
+}
\ No newline at end of file
diff --git a/history.html b/history.html
new file mode 100644
index 0000000..9181b5a
--- /dev/null
+++ b/history.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+ Quipslop History
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/history.tsx b/history.tsx
new file mode 100644
index 0000000..71ca0a1
--- /dev/null
+++ b/history.tsx
@@ -0,0 +1,248 @@
+import React, { useState, useEffect } from "react";
+import { createRoot } from "react-dom/client";
+import "./history.css";
+
+// ── Types ───────────────────────────────────────────────────────────────────
+
+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;
+};
+
+// ── Shared UI Utils ─────────────────────────────────────────────────────────
+
+const MODEL_COLORS: Record = {
+ "Gemini 3.1 Pro": "#4285F4",
+ "Kimi K2": "#00E599",
+ "DeepSeek 3.2": "#4D6BFE",
+ "GLM-5": "#1F63EC",
+ "GPT-5.2": "#10A37F",
+ "Opus 4.6": "#D97757",
+ "Sonnet 4.6": "#D97757",
+ "Grok 4.1": "#FFFFFF",
+};
+
+function getColor(name: string): string {
+ return MODEL_COLORS[name] ?? "#A1A1A1";
+}
+
+function getLogo(name: string): string | null {
+ if (name.includes("Gemini")) return "/assets/logos/gemini.svg";
+ if (name.includes("Kimi")) return "/assets/logos/kimi.svg";
+ if (name.includes("DeepSeek")) return "/assets/logos/deepseek.svg";
+ if (name.includes("GLM")) return "/assets/logos/glm.svg";
+ if (name.includes("GPT")) return "/assets/logos/openai.svg";
+ if (name.includes("Opus") || name.includes("Sonnet")) return "/assets/logos/claude.svg";
+ if (name.includes("Grok")) return "/assets/logos/grok.svg";
+ return null;
+}
+
+function ModelName({ model, className = "" }: { model: Model; className?: string }) {
+ const logo = getLogo(model.name);
+ const color = getColor(model.name);
+ return (
+
+ {logo &&
}
+ {model.name}
+
+ );
+}
+
+// ── Components ──────────────────────────────────────────────────────────────
+
+function HistoryContestant({
+ task,
+ votes,
+ voters
+}: {
+ task: TaskInfo;
+ votes: number;
+ voters: Model[];
+}) {
+ const color = getColor(task.model.name);
+ return (
+
+
+
+
+
+ “{task.result}”
+
+
+
+ {votes} {votes === 1 ? 'vote' : 'votes'}
+
+
+ {voters.map(v => {
+ const logo = getLogo(v.name);
+ if (!logo) return null;
+ return

;
+ })}
+
+
+
+ );
+}
+
+function HistoryCard({ round }: { round: RoundState }) {
+ const [contA, contB] = round.contestants;
+
+ let votesA = 0, votesB = 0;
+ const votersA: Model[] = [];
+ const votersB: Model[] = [];
+
+ for (const v of round.votes) {
+ if (v.votedFor?.name === contA.name) { votesA++; votersA.push(v.voter); }
+ else if (v.votedFor?.name === contB.name) { votesB++; votersB.push(v.voter); }
+ }
+
+ const isAWinner = votesA > votesB;
+ const isBWinner = votesB > votesA;
+
+ return (
+
+
+
+
+ Prompted by
+
+
+ {round.prompt}
+
+
+
+
+
+
+
+
+
+ {isAWinner &&
WINNER
}
+
+
“{round.answerTasks[0].result}”
+
+
+ {votesA} {votesA === 1 ? 'vote' : 'votes'}
+
+
+ {votersA.map(v => getLogo(v.name) &&
!})
)}
+
+
+
+
+
+
+
+ {isBWinner &&
WINNER
}
+
+
“{round.answerTasks[1].result}”
+
+
+ {votesB} {votesB === 1 ? 'vote' : 'votes'}
+
+
+ {votersB.map(v => getLogo(v.name) &&
!})
)}
+
+
+
+
+
+ );
+}
+
+// ── App ─────────────────────────────────────────────────────────────────────
+
+function App() {
+ const [rounds, setRounds] = useState([]);
+ const [page, setPage] = useState(1);
+ const [totalPages, setTotalPages] = useState(1);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ setLoading(true);
+ fetch(`/api/history?page=${page}`)
+ .then(res => res.json())
+ .then(data => {
+ setRounds(data.rounds);
+ setTotalPages(data.totalPages || 1);
+ setLoading(false);
+ })
+ .catch(err => {
+ setError(err.message);
+ setLoading(false);
+ });
+ }, [page]);
+
+ return (
+
+
+
+
+ Past Rounds
+
+ {loading ? (
+ Loading...
+ ) : error ? (
+ {error}
+ ) : rounds.length === 0 ? (
+ No past rounds found.
+ ) : (
+ <>
+
+ {rounds.map(r => (
+
+ ))}
+
+
+ {totalPages > 1 && (
+
+
+ Page {page} of {totalPages}
+
+
+ )}
+ >
+ )}
+
+
+ );
+}
+
+// ── Mount ───────────────────────────────────────────────────────────────────
+
+const root = createRoot(document.getElementById("root")!);
+root.render();
\ No newline at end of file
diff --git a/server.ts b/server.ts
index 5df4b3b..552c077 100644
--- a/server.ts
+++ b/server.ts
@@ -1,5 +1,7 @@
import type { ServerWebSocket } from "bun";
-import index from "./index.html";
+import indexHtml from "./index.html";
+import historyHtml from "./history.html";
+import { getRounds } from "./db.ts";
import {
MODELS,
LOG_FILE,
@@ -39,12 +41,13 @@ function broadcast() {
// ── Server ──────────────────────────────────────────────────────────────────
-const port = parseInt(process.env.PORT ?? "3000", 10);
+const port = parseInt(process.env.PORT ?? "5109", 10); // 5109 = SLOP
const server = Bun.serve({
port,
routes: {
- "/": index,
+ "/": indexHtml,
+ "/history": historyHtml,
},
fetch(req, server) {
const url = new URL(req.url);
@@ -53,6 +56,12 @@ const server = Bun.serve({
const file = Bun.file(path);
return new Response(file);
}
+ if (url.pathname === "/api/history") {
+ const page = parseInt(url.searchParams.get("page") || "1", 10);
+ return new Response(JSON.stringify(getRounds(page)), {
+ headers: { "Content-Type": "application/json" }
+ });
+ }
if (url.pathname === "/ws") {
const upgraded = server.upgrade(req);
if (!upgraded) {