[object Object]

← 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

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 →