[object Object]

← back to Model Wars

Redo graphics with Stable Diffusion (SDXL) + fix contrarian findings

a979f438b29ff6a1c40660235a1ba3bba4ab8f33 · 2026-08-24 09:08:56 -0700 · Steve Abrams

Graphics (Stable Diffusion XL via Replicate, ~$0.12 total, 11 images):
- 6 cinematic AAA champion portraits (armored fantasy champions per model)
  shown in fighter cards + King's Table avatars.
- 5 cinematic battle backdrops (joust/archery/siege/duel/dragon) drawn behind
  the canvas bouts, replacing the empty gradient stage.
- gen-art.mjs regenerates them from prompts.

Fixes from the contrarian red-team:
- Integrity: a judge failure in LIVE mode no longer falls through to fabricated
  hash scores that moved the persistent ELO ladder — outcome is decided on
  MEASURED speed alone and labeled 'no AI judge'; three honest score modes
  (judge / simulated / measured-only) with distinct SIMULATED labels.
- Custom champions now merge into the roster server-side (allChampions) so a
  summoned champion actually appears in selectors and can battle.
- Ladder writes serialized through a mutex (no last-write-wins corruption).

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

Files touched

Diff

commit a979f438b29ff6a1c40660235a1ba3bba4ab8f33
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 24 09:08:56 2026 -0700

    Redo graphics with Stable Diffusion (SDXL) + fix contrarian findings
    
    Graphics (Stable Diffusion XL via Replicate, ~$0.12 total, 11 images):
    - 6 cinematic AAA champion portraits (armored fantasy champions per model)
      shown in fighter cards + King's Table avatars.
    - 5 cinematic battle backdrops (joust/archery/siege/duel/dragon) drawn behind
      the canvas bouts, replacing the empty gradient stage.
    - gen-art.mjs regenerates them from prompts.
    
    Fixes from the contrarian red-team:
    - Integrity: a judge failure in LIVE mode no longer falls through to fabricated
      hash scores that moved the persistent ELO ladder — outcome is decided on
      MEASURED speed alone and labeled 'no AI judge'; three honest score modes
      (judge / simulated / measured-only) with distinct SIMULATED labels.
    - Custom champions now merge into the roster server-side (allChampions) so a
      summoned champion actually appears in selectors and can battle.
    - Ladder writes serialized through a mutex (no last-write-wins corruption).
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 gen-art.mjs       | 69 +++++++++++++++++++++++++++++++++++++++++++
 providers.js      | 12 ++++----
 public/app.js     | 88 +++++++++++++++++++++++++++++++++++--------------------
 public/battles.js | 46 ++++++++++++++++++++++-------
 public/index.html |  2 ++
 public/styles.css |  7 +++++
 server.js         | 28 ++++++++++++++++--
 7 files changed, 201 insertions(+), 51 deletions(-)

diff --git a/gen-art.mjs b/gen-art.mjs
new file mode 100644
index 0000000..d20ac3b
--- /dev/null
+++ b/gen-art.mjs
@@ -0,0 +1,69 @@
+// gen-art.mjs — generate MODEL WARS art with Stable Diffusion XL (Replicate).
+// Champions as armored fantasy champions + cinematic battle backdrops.
+// Usage: REPLICATE_API_TOKEN=... node gen-art.mjs [only=name]
+import { writeFile, mkdir } from 'node:fs/promises';
+import { existsSync } from 'node:fs';
+
+const TOKEN = process.env.REPLICATE_API_TOKEN;
+if (!TOKEN) { console.error('REPLICATE_API_TOKEN required'); process.exit(1); }
+const SDXL = '7762fd07cf82c948538e41f63f77d685e02b063e37e496e96eefd46c929f9bdc'; // stability-ai/sdxl
+const OUT = new URL('./public/assets/', import.meta.url);
+const onlyFilter = (process.argv.find((a) => a.startsWith('only=')) || '').slice(5);
+
+const NEG = 'text, watermark, signature, logo, blurry, lowres, deformed, extra limbs, cartoon, flat, ugly, jpeg artifacts, modern clothing, photograph of real person';
+const PORTRAIT = 'epic AAA fantasy game character portrait, ornate polished plate armor, intricate detail, dramatic volumetric cinematic lighting, rim light, dark moody background, liquid glass reflections, gold and silver trim, embers, artstation, unreal engine 5, octane render, 8k';
+const SCENE = 'epic cinematic medieval fantasy environment, AAA game splash art, dramatic volumetric lighting, deep blacks, gold light, fire embers and smoke, atmospheric depth of field, artstation, unreal engine 5, 8k, no people in foreground';
+
+const JOBS = [
+  // champions — 768x768 square portraits
+  { name: 'champ-gpt',      w: 768, h: 768, prompt: `The Emerald Knight, a noble knight in emerald-green enameled plate armor with a glowing emerald gemstone on the breastplate, emerald aura, ${PORTRAIT}` },
+  { name: 'champ-claude',   w: 768, h: 768, prompt: `The Golden Scholar, a wise knight-scholar in radiant golden filigree armor over scholarly robes, holding an ancient tome, warm golden glow, ${PORTRAIT}` },
+  { name: 'champ-gemini',   w: 768, h: 768, prompt: `The Celestial Mage, an ethereal mage in star-flecked azure-blue robes and silver circlet, cosmic nebula magic swirling, celestial blue glow, ${PORTRAIT}` },
+  { name: 'champ-grok',     w: 768, h: 768, prompt: `The Black Knight, a menacing warrior in obsidian-black spiked plate armor with sharp silver edges, red visor glow, ominous, ${PORTRAIT}` },
+  { name: 'champ-deepseek', w: 768, h: 768, prompt: `The Eastern Strategist, a calm master strategist in lacquered violet and jade eastern-inspired armor with a war fan, tactical, violet glow, ${PORTRAIT}` },
+  { name: 'champ-llama',    w: 768, h: 768, prompt: `The Crimson Ranger, an agile ranger in crimson leather and a red hooded cloak, drawing a longbow, forest at dusk, crimson glow, ${PORTRAIT}` },
+  // battle backdrops — 1024x576 wide
+  { name: 'scene-joust',   w: 1024, h: 576, prompt: `a grand medieval tournament jousting arena, long wooden tilt barrier down the center, colorful heraldic banners, crowded timber grandstands, dusk sky, ${SCENE}` },
+  { name: 'scene-archery', w: 1024, h: 576, prompt: `a medieval archery range in a castle courtyard, several large round straw archery targets with painted rings on the right, banners, morning light, ${SCENE}` },
+  { name: 'scene-siege',   w: 1024, h: 576, prompt: `an epic castle siege battlefield, two great stone castles facing each other across a scarred field, trebuchets, flaming projectiles, smoke and debris, ${SCENE}` },
+  { name: 'scene-duel',    w: 1024, h: 576, prompt: `a torchlit medieval stone dueling arena, circular sand floor, flickering wall torches, dark stone columns, tense atmosphere, ${SCENE}` },
+  { name: 'scene-dragon',  w: 1024, h: 576, prompt: `a colossal dragon's lair inside a vast glowing cavern, molten lava cracks, hoard of gold, ember-filled air, ominous scale, ${SCENE}` },
+];
+
+async function gen(job) {
+  const start = await fetch('https://api.replicate.com/v1/predictions', {
+    method: 'POST',
+    headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ version: SDXL, input: {
+      prompt: job.prompt, negative_prompt: NEG, width: job.w, height: job.h,
+      num_outputs: 1, num_inference_steps: 35, guidance_scale: 7.5,
+      scheduler: 'K_EULER', refine: 'expert_ensemble_refiner', apply_watermark: false,
+    } }),
+  });
+  let pred = await start.json();
+  if (pred.error) throw new Error(pred.error);
+  while (pred.status !== 'succeeded' && pred.status !== 'failed' && pred.status !== 'canceled') {
+    await new Promise((r) => setTimeout(r, 1500));
+    pred = await fetch(pred.urls.get, { headers: { Authorization: `Bearer ${TOKEN}` } }).then((x) => x.json());
+  }
+  if (pred.status !== 'succeeded') throw new Error(`${job.name}: ${pred.status} ${pred.error || ''}`);
+  const url = Array.isArray(pred.output) ? pred.output[0] : pred.output;
+  const buf = Buffer.from(await (await fetch(url)).arrayBuffer());
+  await writeFile(new URL(`${job.name}.png`, OUT), buf);
+  return buf.length;
+}
+
+if (!existsSync(OUT)) await mkdir(OUT, { recursive: true });
+const jobs = onlyFilter ? JOBS.filter((j) => j.name.includes(onlyFilter)) : JOBS;
+const COST_EACH = 0.011; // ~SDXL on Replicate
+let done = 0;
+console.log(`Generating ${jobs.length} images via SDXL · est $${(jobs.length * COST_EACH).toFixed(2)} total`);
+for (const job of jobs) {
+  try {
+    const t = Date.now();
+    const bytes = await gen(job);
+    done++;
+    console.log(`✓ ${job.name}.png  ${(bytes / 1024).toFixed(0)}KB  ${((Date.now() - t) / 1000).toFixed(1)}s  ($${(done * COST_EACH).toFixed(2)} spent)`);
+  } catch (e) { console.error(`✗ ${job.name}: ${e.message}`); }
+}
+console.log(`Done. ${done}/${jobs.length} images · ~$${(done * COST_EACH).toFixed(2)} total.`);
diff --git a/providers.js b/providers.js
index 84f2b78..cebb05d 100644
--- a/providers.js
+++ b/providers.js
@@ -34,12 +34,12 @@ export function costOf(model, inTok, outTok) {
 
 // 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: '🏹' },
+  { id: 'gpt',      name: 'GPT',      title: 'The Emerald Knight',     provider: 'openai',   color: '#2ecc71', crest: '⚔️', img: '/assets/champ-gpt.png' },
+  { id: 'claude',   name: 'Claude',   title: 'The Golden Scholar',     provider: 'anthropic',color: '#e8b64c', crest: '📜', img: '/assets/champ-claude.png' },
+  { id: 'gemini',   name: 'Gemini',   title: 'The Celestial Mage',     provider: 'google',   color: '#6aa8ff', crest: '✦', img: '/assets/champ-gemini.png' },
+  { id: 'grok',     name: 'Grok',     title: 'The Black Knight',       provider: 'xai',      color: '#9aa0a6', crest: '🗡️', img: '/assets/champ-grok.png' },
+  { id: 'deepseek', name: 'DeepSeek', title: 'The Eastern Strategist', provider: 'deepseek', color: '#7c5cff', crest: '☯', img: '/assets/champ-deepseek.png' },
+  { id: 'llama',    name: 'Llama',    title: 'The Crimson Ranger',     provider: 'groq',     color: '#e05a5a', crest: '🏹', img: '/assets/champ-llama.png' },
 ];
 
 const PROVIDER_ENV = {
diff --git a/public/app.js b/public/app.js
index 040f4a9..ce5f98f 100644
--- a/public/app.js
+++ b/public/app.js
@@ -64,8 +64,12 @@
     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})`;
+    const portrait = box.querySelector('.portrait');
+    const crest = box.querySelector('.crest');
+    if (c.img) { portrait.src = c.img; portrait.style.display = ''; crest.style.display = 'none'; portrait.style.boxShadow = `0 0 30px ${c.color}55`; }
+    else { portrait.style.display = 'none'; crest.style.display = ''; }
+    crest.textContent = c.crest;
+    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;
@@ -141,7 +145,9 @@
     $('#results').style.display = 'none';
     const live = bothLive();
 
-    let A, B, dimsA, dimsB, exhibition = !live;
+    // scoreMode: 'judge' (real AI judge) · 'sim' (exhibition simulation) · 'none'
+    // (real responses but no judge available → decided on measured speed only).
+    let A, B, dimsA = null, dimsB = null, exhibition = !live, scoreMode = 'sim';
     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());
@@ -149,32 +155,38 @@
           toast('A champion failed to answer — running exhibition. ' + (res.a.error || res.b.error || ''));
           exhibition = true;
         } else {
-          A = res.a; B = res.b;
+          A = res.a; B = res.b; exhibition = false;
           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 (jr.scored) { dimsA = jr.scored.a; dimsB = jr.scored.b; scoreMode = 'judge'; }
+          else { toast('Judge unavailable — winner decided on measured speed only'); scoreMode = 'none'; dimsA = dimsB = null; }
         }
       }
       if (exhibition) {
+        // Simulated bout — clearly labeled, never presented as real model output,
+        // and (below) never allowed to move the persistent King's Table.
+        scoreMode = 'sim';
         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 };
+        A = { ok: true, text: `⟪ EXHIBITION — SIMULATED ⟫ ${a.name} has no live API key, so this bout is a deterministic simulation, NOT a real model response. Add ${a.name}'s key to .env to see genuine output and a judged, counted battle.`, ttft: 300 + hash(a.id) * 500, total: 900 + hash(a.id + 't') * 1600, inTok: 40, outTok: Math.round(40 + hash(a.id) * 120), model: 'simulated', cost: 0, simulated: true };
+        B = { ok: true, text: `⟪ EXHIBITION — SIMULATED ⟫ ${b.name} has no live API key, so this bout is a deterministic simulation, NOT a real model response. Add ${b.name}'s key to .env to see genuine output and a judged, counted battle.`, ttft: 300 + hash(b.id) * 500, total: 900 + hash(b.id + 't') * 1600, inTok: 40, outTok: Math.round(40 + hash(b.id) * 120), model: 'simulated', cost: 0, simulated: true };
       }
 
-      // powers fold AI-judge composite (opinion) with measured speed (objective)
-      const cA = composite(dimsA), cB = composite(dimsB);
+      // Power = AI-judge/simulated composite (when we have dims) folded with the
+      // MEASURED speed (objective). When there is no judge, the outcome is decided
+      // by objective speed ALONE — no fabricated score ever moves the ladder.
       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 cA = dimsA ? composite(dimsA) : null, cB = dimsB ? composite(dimsB) : null;
+      const pA = cA != null ? 0.82 * cA + 0.18 * sA : sA;
+      const pB = cB != null ? 0.82 * cB + 0.18 * sB : sB;
       const eps = 0.015;
       const outcome = Math.abs(pA - pB) < eps ? 0.5 : (pA > pB ? 1 : 0);
 
-      // animate
+      // animate (drive the fight by composite when present, else by speed)
       await Stage.play(mode.id, {
         aColor: a.color, bColor: b.color, aName: a.name, bName: b.name,
-        aPower: cA, bPower: cB, outcome, stages: 4,
+        aPower: cA != null ? cA : sA, bPower: cB != null ? cB : sB, outcome, stages: 4,
       });
 
-      last = { a, b, A, B, dimsA, dimsB, cA, cB, pA, pB, outcome, exhibition, prompt };
+      last = { a, b, A, B, dimsA, dimsB, cA, cB, pA, pB, outcome, exhibition, scoreMode, prompt };
       renderResults();
 
       // Only LIVE, measured battles move the persistent King's Table.
@@ -197,41 +209,53 @@
 
   // ── result cards ───────────────────────────────────────
   function renderResults() {
-    const { a, b, A, B, dimsA, dimsB, pA, pB, outcome, exhibition } = last;
+    const { a, b, A, B, dimsA, dimsB, outcome, exhibition, scoreMode } = last;
+    const tag = exhibition ? ' · EXHIBITION (simulated · not counted on the King\'s Table)'
+      : scoreMode === 'none' ? ' · decided on measured speed (no AI judge available)' : '';
     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>`;
+    if (outcome === 0.5) wb.innerHTML = `A Draw of Honour<div class="sub">${a.name} and ${b.name} are evenly matched${tag}</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>`;
+      wb.innerHTML = `<span style="color:${win.color}">${win.crest} ${win.name} is Victorious!</span><div class="sub">${win.title} claims the ${mode.name}${tag}</div>`;
     }
-    $('#cardA').innerHTML = card(a, A, dimsA, pA, B);
-    $('#cardB').innerHTML = card(b, B, dimsB, pB, A);
+    $('#cardA').innerHTML = card(a, A, dimsA, B, scoreMode);
+    $('#cardB').innerHTML = card(b, B, dimsB, A, scoreMode);
     $('#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);
+  // scoreMode: 'judge' real AI-judge · 'sim' exhibition simulation · 'none' measured-only
+  function card(c, R, dims, other, scoreMode) {
     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('');
+    let scoreBlock = '';
+    if (scoreMode === 'none') {
+      scoreBlock = `<div class="aiscore none"><span class="of">⚖ No AI judge configured — winner decided on <b>measured speed</b> only. The six-dimension scores need a judge model (add a key to <code>.env</code>).</span></div>`;
+    } else {
+      const aiScore = Math.round(composite(dims) * 100);
+      const label = scoreMode === 'sim' ? '/ 100 · SIMULATED estimate' : '/ 100 · AI-Judge opinion';
+      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('');
+      scoreBlock = `<div class="aiscore ${scoreMode}"><span class="big">${aiScore}</span><span class="of">${label}</span></div>
+        <div class="dims">${dimRows}</div>
+        ${dims.rationale ? `<div class="rationale">“${dims.rationale}”</div>` : ''}`;
+    }
+    const badge = scoreMode === 'sim'
+      ? `<span class="badge sim">SIMULATED</span>`
+      : `<span class="badge">${R.model || '—'}</span>`;
     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="resp-head"><div class="n" style="color:${c.color}">${c.crest} ${c.name}</div>${badge}</div>
+      ${scoreBlock}
+      <div class="objective-note"><b>Measured${scoreMode === 'sim' ? ' (simulated)' : ''}:</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])); }
@@ -264,7 +288,7 @@
     $('#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>
+        <td><div class="champcell">${(champById(r.id) && champById(r.id).img) ? `<img class="avatar" src="${champById(r.id).img}" alt="" style="box-shadow:0 0 10px ${r.color}66">` : `<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>
diff --git a/public/battles.js b/public/battles.js
index 17d2d81..aa85b7c 100644
--- a/public/battles.js
+++ b/public/battles.js
@@ -20,14 +20,38 @@ window.Stage = (function () {
   const lerp = (a, b, t) => a + (b - a) * t;
   const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
 
+  // Preloaded SDXL cinematic backdrops, one per mode.
+  const sceneImg = {};
+  ['joust', 'archery', 'siege', 'duel', 'dragon'].forEach((m) => {
+    const im = new Image(); im.src = `/assets/scene-${m}.png`; sceneImg[m] = im;
+  });
+  let currentScene = null;
+
+  function coverDraw(im) { // draw image covering WxH, centered
+    const ir = im.width / im.height, cr = W / H;
+    let dw, dh, dx, dy;
+    if (ir > cr) { dh = H; dw = H * ir; dx = (W - dw) / 2; dy = 0; }
+    else { dw = W; dh = W / ir; dx = 0; dy = (H - dh) / 2; }
+    ctx.drawImage(im, dx, dy, dw, dh);
+  }
+
   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();
+    const im = currentScene && sceneImg[currentScene];
+    if (im && im.complete && im.naturalWidth) {
+      coverDraw(im);
+      // darken for figure/UI contrast + a bottom vignette the action sits on
+      ctx.fillStyle = 'rgba(6,7,12,.42)'; ctx.fillRect(0, 0, W, H);
+      const v = ctx.createLinearGradient(0, H - 150, 0, H);
+      v.addColorStop(0, 'rgba(5,5,11,0)'); v.addColorStop(1, 'rgba(5,5,11,.75)');
+      ctx.fillStyle = v; ctx.fillRect(0, H - 150, W, 150);
+    } else {
+      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);
+      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)
@@ -292,13 +316,15 @@ window.Stage = (function () {
   }
 
   fit();
-  // idle attract animation
-  (function idle() { bg(); banner('Choose your champions and the battle'); })();
+  // idle attract — show the joust backdrop once it loads
+  function idle() { currentScene = 'joust'; bg(); banner('Choose your champions and the battle'); }
+  idle();
+  Object.values(sceneImg).forEach((im) => { im.onload = () => { if (!rafId) idle(); }; });
 
   return {
     fit,
     async play(mode, cfg) {
-      fit(); parts = [];
+      fit(); parts = []; currentScene = sceneImg[mode] ? mode : null;
       const fn = MODES[mode] || MODES.joust;
       await fn(cfg);
     },
diff --git a/public/index.html b/public/index.html
index d24ce7c..dcc9d33 100644
--- a/public/index.html
+++ b/public/index.html
@@ -35,6 +35,7 @@
       <div class="panel-title">Choose Your Champions</div>
       <div class="combatants">
         <div class="fighter side-a glass" id="fighterA">
+          <img class="portrait" alt="" />
           <div class="crest">⚔️</div>
           <div class="cname">—</div>
           <div class="ctitle">—</div>
@@ -43,6 +44,7 @@
         </div>
         <div class="vs-emblem">⚜<br />VS</div>
         <div class="fighter side-b glass" id="fighterB">
+          <img class="portrait" alt="" />
           <div class="crest">🗡️</div>
           <div class="cname">—</div>
           <div class="ctitle">—</div>
diff --git a/public/styles.css b/public/styles.css
index f8fc757..b06c9f8 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -110,6 +110,8 @@ nav.tabs { display:flex; gap:8px; justify-content:center; margin:26px 0 8px; fle
   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 .portrait { width:100%; aspect-ratio:1; object-fit:cover; border-radius:14px;
+  border:1px solid var(--stroke); margin-bottom:8px; display:block; }
 .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; }
@@ -174,7 +176,11 @@ textarea#prompt {
 .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.sim .big { color:var(--ember); }
 .aiscore .of { color:var(--muted); font-size:14px; }
+.aiscore.none { padding:10px 0; }
+.aiscore.none .of { color:var(--ember); font-size:13px; line-height:1.5; }
+.resp-head .badge.sim { color:var(--ember); border-color:color-mix(in srgb,var(--ember),transparent 55%); }
 .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; }
@@ -203,6 +209,7 @@ table.ladder td { padding:11px 10px; border-bottom:1px solid rgba(255,255,255,.0
 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 .avatar { width:34px; height:34px; border-radius:9px; object-fit:cover; border:1px solid var(--stroke); }
 .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); }
diff --git a/server.js b/server.js
index 754e5aa..d0e0c9b 100644
--- a/server.js
+++ b/server.js
@@ -63,10 +63,31 @@ async function saveLadder(ladder) {
   await writeFile(LB_PATH, JSON.stringify(ladder, null, 2));
 }
 
+// Serialize every read-modify-write on the ladder so concurrent battles /
+// champion adds can't clobber each other's writes (last-write-wins corruption).
+let _lock = Promise.resolve();
+function serialize(task) {
+  const run = _lock.then(task, task);
+  _lock = run.then(() => {}, () => {});
+  return run;
+}
+
+// The full roster = static champions + any custom champions registered on the
+// ladder. This is the single source of truth for selectors AND battles, so a
+// summoned champion actually appears and can fight.
+async function allChampions() {
+  const ladder = await loadLadder();
+  const custom = Object.values(ladder)
+    .filter((r) => r.custom && !CHAMPIONS.some((c) => c.id === r.id))
+    .map((r) => ({ id: r.id, name: r.name, title: r.title, provider: r.provider, color: r.color, crest: r.crest, custom: true }));
+  return [...CHAMPIONS, ...custom];
+}
+
 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 }) {
+function applyResult(args) { return serialize(() => _applyResult(args)); }
+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;
@@ -115,8 +136,9 @@ function ladderRows(ladder) {
 
 // ── Routes ────────────────────────────────────────────────────────────
 
-app.get('/api/champions', (req, res) => {
-  res.json({ champions: CHAMPIONS.map(championWithAvailability), judge: judgeProvider() });
+app.get('/api/champions', async (req, res) => {
+  const champs = await allChampions();
+  res.json({ champions: champs.map(championWithAvailability), judge: judgeProvider() });
 });
 
 app.get('/api/leaderboard', async (req, res) => {

← 57498cc auto-data-snapshot: 2026-08-24T09:04:34 (11 data files) — pu  ·  back to Model Wars  ·  Composite Stable Diffusion sprites into every bout bdc0862 →