← back to Model Arena
model-arena: README + first real battle (hermes3 13s vs gemma3 22s, gemma crowned; mac1 qwen host-timeout captured) — vote + ledger paths verified
304fb759f7fe934d1302355a1bb700a5d88af7a1 · 2026-07-22 22:15:22 -0700 · Steve
Files touched
A README.mdA data/artifacts/0ac1af2eaed4/gemma3-12b.htmlA data/artifacts/0ac1af2eaed4/hermes3-8b.htmlA data/challenges.jsonA data/votes.jsonl
Diff
commit 304fb759f7fe934d1302355a1bb700a5d88af7a1
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jul 22 22:15:22 2026 -0700
model-arena: README + first real battle (hermes3 13s vs gemma3 22s, gemma crowned; mac1 qwen host-timeout captured) — vote + ledger paths verified
---
README.md | 53 +++++++++
data/artifacts/0ac1af2eaed4/gemma3-12b.html | 93 +++++++++++++++
data/artifacts/0ac1af2eaed4/hermes3-8b.html | 169 ++++++++++++++++++++++++++++
data/challenges.json | 41 +++++++
data/votes.jsonl | 1 +
5 files changed, 357 insertions(+)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..34bfae6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,53 @@
+# Model Arena
+
+Fan ONE real-world build challenge out to multiple AI models, render each
+model's single-file HTML artifact side-by-side in sandboxed iframes, crown a
+winner, and build a **real-world win-rate ledger** per model over time.
+
+Seeded by Julian Goldie SEO's Jul 20 2026 X post (Kimi K3 vs Claude Fable 5 vs
+GPT 5.6 head-to-head): *"Don't marry one AI model. Build a workflow where every
+model does the job it performs best at — trust real-world testing over
+leaderboard screenshots."* See `BRIEF.md`.
+
+## Run
+
+```sh
+node server.js # http://localhost:9758, basic auth admin / DW2024!
+PORT=4000 node server.js # custom port
+BASIC_AUTH= node server.js # disable the gate (local only)
+BASIC_AUTH=u:p node server.js # custom credentials
+```
+
+Zero dependencies. State lives in `data/` (challenges.json, artifacts/,
+votes.jsonl, costlog.jsonl).
+
+## Models
+
+- **Local ($0, pre-checked):** qwen3:14b · gemma3:12b · hermes3:8b (Mac2
+ Ollama) + qwen2.5:7b (Mac1 Ollama at 192.168.1.133). Generations are
+ serialized per host so Ollama doesn't thrash model loads; hosts run in
+ parallel.
+- **Metered (opt-in per battle, never pre-checked):** Claude Fable 5, Kimi
+ K2.5, GPT-5.3, Grok 4.5. Each needs its env key (`ANTHROPIC_API_KEY`,
+ `MOONSHOT_API_KEY`, `OPENAI_API_KEY`, `XAI_API_KEY` — source from
+ secrets-manager). Estimated cost shows in the UI before the run; actual
+ token cost is appended to `data/costlog.jsonl` AND `~/.claude/cost-ledger.jsonl`.
+
+## API
+
+- `GET /api/models` — roster + availability + est cost
+- `GET /api/challenges?sort=newest|oldest|title|models|winner`
+- `POST /api/challenges {title, prompt, models[]}` — starts the battle
+- `GET /api/challenges/:id` · `DELETE /api/challenges/:id`
+- `POST /api/challenges/:id/vote {winner}` — crown, appends to votes.jsonl
+- `POST /api/challenges/:id/retry/:model`
+- `GET /api/ledger` — per-model battles/wins/win-rate/errors/avg-time/spend
+- `GET /artifact/:id/:model` — the raw artifact, served with a locked-down
+ CSP (no network); embedded with `sandbox="allow-scripts"`
+
+## Safety rails
+
+- Artifacts are untrusted model output: sandboxed iframes + CSP `default-src 'none'`.
+- Basic auth on by default.
+- $0 by default; paid calls are per-battle opt-in with cost surfaced + ledgered.
+- No deploy config on purpose — launch is Steve-gated per ACCELERATOR.md.
diff --git a/data/artifacts/0ac1af2eaed4/gemma3-12b.html b/data/artifacts/0ac1af2eaed4/gemma3-12b.html
new file mode 100644
index 0000000..88e3380
--- /dev/null
+++ b/data/artifacts/0ac1af2eaed4/gemma3-12b.html
@@ -0,0 +1,93 @@
+<!DOCTYPE html>
+<html>
+<head>
+<title>Fireworks!</title>
+<style>
+body { margin: 0; overflow: hidden; background: #000; }
+canvas { display: block; }
+</style>
+</head>
+<body>
+<canvas id="fireworksCanvas"></canvas>
+<script>
+const canvas = document.getElementById('fireworksCanvas');
+const ctx = canvas.getContext('2d');
+
+canvas.width = window.innerWidth;
+canvas.height = window.innerHeight;
+
+let particles = [];
+
+class Particle {
+ constructor(x, y, color, size) {
+ this.x = x;
+ this.y = y;
+ this.color = color;
+ this.size = size;
+ this.vx = (Math.random() - 0.5) * 2;
+ this.vy = (Math.random() - 0.5) * 2;
+ this.gravity = 0.1;
+ this.alpha = Math.random();
+ }
+
+ update() {
+ this.vx *= 0.98;
+ this.vy -= this.gravity;
+ this.x += this.vx;
+ this.y += this.vy;
+ this.alpha *= 0.96;
+ }
+
+ draw() {
+ ctx.globalAlpha = this.alpha;
+ ctx.fillStyle = this.color;
+ ctx.beginPath();
+ ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.globalAlpha = 1;
+ }
+}
+
+function launchFirework() {
+ const numParticles = 50 + Math.floor(Math.random() * 30);
+ for (let i = 0; i < numParticles; i++) {
+ particles.push(new Particle(canvas.width / 2, canvas.height / 2, `rgba(255, ${Math.floor(Math.random() * 100)}, ${Math.floor(Math.random() * 100)}, 1)`, Math.random() * 3));
+ }
+}
+
+canvas.addEventListener('click', launchFirework);
+
+function autoShow() {
+ setTimeout(() => {
+ launchFirework();
+ autoShow();
+ }, 2000 + Math.random() * 1000);
+}
+
+
+function animate() {
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+ for (let i = particles.length - 1; i >= 0; i--) {
+ particles[i].update();
+ particles[i].draw();
+
+ if (particles[i].alpha <= 0) {
+ particles.splice(i, 1);
+ }
+ }
+
+ requestAnimationFrame(animate);
+}
+
+window.addEventListener('resize', () => {
+ canvas.width = window.innerWidth;
+ canvas.height = window.innerHeight;
+});
+
+
+autoShow();
+animate();
+</script>
+</body>
+</html>
\ No newline at end of file
diff --git a/data/artifacts/0ac1af2eaed4/hermes3-8b.html b/data/artifacts/0ac1af2eaed4/hermes3-8b.html
new file mode 100644
index 0000000..6378dae
--- /dev/null
+++ b/data/artifacts/0ac1af2eaed4/hermes3-8b.html
@@ -0,0 +1,169 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Fireworks Simulator</title>
+ <style>
+ body {
+ margin: 0;
+ overflow: hidden;
+ touch-action: none;
+ cursor: crosshair;
+ }
+
+ canvas {
+ display: block;
+ }
+ </style>
+</head>
+<body>
+ <canvas id="fireworks"></canvas>
+
+ <script>
+ const canvas = document.getElementById('fireworks');
+ const ctx = canvas.getContext('2d');
+
+ canvas.width = window.innerWidth;
+ canvas.height = window.innerHeight;
+
+ let particles = [];
+ let rockets = [];
+
+ function random(min, max) {
+ return Math.random() * (max - min) + min;
+ }
+
+ function Vector(x, y) {
+ this.x = x;
+ this.y = y;
+
+ this.add = function(v) {
+ this.x += v.x;
+ this.y += v.y;
+ };
+
+ this.mult = function(s) {
+ this.x *= s;
+ this.y *= s;
+ };
+ }
+
+ class Particle {
+ constructor(x, y, radius, color) {
+ this.pos = new Vector(x, y);
+ this.radius = radius;
+ this.color = color;
+ this.trailColor = this.color;
+ this.life = random(50, 200);
+ this.speed = random(-0.1, 0.1);
+ }
+
+ update() {
+ this.life--;
+ if (this.life <= 0) return false;
+
+ this.pos.y += this.speed;
+
+ return true;
+ }
+
+ draw() {
+ ctx.fillStyle = `rgba(${Math.floor(this.color[0]), Math.floor(this.color[1])}, ${Math.floor(this.color[2])}, 0.8}`;
+ ctx.beginPath();
+ ctx.arc(this.pos.x, this.pos.y, this.radius, 0, Math.PI * 2);
+ ctx.fill();
+
+ if (this.life % 5 === 0) {
+ for (let i = 1; i < 4; i++) {
+ let trailRadius = (i / 3) * this.radius;
+ ctx.fillStyle = `rgba(${Math.floor(this.trailColor[0])}, ${Math.floor(this.trailColor[1])}, ${Math.floor(this.trailColor[2])}, 0.5)`;
+ ctx.beginPath();
+ ctx.arc(this.pos.x, this.pos.y - (i * this.speed), trailRadius, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+
+ return this;
+ }
+ }
+
+ class Rocket {
+ constructor(x, y, color) {
+ this.particles = [];
+
+ for (let i = 0; i < 10; i++) {
+ let radius = i / 6 + 3;
+ let r = random(1, 5);
+ let g = random(1, 5);
+ let b = random(1, 5);
+ this.particles.push(new Particle(x, y - (i * 20), radius, [r, g, b]));
+ }
+
+ this.life = 100;
+ }
+
+ update() {
+ for (let i in this.particles) {
+ if (!this.particles[i].update()) delete this.particles[i];
+ }
+
+ this.life--;
+
+ return this.life > 0;
+ }
+
+ draw() {
+ for (let particle of this.particles) {
+ particle.trailColor = [particle.color[0] * 1.2, particle.color[1] * 1.2, particle.color[2] * 1.2];
+ particle.draw();
+ }
+
+ return this;
+ }
+ }
+
+ function spawnRocket(x, y, color) {
+ rockets.push(new Rocket(x, y, color));
+
+ for (let i = 0; i < particles.length; i++) {
+ if (!particles[i].update()) {
+ particles.splice(i, 1);
+ break;
+ }
+ }
+
+ particles = particles.concat(...rockets[rockets.length - 1].particles);
+
+ rockets.sort((a, b) => b.life - a.life);
+ }
+
+ function loop() {
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+ for (let i in particles) {
+ let particle = particles[i];
+ particle.draw();
+ }
+
+ for (let rocket of rockets) {
+ if (!rocket.update()) delete rockets[rockets.indexOf(rocket)];
+ rocket.draw();
+ }
+
+ spawnRocket(random(0, canvas.width), random(-100, -30), [random(1, 5), random(1, 5), random(1, 5)]);
+
+ requestAnimationFrame(loop);
+ }
+
+ document.addEventListener('click', (e) => {
+ let x = e.clientX;
+ let y = e.clientY;
+
+ if (y + window.innerHeight / 2 > canvas.height / 3) return;
+
+ spawnRocket(x, y, [random(1, 5), random(1, 5), random(1, 5)]);
+ });
+
+ loop();
+ </script>
+</body>
+</html>
\ No newline at end of file
diff --git a/data/challenges.json b/data/challenges.json
new file mode 100644
index 0000000..c8308dc
--- /dev/null
+++ b/data/challenges.json
@@ -0,0 +1,41 @@
+[
+ {
+ "id": "0ac1af2eaed4",
+ "title": "Smoke Test: Particle Fireworks",
+ "prompt": "Build an interactive fireworks simulator in a single HTML file: click anywhere to launch a rocket that explodes into physics-based particles with trails, gravity, and glow. Include an auto-show mode. Keep it under 200 lines.",
+ "created_at": "2026-07-23T05:02:46.618Z",
+ "winner": "gemma3-12b",
+ "runs": [
+ {
+ "model": "hermes3-8b",
+ "status": "done",
+ "error": null,
+ "seconds": 13,
+ "cost": 0,
+ "started_at": "2026-07-23T05:02:46.619Z",
+ "bytes": 4010,
+ "finished_at": "2026-07-23T05:02:59.140Z"
+ },
+ {
+ "model": "qwen25-7b",
+ "status": "error",
+ "error": "timeout after 600s",
+ "seconds": 600,
+ "cost": null,
+ "started_at": "2026-07-23T05:02:46.625Z",
+ "finished_at": "2026-07-23T05:12:46.664Z"
+ },
+ {
+ "model": "gemma3-12b",
+ "status": "done",
+ "error": null,
+ "seconds": 22,
+ "cost": 0,
+ "started_at": "2026-07-23T05:14:02.411Z",
+ "bytes": 1986,
+ "finished_at": "2026-07-23T05:14:24.146Z"
+ }
+ ],
+ "voted_at": "2026-07-23T05:15:22.408Z"
+ }
+]
\ No newline at end of file
diff --git a/data/votes.jsonl b/data/votes.jsonl
new file mode 100644
index 0000000..536246e
--- /dev/null
+++ b/data/votes.jsonl
@@ -0,0 +1 @@
+{"ts":"2026-07-23T05:15:22.408Z","challenge":"0ac1af2eaed4","title":"Smoke Test: Particle Fireworks","winner":"gemma3-12b","field":["hermes3-8b","gemma3-12b"]}
← e2ce015 model-arena: arena server + neon UI — challenges, per-host o
·
back to Model Arena
·
model-arena: unauthenticated /api/healthz; live at modelaren 56cdd2f →