35

Connect Four

click a column to drop

Classic 7-column, 6-row Connect Four against a minimax bot with alpha-beta pruning at depth 5. You are red and move first — click any column to drop a chip and watch it fall under gravity. The bot evaluates every 4-in-a-row window on the board with a center-bias heuristic and weights blocking your threats slightly higher than building its own. Get four in a row (horizontal, vertical, or diagonal) before it does and the winning quartet pulses white. Click anywhere after the game ends to restart.

idle
268 lines · vanilla
view source
// Connect Four vs minimax bot (alpha-beta, depth 5).
// 7 cols x 6 rows. Human is red and moves first. Click a column to drop.

const COLS = 7, ROWS = 6;
const EMPTY = 0, P_HUMAN = 1, P_BOT = 2;
const MAX_DEPTH = 5;
const MOVE_ORDER = [3, 2, 4, 1, 5, 0, 6]; // center-out for better alpha-beta

let board, heights, turn, state, winLine, falling;
let botThinking, botDelay;
let cellSize, boardX, boardY, boardW, boardH;

function initBoard() {
  board = new Uint8Array(COLS * ROWS);
  heights = new Uint8Array(COLS);
  turn = P_HUMAN;
  state = "PLAY";
  winLine = null;
  falling = null;
  botThinking = false;
  botDelay = 0;
}

function layout(width, height) {
  const topPad = 56, sidePad = 8, bottomPad = 12;
  cellSize = Math.max(24, Math.floor(Math.min((width - sidePad * 2) / COLS, (height - topPad - bottomPad) / ROWS)));
  boardW = cellSize * COLS;
  boardH = cellSize * ROWS;
  boardX = Math.floor((width - boardW) / 2);
  boardY = topPad;
}

function init({ width, height }) { initBoard(); layout(width, height); }

function colFromX(x) {
  if (x < boardX || x >= boardX + boardW) return -1;
  return Math.floor((x - boardX) / cellSize);
}

function canPlay(col) { return col >= 0 && col < COLS && heights[col] < ROWS; }

// Returns true if the just-placed piece at column `col` connects four.
function isWin(b, h, col, player) {
  const row = h[col] - 1;
  if (row < 0) return false;
  const dirs = [[1, 0], [0, 1], [1, 1], [1, -1]];
  for (const [dc, dr] of dirs) {
    let count = 1;
    for (let s = 1; s < 4; s++) {
      const c = col + dc * s, r = row + dr * s;
      if (c < 0 || c >= COLS || r < 0 || r >= ROWS || b[r * COLS + c] !== player) break;
      count++;
    }
    for (let s = 1; s < 4; s++) {
      const c = col - dc * s, r = row - dr * s;
      if (c < 0 || c >= COLS || r < 0 || r >= ROWS || b[r * COLS + c] !== player) break;
      count++;
    }
    if (count >= 4) return true;
  }
  return false;
}

function findWinLine(b, player) {
  const dirs = [[1, 0], [0, 1], [1, 1], [1, -1]];
  for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
    if (b[r * COLS + c] !== player) continue;
    for (const [dc, dr] of dirs) {
      const r2 = r + dr * 3, c2 = c + dc * 3;
      if (r2 < 0 || r2 >= ROWS || c2 < 0 || c2 >= COLS) continue;
      let ok = true;
      for (let s = 1; s < 4; s++) {
        if (b[(r + dr * s) * COLS + (c + dc * s)] !== player) { ok = false; break; }
      }
      if (ok) return [0, 1, 2, 3].map((s) => ({ r: r + dr * s, c: c + dc * s }));
    }
  }
  return null;
}

function scoreWindow(w, player) {
  const opp = player === P_BOT ? P_HUMAN : P_BOT;
  let p = 0, o = 0, e = 0;
  for (const v of w) { if (v === player) p++; else if (v === opp) o++; else e++; }
  if (p === 4) return 100000;
  if (o === 4) return -100000;
  if (p === 3 && e === 1) return 50;
  if (p === 2 && e === 2) return 5;
  if (o === 3 && e === 1) return -80; // defense weighted higher than offense
  if (o === 2 && e === 2) return -4;
  return 0;
}

function evaluate(b, player) {
  let score = 0;
  for (let r = 0; r < ROWS; r++) {
    const v = b[r * COLS + 3];
    if (v === player) score += 3; else if (v !== EMPTY) score -= 3;
  }
  const w = [0, 0, 0, 0];
  for (let r = 0; r < ROWS; r++) for (let c = 0; c <= COLS - 4; c++) {
    for (let i = 0; i < 4; i++) w[i] = b[r * COLS + c + i];
    score += scoreWindow(w, player);
  }
  for (let c = 0; c < COLS; c++) for (let r = 0; r <= ROWS - 4; r++) {
    for (let i = 0; i < 4; i++) w[i] = b[(r + i) * COLS + c];
    score += scoreWindow(w, player);
  }
  for (let r = 0; r <= ROWS - 4; r++) for (let c = 0; c <= COLS - 4; c++) {
    for (let i = 0; i < 4; i++) w[i] = b[(r + i) * COLS + c + i];
    score += scoreWindow(w, player);
  }
  for (let r = 3; r < ROWS; r++) for (let c = 0; c <= COLS - 4; c++) {
    for (let i = 0; i < 4; i++) w[i] = b[(r - i) * COLS + c + i];
    score += scoreWindow(w, player);
  }
  return score;
}

function isBoardFull(h) {
  for (let c = 0; c < COLS; c++) if (h[c] < ROWS) return false;
  return true;
}

function minimax(b, h, depth, alpha, beta, maximizing) {
  if (depth === 0) return { score: evaluate(b, P_BOT), col: -1 };
  if (isBoardFull(h)) return { score: 0, col: -1 };
  const me = maximizing ? P_BOT : P_HUMAN;
  let bestCol = -1;
  let value = maximizing ? -Infinity : Infinity;
  for (const col of MOVE_ORDER) {
    if (h[col] >= ROWS) continue;
    const row = h[col];
    b[row * COLS + col] = me;
    h[col]++;
    let score;
    if (isWin(b, h, col, me)) {
      score = maximizing ? 100000 + depth : -100000 - depth;
    } else {
      score = minimax(b, h, depth - 1, alpha, beta, !maximizing).score;
    }
    h[col]--;
    b[row * COLS + col] = EMPTY;
    if (maximizing) {
      if (score > value) { value = score; bestCol = col; }
      if (value > alpha) alpha = value;
    } else {
      if (score < value) { value = score; bestCol = col; }
      if (value < beta) beta = value;
    }
    if (alpha >= beta) break;
  }
  return { score: value, col: bestCol };
}

function botPickMove() {
  const res = minimax(board, heights, MAX_DEPTH, -Infinity, Infinity, true);
  return res.col >= 0 ? res.col : MOVE_ORDER.find((c) => heights[c] < ROWS);
}

function startDrop(col, player) {
  const row = heights[col]; // landing row
  const fromY = boardY - cellSize * 0.5;
  const toY = boardY + (ROWS - 1 - row) * cellSize + cellSize / 2;
  falling = { col, fromY, toY, t: 0, dur: 0.32, player };
}

function commitMove(col, player) {
  const row = heights[col];
  board[row * COLS + col] = player;
  heights[col]++;
  if (isWin(board, heights, col, player)) {
    state = player === P_HUMAN ? "WIN_H" : "WIN_B";
    winLine = findWinLine(board, player);
    return;
  }
  if (isBoardFull(heights)) { state = "DRAW"; return; }
  turn = player === P_HUMAN ? P_BOT : P_HUMAN;
  if (turn === P_BOT) { botThinking = true; botDelay = 0.18; }
}

function tick({ ctx, dt, width, height, input }) {
  if (dt > 0.05) dt = 0.05;
  layout(width, height);
  const clicks = input.consumeClicks();

  if (state !== "PLAY") {
    if (clicks.length > 0 && !falling) { initBoard(); layout(width, height); }
  } else if (!falling && !botThinking && turn === P_HUMAN) {
    for (const c of clicks) {
      const col = colFromX(c.x);
      if (canPlay(col)) { startDrop(col, P_HUMAN); break; }
    }
  }

  if (botThinking && !falling) {
    botDelay -= dt;
    if (botDelay <= 0) {
      const col = botPickMove();
      botThinking = false;
      if (col >= 0 && canPlay(col)) startDrop(col, P_BOT);
    }
  }

  if (falling) {
    falling.t += dt;
    if (falling.t >= falling.dur) {
      const { col, player } = falling;
      falling = null;
      commitMove(col, player);
    }
  }

  draw(ctx, width, height, input);
}

function chipColor(player) { return player === P_HUMAN ? "#ff3b30" : "#ffd60a"; }

function draw(ctx, width, height, input) {
  ctx.fillStyle = "#0a0a14";
  ctx.fillRect(0, 0, width, height);

  // HUD
  ctx.font = "13px system-ui, sans-serif";
  ctx.textBaseline = "alphabetic";
  ctx.textAlign = "left";
  ctx.fillStyle = "#9aa0b4";
  let status;
  if (state === "WIN_H") status = "You win — click to restart";
  else if (state === "WIN_B") status = "Bot wins — click to restart";
  else if (state === "DRAW") status = "Draw — click to restart";
  else if (botThinking) status = "Bot thinking…";
  else if (falling) status = "…";
  else status = turn === P_HUMAN ? "Your turn (red)" : "Bot turn (yellow)";
  ctx.fillText(status, 10, 20);
  ctx.textAlign = "right";
  ctx.fillStyle = "#777a8a";
  ctx.fillText(`minimax α-β depth ${MAX_DEPTH}`, width - 10, 20);

  // Hover indicator
  if (state === "PLAY" && !falling && !botThinking && turn === P_HUMAN) {
    const hc = colFromX(input.mouseX);
    if (hc >= 0 && canPlay(hc)) {
      ctx.fillStyle = chipColor(P_HUMAN);
      ctx.beginPath();
      ctx.arc(boardX + hc * cellSize + cellSize / 2, boardY - cellSize * 0.5, cellSize * 0.32, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  // Board panel
  ctx.fillStyle = "#1a4ad9";
  ctx.fillRect(boardX, boardY, boardW, boardH);

  // Cells + chips
  const radius = cellSize * 0.38;
  for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
    const cx = boardX + c * cellSize + cellSize / 2;
    const cy = boardY + (ROWS - 1 - r) * cellSize + cellSize / 2;
    const v = board[r * COLS + c];
    ctx.fillStyle = v === EMPTY ? "#0a0a14" : chipColor(v);
    ctx.beginPath();
    ctx.arc(cx, cy, radius, 0, Math.PI * 2);
    ctx.fill();
    if (v !== EMPTY) {
      ctx.strokeStyle = "rgba(0,0,0,0.25)";
      ctx.lineWidth = 1.5;
      ctx.stroke();
    }
  }

  // Falling chip (ease-in for gravity feel)
  if (falling) {
    const u = Math.min(1, falling.t / falling.dur);
    const e = u * u;
    const cx = boardX + falling.col * cellSize + cellSize / 2;
    const cy = falling.fromY + (falling.toY - falling.fromY) * e;
    ctx.fillStyle = chipColor(falling.player);
    ctx.beginPath();
    ctx.arc(cx, cy, radius, 0, Math.PI * 2);
    ctx.fill();
    ctx.strokeStyle = "rgba(0,0,0,0.25)";
    ctx.lineWidth = 1.5;
    ctx.stroke();
  }

  // Winning four pulse
  if (winLine) {
    const pulse = 0.55 + 0.45 * Math.sin(performance.now() / 180);
    ctx.strokeStyle = `rgba(255,255,255,${pulse.toFixed(3)})`;
    ctx.lineWidth = 3;
    for (const { r, c } of winLine) {
      ctx.beginPath();
      ctx.arc(boardX + c * cellSize + cellSize / 2, boardY + (ROWS - 1 - r) * cellSize + cellSize / 2, radius + 2, 0, Math.PI * 2);
      ctx.stroke();
    }
  }

  // Column separators
  ctx.strokeStyle = "rgba(0,0,0,0.15)";
  ctx.lineWidth = 1;
  for (let c = 1; c < COLS; c++) {
    ctx.beginPath();
    ctx.moveTo(boardX + c * cellSize, boardY);
    ctx.lineTo(boardX + c * cellSize, boardY + boardH);
    ctx.stroke();
  }
}

Comments (0)

Log in to comment.