[object Object]

← back to Model Wars

MODEL WARS — real API battles, AI judges, medieval ELO ladder

e2b346c42a6706a6b0ff1bd2080c87d5ab112082 · 2026-08-24 08:55:39 -0700 · Steve Abrams

Full rebuild of the Qwen2.5 stub into a working project:
- Express backend with streaming provider adapters (OpenAI/Anthropic/Google/
  xAI/DeepSeek/Groq), true time-to-first-token, token+cost accounting, AI judge,
  persistent ELO ladder (K=32) with per-category/streak/cost-efficiency stats.
- 2026 liquid-glass UI: embers, cursor torchlight, parallax castle.
- Five cinematic canvas bouts: Joust, Archery, Castle Siege, Duel, Dragon.
- Honest by design: measured metrics vs AI-judge opinions kept distinct;
  People's Choice tallied separately; no-key champions run clearly-labeled
  Exhibition sims that never touch the persistent ladder.
- Dynamic champion registration; View The Evidence; animated crown on #1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit e2b346c42a6706a6b0ff1bd2080c87d5ab112082
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 24 08:55:39 2026 -0700

    MODEL WARS — real API battles, AI judges, medieval ELO ladder
    
    Full rebuild of the Qwen2.5 stub into a working project:
    - Express backend with streaming provider adapters (OpenAI/Anthropic/Google/
      xAI/DeepSeek/Groq), true time-to-first-token, token+cost accounting, AI judge,
      persistent ELO ladder (K=32) with per-category/streak/cost-efficiency stats.
    - 2026 liquid-glass UI: embers, cursor torchlight, parallax castle.
    - Five cinematic canvas bouts: Joust, Archery, Castle Siege, Duel, Dragon.
    - Honest by design: measured metrics vs AI-judge opinions kept distinct;
      People's Choice tallied separately; no-key champions run clearly-labeled
      Exhibition sims that never touch the persistent ladder.
    - Dynamic champion registration; View The Evidence; animated crown on #1.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 .env.example      |  35 +++
 .gitignore        |  11 +
 README.md         |  86 ++++++
 package-lock.json | 829 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 package.json      |  15 +
 providers.js      | 247 ++++++++++++++++
 public/app.js     | 312 ++++++++++++++++++++
 public/battles.js | 306 ++++++++++++++++++++
 public/index.html | 135 +++++++++
 public/scene.js   |  84 ++++++
 public/styles.css | 238 ++++++++++++++++
 server.js         | 199 +++++++++++++
 12 files changed, 2497 insertions(+)

diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..78dae01
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,35 @@
+# ── MODEL WARS — API keys ─────────────────────────────────────────────
+# Add a key to enable that champion for LIVE battles. Champions without a key
+# stay selectable in EXHIBITION mode only (clearly labeled simulation — never
+# presented as a real model response).
+#
+# Copy this file to .env and fill in whatever you have. Any subset works.
+
+# OpenAI / GPT — The Emerald Knight
+OPENAI_API_KEY=
+OPENAI_MODEL=gpt-4o-mini
+
+# Anthropic / Claude — The Golden Scholar
+ANTHROPIC_API_KEY=
+ANTHROPIC_MODEL=claude-3-5-sonnet-latest
+
+# Google / Gemini — The Celestial Mage
+GEMINI_API_KEY=
+GEMINI_MODEL=gemini-1.5-flash
+
+# xAI / Grok — The Black Knight
+XAI_API_KEY=
+XAI_MODEL=grok-2-latest
+
+# DeepSeek — The Eastern Strategist
+DEEPSEEK_API_KEY=
+DEEPSEEK_MODEL=deepseek-chat
+
+# Llama (served via Groq) — The Crimson Ranger
+GROQ_API_KEY=
+GROQ_MODEL=llama-3.3-70b-versatile
+
+# Which provider judges the duel (must have a key). Defaults to first available.
+JUDGE_PROVIDER=
+
+PORT=9911
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..fb0e9b1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+node_modules/
+.env
+.env.*
+!.env.example
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+data/leaderboard.json
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..d1b5bad
--- /dev/null
+++ b/README.md
@@ -0,0 +1,86 @@
+# ⚔️ MODEL WARS
+
+**Leading AI models battle head-to-head in a medieval tournament.** A 2026
+liquid-glass interface turns model comparisons into jousts, sieges, duels,
+archery and dragon-slaying — backed by **real API battles**, **AI judges**, and a
+**persistent medieval ELO ladder**.
+
+Each model is a champion:
+
+| Champion | Model | Title |
+|---|---|---|
+| ⚔️ GPT | OpenAI | The Emerald Knight |
+| 📜 Claude | Anthropic | The Golden Scholar |
+| ✦ Gemini | Google | The Celestial Mage |
+| 🗡️ Grok | xAI | The Black Knight |
+| ☯ DeepSeek | DeepSeek | The Eastern Strategist |
+| 🏹 Llama | Groq | The Crimson Ranger |
+
+New champions can be summoned dynamically from **The King's Table**.
+
+## The tournament
+
+Pick two champions and a battle. The **identical prompt** is sent to both models
+simultaneously; the animation is driven by how they actually score.
+
+- **⚔️ Joust** — full-spectrum clash; every category feeds the strike.
+- **🏹 Archery** — precision questions; accuracy places the arrow near the gold.
+- **🏰 Castle Siege** — multi-step reasoning topples gate → walls → towers → keep.
+- **🗡️ Duel** — coding / math / logic; correct reasoning strikes, errors are blocked.
+- **🐉 Dragon Challenge** — one brutal problem; both race to slay the same beast.
+
+## Honesty first — never faked
+
+- The **Live Arena** sends the real prompt to real APIs and shows the **measured**
+  time-to-first-token, total generation time, token usage, and estimated cost.
+- **AI-judge** scores (correctness, reasoning, relevance, completeness,
+  instruction adherence, hallucination risk — 0–100 each) are clearly labeled as
+  **opinions**, kept visually distinct from the **measured** objective metrics.
+- **People's Choice** (human votes) is tallied **separately** from the AI Score.
+- If a champion has **no API key**, it is not faked. It stays selectable in
+  **Exhibition mode** — a clearly-labeled *simulation* that never masquerades as a
+  real model response and **never moves the persistent King's Table**. Only real,
+  measured battles change the ladder.
+- **View The Evidence** exposes the full raw model outputs and the scoring.
+
+## The Medieval ELO system
+
+Every champion carries a persistent rating (standard ELO, K=32). Wins and losses
+move it. **The King's Table** shows rank, model, provider, ELO, W/L/D, win %,
+current streak, strongest category, average response time and cost efficiency —
+with a subtly animated crown on the #1 champion.
+
+## Run it
+
+```bash
+npm install
+cp .env.example .env      # add whatever API keys you have (any subset works)
+npm start                 # → http://localhost:9911
+```
+
+With **zero keys** it runs in Exhibition mode so you can explore every battle,
+animation and the ladder. Add **two or more** keys to fight live, judged battles
+that count on the King's Table.
+
+### Environment
+
+Each provider reads `PROVIDER_API_KEY` (+ optional `PROVIDER_MODEL`) from `.env` —
+see `.env.example`. `JUDGE_PROVIDER` picks which model judges (defaults to the
+first available). Battles stream, so time-to-first-token is genuinely measured.
+
+## Architecture
+
+```
+server.js      Express: static host + /api/battle, /api/judge, /api/result,
+               /api/leaderboard, /api/champions. Persists the ELO ladder.
+providers.js   Streaming adapters for OpenAI/Anthropic/Google/xAI/DeepSeek/Groq,
+               token+cost accounting, and the AI-judge call. Never fabricates.
+public/        The 2026 liquid-glass UI:
+  index.html   structure
+  styles.css   glass panels, gold/silver trim, medieval + modern type
+  scene.js     embers, cursor torchlight, parallax castle
+  battles.js   the five canvas bouts (knights, arrows, castles, swords, dragon)
+  app.js       orchestration, scoring, judging, ladder, voting
+```
+
+Built from — and a full rebuild of — a Model Arena challenge starter.
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..877bacc
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,829 @@
+{
+  "name": "model-wars",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "model-wars",
+      "version": "1.0.0",
+      "license": "MIT",
+      "dependencies": {
+        "express": "^4.19.2"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+      "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "~2.1.34",
+        "negotiator": "0.6.3"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/array-flatten": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+      "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+      "license": "MIT"
+    },
+    "node_modules/body-parser": {
+      "version": "1.20.6",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
+      "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "content-type": "~1.0.5",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "~1.2.0",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "on-finished": "~2.4.1",
+        "qs": "~6.15.1",
+        "raw-body": "~2.5.3",
+        "type-is": "~1.6.18",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "0.5.4",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+      "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+      "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+      "license": "MIT"
+    },
+    "node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/destroy": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+      "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/express": {
+      "version": "4.22.2",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+      "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "~1.3.8",
+        "array-flatten": "1.1.1",
+        "body-parser": "~1.20.5",
+        "content-disposition": "~0.5.4",
+        "content-type": "~1.0.4",
+        "cookie": "~0.7.1",
+        "cookie-signature": "~1.0.6",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "~1.3.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.0",
+        "merge-descriptors": "1.0.3",
+        "methods": "~1.1.2",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "~0.1.12",
+        "proxy-addr": "~2.0.7",
+        "qs": "~6.15.1",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.2.1",
+        "send": "~0.19.0",
+        "serve-static": "~1.16.2",
+        "setprototypeof": "1.2.0",
+        "statuses": "~2.0.1",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+      "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "statuses": "~2.0.2",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+      "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+      "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "0.1.13",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+      "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+      "license": "MIT"
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.15.3",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+      "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "es-define-property": "^1.0.1",
+        "side-channel": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "2.5.3",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+      "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/send": {
+      "version": "0.19.2",
+      "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+      "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "1.2.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.1",
+        "mime": "1.6.0",
+        "ms": "2.1.3",
+        "on-finished": "~2.4.1",
+        "range-parser": "~1.2.1",
+        "statuses": "~2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/send/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/serve-static": {
+      "version": "1.16.3",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+      "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "parseurl": "~1.3.3",
+        "send": "~0.19.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+      "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4",
+        "side-channel-list": "^1.0.1",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "license": "MIT",
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/utils-merge": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..e5ec320
--- /dev/null
+++ b/package.json
@@ -0,0 +1,15 @@
+{
+  "name": "model-wars",
+  "version": "1.0.0",
+  "description": "MODEL WARS — leading AI models battle head-to-head in a medieval tournament. Real API battles, AI judges, and a persistent medieval ELO ladder.",
+  "type": "module",
+  "main": "server.js",
+  "scripts": {
+    "start": "node server.js",
+    "dev": "node --watch server.js"
+  },
+  "dependencies": {
+    "express": "^4.19.2"
+  },
+  "license": "MIT"
+}
diff --git a/providers.js b/providers.js
new file mode 100644
index 0000000..84f2b78
--- /dev/null
+++ b/providers.js
@@ -0,0 +1,247 @@
+// providers.js — real API adapters for each champion.
+// Every adapter streams so we can measure true time-to-first-token (TTFT).
+// No adapter ever fabricates output: if a key is missing or a call fails,
+// the caller learns the truth and the UI blocks live battle for that champion.
+
+import 'node:process';
+
+const env = process.env;
+
+// Pricing in USD per 1,000,000 tokens: [input, output]. Best-effort public
+// list prices; used only for the "estimated API cost" display.
+const PRICING = {
+  'gpt-4o-mini':               [0.15, 0.60],
+  'gpt-4o':                    [2.50, 10.00],
+  'gpt-4.1-mini':              [0.40, 1.60],
+  'claude-3-5-sonnet-latest':  [3.00, 15.00],
+  'claude-3-5-haiku-latest':   [0.80, 4.00],
+  'gemini-1.5-flash':          [0.075, 0.30],
+  'gemini-2.0-flash':          [0.10, 0.40],
+  'grok-2-latest':             [2.00, 10.00],
+  'grok-beta':                 [5.00, 15.00],
+  'deepseek-chat':             [0.27, 1.10],
+  'llama-3.3-70b-versatile':   [0.59, 0.79],
+};
+
+function priceFor(model) {
+  return PRICING[model] || [0.5, 1.5]; // conservative fallback
+}
+
+export function costOf(model, inTok, outTok) {
+  const [pin, pout] = priceFor(model);
+  return (inTok / 1e6) * pin + (outTok / 1e6) * pout;
+}
+
+// The canonical champion roster. `key`/`model` resolved from env at runtime.
+export const CHAMPIONS = [
+  { id: 'gpt',      name: 'GPT',      title: 'The Emerald Knight',     provider: 'openai',   color: '#2ecc71', crest: '⚔️' },
+  { id: 'claude',   name: 'Claude',   title: 'The Golden Scholar',     provider: 'anthropic',color: '#e8b64c', crest: '📜' },
+  { id: 'gemini',   name: 'Gemini',   title: 'The Celestial Mage',     provider: 'google',   color: '#6aa8ff', crest: '✦' },
+  { id: 'grok',     name: 'Grok',     title: 'The Black Knight',       provider: 'xai',      color: '#9aa0a6', crest: '🗡️' },
+  { id: 'deepseek', name: 'DeepSeek', title: 'The Eastern Strategist', provider: 'deepseek', color: '#7c5cff', crest: '☯' },
+  { id: 'llama',    name: 'Llama',    title: 'The Crimson Ranger',     provider: 'groq',     color: '#e05a5a', crest: '🏹' },
+];
+
+const PROVIDER_ENV = {
+  openai:    { key: 'OPENAI_API_KEY',    model: 'OPENAI_MODEL',    default: 'gpt-4o-mini' },
+  anthropic: { key: 'ANTHROPIC_API_KEY', model: 'ANTHROPIC_MODEL', default: 'claude-3-5-sonnet-latest' },
+  google:    { key: 'GEMINI_API_KEY',    model: 'GEMINI_MODEL',    default: 'gemini-1.5-flash' },
+  xai:       { key: 'XAI_API_KEY',       model: 'XAI_MODEL',       default: 'grok-2-latest' },
+  deepseek:  { key: 'DEEPSEEK_API_KEY',  model: 'DEEPSEEK_MODEL',  default: 'deepseek-chat' },
+  groq:      { key: 'GROQ_API_KEY',      model: 'GROQ_MODEL',      default: 'llama-3.3-70b-versatile' },
+};
+
+export function providerConfig(provider) {
+  const cfg = PROVIDER_ENV[provider];
+  if (!cfg) return null;
+  return {
+    provider,
+    apiKey: env[cfg.key] || '',
+    model: env[cfg.model] || cfg.default,
+    available: !!(env[cfg.key] && env[cfg.key].trim()),
+  };
+}
+
+export function championWithAvailability(champ) {
+  const cfg = providerConfig(champ.provider);
+  return { ...champ, model: cfg ? cfg.model : null, available: cfg ? cfg.available : false };
+}
+
+// Estimate tokens when a provider doesn't report usage. ~4 chars/token.
+const estTok = (s) => Math.max(1, Math.round((s || '').length / 4));
+
+// ── Streaming adapters ────────────────────────────────────────────────
+// Each returns: { text, ttft, total, inTok, outTok, model, cost }
+// ttft/total in ms. Throws on any failure (the caller reports it honestly).
+
+async function readSSE(res, onEvent) {
+  const reader = res.body.getReader();
+  const dec = new TextDecoder();
+  let buf = '';
+  while (true) {
+    const { value, done } = await reader.read();
+    if (done) break;
+    buf += dec.decode(value, { stream: true });
+    let idx;
+    while ((idx = buf.indexOf('\n')) >= 0) {
+      const line = buf.slice(0, idx).trim();
+      buf = buf.slice(idx + 1);
+      if (line.startsWith('data:')) onEvent(line.slice(5).trim());
+    }
+  }
+  if (buf.trim().startsWith('data:')) onEvent(buf.trim().slice(5).trim());
+}
+
+async function openAICompatible({ base, apiKey, model, prompt, extraHeaders = {} }) {
+  const t0 = performance.now();
+  const res = await fetch(`${base}/chat/completions`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, ...extraHeaders },
+    body: JSON.stringify({
+      model,
+      messages: [{ role: 'user', content: prompt }],
+      stream: true,
+      stream_options: { include_usage: true },
+      temperature: 0.7,
+    }),
+  });
+  if (!res.ok) throw new Error(`${model}: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
+  let text = '', ttft = null, inTok = 0, outTok = 0;
+  await readSSE(res, (data) => {
+    if (data === '[DONE]') return;
+    let j; try { j = JSON.parse(data); } catch { return; }
+    const delta = j.choices?.[0]?.delta?.content;
+    if (delta) { if (ttft === null) ttft = performance.now() - t0; text += delta; }
+    if (j.usage) { inTok = j.usage.prompt_tokens || inTok; outTok = j.usage.completion_tokens || outTok; }
+  });
+  const total = performance.now() - t0;
+  if (!inTok) inTok = estTok(prompt);
+  if (!outTok) outTok = estTok(text);
+  return { text, ttft: ttft ?? total, total, inTok, outTok, model, cost: costOf(model, inTok, outTok) };
+}
+
+async function anthropic({ apiKey, model, prompt }) {
+  const t0 = performance.now();
+  const res = await fetch('https://api.anthropic.com/v1/messages', {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      'x-api-key': apiKey,
+      'anthropic-version': '2023-06-01',
+    },
+    body: JSON.stringify({ model, max_tokens: 1024, stream: true, messages: [{ role: 'user', content: prompt }] }),
+  });
+  if (!res.ok) throw new Error(`${model}: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
+  let text = '', ttft = null, inTok = 0, outTok = 0;
+  await readSSE(res, (data) => {
+    let j; try { j = JSON.parse(data); } catch { return; }
+    if (j.type === 'content_block_delta' && j.delta?.text) {
+      if (ttft === null) ttft = performance.now() - t0;
+      text += j.delta.text;
+    }
+    if (j.type === 'message_start' && j.message?.usage) inTok = j.message.usage.input_tokens || inTok;
+    if (j.usage?.output_tokens) outTok = j.usage.output_tokens;
+    if (j.type === 'message_delta' && j.usage?.output_tokens) outTok = j.usage.output_tokens;
+  });
+  const total = performance.now() - t0;
+  if (!inTok) inTok = estTok(prompt);
+  if (!outTok) outTok = estTok(text);
+  return { text, ttft: ttft ?? total, total, inTok, outTok, model, cost: costOf(model, inTok, outTok) };
+}
+
+async function google({ apiKey, model, prompt }) {
+  const t0 = performance.now();
+  const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?alt=sse&key=${apiKey}`;
+  const res = await fetch(url, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ contents: [{ role: 'user', parts: [{ text: prompt }] }] }),
+  });
+  if (!res.ok) throw new Error(`${model}: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
+  let text = '', ttft = null, inTok = 0, outTok = 0;
+  await readSSE(res, (data) => {
+    let j; try { j = JSON.parse(data); } catch { return; }
+    const part = j.candidates?.[0]?.content?.parts?.[0]?.text;
+    if (part) { if (ttft === null) ttft = performance.now() - t0; text += part; }
+    if (j.usageMetadata) {
+      inTok = j.usageMetadata.promptTokenCount || inTok;
+      outTok = j.usageMetadata.candidatesTokenCount || outTok;
+    }
+  });
+  const total = performance.now() - t0;
+  if (!inTok) inTok = estTok(prompt);
+  if (!outTok) outTok = estTok(text);
+  return { text, ttft: ttft ?? total, total, inTok, outTok, model, cost: costOf(model, inTok, outTok) };
+}
+
+// Dispatch a real streamed call for a champion. Throws if unavailable/failed.
+export async function callChampion(champ, prompt) {
+  const cfg = providerConfig(champ.provider);
+  if (!cfg) throw new Error(`Unknown provider ${champ.provider}`);
+  if (!cfg.available) throw new Error(`${champ.name} has no API key configured`);
+  const { apiKey, model } = cfg;
+  switch (champ.provider) {
+    case 'openai':   return openAICompatible({ base: 'https://api.openai.com/v1', apiKey, model, prompt });
+    case 'xai':      return openAICompatible({ base: 'https://api.x.ai/v1', apiKey, model, prompt });
+    case 'deepseek': return openAICompatible({ base: 'https://api.deepseek.com', apiKey, model, prompt });
+    case 'groq':     return openAICompatible({ base: 'https://api.groq.com/openai/v1', apiKey, model, prompt });
+    case 'anthropic':return anthropic({ apiKey, model, prompt });
+    case 'google':   return google({ apiKey, model, prompt });
+    default: throw new Error(`No adapter for ${champ.provider}`);
+  }
+}
+
+// A non-streaming JSON call used by the AI judge. Returns parsed JSON or throws.
+export async function judgeCall(provider, prompt) {
+  const cfg = providerConfig(provider);
+  if (!cfg || !cfg.available) throw new Error(`Judge provider ${provider} unavailable`);
+  const { apiKey, model } = cfg;
+  const sys = 'You are a strict, impartial tournament judge. Respond with ONLY valid minified JSON, no markdown.';
+  if (provider === 'anthropic') {
+    const res = await fetch('https://api.anthropic.com/v1/messages', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
+      body: JSON.stringify({ model, max_tokens: 1024, system: sys, messages: [{ role: 'user', content: prompt }] }),
+    });
+    if (!res.ok) throw new Error(`judge HTTP ${res.status}`);
+    const j = await res.json();
+    return extractJSON(j.content?.[0]?.text || '');
+  }
+  if (provider === 'google') {
+    const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
+    const res = await fetch(url, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ systemInstruction: { parts: [{ text: sys }] }, contents: [{ role: 'user', parts: [{ text: prompt }] }], generationConfig: { responseMimeType: 'application/json' } }),
+    });
+    if (!res.ok) throw new Error(`judge HTTP ${res.status}`);
+    const j = await res.json();
+    return extractJSON(j.candidates?.[0]?.content?.parts?.[0]?.text || '');
+  }
+  // OpenAI-compatible (openai/xai/deepseek/groq)
+  const base = { openai: 'https://api.openai.com/v1', xai: 'https://api.x.ai/v1', deepseek: 'https://api.deepseek.com', groq: 'https://api.groq.com/openai/v1' }[provider];
+  const res = await fetch(`${base}/chat/completions`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
+    body: JSON.stringify({ model, messages: [{ role: 'system', content: sys }, { role: 'user', content: prompt }], temperature: 0, response_format: { type: 'json_object' } }),
+  });
+  if (!res.ok) throw new Error(`judge HTTP ${res.status}`);
+  const j = await res.json();
+  return extractJSON(j.choices?.[0]?.message?.content || '');
+}
+
+function extractJSON(s) {
+  if (!s) throw new Error('empty judge response');
+  const a = s.indexOf('{'), b = s.lastIndexOf('}');
+  if (a < 0 || b < 0) throw new Error('no JSON in judge response');
+  return JSON.parse(s.slice(a, b + 1));
+}
+
+export function judgeProvider() {
+  const forced = env.JUDGE_PROVIDER;
+  if (forced && providerConfig(forced)?.available) return forced;
+  for (const p of ['anthropic', 'openai', 'google', 'deepseek', 'xai', 'groq']) {
+    if (providerConfig(p)?.available) return p;
+  }
+  return null;
+}
diff --git a/public/app.js b/public/app.js
new file mode 100644
index 0000000..040f4a9
--- /dev/null
+++ b/public/app.js
@@ -0,0 +1,312 @@
+// app.js — MODEL WARS orchestration: champions, battles, judging, ELO ladder.
+(function () {
+  'use strict';
+  const $ = (s, r = document) => r.querySelector(s);
+  const $$ = (s, r = document) => [...r.querySelectorAll(s)];
+
+  let CHAMPS = [];      // from /api/champions
+  let JUDGE = null;
+  let last = null;      // last battle payload (for evidence + voting)
+
+  // ── battle catalogue ───────────────────────────────────
+  const MODES = [
+    { id: 'joust',   icon: '⚔️', name: 'Joust',      desc: 'Full-spectrum clash. Every category feeds the strike.',
+      prompt: 'Explain why the sky is blue in exactly three sentences, then name one common misconception about it.',
+      w: { correctness: 2, reasoning: 2, relevance: 2, completeness: 2, instruction_adherence: 2, hallucination_risk: 2 } },
+    { id: 'archery', icon: '🏹', name: 'Archery',    desc: 'Precision questions. Accuracy places the arrow.',
+      prompt: 'Answer precisely and concisely:\n1) The 12th prime number?\n2) The year the Magna Carta was sealed?\n3) The chemical symbol for tungsten?',
+      w: { correctness: 4, reasoning: 1, relevance: 2, completeness: 1, instruction_adherence: 2, hallucination_risk: 4 } },
+    { id: 'siege',   icon: '🏰', name: 'Castle Siege', desc: 'Multi-step reasoning topples gate, walls, towers, keep.',
+      prompt: 'A farmer has 17 sheep. All but 9 run away. He then buys three times as many as remain, and finally sells half of his flock. How many sheep does he have? Show each reasoning step.',
+      w: { correctness: 3, reasoning: 4, relevance: 1, completeness: 3, instruction_adherence: 2, hallucination_risk: 2 } },
+    { id: 'duel',    icon: '🗡️', name: 'Duel',       desc: 'Coding, math or logic. Correct reasoning strikes true.',
+      prompt: 'Write a JavaScript function isPalindrome(s) that ignores case and non-alphanumeric characters, then state its time and space complexity.',
+      w: { correctness: 4, reasoning: 3, relevance: 1, completeness: 2, instruction_adherence: 3, hallucination_risk: 2 } },
+    { id: 'dragon',  icon: '🐉', name: 'Dragon',     desc: 'One brutal problem. Race to defeat the beast first.',
+      prompt: 'Prove there are infinitely many prime numbers, then estimate how many primes lie below 1,000,000 and justify the estimate.',
+      w: { correctness: 3, reasoning: 4, relevance: 1, completeness: 3, instruction_adherence: 1, hallucination_risk: 3 } },
+  ];
+  let mode = MODES[0];
+
+  const DIMS = ['correctness', 'reasoning', 'relevance', 'completeness', 'instruction_adherence', 'hallucination_risk'];
+  const DIM_LABEL = { correctness: 'Correctness', reasoning: 'Reasoning', relevance: 'Relevance', completeness: 'Completeness', instruction_adherence: 'Instruction', hallucination_risk: 'Halluc. risk' };
+
+  const toast = (m) => { const t = $('#toast'); t.textContent = m; t.classList.add('show'); clearTimeout(t._h); t._h = setTimeout(() => t.classList.remove('show'), 2600); };
+  const fmtMs = (ms) => ms == null ? '—' : ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(2)}s`;
+  const fmtCost = (c) => c == null ? '—' : c < 0.01 ? `$${c.toFixed(5)}` : `$${c.toFixed(4)}`;
+
+  // ── boot ───────────────────────────────────────────────
+  async function boot() {
+    const r = await fetch('/api/champions').then((x) => x.json());
+    CHAMPS = r.champions; JUDGE = r.judge;
+    const liveCount = CHAMPS.filter((c) => c.available).length;
+    const flag = $('#modeFlag'), txt = $('#modeText');
+    if (liveCount >= 2) { flag.className = 'mode-flag mode-live'; txt.textContent = `LIVE ARENA · ${liveCount} champions armed · Judge: ${JUDGE || 'none'}`; }
+    else { flag.className = 'mode-flag mode-exh'; txt.textContent = `EXHIBITION MODE · add API keys in .env to battle live (${liveCount}/6 armed)`; }
+    $('#footMode').innerHTML = liveCount >= 2
+      ? 'Live battles move the King\'s Table. Exhibition bouts are labeled and never counted.'
+      : 'Running in <b>exhibition</b> — bouts are simulated & clearly labeled; they do not move the King\'s Table.';
+
+    fillSelect($('#selA'), CHAMPS[0].id);
+    fillSelect($('#selB'), CHAMPS[1].id);
+    renderFighter('A'); renderFighter('B');
+    renderModes();
+    $('#prompt').value = mode.prompt;
+    loadLadder();
+  }
+
+  function fillSelect(sel, chosen) {
+    sel.innerHTML = CHAMPS.map((c) => `<option value="${c.id}" ${c.id === chosen ? 'selected' : ''}>${c.crest} ${c.name} — ${c.title}${c.available ? '' : ' (no key)'}</option>`).join('');
+  }
+  function champById(id) { return CHAMPS.find((c) => c.id === id); }
+
+  function renderFighter(side) {
+    const sel = $(side === 'A' ? '#selA' : '#selB');
+    const box = $(side === 'A' ? '#fighterA' : '#fighterB');
+    const c = champById(sel.value);
+    box.querySelector('.crest').textContent = c.crest;
+    box.querySelector('.crest').style.filter = `drop-shadow(0 0 14px ${c.color})`;
+    box.querySelector('.cname').textContent = c.name;
+    box.querySelector('.cname').style.color = c.color;
+    box.querySelector('.ctitle').textContent = c.title;
+    const av = box.querySelector('.avail');
+    av.className = 'avail ' + (c.available ? 'on' : 'off');
+    av.querySelector('.txt').textContent = c.available ? `Armed · ${c.model}` : 'No API key — exhibition only';
+    updateHint();
+  }
+
+  function renderModes() {
+    $('#modes').innerHTML = MODES.map((m) => `
+      <div class="mode-card glass ${m.id === mode.id ? 'sel' : ''}" data-mode="${m.id}">
+        <div class="mi">${m.icon}</div><div class="mn">${m.name}</div><div class="md">${m.desc}</div>
+      </div>`).join('');
+    $$('#modes .mode-card').forEach((el) => el.onclick = () => {
+      mode = MODES.find((m) => m.id === el.dataset.mode);
+      $('#prompt').value = mode.prompt;
+      renderModes();
+    });
+  }
+
+  function bothLive() {
+    const a = champById($('#selA').value), b = champById($('#selB').value);
+    return a.available && b.available && a.id !== b.id;
+  }
+  function updateHint() {
+    const a = champById($('#selA').value), b = champById($('#selB').value);
+    const h = $('#battleHint');
+    if (a.id === b.id) { h.textContent = '⚠ Choose two different champions.'; return; }
+    h.textContent = bothLive()
+      ? '● LIVE — identical prompt sent to both real APIs, judged, and scored on the King\'s Table.'
+      : '◐ EXHIBITION — simulated bout (labeled). Arm both champions in .env for a real, counted battle.';
+  }
+
+  // ── deterministic exhibition scoring (clearly a simulation) ──
+  function hash(s) { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0) / 4294967295; }
+  function exhibitionDims(champ) {
+    const base = 55 + hash(champ.id) * 30;              // per-champion baseline 55–85
+    const d = {};
+    for (const k of DIMS) {
+      const v = clamp(base + (hash(champ.id + k + mode.id) - 0.5) * 34, 8, 99);
+      d[k] = Math.round(k === 'hallucination_risk' ? 100 - v * 0.55 : v);
+    }
+    return d;
+  }
+  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
+
+  // composite 0..1 from judge dims using the mode weights
+  function composite(dims) {
+    let num = 0, den = 0;
+    for (const k of DIMS) {
+      const w = mode.w[k];
+      const score = k === 'hallucination_risk' ? (100 - (dims[k] ?? 50)) : (dims[k] ?? 50);
+      num += w * score; den += w * 100;
+    }
+    return clamp(num / den, 0, 1);
+  }
+  function speedScore(total, other) {
+    if (total == null || other == null) return 0.5;
+    const max = Math.max(total, other, 1);
+    return clamp(1 - total / (max * 1.4), 0, 1);
+  }
+
+  // ── run a battle ───────────────────────────────────────
+  let battling = false;
+  async function begin() {
+    if (battling) return;
+    const a = champById($('#selA').value), b = champById($('#selB').value);
+    if (a.id === b.id) { toast('Choose two different champions'); return; }
+    battling = true;
+    const prompt = $('#prompt').value.trim() || mode.prompt;
+    const btn = $('#beginBtn'); btn.disabled = true; btn.textContent = 'Battling…';
+    $('#results').style.display = 'none';
+    const live = bothLive();
+
+    let A, B, dimsA, dimsB, exhibition = !live;
+    try {
+      if (live) {
+        const res = await fetch('/api/battle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt, a: a.id, b: b.id }) }).then((x) => x.json());
+        if (!res.a.ok || !res.b.ok) {
+          toast('A champion failed to answer — running exhibition. ' + (res.a.error || res.b.error || ''));
+          exhibition = true;
+        } else {
+          A = res.a; B = res.b;
+          const jr = await fetch('/api/judge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt, aText: A.text, bText: B.text, aName: a.name, bName: b.name }) }).then((x) => x.json());
+          if (jr.scored) { dimsA = jr.scored.a; dimsB = jr.scored.b; }
+          else { toast('Judge unavailable — showing measured metrics only'); dimsA = exhibitionDims(a); dimsB = exhibitionDims(b); }
+        }
+      }
+      if (exhibition) {
+        dimsA = exhibitionDims(a); dimsB = exhibitionDims(b);
+        A = { ok: true, text: `⟪ EXHIBITION ⟫ ${a.name} has no live API key, so this is a simulated bout — not a real model response. Arm ${a.name} in .env to see genuine output.`, ttft: 300 + hash(a.id) * 500, total: 900 + hash(a.id + 't') * 1600, inTok: 40, outTok: Math.round(40 + hash(a.id) * 120), model: a.model || 'simulated', cost: 0 };
+        B = { ok: true, text: `⟪ EXHIBITION ⟫ ${b.name} has no live API key, so this is a simulated bout — not a real model response. Arm ${b.name} in .env to see genuine output.`, ttft: 300 + hash(b.id) * 500, total: 900 + hash(b.id + 't') * 1600, inTok: 40, outTok: Math.round(40 + hash(b.id) * 120), model: b.model || 'simulated', cost: 0 };
+      }
+
+      // powers fold AI-judge composite (opinion) with measured speed (objective)
+      const cA = composite(dimsA), cB = composite(dimsB);
+      const sA = speedScore(A.total, B.total), sB = speedScore(B.total, A.total);
+      const pA = 0.82 * cA + 0.18 * sA, pB = 0.82 * cB + 0.18 * sB;
+      const eps = 0.015;
+      const outcome = Math.abs(pA - pB) < eps ? 0.5 : (pA > pB ? 1 : 0);
+
+      // animate
+      await Stage.play(mode.id, {
+        aColor: a.color, bColor: b.color, aName: a.name, bName: b.name,
+        aPower: cA, bPower: cB, outcome, stages: 4,
+      });
+
+      last = { a, b, A, B, dimsA, dimsB, cA, cB, pA, pB, outcome, exhibition, prompt };
+      renderResults();
+
+      // Only LIVE, measured battles move the persistent King's Table.
+      if (!exhibition) {
+        const r = await fetch('/api/result', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
+          aId: a.id, bId: b.id, outcome, category: mode.id,
+          aStats: { total: A.total, cost: A.cost }, bStats: { total: B.total, cost: B.cost },
+        }) }).then((x) => x.json());
+        if (r.deltas) toast(`King's Table updated · ${a.name} ${fmtDelta(r.deltas[a.id])} · ${b.name} ${fmtDelta(r.deltas[b.id])}`);
+        renderLadderRows(r.rows);
+      }
+    } catch (e) {
+      toast('Battle error: ' + (e.message || e));
+    } finally {
+      battling = false;
+      btn.disabled = false; btn.textContent = 'Begin Battle';
+    }
+  }
+  const fmtDelta = (d) => d == null ? '' : (d >= 0 ? `+${d}` : `${d}`);
+
+  // ── result cards ───────────────────────────────────────
+  function renderResults() {
+    const { a, b, A, B, dimsA, dimsB, pA, pB, outcome, exhibition } = last;
+    const wb = $('#winnerBanner');
+    if (outcome === 0.5) wb.innerHTML = `A Draw of Honour<div class="sub">${a.name} and ${b.name} are evenly matched${exhibition ? ' · EXHIBITION (not counted)' : ''}</div>`;
+    else {
+      const win = outcome === 1 ? a : b;
+      wb.innerHTML = `<span style="color:${win.color}">${win.crest} ${win.name} is Victorious!</span><div class="sub">${win.title} — ${outcome === 1 ? a.name : b.name} claims the ${mode.name}${exhibition ? ' · EXHIBITION (not counted on the King\'s Table)' : ''}</div>`;
+    }
+    $('#cardA').innerHTML = card(a, A, dimsA, pA, B);
+    $('#cardB').innerHTML = card(b, B, dimsB, pB, A);
+    $('#results').style.display = 'block';
+    renderPeoples();
+    $('#evidenceBtn').textContent = '📜 View The Evidence';
+    $('#results').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+  }
+
+  function card(c, R, dims, power, other) {
+    const aiScore = Math.round(composite(dims) * 100);
+    const faster = R.total != null && other.total != null && R.total <= other.total;
+    const cheaper = R.cost != null && other.cost != null && R.cost <= other.cost;
+    const dimRows = DIMS.map((k) => {
+      const v = dims[k] ?? 50; const risk = k === 'hallucination_risk';
+      return `<div class="dim ${risk ? 'risk' : ''}"><span>${DIM_LABEL[k]}</span><span class="bar"><i style="width:${v}%"></i></span><span class="num">${v}</span></div>`;
+    }).join('');
+    return `
+      <div class="resp-head"><div class="n" style="color:${c.color}">${c.crest} ${c.name}</div>
+        <span class="badge">${R.model || '—'}</span></div>
+      <div class="aiscore"><span class="big">${aiScore}</span><span class="of">/ 100 AI-Judge composite</span></div>
+      <div class="dims">${dimRows}</div>
+      <div class="objective-note"><b>Measured:</b> TTFT ${fmtMs(R.ttft)} · total ${fmtMs(R.total)}${faster ? ' ⚡faster' : ''} · ${R.inTok + R.outTok} tokens · ${fmtCost(R.cost)}${cheaper ? ' 💰cheaper' : ''}</div>
+      <div class="metrics">
+        <div class="metric"><div class="k">Time to first token</div><div class="v">${fmtMs(R.ttft)}</div></div>
+        <div class="metric"><div class="k">Total time</div><div class="v">${fmtMs(R.total)}</div></div>
+        <div class="metric"><div class="k">Est. API cost</div><div class="v">${fmtCost(R.cost)}</div></div>
+      </div>
+      ${dims.rationale ? `<div class="rationale">“${dims.rationale}”</div>` : ''}
+      <div class="response-full" data-full>${escapeHtml(R.text || '')}</div>`;
+  }
+  function escapeHtml(s) { return (s || '').replace(/[&<>]/g, (m) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[m])); }
+
+  // ── People's Choice (client-side tally, separate from AI score) ──
+  function peopleKey() { return `pc:${[last.a.id, last.b.id].sort().join('-')}`; }
+  function renderPeoples() {
+    const t = JSON.parse(localStorage.getItem(peopleKey()) || '{}');
+    const na = t[last.a.id] || 0, nb = t[last.b.id] || 0, tot = na + nb;
+    $('#peoplesChoice').innerHTML = tot
+      ? `👥 People's Choice for this matchup — <b style="color:${last.a.color}">${last.a.name} ${tot ? Math.round(na / tot * 100) : 0}%</b> vs <b style="color:${last.b.color}">${last.b.name} ${tot ? Math.round(nb / tot * 100) : 0}%</b> (${tot} votes) · kept separate from the AI Score`
+      : `👥 No public votes yet for this matchup — cast yours above. People's Choice is tallied separately from the AI Score.`;
+    $('#voteA').textContent = `🗳 Vote ${last.a.name}`;
+    $('#voteB').textContent = `🗳 Vote ${last.b.name}`;
+  }
+  function vote(id) {
+    if (!last) return;
+    const k = peopleKey(); const t = JSON.parse(localStorage.getItem(k) || '{}');
+    t[id] = (t[id] || 0) + 1; localStorage.setItem(k, JSON.stringify(t));
+    renderPeoples(); toast('Your vote is counted in the People\'s Choice');
+  }
+
+  // ── King's Table ───────────────────────────────────────
+  async function loadLadder() {
+    const r = await fetch('/api/leaderboard').then((x) => x.json());
+    renderLadderRows(r.rows);
+  }
+  function renderLadderRows(rows) {
+    if (!rows) return;
+    $('#ladderBody').innerHTML = rows.map((r) => `
+      <tr class="${r.rank === 1 ? 'rank1' : ''}">
+        <td>${r.rank === 1 ? '<span class="crown">👑</span> ' : ''}${r.rank}</td>
+        <td><div class="champcell"><span class="cr" style="filter:drop-shadow(0 0 8px ${r.color})">${r.crest}</span>
+          <span><div class="nm" style="color:${r.color}">${r.name}</div><div class="tt">${r.title}</div></span></div></td>
+        <td>${r.provider}</td>
+        <td class="elo">${r.elo}</td>
+        <td>${r.wins}</td><td>${r.losses}</td><td>${r.draws}</td>
+        <td>${r.winPct}%</td>
+        <td class="streak ${r.streak > 0 ? 'pos' : r.streak < 0 ? 'neg' : ''}">${r.streak > 0 ? 'W' + r.streak : r.streak < 0 ? 'L' + (-r.streak) : '—'}</td>
+        <td>${cap(r.strongest)}</td>
+        <td>${r.avgRespMs == null ? '—' : r.avgRespMs + 'ms'}</td>
+        <td>${r.costPerBattle == null ? '—' : fmtCost(r.costPerBattle)}</td>
+      </tr>`).join('');
+  }
+  const cap = (s) => s && s !== '—' ? s[0].toUpperCase() + s.slice(1) : '—';
+
+  // ── wire up ────────────────────────────────────────────
+  $('#selA').onchange = () => renderFighter('A');
+  $('#selB').onchange = () => renderFighter('B');
+  $('#beginBtn').onclick = begin;
+  $('#voteA').onclick = () => vote(last.a.id);
+  $('#voteB').onclick = () => vote(last.b.id);
+  $('#evidenceBtn').onclick = () => {
+    const shown = $$('#results .response-full').some((e) => e.style.display === 'block');
+    $$('#results .response-full').forEach((e) => e.style.display = shown ? 'none' : 'block');
+    $('#evidenceBtn').textContent = shown ? '📜 View The Evidence' : '📜 Hide The Evidence';
+  };
+  $$('.tab').forEach((t) => t.onclick = () => {
+    $$('.tab').forEach((x) => x.classList.remove('active')); t.classList.add('active');
+    $$('.view').forEach((v) => v.classList.remove('active'));
+    $(`#view-${t.dataset.view}`).classList.add('active');
+    if (t.dataset.view === 'table') loadLadder();
+  });
+  $('#addBtn').onclick = async () => {
+    const name = $('#addName').value.trim(); if (!name) { toast('Name required'); return; }
+    const id = name.toLowerCase().replace(/[^a-z0-9]/g, '') || ('c' + Date.now());
+    const body = { id, name, title: $('#addTitle').value.trim() || 'The Challenger', crest: $('#addCrest').value.trim() || '⚜', provider: $('#addProvider').value };
+    const r = await fetch('/api/champions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then((x) => x.json());
+    if (r.ok) { toast(`${name} joins the ladder at ELO 1500`); $('#addName').value = $('#addTitle').value = $('#addCrest').value = ''; loadLadder(); }
+  };
+  $('#resetBtn').onclick = async () => {
+    if (!confirm('Reset the King\'s Table to seed ratings?')) return;
+    const r = await fetch('/api/leaderboard/reset', { method: 'POST' }).then((x) => x.json());
+    renderLadderRows(r.rows); toast('Ladder reset to seeds');
+  };
+
+  boot();
+})();
diff --git a/public/battles.js b/public/battles.js
new file mode 100644
index 0000000..17d2d81
--- /dev/null
+++ b/public/battles.js
@@ -0,0 +1,306 @@
+// battles.js — cinematic canvas bouts for each tournament mode.
+// Stage.play(mode, cfg) animates the fight and resolves when finished.
+// cfg: { aColor, bColor, aName, bName, aPower 0..1, bPower 0..1, outcome 1|0|0.5, stages }
+window.Stage = (function () {
+  'use strict';
+  const canvas = document.getElementById('stage');
+  const ctx = canvas.getContext('2d');
+  let W = 0, H = 0, DPR = 1;
+
+  function fit() {
+    DPR = Math.min(2, window.devicePixelRatio || 1);
+    const r = canvas.getBoundingClientRect();
+    W = r.width; H = 340;
+    canvas.width = W * DPR; canvas.height = H * DPR;
+    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
+  }
+  window.addEventListener('resize', fit);
+
+  const ease = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
+  const lerp = (a, b, t) => a + (b - a) * t;
+  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
+
+  function bg() {
+    const g = ctx.createLinearGradient(0, 0, 0, H);
+    g.addColorStop(0, '#0b0c16'); g.addColorStop(1, '#05050b');
+    ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
+    // ground
+    ctx.fillStyle = 'rgba(255,255,255,.03)'; ctx.fillRect(0, H - 60, W, 60);
+    ctx.strokeStyle = 'rgba(232,197,106,.12)'; ctx.beginPath();
+    ctx.moveTo(0, H - 60); ctx.lineTo(W, H - 60); ctx.stroke();
+  }
+
+  // particles pool (sparks / embers / smoke)
+  let parts = [];
+  function burst(x, y, color, n, spread, kind) {
+    for (let i = 0; i < n; i++) {
+      const a = Math.random() * Math.PI * 2, s = Math.random() * spread;
+      parts.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s - (kind === 'smoke' ? 1 : 0),
+        life: 1, color, r: kind === 'smoke' ? 6 + Math.random() * 8 : 1 + Math.random() * 2, kind });
+    }
+  }
+  function drawParts() {
+    for (const p of parts) {
+      p.x += p.vx; p.y += p.vy; p.vy += p.kind === 'spark' ? 0.12 : 0.02; p.life -= 0.02;
+      if (p.kind === 'smoke') { p.r += 0.3; }
+      ctx.globalAlpha = clamp(p.life, 0, 1) * (p.kind === 'smoke' ? 0.4 : 1);
+      ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, 7); ctx.fill();
+    }
+    ctx.globalAlpha = 1;
+    parts = parts.filter((p) => p.life > 0);
+  }
+
+  function banner(txt) { document.getElementById('stageBanner').textContent = txt; }
+
+  // ── figure primitives ────────────────────────────────
+  function knight(x, y, color, facing, lance, lean) {
+    ctx.save(); ctx.translate(x, y); ctx.scale(facing, 1); ctx.rotate(lean || 0);
+    // horse
+    ctx.fillStyle = '#1a1a22';
+    ctx.beginPath(); ctx.ellipse(0, 34, 46, 18, 0, 0, 7); ctx.fill();
+    ctx.fillRect(30, 8, 12, 30); ctx.fillRect(-38, 10, 10, 28); ctx.fillRect(-20, 12, 10, 26); ctx.fillRect(14, 12, 10, 26);
+    ctx.beginPath(); ctx.moveTo(40, 20); ctx.lineTo(58, 2); ctx.lineTo(60, 16); ctx.lineTo(46, 26); ctx.fill();
+    // barding (banner cloth) in champion color
+    ctx.fillStyle = color; ctx.globalAlpha = .8; ctx.fillRect(-26, 24, 40, 20); ctx.globalAlpha = 1;
+    // rider armour (metallic)
+    const mg = ctx.createLinearGradient(-8, -30, 12, 4);
+    mg.addColorStop(0, '#eef1f6'); mg.addColorStop(.5, '#aab0bd'); mg.addColorStop(1, '#5a5f6b');
+    ctx.fillStyle = mg;
+    ctx.beginPath(); ctx.roundRect(-8, -18, 20, 30, 6); ctx.fill();       // torso
+    ctx.beginPath(); ctx.arc(2, -26, 9, 0, 7); ctx.fill();                // helm
+    ctx.fillStyle = color; ctx.fillRect(-2, -34, 4, 8);                   // plume
+    // lance
+    if (lance) {
+      ctx.strokeStyle = '#d8b26a'; ctx.lineWidth = 4; ctx.beginPath();
+      ctx.moveTo(8, -8); ctx.lineTo(90, -14); ctx.stroke();
+      ctx.fillStyle = '#e8e8ee'; ctx.beginPath(); ctx.moveTo(90, -14); ctx.lineTo(102, -16); ctx.lineTo(90, -10); ctx.fill();
+    }
+    ctx.restore();
+  }
+
+  function castle(x, y, color, damage) { // damage 0..1
+    ctx.save(); ctx.translate(x, y);
+    const stone = '#2b2b36';
+    const parts = [ // [w,h, threshold] gate/walls/towers/keep collapse as damage rises
+      { x: -50, y: -40, w: 100, h: 40, th: 0.0 },   // wall base
+      { x: -60, y: -80, w: 24, h: 44, th: 0.75 },   // left tower
+      { x: 36, y: -80, w: 24, h: 44, th: 0.75 },    // right tower
+      { x: -14, y: -70, w: 28, h: 30, th: 0.25 },   // gate
+      { x: -18, y: -110, w: 36, h: 40, th: 0.9 },   // keep
+    ];
+    for (const p of parts) {
+      if (damage >= p.th && p.th > 0) { // rubble
+        ctx.fillStyle = '#20202a';
+        for (let i = 0; i < 5; i++) ctx.fillRect(p.x + i * (p.w / 5), -2 - Math.random() * 4, p.w / 5 - 2, 4 + Math.random() * 4);
+        continue;
+      }
+      ctx.fillStyle = stone; ctx.fillRect(p.x, p.y, p.w, p.h);
+      ctx.fillStyle = 'rgba(0,0,0,.3)';
+      for (let gx = p.x; gx < p.x + p.w; gx += 12) ctx.fillRect(gx, p.y, 1, p.h);
+    }
+    // banner
+    ctx.fillStyle = color; ctx.fillRect(-2, -150, 4, 40);
+    ctx.beginPath(); ctx.moveTo(2, -150); ctx.lineTo(26, -144); ctx.lineTo(2, -134); ctx.fill();
+    ctx.restore();
+  }
+
+  function dragon(x, y, hp) {
+    ctx.save(); ctx.translate(x, y);
+    const wing = Math.sin(performance.now() * 0.006) * 0.3;
+    ctx.fillStyle = hp > 0 ? '#3a1f4a' : '#241528';
+    // wings
+    ctx.save(); ctx.rotate(-0.4 + wing); ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(-70, -40); ctx.lineTo(-50, 20); ctx.fill(); ctx.restore();
+    ctx.save(); ctx.rotate(0.4 - wing); ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(70, -40); ctx.lineTo(50, 20); ctx.fill(); ctx.restore();
+    // body
+    const bg = ctx.createLinearGradient(0, -20, 0, 40); bg.addColorStop(0, '#6b3fa0'); bg.addColorStop(1, '#2a1636');
+    ctx.fillStyle = bg; ctx.beginPath(); ctx.ellipse(0, 6, 30, 40, 0, 0, 7); ctx.fill();
+    // head
+    ctx.beginPath(); ctx.ellipse(0, -34, 18, 15, 0, 0, 7); ctx.fill();
+    // eyes
+    ctx.fillStyle = hp > 0 ? '#ff5a2a' : '#555'; ctx.beginPath(); ctx.arc(-7, -36, 3, 0, 7); ctx.arc(7, -36, 3, 0, 7); ctx.fill();
+    ctx.restore();
+  }
+
+  function healthBars(cfg, aFrac, bFrac) {
+    const pad = 24, bw = W * 0.28, bh = 12;
+    function bar(x, frac, color, name, right) {
+      ctx.fillStyle = 'rgba(0,0,0,.5)'; ctx.fillRect(x, 20, bw, bh);
+      ctx.fillStyle = color; const w = bw * clamp(frac, 0, 1);
+      ctx.fillRect(right ? x + bw - w : x, 20, w, bh);
+      ctx.strokeStyle = 'rgba(232,197,106,.4)'; ctx.strokeRect(x, 20, bw, bh);
+      ctx.fillStyle = '#f3ecdc'; ctx.font = '700 12px Cinzel, serif';
+      ctx.textAlign = right ? 'right' : 'left';
+      ctx.fillText(name, right ? x + bw : x, 16);
+    }
+    bar(pad, aFrac, cfg.aColor, cfg.aName, false);
+    bar(W - pad - bw, bFrac, cfg.bColor, cfg.bName, true);
+  }
+
+  // ── animation runner ─────────────────────────────────
+  // A single active run at a time; cancelling one always resolves its promise
+  // so awaiters never hang, and a frame error resolves rather than stalls.
+  let rafId = null, pendingResolve = null;
+  function run(duration, frame) {
+    if (rafId) { cancelAnimationFrame(rafId); if (pendingResolve) pendingResolve(); }
+    return new Promise((resolve) => {
+      pendingResolve = resolve;
+      const t0 = performance.now();
+      (function loop(now) {
+        const t = clamp((now - t0) / duration, 0, 1);
+        try { bg(); frame(t); drawParts(); }
+        catch (err) { console.error('battle frame error', err); rafId = null; pendingResolve = null; resolve(); return; }
+        if (t < 1) rafId = requestAnimationFrame(loop);
+        else { rafId = null; pendingResolve = null; resolve(); }
+      })(t0);
+    });
+  }
+
+  // ── modes ────────────────────────────────────────────
+  const MODES = {
+    async joust(cfg) {
+      banner('⚔ THE JOUST — Champions charge!');
+      const midY = H - 96;
+      const aWins = cfg.outcome === 1, draw = cfg.outcome === 0.5;
+      await run(2600, (t) => {
+        const meet = 0.62;
+        let ax = t < meet ? lerp(70, W / 2 - 60, t / meet) : W / 2 - 60;
+        let bx = t < meet ? lerp(W - 70, W / 2 + 60, t / meet) : W / 2 + 60;
+        // impact
+        let aLean = 0, bLean = 0, ay = midY, by = midY;
+        if (t >= meet) {
+          const k = (t - meet) / (1 - meet);
+          if (draw) { aLean = -0.2 * k; bLean = 0.2 * k; }
+          else if (aWins) { bLean = 0.9 * k; by = midY - 30 * Math.sin(k * 3) + 60 * k * k; bx += 40 * k; }
+          else { aLean = -0.9 * k; ay = midY - 30 * Math.sin(k * 3) + 60 * k * k; ax -= 40 * k; }
+          if (Math.abs(t - meet) < 0.03) burst(W / 2, midY - 14, '#ffd27a', 40, 6, 'spark');
+        }
+        knight(ax, ay, cfg.aColor, 1, true, aLean);
+        knight(bx, by, cfg.bColor, -1, true, bLean);
+      });
+      banner(draw ? '⚔ A draw of honour!' : `⚔ ${aWins ? cfg.aName : cfg.bName} unhorses the foe!`);
+    },
+
+    async archery(cfg) {
+      banner('🏹 ARCHERY — Loose your arrows!');
+      const cxT = W - 120, cyT = H / 2;
+      const aErr = (1 - cfg.aPower), bErr = (1 - cfg.bPower);
+      const arrows = [];
+      await run(2600, (t) => {
+        // target
+        const rings = ['#e6e6ea', '#2b2b36', '#6aa8ff', '#e05a5a', '#e8c56a'];
+        rings.forEach((c, i) => { ctx.fillStyle = c; ctx.beginPath(); ctx.arc(cxT, cyT, 70 - i * 14, 0, 7); ctx.fill(); });
+        // archers
+        ctx.fillStyle = cfg.aColor; ctx.fillRect(50, cyT - 40, 8, 80);
+        ctx.fillStyle = cfg.bColor; ctx.fillRect(50, cyT + 50, 8, 60);
+        // volley: 3 arrows each, staggered
+        for (let i = 0; i < 3; i++) {
+          const dl = i * 0.12;
+          for (const [who, err, col, yoff] of [['a', aErr, cfg.aColor, -1], ['b', bErr, cfg.bColor, 1]]) {
+            const lt = clamp((t - dl) / 0.7, 0, 1);
+            if (lt <= 0) continue;
+            const spread = err * 46 + 4;
+            const ty = cyT + yoff * (8 + i * 6) + (Math.sin(i * 3 + (who === 'a' ? 0 : 2)) * spread);
+            const x = lerp(60, cxT - 4, ease(lt));
+            const y = lerp(cyT + yoff * 20, ty, ease(lt));
+            ctx.strokeStyle = col; ctx.lineWidth = 2.5; ctx.beginPath();
+            ctx.moveTo(x - 16, y); ctx.lineTo(x, y); ctx.stroke();
+            if (lt >= 1) { ctx.fillStyle = col; ctx.beginPath(); ctx.arc(x, y, 3, 0, 7); ctx.fill(); }
+          }
+        }
+      });
+      const aw = cfg.outcome === 1, dr = cfg.outcome === 0.5;
+      banner(dr ? '🏹 Even marksmanship!' : `🏹 ${aw ? cfg.aName : cfg.bName} strikes nearer the gold!`);
+    },
+
+    async siege(cfg) {
+      banner('🏰 CASTLE SIEGE — Reasoning topples stone!');
+      const stages = cfg.stages || 4;
+      const aWins = cfg.outcome === 1, draw = cfg.outcome === 0.5;
+      // damage each castle takes ∝ opponent power
+      const aDmg = draw ? 0.5 : (aWins ? 0.2 : 0.95);
+      const bDmg = draw ? 0.5 : (aWins ? 0.95 : 0.2);
+      await run(3200, (t) => {
+        const step = Math.floor(t * stages);
+        const aD = clamp((step / stages) * bDmg * (cfg.bPower + .4), 0, 1);
+        const bD = clamp((step / stages) * aDmg * (cfg.aPower + .4), 0, 1);
+        castle(120, H - 40, cfg.aColor, aD);
+        castle(W - 120, H - 40, cfg.bColor, bD);
+        // trebuchet projectiles arcing between, flaming
+        const p = (t * 3) % 1;
+        const dir = Math.floor(t * 3) % 2 === 0; // a->b then b->a
+        const sx = dir ? 160 : W - 160, ex2 = dir ? W - 160 : 160;
+        const x = lerp(sx, ex2, p), y = (H - 90) - Math.sin(p * Math.PI) * 150;
+        ctx.fillStyle = '#ff7a2a'; ctx.beginPath(); ctx.arc(x, y, 6, 0, 7); ctx.fill();
+        burst(x, y, '#ff9a3a', 2, 1.5, 'smoke');
+        if (p > 0.96) burst(ex2, H - 90, '#ffb347', 20, 5, 'spark');
+      });
+      banner(draw ? '🏰 Both keeps stand scarred!' : `🏰 ${aWins ? cfg.aName : cfg.bName} breaches the keep!`);
+    },
+
+    async duel(cfg) {
+      banner('⚔ THE DUEL — Steel meets logic!');
+      let aHP = 1, bHP = 1;
+      const aw = cfg.outcome === 1, dr = cfg.outcome === 0.5;
+      await run(3000, (t) => {
+        const beat = Math.floor(t * 8);
+        // each beat, stronger side lands the blow
+        aHP = clamp(1 - (dr ? 0.5 * t : (aw ? 0.25 : 0.9) * t), 0, 1);
+        bHP = clamp(1 - (dr ? 0.5 * t : (aw ? 0.9 : 0.25) * t), 0, 1);
+        healthBars(cfg, aHP, bHP);
+        const clash = (t * 8) % 1;
+        const ax = lerp(W / 2 - 90, W / 2 - 30, Math.sin(clash * Math.PI));
+        const bx = lerp(W / 2 + 90, W / 2 + 30, Math.sin(clash * Math.PI));
+        swordsman(ax, H - 80, cfg.aColor, 1);
+        swordsman(bx, H - 80, cfg.bColor, -1);
+        if (clash > 0.45 && clash < 0.55) burst(W / 2, H - 96, '#fff2c0', 10, 4, 'spark');
+      });
+      banner(dr ? '⚔ Blades locked — a draw!' : `⚔ ${aw ? cfg.aName : cfg.bName} lands the final blow!`);
+    },
+
+    async dragon(cfg) {
+      banner('🐉 DRAGON CHALLENGE — Race to slay the beast!');
+      let dhpA = 1, dhpB = 1; // each champion's progress against the SAME dragon
+      const aw = cfg.outcome === 1, dr = cfg.outcome === 0.5;
+      await run(3200, (t) => {
+        dhpA = clamp(1 - (aw || dr ? cfg.aPower : cfg.aPower * 0.7) * t * (aw ? 1.1 : 0.9), 0, 1);
+        dhpB = clamp(1 - (!aw || dr ? cfg.bPower : cfg.bPower * 0.7) * t * (aw ? 0.9 : 1.1), 0, 1);
+        // shared dragon health = min progress remaining shown center; two race bars
+        dragon(W / 2, H / 2 - 6, Math.min(dhpA, dhpB));
+        healthBars({ ...cfg, aName: cfg.aName + ' ⚔', bName: cfg.bName + ' ⚔' }, dhpA, dhpB);
+        // magic bolts toward dragon
+        const p = (t * 4) % 1;
+        const ax = lerp(70, W / 2 - 34, ease(p)), bx = lerp(W - 70, W / 2 + 34, ease(p));
+        ctx.fillStyle = cfg.aColor; ctx.beginPath(); ctx.arc(ax, H / 2 + 30, 4, 0, 7); ctx.fill();
+        ctx.fillStyle = cfg.bColor; ctx.beginPath(); ctx.arc(bx, H / 2 + 30, 4, 0, 7); ctx.fill();
+        if (p > 0.95) burst(W / 2, H / 2, '#c58bff', 12, 4, 'spark');
+      });
+      const winner = dhpA < dhpB ? cfg.aName : cfg.bName;
+      banner(dr ? '🐉 Both fell the dragon as one!' : `🐉 ${aw ? cfg.aName : cfg.bName} slays the dragon first!`);
+    },
+  };
+
+  function swordsman(x, y, color, facing) {
+    ctx.save(); ctx.translate(x, y); ctx.scale(facing, 1);
+    const mg = ctx.createLinearGradient(-6, -40, 8, 0);
+    mg.addColorStop(0, '#eef1f6'); mg.addColorStop(1, '#5a5f6b');
+    ctx.fillStyle = mg; ctx.beginPath(); ctx.roundRect(-7, -36, 16, 34, 5); ctx.fill();
+    ctx.beginPath(); ctx.arc(1, -44, 8, 0, 7); ctx.fill();
+    ctx.fillStyle = color; ctx.fillRect(-3, -52, 4, 8);
+    ctx.fillStyle = color; ctx.globalAlpha = .8; ctx.fillRect(-9, -20, 6, 24); ctx.globalAlpha = 1; // shield-side cloak
+    ctx.strokeStyle = '#dfe3ea'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(6, -30); ctx.lineTo(34, -46); ctx.stroke();
+    ctx.restore();
+  }
+
+  fit();
+  // idle attract animation
+  (function idle() { bg(); banner('Choose your champions and the battle'); })();
+
+  return {
+    fit,
+    async play(mode, cfg) {
+      fit(); parts = [];
+      const fn = MODES[mode] || MODES.joust;
+      await fn(cfg);
+    },
+  };
+})();
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..d24ce7c
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,135 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<title>MODEL WARS — The AI Tournament</title>
+<meta name="description" content="Leading AI models battle head-to-head in a medieval tournament. Real API battles, AI judges, and a persistent medieval ELO ladder." />
+<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ctext y='26' font-size='26'%3E%E2%9A%94%EF%B8%8F%3C/text%3E%3C/svg%3E" />
+<link rel="preconnect" href="https://fonts.googleapis.com" />
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+<link rel="stylesheet" href="styles.css" />
+</head>
+<body>
+<canvas id="embers"></canvas>
+<canvas id="torch"></canvas>
+<div class="parallax" id="parallax"></div>
+<div class="vignette"></div>
+
+<div class="wrap">
+  <header class="hero">
+    <div class="crest-line">Anno Domini MMXXVI · The Grand Tournament</div>
+    <h1 class="wordmark">MODEL WARS</h1>
+    <div class="subtitle">Where Champions of Silicon Clash for Glory</div>
+    <div id="modeFlag" class="mode-flag mode-exh"><span class="dot"></span><span id="modeText">Checking champions…</span></div>
+  </header>
+
+  <nav class="tabs">
+    <button class="tab active" data-view="arena">⚔ The Arena</button>
+    <button class="tab" data-view="table">👑 The King's Table</button>
+  </nav>
+
+  <!-- ══════════ ARENA ══════════ -->
+  <section class="view active" id="view-arena">
+    <div class="glass" style="padding:20px">
+      <div class="panel-title">Choose Your Champions</div>
+      <div class="combatants">
+        <div class="fighter side-a glass" id="fighterA">
+          <div class="crest">⚔️</div>
+          <div class="cname">—</div>
+          <div class="ctitle">—</div>
+          <select id="selA"></select>
+          <div class="avail off"><span class="d"></span><span class="txt">—</span></div>
+        </div>
+        <div class="vs-emblem">⚜<br />VS</div>
+        <div class="fighter side-b glass" id="fighterB">
+          <div class="crest">🗡️</div>
+          <div class="cname">—</div>
+          <div class="ctitle">—</div>
+          <select id="selB"></select>
+          <div class="avail off"><span class="d"></span><span class="txt">—</span></div>
+        </div>
+      </div>
+    </div>
+
+    <div class="glass" style="padding:20px; margin-top:16px">
+      <div class="panel-title">Choose The Battle</div>
+      <div class="modes" id="modes"></div>
+    </div>
+
+    <div class="glass prompt-row">
+      <label id="promptLabel">The Challenge Scroll (identical prompt sent to both champions)</label>
+      <textarea id="prompt"></textarea>
+      <div class="actions">
+        <button class="btn" id="beginBtn">Begin Battle</button>
+        <span class="hint" id="battleHint"></span>
+      </div>
+    </div>
+
+    <div class="glass stage-wrap" style="margin-top:18px; position:relative">
+      <div class="stage-banner" id="stageBanner"></div>
+      <canvas id="stage"></canvas>
+    </div>
+
+    <div class="results" id="results">
+      <div class="winner-banner glass" id="winnerBanner"></div>
+      <div class="score-grid">
+        <div class="resp-card glass" id="cardA"></div>
+        <div class="resp-card glass" id="cardB"></div>
+      </div>
+      <div class="vote-row">
+        <span class="hint">The People's Choice — who won in your eyes?</span>
+        <button class="btn ghost" id="voteA">🗳 Vote Champion A</button>
+        <button class="btn ghost" id="voteB">🗳 Vote Champion B</button>
+        <button class="btn ghost" id="evidenceBtn">📜 View The Evidence</button>
+      </div>
+      <div class="people" id="peoplesChoice"></div>
+    </div>
+  </section>
+
+  <!-- ══════════ KING'S TABLE ══════════ -->
+  <section class="view" id="view-table">
+    <div class="glass kings-table">
+      <div class="panel-title">👑 The King's Table — Global Tournament Ladder</div>
+      <table class="ladder" id="ladder">
+        <thead><tr>
+          <th>Rank</th><th>Champion</th><th>Provider</th><th>ELO</th>
+          <th>W</th><th>L</th><th>D</th><th>Win %</th><th>Streak</th>
+          <th>Strongest</th><th>Avg ms</th><th>Cost/Battle</th>
+        </tr></thead>
+        <tbody id="ladderBody"></tbody>
+      </table>
+
+      <div class="panel-title" style="margin-top:26px">Summon A New Champion</div>
+      <div class="addrow">
+        <input id="addName" placeholder="Name (e.g. Mistral)" />
+        <input id="addTitle" placeholder="Title (e.g. The Frankish Duelist)" />
+        <input id="addCrest" placeholder="Crest emoji (⚜)" style="width:120px" />
+        <select id="addProvider">
+          <option value="openai">OpenAI-compatible</option>
+          <option value="anthropic">Anthropic</option>
+          <option value="google">Google</option>
+          <option value="xai">xAI</option>
+          <option value="deepseek">DeepSeek</option>
+          <option value="groq">Groq / Llama</option>
+        </select>
+        <button class="btn ghost" id="addBtn">Add To Ladder</button>
+        <button class="btn ghost" id="resetBtn" style="margin-left:auto">Reset Ladder</button>
+      </div>
+      <div class="hint" style="margin-top:8px">New champions enter at ELO 1500. Wire their API key in <code>.env</code> to battle live.</div>
+    </div>
+  </section>
+
+  <footer>
+    <div><b>MODEL WARS</b> — objective measurements (speed, tokens, cost) are <b>measured</b>; the six-dimension scores are <b>AI-judge opinions</b>. People's Choice is tallied separately.</div>
+    <div id="footMode"></div>
+  </footer>
+</div>
+
+<div class="toast" id="toast"></div>
+
+<script src="scene.js"></script>
+<script src="battles.js"></script>
+<script src="app.js"></script>
+</body>
+</html>
diff --git a/public/scene.js b/public/scene.js
new file mode 100644
index 0000000..ada5405
--- /dev/null
+++ b/public/scene.js
@@ -0,0 +1,84 @@
+// scene.js — ambient cinematic layers: embers, cursor torchlight, parallax castle.
+(function () {
+  'use strict';
+
+  // ── Parallax castle silhouette (inline SVG data-uri, no asset needed) ──
+  const castle = encodeURIComponent(`
+  <svg xmlns='http://www.w3.org/2000/svg' width='1600' height='520' viewBox='0 0 1600 520'>
+    <defs><linearGradient id='g' x1='0' y1='0' x2='0' y2='1'>
+      <stop offset='0' stop-color='#0a0a12'/><stop offset='1' stop-color='#05050a'/></linearGradient></defs>
+    <g fill='url(#g)'>
+      <rect x='0' y='360' width='1600' height='160'/>
+      <path d='M120 360 V220 h40 v-30 h30 v30 h40 v-30 h30 v30 h40 V360 Z'/>
+      <path d='M1300 360 V200 h50 v-34 h34 v34 h50 v-34 h34 v34 h50 V360 Z'/>
+      <path d='M640 360 V150 l60 -60 l60 60 V360 Z'/>
+      <path d='M760 360 V180 h80 v-40 h30 v40 h80 V360 Z'/>
+      <path d='M300 360 V260 h60 v-20 h20 v20 h60 V360 Z'/>
+      <path d='M980 360 V240 h70 v-26 h22 v26 h70 V360 Z'/>
+      <path d='M540 360 V300 h40 v-16 h16 v16 h40 V360 Z'/>
+    </g>
+  </svg>`);
+  const px = document.getElementById('parallax');
+  if (px) px.style.backgroundImage = `url("data:image/svg+xml,${castle}")`;
+  window.addEventListener('scroll', () => {
+    if (px) px.style.transform = `translateY(${window.scrollY * 0.15}px)`;
+  }, { passive: true });
+
+  // ── Embers ─────────────────────────────────────────────
+  const ec = document.getElementById('embers');
+  const ex = ec.getContext('2d');
+  let W, H, embers = [];
+  function resize() {
+    W = ec.width = window.innerWidth; H = ec.height = window.innerHeight;
+    const t = document.getElementById('torch');
+    t.width = W; t.height = H;
+  }
+  window.addEventListener('resize', resize); resize();
+
+  function spawn() {
+    return {
+      x: Math.random() * W, y: H + Math.random() * 40,
+      r: Math.random() * 2.2 + 0.6, vy: Math.random() * 0.9 + 0.35,
+      vx: (Math.random() - 0.5) * 0.4, life: Math.random() * 0.7 + 0.3,
+      hue: 18 + Math.random() * 22,
+    };
+  }
+  for (let i = 0; i < 90; i++) { const e = spawn(); e.y = Math.random() * H; embers.push(e); }
+
+  function drawEmbers() {
+    ex.clearRect(0, 0, W, H);
+    for (const e of embers) {
+      e.y -= e.vy; e.x += e.vx + Math.sin(e.y * 0.01) * 0.25; e.life -= 0.0016;
+      if (e.y < -10 || e.life <= 0) Object.assign(e, spawn());
+      const a = Math.max(0, Math.min(1, e.life)) * 0.8;
+      ex.beginPath();
+      const grd = ex.createRadialGradient(e.x, e.y, 0, e.x, e.y, e.r * 4);
+      grd.addColorStop(0, `hsla(${e.hue},100%,62%,${a})`);
+      grd.addColorStop(1, 'hsla(20,100%,50%,0)');
+      ex.fillStyle = grd;
+      ex.arc(e.x, e.y, e.r * 4, 0, Math.PI * 2); ex.fill();
+    }
+    requestAnimationFrame(drawEmbers);
+  }
+  drawEmbers();
+
+  // ── Interactive torchlight following cursor ────────────
+  const tc = document.getElementById('torch');
+  const tx = tc.getContext('2d');
+  let mx = -999, my = -999, cx = -999, cy = -999;
+  window.addEventListener('pointermove', (e) => { mx = e.clientX; my = e.clientY; }, { passive: true });
+  function drawTorch() {
+    cx += (mx - cx) * 0.12; cy += (my - cy) * 0.12;
+    tx.clearRect(0, 0, W, H);
+    if (cx > -500) {
+      const flick = 1 + Math.sin(performance.now() * 0.02) * 0.05;
+      const g = tx.createRadialGradient(cx, cy, 0, cx, cy, 240 * flick);
+      g.addColorStop(0, 'rgba(255,180,90,0.16)');
+      g.addColorStop(0.5, 'rgba(255,120,40,0.06)');
+      g.addColorStop(1, 'rgba(0,0,0,0)');
+      tx.fillStyle = g; tx.fillRect(0, 0, W, H);
+    }
+    requestAnimationFrame(drawTorch);
+  }
+  drawTorch();
+})();
diff --git a/public/styles.css b/public/styles.css
new file mode 100644
index 0000000..f8fc757
--- /dev/null
+++ b/public/styles.css
@@ -0,0 +1,238 @@
+/* MODEL WARS — 2026 liquid-glass × medieval tournament × AAA fantasy UI */
+
+@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700;900&family=Cinzel+Decorative:wght@700;900&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500&display=swap');
+
+:root {
+  --bg:#07070b;
+  --bg2:#0d0e16;
+  --ink:#f3ecdc;
+  --muted:#9a94a8;
+  --gold:#e8c56a;
+  --gold-deep:#a9822f;
+  --silver:#c9ced8;
+  --ember:#ff6a2a;
+  --glass:rgba(22,22,32,.44);
+  --glass-brite:rgba(255,255,255,.06);
+  --stroke:rgba(232,197,106,.22);
+  --stroke-soft:rgba(255,255,255,.09);
+  --emerald:#2ecc71; --azure:#6aa8ff; --crimson:#e05a5a; --violet:#7c5cff;
+  --shadow:0 30px 80px rgba(0,0,0,.55);
+  --radius:20px;
+}
+
+* { box-sizing:border-box; }
+html,body { margin:0; height:100%; }
+body {
+  background:
+    radial-gradient(1200px 700px at 15% -10%, rgba(124,92,255,.10), transparent 60%),
+    radial-gradient(1000px 600px at 100% 0%, rgba(232,197,106,.08), transparent 55%),
+    linear-gradient(180deg,var(--bg2),var(--bg));
+  color:var(--ink);
+  font-family:Inter,system-ui,sans-serif;
+  overflow-x:hidden;
+  -webkit-font-smoothing:antialiased;
+}
+
+/* fixed scene layers */
+#embers, #torch { position:fixed; inset:0; z-index:0; pointer-events:none; }
+#torch { mix-blend-mode:screen; }
+.parallax {
+  position:fixed; left:0; right:0; bottom:0; z-index:0; pointer-events:none;
+  height:52vh; opacity:.5;
+  background:no-repeat bottom center / cover;
+}
+.vignette { position:fixed; inset:0; z-index:1; pointer-events:none;
+  box-shadow:inset 0 0 300px 60px rgba(0,0,0,.75); }
+
+.wrap { position:relative; z-index:2; max-width:1180px; margin:0 auto; padding:26px 22px 90px; }
+
+/* ── header ─────────────────────────────────────────────── */
+header.hero { text-align:center; padding:34px 0 10px; }
+.crest-line { font-family:Cinzel; letter-spacing:.55em; font-size:12px; color:var(--gold);
+  text-transform:uppercase; opacity:.8; }
+h1.wordmark {
+  font-family:'Cinzel Decorative',serif; font-weight:900;
+  font-size:clamp(44px,9vw,104px); line-height:.95; margin:.1em 0 .05em;
+  letter-spacing:.02em;
+  background:linear-gradient(180deg,#fff6dd 0%,var(--gold) 42%,var(--gold-deep) 100%);
+  -webkit-background-clip:text; background-clip:text; color:transparent;
+  text-shadow:0 2px 40px rgba(232,197,106,.25);
+  filter:drop-shadow(0 6px 24px rgba(0,0,0,.6));
+}
+.subtitle { font-family:Cinzel; color:var(--silver); letter-spacing:.28em;
+  text-transform:uppercase; font-size:clamp(10px,1.6vw,13px); opacity:.85; }
+
+.mode-flag {
+  display:inline-flex; gap:8px; align-items:center; margin-top:16px;
+  font-family:'JetBrains Mono',monospace; font-size:12px; padding:7px 14px;
+  border-radius:999px; border:1px solid var(--stroke); background:var(--glass);
+  backdrop-filter:blur(14px);
+}
+.mode-flag .dot { width:8px; height:8px; border-radius:50%; }
+.mode-live .dot { background:var(--emerald); box-shadow:0 0 12px var(--emerald); }
+.mode-exh .dot { background:var(--ember); box-shadow:0 0 12px var(--ember); }
+
+/* ── nav tabs ───────────────────────────────────────────── */
+nav.tabs { display:flex; gap:8px; justify-content:center; margin:26px 0 8px; flex-wrap:wrap; }
+.tab {
+  font-family:Cinzel; font-weight:700; letter-spacing:.12em; text-transform:uppercase;
+  font-size:13px; color:var(--muted); padding:11px 20px; border-radius:14px;
+  border:1px solid transparent; cursor:pointer; background:transparent; transition:.25s;
+}
+.tab:hover { color:var(--ink); }
+.tab.active { color:#1a1305; background:linear-gradient(180deg,#fbe6a6,var(--gold));
+  border-color:var(--gold-deep); box-shadow:0 8px 30px rgba(232,197,106,.28); }
+
+/* ── glass panel base ───────────────────────────────────── */
+.glass {
+  background:linear-gradient(180deg,var(--glass-brite),transparent), var(--glass);
+  border:1px solid var(--stroke-soft);
+  border-radius:var(--radius);
+  backdrop-filter:blur(20px) saturate(1.2);
+  -webkit-backdrop-filter:blur(20px) saturate(1.2);
+  box-shadow:var(--shadow), inset 0 1px 0 rgba(255,255,255,.08);
+  position:relative; overflow:hidden;
+}
+.glass::before { /* refractive sheen */
+  content:''; position:absolute; inset:0; pointer-events:none;
+  background:linear-gradient(115deg,transparent 30%,rgba(255,255,255,.06) 45%,transparent 60%);
+}
+.panel-title { font-family:Cinzel; font-weight:700; letter-spacing:.14em; text-transform:uppercase;
+  font-size:13px; color:var(--gold); margin:0 0 14px; }
+
+/* ── champion selectors ─────────────────────────────────── */
+.combatants { display:grid; grid-template-columns:1fr 64px 1fr; gap:14px; align-items:stretch; margin-top:14px; }
+.vs-emblem { display:grid; place-items:center; font-family:'Cinzel Decorative'; font-size:26px;
+  color:var(--gold); text-shadow:0 0 24px rgba(232,197,106,.4); }
+.fighter { padding:18px; }
+.fighter select {
+  width:100%; margin-top:10px; font-family:Cinzel; font-weight:700; font-size:15px;
+  color:var(--ink); background:rgba(0,0,0,.35); border:1px solid var(--stroke);
+  border-radius:12px; padding:11px 12px; cursor:pointer; appearance:none;
+}
+.fighter .crest { font-size:38px; line-height:1; }
+.fighter .cname { font-family:Cinzel; font-weight:900; font-size:22px; margin-top:8px; }
+.fighter .ctitle { color:var(--muted); font-size:13px; font-style:italic; }
+.avail { display:inline-flex; align-items:center; gap:6px; font-size:11px; font-family:'JetBrains Mono';
+  margin-top:8px; color:var(--muted); }
+.avail .d { width:7px; height:7px; border-radius:50%; }
+.avail.on .d { background:var(--emerald); box-shadow:0 0 8px var(--emerald); }
+.avail.off .d { background:var(--ember); }
+.fighter.side-a { border-left:2px solid color-mix(in srgb,var(--emerald),transparent 40%); }
+.fighter.side-b { border-right:2px solid color-mix(in srgb,var(--crimson),transparent 40%); }
+
+/* ── battle mode cards ──────────────────────────────────── */
+.modes { display:grid; grid-template-columns:repeat(5,1fr); gap:12px; margin-top:16px; }
+.mode-card { padding:16px 12px; text-align:center; cursor:pointer; transition:.22s; border:1px solid var(--stroke-soft); }
+.mode-card:hover { transform:translateY(-3px); border-color:var(--stroke); }
+.mode-card.sel { border-color:var(--gold); box-shadow:0 10px 34px rgba(232,197,106,.22), inset 0 0 0 1px var(--gold); }
+.mode-card .mi { font-size:30px; }
+.mode-card .mn { font-family:Cinzel; font-weight:700; letter-spacing:.08em; font-size:13px; margin-top:8px; text-transform:uppercase; }
+.mode-card .md { font-size:11px; color:var(--muted); margin-top:5px; line-height:1.35; min-height:2.4em; }
+
+/* ── prompt + action ────────────────────────────────────── */
+.prompt-row { margin-top:16px; padding:16px; }
+.prompt-row label { font-family:Cinzel; font-size:12px; letter-spacing:.1em; color:var(--gold); text-transform:uppercase; }
+textarea#prompt {
+  width:100%; margin-top:8px; min-height:74px; resize:vertical; color:var(--ink);
+  background:rgba(0,0,0,.35); border:1px solid var(--stroke); border-radius:12px; padding:12px;
+  font-family:Inter; font-size:14px; line-height:1.5;
+}
+.actions { display:flex; gap:12px; align-items:center; margin-top:14px; flex-wrap:wrap; }
+.btn {
+  font-family:Cinzel; font-weight:700; letter-spacing:.1em; text-transform:uppercase; font-size:14px;
+  padding:14px 30px; border-radius:14px; cursor:pointer; border:1px solid var(--gold-deep);
+  color:#1a1305; background:linear-gradient(180deg,#fce9ad,var(--gold));
+  box-shadow:0 12px 34px rgba(232,197,106,.32); transition:.2s;
+}
+.btn:hover { transform:translateY(-2px); box-shadow:0 16px 44px rgba(232,197,106,.42); }
+.btn:disabled { opacity:.5; cursor:not-allowed; transform:none; }
+.btn.ghost { background:transparent; color:var(--ink); border-color:var(--stroke); box-shadow:none; }
+.hint { color:var(--muted); font-size:12px; }
+
+/* ── battle stage ───────────────────────────────────────── */
+.stage-wrap { margin-top:18px; padding:0; }
+#stage { display:block; width:100%; height:340px; border-radius:var(--radius); }
+.stage-banner { position:absolute; top:12px; left:0; right:0; text-align:center;
+  font-family:Cinzel; letter-spacing:.2em; text-transform:uppercase; font-size:13px; color:var(--gold);
+  text-shadow:0 2px 20px rgba(0,0,0,.8); pointer-events:none; }
+
+/* ── results ────────────────────────────────────────────── */
+.results { margin-top:18px; display:none; }
+.winner-banner { text-align:center; padding:18px; margin-bottom:14px;
+  font-family:'Cinzel Decorative'; font-size:clamp(20px,4vw,34px); font-weight:900; }
+.winner-banner .sub { font-family:Inter; font-size:13px; color:var(--muted); letter-spacing:.04em; margin-top:4px; text-transform:none; }
+.score-grid { display:grid; grid-template-columns:1fr 1fr; gap:14px; }
+.resp-card { padding:18px; }
+.resp-head { display:flex; justify-content:space-between; align-items:center; gap:10px; }
+.resp-head .n { font-family:Cinzel; font-weight:900; font-size:19px; }
+.resp-head .badge { font-family:'JetBrains Mono'; font-size:11px; padding:4px 9px; border-radius:8px;
+  border:1px solid var(--stroke); color:var(--gold); }
+.metrics { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; margin:14px 0; }
+.metric { background:rgba(0,0,0,.28); border:1px solid var(--stroke-soft); border-radius:10px; padding:9px 10px; }
+.metric .k { font-size:10px; letter-spacing:.08em; color:var(--muted); text-transform:uppercase; }
+.metric .v { font-family:'JetBrains Mono'; font-size:15px; margin-top:3px; }
+.aiscore { display:flex; align-items:baseline; gap:8px; margin-top:6px; }
+.aiscore .big { font-family:'Cinzel Decorative'; font-size:40px; font-weight:900; color:var(--gold); }
+.aiscore .of { color:var(--muted); font-size:14px; }
+.dims { margin-top:10px; }
+.dim { display:grid; grid-template-columns:130px 1fr 40px; gap:8px; align-items:center; margin:5px 0; font-size:12px; }
+.dim .bar { height:7px; border-radius:99px; background:rgba(255,255,255,.08); overflow:hidden; }
+.dim .bar i { display:block; height:100%; border-radius:99px;
+  background:linear-gradient(90deg,var(--gold-deep),var(--gold)); }
+.dim.risk .bar i { background:linear-gradient(90deg,#8a2b2b,var(--crimson)); }
+.dim .num { text-align:right; font-family:'JetBrains Mono'; color:var(--muted); }
+.rationale { font-size:12px; color:var(--muted); font-style:italic; margin-top:10px; line-height:1.5; }
+.objective-note { font-size:11px; color:var(--muted); margin-top:8px; }
+.objective-note b { color:var(--azure); }
+
+.vote-row { display:flex; gap:12px; align-items:center; justify-content:center; margin-top:16px; flex-wrap:wrap; }
+.vote-row .btn { padding:11px 22px; font-size:12px; }
+.people { text-align:center; font-size:12px; color:var(--muted); margin-top:8px; }
+
+.response-full { display:none; white-space:pre-wrap; font-family:'JetBrains Mono'; font-size:12.5px;
+  line-height:1.6; color:var(--ink); background:rgba(0,0,0,.35); border:1px solid var(--stroke-soft);
+  border-radius:10px; padding:14px; margin-top:10px; max-height:340px; overflow:auto; }
+
+/* ── King's Table ───────────────────────────────────────── */
+.kings-table { padding:20px; overflow:auto; }
+table.ladder { width:100%; border-collapse:collapse; font-size:13px; }
+table.ladder th { font-family:Cinzel; font-weight:700; text-transform:uppercase; letter-spacing:.08em;
+  font-size:11px; color:var(--gold); text-align:left; padding:10px 10px; border-bottom:1px solid var(--stroke); white-space:nowrap; }
+table.ladder td { padding:11px 10px; border-bottom:1px solid rgba(255,255,255,.05); white-space:nowrap; }
+table.ladder tr:hover td { background:rgba(255,255,255,.03); }
+.champcell { display:flex; align-items:center; gap:10px; }
+.champcell .cr { font-size:20px; }
+.champcell .nm { font-family:Cinzel; font-weight:700; }
+.champcell .tt { color:var(--muted); font-size:11px; font-style:italic; }
+.elo { font-family:'JetBrains Mono'; font-size:16px; color:var(--gold); }
+.rank1 td { background:linear-gradient(90deg,rgba(232,197,106,.12),transparent); }
+.crown { display:inline-block; animation:crownFloat 3s ease-in-out infinite; filter:drop-shadow(0 0 8px rgba(232,197,106,.6)); }
+@keyframes crownFloat { 0%,100%{ transform:translateY(0) rotate(-3deg);} 50%{ transform:translateY(-4px) rotate(3deg);} }
+.streak.pos { color:var(--emerald); } .streak.neg { color:var(--crimson); }
+.delta.pos { color:var(--emerald); } .delta.neg { color:var(--crimson); }
+
+/* ── add champion ───────────────────────────────────────── */
+.addrow { display:flex; gap:10px; flex-wrap:wrap; margin-top:14px; align-items:center; }
+.addrow input, .addrow select { background:rgba(0,0,0,.35); border:1px solid var(--stroke); color:var(--ink);
+  border-radius:10px; padding:9px 11px; font-family:Inter; font-size:13px; }
+
+.view { display:none; }
+.view.active { display:block; animation:fade .4s ease; }
+@keyframes fade { from{ opacity:0; transform:translateY(8px);} to{ opacity:1; transform:none; } }
+
+footer { text-align:center; color:var(--muted); font-size:12px; margin-top:40px; line-height:1.7; }
+footer a { color:var(--gold); text-decoration:none; }
+
+.toast { position:fixed; bottom:22px; left:50%; transform:translateX(-50%) translateY(20px);
+  background:var(--glass); border:1px solid var(--stroke); backdrop-filter:blur(16px);
+  padding:12px 20px; border-radius:12px; font-size:13px; z-index:50; opacity:0; transition:.3s; }
+.toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
+
+@media (max-width:820px){
+  .modes { grid-template-columns:repeat(2,1fr); }
+  .combatants { grid-template-columns:1fr; }
+  .vs-emblem { display:none; }
+  .score-grid { grid-template-columns:1fr; }
+  .dim { grid-template-columns:110px 1fr 34px; }
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..754e5aa
--- /dev/null
+++ b/server.js
@@ -0,0 +1,199 @@
+// server.js — MODEL WARS backend.
+// Serves the arena, proxies REAL model API calls, runs AI judges, and keeps a
+// persistent medieval ELO ladder. No route ever fabricates a model response.
+
+import express from 'express';
+import { readFile, writeFile, mkdir } from 'node:fs/promises';
+import { existsSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import {
+  CHAMPIONS, championWithAvailability, callChampion,
+  judgeCall, judgeProvider,
+} from './providers.js';
+
+// Minimal .env loader (no dependency) — reads KEY=value lines into process.env.
+const __dirname = dirname(fileURLToPath(import.meta.url));
+try {
+  const envPath = join(__dirname, '.env');
+  if (existsSync(envPath)) {
+    for (const line of (await readFile(envPath, 'utf8')).split('\n')) {
+      const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
+      if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
+    }
+  }
+} catch { /* no .env — exhibition mode */ }
+
+const app = express();
+app.use(express.json({ limit: '256kb' }));
+app.use(express.static(join(__dirname, 'public')));
+
+const DATA_DIR = join(__dirname, 'data');
+const LB_PATH = join(DATA_DIR, 'leaderboard.json');
+
+// ── ELO ladder persistence ────────────────────────────────────────────
+const START_ELO = 1500;
+function freshRecord(champ) {
+  return {
+    id: champ.id, name: champ.name, title: champ.title, provider: champ.provider,
+    color: champ.color, crest: champ.crest,
+    elo: SEED_ELO[champ.id] ?? START_ELO,
+    wins: 0, losses: 0, draws: 0, streak: 0,
+    catWins: {},          // { joust: 3, archery: 1, ... }
+    totalRespMs: 0, battles: 0, totalCost: 0,
+    custom: !!champ.custom,
+  };
+}
+// Flavor seeds so the ladder opens with a believable spread (from the brief).
+const SEED_ELO = { gpt: 1847, claude: 1821, gemini: 1789, grok: 1744, deepseek: 1712, llama: 1688 };
+
+async function loadLadder() {
+  try {
+    const raw = JSON.parse(await readFile(LB_PATH, 'utf8'));
+    return raw;
+  } catch {
+    const seed = {};
+    for (const c of CHAMPIONS) seed[c.id] = freshRecord(c);
+    await saveLadder(seed);
+    return seed;
+  }
+}
+async function saveLadder(ladder) {
+  if (!existsSync(DATA_DIR)) await mkdir(DATA_DIR, { recursive: true });
+  await writeFile(LB_PATH, JSON.stringify(ladder, null, 2));
+}
+
+function expectedScore(a, b) { return 1 / (1 + 10 ** ((b - a) / 400)); }
+
+// Apply a result to the ladder. outcome: 1 = A wins, 0 = B wins, 0.5 = draw.
+async function applyResult({ aId, bId, outcome, category, aStats, bStats }) {
+  const ladder = await loadLadder();
+  const A = ladder[aId], B = ladder[bId];
+  if (!A || !B) return null;
+  const K = 32;
+  const eA = expectedScore(A.elo, B.elo);
+  const eB = 1 - eA;
+  const beforeA = A.elo, beforeB = B.elo;
+  A.elo = Math.round(A.elo + K * (outcome - eA));
+  B.elo = Math.round(B.elo + K * ((1 - outcome) - eB));
+
+  const bump = (rec, isWin, isDraw, cat, stats) => {
+    if (isDraw) { rec.draws++; rec.streak = 0; }
+    else if (isWin) { rec.wins++; rec.streak = rec.streak >= 0 ? rec.streak + 1 : 1; if (cat) rec.catWins[cat] = (rec.catWins[cat] || 0) + 1; }
+    else { rec.losses++; rec.streak = rec.streak <= 0 ? rec.streak - 1 : -1; }
+    if (stats) { rec.totalRespMs += stats.total || 0; rec.battles++; rec.totalCost += stats.cost || 0; }
+  };
+  const draw = outcome === 0.5;
+  bump(A, outcome === 1, draw, category, aStats);
+  bump(B, outcome === 0, draw, category, bStats);
+  await saveLadder(ladder);
+  return { ladder, deltas: { [aId]: A.elo - beforeA, [bId]: B.elo - beforeB } };
+}
+
+function strongestCategory(rec) {
+  const e = Object.entries(rec.catWins || {});
+  if (!e.length) return '—';
+  return e.sort((x, y) => y[1] - x[1])[0][0];
+}
+
+function ladderRows(ladder) {
+  const rows = Object.values(ladder).map((r) => {
+    const games = r.wins + r.losses + r.draws;
+    return {
+      ...r,
+      games,
+      winPct: games ? Math.round((r.wins / games) * 100) : 0,
+      avgRespMs: r.battles ? Math.round(r.totalRespMs / r.battles) : null,
+      costPerBattle: r.battles ? r.totalCost / r.battles : null,
+      strongest: strongestCategory(r),
+    };
+  });
+  rows.sort((a, b) => b.elo - a.elo);
+  rows.forEach((r, i) => (r.rank = i + 1));
+  return rows;
+}
+
+// ── Routes ────────────────────────────────────────────────────────────
+
+app.get('/api/champions', (req, res) => {
+  res.json({ champions: CHAMPIONS.map(championWithAvailability), judge: judgeProvider() });
+});
+
+app.get('/api/leaderboard', async (req, res) => {
+  res.json({ rows: ladderRows(await loadLadder()) });
+});
+
+// Register a new champion dynamically (brief: "Allow new models to be added").
+app.post('/api/champions', async (req, res) => {
+  const { id, name, title, provider = 'openai', color = '#c9a961', crest = '⚜' } = req.body || {};
+  if (!id || !name) return res.status(400).json({ error: 'id and name required' });
+  const ladder = await loadLadder();
+  if (!ladder[id]) {
+    ladder[id] = freshRecord({ id, name, title: title || 'The Challenger', provider, color, crest, custom: true });
+    await saveLadder(ladder);
+  }
+  res.json({ ok: true, champion: ladder[id] });
+});
+
+// REAL battle: send the identical prompt to both champions simultaneously.
+app.post('/api/battle', async (req, res) => {
+  const { prompt, a, b } = req.body || {};
+  if (!prompt || !a || !b) return res.status(400).json({ error: 'prompt, a, b required' });
+  const champA = CHAMPIONS.find((c) => c.id === a);
+  const champB = CHAMPIONS.find((c) => c.id === b);
+  if (!champA || !champB) return res.status(400).json({ error: 'unknown champion' });
+
+  const run = async (champ) => {
+    try { return { ok: true, ...(await callChampion(champ, prompt)) }; }
+    catch (e) { return { ok: false, error: String(e.message || e) }; }
+  };
+  const [ra, rb] = await Promise.all([run(champA), run(champB)]);
+  res.json({ a: ra, b: rb });
+});
+
+// AI judge: scores both answers 0–100 across the six dimensions.
+app.post('/api/judge', async (req, res) => {
+  const { prompt, aText, bText, aName = 'A', bName = 'B' } = req.body || {};
+  const provider = judgeProvider();
+  if (!provider) return res.status(503).json({ error: 'no judge available (no API keys configured)' });
+  if (!prompt || aText == null || bText == null) return res.status(400).json({ error: 'prompt, aText, bText required' });
+
+  const rubric = `Judge two answers to the SAME prompt. Score EACH answer 0-100 on: correctness, reasoning, relevance, completeness, instruction_adherence, and hallucination_risk (higher hallucination_risk = MORE hallucination = worse). Also give a one-sentence rationale per answer.
+PROMPT:
+"""${prompt.slice(0, 4000)}"""
+ANSWER_A (${aName}):
+"""${(aText || '').slice(0, 6000)}"""
+ANSWER_B (${bName}):
+"""${(bText || '').slice(0, 6000)}"""
+Return JSON exactly: {"a":{"correctness":0,"reasoning":0,"relevance":0,"completeness":0,"instruction_adherence":0,"hallucination_risk":0,"rationale":""},"b":{...same...}}`;
+  try {
+    const scored = await judgeCall(provider, rubric);
+    res.json({ provider, scored });
+  } catch (e) {
+    res.status(502).json({ error: String(e.message || e) });
+  }
+});
+
+// Record a completed battle onto the ELO ladder.
+app.post('/api/result', async (req, res) => {
+  const { aId, bId, outcome, category, aStats, bStats } = req.body || {};
+  if (!aId || !bId || outcome == null) return res.status(400).json({ error: 'aId, bId, outcome required' });
+  const r = await applyResult({ aId, bId, outcome: Number(outcome), category, aStats, bStats });
+  if (!r) return res.status(400).json({ error: 'unknown champion' });
+  res.json({ ok: true, deltas: r.deltas, rows: ladderRows(r.ladder) });
+});
+
+// Reset the ladder to seeds (admin / demo convenience).
+app.post('/api/leaderboard/reset', async (req, res) => {
+  const seed = {};
+  for (const c of CHAMPIONS) seed[c.id] = freshRecord(c);
+  await saveLadder(seed);
+  res.json({ ok: true, rows: ladderRows(seed) });
+});
+
+const PORT = process.env.PORT || 9911;
+app.listen(PORT, () => {
+  const live = CHAMPIONS.map(championWithAvailability).filter((c) => c.available).map((c) => c.name);
+  console.log(`⚔️  MODEL WARS live at http://localhost:${PORT}`);
+  console.log(live.length ? `   Live champions: ${live.join(', ')}` : '   No API keys — EXHIBITION mode (simulated battles, clearly labeled).');
+});

(oldest)  ·  back to Model Wars  ·  auto-data-snapshot: 2026-08-24T09:04:34 (11 data files) — pu 57498cc →