[object Object]

← back to Neon Baguette Battle

Add two-player character select: 6 baguette fighters with Speed/Power/Defense stats that drive gameplay, per-player cursors, ready-up, CPU auto-pick

b93e4035921bebf0e1c4e7e8c17e9ff3e9d14e6a · 2026-08-31 13:07:02 -0700 · Steve Abrams

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

Files touched

Diff

commit b93e4035921bebf0e1c4e7e8c17e9ff3e9d14e6a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 31 13:07:02 2026 -0700

    Add two-player character select: 6 baguette fighters with Speed/Power/Defense stats that drive gameplay, per-player cursors, ready-up, CPU auto-pick
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 README.md   |  14 +++-
 fight2p.png | Bin 0 -> 303502 bytes
 index.html  | 210 ++++++++++++++++++++++++++++++++++++++++++++++++++++--------
 select.png  | Bin 0 -> 279933 bytes
 4 files changed, 195 insertions(+), 29 deletions(-)

diff --git a/README.md b/README.md
index 4c3e495..03b00a5 100644
--- a/README.md
+++ b/README.md
@@ -11,8 +11,18 @@ other"). The starting artifact (Qwen2.5-local) was just a title card with an
 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.
+Open `index.html` in a browser → **1 Player (vs CPU)** or **2 Players (local)** →
+**character select** → fight. Best of 3 rounds; each round is 60s, and on timeout
+the higher-HP loaf wins.
+
+## Character select
+Pick from 6 baguette fighters, each with different **Speed / Power / Defense**
+that actually change gameplay (movement speed, damage dealt, damage taken):
+Baguette Rouge & Bleue (balanced), Le Costaud (heavy hitter), Petit Éclair
+(fast glass), Pain de Campagne (tank), Baguette Noire (fast, hits hard, fragile).
+Both players choose their own fighter with their own controls (P1 `A`/`D` move,
+`F` ready · P2 `←`/`→` move, `,` ready); in 1-player the CPU auto-picks. You can
+also click a card to choose P1's fighter. `Enter`/`Space` or **START FIGHT** begins.
 
 ## Controls
 | Action | Player 1 | Player 2 |
diff --git a/fight2p.png b/fight2p.png
new file mode 100644
index 0000000..c8b8f2b
Binary files /dev/null and b/fight2p.png differ
diff --git a/index.html b/index.html
index c3b3234..a39ff66 100644
--- a/index.html
+++ b/index.html
@@ -64,6 +64,30 @@
   .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; }
+
+  /* character select */
+  .selHead{ display:flex; align-items:center; justify-content:center; gap:22px; margin:2px 0 12px; flex-wrap:wrap; }
+  .pTag{ font-size:13px; letter-spacing:.08em; padding:6px 14px; border-radius:6px; border:1px solid; min-width:170px; }
+  .pTag.p1{ color:#ff8a8a; border-color:rgba(255,107,107,.5); }
+  .pTag.p2{ color:#8ac2ff; border-color:rgba(107,184,255,.5); }
+  .roster{ display:grid; grid-template-columns:repeat(6,1fr); gap:10px; max-width:720px; margin:2px auto; }
+  @media(max-width:720px){ .roster{ grid-template-columns:repeat(3,1fr); max-width:360px; } }
+  .card{ position:relative; background:rgba(255,255,255,.04); border:2px solid rgba(255,255,255,.1);
+         border-radius:10px; padding:6px 5px 8px; cursor:pointer; transition:.12s; }
+  .card:hover{ border-color:rgba(201,169,97,.6); transform:translateY(-2px); }
+  .card canvas{ width:56px; height:70px; display:block; margin:0 auto 2px; }
+  .card .cname{ font-size:10px; letter-spacing:.02em; color:#eee; text-align:center; line-height:1.15;
+                min-height:24px; display:flex; align-items:center; justify-content:center; font-family:Georgia,serif; }
+  .card .stat{ display:flex; align-items:center; gap:4px; font-size:8px; color:#b8b2a6; margin:2px 3px; letter-spacing:.08em; }
+  .card .stat b{ width:24px; display:inline-block; }
+  .card .bar{ flex:1; height:5px; background:rgba(255,255,255,.12); border-radius:3px; overflow:hidden; }
+  .card .bar i{ display:block; height:100%; background:linear-gradient(90deg,#c9a961,#ff3d9a); }
+  .card.p1cursor{ border-color:#ff6b6b; box-shadow:0 0 16px rgba(255,61,90,.55); }
+  .card.p2cursor{ border-color:#6bb8ff; box-shadow:0 0 16px rgba(34,120,255,.55); }
+  .card.p1cursor.p2cursor{ border-color:#c9a961; box-shadow:0 0 18px rgba(201,169,97,.6); }
+  .card .rdy{ position:absolute; top:4px; font-size:9px; font-weight:700; padding:1px 5px; border-radius:4px; }
+  .card .rdy.r1{ left:4px; background:#c0392b; color:#fff; }
+  .card .rdy.r2{ right:4px; background:#2c6fb0; color:#fff; }
 </style>
 </head>
 <body>
@@ -105,6 +129,22 @@
     <div class="tiny">Press&nbsp;<span class="key">P</span>&nbsp;to pause · <span class="key">M</span>&nbsp;mute</div>
   </div>
 
+  <!-- CHARACTER SELECT -->
+  <div class="screen hidden" id="select">
+    <h1 style="font-size:clamp(24px,4.6vw,40px);margin-bottom:2px;">CHOOSE YOUR BAGUETTE</h1>
+    <div class="selHead">
+      <div class="pTag p1" id="p1tag">P1: —</div>
+      <div class="sub" style="margin:0;"><span class="flag"></span></div>
+      <div class="pTag p2" id="p2tag">P2: —</div>
+    </div>
+    <div class="roster" id="roster"></div>
+    <div class="btns" style="margin-top:14px;">
+      <button class="primary" id="btnStart" disabled>SELECT FIGHTERS</button>
+      <button id="btnSelBack">BACK</button>
+    </div>
+    <div class="tiny" id="selHint"></div>
+  </div>
+
   <!-- RESULT SCREEN -->
   <div class="screen hidden" id="result">
     <h1 id="resultTitle">PLAYER 1 WINS</h1>
@@ -231,6 +271,17 @@
     if (['arrowup','arrowdown','arrowleft','arrowright',' ','/'].includes(k)) e.preventDefault();
     if (!keys[k]) keys['_pressed_'+k] = true;   // edge trigger
     keys[k] = true;
+    if (state === 'select'){
+      if (k==='a') selMove(1,-1);
+      else if (k==='d') selMove(1,1);
+      else if (k==='f') selReady(1);
+      else if (mode==='2p' && k==='arrowleft')  selMove(2,-1);
+      else if (mode==='2p' && k==='arrowright') selMove(2,1);
+      else if (mode==='2p' && k===',') selReady(2);
+      else if (k==='enter' || k===' ') beginSelectedMatch();
+      else if (k==='escape'){ state='menu'; hideScreens(); document.getElementById('menu').classList.remove('hidden'); }
+      return;
+    }
     if (k === 'p') togglePause();
     if (k === 'm') { const m = Audio.toggle(); Voice.enabled = !m; }
   });
@@ -238,17 +289,33 @@
   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' },
-  };
+  // Selectable roster. spd/pow/def are 1..5 (shown as bars); converted to gameplay factors in Fighter.
+  const ROSTER = [
+    { id:'rouge',    name:'Baguette Rouge', spd:3, pow:3, def:3,
+      colors:{ crust:'#d9a441', crustDk:'#a9761f', accent:'#ff4d4d', beret:'#c0392b', glow:'#ff6b6b' } },
+    { id:'bleu',     name:'Baguette Bleue', spd:3, pow:3, def:3,
+      colors:{ crust:'#e0b45a', crustDk:'#b08228', accent:'#4d9dff', beret:'#2c6fb0', glow:'#6bb8ff' } },
+    { id:'costaud',  name:'Le Costaud',     spd:2, pow:5, def:4,
+      colors:{ crust:'#c98a3e', crustDk:'#8a5a17', accent:'#ff9a3d', beret:'#7a3b12', glow:'#ffb347' } },
+    { id:'petit',    name:'Petit Éclair',   spd:5, pow:2, def:2,
+      colors:{ crust:'#f0d488', crustDk:'#c9a94e', accent:'#ffe14d', beret:'#e0a800', glow:'#ffe066' } },
+    { id:'sourdough',name:'Pain de Campagne',spd:2, pow:4, def:5,
+      colors:{ crust:'#b8894c', crustDk:'#7c561f', accent:'#d8b48c', beret:'#5b3a1a', glow:'#e6c79a' } },
+    { id:'noir',     name:'Baguette Noire',  spd:4, pow:5, def:1,
+      colors:{ crust:'#8a6a3a', crustDk:'#4a3418', accent:'#ff3d9a', beret:'#141414', glow:'#ff3d9a' } },
+  ];
 
   class Fighter {
-    constructor(x, side, name, human){
+    constructor(x, side, cfg, 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.side = side;                 // 'p1' | 'p2' (controls + facing)
+      this.cfg = cfg;
+      this.name = cfg.name;
+      this.colors = cfg.colors;
+      this.spd  = 3.4 + cfg.spd * 0.42; // movement px/frame
+      this.powF = 0.82 + cfg.pow * 0.07;// outgoing-damage multiplier
+      this.defF = 1.18 - cfg.def * 0.06;// incoming-damage/knockback multiplier (lower = tankier)
       this.human = human;               // false => CPU
       this.facing = side === 'p1' ? 1 : -1;
       this.reset();
@@ -305,6 +372,7 @@
 
     takeHit(dmg, kb, fromDir){
       if (this.invuln > 0 || this.state==='ko') return false;
+      dmg *= this.defF; kb *= this.defF;   // defense stat: tankier fighters take less
       const blocking = this.state === 'block';
       let dealt = dmg;
       if (blocking){
@@ -331,7 +399,7 @@
     controlHuman(map){
       if (this.state==='hurt'||this.state==='ko') return;
       const left = keys[map.left], right = keys[map.right];
-      const speed = 4.4;
+      const speed = this.spd;
       const canMove = !this.attack;
       if (canMove){
         if (left && !right){ this.vx = -speed; this.facing = -1; }
@@ -351,7 +419,7 @@
       const dist = foe.x - this.x;
       const adist = Math.abs(dist);
       this.facing = dist >= 0 ? 1 : -1;
-      const speed = 4.0;
+      const speed = this.spd;
 
       // re-decide intent periodically
       if (this.aiTimer <= 0){
@@ -439,7 +507,7 @@
     }
 
     draw(g){
-      const c = COLORS[this.side];
+      const c = this.colors;
       const f = this.facing;
       g.save();
       g.translate(this.x, this.y);
@@ -604,7 +672,7 @@
       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 (foe.takeHit(18 * b.owner.powF, 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;
     }
@@ -614,7 +682,7 @@
   }
   function drawProjectiles(g){
     for (const b of blasts){
-      const c = COLORS[b.owner.side];
+      const c = b.owner.colors;
       g.save();
       g.globalAlpha = 0.9;
       g.shadowColor = c.glow; g.shadowBlur = 24;
@@ -812,11 +880,11 @@
   // ---------- 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);
+    drawBar(g, 30, 26, 380, f1, false, f1.colors);
+    drawBar(g, W-30-380, 26, 380, f2, true, f2.colors);
     // meter bars
-    drawMeter(g, 30, 60, 260, f1.meter, false, COLORS.p1);
-    drawMeter(g, W-30-260, 60, 260, f2.meter, true, COLORS.p2);
+    drawMeter(g, 30, 60, 260, f1.meter, false, f1.colors);
+    drawMeter(g, W-30-260, 60, 260, f2.meter, true, f2.colors);
     // round pips
     drawPips(g, 30, 78, f1.rounds, false);
     drawPips(g, W-30, 78, f2.rounds, true);
@@ -886,10 +954,94 @@
 
   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');
+  // ---- character select ----
+  let p1Idx = 0, p2Idx = 1, p1Ready = false, p2Ready = false;
+
+  function drawMini(g, c){
+    g.clearRect(0,0,56,70);
+    g.save(); g.translate(28, 37);
+    const bw = 24, bh = 52;
+    roundRectPath(g, -bw/2, -bh/2, bw, bh, bw/2);
+    const grd = g.createLinearGradient(-bw/2,0,bw/2,0);
+    grd.addColorStop(0,c.crustDk); grd.addColorStop(.5,c.crust); grd.addColorStop(1,c.crustDk);
+    g.fillStyle = grd; g.fill();
+    g.lineWidth = 2; g.strokeStyle = 'rgba(90,55,15,.7)'; g.stroke();
+    g.strokeStyle = 'rgba(120,72,20,.85)'; g.lineWidth = 2;
+    for (let i=0;i<4;i++){ const yy = -bh/2+11+i*11; g.beginPath(); g.moveTo(-5,yy); g.quadraticCurveTo(0,yy-4,6,yy-1); g.stroke(); }
+    g.fillStyle = c.beret; g.beginPath(); g.ellipse(1,-bh/2+1,bw*0.5,4.5,0,0,Math.PI*2); g.fill();
+    g.beginPath(); g.arc(1,-bh/2-3,bw*0.4,Math.PI,0); g.fill();
+    g.fillStyle = '#20140a';
+    g.beginPath(); g.arc(-4.5,-bh/2+15,2.2,0,Math.PI*2); g.fill();
+    g.beginPath(); g.arc(4.5,-bh/2+15,2.2,0,Math.PI*2); g.fill();
+    g.strokeStyle='#20140a'; g.lineWidth=2; g.beginPath();
+    g.moveTo(-8,-bh/2+9); g.lineTo(-2,-bh/2+12); g.moveTo(8,-bh/2+9); g.lineTo(2,-bh/2+12); g.stroke();
+    g.fillStyle = c.accent; g.fillRect(-bw/2, bh*0.16, bw, 4);
+    g.restore();
+  }
+
+  function buildRoster(){
+    const r = document.getElementById('roster'); r.innerHTML = '';
+    ROSTER.forEach((c, i) => {
+      const card = document.createElement('div');
+      card.className = 'card'; card.dataset.idx = i;
+      const cv = document.createElement('canvas'); cv.width = 56; cv.height = 70;
+      drawMini(cv.getContext('2d'), c.colors); card.appendChild(cv);
+      const nm = document.createElement('div'); nm.className = 'cname'; nm.textContent = c.name; card.appendChild(nm);
+      [['SPD',c.spd],['PWR',c.pow],['DEF',c.def]].forEach(([lbl,v]) => {
+        const s = document.createElement('div'); s.className = 'stat';
+        s.innerHTML = '<b>'+lbl+'</b><span class="bar"><i style="width:'+(v/5*100)+'%"></i></span>';
+        card.appendChild(s);
+      });
+      const r1 = document.createElement('div'); r1.className='rdy r1'; r1.textContent='P1'; r1.style.display='none'; card.appendChild(r1);
+      const r2 = document.createElement('div'); r2.className='rdy r2'; r2.textContent='P2'; r2.style.display='none'; card.appendChild(r2);
+      card.addEventListener('click', () => { p1Idx = i; p1Ready = false; if (mode!=='2p') p2Ready = false; Audio.poke(); renderSelect(); });
+      r.appendChild(card);
+    });
+  }
+
+  function renderSelect(){
+    const cards = [...document.querySelectorAll('#roster .card')];
+    cards.forEach((card,i) => {
+      card.classList.toggle('p1cursor', i===p1Idx);
+      card.classList.toggle('p2cursor', i===p2Idx);
+      card.querySelector('.rdy.r1').style.display = (i===p1Idx && p1Ready) ? 'block' : 'none';
+      card.querySelector('.rdy.r2').style.display = (i===p2Idx && p2Ready) ? 'block' : 'none';
+    });
+    document.getElementById('p1tag').textContent = 'P1: ' + ROSTER[p1Idx].name + (p1Ready?' ✔':'');
+    document.getElementById('p2tag').textContent = (mode==='2p'?'P2: ':'CPU: ') + ROSTER[p2Idx].name + (p2Ready?' ✔':'');
+    const both = p1Ready && p2Ready;
+    const b = document.getElementById('btnStart');
+    b.disabled = !both; b.textContent = both ? 'START FIGHT ▶' : 'READY UP';
+    document.getElementById('selHint').innerHTML = mode==='2p'
+      ? 'P1 <span class="key">A</span><span class="key">D</span> move · <span class="key">F</span> ready &nbsp;·&nbsp; P2 <span class="key">←</span><span class="key">→</span> move · <span class="key">,</span> ready'
+      : 'P1 <span class="key">A</span><span class="key">D</span> move · <span class="key">F</span> ready · CPU auto-picks · click a card to choose';
+  }
+
+  function openSelect(m){
+    mode = m; state = 'select';
+    p1Idx = 0; p2Idx = 1; p1Ready = false; p2Ready = false;
+    hideScreens(); document.getElementById('select').classList.remove('hidden');
+    Audio.resume(); renderSelect();
+  }
+  function selMove(player, dir){
+    if (player===1){ p1Idx = (p1Idx+dir+ROSTER.length)%ROSTER.length; p1Ready=false; if(mode!=='2p') p2Ready=false; }
+    else { p2Idx = (p2Idx+dir+ROSTER.length)%ROSTER.length; p2Ready=false; }
+    Audio.poke(); renderSelect();
+  }
+  function selReady(player){
+    if (player===1){
+      p1Ready = !p1Ready;
+      if (mode!=='2p'){                       // CPU picks a random fighter when P1 readies
+        if (p1Ready){ p2Idx = Math.floor(Math.random()*ROSTER.length); p2Ready = true; }
+        else p2Ready = false;
+      }
+    } else if (mode==='2p'){ p2Ready = !p2Ready; }
+    Audio.bell(); renderSelect();
+  }
+  function beginSelectedMatch(){
+    if (!(p1Ready && p2Ready)) return;
+    f1 = new Fighter(W*0.32,'p1', ROSTER[p1Idx], true);
+    f2 = new Fighter(W*0.68,'p2', ROSTER[p2Idx], mode==='2p');
     f1.rounds=0; f2.rounds=0; roundNum=1;
     hideScreens(); Audio.resume();
     startRound();
@@ -899,6 +1051,7 @@
     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;
+    for (const kk in keys) if (kk.startsWith('_pressed_')) keys[kk] = false;  // drop stale edge triggers from select
     setAnnounce('ROUND '+roundNum,'#c9a961',56,10);
     Voice.round();
   }
@@ -928,7 +1081,7 @@
 
   // ---------- Screens ----------
   const $ = id => document.getElementById(id);
-  function hideScreens(){ ['menu','result','pause'].forEach(id=>$(id).classList.add('hidden')); }
+  function hideScreens(){ ['menu','result','pause','select'].forEach(id=>$(id).classList.add('hidden')); }
   function showResult(champ){
     state='result';
     if (champ){
@@ -944,14 +1097,16 @@
   }
   let paused=false;
   function togglePause(){
-    if (state==='menu'||state==='result') return;
+    if (state==='menu'||state==='result'||state==='select') return;
     paused = !paused;
     $('pause').classList.toggle('hidden', !paused);
   }
 
-  $('btn1p').onclick = () => newMatch('1p');
-  $('btn2p').onclick = () => newMatch('2p');
-  $('btnRematch').onclick = () => newMatch(mode);
+  $('btn1p').onclick = () => openSelect('1p');
+  $('btn2p').onclick = () => openSelect('2p');
+  $('btnStart').onclick = () => beginSelectedMatch();
+  $('btnSelBack').onclick = () => { state='menu'; hideScreens(); $('menu').classList.remove('hidden'); };
+  $('btnRematch').onclick = () => beginSelectedMatch();                 // same fighters
   $('btnMenu').onclick = () => { state='menu'; hideScreens(); $('menu').classList.remove('hidden'); };
   $('btnResume').onclick = () => togglePause();
   $('btnQuit').onclick = () => { paused=false; state='menu'; hideScreens(); $('menu').classList.remove('hidden'); };
@@ -964,9 +1119,9 @@
     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;
+      const dmg = atk.attack.dmg * atk.powF, kb = atk.attack.kb;
       def.takeHit(dmg, kb, dir);
-      atk.meter = Math.min(100, atk.meter + dmg*0.6);
+      atk.meter = Math.min(100, atk.meter + atk.attack.dmg*0.6);
     }
   }
 
@@ -1049,6 +1204,7 @@
     ctx.fillStyle=vg; ctx.fillRect(0,0,W,H);
   }
 
+  buildRoster();
   requestAnimationFrame(loop);
 })();
 </script>
diff --git a/select.png b/select.png
new file mode 100644
index 0000000..4c871e9
Binary files /dev/null and b/select.png differ

← fb4abc6 Baguette Battle: playable neon-Paris fighting game (baguette  ·  back to Neon Baguette Battle  ·  Redesign fighters as Frenchmen in berets wielding baguettes; d93d9ac →