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
16 changes: 16 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Test files
test-*.html
test-*.js

# macOS
.DS_Store

# Editor files
*.swp
*.swo
*~
.vscode/
.idea/

# Logs
*.log
90 changes: 48 additions & 42 deletions Connect-4.css
Original file line number Diff line number Diff line change
Expand Up @@ -55,76 +55,82 @@ h2 {
border-radius: 8px;
}

.dif-options {
clear: both;
overflow: hidden;
margin: 20px -7px 0;
/* Timer Panel */
.timer-panel {
display: flex;
flex-direction: column;
gap: 10px;
padding: 15px;
}

.dif-options div {
float: left;
width: 20%;
.timer {
padding: 15px;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 8px;
font-family: "Doppio One", monospace;
text-align: center;
border: 2px solid transparent;
transition: all 0.3s ease;
}

.dif-options input {
display: none;
.timer.active {
border-color: #4CAF50;
box-shadow: 0 0 15px rgba(76, 175, 80, 0.5);
background-color: rgba(76, 175, 80, 0.2);
}

.dif-options input:checked+label {
color: #fff;
background-color: #593f6b;
border-color: #fff;
cursor: default;
.timer.warning {
border-color: #f44336;
background-color: rgba(244, 67, 54, 0.2);
animation: pulse 1s infinite;
}

.dif-options label {
display: block;
margin: 0 auto;
width: 24px;
height: 24px;
background-color: #666;
border: solid 2px #ccc;
border-radius: 8px;
color: #999;
text-align: center;
line-height: 24px;
cursor: pointer;
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}

.freeze .dif-options input:not(:checked)+label {
font-size: 0;
margin: 7px auto;
width: 10px;
height: 10px;
border-radius: 4px;
color: transparent;
line-height: 10px;
cursor: default;
transition: .2s
.timer-label {
font-size: 14px;
color: #aaa;
margin-bottom: 5px;
}

.start {
margin-top: 20px;
.timer-value {
font-size: 32px;
color: #fff;
font-weight: bold;
}

.start button {
/* Start Panel */
.start-panel {
padding: 12px;
}

.start-button {
display: block;
width: 100%;
padding: 2px 12px 4px;
padding: 8px 12px;
font-family: inherit;
font-size: 24px;
border: solid 2px #ccc;
border-radius: 8px;
background-color: #593f6b;
color: #fff;
cursor: pointer;
transition: all 0.2s;
}

.start-button:hover {
background-color: #6d4d82;
transform: scale(1.02);
}

.start button:focus {
.start-button:focus {
outline: none;
}

.freeze .start {
.start-panel.freeze .start-button {
display: none;
}

Expand Down
128 changes: 103 additions & 25 deletions Connect-4.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ const MAX_TT_SIZE = 1000000; // Max entries in transposition table
const BOARD_HEIGHT = TOTAL_ROWS + 1; // Extra row for overflow detection
const BOARD_WIDTH = TOTAL_COLUMNS;

// Opening book - prioritize center column
const OPENING_BOOK = {
'': 3, // First move - always play center column
};
const MAX_OPENING_MOVES = 2; // Only use opening book for first 2 moves

Comment on lines +22 to +23
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 opening book currently only contains an entry for the empty board state (first move). The MAX_OPENING_MOVES constant is set to 2, suggesting the opening book should be used for the first 2 moves, but there's no entry defined for the second move. Either remove the MAX_OPENING_MOVES constant and use a simple check for empty board, or add actual second-move entries to the opening book to match the intended design.

Suggested change
const MAX_OPENING_MOVES = 2; // Only use opening book for first 2 moves

Copilot uses AI. Check for mistakes.
// Column ordering for move ordering (center columns first for better alpha-beta pruning)
const COLUMN_ORDER = [3, 2, 4, 1, 5, 0, 6];

// Position evaluation weights
const CENTER_COLUMN_WEIGHT = 3;
const CENTER_ADJACENT_WEIGHT = 2;

// Initialize Zobrist hashing table (random 64-bit values for each position and player)
const zobristTable = [];
function initZobrist() {
Expand Down Expand Up @@ -239,6 +252,53 @@ GameState.prototype.getPlayerForChipAt = function(col, row) {
return player;
}

// Evaluate position heuristically for non-terminal positions
GameState.prototype.evaluatePosition = function(player) {
let score = 0;

// Center control - pieces in center columns are more valuable
for (let row = 0; row < this.bitboard.heights[3]; row++) {
if (this.board[3][row] === player) {
score += CENTER_COLUMN_WEIGHT;
} else if (this.board[3][row] !== undefined) {
score -= CENTER_COLUMN_WEIGHT;
}
}

// Adjacent to center also valuable
for (let col of [2, 4]) {
for (let row = 0; row < this.bitboard.heights[col]; row++) {
if (this.board[col][row] === player) {
score += CENTER_ADJACENT_WEIGHT;
} else if (this.board[col][row] !== undefined) {
score -= CENTER_ADJACENT_WEIGHT;
}
}
}

// Normalize score to be within minimax range
return score * 0.1;
}

// Get a simple board state hash for opening book lookup
function getBoardStateKey(gameState) {
let key = '';
let moveCount = 0;
for (let col = 0; col < TOTAL_COLUMNS; col++) {
moveCount += gameState.board[col].length;
}

// Only use opening book for first few moves
if (moveCount > MAX_OPENING_MOVES) return null;

for (let col = 0; col < TOTAL_COLUMNS; col++) {
for (let row = 0; row < gameState.board[col].length; row++) {
key += col + '' + gameState.board[col][row];
Comment on lines +294 to +296
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 opening book lookup generates a key by concatenating column and player values without delimiters. This could create ambiguous keys. For example, column 1 with player 1 followed by column 0 with player 2 would generate "11" + "02" = "1102", but column 11 with player 0 followed by column 2 with something else could potentially create the same pattern. While column 11 doesn't exist in Connect-4, this pattern is fragile. Consider using a delimiter like "," or "-" between moves for clarity and robustness.

Copilot uses AI. Check for mistakes.
}
}
return key;
}

// listen for messages from the main thread
self.addEventListener('message', function(e) {
switch(e.data.messageType) {
Expand Down Expand Up @@ -285,27 +345,40 @@ function makeComputerMove(maxDepth) {
let isWinImminent = false;
let isLossImminent = false;

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

// 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
isLossImminent = true;
break;
} else if (origin.score === COMPUTER_WIN_SCORE) {
// AI knows how to win, no need to think deeper, use this move
// this solves the "cocky" problem
col = tentativeCol;
isWinImminent = true;
break;
} else {
// go with this move, for now at least
col = tentativeCol;
// Check opening book first
const boardKey = getBoardStateKey(currentGameState);
if (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 fixed high depth
for (let depth = 0; depth <= maxDepth; depth++) {
const origin = new GameState(currentGameState);
const isTopLevel = (depth === maxDepth);

// 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
isLossImminent = true;
break;
} else if (origin.score === COMPUTER_WIN_SCORE) {
// AI knows how to win, no need to think deeper, use this move
// this solves the "cocky" problem
col = tentativeCol;
isWinImminent = true;
break;
} else {
// go with this move, for now at least
col = tentativeCol;
}
}
}

Expand Down Expand Up @@ -350,13 +423,14 @@ function think(node, player, recursionsRemaining, isTopLevel, alpha, beta) {
}
}

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

// consider each column as a potential move
for (col = 0; col < TOTAL_COLUMNS; col++) {
// Use column ordering for better alpha-beta pruning (center columns first)
for (let colIdx = 0; colIdx < COLUMN_ORDER.length; colIdx++) {
const col = COLUMN_ORDER[colIdx];

if(isTopLevel) {
self.postMessage({
messageType: 'progress',
Expand All @@ -376,6 +450,10 @@ function think(node, player, recursionsRemaining, isTopLevel, alpha, beta) {
// no game stopping win and there are still recursions to make, think deeper
const nextPlayer = (player === 1) ? 2 : 1;
think(childNode, nextPlayer, recursionsRemaining - 1, false, alpha, beta);
} else if (!childNode.isWin() && recursionsRemaining === 0) {
// At leaf node, apply heuristic evaluation
const heuristicScore = childNode.evaluatePosition(2); // Evaluate for computer
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 heuristic evaluation is only applied when the computer (player 2) is being evaluated, but this score is used in the minimax tree for both players. The function should evaluate from the perspective of the current player being considered, not always from player 2's perspective. This could lead to suboptimal move selection when the AI is considering the human's responses.

Suggested change
const heuristicScore = childNode.evaluatePosition(2); // Evaluate for computer
const heuristicScore = childNode.evaluatePosition(player); // Evaluate for current player

Copilot uses AI. Check for mistakes.
childNode.score = heuristicScore;
}

if (!scoreSet) {
Expand Down Expand Up @@ -433,7 +511,7 @@ function think(node, player, recursionsRemaining, isTopLevel, alpha, beta) {
// For non-top level, just return the best move (may have been pruned)
if (isTopLevel) {
const candidates = [];
for (col = 0; col < TOTAL_COLUMNS; col++) {
for (let col = 0; col < TOTAL_COLUMNS; col++) {
if (childNodes[col] !== undefined && childNodes[col].score === node.score) {
candidates.push(col);
}
Expand Down
Loading