35
Connect Four
click a column to drop
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.