← back to Games Agentabrams

games/connect-four/AbramsConnect4.aa

721 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Abrams Connect 4</title>
<style>
  :root{
    --bg0:#070912;
    --bg1:#0d1226;
    --board:#0b1030;
    --board-edge:#1b2a6b;
    --cell:#050814;
    --p1:#ff2d78;      /* neon pink */
    --p1b:#ff8fc0;
    --p2:#22e0ff;      /* neon cyan */
    --p2b:#a9f4ff;
    --ink:#eaf0ff;
    --muted:#8b96c4;
    --line:#2a3670;
    --win:#ffe14d;
    --accent:#8a6bff;
  }
  *{box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
  html,body{margin:0;height:100%;}
  body{
    background:
      radial-gradient(1200px 700px at 15% -10%, #16205a55, transparent 60%),
      radial-gradient(1000px 700px at 110% 120%, #4a1e6b55, transparent 55%),
      linear-gradient(160deg,var(--bg1),var(--bg0));
    color:var(--ink);
    font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
    display:flex;flex-direction:column;align-items:center;
    min-height:100%;overflow:hidden;
    user-select:none;
  }
  .wrap{
    width:100%;max-width:640px;height:100dvh;
    display:flex;flex-direction:column;
    padding:10px clamp(8px,3vw,18px);
    gap:10px;
  }
  header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;}
  .title{
    font-weight:800;letter-spacing:.5px;font-size:clamp(18px,5vw,26px);
    background:linear-gradient(90deg,var(--p1),var(--accent),var(--p2));
    -webkit-background-clip:text;background-clip:text;color:transparent;
    text-shadow:0 0 24px #8a6bff33;
    display:flex;align-items:center;gap:8px;
  }
  .title .dot{width:12px;height:12px;border-radius:50%;background:var(--p1);box-shadow:0 0 12px var(--p1);}
  .controls{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
  select,button{
    font:inherit;color:var(--ink);
    background:linear-gradient(180deg,#141c40,#0c1330);
    border:1px solid var(--line);border-radius:10px;
    padding:8px 10px;cursor:pointer;transition:.15s;
    font-size:13px;
  }
  button:hover,select:hover{border-color:var(--accent);box-shadow:0 0 0 1px #8a6bff44;}
  button:active{transform:translateY(1px);}
  .btn-primary{background:linear-gradient(180deg,#6d54ff,#4b32d6);border-color:#7a63ff;font-weight:700;}
  .iconbtn{padding:8px 11px;}

  .scorebar{
    display:flex;gap:8px;align-items:stretch;justify-content:center;
  }
  .score{
    flex:1;max-width:190px;
    display:flex;align-items:center;gap:10px;
    background:linear-gradient(180deg,#0f1638,#0a1029);
    border:1px solid var(--line);border-radius:12px;
    padding:8px 12px;position:relative;overflow:hidden;
  }
  .score.active{border-color:currentColor;box-shadow:0 0 18px -4px currentColor, inset 0 0 22px -12px currentColor;}
  .score.p1{color:var(--p1);}
  .score.p2{color:var(--p2);}
  .score .chip{
    width:26px;height:26px;border-radius:50%;flex:0 0 auto;
    background:radial-gradient(circle at 35% 30%,#fff8,currentColor 60%);
    box-shadow:0 0 12px currentColor;
  }
  .score .meta{display:flex;flex-direction:column;line-height:1.15;}
  .score .name{font-size:12px;color:var(--muted);}
  .score .val{font-size:20px;font-weight:800;color:var(--ink);}

  .status{
    text-align:center;font-size:14px;min-height:20px;color:var(--muted);
    font-weight:600;letter-spacing:.3px;
  }
  .status b.p1{color:var(--p1);} .status b.p2{color:var(--p2);}

  .boardwrap{flex:1;display:flex;align-items:center;justify-content:center;min-height:0;}
  canvas{
    display:block;width:100%;height:100%;
    touch-action:none;border-radius:16px;
    cursor:pointer;
  }
  footer{
    text-align:center;font-size:11px;color:#5b6699;padding-bottom:4px;
  }
  .thinking{color:var(--accent);}
  @media (max-width:420px){
    .score .val{font-size:18px;}
  }
</style>
</head>
<body>
<div class="wrap">
  <header>
    <div class="title"><span class="dot"></span>Abrams Connect 4</div>
    <div class="controls">
      <select id="mode" title="Game mode">
        <option value="ai">vs Computer</option>
        <option value="2p">2 Players</option>
      </select>
      <select id="diff" title="Difficulty">
        <option value="1">Easy</option>
        <option value="3">Medium</option>
        <option value="5" selected>Hard</option>
        <option value="7">Expert</option>
      </select>
      <button id="mute" class="iconbtn" title="Toggle sound">🔊</button>
      <button id="newgame" class="btn-primary">New Game</button>
    </div>
  </header>

  <div class="scorebar">
    <div class="score p1 active" id="score1">
      <span class="chip"></span>
      <span class="meta"><span class="name" id="name1">You</span><span class="val" id="val1">0</span></span>
    </div>
    <div class="score p2" id="score2">
      <span class="chip"></span>
      <span class="meta"><span class="name" id="name2">Computer</span><span class="val" id="val2">0</span></span>
    </div>
  </div>

  <div class="status" id="status">Your turn — drop a disc</div>

  <div class="boardwrap">
    <canvas id="board"></canvas>
  </div>

  <footer>Connect four in a row — horizontal, vertical, or diagonal · Pure vanilla JS, zero dependencies.</footer>
</div>

<script>
(() => {
  "use strict";

  // ---------- Constants ----------
  const COLS = 7, ROWS = 6, NEED = 4;
  const EMPTY = 0, P1 = 1, P2 = 2;

  // ---------- DOM ----------
  const canvas = document.getElementById('board');
  const ctx = canvas.getContext('2d');
  const $ = id => document.getElementById(id);
  const modeSel = $('mode'), diffSel = $('diff'), muteBtn = $('mute');
  const statusEl = $('status');
  const val1 = $('val1'), val2 = $('val2'), name1 = $('name1'), name2 = $('name2');
  const score1El = $('score1'), score2El = $('score2');

  // ---------- State ----------
  let board = [];            // board[r][c], r=0 is TOP
  let current = P1;
  let scores = { 1:0, 2:0 };
  let gameOver = false;
  let winLine = null;        // array of [r,c]
  let winPlayer = 0;
  let aiThinking = false;
  let hoverCol = -1;
  let muted = false;
  let animating = false;

  // Animated / falling discs
  const discs = [];  // {r,c,player,y (px), targetY, vy, settled}
  let dropAnim = null;

  // Layout (recomputed on resize)
  let L = { size: 0, pad: 0, cell: 0, radius: 0, boardX: 0, boardY: 0, boardW: 0, boardH: 0, top: 0 };

  function newBoard() {
    board = Array.from({length: ROWS}, () => new Array(COLS).fill(EMPTY));
  }

  // ---------- Audio (procedural, optional) ----------
  let audioCtx = null;
  function ac() {
    if (muted) return null;
    if (!audioCtx) {
      try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }
      catch (e) { return null; }
    }
    if (audioCtx.state === 'suspended') audioCtx.resume();
    return audioCtx;
  }
  function tone(freq, dur, type = 'sine', gain = 0.12, slideTo = null) {
    const a = ac(); 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.setValueAtTime(0, a.currentTime);
    g.gain.linearRampToValueAtTime(gain, a.currentTime + 0.008);
    g.gain.exponentialRampToValueAtTime(0.0001, a.currentTime + dur);
    o.connect(g); g.connect(a.destination);
    o.start(); o.stop(a.currentTime + dur + 0.02);
  }
  const sfx = {
    drop(){ tone(220, 0.14, 'triangle', 0.13, 140); },
    land(){ tone(120, 0.10, 'sine', 0.16); tone(180, 0.06, 'square', 0.05); },
    hover(){ tone(660, 0.03, 'sine', 0.04); },
    win(){ [523,659,784,1046].forEach((f,i)=>setTimeout(()=>tone(f,0.22,'triangle',0.14),i*110)); },
    draw(){ tone(300,0.2,'sine',0.1,180); },
    click(){ tone(440,0.04,'square',0.05); }
  };

  // ---------- Layout ----------
  function resize() {
    const wrap = canvas.parentElement;
    const availW = wrap.clientWidth;
    const availH = wrap.clientHeight;
    // board aspect: COLS wide, ROWS+~1 (drop lane) tall
    const laneRows = ROWS + 1;
    const targetRatio = COLS / laneRows;
    let w = availW, h = w / targetRatio;
    if (h > availH) { h = availH; w = h * targetRatio; }
    const dpr = Math.min(window.devicePixelRatio || 1, 2.5);
    canvas.style.width = w + 'px';
    canvas.style.height = h + 'px';
    canvas.width = Math.round(w * dpr);
    canvas.height = Math.round(h * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

    L.cell = w / COLS;
    L.top = L.cell;                 // drop lane height (one cell)
    L.boardX = 0;
    L.boardY = L.top;
    L.boardW = w;
    L.boardH = L.cell * ROWS;
    L.radius = L.cell * 0.40;
    L.w = w; L.h = h;
    // sync any settled disc pixel positions
    for (const d of discs) if (d.settled) d.y = cellCenterY(d.r);
    draw();
  }

  function cellCenterX(c){ return L.cell * c + L.cell/2; }
  function cellCenterY(r){ return L.top + L.cell * r + L.cell/2; }

  // ---------- Drawing ----------
  function roundRect(x,y,w,h,r){
    ctx.beginPath();
    ctx.moveTo(x+r,y);
    ctx.arcTo(x+w,y,x+w,y+h,r);
    ctx.arcTo(x+w,y+h,x,y+h,r);
    ctx.arcTo(x,y+h,x,y,r);
    ctx.arcTo(x,y,x+w,y,r);
    ctx.closePath();
  }

  function discColors(p){
    return p === P1
      ? { a:'#ff2d78', b:'#ff8fc0', glow:'#ff2d78' }
      : { a:'#22e0ff', b:'#a9f4ff', glow:'#22e0ff' };
  }

  function drawDisc(cx, cy, p, r, highlight){
    const col = discColors(p);
    ctx.save();
    ctx.shadowColor = col.glow;
    ctx.shadowBlur = highlight ? r*1.1 : r*0.55;
    const g = ctx.createRadialGradient(cx - r*0.35, cy - r*0.4, r*0.1, cx, cy, r);
    g.addColorStop(0, col.b);
    g.addColorStop(0.55, col.a);
    g.addColorStop(1, p===P1 ? '#a3134b' : '#0f7f96');
    ctx.fillStyle = g;
    ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); ctx.fill();
    ctx.shadowBlur = 0;
    // rim
    ctx.lineWidth = Math.max(1, r*0.06);
    ctx.strokeStyle = 'rgba(255,255,255,.22)';
    ctx.beginPath(); ctx.arc(cx, cy, r*0.96, 0, Math.PI*2); ctx.stroke();
    // gloss
    ctx.fillStyle = 'rgba(255,255,255,.28)';
    ctx.beginPath(); ctx.ellipse(cx - r*0.3, cy - r*0.35, r*0.34, r*0.2, -0.5, 0, Math.PI*2); ctx.fill();
    ctx.restore();
  }

  function draw(){
    if (!L.w) return;
    ctx.clearRect(0,0,L.w,L.h);

    // hover preview in the lane
    if (!gameOver && !aiThinking && hoverCol >= 0 && dropRow(hoverCol) >= 0) {
      const cx = cellCenterX(hoverCol);
      ctx.save();
      ctx.globalAlpha = 0.5;
      drawDisc(cx, L.top/2, current, L.radius, false);
      ctx.restore();
      // column guide
      ctx.save();
      ctx.globalAlpha = 0.10;
      ctx.fillStyle = discColors(current).a;
      ctx.fillRect(L.cell*hoverCol, L.boardY, L.cell, L.boardH);
      ctx.restore();
    }

    // board panel
    ctx.save();
    ctx.shadowColor = '#1b2a6b';
    ctx.shadowBlur = 30;
    const bg = ctx.createLinearGradient(0, L.boardY, 0, L.boardY+L.boardH);
    bg.addColorStop(0, '#131a44');
    bg.addColorStop(1, '#0a1030');
    ctx.fillStyle = bg;
    roundRect(L.boardX+2, L.boardY, L.boardW-4, L.boardH, L.cell*0.28);
    ctx.fill();
    ctx.restore();
    // board edge stroke
    ctx.lineWidth = 2;
    ctx.strokeStyle = 'rgba(120,150,255,.25)';
    roundRect(L.boardX+2, L.boardY, L.boardW-4, L.boardH, L.cell*0.28);
    ctx.stroke();

    // holes + settled discs
    for (let r=0;r<ROWS;r++){
      for (let c=0;c<COLS;c++){
        const cx = cellCenterX(c), cy = cellCenterY(r);
        // hole
        ctx.save();
        ctx.fillStyle = '#050814';
        ctx.beginPath(); ctx.arc(cx, cy, L.radius, 0, Math.PI*2); ctx.fill();
        ctx.lineWidth = Math.max(1, L.radius*0.05);
        ctx.strokeStyle = 'rgba(0,0,0,.6)';
        ctx.beginPath(); ctx.arc(cx, cy, L.radius, 0, Math.PI*2); ctx.stroke();
        ctx.restore();
      }
    }

    // discs (settled + falling)
    for (const d of discs) {
      const cx = cellCenterX(d.c);
      const hi = winLine && winLine.some(p => p[0]===d.r && p[1]===d.c);
      drawDisc(cx, d.y, d.player, L.radius, hi);
    }

    // win line
    if (winLine && winLine.length) {
      const a = winLine[0], b = winLine[winLine.length-1];
      ctx.save();
      ctx.strokeStyle = 'rgba(255,225,77,.9)';
      ctx.lineWidth = L.radius*0.22;
      ctx.lineCap = 'round';
      ctx.shadowColor = '#ffe14d';
      ctx.shadowBlur = 24;
      ctx.beginPath();
      ctx.moveTo(cellCenterX(a[1]), cellCenterY(a[0]));
      ctx.lineTo(cellCenterX(b[1]), cellCenterY(b[0]));
      ctx.stroke();
      ctx.restore();
    }
  }

  // ---------- Game logic ----------
  function dropRow(c){
    for (let r=ROWS-1;r>=0;r--) if (board[r][c]===EMPTY) return r;
    return -1;
  }

  function validCols(bd){
    const out=[];
    for (let c=0;c<COLS;c++) if (bd[0][c]===EMPTY) out.push(c);
    return out;
  }

  function boardFull(bd){
    for (let c=0;c<COLS;c++) if (bd[0][c]===EMPTY) return false;
    return true;
  }

  // returns winning line (array of [r,c]) for player p, or null
  function findWin(bd, p){
    const dirs = [[0,1],[1,0],[1,1],[1,-1]];
    for (let r=0;r<ROWS;r++){
      for (let c=0;c<COLS;c++){
        if (bd[r][c]!==p) continue;
        for (const [dr,dc] of dirs){
          const line=[[r,c]];
          let rr=r+dr, cc=c+dc;
          while (rr>=0 && rr<ROWS && cc>=0 && cc<COLS && bd[rr][cc]===p){
            line.push([rr,cc]);
            if (line.length===NEED) return line;
            rr+=dr; cc+=dc;
          }
        }
      }
    }
    return null;
  }

  // ---------- Human move ----------
  function attemptDrop(c){
    if (gameOver || animating || aiThinking) return;
    if (modeSel.value==='ai' && current===P2) return; // not human's turn
    const r = dropRow(c);
    if (r<0) return;
    doDrop(c, current);
  }

  function doDrop(c, player){
    const r = dropRow(c);
    if (r<0) return;
    board[r][c] = player;
    animating = true;
    sfx.drop();
    const targetY = cellCenterY(r);
    const disc = { r, c, player, y: L.top/2, vy: 0, settled:false };
    discs.push(disc);
    startFall(disc, targetY);
  }

  function startFall(disc, targetY){
    const grav = L.cell * 0.06;   // px per frame^2 (scaled by cell)
    let last = performance.now();
    let bounced = false;
    function step(now){
      const dt = Math.min(2, (now - last)/16.6667); last = now;
      disc.vy += grav * dt;
      disc.y += disc.vy * dt;
      if (disc.y >= targetY){
        disc.y = targetY;
        if (!bounced && disc.vy > L.cell*0.25){
          disc.vy = -disc.vy * 0.28;  // small bounce
          bounced = true;
          sfx.land();
          draw();
          requestAnimationFrame(step);
          return;
        }
        disc.settled = true;
        animating = false;
        if (!bounced) sfx.land();
        draw();
        afterMove(disc.r, disc.c, disc.player);
        return;
      }
      draw();
      requestAnimationFrame(step);
    }
    requestAnimationFrame(step);
  }

  function afterMove(r, c, player){
    const win = findWin(board, player);
    if (win){
      winLine = win; winPlayer = player; gameOver = true;
      scores[player]++;
      updateScores();
      sfx.win();
      setStatus(playerName(player, true) + ' wins the round! 🎉');
      flashWin();
      draw();
      return;
    }
    if (boardFull(board)){
      gameOver = true;
      sfx.draw();
      setStatus("It's a draw — board full.");
      draw();
      return;
    }
    current = player===P1 ? P2 : P1;
    updateTurnUI();
    if (modeSel.value==='ai' && current===P2 && !gameOver){
      queueAI();
    }
  }

  let winFlashTimer = null;
  function flashWin(){
    let n=0;
    clearInterval(winFlashTimer);
    winFlashTimer = setInterval(()=>{
      n++;
      // toggle handled implicitly by redraw jitter of glow via time — simple pulse:
      draw();
      if (n>8) clearInterval(winFlashTimer);
    }, 140);
  }

  // ---------- AI: minimax + alpha-beta ----------
  function queueAI(){
    aiThinking = true;
    setStatus('<span class="thinking">Computer is thinking…</span>');
    draw();
    // yield to let UI paint, keep responsive
    setTimeout(runAI, 60);
  }

  function runAI(){
    const depth = parseInt(diffSel.value, 10) || 5;
    const move = bestMove(board, depth);
    aiThinking = false;
    if (move == null){ // no move (shouldn't happen)
      updateTurnUI();
      return;
    }
    doDrop(move, P2);
  }

  // Order columns center-first for better pruning
  const COL_ORDER = (() => {
    const mid=(COLS-1)/2;
    return [...Array(COLS).keys()].sort((a,b)=>Math.abs(a-mid)-Math.abs(b-mid));
  })();

  function bestMove(bd, depth){
    // immediate win / block shortcuts for snappiness at low depth
    const valid = validColsOrdered(bd);
    if (!valid.length) return null;
    // win now?
    for (const c of valid){ const b2=cloneDrop(bd,c,P2); if (findWin(b2,P2)) return c; }
    // block opponent win?
    for (const c of valid){ const b2=cloneDrop(bd,c,P1); if (findWin(b2,P1)) return c; }

    let bestScore = -Infinity, bestCols = [valid[0]];
    for (const c of valid){
      const b2 = cloneDrop(bd, c, P2);
      const s = minimax(b2, depth-1, -Infinity, Infinity, false);
      if (s > bestScore){ bestScore = s; bestCols = [c]; }
      else if (s === bestScore){ bestCols.push(c); }
    }
    // prefer central among ties
    bestCols.sort((a,b)=>Math.abs(a-(COLS-1)/2)-Math.abs(b-(COLS-1)/2));
    return bestCols[0];
  }

  function validColsOrdered(bd){
    return COL_ORDER.filter(c => bd[0][c]===EMPTY);
  }

  function cloneDrop(bd, c, p){
    const b2 = bd.map(row => row.slice());
    for (let r=ROWS-1;r>=0;r--){ if (b2[r][c]===EMPTY){ b2[r][c]=p; break; } }
    return b2;
  }

  function minimax(bd, depth, alpha, beta, maximizing){
    if (findWin(bd, P2)) return 100000 + depth;      // AI win, sooner better
    if (findWin(bd, P1)) return -100000 - depth;     // human win
    if (depth===0 || boardFull(bd)) return evaluate(bd);

    const cols = validColsOrdered(bd);
    if (maximizing){
      let value = -Infinity;
      for (const c of cols){
        const b2 = cloneDrop(bd, c, P2);
        value = Math.max(value, minimax(b2, depth-1, alpha, beta, false));
        alpha = Math.max(alpha, value);
        if (alpha >= beta) break;
      }
      return value;
    } else {
      let value = Infinity;
      for (const c of cols){
        const b2 = cloneDrop(bd, c, P1);
        value = Math.min(value, minimax(b2, depth-1, alpha, beta, true));
        beta = Math.min(beta, value);
        if (beta <= alpha) break;
      }
      return value;
    }
  }

  // Heuristic evaluation from AI (P2) perspective
  function evaluate(bd){
    let score = 0;
    // center column preference
    const centerC = Math.floor(COLS/2);
    let centerCount = 0;
    for (let r=0;r<ROWS;r++) if (bd[r][centerC]===P2) centerCount++;
    score += centerCount * 6;

    const windows = [];
    // horizontal
    for (let r=0;r<ROWS;r++)
      for (let c=0;c<=COLS-4;c++)
        windows.push([bd[r][c],bd[r][c+1],bd[r][c+2],bd[r][c+3]]);
    // vertical
    for (let c=0;c<COLS;c++)
      for (let r=0;r<=ROWS-4;r++)
        windows.push([bd[r][c],bd[r+1][c],bd[r+2][c],bd[r+3][c]]);
    // diag down-right
    for (let r=0;r<=ROWS-4;r++)
      for (let c=0;c<=COLS-4;c++)
        windows.push([bd[r][c],bd[r+1][c+1],bd[r+2][c+2],bd[r+3][c+3]]);
    // diag up-right
    for (let r=3;r<ROWS;r++)
      for (let c=0;c<=COLS-4;c++)
        windows.push([bd[r][c],bd[r-1][c+1],bd[r-2][c+2],bd[r-3][c+3]]);

    for (const w of windows) score += scoreWindow(w);
    return score;
  }

  function scoreWindow(w){
    let ai=0, hu=0, e=0;
    for (const v of w){ if (v===P2) ai++; else if (v===P1) hu++; else e++; }
    if (ai>0 && hu>0) return 0; // mixed, dead
    let s=0;
    if (ai===4) s+=1000;
    else if (ai===3 && e===1) s+=12;
    else if (ai===2 && e===2) s+=4;
    if (hu===4) s-=1000;
    else if (hu===3 && e===1) s-=16;   // block threats a bit harder
    else if (hu===2 && e===2) s-=4;
    return s;
  }

  // ---------- UI helpers ----------
  function playerName(p, cap){
    if (modeSel.value==='ai') return p===P1 ? 'You' : 'Computer';
    return p===P1 ? 'Player 1' : 'Player 2';
  }
  function setStatus(html){ statusEl.innerHTML = html; }
  function updateScores(){ val1.textContent = scores[P1]; val2.textContent = scores[P2]; }
  function updateNames(){
    if (modeSel.value==='ai'){ name1.textContent='You'; name2.textContent='Computer'; }
    else { name1.textContent='Player 1'; name2.textContent='Player 2'; }
  }
  function updateTurnUI(){
    score1El.classList.toggle('active', current===P1);
    score2El.classList.toggle('active', current===P2);
    if (gameOver) return;
    const cls = current===P1 ? 'p1' : 'p2';
    const who = playerName(current);
    if (modeSel.value==='ai' && current===P2){
      setStatus('<span class="thinking">Computer is thinking…</span>');
    } else {
      setStatus(`<b class="${cls}">${who}</b> — drop a disc`);
    }
  }

  // ---------- New game ----------
  function newGame(){
    clearInterval(winFlashTimer);
    newBoard();
    discs.length = 0;
    current = P1;
    gameOver = false; winLine = null; winPlayer = 0; aiThinking = false; animating = false;
    hoverCol = -1;
    updateNames();
    updateScores();
    updateTurnUI();
    resize();
    // if AI is P1? No — human/P1 always starts here.
  }

  // ---------- Input ----------
  function colFromEvent(e){
    const rect = canvas.getBoundingClientRect();
    const x = (e.clientX ?? (e.touches && e.touches[0].clientX)) - rect.left;
    const c = Math.floor(x / (rect.width / COLS));
    return Math.max(0, Math.min(COLS-1, c));
  }

  canvas.addEventListener('mousemove', e => {
    if (gameOver || aiThinking) { if (hoverCol!==-1){hoverCol=-1;draw();} return; }
    const c = colFromEvent(e);
    if (c !== hoverCol){ hoverCol = c; if(!muted && dropRow(c)>=0) sfx.hover(); draw(); }
  });
  canvas.addEventListener('mouseleave', () => { if (hoverCol!==-1){ hoverCol=-1; draw(); } });
  canvas.addEventListener('click', e => { ac(); attemptDrop(colFromEvent(e)); });

  // Touch
  let touchCol = -1;
  canvas.addEventListener('touchstart', e => {
    ac();
    e.preventDefault();
    touchCol = colFromEvent(e);
    hoverCol = touchCol;
    draw();
  }, {passive:false});
  canvas.addEventListener('touchmove', e => {
    e.preventDefault();
    const c = colFromEvent(e);
    if (c!==hoverCol){ hoverCol=c; draw(); }
  }, {passive:false});
  canvas.addEventListener('touchend', e => {
    e.preventDefault();
    if (hoverCol>=0){ const c=hoverCol; hoverCol=-1; attemptDrop(c); draw(); }
  }, {passive:false});

  // Keyboard: 1-7 to drop, N new game
  window.addEventListener('keydown', e => {
    if (e.key>='1' && e.key<='7'){ ac(); attemptDrop(parseInt(e.key,10)-1); }
    else if (e.key==='n' || e.key==='N'){ newGame(); }
  });

  // Controls
  $('newgame').addEventListener('click', () => { ac(); sfx.click(); newGame(); });
  modeSel.addEventListener('change', () => { sfx.click(); scores={1:0,2:0}; newGame(); });
  diffSel.addEventListener('change', () => { sfx.click(); });
  muteBtn.addEventListener('click', () => {
    muted = !muted;
    muteBtn.textContent = muted ? '🔇' : '🔊';
    if (!muted) { ac(); sfx.click(); }
  });

  window.addEventListener('resize', resize);
  window.addEventListener('orientationchange', () => setTimeout(resize, 120));

  // ---------- Boot ----------
  newGame();
})();
</script>
</body>
</html>