Skip to content
202 changes: 152 additions & 50 deletions Connect-4.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// constants
const TOTAL_COLUMNS = 7;
const TOTAL_ROWS = 7;
const HUMAN_WIN_SCORE = -4;
const COMPUTER_WIN_SCORE = 4;
const HUMAN_WIN_SCORE = -1000000;
const COMPUTER_WIN_SCORE = 1000000;
const NO_WIN_SCORE = 0;

// Transposition Table constants
Expand Down Expand Up @@ -55,6 +55,17 @@ const OPENING_BOOK = {
// Block 3-stacks on edges
'010111': 0, // Block left edge 3-stack
'616161': 6, // Block right edge 3-stack

// Vertical stacking defense (any column)
'4142': 4, // Block column 4 stack
'414241': 4, // Continue blocking column 4
'2122': 2, // Block column 2 stack
'5152': 5, // Block column 5 stack
Comment on lines +61 to +63
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The opening book entry '414241': 4 represents a game sequence where AI plays column 4 in response to human's first move on column 4. However, the opening book already defines that when human plays column 4 first ('41'), AI should respond with column 3 (line 27). This means the sequence '414241' would never occur if the AI follows its own opening book, making this entry unreachable dead code. Similar logic applies to entries '2122' and '5152' on lines 62-63.

Suggested change
'414241': 4, // Continue blocking column 4
'2122': 2, // Block column 2 stack
'5152': 5, // Block column 5 stack

Copilot uses AI. Check for mistakes.

// Human stacks center - AI contests by stacking same column
'3132': 3, // Contest center column
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The opening book has duplicate key '3132' with different intended behaviors. Line 30 assigns it to respond to "human center (31), AI center (32)" scenario, while line 66 has a comment "Human stacks center - AI contests by stacking same column" with the same key. Both map to column 3, but the duplicate entry suggests confusion about what this key represents. The key '3132' means moves 31 (human column 3) and 32 (AI column 3), so lines 30 and 66 represent the same board state and should not be duplicated.

Suggested change
'3132': 3, // Contest center column

Copilot uses AI. Check for mistakes.
'313231': 3, // Continue center control
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The opening book has duplicate key '313231' on lines 42 and 67. Both entries map to column 3 but are listed under different comment sections. This duplication is unnecessary and makes the opening book harder to maintain. Consider removing one of these duplicate entries.

Suggested change
'313231': 3, // Continue center control

Copilot uses AI. Check for mistakes.
'31323132': 3, // Keep stacking center
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The opening book entry '31323132': 3 on line 68 represents a sequence where human and AI alternate playing column 3 four times. However, this is an 8-character key representing 4 moves, which means it would only be consulted after move 4. But looking at the pattern: after '3132' (line 30/66), AI plays 3. Then if human plays 3 again, the board key is '313231', and the book says AI plays 3 (line 42/67). Then if human plays 3 again, the key would be '31323132', which is this entry. However, at this point, the column would likely be getting full (4 pieces in a 7-row board), and this represents a very specific scenario. Consider whether this entry adds value or if the search would handle this adequately.

Suggested change
'31323132': 3, // Keep stacking center

Copilot uses AI. Check for mistakes.
};
const MAX_OPENING_MOVES = 15; // Use opening book for first 15 moves (7-8 ply per side)

Expand All @@ -70,6 +81,7 @@ const CENTER_ADJACENT_WEIGHT = 2;
// ============================================================================
const AI_CONFIG = {
MAX_DEPTH: 20, // Maximum search depth (20 ply)
MIN_DEPTH: 12, // Minimum search depth (12 ply)
MAX_TIME: 5000, // Max 5 seconds per move
TT_SIZE: MAX_TT_SIZE, // Transposition table size
USE_OPENING_BOOK: true,
Expand All @@ -81,12 +93,13 @@ const AI_CONFIG = {
USE_THREAT_SEARCH: true,

// Evaluation weights for near-perfect play
DOUBLE_THREAT_WEIGHT: 5000,
THREAT_WEIGHT: 500,
POTENTIAL_THREAT_WEIGHT: 50,
CENTER_WEIGHT: 100,
ODD_EVEN_WEIGHT: 300,
MOBILITY_WEIGHT: 10,
DOUBLE_THREAT_WEIGHT: 50000,
THREAT_WEIGHT: 5000,
POTENTIAL_THREAT_WEIGHT: 500,
CENTER_WEIGHT: 200,
ODD_EVEN_WEIGHT: 600,
MOBILITY_WEIGHT: 20,
VERTICAL_THREAT_WEIGHT: 1500,
};

// ============================================================================
Expand Down Expand Up @@ -312,6 +325,20 @@ GameState.prototype.isWin = function() {
return (this.score === HUMAN_WIN_SCORE || this.score === COMPUTER_WIN_SCORE);
}

// Helper function to check if a score indicates a win, accounting for depth bonus
function isWinScore(score) {
return score >= COMPUTER_WIN_SCORE - AI_CONFIG.MAX_DEPTH ||
score <= HUMAN_WIN_SCORE + AI_CONFIG.MAX_DEPTH;
}

function isComputerWinScore(score) {
return score >= COMPUTER_WIN_SCORE - AI_CONFIG.MAX_DEPTH;
}

function isHumanWinScore(score) {
return score <= HUMAN_WIN_SCORE + AI_CONFIG.MAX_DEPTH;
}

// Keep legacy methods for backward compatibility if needed
GameState.prototype.checkRuns = function(player, col, row, colStep, rowStep) {
let runCount = 0;
Expand Down Expand Up @@ -571,31 +598,30 @@ GameState.prototype.countPotentialLines = function(player) {
return lines;
}

// Detect threats building on edges (columns 0, 1, 5, 6)
GameState.prototype.detectEdgeThreats = function(player) {
// Detect vertical stacking threats on ANY column
GameState.prototype.detectVerticalThreats = function(player) {
let threats = 0;
const edgeCols = [0, 1, 5, 6];

for (const col of edgeCols) {
for (let col = 0; col < TOTAL_COLUMNS; col++) {
const height = this.bitboard.heights[col];
if (height < 2 || height >= TOTAL_ROWS) continue;

// Check vertical stacking - count consecutive pieces from the top
if (height >= 2) {
let consecutive = 0;
let maxConsecutive = 0;

for (let row = 0; row < height; row++) {
if (this.board[col][row] === player) {
consecutive++;
maxConsecutive = Math.max(maxConsecutive, consecutive);
} else {
consecutive = 0;
}
// Count consecutive pieces from top of stack
let consecutive = 0;
for (let row = height - 1; row >= 0; row--) {
if (this.board[col][row] === player) {
consecutive++;
} else {
break; // Stop at first non-player piece
}

// Threat if there are 2+ consecutive pieces and room to grow
if (maxConsecutive >= 2 && height < TOTAL_ROWS) {
threats++;
}

// Threat if 2+ consecutive at top with room to grow
if (consecutive >= 2) {
threats++;
// Extra threat for 3 in a row (one move from winning)
if (consecutive >= 3) {
threats += 2;
}
}
}
Expand All @@ -608,8 +634,8 @@ GameState.prototype.advancedEvaluate = function(player) {
const opponent = player === 1 ? 2 : 1;
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused variable opponent.

Suggested change
const opponent = player === 1 ? 2 : 1;

Copilot uses AI. Check for mistakes.

// Terminal states
if (this.score === COMPUTER_WIN_SCORE) return 100000;
if (this.score === HUMAN_WIN_SCORE) return -100000;
if (this.score === COMPUTER_WIN_SCORE) return COMPUTER_WIN_SCORE;
if (this.score === HUMAN_WIN_SCORE) return HUMAN_WIN_SCORE;
if (this.isBoardFull()) return 0;

let score = 0;
Expand Down Expand Up @@ -649,11 +675,11 @@ GameState.prototype.advancedEvaluate = function(player) {
score += this.countPotentialLines(2) * AI_CONFIG.MOBILITY_WEIGHT;
score -= this.countPotentialLines(1) * AI_CONFIG.MOBILITY_WEIGHT;

// 7. Edge threat detection
const aiEdgeThreats = this.detectEdgeThreats(2);
const humanEdgeThreats = this.detectEdgeThreats(1);
score += aiEdgeThreats * 400;
score -= humanEdgeThreats * 600; // Weight human edge threats higher (defensive)
// 7. Vertical threat detection (ALL columns, not just edges)
const aiVerticalThreats = this.detectVerticalThreats(2);
const humanVerticalThreats = this.detectVerticalThreats(1);
score += aiVerticalThreats * AI_CONFIG.VERTICAL_THREAT_WEIGHT;
score -= humanVerticalThreats * (AI_CONFIG.VERTICAL_THREAT_WEIGHT * 1.5); // Weight human threats higher (defensive priority)

return score;
}
Expand Down Expand Up @@ -843,26 +869,87 @@ function makePlayer2Move(col) {
});
}

// Check for forced moves (instant wins or required blocks)
function getForcedMove(gameState) {
const validMoves = [];
const winningMoves = [];
const blockingMoves = [];

for (let col = 0; col < TOTAL_COLUMNS; col++) {
if (gameState.bitboard.heights[col] >= TOTAL_ROWS) continue;
validMoves.push(col);

// Check if AI can win immediately
const winTest = new GameState(gameState);
winTest.makeMove(2, col);
if (winTest.isWin()) {
winningMoves.push(col);
}

// Check if human would win if they play here
const blockTest = new GameState(gameState);
blockTest.makeMove(1, col);
if (blockTest.isWin()) {
blockingMoves.push(col);
}
}

// Priority 1: Take immediate win
if (winningMoves.length > 0) {
return { col: winningMoves[0], type: 'win' };
}

// Priority 2: Block immediate threat (if only one blocking move)
if (blockingMoves.length === 1) {
return { col: blockingMoves[0], type: 'block' };
}

// Priority 3: Multiple threats = losing position, but still must block one
if (blockingMoves.length > 1) {
return { col: blockingMoves[0], type: 'desperate-block' };
}

// Priority 4: Only one valid move
if (validMoves.length === 1) {
return { col: validMoves[0], type: 'only-move' };
}

return null; // No forced move, use search
}

function makeComputerMove(maxDepth) {
let col;
let isWinImminent = false;
let isLossImminent = false;

// Check opening book first
const boardKey = getBoardStateKey(currentGameState);
if (AI_CONFIG.USE_OPENING_BOOK && boardKey !== null && boardKey in OPENING_BOOK) {
const openingCol = OPENING_BOOK[boardKey];
// Verify move is valid
if (currentGameState.bitboard.heights[openingCol] < TOTAL_ROWS) {
col = openingCol;
// Check for forced moves FIRST (before opening book)
const forcedMove = getForcedMove(currentGameState);
if (forcedMove) {
col = forcedMove.col;
if (forcedMove.type === 'win') {
isWinImminent = true;
} else if (forcedMove.type === 'desperate-block') {
isLossImminent = true;
}
}

// Then check opening book (only if no forced move)
if (col === undefined) {
const boardKey = getBoardStateKey(currentGameState);
if (AI_CONFIG.USE_OPENING_BOOK && boardKey !== null && boardKey in OPENING_BOOK) {
const openingCol = OPENING_BOOK[boardKey];
// Verify move is valid
if (currentGameState.bitboard.heights[openingCol] < TOTAL_ROWS) {
col = openingCol;
}
}
}

if (col === undefined) {
// Use iterative deepening with aspiration windows and time management
const startTime = Date.now();
const maxTime = AI_CONFIG.MAX_TIME;
const actualMaxDepth = Math.min(maxDepth, AI_CONFIG.MAX_DEPTH);
const actualMaxDepth = Math.max(AI_CONFIG.MIN_DEPTH, Math.min(maxDepth, AI_CONFIG.MAX_DEPTH));

let bestMove = 3; // Center as default
let bestScore = 0;
Expand Down Expand Up @@ -900,12 +987,13 @@ function makeComputerMove(maxDepth) {

bestScore = origin.score;

if (origin.score === HUMAN_WIN_SCORE) {
// Check for win/loss using helper functions that account for depth bonuses
if (isHumanWinScore(origin.score)) {
// AI realizes it can lose
isLossImminent = true;
// Keep the best move from previous depth
break;
} else if (origin.score === COMPUTER_WIN_SCORE) {
} else if (isComputerWinScore(origin.score)) {
// AI knows how to win
col = tentativeCol;
isWinImminent = true;
Expand All @@ -922,7 +1010,7 @@ function makeComputerMove(maxDepth) {
}

// Early exit if we have a very strong position
if (Math.abs(bestScore) > 8000) {
if (Math.abs(bestScore) > 50000) {
break;
}
}
Expand Down Expand Up @@ -1027,7 +1115,14 @@ function think(node, player, recursionsRemaining, isTopLevel, alpha, beta) {

if (childNode.isWin()) {
// Terminal win node
// Properly handle win scores relative to current player
score = childNode.score;
// Add depth bonus - prefer faster wins, delay losses
if (score === COMPUTER_WIN_SCORE) {
score = score - recursionsRemaining; // Prefer faster AI wins
} else if (score === HUMAN_WIN_SCORE) {
score = score + recursionsRemaining; // Prefer slower human wins (delay loss)
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "prefer slower human wins (delay loss)" but the actual effect is achieved by adding recursionsRemaining to HUMAN_WIN_SCORE, which makes shallower human wins more negative (worse from AI's perspective). The comment is correct in intent but could be clearer. Consider rephrasing to: "Make immediate human wins worse (more negative) so AI prefers positions where losses are delayed".

Suggested change
score = score + recursionsRemaining; // Prefer slower human wins (delay loss)
score = score + recursionsRemaining; // Make immediate human wins worse (more negative) so AI prefers positions where losses are delayed

Copilot uses AI. Check for mistakes.
}
} else if (childNode.isBoardFull()) {
// Terminal draw node
score = 0;
Expand Down Expand Up @@ -1140,17 +1235,24 @@ function think(node, player, recursionsRemaining, isTopLevel, alpha, beta) {
});
}

// For top level, collect all moves tied for best move and randomly pick one
// For top level, collect all moves tied for best score and use center-preference tie-breaking
if (isTopLevel) {
const candidates = [];
for (let col = 0; col < TOTAL_COLUMNS; col++) {
if (childNodes[col] !== undefined &&
((player === 2 && childNodes[col].score === node.score) ||
(player === 1 && childNodes[col].score === node.score))) {
((player === 2 && childNodes[col].score === bestScore) ||
(player === 1 && childNodes[col].score === bestScore))) {
Comment on lines 1242 to +1244
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tie-breaking logic compares childNodes[col].score with bestScore, but these scores are from different perspectives. The childNodes[col].score contains the score from the child node's perspective (set by recursive think() calls), while bestScore contains the negated score from the parent's perspective (computed as -childNode.score at line 1163). This comparison will not correctly identify tied moves. Consider storing the computed scores in a separate array during the search loop, or tracking which moves achieved the best score using a different mechanism.

Copilot uses AI. Check for mistakes.
candidates.push(col);
}
}
return candidates.length > 0 ? candidates[Math.floor(Math.random() * candidates.length)] : bestMove;
// Deterministic tie-breaking - prefer center columns
if (candidates.length > 0) {
const preference = [3, 2, 4, 1, 5, 0, 6];
for (const col of preference) {
if (candidates.includes(col)) return col;
}
}
return bestMove;
}

return bestMove;
Expand Down
35 changes: 35 additions & 0 deletions ai-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,41 @@ const AI_TESTS = [
setup: [{ player: 1, col: 3 }],
shouldConsider: [2, 3, 4],
},

// Center Column Vertical Threat Detection (Bug from screenshot)
{
name: "AI blocks column 4 (center-adjacent) vertical stack",
setup: [
{ player: 1, col: 4 }, { player: 2, col: 3 },
{ player: 1, col: 4 }, { player: 2, col: 3 },
{ player: 1, col: 4 },
],
expectedMove: 4,
mustBlock: true,
description: "Tests vertical threat detection on center-adjacent column"
},
{
name: "AI blocks column 3 (center) vertical stack",
setup: [
{ player: 1, col: 3 }, { player: 2, col: 2 },
{ player: 1, col: 3 }, { player: 2, col: 2 },
{ player: 1, col: 3 },
],
expectedMove: 3,
mustBlock: true,
description: "AI must block vertical threat on center column"
},
{
name: "AI blocks column 2 vertical stack",
setup: [
{ player: 1, col: 2 }, { player: 2, col: 3 },
{ player: 1, col: 2 }, { player: 2, col: 3 },
{ player: 1, col: 2 },
],
expectedMove: 2,
mustBlock: true,
description: "AI must block vertical threat on column 2"
},
];

// Test runner
Expand Down
Loading