[object Object]

← back to Model Wars

Composite Stable Diffusion sprites into every bout

bdc08621a66b59ec452705db35400d0cb0cbfaf4 · 2026-08-24 09:18:13 -0700 · Steve Abrams

The combatants are now SDXL art, not vector shapes. gen-sprites.mjs generates
5 transparent-PNG sprites (SDXL → 851-labs background-remover, ~$0.09):
mounted knight, archer, swordsman, dragon boss, trebuchet.

battles.js rewritten to composite the sprites over the scene backdrops with a
drawSprite() transform helper (scale/mirror/rotate/alpha/glow):
- Joust: two knights charge and one is unhorsed (rotate + fall + fade).
- Archery: real archers loose arrows at a scoring target.
- Siege: two trebuchets trade flaming volleys; castle-integrity health bars.
- Duel: two swordsmen clash at center with health bars.
- Dragon: the SDXL dragon boss shakes/reddens/fades as both race to slay it.
Champion-colored auras + pennants carry identity on the neutral sprites;
vector fallback if a sprite hasn't loaded. All 5 modes verified, zero errors.

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

Files touched

Diff

commit bdc08621a66b59ec452705db35400d0cb0cbfaf4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 24 09:18:13 2026 -0700

    Composite Stable Diffusion sprites into every bout
    
    The combatants are now SDXL art, not vector shapes. gen-sprites.mjs generates
    5 transparent-PNG sprites (SDXL → 851-labs background-remover, ~$0.09):
    mounted knight, archer, swordsman, dragon boss, trebuchet.
    
    battles.js rewritten to composite the sprites over the scene backdrops with a
    drawSprite() transform helper (scale/mirror/rotate/alpha/glow):
    - Joust: two knights charge and one is unhorsed (rotate + fall + fade).
    - Archery: real archers loose arrows at a scoring target.
    - Siege: two trebuchets trade flaming volleys; castle-integrity health bars.
    - Duel: two swordsmen clash at center with health bars.
    - Dragon: the SDXL dragon boss shakes/reddens/fades as both race to slay it.
    Champion-colored auras + pennants carry identity on the neutral sprites;
    vector fallback if a sprite hasn't loaded. All 5 modes verified, zero errors.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 gen-sprites.mjs                    |  65 ++++++++
 public/assets/sprite-archer.png    | Bin 0 -> 407299 bytes
 public/assets/sprite-dragon.png    | Bin 0 -> 616998 bytes
 public/assets/sprite-knight.png    | Bin 0 -> 528260 bytes
 public/assets/sprite-swordsman.png | Bin 0 -> 610638 bytes
 public/assets/sprite-trebuchet.png | Bin 0 -> 521381 bytes
 public/battles.js                  | 306 +++++++++++++++----------------------
 7 files changed, 189 insertions(+), 182 deletions(-)

diff --git a/gen-sprites.mjs b/gen-sprites.mjs
new file mode 100644
index 0000000..4d7132a
--- /dev/null
+++ b/gen-sprites.mjs
@@ -0,0 +1,65 @@
+// gen-sprites.mjs — SDXL combatant sprites → transparent PNGs (bg removed).
+// Usage: REPLICATE_API_TOKEN=... node gen-sprites.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';
+const RMBG = 'a029dff38972b5fda4ec5d75d7d1cd25aeff621d2cf4946a41055d7db66b80bc'; // 851-labs/background-remover
+const OUT = new URL('./public/assets/', import.meta.url);
+const onlyFilter = (process.argv.find((a) => a.startsWith('only=')) || '').slice(5);
+
+const STYLE = 'AAA fantasy game sprite, highly detailed, dramatic rim lighting, clean flat pure white background, centered, single subject, isolated, no ground shadow';
+const NEG = 'multiple characters, crowd, text, watermark, busy background, scenery, landscape, frame, border, cropped, extra limbs, blurry, lowres';
+
+const JOBS = [
+  { name: 'sprite-knight', w: 832, h: 640, prompt: `a single armored medieval knight riding a barded warhorse and charging with a couched lance, full body strict side profile facing right, gleaming silver steel plate armor, ${STYLE}` },
+  { name: 'sprite-archer', w: 640, h: 768, prompt: `a single medieval archer at full draw aiming a longbow to the right, full body strict side profile facing right, brown leather armor and hood, ${STYLE}` },
+  { name: 'sprite-swordsman', w: 640, h: 768, prompt: `a single armored medieval knight lunging forward with a raised longsword to the right, full body strict side profile facing right, steel plate armor, ${STYLE}` },
+  { name: 'sprite-dragon', w: 832, h: 704, prompt: `a colossal fearsome dragon with wings spread wide and jaws open, front three-quarter view, dark red and black scales, glowing eyes, epic boss creature, ${STYLE}` },
+  { name: 'sprite-trebuchet', w: 768, h: 640, prompt: `a wooden medieval trebuchet siege engine loaded with a boulder, strict side profile facing right, ${STYLE}` },
+];
+
+async function run(version, input) {
+  const start = await fetch('https://api.replicate.com/v1/predictions', {
+    method: 'POST',
+    headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ version, input }),
+  });
+  let pred = await start.json();
+  if (pred.error) throw new Error(pred.error);
+  while (!['succeeded', 'failed', 'canceled'].includes(pred.status)) {
+    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(`${pred.status} ${pred.error || ''}`);
+  return Array.isArray(pred.output) ? pred.output[0] : pred.output;
+}
+
+async function gen(job) {
+  const raw = await run(SDXL, {
+    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,
+  });
+  const cut = await run(RMBG, { image: raw }); // transparent PNG
+  const buf = Buffer.from(await (await fetch(cut)).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.017; // SDXL + rmbg
+let done = 0;
+console.log(`Generating ${jobs.length} sprites (SDXL + bg-remove) · est $${(jobs.length * COST_EACH).toFixed(2)}`);
+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} sprites · ~$${(done * COST_EACH).toFixed(2)}.`);
diff --git a/public/assets/sprite-archer.png b/public/assets/sprite-archer.png
new file mode 100644
index 0000000..7c8fbff
Binary files /dev/null and b/public/assets/sprite-archer.png differ
diff --git a/public/assets/sprite-dragon.png b/public/assets/sprite-dragon.png
new file mode 100644
index 0000000..d44cd13
Binary files /dev/null and b/public/assets/sprite-dragon.png differ
diff --git a/public/assets/sprite-knight.png b/public/assets/sprite-knight.png
new file mode 100644
index 0000000..fc36bd7
Binary files /dev/null and b/public/assets/sprite-knight.png differ
diff --git a/public/assets/sprite-swordsman.png b/public/assets/sprite-swordsman.png
new file mode 100644
index 0000000..f02c959
Binary files /dev/null and b/public/assets/sprite-swordsman.png differ
diff --git a/public/assets/sprite-trebuchet.png b/public/assets/sprite-trebuchet.png
new file mode 100644
index 0000000..7eaa983
Binary files /dev/null and b/public/assets/sprite-trebuchet.png differ
diff --git a/public/battles.js b/public/battles.js
index aa85b7c..aab6535 100644
--- a/public/battles.js
+++ b/public/battles.js
@@ -1,4 +1,4 @@
-// battles.js — cinematic canvas bouts for each tournament mode.
+// battles.js — cinematic canvas bouts using SDXL scene backdrops + sprites.
 // 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 () {
@@ -20,41 +20,70 @@ 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;
-  });
+  // ── image assets (backdrops + transparent sprites) ────
+  function load(src) { const i = new Image(); i.src = src; return i; }
+  const sceneImg = {}; ['joust', 'archery', 'siege', 'duel', 'dragon'].forEach((m) => sceneImg[m] = load(`/assets/scene-${m}.png`));
+  const sprite = {
+    knight: load('/assets/sprite-knight.png'),
+    archer: load('/assets/sprite-archer.png'),
+    swordsman: load('/assets/sprite-swordsman.png'),
+    dragon: load('/assets/sprite-dragon.png'),
+    trebuchet: load('/assets/sprite-trebuchet.png'),
+  };
   let currentScene = null;
+  const ready = (im) => im && im.complete && im.naturalWidth > 0;
 
-  function coverDraw(im) { // draw image covering WxH, centered
+  function coverDraw(im) {
     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 im = currentScene && sceneImg[currentScene];
-    if (im && im.complete && im.naturalWidth) {
+    if (ready(im)) {
       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);
+      ctx.fillStyle = 'rgba(6,7,12,.44)'; ctx.fillRect(0, 0, W, H);
+      const v = ctx.createLinearGradient(0, H - 160, 0, H);
+      v.addColorStop(0, 'rgba(5,5,11,0)'); v.addColorStop(1, 'rgba(5,5,11,.8)');
+      ctx.fillStyle = v; ctx.fillRect(0, H - 160, W, 160);
     } 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)
+  // Draw a sprite scaled to targetH, anchored at (cx, anchorY) where anchorY is
+  // the vertical anchor fraction (1 = bottom sits at cy). facing -1 mirrors.
+  function drawSprite(im, cx, cy, targetH, { facing = 1, rot = 0, alpha = 1, anchor = 1, glow = null } = {}) {
+    if (!ready(im)) return false;
+    const scale = targetH / im.naturalHeight;
+    const w = im.naturalWidth * scale, h = targetH;
+    ctx.save();
+    ctx.translate(cx, cy - h * (1 - anchor));
+    ctx.rotate(rot); ctx.scale(facing, 1); ctx.globalAlpha = clamp(alpha, 0, 1);
+    if (glow) { ctx.shadowColor = glow; ctx.shadowBlur = 26; }
+    ctx.drawImage(im, -w / 2, -h, w, h);
+    ctx.restore();
+    return true;
+  }
+
+  // Colored aura + banner so each champion's identity reads on a neutral sprite.
+  function aura(cx, cy, color, r) {
+    const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
+    g.addColorStop(0, color + '66'); g.addColorStop(1, color + '00');
+    ctx.fillStyle = g; ctx.beginPath(); ctx.arc(cx, cy, r, 0, 7); ctx.fill();
+  }
+  function pennant(x, y, color, h = 40, facing = 1) {
+    ctx.save(); ctx.translate(x, y); ctx.scale(facing, 1);
+    ctx.strokeStyle = 'rgba(0,0,0,.5)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, -h); ctx.stroke();
+    ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(0, -h); ctx.lineTo(24, -h + 8); ctx.lineTo(0, -h + 16); ctx.fill();
+    ctx.restore();
+  }
+
+  // particles
   let parts = [];
   function burst(x, y, color, n, spread, kind) {
     for (let i = 0; i < n; i++) {
@@ -66,7 +95,7 @@ window.Stage = (function () {
   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; }
+      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();
     }
@@ -76,93 +105,22 @@ window.Stage = (function () {
 
   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 healthBars(cfg, aFrac, bFrac, tagA, tagB) {
+    const pad = 24, bw = W * 0.3, bh = 12;
     function bar(x, frac, color, name, right) {
-      ctx.fillStyle = 'rgba(0,0,0,.5)'; ctx.fillRect(x, 20, bw, bh);
+      ctx.fillStyle = 'rgba(0,0,0,.55)'; 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.strokeStyle = 'rgba(232,197,106,.5)'; 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);
+      ctx.fillText(name, right ? x + bw : x, 15);
     }
-    bar(pad, aFrac, cfg.aColor, cfg.aName, false);
-    bar(W - pad - bw, bFrac, cfg.bColor, cfg.bName, true);
+    bar(pad, aFrac, cfg.aColor, (cfg.aName) + (tagA || ''), false);
+    bar(W - pad - bw, bFrac, cfg.bColor, (cfg.bName) + (tagB || ''), 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.
+  // ── animation runner (single active; always resolves) ──
   let rafId = null, pendingResolve = null;
   function run(duration, frame) {
     if (rafId) { cancelAnimationFrame(rafId); if (pendingResolve) pendingResolve(); }
@@ -183,51 +141,49 @@ window.Stage = (function () {
   const MODES = {
     async joust(cfg) {
       banner('⚔ THE JOUST — Champions charge!');
-      const midY = H - 96;
+      const midY = H - 40, size = 150;
       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;
+        const meet = 0.6;
+        let ax = t < meet ? lerp(60, W / 2 - 70, t / meet) : W / 2 - 70;
+        let bx = t < meet ? lerp(W - 60, W / 2 + 70, t / meet) : W / 2 + 70;
+        let aRot = 0, bRot = 0, aY = midY, bY = midY, aAlpha = 1, bAlpha = 1;
         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');
+          if (!draw && aWins) { bRot = 0.5 * k; bY = midY + 40 * k * k; bx += 30 * k; bAlpha = 1 - 0.3 * k; }
+          else if (!draw) { aRot = -0.5 * k; aY = midY + 40 * k * k; ax -= 30 * k; aAlpha = 1 - 0.3 * k; }
+          if (Math.abs(t - meet) < 0.03) burst(W / 2, midY - 60, '#ffd27a', 46, 6.5, 'spark');
         }
-        knight(ax, ay, cfg.aColor, 1, true, aLean);
-        knight(bx, by, cfg.bColor, -1, true, bLean);
+        aura(ax, midY - 50, cfg.aColor, 90); aura(bx, midY - 50, cfg.bColor, 90);
+        drawSprite(sprite.knight, ax, aY, size, { facing: 1, rot: aRot, alpha: aAlpha, glow: cfg.aColor });
+        drawSprite(sprite.knight, bx, bY, size, { facing: -1, rot: bRot, alpha: bAlpha, glow: cfg.bColor });
+        pennant(ax - 40, aY - size + 20, cfg.aColor); pennant(bx + 40, bY - size + 20, cfg.bColor, 40, -1);
       });
       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 = [];
+      const cxT = W - 110, cyT = H / 2 - 6, size = 150;
+      const aErr = 1 - cfg.aPower, bErr = 1 - cfg.bPower;
       await run(2600, (t) => {
-        // target
+        // target (backdrop has one but draw a crisp scoring 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
+        rings.forEach((c, i) => { ctx.fillStyle = c; ctx.beginPath(); ctx.arc(cxT, cyT, 62 - i * 12, 0, 7); ctx.fill(); });
+        // two archers stacked on the left
+        aura(70, H - 90, cfg.aColor, 70); aura(70, H - 40, cfg.bColor, 70);
+        drawSprite(sprite.archer, 74, H - 70, size, { facing: 1, glow: cfg.aColor });
+        pennant(30, H - 70 - size + 30, cfg.aColor);
         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]]) {
+          for (const [err, col, sy] of [[aErr, cfg.aColor, H - 70 - size / 2], [bErr, cfg.bColor, H - 70 - size / 2]]) {
             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();
+            const spread = err * 44 + 4;
+            const ty = cyT + (Math.sin(i * 3 + (col === cfg.aColor ? 0 : 2)) * spread);
+            const x = lerp(96, cxT - 4, ease(lt));
+            const y = lerp(sy, 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(); }
           }
         }
@@ -238,88 +194,74 @@ window.Stage = (function () {
 
     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);
+      const aWins = cfg.outcome === 1, draw = cfg.outcome === 0.5, size = 130;
+      const aInteg0 = draw ? 0.5 : aWins ? 0.85 : 0.1;
+      const bInteg0 = draw ? 0.5 : aWins ? 0.1 : 0.85;
       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();
+        const aInteg = clamp(1 - (1 - aInteg0) * t, 0, 1);
+        const bInteg = clamp(1 - (1 - bInteg0) * t, 0, 1);
+        healthBars(cfg, aInteg, bInteg, ' 🏰', ' 🏰');
+        drawSprite(sprite.trebuchet, 90, H - 30, size, { facing: 1 });
+        drawSprite(sprite.trebuchet, W - 90, H - 30, size, { facing: -1 });
+        pennant(90, H - 30 - size, cfg.aColor); pennant(W - 90, H - 30 - size, cfg.bColor, 40, -1);
+        // flaming volleys back and forth
+        const p = (t * 3) % 1, dir = Math.floor(t * 3) % 2 === 0;
+        const sx = dir ? 150 : W - 150, ex = dir ? W - 150 : 150;
+        const x = lerp(sx, ex, p), y = (H - 120) - Math.sin(p * Math.PI) * 150;
+        ctx.fillStyle = '#ff7a2a'; ctx.shadowColor = '#ff7a2a'; ctx.shadowBlur = 16;
+        ctx.beginPath(); ctx.arc(x, y, 6, 0, 7); ctx.fill(); ctx.shadowBlur = 0;
         burst(x, y, '#ff9a3a', 2, 1.5, 'smoke');
-        if (p > 0.96) burst(ex2, H - 90, '#ffb347', 20, 5, 'spark');
+        if (p > 0.95) burst(ex, H - 120, '#ffb347', 24, 6, '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;
+      const aw = cfg.outcome === 1, dr = cfg.outcome === 0.5, size = 168;
       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);
+        const aHP = clamp(1 - (dr ? 0.5 * t : aw ? 0.25 * t : 0.9 * t), 0, 1);
+        const bHP = clamp(1 - (dr ? 0.5 * t : aw ? 0.9 * t : 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');
+        const clash = (t * 8) % 1, lunge = Math.sin(clash * Math.PI);
+        const ax = lerp(W / 2 - 96, W / 2 - 44, lunge);
+        const bx = lerp(W / 2 + 96, W / 2 + 44, lunge);
+        aura(ax, H - 60, cfg.aColor, 70); aura(bx, H - 60, cfg.bColor, 70);
+        drawSprite(sprite.swordsman, ax, H - 20, size, { facing: 1, glow: cfg.aColor });
+        drawSprite(sprite.swordsman, bx, H - 20, size, { facing: -1, glow: cfg.bColor });
+        if (clash > 0.46 && clash < 0.56) burst(W / 2, H - 110, '#fff2c0', 12, 4.5, '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;
+      const aw = cfg.outcome === 1, dr = cfg.outcome === 0.5, size = 210;
       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 dhpA = clamp(1 - (aw || dr ? cfg.aPower : cfg.aPower * 0.75) * t * (aw ? 1.1 : 0.9), 0, 1);
+        const dhpB = clamp(1 - (!aw || dr ? cfg.bPower : cfg.bPower * 0.75) * t * (aw ? 0.9 : 1.1), 0, 1);
+        const hp = Math.min(dhpA, dhpB);
+        const hit = (t * 4) % 1;
+        // dragon shakes + reddens as it takes damage
+        const shake = hit > 0.9 ? (Math.random() - 0.5) * 6 : 0;
+        drawSprite(sprite.dragon, W / 2 + shake, H - 24, size * (0.75 + hp * 0.25), { alpha: 0.35 + hp * 0.65, glow: hp < 0.4 ? '#ff3a2a' : null });
+        healthBars(cfg, dhpA, dhpB, ' ⚔', ' ⚔');
         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 ax = lerp(60, W / 2 - 40, ease(p)), bx = lerp(W - 60, W / 2 + 40, ease(p));
+        for (const [x, col] of [[ax, cfg.aColor], [bx, cfg.bColor]]) {
+          ctx.fillStyle = col; ctx.shadowColor = col; ctx.shadowBlur = 14;
+          ctx.beginPath(); ctx.arc(x, H / 2 + 30, 5, 0, 7); ctx.fill(); ctx.shadowBlur = 0;
+        }
+        if (p > 0.94) burst(W / 2, H / 2, '#c58bff', 14, 5, '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 — 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(); }; });
+  [...Object.values(sceneImg), ...Object.values(sprite)].forEach((im) => { im.onload = () => { if (!rafId) idle(); }; });
 
   return {
     fit,

← a979f43 Redo graphics with Stable Diffusion (SDXL) + fix contrarian  ·  back to Model Wars  ·  5x verification: six-way core 6/6 PASS; add sweep report cb1a8df →