diff --git a/README.md b/README.md
index 00a41b5..218191f 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,60 @@
-# Quipslop
+# argument.es
-Streamed live on [twitch.tv/quipslop](https://twitch.tv/quipslop).
+Un juego de comedia en el que modelos de IA compiten respondiendo preguntas de rellena-el-espacio al estilo Quiplash — todo en español.
+
+Cada ronda, un modelo genera una pregunta, dos modelos compiten respondiendo, y el resto votan por la más graciosa. El público también puede votar directamente desde la web.
+
+## Modelos participantes
+
+- Gemini 3.1 Pro
+- Kimi K2
+- DeepSeek 3.2
+- GPT-5.2
+- Claude Opus 4.6
+- Claude Sonnet 4.6
+- Grok 4.1
+
+## Inicio rápido
+
+```bash
+cp .env.sample .env
+# Edita .env y añade tu OPENROUTER_API_KEY y ADMIN_SECRET
+bun install
+bun start
+```
+
+Abre [http://localhost:5109](http://localhost:5109).
+
+## Docker
+
+```bash
+cp .env.sample .env
+# Edita .env
+docker compose up -d
+```
+
+La base de datos SQLite se persiste en un volumen Docker (`argumentes_data`).
+
+## Variables de entorno
+
+| Variable | Obligatoria | Por defecto | Descripción |
+|---|---|---|---|
+| `OPENROUTER_API_KEY` | ✅ | — | Clave de API de OpenRouter |
+| `ADMIN_SECRET` | ✅ | — | Contraseña del panel de administración |
+| `PORT` | | `5109` | Puerto del servidor |
+| `DATABASE_PATH` | | `argumentes.sqlite` | Ruta al archivo SQLite |
+
+Ver `.env.sample` para todas las opciones.
+
+## Panel de administración
+
+Disponible en `/admin`. Funcionalidades:
+
+- **Pausar / Reanudar** el bucle de juego
+- **Exportar** todas las rondas como JSON
+- **Borrar** todos los datos (requiere confirmación)
+- **Estado** del servidor en tiempo real
+
+## Cómo funcionan las preguntas
+
+El array `ALL_PROMPTS` en `prompts.ts` sirve únicamente como **guía de estilo**. En cada ronda, se seleccionan 80 preguntas aleatorias del array y se pasan al modelo como ejemplos. El modelo genera siempre una pregunta completamente **original** — la lista nunca se agota.
diff --git a/admin.tsx b/admin.tsx
index 355ea46..e1ca6d4 100644
--- a/admin.tsx
+++ b/admin.tsx
@@ -133,7 +133,7 @@ function App() {
const blob = await response.blob();
const disposition = response.headers.get("content-disposition") ?? "";
const fileNameMatch = disposition.match(/filename="([^"]+)"/i);
- const fileName = fileNameMatch?.[1] ?? `quipslop-export-${Date.now()}.json`;
+ const fileName = fileNameMatch?.[1] ?? `argumentes-export-${Date.now()}.json`;
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
diff --git a/check-db.ts b/check-db.ts
index 0d2d0a1..6c95c10 100644
--- a/check-db.ts
+++ b/check-db.ts
@@ -1,4 +1,4 @@
import { Database } from "bun:sqlite";
-const db = new Database("quipslop.sqlite");
+const db = new Database("argumentes.sqlite");
const rows = db.query("SELECT id, num FROM rounds ORDER BY id DESC LIMIT 20").all();
console.log(rows);
diff --git a/frontend.tsx b/frontend.tsx
index 709520f..bdfe4c4 100644
--- a/frontend.tsx
+++ b/frontend.tsx
@@ -473,9 +473,6 @@ function Standings({
Historial
-
- Twitch
-
Web
diff --git a/package.json b/package.json
index 9fbba72..d5cdc40 100644
--- a/package.json
+++ b/package.json
@@ -5,10 +5,7 @@
"private": true,
"scripts": {
"start": "bun server.ts",
- "start:cli": "bun quipslop.tsx",
- "start:web": "bun --hot server.ts",
- "start:stream": "bun ./scripts/stream-browser.ts live",
- "start:stream:dryrun": "bun ./scripts/stream-browser.ts dryrun"
+ "start:dev": "bun --hot server.ts"
},
"devDependencies": {
"@types/bun": "latest",
diff --git a/scripts/stream-browser.ts b/scripts/stream-browser.ts
deleted file mode 100644
index 325a2c0..0000000
--- a/scripts/stream-browser.ts
+++ /dev/null
@@ -1,385 +0,0 @@
-import puppeteer from "puppeteer";
-
-type Mode = "live" | "dryrun";
-
-type SinkWriter = {
- write(chunk: Uint8Array): number;
- end(error?: Error): number;
-};
-
-function parsePositiveInt(value: string | undefined, fallback: number): number {
- const parsed = Number.parseInt(value ?? "", 10);
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
-}
-
-function usage(): never {
- console.error("Usage: bun scripts/stream-browser.ts ");
- console.error("Required for live mode: TWITCH_STREAM_KEY");
- process.exit(1);
-}
-
-function resolveMode(value: string | undefined): Mode {
- if (value === "live" || value === "dryrun") return value;
- return usage();
-}
-
-const mode = resolveMode(process.argv[2]);
-
-const streamFps = parsePositiveInt(process.env.STREAM_FPS, 30);
-const captureBitrate = parsePositiveInt(process.env.STREAM_CAPTURE_BITRATE, 12_000_000);
-const targetSize = process.env.STREAM_TARGET_SIZE ?? "1920x1080";
-const targetParts = targetSize.split("x");
-const targetWidth = targetParts[0] ?? "1920";
-const targetHeight = targetParts[1] ?? "1080";
-const videoBitrate = process.env.STREAM_VIDEO_BITRATE ?? "6000k";
-const maxrate = process.env.STREAM_MAXRATE ?? "6000k";
-const bufsize = process.env.STREAM_BUFSIZE ?? "12000k";
-const gop = String(parsePositiveInt(process.env.STREAM_GOP, 60));
-const audioBitrate = process.env.STREAM_AUDIO_BITRATE ?? "160k";
-const streamKey = process.env.TWITCH_STREAM_KEY;
-const serverPort = process.env.STREAM_APP_PORT ?? "5109";
-const broadcastUrl = process.env.BROADCAST_URL ?? `http://127.0.0.1:${serverPort}/broadcast`;
-const redactionTokens = [
- broadcastUrl,
- streamKey,
- streamKey ? encodeURIComponent(streamKey) : undefined,
- streamKey ? `rtmp://live.twitch.tv/app/${streamKey}` : undefined,
-].filter((token): token is string => Boolean(token));
-const redactionWindow = Math.max(1, ...redactionTokens.map((token) => token.length));
-
-function redactSensitive(value: string): string {
- let output = value;
- for (const token of redactionTokens) {
- output = output.split(token).join("[REDACTED]");
- }
- return output;
-}
-
-if (mode === "live" && !streamKey) {
- console.error("TWITCH_STREAM_KEY is not set.");
- process.exit(1);
-}
-
-async function assertBroadcastReachable(url: string) {
- const timeoutMs = 5_000;
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
- try {
- const res = await fetch(url, { signal: controller.signal });
- if (!res.ok) {
- throw new Error(`HTTP ${res.status}`);
- }
- } catch (error) {
- const detail = error instanceof Error ? error.message : String(error);
- throw new Error(
- `Cannot reach broadcast page (${redactSensitive(detail)}). Start the app server first (bun run start or bun run start:web).`,
- );
- } finally {
- clearTimeout(timeout);
- }
-}
-
-function buildFfmpegArgs(currentMode: Mode): string[] {
- const args = [
- "-hide_banner",
- "-loglevel",
- "warning",
- "-fflags",
- "+genpts",
- "-f",
- "webm",
- "-i",
- "pipe:0",
- "-f",
- "lavfi",
- "-i",
- "anullsrc=channel_layout=stereo:sample_rate=44100",
- "-map",
- "0:v:0",
- "-map",
- "1:a:0",
- "-vf",
- `scale=${targetWidth}:${targetHeight}:force_original_aspect_ratio=decrease,pad=${targetWidth}:${targetHeight}:(ow-iw)/2:(oh-ih)/2`,
- "-c:v",
- "libx264",
- "-preset",
- "veryfast",
- "-tune",
- "zerolatency",
- "-pix_fmt",
- "yuv420p",
- "-b:v",
- videoBitrate,
- "-maxrate",
- maxrate,
- "-bufsize",
- bufsize,
- "-g",
- gop,
- "-keyint_min",
- gop,
- "-sc_threshold",
- "0",
- "-c:a",
- "aac",
- "-b:a",
- audioBitrate,
- "-ar",
- "44100",
- "-ac",
- "2",
- ];
-
- if (currentMode === "live") {
- args.push("-f", "flv", `rtmp://live.twitch.tv/app/${streamKey}`);
- return args;
- }
-
- args.push("-f", "mpegts", "pipe:1");
- return args;
-}
-
-async function pipeReadableToSink(
- readable: ReadableStream,
- sink: SinkWriter,
-) {
- const reader = readable.getReader();
- try {
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- if (value) sink.write(value);
- }
- } finally {
- sink.end();
- }
-}
-
-async function pipeReadableToRedactedStderr(readable: ReadableStream) {
- const reader = readable.getReader();
- const decoder = new TextDecoder();
- let carry = "";
- try {
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- const chunk = decoder.decode(value, { stream: true });
- const combined = carry + chunk;
- if (combined.length <= redactionWindow) {
- carry = combined;
- continue;
- }
- const flushUntil = combined.length - (redactionWindow - 1);
- const safeOutput = combined.slice(0, flushUntil);
- carry = combined.slice(flushUntil);
- if (safeOutput.length > 0) {
- process.stderr.write(redactSensitive(safeOutput));
- }
- }
- const trailing = carry + decoder.decode();
- if (trailing.length > 0) {
- process.stderr.write(redactSensitive(trailing));
- }
- } finally {
- reader.releaseLock();
- }
-}
-
-async function main() {
- await assertBroadcastReachable(broadcastUrl);
-
- const ffmpegArgs = buildFfmpegArgs(mode);
- const ffmpeg = Bun.spawn(["ffmpeg", ...ffmpegArgs], {
- stdin: "pipe",
- stdout: mode === "dryrun" ? "pipe" : "inherit",
- stderr: "pipe",
- });
- if (ffmpeg.stderr) {
- void pipeReadableToRedactedStderr(ffmpeg.stderr);
- }
- let ffmpegWritable = true;
-
- let ffplay: Bun.Subprocess | null = null;
- let ffplayPump: Promise | null = null;
- if (mode === "dryrun") {
- ffplay = Bun.spawn(
- [
- "ffplay",
- "-hide_banner",
- "-fflags",
- "nobuffer",
- "-flags",
- "low_delay",
- "-framedrop",
- "-i",
- "pipe:0",
- ],
- {
- stdin: "pipe",
- stdout: "inherit",
- stderr: "inherit",
- },
- );
- const stdout = ffmpeg.stdout;
- if (!stdout || !ffplay.stdin) {
- throw new Error("Failed to pipe ffmpeg output into ffplay.");
- }
- if (typeof ffplay.stdin === "number") {
- throw new Error("ffplay stdin is not writable.");
- }
- ffplayPump = pipeReadableToSink(stdout, ffplay.stdin as SinkWriter);
- }
-
- let firstChunkResolve: (() => void) | null = null;
- let firstChunkReject: ((error: Error) => void) | null = null;
- const firstChunk = new Promise((resolve, reject) => {
- firstChunkResolve = resolve;
- firstChunkReject = reject;
- });
- let shutdown: (() => Promise) | null = null;
-
- const chunkServer = Bun.serve({
- port: 0,
- fetch(req, server) {
- const url = new URL(req.url);
- if (url.pathname === "/chunks" && server.upgrade(req)) {
- return;
- }
- return new Response("Not found", { status: 404 });
- },
- websocket: {
- message(_ws, message) {
- if (!ffmpegWritable || !ffmpeg.stdin || typeof ffmpeg.stdin === "number") {
- return;
- }
- if (typeof message === "string") return;
-
- let chunk: Uint8Array | null = null;
- if (message instanceof ArrayBuffer) {
- chunk = new Uint8Array(message);
- } else if (ArrayBuffer.isView(message)) {
- chunk = new Uint8Array(
- message.buffer,
- message.byteOffset,
- message.byteLength,
- );
- }
- if (!chunk) return;
-
- try {
- ffmpeg.stdin.write(chunk);
- firstChunkResolve?.();
- firstChunkResolve = null;
- firstChunkReject = null;
- } catch (error) {
- ffmpegWritable = false;
- const detail = error instanceof Error ? error : new Error(String(error));
- firstChunkReject?.(detail);
- firstChunkResolve = null;
- firstChunkReject = null;
- void shutdown?.();
- }
- },
- },
- });
-
- const browser = await puppeteer.launch({
- headless: true,
- args: [
- "--autoplay-policy=no-user-gesture-required",
- "--disable-background-timer-throttling",
- "--disable-renderer-backgrounding",
- "--disable-backgrounding-occluded-windows",
- "--allow-running-insecure-content",
- "--disable-features=LocalNetworkAccessChecks",
- ],
- });
-
- const page = await browser.newPage();
- await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1 });
- page.on("console", (msg) => {
- if (process.env.STREAM_DEBUG === "1") {
- console.log(`[broadcast] ${msg.type()}: ${redactSensitive(msg.text())}`);
- }
- });
-
- const captureUrl = new URL(broadcastUrl);
- captureUrl.searchParams.set("sink", `ws://127.0.0.1:${chunkServer.port}/chunks`);
- captureUrl.searchParams.set("captureFps", String(streamFps));
- captureUrl.searchParams.set("captureBitrate", String(captureBitrate));
-
- await page.goto(captureUrl.toString(), { waitUntil: "networkidle2" });
- await page.waitForSelector("#broadcast-canvas", { timeout: 10_000 });
-
- const firstChunkTimer = setTimeout(() => {
- firstChunkReject?.(
- new Error("No media chunks received from headless browser within 10s."),
- );
- }, 10_000);
-
- await firstChunk.finally(() => clearTimeout(firstChunkTimer));
- console.log(`Streaming broadcast in ${mode} mode`);
-
- let shuttingDown = false;
- shutdown = async () => {
- if (shuttingDown) return;
- shuttingDown = true;
- ffmpegWritable = false;
- try {
- chunkServer.stop(true);
- } catch {}
- try {
- await browser.close();
- } catch {}
- try {
- ffmpeg.stdin?.end();
- } catch {}
- try {
- ffmpeg.kill();
- } catch {}
- if (ffplay) {
- try {
- if (ffplay.stdin && typeof ffplay.stdin !== "number") {
- ffplay.stdin.end();
- }
- } catch {}
- try {
- ffplay.kill();
- } catch {}
- }
- };
-
- const ffmpegExit = ffmpeg.exited.then((code) => {
- ffmpegWritable = false;
- void shutdown?.();
- return code;
- });
-
- process.on("SIGINT", () => {
- void shutdown?.();
- });
- process.on("SIGTERM", () => {
- void shutdown?.();
- });
-
- const exitCode = await ffmpegExit;
- if (ffplayPump) {
- await ffplayPump.catch(() => {
- // Ignore downstream pipe failures on shutdown.
- });
- }
- if (ffplay) {
- await ffplay.exited;
- }
- await shutdown?.();
-
- if (exitCode !== 0) {
- process.exit(exitCode);
- }
-}
-
-main().catch((error) => {
- const detail = error instanceof Error ? error.message : String(error);
- console.error(redactSensitive(detail));
- process.exit(1);
-});