Back to Frontend
Evergreen··14 min read

Build Connect Four from Scratch with Vanilla JS

A complete guide to building Connect Four using only HTML, CSS, and vanilla JavaScript. Covers gravity-based piece dropping, win detection across rows/columns/diagonals, and AI opponent. Interview-ready implementation.

javascriptvanilla-jsinterviewsgamehtmlcss
Share

Build Connect Four from Scratch with Vanilla JS

Connect Four is a step up from Tic-Tac-Toe in interview difficulty. The board is larger (6 rows × 7 columns), pieces fall due to gravity, and win detection must scan in four directions. It's an excellent test of your ability to work with 2D grids and manage more complex state.

By the end, you'll have:

  • Gravity-based piece dropping — pieces fall to the lowest available row
  • Four-direction win detection — horizontal, vertical, and both diagonals
  • Column hover preview — shows where a piece will land
  • AI opponent — a heuristic-based bot that plays well
  • Animated drops — CSS-powered falling animation
  • Full keyboard support — arrow keys to select column, Enter to drop

Why Interviewers Ask This

Connect Four builds on Tic-Tac-Toe but adds:

  1. 2D grid management — you need a real row/column model now
  2. Gravity simulation — pieces must "fall" to the lowest empty row in a column
  3. Directional scanning — win checking across four directions with bounds checking
  4. Larger state space — 42 cells means brute-force minimax isn't instant; you need heuristics or depth limits

Step 1: HTML Structure

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Connect Four</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <div class="game">
    <h1>Connect Four</h1>
    <div class="status" aria-live="polite">Red's turn</div>
 
    <div class="board-container">
      <div class="column-selectors" role="toolbar" aria-label="Column selection">
        <button class="col-btn" data-col="0" aria-label="Drop piece in column 1">↓</button>
        <button class="col-btn" data-col="1" aria-label="Drop piece in column 2">↓</button>
        <button class="col-btn" data-col="2" aria-label="Drop piece in column 3">↓</button>
        <button class="col-btn" data-col="3" aria-label="Drop piece in column 4">↓</button>
        <button class="col-btn" data-col="4" aria-label="Drop piece in column 5">↓</button>
        <button class="col-btn" data-col="5" aria-label="Drop piece in column 6">↓</button>
        <button class="col-btn" data-col="6" aria-label="Drop piece in column 7">↓</button>
      </div>
 
      <div class="board" role="grid" aria-label="Connect Four board" id="board"></div>
    </div>
 
    <div class="controls">
      <button class="btn restart-btn">Restart</button>
      <button class="btn ai-btn">Play vs AI</button>
    </div>
  </div>
 
  <script src="script.js"></script>
</body>
</html>

Key decisions:

  • The board cells are generated by JavaScript (42 cells is tedious to write by hand and error-prone).
  • Column selector buttons sit above the board — this is the primary interaction point.
  • The board itself uses role="grid" for accessibility.

Step 2: CSS Styling

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
 
body {
  font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
  background: #1a1a2e;
  color: #e0e0e0;
}
 
.game {
  text-align: center;
}
 
h1 {
  font-size: 2rem;
  margin-bottom: 0.5rem;
  color: #e94560;
}
 
.status {
  font-size: 1.25rem;
  margin-bottom: 1rem;
  min-height: 1.5em;
}
 
.board-container {
  display: inline-block;
}
 
.column-selectors {
  display: grid;
  grid-template-columns: repeat(7, 60px);
  gap: 4px;
  padding: 0 4px;
  margin-bottom: 4px;
}
 
.col-btn {
  height: 36px;
  background: transparent;
  border: 2px solid transparent;
  color: #e0e0e0;
  font-size: 1.2rem;
  cursor: pointer;
  border-radius: 6px;
  transition: all 0.15s ease;
}
 
.col-btn:hover:not(:disabled),
.col-btn:focus-visible {
  border-color: #e94560;
  background: rgba(233, 69, 96, 0.15);
}
 
.col-btn:disabled {
  opacity: 0.3;
  cursor: not-allowed;
}
 
.board {
  display: grid;
  grid-template-columns: repeat(7, 60px);
  grid-template-rows: repeat(6, 60px);
  gap: 4px;
  background: #16213e;
  padding: 4px;
  border-radius: 8px;
}
 
.cell {
  width: 60px;
  height: 60px;
  background: #0f3460;
  border-radius: 50%;
  transition: background 0.15s ease;
}
 
.cell.red {
  background: #e94560;
  animation: drop 0.3s ease-out;
}
 
.cell.yellow {
  background: #f5c542;
  animation: drop 0.3s ease-out;
}
 
.cell.winner {
  animation: pulse 0.6s ease-in-out infinite alternate;
  box-shadow: 0 0 12px 4px rgba(255, 255, 255, 0.4);
}
 
.cell.preview {
  opacity: 0.35;
}
 
@keyframes drop {
  from { transform: translateY(-200px); opacity: 0.5; }
  to { transform: translateY(0); opacity: 1; }
}
 
@keyframes pulse {
  from { transform: scale(1); }
  to { transform: scale(1.1); }
}
 
.controls {
  margin-top: 1.5rem;
  display: flex;
  gap: 0.75rem;
  justify-content: center;
}
 
.btn {
  padding: 0.6rem 1.5rem;
  font-size: 1rem;
  border: 2px solid #e94560;
  background: transparent;
  color: #e94560;
  border-radius: 6px;
  cursor: pointer;
  transition: all 0.2s ease;
}
 
.btn:hover {
  background: #e94560;
  color: #1a1a2e;
}
 
.btn.active {
  background: #e94560;
  color: #1a1a2e;
}

The board uses circular cells (border-radius: 50%) to look like the classic Connect Four board. The drop animation makes pieces appear to fall in from above.


Step 3: JavaScript — Game Logic

(function () {
  'use strict';
 
  // --- Constants ---
  const ROWS = 6;
  const COLS = 7;
  const EMPTY = 0;
  const RED = 1;
  const YELLOW = 2;
  const WIN_LENGTH = 4;
 
  // Direction vectors for win checking: [rowDelta, colDelta]
  const DIRECTIONS = [
    [0, 1],   // horizontal →
    [1, 0],   // vertical ↓
    [1, 1],   // diagonal ↘
    [1, -1],  // diagonal ↙
  ];
 
  // --- State ---
  let board; // 2D array: board[row][col]
  let currentPlayer;
  let gameOver;
  let aiMode = false;
 
  // --- DOM References ---
  const boardEl = document.getElementById('board');
  const statusEl = document.querySelector('.status');
  const restartBtn = document.querySelector('.restart-btn');
  const aiBtn = document.querySelector('.ai-btn');
  const colBtns = document.querySelectorAll('.col-btn');
 
  // --- Initialization ---
  function init() {
    board = Array.from({ length: ROWS }, () => Array(COLS).fill(EMPTY));
    currentPlayer = RED;
    gameOver = false;
    statusEl.textContent = "Red's turn";
    buildBoard();
    updateColButtons();
  }
 
  function buildBoard() {
    boardEl.innerHTML = '';
    for (let r = 0; r < ROWS; r++) {
      for (let c = 0; c < COLS; c++) {
        const cell = document.createElement('div');
        cell.classList.add('cell');
        cell.dataset.row = r;
        cell.dataset.col = c;
        cell.setAttribute('role', 'gridcell');
        cell.setAttribute('aria-label', `Row ${r + 1}, Column ${c + 1}: empty`);
        boardEl.appendChild(cell);
      }
    }
  }
 
  // --- Event Listeners ---
  colBtns.forEach((btn) => {
    btn.addEventListener('click', () => {
      const col = Number(btn.dataset.col);
      handleDrop(col);
    });
 
    btn.addEventListener('mouseenter', () => showPreview(Number(btn.dataset.col)));
    btn.addEventListener('mouseleave', clearPreview);
  });
 
  restartBtn.addEventListener('click', init);
  aiBtn.addEventListener('click', () => {
    aiMode = !aiMode;
    aiBtn.classList.toggle('active', aiMode);
    aiBtn.textContent = aiMode ? 'Play vs Human' : 'Play vs AI';
    init();
  });
 
  document.addEventListener('keydown', (e) => {
    if (gameOver) return;
    if (e.key >= '1' && e.key <= '7') {
      handleDrop(Number(e.key) - 1);
    }
  });
 
  // --- Core Game Logic ---
 
  function getLowestEmptyRow(col) {
    for (let r = ROWS - 1; r >= 0; r--) {
      if (board[r][col] === EMPTY) return r;
    }
    return -1; // column is full
  }
 
  function handleDrop(col) {
    if (gameOver) return;
 
    const row = getLowestEmptyRow(col);
    if (row === -1) return; // column full
 
    placePiece(row, col, currentPlayer);
    clearPreview();
 
    const winner = checkWin(row, col);
    if (winner) {
      endGame(winner);
      return;
    }
 
    if (isBoardFull()) {
      endGame(null);
      return;
    }
 
    currentPlayer = currentPlayer === RED ? YELLOW : RED;
    statusEl.textContent = `${currentPlayer === RED ? "Red" : "Yellow"}'s turn`;
    updateColButtons();
 
    if (aiMode && currentPlayer === YELLOW && !gameOver) {
      disableColButtons();
      setTimeout(() => {
        const aiCol = getAIMove();
        if (aiCol !== -1) handleDrop(aiCol);
        enableColButtons();
      }, 400);
    }
  }
 
  function placePiece(row, col, player) {
    board[row][col] = player;
    const cell = getCellEl(row, col);
    cell.classList.add(player === RED ? 'red' : 'yellow');
    cell.setAttribute(
      'aria-label',
      `Row ${row + 1}, Column ${col + 1}: ${player === RED ? 'Red' : 'Yellow'}`
    );
  }
 
  function getCellEl(row, col) {
    return boardEl.children[row * COLS + col];
  }
 
  function isBoardFull() {
    return board[0].every((cell) => cell !== EMPTY);
  }
 
  // --- Win Detection ---
 
  function checkWin(row, col) {
    const player = board[row][col];
 
    for (const [dr, dc] of DIRECTIONS) {
      const cells = collectLine(row, col, dr, dc, player);
      if (cells.length >= WIN_LENGTH) {
        return { player, cells };
      }
    }
 
    return null;
  }
 
  function collectLine(row, col, dr, dc, player) {
    const cells = [[row, col]];
 
    // Check in the positive direction
    for (let i = 1; i < WIN_LENGTH; i++) {
      const r = row + dr * i;
      const c = col + dc * i;
      if (r < 0 || r >= ROWS || c < 0 || c >= COLS) break;
      if (board[r][c] !== player) break;
      cells.push([r, c]);
    }
 
    // Check in the negative direction
    for (let i = 1; i < WIN_LENGTH; i++) {
      const r = row - dr * i;
      const c = col - dc * i;
      if (r < 0 || r >= ROWS || c < 0 || c >= COLS) break;
      if (board[r][c] !== player) break;
      cells.push([r, c]);
    }
 
    return cells;
  }
 
  function endGame(result) {
    gameOver = true;
    disableColButtons();
 
    if (result) {
      const name = result.player === RED ? 'Red' : 'Yellow';
      statusEl.textContent = `${name} wins!`;
      result.cells.forEach(([r, c]) => {
        getCellEl(r, c).classList.add('winner');
      });
    } else {
      statusEl.textContent = "It's a draw!";
    }
  }
 
  // --- Preview ---
 
  function showPreview(col) {
    if (gameOver) return;
    clearPreview();
    const row = getLowestEmptyRow(col);
    if (row === -1) return;
    const cell = getCellEl(row, col);
    cell.classList.add('preview', currentPlayer === RED ? 'red' : 'yellow');
  }
 
  function clearPreview() {
    boardEl.querySelectorAll('.preview').forEach((cell) => {
      cell.classList.remove('preview', 'red', 'yellow');
      // Re-add color if the cell is actually occupied
      const r = Number(cell.dataset.row);
      const c = Number(cell.dataset.col);
      if (board[r][c] === RED) cell.classList.add('red');
      if (board[r][c] === YELLOW) cell.classList.add('yellow');
    });
  }
 
  // --- Column Button State ---
 
  function updateColButtons() {
    colBtns.forEach((btn) => {
      const col = Number(btn.dataset.col);
      btn.disabled = getLowestEmptyRow(col) === -1 || gameOver;
    });
  }
 
  function disableColButtons() {
    colBtns.forEach((btn) => (btn.disabled = true));
  }
 
  function enableColButtons() {
    updateColButtons();
  }
 
  // --- AI: Heuristic Scoring ---
 
  function getAIMove() {
    let bestScore = -Infinity;
    let bestCol = -1;
 
    // Evaluate each possible column
    for (let c = 0; c < COLS; c++) {
      const r = getLowestEmptyRow(c);
      if (r === -1) continue;
 
      // Check for immediate win
      board[r][c] = YELLOW;
      if (checkWin(r, c)) {
        board[r][c] = EMPTY;
        return c; // take the win
      }
      board[r][c] = EMPTY;
 
      // Check if opponent wins here — must block
      board[r][c] = RED;
      if (checkWin(r, c)) {
        board[r][c] = EMPTY;
        bestCol = c;
        bestScore = 1000; // high priority to block
        continue;
      }
      board[r][c] = EMPTY;
    }
 
    if (bestScore >= 1000) return bestCol;
 
    // Score columns by position and connectivity
    bestScore = -Infinity;
    bestCol = -1;
 
    for (let c = 0; c < COLS; c++) {
      const r = getLowestEmptyRow(c);
      if (r === -1) continue;
 
      board[r][c] = YELLOW;
      const score = evaluatePosition(r, c, YELLOW) - evaluatePosition(r, c, RED) * 0.8;
      // Prefer center columns
      const centerBonus = (3 - Math.abs(c - 3)) * 3;
      const totalScore = score + centerBonus;
 
      // Avoid moves that let opponent win above
      let opponentWinsAbove = false;
      if (r - 1 >= 0) {
        board[r][c] = EMPTY;
        board[r - 1][c] = RED;
        if (checkWin(r - 1, c)) opponentWinsAbove = true;
        board[r - 1][c] = EMPTY;
        board[r][c] = YELLOW;
      }
 
      board[r][c] = EMPTY;
 
      if (opponentWinsAbove) continue;
 
      if (totalScore > bestScore) {
        bestScore = totalScore;
        bestCol = c;
      }
    }
 
    // Fallback: pick any valid column
    if (bestCol === -1) {
      for (let c = 0; c < COLS; c++) {
        if (getLowestEmptyRow(c) !== -1) return c;
      }
    }
 
    return bestCol;
  }
 
  function evaluatePosition(row, col, player) {
    let score = 0;
 
    for (const [dr, dc] of DIRECTIONS) {
      let count = 1;
      let openEnds = 0;
 
      // Positive direction
      for (let i = 1; i < WIN_LENGTH; i++) {
        const r = row + dr * i;
        const c = col + dc * i;
        if (r < 0 || r >= ROWS || c < 0 || c >= COLS) break;
        if (board[r][c] === player) count++;
        else {
          if (board[r][c] === EMPTY) openEnds++;
          break;
        }
      }
 
      // Negative direction
      for (let i = 1; i < WIN_LENGTH; i++) {
        const r = row - dr * i;
        const c = col - dc * i;
        if (r < 0 || r >= ROWS || c < 0 || c >= COLS) break;
        if (board[r][c] === player) count++;
        else {
          if (board[r][c] === EMPTY) openEnds++;
          break;
        }
      }
 
      if (count >= 4) score += 100;
      else if (count === 3 && openEnds > 0) score += 20;
      else if (count === 2 && openEnds === 2) score += 8;
    }
 
    return score;
  }
 
  // --- Start ---
  init();
})();

Step 4: Code Walkthrough

Board Representation

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

A 2D array is the right choice for Connect Four. Unlike Tic-Tac-Toe, you need row/column coordinates for gravity calculation and directional win scanning. board[row][col] maps naturally to grid positions where row 0 is the top.

Gravity: Finding the Landing Row

function getLowestEmptyRow(col) {
  for (let r = ROWS - 1; r >= 0; r--) {
    if (board[r][col] === EMPTY) return r;
  }
  return -1;
}

We scan from the bottom (row 5) upward. The first empty cell is where the piece lands. If no empty cell exists, the column is full and returns -1.

This is the fundamental difference from Tic-Tac-Toe — players don't choose a cell, they choose a column, and gravity determines the row.

Win Detection: Directional Scanning

We check four directions from the last-placed piece:

→  horizontal     (0, +1)
↓  vertical       (+1, 0)
↘  diagonal down  (+1, +1)
↙  diagonal up    (+1, -1)

For each direction, we scan both ways (positive and negative) and count consecutive matching pieces. If the total reaches 4, that player wins.

function collectLine(row, col, dr, dc, player) {
  const cells = [[row, col]];
 
  // Scan positive direction
  for (let i = 1; i < WIN_LENGTH; i++) {
    const r = row + dr * i;
    const c = col + dc * i;
    if (r < 0 || r >= ROWS || c < 0 || c >= COLS) break;
    if (board[r][c] !== player) break;
    cells.push([r, c]);
  }
 
  // Scan negative direction
  for (let i = 1; i < WIN_LENGTH; i++) {
    const r = row - dr * i;
    const c = col - dc * i;
    if (r < 0 || r >= ROWS || c < 0 || c >= COLS) break;
    if (board[r][c] !== player) break;
    cells.push([r, c]);
  }
 
  return cells;
}

Why scan from the last move? You only need to check if the most recent piece created a four-in-a-row. Scanning the entire board on every move is wasteful.

AI Strategy

Full minimax on a 6×7 board is expensive (4,531,985,219,092 possible positions). Instead, we use a heuristic approach:

  1. Immediate win — if AI can win this turn, take it
  2. Block opponent — if opponent wins next turn, block it
  3. Score positions — evaluate connectivity (how many 2-in-a-rows and 3-in-a-rows each move creates)
  4. Center preference — center columns give more winning opportunities
  5. Trap avoidance — skip moves that let the opponent win in the row above

This produces an AI that plays well enough to beat casual players without needing full game tree search.


Step 5: Interview Variations

"How would you implement full minimax with alpha-beta pruning?"

For a competitive AI, use depth-limited minimax with alpha-beta pruning:

function minimaxAI(b, depth, alpha, beta, isMaximizing) {
  // Terminal checks
  const lastMove = findLastMove(b);
  if (lastMove && checkWinForBoard(b, lastMove.row, lastMove.col)) {
    return isMaximizing ? -10000 + depth : 10000 - depth;
  }
  if (depth === 0 || isBoardFull(b)) {
    return evaluateBoard(b);
  }
 
  if (isMaximizing) {
    let maxEval = -Infinity;
    for (let c = 0; c < COLS; c++) {
      const r = getLowestEmptyRowForBoard(b, c);
      if (r === -1) continue;
      b[r][c] = YELLOW;
      const eval_ = minimaxAI(b, depth - 1, alpha, beta, false);
      b[r][c] = EMPTY;
      maxEval = Math.max(maxEval, eval_);
      alpha = Math.max(alpha, eval_);
      if (beta <= alpha) break;
    }
    return maxEval;
  } else {
    let minEval = Infinity;
    for (let c = 0; c < COLS; c++) {
      const r = getLowestEmptyRowForBoard(b, c);
      if (r === -1) continue;
      b[r][c] = RED;
      const eval_ = minimaxAI(b, depth - 1, alpha, beta, true);
      b[r][c] = EMPTY;
      minEval = Math.min(minEval, eval_);
      beta = Math.min(beta, eval_);
      if (beta <= alpha) break;
    }
    return minEval;
  }
}

A depth of 6–8 with good move ordering produces strong play in reasonable time.

"Make it responsive for mobile"

Use vmin units and media queries:

.board {
  --cell-size: min(60px, 12vmin);
  grid-template-columns: repeat(7, var(--cell-size));
  grid-template-rows: repeat(6, var(--cell-size));
}
 
.cell {
  width: var(--cell-size);
  height: var(--cell-size);
}
 
@media (max-width: 500px) {
  .column-selectors {
    grid-template-columns: repeat(7, var(--cell-size));
  }
}

"Add online multiplayer"

The cleanest approach uses WebSockets:

// Pseudocode for the client side
const ws = new WebSocket('ws://localhost:8080');
 
ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
 
  switch (msg.type) {
    case 'move':
      // Opponent dropped a piece
      handleDrop(msg.col);
      break;
    case 'assign':
      // Server tells you which color you are
      myColor = msg.color;
      break;
  }
};
 
function handleDrop(col) {
  // ... existing logic ...
 
  // Send your move to the server
  if (currentPlayer === myColor) {
    ws.send(JSON.stringify({ type: 'move', col }));
  }
}

Common Mistakes in Interviews

  1. Off-by-one errors in bounds checking. Always verify r >= 0 && r < ROWS && c >= 0 && c < COLS before accessing board[r][c].

  2. Forgetting to check all four directions. Candidates often check horizontal and vertical but miss one or both diagonals.

  3. Not scanning both ways. If you only check in the positive direction from the placed piece, you'll miss wins where the piece completes the middle of a line.

  4. Column full handling. After a column fills up, disable the column button and reject further drops. Forgetting this causes index errors when getLowestEmptyRow returns -1.

  5. Draw detection. Only the top row matters — if board[0] has no empty cells, the board is full.


Key Takeaway

Connect Four teaches you to work with 2D grids and directional scanning — patterns that appear everywhere in frontend work (tables, calendars, grids, drag-and-drop). The gravity mechanic forces you to separate user intent (choosing a column) from game rules (piece falls to lowest row). That same principle — separating what the user wants from how the system fulfills it — is fundamental to good UI architecture.

You might also like