-
Notifications
You must be signed in to change notification settings - Fork 65
Open
Description
<title>Tic-Tac-Toe vs. Learning AI</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* Custom styles for a better look and feel */
body {
font-family: 'Inter', sans-serif;
background-color: #1a202c; /* Dark gray/black background */
}
/* Style for the game cells */
.cell {
width: 100px;
height: 100px;
display: flex;
justify-content: center;
align-items: center;
font-size: 3rem;
font-weight: 900; /* Bolder font for X and O */
cursor: pointer;
transition: all 0.2s ease-in-out;
background-color: #2d3748; /* Darker gray for cells */
color: #fbd38d; /* Yellow-ish text */
}
/* Hover effect for cells to give them a lift */
.cell:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
/* Mobile-first approach for responsive design */
@media (max-width: 640px) {
.cell {
width: 80px;
height: 80px;
font-size: 2.5rem;
}
}
</style>
Tic-Tac-Toe vs. Learning AI
<!-- The main game container -->
<div id="game-container">
<div id="status" class="text-center text-2xl font-semibold mb-6 h-8 text-yellow-400">It's your turn (X).</div>
<!-- The game board using CSS Grid for a 3x3 layout -->
<div id="board" class="grid grid-cols-3 gap-2 bg-yellow-400 p-2 rounded-xl">
<!-- Each div represents a single cell on the board -->
<div class="cell rounded-xl transition-all duration-200" data-cell-index="0"></div>
<div class="cell rounded-xl transition-all duration-200" data-cell-index="1"></div>
<div class="cell rounded-xl transition-all duration-200" data-cell-index="2"></div>
<div class="cell rounded-xl transition-all duration-200" data-cell-index="3"></div>
<div class="cell rounded-xl transition-all duration-200" data-cell-index="4"></div>
<div class="cell rounded-xl transition-all duration-200" data-cell-index="5"></div>
<div class="cell rounded-xl transition-all duration-200" data-cell-index="6"></div>
<div class="cell rounded-xl transition-all duration-200" data-cell-index="7"></div>
<div class="cell rounded-xl transition-all duration-200" data-cell-index="8"></div>
</div>
<!-- Game control button -->
<div class="mt-6 text-center">
<button id="restart-button" class="bg-yellow-400 text-black px-6 py-3 rounded-xl shadow-lg hover:bg-yellow-500 hover:shadow-xl transition-colors focus:outline-none">
Restart Game
</button>
</div>
<div id="ai-info" class="mt-4 text-center text-yellow-400 text-sm">
AI's Memory: <span id="q-table-size">0</span> states
</div>
</div>
</div>
<script>
// --- Game State Variables and DOM Elements ---
let board = ['', '', '', '', '', '', '', '', ''];
let currentPlayer = 'X';
let isGameActive = true;
// Game history to store AI's moves for learning.
let gameHistory = [];
// DOM Elements
const statusDisplay = document.getElementById('status');
const cells = document.querySelectorAll('.cell');
const restartButton = document.getElementById('restart-button');
const qTableSizeDisplay = document.getElementById('q-table-size');
// Define all possible winning conditions for Tic-Tac-Toe
const winningConditions = [
[0, 1, 2], [3, 4, 5], [6, 7, 8],
[0, 3, 6], [1, 4, 7], [2, 5, 8],
[0, 4, 8], [2, 4, 6]
];
// --- Q-learning AI Parameters ---
// The Q-table is an object mapping board states to actions and their Q-values.
// It acts as the AI's "memory."
let qTable = {};
const Q_TABLE_KEY = "tictactoe_q_table";
// Hyperparameters for the Q-learning algorithm
const LEARNING_RATE = 0.1; // How much new information updates old knowledge.
const DISCOUNT_FACTOR = 0.9; // How much future rewards are valued.
let EPSILON = 0.2; // The probability of the AI making a random (exploratory) move.
const EPSILON_DECAY = 0.9999; // Rate at which epsilon decreases over time.
// --- Q-learning Helper Functions ---
/**
* Converts the board array into a unique string to be used as a key in the Q-table.
* @param {Array<string>} currentBoard The current state of the board.
* @returns {string} A string representation of the board state.
*/
const getBoardStateKey = (currentBoard) => {
return currentBoard.join('');
};
/**
* Saves the current Q-table to the browser's local storage.
*/
const saveQTable = () => {
try {
localStorage.setItem(Q_TABLE_KEY, JSON.stringify(qTable));
console.log("Q-table saved.");
} catch (e) {
console.error("Error saving Q-table to local storage:", e);
}
};
/**
* Loads the Q-table from local storage, if it exists.
* Otherwise, it starts with an empty Q-table.
*/
const loadQTable = () => {
try {
const storedQTable = localStorage.getItem(Q_TABLE_KEY);
if (storedQTable) {
qTable = JSON.parse(storedQTable);
console.log("Q-table loaded.");
} else {
console.log("No existing Q-table found. Starting from scratch.");
}
} catch (e) {
console.error("Error loading Q-table from local storage:", e);
qTable = {};
}
// Update the display with the current size of the Q-table.
qTableSizeDisplay.textContent = Object.keys(qTable).length;
};
/**
* Updates the Q-table after a game has finished based on the final reward.
* The reward is back-propagated through the AI's game history.
* @param {Array<Object>} history An array of objects containing the state and action for each of the AI's moves.
* @param {number} reward The final reward for the AI (1 for win, 0.5 for draw, -1 for loss).
*/
const learn = (history, reward) => {
if (history.length === 0) return;
// Update the Q-value for the last move first.
const { state, action } = history[history.length - 1];
const oldQ = (qTable[state] && qTable[state][action]) || 0;
// The last state has no future reward, so maxNextQ is 0.
qTable[state] = qTable[state] || {};
qTable[state][action] = oldQ + LEARNING_RATE * (reward - oldQ);
// Back-propagate rewards for all previous moves.
for (let i = history.length - 2; i >= 0; i--) {
const { state: currentState, action: currentAction } = history[i];
const { state: nextState } = history[i + 1];
const oldQ = (qTable[currentState] && qTable[currentState][currentAction]) || 0;
// Find the maximum Q-value for the next state, representing the best possible future reward.
const maxNextQ = qTable[nextState] ? Math.max(...Object.values(qTable[nextState])) : 0;
// Q-learning update formula.
const newQ = oldQ + LEARNING_RATE * (0 + DISCOUNT_FACTOR * maxNextQ - oldQ);
qTable[currentState] = qTable[currentState] || {};
qTable[currentState][currentAction] = newQ;
}
};
/**
* Chooses the AI's next move using an epsilon-greedy strategy.
* @returns {number} The index of the chosen move.
*/
const chooseAIMove = () => {
const stateKey = getBoardStateKey(board);
const emptyCells = getEmptyCells(board);
// Epsilon-greedy strategy: with probability epsilon, explore; otherwise, exploit.
if (Math.random() < EPSILON) {
// Explore: choose a random move.
return emptyCells[Math.floor(Math.random() * emptyCells.length)];
} else {
// Exploit: choose the move with the highest Q-value.
let bestMove = -1;
let maxQValue = -Infinity;
if (qTable[stateKey]) {
// Check all available moves and find the one with the highest Q-value.
for (const move of emptyCells) {
const qValue = qTable[stateKey][move] || 0;
if (qValue > maxQValue) {
maxQValue = qValue;
bestMove = move;
}
}
}
// If no good move is found (no Q-values or all are 0), pick a random move.
if (bestMove === -1) {
return emptyCells[Math.floor(Math.random() * emptyCells.length)];
}
return bestMove;
}
};
// --- Game Logic Functions ---
/**
* Updates the status message displayed to the user.
* @param {string} message The message to display.
*/
const updateStatus = (message) => {
statusDisplay.textContent = message;
};
/**
* Checks if a specific player has won the game.
* @param {Array<string>} currentBoard The current state of the board.
* @param {string} player The player's symbol ('X' or 'O').
* @returns {boolean} True if the player has won, otherwise False.
*/
const checkWin = (currentBoard, player) => {
return winningConditions.some(combination => {
return combination.every(index => {
return currentBoard[index] === player;
});
});
};
/**
* Checks if the game has ended in a draw.
* @param {Array<string>} currentBoard The current state of the board.
* @returns {boolean} True if the board is full, otherwise False.
*/
const checkDraw = (currentBoard) => {
return !currentBoard.includes('');
};
/**
* Handles the user's click on a cell.
* @param {Event} event The click event object.
*/
const handleCellClick = (event) => {
const clickedCell = event.target;
const clickedCellIndex = parseInt(clickedCell.getAttribute('data-cell-index'));
// Ignore the click if the cell is already taken or the game is over.
if (board[clickedCellIndex] !== '' || !isGameActive || currentPlayer !== 'X') {
return;
}
// Update the board state and the cell's content on the page.
board[clickedCellIndex] = currentPlayer;
clickedCell.textContent = currentPlayer;
// 'X' gets a brighter yellow color.
clickedCell.classList.add('text-yellow-300');
// Check if the current move results in a win or a draw.
if (checkWin(board, currentPlayer)) {
isGameActive = false;
updateStatus(`You have won!`);
learn(gameHistory, -1); // AI loses, so it gets a negative reward.
return;
}
if (checkDraw(board)) {
isGameActive = false;
updateStatus(`It's a draw!`);
learn(gameHistory, 0.5); // AI gets a small positive reward for a draw.
return;
}
// Switch to the AI's turn.
currentPlayer = 'O';
updateStatus(`AI is thinking...`);
// Use a small delay to make the AI's "thinking" visible.
setTimeout(aiMove, 500);
};
/**
* Resets the game board and state for a new game.
*/
const resetGame = () => {
// Decay epsilon slightly to make the AI more confident over time.
EPSILON *= EPSILON_DECAY;
isGameActive = true;
currentPlayer = 'X';
gameHistory = [];
board = ['', '', '', '', '', '', '', '', ''];
for (let i = 0; i < 9; i++) {
cells[i].textContent = '';
cells[i].classList.remove('text-yellow-300', 'text-yellow-400');
}
updateStatus("It's your turn (X).");
qTableSizeDisplay.textContent = Object.keys(qTable).length;
// Save the Q-table after each game.
saveQTable();
};
/**
* Returns a list of indices of all empty cells.
* @param {Array<string>} currentBoard The current board state.
* @returns {Array<number>} An array of empty cell indices.
*/
const getEmptyCells = (currentBoard) => {
const emptyCells = [];
currentBoard.forEach((cell, index) => {
if (cell === '') {
emptyCells.push(index);
}
});
return emptyCells;
};
/**
* Executes the AI's turn by finding a move and placing its mark.
*/
const aiMove = () => {
const stateBeforeMove = getBoardStateKey(board);
const move = chooseAIMove();
if (move !== undefined) {
// Store the AI's move and the state before it for learning later.
gameHistory.push({ state: stateBeforeMove, action: move });
const clickedCell = cells[move];
board[move] = 'O';
clickedCell.textContent = 'O';
clickedCell.classList.add('text-yellow-400');
// Check for game end after the AI's move
if (checkWin(board, 'O')) {
isGameActive = false;
updateStatus("AI has won!");
learn(gameHistory, 1); // AI wins, so it gets a positive reward.
return;
}
if (checkDraw(board)) {
isGameActive = false;
updateStatus("It's a draw!");
learn(gameHistory, 0.5); // AI gets a small positive reward for a draw.
return;
}
// Switch back to the human player
currentPlayer = 'X';
updateStatus("It's your turn (X).");
}
};
// --- Event Listeners and Initialization ---
// Add a click event listener to each cell on the board
cells.forEach(cell => cell.addEventListener('click', handleCellClick));
// Add an event listener for the restart button
restartButton.addEventListener('click', resetGame);
// Load the AI's Q-table from local storage when the page loads.
window.addEventListener('load', loadQTable);
</script>
Metadata
Metadata
Assignees
Labels
No labels