Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
283 changes: 259 additions & 24 deletions Connect-4.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,168 @@ const HUMAN_WIN_SCORE = -4;
const COMPUTER_WIN_SCORE = 4;
const NO_WIN_SCORE = 0;

// Transposition Table constants
const TT_EXACT = 0;
const TT_LOWERBOUND = 1;
const TT_UPPERBOUND = 2;
const MAX_TT_SIZE = 1000000; // Max entries in transposition table

// Bitboard constants
const BOARD_HEIGHT = TOTAL_ROWS + 1; // Extra row for overflow detection
const BOARD_WIDTH = TOTAL_COLUMNS;
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

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

The BOARD_WIDTH constant is defined but never used in the code. Since TOTAL_COLUMNS is used consistently throughout the codebase for the same purpose, BOARD_WIDTH appears to be redundant. Consider removing this unused constant to reduce code clutter.

Suggested change
const BOARD_WIDTH = TOTAL_COLUMNS;

Copilot uses AI. Check for mistakes.

// Initialize Zobrist hashing table (random 64-bit values for each position and player)
const zobristTable = [];
function initZobrist() {
zobristTable.length = 0;
// Use a simple seeded random number generator for better distribution
let seed = 12345n;
const next = () => {
seed = (seed * 48271n) % 2147483647n;
return seed;
};

for (let col = 0; col < TOTAL_COLUMNS; col++) {
zobristTable[col] = [];
for (let row = 0; row < TOTAL_ROWS; row++) {
zobristTable[col][row] = [];
// Generate pseudo-random 64-bit values for each player
zobristTable[col][row][1] = (next() << 32n) | next();
zobristTable[col][row][2] = (next() << 32n) | next();
Comment on lines +23 to +35
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

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

The Zobrist hash generation is using a 32-bit seed (2147483647 = 2^31 - 1) with a limited multiplicative congruential generator. This produces values in the 32-bit range, not true 64-bit values. The shift operation (next() << 32n) | next() will result in the upper 32 bits being just zeros or small values since next() returns at most 2147483647. This significantly reduces the randomness and could lead to hash collisions.

Consider using a better 64-bit random number generator, or use JavaScript's crypto API for truly random 64-bit values during initialization.

Copilot uses AI. Check for mistakes.
}
}
}
initZobrist();

// Transposition table
let transpositionTable = new Map();

// Bitboard utility functions
function createBitboard() {
return {
player1: 0n, // Bitboard for player 1
player2: 0n, // Bitboard for player 2
heights: Array(TOTAL_COLUMNS).fill(0), // Height of each column
hash: 0n // Zobrist hash
};
}

function copyBitboard(bb) {
return {
player1: bb.player1,
player2: bb.player2,
heights: bb.heights.slice(),
hash: bb.hash
};
}

// Convert column and row to bit position
function positionToBit(col, row) {
return BigInt(col * BOARD_HEIGHT + row);
}

// Make a move on bitboard
function bitboardMakeMove(bb, player, col) {
const row = bb.heights[col];
if (row >= TOTAL_ROWS) {
return null; // Column full
}

const pos = positionToBit(col, row);
const mask = 1n << pos;

if (player === 1) {
bb.player1 |= mask;
} else {
bb.player2 |= mask;
}

// Update Zobrist hash
bb.hash ^= zobristTable[col][row][player];

bb.heights[col]++;
return { col, row };
}

// Check if a bitboard position has 4 in a row
function bitboardCheckWin(bitboard) {
// Horizontal check
let m = bitboard & (bitboard >> BigInt(BOARD_HEIGHT));
if (m & (m >> BigInt(BOARD_HEIGHT * 2))) {
return true;
}

// Vertical check
m = bitboard & (bitboard >> 1n);
if (m & (m >> 2n)) {
return true;
}

// Diagonal / check
m = bitboard & (bitboard >> BigInt(BOARD_HEIGHT - 1));
if (m & (m >> BigInt((BOARD_HEIGHT - 1) * 2))) {
return true;
}

// Diagonal \ check
m = bitboard & (bitboard >> BigInt(BOARD_HEIGHT + 1));
if (m & (m >> BigInt((BOARD_HEIGHT + 1) * 2))) {
return true;
}

return false;
}

// Find winning chips for highlighting
function findWinningChips(bitboard, lastCol, lastRow) {
const directions = [
{ dc: 0, dr: 1 }, // Vertical
{ dc: 1, dr: 0 }, // Horizontal
{ dc: 1, dr: 1 }, // Diagonal /
{ dc: 1, dr: -1 } // Diagonal \
];

for (const dir of directions) {
const chips = [];

// Check in both directions from last move
for (let step = -3; step <= 3; step++) {
const c = lastCol + step * dir.dc;
const r = lastRow + step * dir.dr;

if (c >= 0 && c < TOTAL_COLUMNS && r >= 0 && r < TOTAL_ROWS) {
const pos = positionToBit(c, r);
const mask = 1n << pos;

if (bitboard & mask) {
chips.push({ col: c, row: r });
if (chips.length === 4) {
return chips;
}
} else {
chips.length = 0;
if (step === 0) break;
}
} else {
chips.length = 0;
if (step === 0) break;
}
}
}

return null;
}

// game state object
const GameState = function (cloneGameState) {
this.board = Array.from({ length: TOTAL_COLUMNS }, () => []);
this.bitboard = createBitboard();
this.score = NO_WIN_SCORE;
this.winningChips = undefined;

if (cloneGameState) {
this.board = cloneGameState.board.map(col => col.slice());
this.bitboard = copyBitboard(cloneGameState.bitboard);
this.score = cloneGameState.score;
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

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

The GameState constructor doesn't clone the winningChips property when cloning a game state. Line 170 only copies the score, but winningChips is left undefined. This could cause issues if a cloned state is supposed to represent a winning position, as the winning chips information would be lost. While this may not affect the AI search (since it primarily uses the score), it could cause inconsistencies if cloned states are used for other purposes.

Suggested change
this.score = cloneGameState.score;
this.score = cloneGameState.score;
this.winningChips = Array.isArray(cloneGameState.winningChips)
? cloneGameState.winningChips.map(chip => ({ ...chip }))
: cloneGameState.winningChips;

Copilot uses AI. Check for mistakes.
}
};
Expand All @@ -22,26 +176,37 @@ GameState.prototype.makeMove = function(player, col) {
const row = this.board[col].length;
if (row < TOTAL_ROWS) {
this.board[col][row] = player;

// Also make move on bitboard
coords = bitboardMakeMove(this.bitboard, player, col);
Comment on lines 176 to +181
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

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

The declaration of coords is initialized to undefined on line 175, but then immediately overwritten on line 181 by the result of bitboardMakeMove. This initialization is redundant since coords will always be assigned a new value within the if block. Consider declaring coords only when needed for clarity.

Copilot uses AI. Check for mistakes.

this.setScore(player, col, row);
Comment on lines 176 to 183
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

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

The makeMove function updates both the legacy array-based board and the new bitboard representation. However, there's a subtle inconsistency: the row variable is calculated from the array-based board (line 176), but then used to make a move on the bitboard (line 181). This works currently because both representations start empty and are kept in sync, but if there were ever a desynchronization, this could lead to incorrect behavior. It would be more robust to use the bitboard's heights array directly for determining the row, or to verify that both representations agree.

Suggested change
const row = this.board[col].length;
if (row < TOTAL_ROWS) {
this.board[col][row] = player;
// Also make move on bitboard
coords = bitboardMakeMove(this.bitboard, player, col);
this.setScore(player, col, row);
// Use bitboard's heights to determine row after move
if (this.board[col].length < TOTAL_ROWS) {
const row = this.board[col].length;
this.board[col][row] = player;
// Make move on bitboard and get the actual row from bitboard
coords = bitboardMakeMove(this.bitboard, player, col);
// Use the row from bitboard's heights (coords.row)
if (coords && typeof coords.row === "number") {
this.setScore(player, col, coords.row);
} else {
// Fallback: use row from array-based board (should not happen)
this.setScore(player, col, row);
}

Copilot uses AI. Check for mistakes.
coords = { col, row };
}
return coords;
};

GameState.prototype.isBoardFull = function() {
return this.board.every(col => col.length >= TOTAL_ROWS);
return this.bitboard.heights.every(h => h >= TOTAL_ROWS);
};

GameState.prototype.setScore = function(player, col, row) {
const isWin =
this.checkRuns(player, col, row, 0, 1) ||
this.checkRuns(player, col, row, 1, 0) ||
this.checkRuns(player, col, row, 1, 1) ||
this.checkRuns(player, col, row, 1, -1);

this.score = isWin ? (player === 1 ? HUMAN_WIN_SCORE : COMPUTER_WIN_SCORE) : NO_WIN_SCORE;
// Use fast bitboard win detection
const playerBitboard = player === 1 ? this.bitboard.player1 : this.bitboard.player2;
const isWin = bitboardCheckWin(playerBitboard);

if (isWin) {
this.score = player === 1 ? HUMAN_WIN_SCORE : COMPUTER_WIN_SCORE;
this.winningChips = findWinningChips(playerBitboard, col, row);
} else {
this.score = NO_WIN_SCORE;
}
};

GameState.prototype.isWin = function() {
return (this.score === HUMAN_WIN_SCORE || this.score === COMPUTER_WIN_SCORE);
}

// Keep legacy methods for backward compatibility if needed
GameState.prototype.checkRuns = function(player, col, row, colStep, rowStep) {
let runCount = 0;

Expand Down Expand Up @@ -73,9 +238,6 @@ GameState.prototype.getPlayerForChipAt = function(col, row) {
}
return player;
}
GameState.prototype.isWin = function() {
return (this.score === HUMAN_WIN_SCORE || this.score === COMPUTER_WIN_SCORE);
}

// listen for messages from the main thread
self.addEventListener('message', function(e) {
Expand All @@ -94,7 +256,10 @@ self.addEventListener('message', function(e) {

function resetGame() {
currentGameState = new GameState();


// Clear transposition table on game reset
transpositionTable.clear();

self.postMessage({
messageType: 'reset-done'
});
Expand All @@ -119,12 +284,14 @@ function makeComputerMove(maxDepth) {
let col;
let isWinImminent = false;
let isLossImminent = false;

for (let depth = 0; depth <= maxDepth; depth++) {
const origin = new GameState(currentGameState);
const isTopLevel = (depth === maxDepth);

// fun recursive AI stuff kicks off here
const tentativeCol = think(origin, 2, depth, isTopLevel);
// Alpha-beta search with initial bounds
const tentativeCol = think(origin, 2, depth, isTopLevel, -Infinity, Infinity);

if (origin.score === HUMAN_WIN_SCORE) {
// AI realizes it can lose, thinks all moves suck now, keep move picked at previous depth
// this solves the "apathy" problem
Expand Down Expand Up @@ -157,10 +324,36 @@ function makeComputerMove(maxDepth) {
});
}

function think(node, player, recursionsRemaining, isTopLevel) {
function think(node, player, recursionsRemaining, isTopLevel, alpha, beta) {
// Store original bounds for transposition table flag determination
const origAlpha = alpha;
const origBeta = beta;

// Check transposition table
const hash = node.bitboard.hash;
const ttEntry = transpositionTable.get(hash);

if (ttEntry && ttEntry.depth >= recursionsRemaining && !isTopLevel) {
// Use cached result if depth is sufficient
if (ttEntry.flag === TT_EXACT) {
node.score = ttEntry.score;
return ttEntry.bestMove;
} else if (ttEntry.flag === TT_LOWERBOUND) {
alpha = Math.max(alpha, ttEntry.score);
} else if (ttEntry.flag === TT_UPPERBOUND) {
beta = Math.min(beta, ttEntry.score);
}

if (alpha >= beta) {
node.score = ttEntry.score;
return ttEntry.bestMove;
}
}

let col;
let scoreSet = false;
const childNodes = [];
let bestMove = -1;

// consider each column as a potential move
for (col = 0; col < TOTAL_COLUMNS; col++) {
Expand All @@ -172,7 +365,7 @@ function think(node, player, recursionsRemaining, isTopLevel) {
}

// make sure column isn't already full
const row = node.board[col].length;
const row = node.bitboard.heights[col];
if (row < TOTAL_ROWS) {
// create new child node to represent this potential move
const childNode = new GameState(node);
Expand All @@ -182,29 +375,71 @@ function think(node, player, recursionsRemaining, isTopLevel) {
if(!childNode.isWin() && recursionsRemaining > 0) {
// no game stopping win and there are still recursions to make, think deeper
const nextPlayer = (player === 1) ? 2 : 1;
think(childNode, nextPlayer, recursionsRemaining - 1);
think(childNode, nextPlayer, recursionsRemaining - 1, false, alpha, beta);
}

if (!scoreSet) {
// no best score yet, just go with this one for now
node.score = childNode.score;
bestMove = col;
scoreSet = true;

// Update alpha or beta
if (player === 2) {
alpha = Math.max(alpha, node.score);
} else {
beta = Math.min(beta, node.score);
}
} else if (player === 1 && childNode.score < node.score) {
// assume human will always pick the lowest scoring move (least favorable to computer)
node.score = childNode.score;
bestMove = col;
beta = Math.min(beta, node.score);
} else if (player === 2 && childNode.score > node.score) {
// computer should always pick the highest scoring move (most favorable to computer)
node.score = childNode.score;
bestMove = col;
alpha = Math.max(alpha, node.score);
}

// Alpha-beta pruning
if (beta <= alpha) {
break; // Prune remaining branches
}
}
}

Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

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

There's a potential issue with the logic when no valid moves are available (all columns full). If all columns are full and no scoreSet occurs, node.score will retain its previous value (or be undefined for a new node), and bestMove will remain -1. This could cause issues when the function returns -1 as the best move. While this is a rare edge case (game should be detected as full before this happens), it would be more robust to handle this case explicitly or ensure scoreSet is always true when at least one move is possible.

Suggested change
// If no valid moves were found, set node.score and bestMove explicitly
if (!scoreSet) {
node.score = NO_WIN_SCORE;
bestMove = -1;
}

Copilot uses AI. Check for mistakes.
// Store in transposition table (with size limit)
if (transpositionTable.size < MAX_TT_SIZE) {
let flag;
// Use original bounds to determine flag type
if (node.score <= origAlpha) {
flag = TT_UPPERBOUND;
} else if (node.score >= origBeta) {
flag = TT_LOWERBOUND;
} else {
flag = TT_EXACT;
}

transpositionTable.set(hash, {
score: node.score,
depth: recursionsRemaining,
flag: flag,
bestMove: bestMove
});
}
Comment on lines +412 to 430
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

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

There's a subtle issue with storing transposition table entries when alpha-beta pruning occurs. When the search is cut off (break at line 407), the node.score may not represent the true minimax value of the position—it's just a bound. However, the flag determination logic at lines 416-422 correctly handles this by comparing against the original alpha and beta values. The issue is that even with correct flag types, storing a pruned position might lead to suboptimal move ordering since bestMove might be the move that caused the cutoff rather than the actual best move. This is generally acceptable in practice but worth documenting.

Copilot uses AI. Check for mistakes.

// collect all moves tied for best move and randomly pick one
const candidates = [];
for (col = 0; col < TOTAL_COLUMNS; col++) {
if (childNodes[col] !== undefined && childNodes[col].score === node.score) {
candidates.push(col);
// For top level, collect all moves tied for best move and randomly pick one
// For non-top level, just return the best move (may have been pruned)
if (isTopLevel) {
const candidates = [];
for (col = 0; col < TOTAL_COLUMNS; col++) {
if (childNodes[col] !== undefined && childNodes[col].score === node.score) {
candidates.push(col);
Comment on lines +436 to +438
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

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

The variable declaration uses 'let' but 'col' is already declared in the function scope at line 353. This shadows the outer 'col' variable, which could lead to confusion. While this is valid JavaScript, it would be clearer to use a different variable name for the loop in lines 436-440, such as 'i' or 'candidateCol', to avoid shadowing and improve code readability.

Suggested change
for (col = 0; col < TOTAL_COLUMNS; col++) {
if (childNodes[col] !== undefined && childNodes[col].score === node.score) {
candidates.push(col);
for (let candidateCol = 0; candidateCol < TOTAL_COLUMNS; candidateCol++) {
if (childNodes[candidateCol] !== undefined && childNodes[candidateCol].score === node.score) {
candidates.push(candidateCol);

Copilot uses AI. Check for mistakes.
}
}
return candidates.length > 0 ? candidates[Math.floor(Math.random() * candidates.length)] : bestMove;
}
return candidates[Math.floor(Math.random() * candidates.length)];

return bestMove;
}
Loading