[object Object]

← back to Neon Baguette Battle

Baguette Battle: playable neon-Paris fighting game (baguette fighters, flying food hazards, French-accent voice SFX)

fb4abc6ab5ec9b48e89bf9143169f3e8e4604bd2 · 2026-08-31 12:54:39 -0700 · Steve Abrams

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

Files touched

Diff

commit fb4abc6ab5ec9b48e89bf9143169f3e8e4604bd2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 31 12:54:39 2026 -0700

    Baguette Battle: playable neon-Paris fighting game (baguette fighters, flying food hazards, French-accent voice SFX)
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 .gitignore     |    8 +
 README.md      |   46 +++
 index.html     | 1056 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 screenshot.png |  Bin 0 -> 304810 bytes
 4 files changed, 1110 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4c3e495
--- /dev/null
+++ b/README.md
@@ -0,0 +1,46 @@
+# Baguette Battle — Neon Paris 🥖⚔️
+
+A playable browser fighting game. Two artisanal baguettes duel beneath a neon
+Eiffel Tower while pies, french rolls, and croissants fly across the arena.
+Single self-contained `index.html` — no build step, no dependencies, no assets.
+Open it in any modern browser and play.
+
+Built from a Model Arena challenge ("a game where baguettes in Paris fight each
+other"). The starting artifact (Qwen2.5-local) was just a title card with an
+`alert()`; this is the real game built on top of it — its Parisian-serif look
+and title screen were kept, everything else added.
+
+## Play
+Open `index.html` in a browser → **1 Player (vs CPU)** or **2 Players (local)**.
+Best of 3 rounds. Each round is 60s; on timeout the higher-HP loaf wins.
+
+## Controls
+| Action | Player 1 | Player 2 |
+|---|---|---|
+| Move | `A` / `D` | `←` / `→` |
+| Jump | `W` | `↑` |
+| Block | `S` | `↓` |
+| Poke (fast, weak) | `F` | `,` |
+| Swing (slow, strong, knockback) | `G` | `.` |
+| Crumb Blast (projectile, needs full meter) | `H` | `/` |
+| Pause | `P` | |
+| Mute (audio + voice) | `M` | |
+
+## Features
+- **Real combat**: startup/active/recovery attack frames, blocking with chip
+  damage, knockback, hit-stun, a build-up special meter, and a **Crumb Blast**
+  projectile.
+- **CPU opponent** with an approach/attack/block/retreat/blast state machine.
+- **Flying food hazards**: pies (cream splat, big damage), french rolls (fast),
+  and croissants arc in from off-screen and hit either fighter.
+- **Neon Paris arena**: animated Eiffel Tower, moon, twinkling stars, skyline
+  with lit windows, glowing perspective-grid floor, screen shake + vignette.
+- **Sound**: WebAudio synth SFX for pokes/swings/blocks/blasts/KO, plus comedic
+  **voice lines** via the browser Speech API — an American hamming up a French
+  accent ("Take zat!", "Oh la la, magnifique!", "Zut alors!").
+- Health/meter bars, round pips, round timer, KO / winner flow, pause menu.
+
+## Notes
+- Voice lines use `speechSynthesis`; they sound best when a French system voice
+  is installed (the game auto-selects one). Everything still works without it.
+- 100% local, $0 to run — canvas + WebAudio + Speech API, zero network calls.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..c3b3234
--- /dev/null
+++ b/index.html
@@ -0,0 +1,1056 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Baguette Battle — Neon Paris</title>
+<style>
+  :root{ --gold:#c9a961; --cream:#f5f2ea; --pink:#ff3d9a; --cyan:#22e0ff; }
+  *{ box-sizing:border-box; }
+  html,body{ margin:0; height:100%; background:#05050a; }
+  body{
+    font-family:'Didot','Bodoni MT','Playfair Display',Georgia,serif;
+    color:var(--cream);
+    display:flex; justify-content:center; align-items:center; min-height:100vh;
+    overflow:hidden;
+    -webkit-user-select:none; user-select:none;
+  }
+  #stage{
+    position:relative;
+    width:min(96vw,960px);
+    aspect-ratio:960/540;
+    border-radius:14px;
+    overflow:hidden;
+    box-shadow:0 0 0 1px rgba(255,255,255,.06), 0 24px 80px rgba(255,61,154,.18), 0 8px 40px rgba(34,224,255,.12);
+    background:#0d0d14;
+  }
+  canvas{ position:absolute; inset:0; width:100%; height:100%; display:block; image-rendering:auto; }
+
+  /* overlay screens */
+  .screen{
+    position:absolute; inset:0; z-index:5;
+    display:flex; flex-direction:column; justify-content:center; align-items:center;
+    text-align:center; padding:24px;
+    background:radial-gradient(120% 90% at 50% 10%, rgba(20,16,40,.55), rgba(5,5,12,.9));
+    backdrop-filter:blur(2px);
+  }
+  .screen.hidden{ display:none; }
+  h1{
+    font-size:clamp(34px,7vw,64px); letter-spacing:.14em; margin:0 0 6px;
+    color:var(--cream);
+    text-shadow:0 0 18px rgba(255,61,154,.65), 0 0 40px rgba(34,224,255,.35);
+  }
+  .sub{ letter-spacing:.35em; text-transform:uppercase; font-size:clamp(10px,1.6vw,14px);
+        color:var(--gold); margin-bottom:26px; }
+  p{ font-size:clamp(13px,1.8vw,16px); line-height:1.5; max-width:640px; color:#d9d3c6; }
+  .btns{ display:flex; gap:16px; flex-wrap:wrap; justify-content:center; margin-top:22px; }
+  button{
+    font-family:inherit; font-size:clamp(14px,2vw,18px); letter-spacing:.08em;
+    padding:14px 30px; border:1px solid rgba(201,169,97,.5); border-radius:8px;
+    background:linear-gradient(180deg,rgba(201,169,97,.18),rgba(201,169,97,.05));
+    color:var(--cream); cursor:pointer; transition:.18s;
+  }
+  button:hover{ border-color:var(--pink); box-shadow:0 0 22px rgba(255,61,154,.4); transform:translateY(-2px); }
+  button.primary{ background:linear-gradient(180deg,#c9a961,#a4854a); color:#1a140a; border-color:#e7c987; font-weight:600; }
+  .controls{ display:flex; gap:34px; flex-wrap:wrap; justify-content:center; margin-top:12px; font-size:13px; }
+  .ctl{ text-align:left; }
+  .ctl h3{ margin:0 0 8px; letter-spacing:.16em; text-transform:uppercase; font-size:12px; }
+  .ctl.p1 h3{ color:#ff6b6b; } .ctl.p2 h3{ color:#6bb8ff; }
+  .ctl table{ border-collapse:collapse; }
+  .ctl td{ padding:2px 10px 2px 0; color:#cfc9bd; }
+  .key{ display:inline-block; min-width:20px; padding:1px 7px; margin-right:2px;
+        border:1px solid rgba(255,255,255,.25); border-radius:4px; background:rgba(255,255,255,.06);
+        font-family:ui-monospace,Menlo,monospace; font-size:11px; color:#fff; }
+  .tiny{ font-size:11px; letter-spacing:.2em; color:#8a8478; margin-top:26px; text-transform:uppercase; }
+  .flag{ display:inline-block; width:34px; height:22px; border-radius:2px; vertical-align:middle;
+         background:linear-gradient(90deg,#0055A4 0 33%,#fff 33% 66%,#EF4135 66% 100%); margin:0 8px; }
+</style>
+</head>
+<body>
+<div id="stage">
+  <canvas id="game" width="960" height="540"></canvas>
+
+  <!-- START SCREEN -->
+  <div class="screen" id="menu">
+    <h1>BAGUETTE BATTLE</h1>
+    <div class="sub"><span class="flag"></span> Neon&nbsp;Paris&nbsp;Combat&nbsp;Arena <span class="flag"></span></div>
+    <p>Two artisanal baguettes settle their differences beneath a neon Eiffel Tower.
+       Poke, swing, block, and unleash the <b>Crumb Blast</b>. Best of 3 rounds wins.</p>
+    <div class="btns">
+      <button class="primary" id="btn1p">1 PLAYER · vs&nbsp;CPU</button>
+      <button id="btn2p">2 PLAYERS · local</button>
+    </div>
+    <div class="controls">
+      <div class="ctl p1"><h3>🥖 Player 1</h3>
+        <table>
+          <tr><td>Move</td><td><span class="key">A</span><span class="key">D</span></td></tr>
+          <tr><td>Jump</td><td><span class="key">W</span></td></tr>
+          <tr><td>Block</td><td><span class="key">S</span></td></tr>
+          <tr><td>Poke</td><td><span class="key">F</span></td></tr>
+          <tr><td>Swing</td><td><span class="key">G</span></td></tr>
+          <tr><td>Crumb Blast</td><td><span class="key">H</span></td></tr>
+        </table>
+      </div>
+      <div class="ctl p2"><h3>🥖 Player 2</h3>
+        <table>
+          <tr><td>Move</td><td><span class="key">←</span><span class="key">→</span></td></tr>
+          <tr><td>Jump</td><td><span class="key">↑</span></td></tr>
+          <tr><td>Block</td><td><span class="key">↓</span></td></tr>
+          <tr><td>Poke</td><td><span class="key">,</span></td></tr>
+          <tr><td>Swing</td><td><span class="key">.</span></td></tr>
+          <tr><td>Crumb Blast</td><td><span class="key">/</span></td></tr>
+        </table>
+      </div>
+    </div>
+    <div class="tiny">Press&nbsp;<span class="key">P</span>&nbsp;to pause · <span class="key">M</span>&nbsp;mute</div>
+  </div>
+
+  <!-- RESULT SCREEN -->
+  <div class="screen hidden" id="result">
+    <h1 id="resultTitle">PLAYER 1 WINS</h1>
+    <div class="sub" id="resultSub">Magnifique!</div>
+    <div class="btns">
+      <button class="primary" id="btnRematch">REMATCH</button>
+      <button id="btnMenu">MAIN MENU</button>
+    </div>
+  </div>
+
+  <!-- PAUSE SCREEN -->
+  <div class="screen hidden" id="pause">
+    <h1>PAUSED</h1>
+    <div class="btns">
+      <button class="primary" id="btnResume">RESUME</button>
+      <button id="btnQuit">QUIT TO MENU</button>
+    </div>
+    <div class="tiny">Press&nbsp;<span class="key">P</span>&nbsp;to resume</div>
+  </div>
+</div>
+
+<script>
+"use strict";
+(() => {
+  const cv = document.getElementById('game');
+  const ctx = cv.getContext('2d');
+  const W = cv.width, H = cv.height;
+  const GROUND = H - 92;         // y of the floor line
+  const GRAV = 0.9;
+  const ARENA_L = 60, ARENA_R = W - 60;
+
+  // ---------- Audio (tiny WebAudio synth, no assets) ----------
+  const Audio = (() => {
+    let ac = null, muted = false;
+    const ensure = () => { if (!ac) { try { ac = new (window.AudioContext||window.webkitAudioContext)(); } catch(e){} } return ac; };
+    function tone(freq, dur, type='square', vol=0.15, slideTo=null){
+      if (muted) return; const a = ensure(); if(!a) return;
+      const o = a.createOscillator(), g = a.createGain();
+      o.type = type; o.frequency.value = freq;
+      if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, a.currentTime + dur);
+      g.gain.value = vol;
+      g.gain.exponentialRampToValueAtTime(0.0001, a.currentTime + dur);
+      o.connect(g); g.connect(a.destination);
+      o.start(); o.stop(a.currentTime + dur);
+    }
+    function noise(dur=0.12, vol=0.2){
+      if (muted) return; const a = ensure(); if(!a) return;
+      const n = a.createBufferSource();
+      const buf = a.createBuffer(1, a.sampleRate*dur, a.sampleRate);
+      const d = buf.getChannelData(0);
+      for (let i=0;i<d.length;i++) d[i] = (Math.random()*2-1) * (1 - i/d.length);
+      n.buffer = buf;
+      const g = a.createGain(); g.gain.value = vol;
+      g.gain.exponentialRampToValueAtTime(0.0001, a.currentTime + dur);
+      const f = a.createBiquadFilter(); f.type='bandpass'; f.frequency.value=1400;
+      n.connect(f); f.connect(g); g.connect(a.destination); n.start();
+    }
+    return {
+      poke:  () => tone(520, .08, 'square', .12, 380),
+      swing: () => { tone(180,.18,'sawtooth',.16,90); noise(.14,.14); },
+      block: () => tone(240, .1, 'triangle', .14),
+      hurt:  () => { noise(.16,.22); tone(140,.15,'sawtooth',.12,70); },
+      blast: () => { tone(660,.25,'sawtooth',.16,120); noise(.2,.16); },
+      jump:  () => tone(300,.12,'sine',.1,540),
+      ko:    () => { tone(120,.5,'sawtooth',.2,50); noise(.4,.2); },
+      bell:  () => { tone(880,.5,'sine',.12); tone(1320,.5,'sine',.06); },
+      toggle: () => { muted = !muted; return muted; },
+      resume: () => { const a = ensure(); if (a && a.state === 'suspended') a.resume(); },
+      get muted(){ return muted; }
+    };
+  })();
+
+  // ---------- Voice (American hamming a French accent, via a French TTS voice) ----------
+  const Voice = (() => {
+    let voice = null, enabled = true, lastAt = 0;
+    const has = ('speechSynthesis' in window);
+    function pick(){
+      if (!has) return;
+      const vs = speechSynthesis.getVoices();
+      voice = vs.find(v => /fr-FR|fr_FR/i.test(v.lang))
+           || vs.find(v => /^fr/i.test(v.lang))
+           || vs.find(v => /(français|france|thomas|amelie|aur[eé]lie)/i.test(v.name))
+           || vs[0] || null;
+    }
+    if (has){ pick(); speechSynthesis.onvoiceschanged = pick; }
+    const rnd = a => a[Math.floor(Math.random()*a.length)];
+    function say(text, {rate=0.95, pitch=1.1, force=false} = {}){
+      if (!enabled || Audio.muted || !has) return;
+      const now = performance.now();
+      if (!force && now - lastAt < 750) return;   // throttle chatter
+      lastAt = now;
+      try {
+        if (force) speechSynthesis.cancel();
+        const u = new SpeechSynthesisUtterance(text);
+        if (voice) u.voice = voice;
+        u.lang = voice ? voice.lang : 'fr-FR';
+        u.rate = rate; u.pitch = pitch; u.volume = 1;
+        speechSynthesis.speak(u);
+      } catch(e){}
+    }
+    return {
+      // phonetic English so a French voice reads it with the accent
+      taunt:  () => say(rnd(["Take zat!","Ooh la la!","On garde!","Zut alors!","Eat my baguette!","Ha! Too slow, mon ami!"]), {pitch:1.15}),
+      hurt:   () => say(rnd(["Oh non!","Mon dieu!","Sacrebleu!","Ze pain!","Aïe!"]), {pitch:1.25, rate:1.05}),
+      block:  () => say(rnd(["Non non non!","Not today!"]), {pitch:1.15}),
+      blast:  () => say("Crumb blast! Boom!", {rate:0.9, pitch:1.0, force:true}),
+      round:  () => say("Allez!", {force:true, pitch:1.1}),
+      fight:  () => say("Fight, mon ami!", {force:true, pitch:1.15}),
+      ko:     () => say("Oh la la! Magnifique!", {force:true, pitch:1.05}),
+      win:    () => say(rnd(["Champion de Paris!","Formidable!","Très bien! Ze winner!"]), {force:true}),
+      hazard: () => say(rnd(["Incoming pie!","Look out! Ze french roll!","Duck, mon ami!"]), {pitch:1.15}),
+      stop:   () => { if (has) try { speechSynthesis.cancel(); } catch(e){} },
+      set enabled(v){ enabled = v; if (!v) this.stop(); },
+      get enabled(){ return enabled; }
+    };
+  })();
+
+  // ---------- Input ----------
+  const keys = {};
+  const P1MAP = { left:'a', right:'d', jump:'w', block:'s', poke:'f', swing:'g', blast:'h' };
+  const P2MAP = { left:'arrowleft', right:'arrowright', jump:'arrowup', block:'arrowdown', poke:',', swing:'.', blast:'/' };
+  addEventListener('keydown', e => {
+    const k = e.key.toLowerCase();
+    if (['arrowup','arrowdown','arrowleft','arrowright',' ','/'].includes(k)) e.preventDefault();
+    if (!keys[k]) keys['_pressed_'+k] = true;   // edge trigger
+    keys[k] = true;
+    if (k === 'p') togglePause();
+    if (k === 'm') { const m = Audio.toggle(); Voice.enabled = !m; }
+  });
+  addEventListener('keyup', e => { keys[e.key.toLowerCase()] = false; });
+  const pressed = (k) => { if (keys['_pressed_'+k]) { keys['_pressed_'+k] = false; return true; } return false; };
+
+  // ---------- Fighter ----------
+  const COLORS = {
+    p1: { crust:'#d9a441', crustDk:'#a9761f', accent:'#ff4d4d', beret:'#c0392b', glow:'#ff6b6b' },
+    p2: { crust:'#e0b45a', crustDk:'#b08228', accent:'#4d9dff', beret:'#2c6fb0', glow:'#6bb8ff' },
+  };
+
+  class Fighter {
+    constructor(x, side, name, human){
+      this.x = x; this.y = GROUND; this.vx = 0; this.vy = 0;
+      this.w = 58; this.h = 118;
+      this.side = side;                 // 'p1' | 'p2'
+      this.name = name;
+      this.human = human;               // false => CPU
+      this.facing = side === 'p1' ? 1 : -1;
+      this.reset();
+      this.rounds = 0;
+    }
+    reset(){
+      this.hp = 100; this.meter = 0;
+      this.vx = 0; this.vy = 0; this.onGround = true; this.y = GROUND;
+      this.state = 'idle';              // idle|walk|jump|poke|swing|block|hurt|ko
+      this.stateTime = 0;
+      this.attack = null;               // {type,startup,active,recover,t,hit,dmg,reach,kb}
+      this.hurtTimer = 0; this.blockHold = 0; this.invuln = 0;
+      this.animT = Math.random()*10;
+      this.aiTimer = 0; this.aiIntent = 'idle';
+      this.armSwing = 0;                // visual arm extension 0..1
+    }
+    get top(){ return this.y - this.h; }
+    get cx(){ return this.x; }
+    get cy(){ return this.y - this.h*0.5; }
+    get alive(){ return this.hp > 0; }
+
+    hurtbox(){ return { x:this.x - this.w/2, y:this.y - this.h, w:this.w, h:this.h }; }
+
+    // attack hitbox in front of the fighter, when in active frames
+    hitbox(){
+      if (!this.attack || this.attack.phase !== 'active') return null;
+      const reach = this.attack.reach;
+      const hy = this.attack.type === 'swing' ? this.y - this.h*0.62 : this.y - this.h*0.5;
+      const hh = this.attack.type === 'swing' ? 64 : 34;
+      const hx = this.facing === 1 ? this.x + this.w/2 : this.x - this.w/2 - reach;
+      return { x:hx, y:hy - hh/2, w:reach, h:hh };
+    }
+
+    startAttack(type){
+      if (this.attack || this.state==='hurt' || this.state==='ko' || !this.onGround) return;
+      if (type === 'blast'){
+        if (this.meter < 100) return;
+        this.meter = 0;
+        spawnBlast(this);
+        Audio.blast(); Voice.blast();
+        this.attack = { type:'blast', phase:'recover', t:0, recover:16 };
+        this.state = 'poke'; this.stateTime = 0; this.armSwing = 1;
+        return;
+      }
+      const spec = type === 'poke'
+        ? { startup:4, active:5, recover:9,  dmg:6,  reach:52, kb:5,  self:2.5 }
+        : { startup:9, active:6, recover:20, dmg:15, reach:74, kb:12, self:5 };
+      this.attack = { type, phase:'startup', t:0, hit:false, ...spec };
+      this.state = type; this.stateTime = 0;
+      if (type==='poke') Audio.poke(); else Audio.swing();
+      if (type==='swing' && Math.random()<0.5) Voice.taunt();
+      else if (type==='poke' && Math.random()<0.22) Voice.taunt();
+    }
+
+    takeHit(dmg, kb, fromDir){
+      if (this.invuln > 0 || this.state==='ko') return false;
+      const blocking = this.state === 'block';
+      let dealt = dmg;
+      if (blocking){
+        dealt = Math.ceil(dmg * 0.22);           // chip damage
+        kb *= 0.4;
+        Audio.block(); if (Math.random()<0.45) Voice.block();
+        spawnSpark(this.x + fromDir* -this.w*0.4, this.cy, '#bfe9ff', 6);
+        this.meter = Math.min(100, this.meter + dmg*0.5);
+      } else {
+        Audio.hurt(); if (Math.random()<0.55) Voice.hurt();
+        this.state = 'hurt'; this.stateTime = 0; this.hurtTimer = 16;
+        this.attack = null;
+        spawnSpark(this.x + fromDir* -this.w*0.4, this.cy, '#ffd27a', 14);
+        this.meter = Math.min(100, this.meter + dmg*0.8);
+      }
+      this.hp = Math.max(0, this.hp - dealt);
+      this.vx += fromDir * kb;
+      if (!blocking){ this.vy = -Math.min(9, kb*0.7); this.onGround = false; }
+      if (this.hp <= 0){ this.state='ko'; this.stateTime=0; this.attack=null; }
+      shake = Math.min(16, shake + (blocking?3:8));
+      return true;
+    }
+
+    controlHuman(map){
+      if (this.state==='hurt'||this.state==='ko') return;
+      const left = keys[map.left], right = keys[map.right];
+      const speed = 4.4;
+      const canMove = !this.attack;
+      if (canMove){
+        if (left && !right){ this.vx = -speed; this.facing = -1; }
+        else if (right && !left){ this.vx = speed; this.facing = 1; }
+      }
+      if (keys[map.block] && this.onGround && !this.attack){ this.state = 'block'; }
+      else if (this.state==='block'){ this.state = 'idle'; }
+      if (pressed(map.jump) && this.onGround && !this.attack){ this.vy = -16; this.onGround = false; Audio.jump(); }
+      if (pressed(map.poke))  this.startAttack('poke');
+      if (pressed(map.swing)) this.startAttack('swing');
+      if (pressed(map.blast)) this.startAttack('blast');
+    }
+
+    controlAI(foe){
+      if (this.state==='hurt'||this.state==='ko') return;
+      this.aiTimer--;
+      const dist = foe.x - this.x;
+      const adist = Math.abs(dist);
+      this.facing = dist >= 0 ? 1 : -1;
+      const speed = 4.0;
+
+      // re-decide intent periodically
+      if (this.aiTimer <= 0){
+        this.aiTimer = 14 + Math.floor(Math.random()*22);
+        const r = Math.random();
+        if (this.hp < 28 && r < 0.35) this.aiIntent = 'retreat';
+        else if (adist > 150) this.aiIntent = 'approach';
+        else if (adist < 95){
+          this.aiIntent = r < 0.5 ? 'attack' : (r < 0.72 ? 'block' : (r<0.86?'retreat':'jump'));
+        } else this.aiIntent = r < 0.62 ? 'approach' : 'attack';
+        if (this.meter >= 100 && adist < 260 && r < 0.6) this.aiIntent = 'blast';
+      }
+
+      // react to incoming attack -> chance to block
+      if (foe.attack && foe.attack.phase!=='recover' && adist < 110 && Math.random() < 0.14){
+        this.aiIntent = 'block';
+      }
+
+      if (this.state === 'block' && this.aiIntent !== 'block') this.state = 'idle';
+
+      switch(this.aiIntent){
+        case 'approach': if (!this.attack) this.vx = this.facing * speed; break;
+        case 'retreat':  if (!this.attack) this.vx = -this.facing * speed; break;
+        case 'jump':
+          if (this.onGround && !this.attack){ this.vy=-15; this.onGround=false; Audio.jump(); }
+          if (!this.attack) this.vx = this.facing * speed*0.6;
+          this.aiIntent='approach'; break;
+        case 'block':
+          if (this.onGround && !this.attack) this.state='block';
+          break;
+        case 'attack':
+          if (adist < 100) this.startAttack(Math.random()<0.55?'poke':'swing');
+          else if (!this.attack) this.vx = this.facing * speed;
+          break;
+        case 'blast':
+          if (this.meter>=100) this.startAttack('blast');
+          this.aiIntent='approach'; break;
+      }
+    }
+
+    update(foe){
+      this.animT += 0.16;
+      if (this.invuln>0) this.invuln--;
+      // attack phase machine
+      if (this.attack){
+        this.attack.t++;
+        const a = this.attack;
+        if (a.type==='blast'){
+          if (a.t >= a.recover){ this.attack=null; if(this.state!=='hurt') this.state='idle'; }
+        } else {
+          if (a.phase==='startup' && a.t>=a.startup){ a.phase='active'; a.t=0; }
+          else if (a.phase==='active' && a.t>=a.active){ a.phase='recover'; a.t=0; }
+          else if (a.phase==='recover' && a.t>=a.recover){ this.attack=null; if(this.state!=='hurt') this.state='idle'; }
+          // arm extension visual
+          if (a.phase==='startup') this.armSwing = a.t/Math.max(1,a.startup)*0.5;
+          else if (a.phase==='active') this.armSwing = 1;
+          else this.armSwing = Math.max(0, 1 - a.t/Math.max(1,a.recover));
+        }
+      } else {
+        this.armSwing *= 0.7;
+      }
+
+      if (this.state==='hurt'){ this.hurtTimer--; if (this.hurtTimer<=0 && this.onGround) this.state='idle'; }
+
+      // physics
+      this.x += this.vx;
+      if (!this.attack && this.state!=='hurt') this.vx *= this.onGround ? 0.72 : 0.9;
+      else this.vx *= 0.86;
+
+      this.vy += GRAV;
+      this.y += this.vy;
+      if (this.y >= GROUND){ this.y = GROUND; this.vy = 0; if(!this.onGround){ this.onGround = true; if(this.state==='jump') this.state='idle'; } }
+      else { this.onGround = false; if(this.state!=='hurt' && this.state!=='ko' && !this.attack) this.state='jump'; }
+
+      // arena bounds
+      this.x = Math.max(ARENA_L, Math.min(ARENA_R, this.x));
+
+      // determine walk state
+      if (this.onGround && !this.attack && this.state!=='hurt' && this.state!=='block' && this.state!=='ko'){
+        this.state = Math.abs(this.vx) > 0.6 ? 'walk' : 'idle';
+      }
+      // passive meter gain
+      this.meter = Math.min(100, this.meter + 0.06);
+      this.stateTime++;
+    }
+
+    draw(g){
+      const c = COLORS[this.side];
+      const f = this.facing;
+      g.save();
+      g.translate(this.x, this.y);
+
+      // shadow
+      g.save();
+      g.scale(1, .35);
+      g.beginPath(); g.arc(0, 6/.35, this.w*0.5+6, 0, Math.PI*2);
+      g.fillStyle = 'rgba(0,0,0,.45)'; g.fill();
+      g.restore();
+
+      g.scale(f, 1);   // face direction
+
+      const hurtFlash = this.state==='hurt' && (Math.floor(this.stateTime/2)%2===0);
+      const lean = this.state==='walk' ? Math.sin(this.animT)*3 : 0;
+      const bob  = this.state==='idle' ? Math.sin(this.animT*0.8)*2 : 0;
+      const koFall = this.state==='ko' ? Math.min(1,this.stateTime/26) : 0;
+
+      g.save();
+      g.translate(0, bob);
+      if (koFall>0){ g.rotate(koFall*Math.PI*0.5*0.9); g.translate(0, koFall*10); }
+      g.rotate(lean*Math.PI/180);
+
+      // ---- baguette body ----
+      const bx=0, by=-this.h, bw=this.w, bh=this.h;
+      g.save();
+      g.translate(0,0);
+      // body glow
+      g.shadowColor = c.glow; g.shadowBlur = hurtFlash?26:14;
+      roundRectPath(g, -bw/2, by, bw, bh, bw/2);
+      const grd = g.createLinearGradient(-bw/2,0,bw/2,0);
+      grd.addColorStop(0, c.crustDk); grd.addColorStop(0.5, c.crust); grd.addColorStop(1, c.crustDk);
+      g.fillStyle = hurtFlash ? '#ffffff' : grd; g.fill();
+      g.shadowBlur = 0;
+      // crust rim
+      g.lineWidth = 3; g.strokeStyle = 'rgba(90,55,15,.7)'; g.stroke();
+
+      // baguette diagonal score marks
+      if (!hurtFlash){
+        g.strokeStyle = 'rgba(120,72,20,.85)'; g.lineWidth = 3;
+        for (let i=0;i<5;i++){
+          const yy = by + 22 + i*(bh-40)/4;
+          g.beginPath();
+          g.moveTo(-bw*0.22, yy);
+          g.quadraticCurveTo(0, yy-8, bw*0.24, yy-3);
+          g.stroke();
+        }
+        // flour speckles
+        g.fillStyle = 'rgba(255,248,230,.5)';
+        for (let i=0;i<8;i++){
+          const sx=(Math.sin(i*12.9+this.side.length)*0.5)*bw*0.7;
+          const sy=by+18+((i*37)%(bh-30));
+          g.beginPath(); g.arc(sx, sy, 1.6, 0, Math.PI*2); g.fill();
+        }
+      }
+      g.restore();
+
+      // ---- beret ----
+      g.save();
+      g.translate(0, by+4);
+      g.fillStyle = c.beret;
+      g.beginPath(); g.ellipse(2, 0, bw*0.42, 12, 0, 0, Math.PI*2); g.fill();
+      g.beginPath(); g.arc(2, -6, bw*0.34, Math.PI, 0); g.fill();
+      g.fillStyle = '#1a1a1a'; g.beginPath(); g.arc(bw*0.30, -8, 2.5, 0, Math.PI*2); g.fill(); // stalk
+      g.restore();
+
+      // ---- face ----
+      const eyeY = by + 34;
+      g.fillStyle = '#20140a';
+      // eyes
+      g.beginPath(); g.ellipse(-9, eyeY, 4, 6, 0, 0, Math.PI*2); g.fill();
+      g.beginPath(); g.ellipse( 9, eyeY, 4, 6, 0, 0, Math.PI*2); g.fill();
+      if (this.state!=='ko'){
+        g.fillStyle='#fff';
+        g.beginPath(); g.arc(-8, eyeY-2, 1.6,0,Math.PI*2); g.fill();
+        g.beginPath(); g.arc(10, eyeY-2, 1.6,0,Math.PI*2); g.fill();
+      }
+      // angry brows
+      g.strokeStyle='#20140a'; g.lineWidth=3; g.beginPath();
+      g.moveTo(-15, eyeY-9); g.lineTo(-4, eyeY-5);
+      g.moveTo( 15, eyeY-9); g.lineTo( 4, eyeY-5); g.stroke();
+      // mouth
+      g.beginPath();
+      if (this.state==='ko'){ g.arc(0, eyeY+20, 6, 0, Math.PI, true); }
+      else if (this.state==='hurt'){ g.arc(0, eyeY+18, 7, 0, Math.PI*2); }
+      else if (this.attack){ g.arc(0, eyeY+16, 6, 0, Math.PI*2); }
+      else { g.arc(0, eyeY+20, 8, 0.15*Math.PI, 0.85*Math.PI); }
+      g.stroke();
+
+      // ---- scarf (accent) ----
+      g.fillStyle = c.accent;
+      g.fillRect(-bw/2, by+bh*0.52, bw, 10);
+      g.fillRect(bw/2-14, by+bh*0.52, 10, 26);
+
+      // ---- arms ----
+      const armExt = this.armSwing;
+      g.strokeStyle = c.crustDk; g.lineWidth = 8; g.lineCap='round';
+      // back arm
+      g.beginPath(); g.moveTo(-bw*0.35, by+bh*0.45); g.lineTo(-bw*0.35-8, by+bh*0.62); g.stroke();
+      // front arm (attacking)
+      const shoulderX = bw*0.35, shoulderY = by+bh*0.44;
+      let handX, handY;
+      if (this.state==='swing'){
+        const ang = -0.7 + armExt*2.0;
+        handX = shoulderX + Math.cos(ang)*(46+armExt*34);
+        handY = shoulderY + Math.sin(ang)*(46+armExt*34);
+      } else if (this.state==='poke' || (this.attack&&this.attack.type==='blast')){
+        handX = shoulderX + (34 + armExt*30);
+        handY = shoulderY + 6;
+      } else if (this.state==='block'){
+        handX = shoulderX - 6; handY = shoulderY + 4;
+      } else {
+        handX = shoulderX + 6 + Math.sin(this.animT)*2; handY = shoulderY + 20;
+      }
+      g.beginPath(); g.moveTo(shoulderX, shoulderY); g.lineTo(handX, handY); g.stroke();
+      // little baguette-crumb fist
+      g.fillStyle = c.crust; g.beginPath(); g.arc(handX, handY, 7, 0, Math.PI*2); g.fill();
+
+      // block shield shimmer
+      if (this.state==='block'){
+        g.strokeStyle='rgba(180,225,255,.8)'; g.lineWidth=3;
+        g.beginPath(); g.arc(bw*0.2, by+bh*0.5, bw*0.6, -0.7, 0.7); g.stroke();
+      }
+
+      // ---- legs ----
+      g.strokeStyle=c.crustDk; g.lineWidth=8;
+      const legPhase = this.state==='walk'? Math.sin(this.animT):0;
+      g.beginPath(); g.moveTo(-10, by+bh-6); g.lineTo(-12 - legPhase*8, by+bh+10); g.stroke();
+      g.beginPath(); g.moveTo( 10, by+bh-6); g.lineTo( 12 + legPhase*8, by+bh+10); g.stroke();
+
+      g.restore(); // bob/ko
+      g.restore(); // translate/scale
+    }
+  }
+
+  function roundRectPath(g,x,y,w,h,r){
+    r = Math.min(r, w/2, h/2);
+    g.beginPath();
+    g.moveTo(x+r,y);
+    g.arcTo(x+w,y,x+w,y+h,r);
+    g.arcTo(x+w,y+h,x,y+h,r);
+    g.arcTo(x,y+h,x,y,r);
+    g.arcTo(x,y,x+w,y,r);
+    g.closePath();
+  }
+
+  // ---------- Projectiles & particles ----------
+  let blasts = [], sparks = [], hazards = [], hazardTimer = 140, shake = 0;
+  function spawnBlast(owner){
+    blasts.push({ x: owner.x + owner.facing*owner.w*0.5, y: owner.y - owner.h*0.5,
+                  vx: owner.facing*10, r: 16, owner, life: 90, t:0 });
+  }
+  function spawnSpark(x,y,color,n){
+    for (let i=0;i<n;i++){
+      const a = Math.random()*Math.PI*2, sp = 1+Math.random()*5;
+      sparks.push({ x,y, vx:Math.cos(a)*sp, vy:Math.sin(a)*sp-2, life:20+Math.random()*16, color, r:2+Math.random()*3 });
+    }
+  }
+  function updateProjectiles(f1,f2){
+    for (const b of blasts){
+      b.t++; b.x += b.vx; b.life--;
+      const foe = b.owner === f1 ? f2 : f1;
+      const hb = foe.hurtbox();
+      if (b.x+b.r > hb.x && b.x-b.r < hb.x+hb.w && b.y+b.r>hb.y && b.y-b.r<hb.y+hb.h){
+        if (foe.takeHit(18, 16, Math.sign(b.vx))) { b.life = 0; spawnSpark(b.x,b.y,'#ffb347',18); }
+      }
+      if (b.x < ARENA_L-40 || b.x > ARENA_R+40) b.life = 0;
+    }
+    blasts = blasts.filter(b => b.life>0);
+    for (const s of sparks){ s.x+=s.vx; s.y+=s.vy; s.vy+=0.25; s.life--; }
+    sparks = sparks.filter(s => s.life>0);
+  }
+  function drawProjectiles(g){
+    for (const b of blasts){
+      const c = COLORS[b.owner.side];
+      g.save();
+      g.globalAlpha = 0.9;
+      g.shadowColor = c.glow; g.shadowBlur = 24;
+      const grd = g.createRadialGradient(b.x,b.y,2,b.x,b.y,b.r);
+      grd.addColorStop(0,'#fff'); grd.addColorStop(0.4,c.crust); grd.addColorStop(1,c.glow);
+      g.fillStyle = grd;
+      g.beginPath(); g.arc(b.x,b.y,b.r,0,Math.PI*2); g.fill();
+      // trailing crumbs
+      for (let i=1;i<=3;i++){ g.globalAlpha=0.4/i; g.beginPath(); g.arc(b.x-b.vx*i*1.6,b.y,b.r*(1-i*0.2),0,Math.PI*2); g.fill(); }
+      g.restore();
+    }
+    for (const s of sparks){
+      g.globalAlpha = Math.max(0, s.life/26);
+      g.fillStyle = s.color;
+      g.beginPath(); g.arc(s.x,s.y,s.r,0,Math.PI*2); g.fill();
+    }
+    g.globalAlpha = 1;
+  }
+
+  // ---------- Flying food hazards (pies, french rolls, croissants) ----------
+  function spawnHazard(){
+    const r = Math.random();
+    const type = r < 0.42 ? 'pie' : (r < 0.75 ? 'roll' : 'croissant');
+    const fromLeft = Math.random() < 0.5;
+    hazards.push({
+      type,
+      x: fromLeft ? ARENA_L - 40 : ARENA_R + 40,
+      y: 110 + Math.random() * (GROUND - 210),
+      vx: (fromLeft ? 1 : -1) * (type === 'roll' ? 8.5 + Math.random()*3 : 5.5 + Math.random()*2),
+      vy: type === 'pie' ? -3 - Math.random()*2 : (Math.random()-0.5)*3,
+      rot: 0, spin: (Math.random()-0.5) * 0.45,
+      r: type === 'pie' ? 21 : type === 'croissant' ? 17 : 13,
+      dmg: type === 'pie' ? 12 : type === 'roll' ? 8 : 10,
+      kb:  type === 'pie' ? 12 : type === 'roll' ? 9 : 10,
+      life: 220, hit: false
+    });
+  }
+  function splat(h){
+    spawnSpark(h.x, h.y, h.type === 'pie' ? '#fff6e0' : '#e8c37a', h.type === 'pie' ? 20 : 11);
+    if (h.type === 'pie') spawnSpark(h.x, h.y, '#ffd9e6', 8);
+    shake = Math.min(16, shake + 5);
+  }
+  function updateHazards(dt){
+    if (state === 'fight'){
+      hazardTimer -= dt;
+      if (hazardTimer <= 0){
+        spawnHazard();
+        hazardTimer = 90 + Math.random()*130;
+        Voice.hazard();
+      }
+    }
+    for (const h of hazards){
+      h.x += h.vx; h.y += h.vy; h.vy += 0.14; h.rot += h.spin; h.life--;
+      if (!h.hit){
+        for (const fter of [f1, f2]){
+          const b = fter.hurtbox();
+          if (h.x > b.x && h.x < b.x+b.w && h.y > b.y && h.y < b.y+b.h){
+            h.hit = true;
+            fter.takeHit(h.dmg, h.kb, Math.sign(h.vx) || 1);
+            splat(h); h.life = 0; break;
+          }
+        }
+      }
+      if (h.life > 0 && h.y > GROUND + 2){ splat(h); h.life = 0; }
+      if (h.x < ARENA_L - 90 || h.x > ARENA_R + 90) h.life = 0;
+    }
+    hazards = hazards.filter(h => h.life > 0);
+  }
+  function drawHazards(g){
+    for (const h of hazards){
+      g.save();
+      g.translate(h.x, h.y); g.rotate(h.rot);
+      g.shadowColor = 'rgba(255,220,150,.7)'; g.shadowBlur = 14;
+      if (h.type === 'pie'){
+        g.fillStyle = '#b98a4b'; g.beginPath(); g.arc(0,0,h.r,0,Math.PI*2); g.fill();       // tin
+        g.fillStyle = '#fff6e0'; g.beginPath(); g.arc(0,-2,h.r*0.82,0,Math.PI*2); g.fill();  // cream
+        g.fillStyle = '#ffd0dd'; g.beginPath(); g.arc(0,-3,h.r*0.34,0,Math.PI*2); g.fill();  // cherry
+      } else if (h.type === 'croissant'){
+        g.fillStyle = '#e0a94e'; g.strokeStyle = '#a9761f'; g.lineWidth = 2;
+        g.beginPath();
+        g.arc(0, 0, h.r, 0.18*Math.PI, 1.82*Math.PI);
+        g.arc(5, 0, h.r*0.62, 1.82*Math.PI, 0.18*Math.PI, true);
+        g.closePath(); g.fill(); g.stroke();
+      } else { // french roll
+        g.fillStyle = '#e8c37a'; g.strokeStyle = '#a9761f'; g.lineWidth = 2;
+        g.beginPath(); g.ellipse(0,0,h.r,h.r*0.62,0,0,Math.PI*2); g.fill(); g.stroke();
+        g.strokeStyle = 'rgba(120,72,20,.7)'; g.lineWidth = 1.5;
+        g.beginPath(); g.moveTo(-h.r*0.55,0); g.lineTo(h.r*0.55,0); g.stroke();
+      }
+      g.restore();
+    }
+    g.shadowBlur = 0;
+  }
+
+  // ---------- Background (neon Paris) ----------
+  const stars = Array.from({length:70}, () => ({ x:Math.random()*W, y:Math.random()*GROUND*0.8, r:Math.random()*1.4+0.3, p:Math.random()*Math.PI*2 }));
+  let bgT = 0;
+  function drawBackground(g){
+    bgT += 0.01;
+    // sky
+    const sky = g.createLinearGradient(0,0,0,GROUND);
+    sky.addColorStop(0,'#0a0620'); sky.addColorStop(0.5,'#1a0f38'); sky.addColorStop(1,'#3a1550');
+    g.fillStyle = sky; g.fillRect(0,0,W,GROUND);
+
+    // moon
+    g.save(); g.shadowColor='#fff6d0'; g.shadowBlur=40;
+    g.fillStyle='#f3ecc7'; g.beginPath(); g.arc(W-120,90,34,0,Math.PI*2); g.fill();
+    g.restore();
+
+    // stars
+    for (const s of stars){
+      const tw = 0.5+0.5*Math.sin(bgT*4+s.p);
+      g.globalAlpha = 0.3+tw*0.7; g.fillStyle='#fff';
+      g.beginPath(); g.arc(s.x,s.y,s.r,0,Math.PI*2); g.fill();
+    }
+    g.globalAlpha=1;
+
+    // distant skyline silhouettes with neon windows
+    drawSkyline(g);
+
+    // Eiffel Tower (neon)
+    drawEiffel(g, W*0.5, GROUND, 300);
+
+    // neon grid floor
+    drawFloor(g);
+  }
+  function drawSkyline(g){
+    g.fillStyle='rgba(8,4,20,.85)';
+    const base = GROUND;
+    const blocks = [ [40,120,90],[150,80,140],[240,150,80],[720,110,120],[820,90,150],[900,70,110] ];
+    for (const [x,w,h] of blocks){
+      g.fillRect(x, base-h, w, h);
+      // neon windows
+      g.fillStyle = Math.random()>0 ? 'rgba(34,224,255,.25)' : '#22e0ff';
+      for (let wy=base-h+12; wy<base-8; wy+=16)
+        for (let wx=x+8; wx<x+w-6; wx+=14){
+          g.globalAlpha = 0.12 + (Math.sin(wx*wy)+1)*0.18;
+          g.fillStyle = (wx+wy)%2? '#22e0ff':'#ff3d9a';
+          g.fillRect(wx,wy,5,7);
+        }
+      g.globalAlpha=1; g.fillStyle='rgba(8,4,20,.85)';
+    }
+  }
+  function drawEiffel(g, cx, baseY, h){
+    g.save();
+    g.strokeStyle = '#ff3d9a';
+    g.shadowColor = '#ff3d9a'; g.shadowBlur = 16;
+    g.lineWidth = 3;
+    const topY = baseY - h, topW = 8, baseW = 150;
+    const wAt = (y) => { const t=(baseY-y)/h; return baseW*(1-t)*(1-t)*0.9 + topW; };
+    // legs (curved)
+    for (const dir of [-1,1]){
+      g.beginPath();
+      for (let y=baseY; y>=topY; y-=6){ const w=wAt(y)/2; g.lineTo(cx+dir*w, y); }
+      g.stroke();
+    }
+    // horizontal platforms
+    g.lineWidth=2;
+    for (const py of [baseY-h*0.22, baseY-h*0.5, baseY-h*0.78]){
+      const w = wAt(py)/2; g.beginPath(); g.moveTo(cx-w,py); g.lineTo(cx+w,py); g.stroke();
+    }
+    // cross lattice
+    g.strokeStyle='rgba(34,224,255,.5)'; g.shadowColor='#22e0ff'; g.lineWidth=1;
+    for (let y=baseY; y>topY; y-=22){
+      const w1=wAt(y)/2, w2=wAt(y-22)/2;
+      g.beginPath(); g.moveTo(cx-w1,y); g.lineTo(cx+w2,y-22);
+      g.moveTo(cx+w1,y); g.lineTo(cx-w2,y-22); g.stroke();
+    }
+    // top spire + beacon
+    g.strokeStyle='#ff3d9a'; g.shadowColor='#ff3d9a'; g.lineWidth=3;
+    g.beginPath(); g.moveTo(cx, topY); g.lineTo(cx, topY-24); g.stroke();
+    g.fillStyle='#fff'; g.shadowColor='#fff'; g.shadowBlur=20;
+    g.beginPath(); g.arc(cx, topY-26, 3+Math.sin(bgT*6)*1.5, 0, Math.PI*2); g.fill();
+    g.restore();
+  }
+  function drawFloor(g){
+    g.fillStyle = '#0b0714'; g.fillRect(0, GROUND, W, H-GROUND);
+    g.save();
+    g.strokeStyle='rgba(255,61,154,.6)'; g.shadowColor='#ff3d9a'; g.shadowBlur=8; g.lineWidth=2;
+    g.beginPath(); g.moveTo(0,GROUND); g.lineTo(W,GROUND); g.stroke();
+    // perspective grid
+    g.strokeStyle='rgba(34,224,255,.35)'; g.shadowColor='#22e0ff'; g.lineWidth=1;
+    const vpx = W/2;
+    for (let i=-10;i<=10;i++){
+      const gx = vpx + i*90;
+      g.beginPath(); g.moveTo(vpx + i*16, GROUND); g.lineTo(gx, H); g.stroke();
+    }
+    for (let j=1;j<=6;j++){
+      const y = GROUND + (H-GROUND)*(j/6)*(j/6);
+      g.beginPath(); g.moveTo(0,y); g.lineTo(W,y); g.stroke();
+    }
+    g.restore();
+  }
+
+  // ---------- HUD ----------
+  function drawHUD(g){
+    // health bars
+    drawBar(g, 30, 26, 380, f1, false, COLORS.p1);
+    drawBar(g, W-30-380, 26, 380, f2, true, COLORS.p2);
+    // meter bars
+    drawMeter(g, 30, 60, 260, f1.meter, false, COLORS.p1);
+    drawMeter(g, W-30-260, 60, 260, f2.meter, true, COLORS.p2);
+    // round pips
+    drawPips(g, 30, 78, f1.rounds, false);
+    drawPips(g, W-30, 78, f2.rounds, true);
+    // timer
+    g.fillStyle='#fff'; g.font='700 30px Georgia'; g.textAlign='center';
+    g.shadowColor='#ff3d9a'; g.shadowBlur=12;
+    g.fillText(Math.ceil(roundTime).toString().padStart(2,'0'), W/2, 52);
+    g.shadowBlur=0;
+    g.font='12px Georgia'; g.fillStyle='#c9a961';
+    g.fillText('ROUND '+roundNum, W/2, 70);
+    // announce
+    if (announce.text){
+      g.globalAlpha = Math.min(1, announce.t/12) * Math.min(1, announce.life/16);
+      g.textAlign='center';
+      g.font = '800 '+announce.size+'px Georgia';
+      g.fillStyle = announce.color; g.shadowColor=announce.color; g.shadowBlur=24;
+      g.fillText(announce.text, W/2, H*0.42);
+      g.shadowBlur=0; g.globalAlpha=1;
+    }
+    // mute indicator
+    if (Audio.muted){ g.fillStyle='#8a8478'; g.font='12px Georgia'; g.textAlign='right'; g.fillText('🔇 muted (M)', W-30, H-16); }
+    g.textAlign='left';
+  }
+  function drawBar(g,x,y,w,fighter,flip,c){
+    const h=18;
+    g.fillStyle='rgba(0,0,0,.5)'; g.fillRect(x-2,y-2,w+4,h+4);
+    g.fillStyle='#2a0f22'; g.fillRect(x,y,w,h);
+    const pct = Math.max(0,fighter.hp)/100;
+    const bw = w*pct;
+    const bx = flip ? x+w-bw : x;
+    const grd = g.createLinearGradient(x,0,x+w,0);
+    grd.addColorStop(0, pct>0.3?'#ff9a3d':'#ff3d3d'); grd.addColorStop(1, c.glow);
+    g.fillStyle = grd; g.fillRect(bx, y, bw, h);
+    g.strokeStyle='rgba(255,255,255,.3)'; g.lineWidth=1; g.strokeRect(x,y,w,h);
+    // name
+    g.fillStyle='#fff'; g.font='700 14px Georgia';
+    g.textAlign = flip?'right':'left';
+    g.fillText(fighter.name, flip?x+w:x, y-8);
+    g.textAlign='left';
+  }
+  function drawMeter(g,x,y,w,val,flip,c){
+    const h=8;
+    g.fillStyle='rgba(0,0,0,.5)'; g.fillRect(x,y,w,h);
+    const bw=w*val/100, bx=flip?x+w-bw:x;
+    g.fillStyle = val>=100 ? '#fff' : c.accent;
+    if (val>=100){ g.shadowColor=c.glow; g.shadowBlur=10; }
+    g.fillRect(bx,y,bw,h); g.shadowBlur=0;
+    if (val>=100){ g.fillStyle=c.accent; g.font='700 9px Georgia'; g.textAlign=flip?'right':'left';
+      g.fillText('CRUMB BLAST READY', flip?x+w:x, y+18); g.textAlign='left'; }
+  }
+  function drawPips(g,x,y,n,flip){
+    for (let i=0;i<2;i++){
+      const px = flip ? x-8-i*20 : x+8+i*20;
+      g.beginPath(); g.arc(px,y,6,0,Math.PI*2);
+      g.fillStyle = i<n ? '#ffd166' : 'rgba(255,255,255,.15)';
+      if (i<n){ g.shadowColor='#ffd166'; g.shadowBlur=10; }
+      g.fill(); g.shadowBlur=0;
+    }
+  }
+
+  // ---------- Match / round flow ----------
+  let f1, f2, mode='1p';
+  let state = 'menu';            // menu | intro | fight | koPause | result
+  let roundNum = 1, roundTime = 60, roundTimer=0;
+  let announce = { text:'', color:'#fff', size:56, t:0, life:0 };
+  let koDelay = 0;
+
+  function setAnnounce(text,color='#fff',size=56,life=70){ announce={text,color,size,t:0,life}; }
+
+  function newMatch(m){
+    mode = m;
+    f1 = new Fighter(W*0.32,'p1','BAGUETTE ROUGE', true);
+    f2 = new Fighter(W*0.68,'p2','BAGUETTE BLEUE', mode==='2p');
+    f1.rounds=0; f2.rounds=0; roundNum=1;
+    hideScreens(); Audio.resume();
+    startRound();
+  }
+  function startRound(){
+    f1.reset(); f2.reset();
+    f1.x=W*0.32; f2.x=W*0.68; f1.facing=1; f2.facing=-1;
+    roundTime=60; roundTimer=0; blasts=[]; sparks=[]; hazards=[]; hazardTimer=150; koDelay=0;
+    state='intro'; introT=0;
+    setAnnounce('ROUND '+roundNum,'#c9a961',56,10);
+    Voice.round();
+  }
+  let introT=0;
+  function beginFight(){ state='fight'; setAnnounce('FIGHT!','#ff3d9a',64,40); Audio.bell(); Voice.fight(); }
+
+  function endRound(winner){
+    if (state!=='fight') return;
+    state='koPause'; koDelay=0;
+    if (winner==='draw'){ setAnnounce('DOUBLE K.O.!','#fff',56,120); }
+    else {
+      const wf = winner===f1?f1:f2; wf.rounds++;
+      Audio.ko(); Voice.ko();
+      setAnnounce('K.O.','#ff3d3d',88,120);
+    }
+  }
+  function afterKO(){
+    if (f1.rounds>=2 || f2.rounds>=2 || (roundNum>=3)){
+      // match over
+      let champ = f1.rounds>f2.rounds ? f1 : (f2.rounds>f1.rounds ? f2 : null);
+      showResult(champ);
+    } else {
+      roundNum++;
+      startRound();
+    }
+  }
+
+  // ---------- Screens ----------
+  const $ = id => document.getElementById(id);
+  function hideScreens(){ ['menu','result','pause'].forEach(id=>$(id).classList.add('hidden')); }
+  function showResult(champ){
+    state='result';
+    if (champ){
+      $('resultTitle').textContent = champ.name + ' WINS';
+      $('resultSub').innerHTML = (champ.side==='p1'?'🥖 ':'🥖 ') +
+        ['Magnifique!','Formidable!','Très bien!','Champion de Paris!'][Math.floor(Math.random()*4)];
+      Voice.win();
+    } else {
+      $('resultTitle').textContent = 'DRAW';
+      $('resultSub').textContent = 'Both loaves crumbled.';
+    }
+    $('result').classList.remove('hidden');
+  }
+  let paused=false;
+  function togglePause(){
+    if (state==='menu'||state==='result') return;
+    paused = !paused;
+    $('pause').classList.toggle('hidden', !paused);
+  }
+
+  $('btn1p').onclick = () => newMatch('1p');
+  $('btn2p').onclick = () => newMatch('2p');
+  $('btnRematch').onclick = () => newMatch(mode);
+  $('btnMenu').onclick = () => { state='menu'; hideScreens(); $('menu').classList.remove('hidden'); };
+  $('btnResume').onclick = () => togglePause();
+  $('btnQuit').onclick = () => { paused=false; state='menu'; hideScreens(); $('menu').classList.remove('hidden'); };
+
+  // ---------- Combat resolution ----------
+  function resolveHits(atk, def){
+    if (!atk.attack || atk.attack.hit) return;
+    const hb = atk.hitbox(); if (!hb) return;
+    const db = def.hurtbox();
+    if (hb.x < db.x+db.w && hb.x+hb.w > db.x && hb.y < db.y+db.h && hb.y+hb.h > db.y){
+      atk.attack.hit = true;
+      const dir = Math.sign(atk.facing);
+      const dmg = atk.attack.dmg, kb = atk.attack.kb;
+      def.takeHit(dmg, kb, dir);
+      atk.meter = Math.min(100, atk.meter + dmg*0.6);
+    }
+  }
+
+  // ---------- Main loop ----------
+  let last=performance.now();
+  function loop(now){
+    const dt = Math.min(2, (now-last)/16.67); last=now;
+    requestAnimationFrame(loop);
+    if (!paused) step(dt);
+    render();
+  }
+
+  function step(dt){
+    // announce timers
+    if (announce.life>0){ announce.t+=dt*2; announce.life-=dt; }
+
+    if (state==='intro'){
+      introT += dt;
+      // idle physics so they stand
+      f1.update(f2); f2.update(f1);
+      if (introT > 46) beginFight();
+      return;
+    }
+
+    if (state==='fight'){
+      roundTimer += dt; roundTime = Math.max(0, 60 - roundTimer/60*60*0);  // handled below
+      roundTime = Math.max(0, 60 - roundTimer/60);
+      // control
+      if (f1.human) f1.controlHuman(P1MAP); else f1.controlAI(f2);
+      if (f2.human) f2.controlHuman(P2MAP); else f2.controlAI(f1);
+      f1.update(f2); f2.update(f1);
+      resolveHits(f1,f2); resolveHits(f2,f1);
+      updateProjectiles(f1,f2);
+      updateHazards(dt);
+
+      // separation so loaves don't overlap fully
+      const overlap = (f1.w/2+f2.w/2) - Math.abs(f1.x-f2.x);
+      if (overlap>0){ const push=overlap/2*Math.sign(f1.x-f2.x||1); f1.x+=push; f2.x-=push;
+        f1.x=Math.max(ARENA_L,Math.min(ARENA_R,f1.x)); f2.x=Math.max(ARENA_L,Math.min(ARENA_R,f2.x)); }
+
+      // win checks
+      if (!f1.alive && !f2.alive) endRound('draw');
+      else if (!f1.alive) endRound(f2);
+      else if (!f2.alive) endRound(f1);
+      else if (roundTime<=0){
+        // time out -> higher hp wins
+        if (f1.hp>f2.hp) endRound(f1); else if (f2.hp>f1.hp) endRound(f2); else endRound('draw');
+      }
+    }
+    else if (state==='koPause'){
+      koDelay += dt;
+      f1.update(f2); f2.update(f1);
+      updateProjectiles(f1,f2);
+      updateHazards(dt);
+      if (koDelay>70) afterKO();
+    }
+
+    if (shake>0) shake=Math.max(0, shake-dt*1.2);
+  }
+
+  function render(){
+    ctx.save();
+    if (shake>0){ ctx.translate((Math.random()-0.5)*shake, (Math.random()-0.5)*shake); }
+    drawBackground(ctx);
+
+    if (state!=='menu' && f1 && f2){
+      // draw order: farther-back fighter first (by facing not needed) — draw both
+      drawProjectiles(ctx);
+      // draw the one behind first for slight depth: draw lower hp? just draw p2 then p1
+      f2.draw(ctx); f1.draw(ctx);
+      drawHazards(ctx);
+      drawProjectiles(ctx); // sparks on top
+      drawHUD(ctx);
+    }
+    ctx.restore();
+
+    // subtle scanline / vignette
+    const vg = ctx.createRadialGradient(W/2,H/2,H*0.4,W/2,H/2,H*0.9);
+    vg.addColorStop(0,'rgba(0,0,0,0)'); vg.addColorStop(1,'rgba(0,0,0,.45)');
+    ctx.fillStyle=vg; ctx.fillRect(0,0,W,H);
+  }
+
+  requestAnimationFrame(loop);
+})();
+</script>
+</body>
+</html>
diff --git a/screenshot.png b/screenshot.png
new file mode 100644
index 0000000..0a9643b
Binary files /dev/null and b/screenshot.png differ

(oldest)  ·  back to Neon Baguette Battle  ·  Add two-player character select: 6 baguette fighters with Sp b93e403 →