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.
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:
- 2D grid management — you need a real row/column model now
- Gravity simulation — pieces must "fall" to the lowest empty row in a column
- Directional scanning — win checking across four directions with bounds checking
- 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:
- Immediate win — if AI can win this turn, take it
- Block opponent — if opponent wins next turn, block it
- Score positions — evaluate connectivity (how many 2-in-a-rows and 3-in-a-rows each move creates)
- Center preference — center columns give more winning opportunities
- 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
-
Off-by-one errors in bounds checking. Always verify
r >= 0 && r < ROWS && c >= 0 && c < COLSbefore accessingboard[r][c]. -
Forgetting to check all four directions. Candidates often check horizontal and vertical but miss one or both diagonals.
-
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.
-
Column full handling. After a column fills up, disable the column button and reject further drops. Forgetting this causes index errors when
getLowestEmptyRowreturns -1. -
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
Build Snake from Scratch with Vanilla JS
A complete guide to building the classic Snake game using only HTML, CSS, and vanilla JavaScript. Covers game loops, collision detection, growing mechanics, and responsive canvas rendering. Interview-ready implementation.
FrontendBuild Tic-Tac-Toe from Scratch with Vanilla JS
A step-by-step guide to building a complete Tic-Tac-Toe game using only HTML, CSS, and vanilla JavaScript. Covers game state management, win detection, and AI opponent — perfect for frontend interview prep.
FrontendBuild a Hierarchical Checkbox from Scratch with Vanilla JS
A complete guide to building a hierarchical (tri-state) checkbox tree using only HTML, CSS, and vanilla JavaScript. Covers parent-child propagation, indeterminate state, dynamic trees, and accessibility. Interview-ready implementation.