Skip to content

How To: Add AI to our jQuery Tic-Tac-Toe

Previously, in the tic-tac-toe series, we programmed a simple game of tic-tac-toe. The version we built reminded me of two-player games in the early ’90s, when friends sat at the same computer to play. However, since playing with a friend is not always possible, it’s time to add a computer opponent to the mix. At the very least, now I won’t feel weird playing both sides myself.

This tutorial starts where the previous one finished. We will keep the adjustable board, SVG tokens, score counter, and two-human-player option. Instead of rebuilding all of that, we will focus on the code that changed: choosing an AI opponent, finding good moves, giving the AI several difficulty levels, and adding a little interface polish so the computer feels like part of the game.

What are we adding?

Before we begin, we need to know what we are actually adding to the game. In other words, what do we need to code so the new features work as expected? This also gives us a small task list to follow, which is much nicer than wandering into an AI algorithm armed only with optimism.

Our basic requirements:

Some of those requirements can look a bit daunting, but we’ll tackle them one by one. We won’t repeat every ordinary HTML or CSS declaration from the first tutorial. The AI logic deserves a closer look, though, and we’ll cover every new JavaScript responsibility even when a few related lines are grouped into one snippet.

I’m getting a bit ahead of myself—you might be wondering if you need to know anything special before continuing. Don’t worry. Read the previous tutorial, and if its arrays, functions, and jQuery event handlers make sense, you are in a good place to tackle this one. Recursion will be our one big new idea, and we’ll take it slowly.

If you want to check out the final result, you can play the demo and see it all together. A full download of the source code is also available here.

Since we are only changing three first-party files in this version—index.html, style.css, and tic_tac_toe.js—you can copy the folder from the previous tutorial and edit it. Forgot where it is? No worries; you can download the original project. The jQuery file and token-icons.svg do not change.

An interface for a human or AI opponent

Player 1 stays human, while the old Player 2 card becomes an opponent card. It starts with two radio buttons: Human and AI. The human choice reveals the familiar name and token controls; the AI choice reveals a difficulty list instead.

Switching the opponent controls

This is the meaningful new part of the Player 2 form. The full, slightly repetitive token picker is already in index.html inside the downloadable ZIP, so we can safely spare ourselves another wall of four nearly identical SVG labels.

<fieldset>
	<legend>Opponent type</legend>
	<div class="choice-picker">
		<label>
			<input type="radio" name="opponent-type" value="human" />
			<span>Human</span>
		</label>
		<label>
			<input type="radio" name="opponent-type" value="ai" checked />
			<span>AI</span>
		</label>
	</div>
</fieldset>

<div class="human-settings" id="human-settings" hidden>
	<label for="player-2-name">Display name</label>
	<input
		id="player-2-name"
		name="name"
		type="text"
		value="Player 2"
		maxlength="20"
	/>

	<!-- The existing Player 2 token picker goes here. -->
</div>

<div class="ai-settings" id="ai-settings">
	<label for="ai-difficulty">AI difficulty</label>
	<select id="ai-difficulty" name="difficulty">
		<option value="easy">Easy</option>
		<option value="normal" selected>Normal</option>
		<option value="hard">Hard</option>
		<option value="expert">Expert</option>
	</select>
	<p class="field-help">
		The AI chooses its own name and available token.
	</p>
</div>

The two settings groups always exist in the page; JavaScript only changes which one is available. Importantly, it disables the hidden group as well as hiding it:

function toggleOpponentSettings() {
	const isAi =
		$('#player-2-form [name="opponent-type"]:checked').val() === 'ai';

	$('#human-settings')
		.prop('hidden', isAi)
		.find('input')
		.prop('disabled', isAi);
	$('#ai-settings')
		.prop('hidden', !isAi)
		.find('select')
		.prop('disabled', !isAi);
	$('#player-2-form .button--player').text(
		isAi ? 'Set AI opponent' : 'Set Player 2'
	);
	updateTokenAvailability();
}

Doing this instead of only adding a CSS class has two benefits. Hidden controls cannot accidentally participate in validation, and a keyboard user cannot tab into controls they cannot see. The button text changes too, so the action still makes sense in either mode.

Giving our game a little more state

At the top of tic_tac_toe.js, we give the two array positions readable names and add the settings the AI will need:

const humanPlayerIndex = 0;
const aiPlayerIndex = 1;
const lossesBeforeDifficultySuggestion = 2;
const winsBeforeDifficultySuggestion = 3;
const allowedIcons = ['hat', 'car', 'ship', 'boot'];
const boardSizeClasses =
	'board-size-3 board-size-4 board-size-5 board-size-6';
const currentPlayerClasses = 'current-player-one current-player-two';

// A short, gender-neutral name pool gives the AI a little personality.
const aiNames = [
	'Alex',
	'Avery',
	'Casey',
	'Jordan',
	'Morgan',
	'Parker',
	'Quinn',
	'Riley',
	'Skyler',
	'Taylor',
];

// Each difficulty controls how often the AI uses a calculated move.
const difficulties = {
	easy: { label: 'Easy', calculatedMoveChance: 0 },
	normal: { label: 'Normal', calculatedMoveChance: 0.5 },
	hard: { label: 'Hard', calculatedMoveChance: 0.85 },
	expert: { label: 'Expert', calculatedMoveChance: 1 },
};
const difficultyOrder = ['easy', 'normal', 'hard', 'expert'];

// Searching every possible move is quick on a 3 × 3 board. Bigger boards
// use fewer search levels so the browser stays responsive.
const searchDepthByBoardSize = {
	3: 9,
	4: 4,
	5: 3,
	6: 3,
};

humanPlayerIndex is nicer to read than a mysterious 0 inside an algorithm. We also changed allowedIcons from a Set to an array. A set was handy when we only asked “is this icon allowed?”, but an array can answer that with includes() and can be filtered when the AI needs a random unused icon.

Each player now records whether it is controlled by the AI and, if so, its difficulty:

const players = [
	{
		name: 'Player 1',
		icon: 'hat',
		wins: 0,
		isReady: false,
		isAi: false,
		difficulty: null,
	},
	{
		name: 'Player 2',
		icon: 'car',
		wins: 0,
		isReady: false,
		isAi: false,
		difficulty: 'normal',
	},
];

let boardSize = 3;
let board = [];
let winningLines = [];
let currentPlayer = humanPlayerIndex;
let roundIsOver = false;
let aiTimer;
let humanWinStreak = 0;
let humanLossStreak = 0;
let showDifficultySuggestions = true;
let suggestedDifficulty = null;
let suggestionDirection = 0;
let pendingPlayerForm = null;
let confirmedTokenChangeForm = null;

That looks like quite a shopping list, but each variable has one job. winningLines avoids rebuilding the same rows, columns, and diagonals during every step of the AI search. The timer controls the short “thinking” pause. The streak and suggestion variables belong to the difficulty helper, and the final two variables let us confirm a token change without losing track of which form started it.

Saving either kind of opponent

The original setPlayer() only knew about a name and icon. The new version first decides which branch of the form is active. AI settings validate the difficulty; human settings keep the previous name and token checks.

// Save one player's form and update the visible summary.
function setPlayer(event) {
	event.preventDefault();

	const $form = $(this);
	const playerIndex = Number($form.attr('data-player'));
	const otherPlayerIndex = (playerIndex + 1) % players.length;
	const player = players[playerIndex];
	const isAi =
		playerIndex === aiPlayerIndex &&
		$form.find('[name="opponent-type"]:checked').val() === 'ai';
	const $message = $form.find('.form-message');
	let name = null;
	let icon = null;
	let difficulty = null;

	$message.text('');

	if (isAi) {
		difficulty = String($form.find('[name="difficulty"]').val());

		if (!Object.hasOwn(difficulties, difficulty)) {
			$message.text('Please choose an AI difficulty.');
			return;
		}
	} else {
		name = String($form.find('[name="name"]').val()).trim();
		icon = String($form.find('[name="icon"]:checked').val());

		if (!name) {
			$message.text('Please enter a display name.');
			$form.find('[name="name"]').trigger('focus');
			return;
		}

		if (!allowedIcons.includes(icon)) {
			$message.text('Please choose a token icon.');
			return;
		}

		if (
			players[otherPlayerIndex].isReady &&
			players[otherPlayerIndex].icon === icon
		) {
			$message.text(
				'Please choose a token the other player is not using.'
			);
			updateTokenAvailability();
			return;
		}
	}

	// The token-change confirmation is added in the polish section below.

	if (isAi) {
		player.name = pickRandom(aiNames);
		player.icon = getRandomAiIcon();
		player.difficulty = difficulty;
	} else {
		player.name = name;
		player.icon = icon;
	}

	player.isAi = isAi;
	player.isReady = true;
	updateTokenAvailability();

	if (playerIndex === aiPlayerIndex) {
		humanWinStreak = 0;
		humanLossStreak = 0;
	}

	// Reset the board before the updated setup appears in the summary.
	startNewRound();
	updatePlayerSummary(playerIndex);

	$form
		.find('.button--player')
		.text(
			playerIndex === aiPlayerIndex && isAi
				? 'Pick a new AI opponent'
				: `Update Player ${playerIndex + 1}`
		);
	$form
		.closest('.player-card')
		.find('.player-settings')
		.prop('open', false);
}

Object.hasOwn(difficulties, difficulty) checks the submitted value against our real difficulty table instead of trusting that nobody changed the HTML in the browser’s developer tools. The same idea protected the icon name in the first game.

An AI receives a random name and a token that Player 1 is not using:

function pickRandom(items) {
	return items[Math.floor(Math.random() * items.length)];
}

function getRandomAiIcon() {
	const otherPlayer = players[humanPlayerIndex];
	const availableIcons = allowedIcons.filter(
		(icon) => !otherPlayer.isReady || icon !== otherPlayer.icon
	);

	return pickRandom(availableIcons);
}

Keeping pickRandom() generic pays off twice: it chooses cosmetic names and icons here, then later chooses moves. A tiny helper with three jobs is better than copying the same Math.floor(Math.random() * ...) spell everywhere and eventually mistyping one of them.

The summary function now also describes the opponent type:

function updatePlayerSummary(playerIndex) {
	const player = players[playerIndex];
	const $playerCard = $(`.player-card:eq(${playerIndex})`);
	const $summary = $playerCard.find('.player-summary');

	$summary.find('.summary-token').empty().append(createIcon(player.icon));
	$summary.find('.summary-name').text(player.name);
	$summary.find('.win-count').text(player.wins);

	if (playerIndex === aiPlayerIndex) {
		const details = player.isAi
			? `AI · ${difficulties[player.difficulty].label} difficulty`
			: 'Human opponent';

		$summary.find('.player-details').text(details);
	}

	$summary.prop('hidden', false);
}

A simple Tic-Tac-Toe AI

Our AI is not a trained model and it does not secretly call a server. It is a small search algorithm running entirely in the browser. It imagines a move, imagines our reply, imagines its next reply, and scores the result. Very thoughtful for something that just received a racing-car token.

Before we reach that algorithm, both humans and computers need to use the same game rules.

One move pipeline for everybody

The first version’s click handler both selected a cell and performed the whole move. That no longer works because an AI does not click. We split the work into three layers:

  • playHumanMove() reads the clicked cell;
  • playAiTurn() chooses a cell with code;
  • both call playMoveAt(), which applies the shared win, draw, score, and turn rules.
function markCell(cellIndex, playerIndex) {
	const player = players[playerIndex];
	const $cell = $(`#game-board .cell[data-index="${cellIndex}"]`);

	board[cellIndex] = playerIndex;
	$cell
		.append(createIcon(player.icon))
		.addClass(`is-marked player-${playerIndex === 0 ? 'one' : 'two'}`)
		.attr('aria-label', `${player.name} marked this square`)
		.attr('aria-disabled', 'true')
		.prop('disabled', true);
}

function playMoveAt(cellIndex) {
	if (roundIsOver || board[cellIndex] !== null || !playersAreReady()) {
		return;
	}

	const playerIndex = currentPlayer;
	const player = players[playerIndex];
	markCell(cellIndex, playerIndex);

	const winningLine = findWinningLine(playerIndex);

	if (winningLine) {
		winningLine.forEach((index) => {
			$(`#game-board .cell[data-index="${index}"]`).addClass(
				'is-winning'
			);
		});

		player.wins += 1;
		$(`.player-card:eq(${playerIndex}) .win-count`).text(player.wins);
		finishRound(`${player.name} wins this round!`, playerIndex);
		return;
	}

	if (board.every((cell) => cell !== null)) {
		finishRound('This round is a draw.');
		return;
	}

	currentPlayer = (currentPlayer + 1) % players.length;
	beginTurn();
}

function playHumanMove() {
	if (players[currentPlayer].isAi) {
		return;
	}

	playMoveAt(Number($(this).attr('data-index')));
}

Doing this instead of creating a second “AI version” of the move code means a future rule change has one home. The guard inside playHumanMove() is deliberately repeated even though AI turns disable the board. The disabled buttons guide the interface; the state check protects the game.

Reusing the winning lines

The previous game called getWinningLines() each time it checked for a winner. Minimax will check thousands of pretend boards, so rebuilding those identical lines each time would be wasteful. A new round calculates them once, and findWinningLine() can now inspect either the real board or a board supplied by the search:

// Return the winning positions or undefined when no line matches.
function findWinningLine(playerIndex, squares = board) {
	return winningLines.find((line) =>
		line.every((cellIndex) => squares[cellIndex] === playerIndex)
	);
}

function getAvailableMoves(squares) {
	const moves = [];

	squares.forEach((square, index) => {
		if (square === null) {
			moves.push(index);
		}
	});

	return moves;
}

// Reset moves but keep names, tokens, and scores.
function startNewRound() {
	window.clearTimeout(aiTimer);
	closeDialog('round-actions');
	closeDialog('difficulty-suggestion');
	board = Array(boardSize * boardSize).fill(null);
	winningLines = getWinningLines();
	currentPlayer = humanPlayerIndex;
	roundIsOver = false;
	$('#hide-suggestions').prop('checked', false);
	renderBoard();

	if (playersAreReady()) {
		beginTurn();
	} else {
		$('#game-status').text('Set both players to begin.');
	}
}

The squares = board default keeps old calls short, while Minimax can pass its temporary board explicitly. getAvailableMoves() returns the indexes of every null cell; those are the branches the search can explore.

The Minimax algorithm

Minimax is a decision-making algorithm for two-player games where one player’s gain is the other player’s loss. The AI is the maximizing player because it wants the largest score. The human is the minimizing player because their best reply should push that score downward.

The “what” is simple: try possible moves and give each future outcome a number. The “how” is recursion—a function calls itself to look one turn farther ahead. The “be careful” is important: without a stopping condition, the recursion never ends; without restoring each pretend move, one branch leaks into the next; and on a large board, looking all the way to the end can freeze the page.

We first order empty cells from nearest the center to farthest away:

// Give promising center squares to minimax first. Move ordering does not
// change the answer, but it lets alpha-beta pruning skip more work.
function orderMoves(moves) {
	const center = (boardSize - 1) / 2;

	return moves.slice().sort((first, second) => {
		const firstRow = Math.floor(first / boardSize);
		const firstColumn = first % boardSize;
		const secondRow = Math.floor(second / boardSize);
		const secondColumn = second % boardSize;
		const firstDistance =
			Math.abs(firstRow - center) + Math.abs(firstColumn - center);
		const secondDistance =
			Math.abs(secondRow - center) + Math.abs(secondColumn - center);

		return firstDistance - secondDistance;
	});
}

slice() creates a copy before sorting, so a useful optimization does not unexpectedly rearrange the caller’s array. Center-first ordering does not make a move magically better; it simply helps the pruning step reach strong moves sooner.

Here is the recursive part:

// Minimax tries an AI move, then a human move, until the game ends or the
// search limit is reached. Alpha and beta avoid branches that cannot improve
// the result we have already found.
function minimax(squares, depth, maximizing, alpha, beta, maxDepth) {
	if (findWinningLine(aiPlayerIndex, squares)) {
		return 100000 - depth;
	}

	if (findWinningLine(humanPlayerIndex, squares)) {
		return depth - 100000;
	}

	const availableMoves = getAvailableMoves(squares);

	if (availableMoves.length === 0) {
		return 0;
	}

	if (depth >= maxDepth) {
		return evaluateBoard(squares);
	}

	if (maximizing) {
		let bestScore = -Infinity;

		for (const move of orderMoves(availableMoves)) {
			squares[move] = aiPlayerIndex;
			bestScore = Math.max(
				bestScore,
				minimax(squares, depth + 1, false, alpha, beta, maxDepth)
			);
			squares[move] = null;
			alpha = Math.max(alpha, bestScore);

			if (beta <= alpha) {
				break;
			}
		}

		return bestScore;
	}

	let bestScore = Infinity;

	for (const move of orderMoves(availableMoves)) {
		squares[move] = humanPlayerIndex;
		bestScore = Math.min(
			bestScore,
			minimax(squares, depth + 1, true, alpha, beta, maxDepth)
		);
		squares[move] = null;
		beta = Math.min(beta, bestScore);

		if (beta <= alpha) {
			break;
		}
	}

	return bestScore;
}

The first three returns are terminal results: an AI win is a very large positive score, a human win is a very large negative score, and a draw is zero. Including depth means the AI prefers a quick win over a slow one and delays an unavoidable loss as long as it can. Petty? Maybe. Correct? Also yes.

Inside each loop we place a pretend token, recurse, and immediately set that square back to null. Mutating and restoring one array is cheaper than cloning a new board at every branch, but the restore line is non-negotiable. Forget it and your imaginary games begin sharing imaginary moves, which creates very real bugs.

alpha is the best score the maximizing side can already guarantee; beta is the best score the minimizing side can already guarantee. Once beta <= alpha, that branch cannot change the final decision, so we stop exploring it. This shortcut is called alpha-beta pruning. It changes how much work we do, not which best move we find.

Scoring unfinished larger boards

A 3 × 3 board has a small enough game tree to search to the end. Our 4 × 4 through 6 × 6 boards do not. The number of possible move sequences grows wildly, so the depth table stops those searches early. When that happens, evaluateBoard() estimates who has the more promising position:

// At the search limit, reward lines the AI can still complete and penalize
// lines the human can complete. More marks in a line are worth much more.
function evaluateBoard(squares) {
	let score = 0;

	winningLines.forEach((line) => {
		const aiMarks = line.filter(
			(index) => squares[index] === aiPlayerIndex
		).length;
		const humanMarks = line.filter(
			(index) => squares[index] === humanPlayerIndex
		).length;

		if (aiMarks > 0 && humanMarks === 0) {
			score += 10 ** aiMarks;
		}

		if (humanMarks > 0 && aiMarks === 0) {
			score -= 1.1 * 10 ** humanMarks;
		}
	});

	return score;
}

A line containing marks from both players is blocked, so it contributes nothing. An open line with one AI mark earns 10; two marks earn 100; three earn 1000. The exponential score makes nearly completed lines much more important than scattered tokens. Human threats are multiplied by 1.1, giving defense a slight nudge when attack and defense otherwise look equal.

This is a heuristic: a useful estimate, not a promise of perfect play. That distinction matters. Expert is unbeatable on a normal 3 × 3 board because it can search the full remaining game, but on larger boards it is “our strongest search within a sensible browser budget,” not an all-knowing grid deity.

Finally, the top-level search tries each currently available move and keeps every move tied for the best score:

function findCalculatedMove() {
	const availableMoves = orderMoves(getAvailableMoves(board));
	const maxDepth = Math.min(
		searchDepthByBoardSize[boardSize],
		availableMoves.length
	);
	let bestScore = -Infinity;
	let bestMoves = [];

	for (const move of availableMoves) {
		board[move] = aiPlayerIndex;
		const score = minimax(
			board,
			1,
			false,
			-Infinity,
			Infinity,
			maxDepth
		);
		board[move] = null;

		if (score > bestScore) {
			bestScore = score;
			bestMoves = [move];
		} else if (score === bestScore) {
			bestMoves.push(move);
		}
	}

	return pickRandom(bestMoves);
}

Choosing randomly from equally good moves instead of always taking the first one makes repeat games less mechanical without making the AI deliberately worse. It also avoids the board’s lowest index becoming its suspiciously favorite square.

Difficulty without the “always win or tie” trap

A perfect tic-tac-toe AI sounds impressive until every game becomes the same loss or draw. We already did the hard work of finding a calculated move, so difficulty can stay wonderfully small: decide how often to use that move and how often to choose any empty square.

Mixing calculated and random moves

function chooseAiMove() {
	const availableMoves = getAvailableMoves(board);
	const difficulty = difficulties[players[aiPlayerIndex].difficulty];
	const shouldCalculate = Math.random() < difficulty.calculatedMoveChance;

	if (!shouldCalculate) {
		return pickRandom(availableMoves);
	}

	return findCalculatedMove();
}

function playAiTurn() {
	if (
		roundIsOver ||
		currentPlayer !== aiPlayerIndex ||
		!players[aiPlayerIndex].isAi
	) {
		return;
	}

	playMoveAt(chooseAiMove());
}

Easy always picks randomly. Normal calculates half of its moves. Hard calculates 85 percent, and Expert calculates every move. That gives us noticeably different personalities without maintaining four separate algorithms.

The random branch is intentionally allowed to make a bad move. If we only randomized between the best Minimax moves, every level would still be optimal—just optimal in a less predictable order. The guards in playAiTurn() matter because its call is delayed. During that pause the round might be restarted, the opponent might be changed back to human, or another setting might invalidate the scheduled turn.

Suggesting a better difficulty

The game quietly watches streaks when Player 2 is an AI. After three human wins it offers the next harder level; after two human losses it offers the next easier one. Draws break both streaks, and a two-human game resets them because difficulty no longer means anything.

function updateWinStreaks(winnerIndex) {
	if (!players[aiPlayerIndex].isAi) {
		humanWinStreak = 0;
		humanLossStreak = 0;
		return false;
	}

	if (winnerIndex === humanPlayerIndex) {
		humanWinStreak += 1;
		humanLossStreak = 0;

		if (humanWinStreak === winsBeforeDifficultySuggestion) {
			return showDifficultySuggestion(1);
		}
		return false;
	}

	if (winnerIndex === aiPlayerIndex) {
		humanLossStreak += 1;
		humanWinStreak = 0;

		if (humanLossStreak === lossesBeforeDifficultySuggestion) {
			return showDifficultySuggestion(-1);
		}
		return false;
	}

	// A draw breaks both winning and losing streaks.
	humanWinStreak = 0;
	humanLossStreak = 0;
	return false;
}

The direction is 1 for harder and -1 for easier. That lets one function move through difficultyOrder in either direction:

function showDifficultySuggestion(direction) {
	if (!showDifficultySuggestions) {
		return false;
	}

	const currentDifficulty = players[aiPlayerIndex].difficulty;
	const currentIndex = difficultyOrder.indexOf(currentDifficulty);
	const nextIndex = currentIndex + direction;

	if (nextIndex < 0 || nextIndex >= difficultyOrder.length) {
		return false;
	}

	suggestedDifficulty = difficultyOrder[nextIndex];
	suggestionDirection = direction;
	const nextLabel = difficulties[suggestedDifficulty].label;
	const message =
		direction < 0
			? `You lost ${lossesBeforeDifficultySuggestion} games in a row. Would you like to try ${nextLabel}?`
			: `You won ${winsBeforeDifficultySuggestion} games in a row. Ready to try ${nextLabel}?`;

	$('#difficulty-suggestion-message').text(message);
	$('#accept-difficulty').text(`Switch to ${nextLabel}`);
	$('#hide-suggestions').prop('checked', false);
	document.getElementById('difficulty-suggestion').showModal();
	return true;
}

The boundary check prevents suggestions below Easy or above Expert. Returning a Boolean also tells finishRound() whether the suggestion dialog replaced the normal replay dialog.

function rememberSuggestionPreference() {
	if ($('#hide-suggestions').prop('checked')) {
		showDifficultySuggestions = false;
	}
}

function closeDifficultyDialog() {
	closeDialog('difficulty-suggestion');
	suggestedDifficulty = null;
	suggestionDirection = 0;
}

// Refusing starts a fresh streak, so the question can appear again later.
function refuseDifficultySuggestion() {
	rememberSuggestionPreference();

	if (suggestionDirection < 0) {
		humanLossStreak = 0;
	} else if (suggestionDirection > 0) {
		humanWinStreak = 0;
	}

	closeDifficultyDialog();
	startNewRound();
}

function acceptDifficultySuggestion() {
	if (!suggestedDifficulty) {
		return;
	}

	const newDifficulty = suggestedDifficulty;
	rememberSuggestionPreference();
	humanWinStreak = 0;
	humanLossStreak = 0;
	closeDifficultyDialog();
	players[aiPlayerIndex].difficulty = newDifficulty;

	// The new round is visible before the summary reveals the new level.
	startNewRound();
	$('#ai-difficulty').val(newDifficulty);
	updatePlayerSummary(aiPlayerIndex);
}

“Don’t show this again” lasts for the current page visit rather than being saved permanently. That keeps the demo simple and avoids introducing localStorage for one checkbox. Refusing resets only the streak that caused the suggestion, so the same question can return after another genuine streak instead of appearing after every following round.

Some extra polish to make it feel good

The AI now works, so everything in this section is about making its behavior understandable and keeping small interface surprises out of the way. None of these pieces makes Minimax smarter, but together they make the game feel much less like a spreadsheet that occasionally places a hat.

A visible thinking turn and a locked board

The old showTurn() becomes beginTurn(). Human turns enable empty cells. AI turns lock every cell, show a thinking message, and wait 450 milliseconds before moving:

// Enable empty cells only when it is a human player's turn.
function updateBoardAvailability() {
	const humanCanPlay =
		playersAreReady() && !roundIsOver && !players[currentPlayer].isAi;
	const currentPlayerClass =
		currentPlayer === humanPlayerIndex
			? 'current-player-one'
			: 'current-player-two';
	const $board = $('#game-board');

	$board.removeClass(currentPlayerClasses).addClass(currentPlayerClass);

	$board.find('.cell').each(function () {
		const cellIndex = Number($(this).attr('data-index'));
		const canUseCell = humanCanPlay && board[cellIndex] === null;

		$(this)
			.prop('disabled', !canUseCell)
			.attr('aria-disabled', String(!canUseCell));
	});
}

function showPlayerStatus(playerIndex, message) {
	const player = players[playerIndex];
	const playerClass =
		playerIndex === humanPlayerIndex ? 'player-one' : 'player-two';
	const $playerName = $('<span>', {
		class: `game-status__player ${playerClass}`,
		text: player.name,
	});

	$('#game-status').empty().append($playerName, message);
}

function beginTurn() {
	if (!playersAreReady() || roundIsOver) {
		updateBoardAvailability();
		return;
	}

	const player = players[currentPlayer];

	if (player.isAi) {
		showPlayerStatus(currentPlayer, ' is thinking…');
		updateBoardAvailability();

		// The short pause makes the computer turn easy to follow.
		window.clearTimeout(aiTimer);
		aiTimer = window.setTimeout(playAiTurn, 450);
		return;
	}

	showPlayerStatus(currentPlayer, ', choose an empty square.');
	updateBoardAvailability();
}

The delay is not pretending that the calculation takes 450 milliseconds—the search usually finishes much faster. It gives the player enough time to see that the turn changed. Using setTimeout() instead of a blocking delay keeps the page responsive, and every reset calls clearTimeout() so an old AI move cannot arrive late on a fresh board.

showPlayerStatus() creates the name with jQuery’s text option rather than assembling an HTML string. That keeps user-entered names as text while still letting us color the name separately from the rest of the sentence.

renderBoard() now creates every cell disabled. That is a safer starting point than guessing whether somebody can play while the buttons are being built; beginTurn() immediately calls updateBoardAvailability() with the complete current state.

Turn-colored hover shades

The board receives either .current-player-one or .current-player-two. Those classes fill two temporary custom properties used by the cell hover state:

:root {
	--player-one-hover: color-mix(
		in oklab,
		var(--paper),
		var(--player-one) 10%
	);
	--player-two-hover: color-mix(
		in oklab,
		var(--paper),
		var(--player-two) 10%
	);
}

.board.current-player-one {
	--current-player: var(--player-one);
	--current-player-hover: var(--player-one-hover);
}

.board.current-player-two {
	--current-player: var(--player-two);
	--current-player-hover: var(--player-two-hover);
}

.cell:not(:disabled):hover {
	border-color: var(--current-player);
	background: var(--current-player-hover);
	transform: translateY(-2px);
}

The hover now previews the current player’s color instead of always using Player 2’s green. We mix the pale backgrounds in oklab rather than oklch. Both are perceptual color spaces, but interpolating rectangular Lab coordinates behaved more smoothly here when the two player colors travel toward nearly white paper. With polar oklch, hue becomes unstable as chroma approaches zero, which can give very pale mixes an unexpected tint. The color-mix() reference has more examples if you want to experiment.

Keeping token choices honest

The first game rejected a duplicate token only after form submission. This version also disables any token already in use, and disables the entire human picker when AI mode is selected:

function updateTokenAvailability() {
	$('.player-form').each(function () {
		const $form = $(this);
		const playerIndex = Number($form.attr('data-player'));
		const usesAiSettings =
			playerIndex === aiPlayerIndex &&
			$form.find('[name="opponent-type"]:checked').val() === 'ai';

		$form.find('[name="icon"]').each(function () {
			const $input = $(this);
			const icon = String($input.val());
			const isUsedByOtherPlayer = players.some(
				(player, index) =>
					index !== playerIndex &&
					player.isReady &&
					player.icon === icon
			);

			$input.prop('disabled', usesAiSettings || isUsedByOtherPlayer);

			if (isUsedByOtherPlayer && $input.prop('checked')) {
				$input.prop('checked', false);
			}
		});
	});
}

This is interface help, not a replacement for validation, so setPlayer() still rejects a duplicate. Doing both means the form prevents the common mistake while the game rules still protect themselves if state changes in an unexpected order.

Native dialogs for results and confirmations

The inline replay box becomes a native <dialog>, and two more dialogs confirm a mid-game token change and suggest a difficulty. Here is the token confirmation; the other two use the same pattern and are included in the full index.html:

<dialog
	class="restart-confirmation"
	id="token-change-confirmation"
	aria-labelledby="token-change-confirmation-title"
	aria-describedby="token-change-confirmation-message"
>
	<h3 id="token-change-confirmation-title">Restart the game?</h3>
	<p id="token-change-confirmation-message"></p>
	<div class="confirmation-actions">
		<button
			class="button button--quiet"
			id="cancel-token-change"
			type="button"
		>
			Cancel
		</button>
		<button class="button" id="confirm-token-change" type="button">
			Change token and restart
		</button>
	</div>
</dialog>

Using the native dialog element instead of building a pretend modal from a positioned <div> gives us modal behavior, a backdrop, and Escape-key events. We still decide what Escape means so every closing path runs the same cleanup.

Inside setPlayer(), this check pauses a real token change only after the game has started. Changing a name or difficulty can continue normally:

if (
	!isAi &&
	player.isReady &&
	gameHasStarted() &&
	icon !== player.icon &&
	confirmedTokenChangeForm !== this
) {
	pendingPlayerForm = this;
	window.clearTimeout(aiTimer);
	$('#token-change-confirmation-message').text(
		`Changing the token for ${player.name} will restart the current game and clear the board. Do you want to continue?`
	);
	document.getElementById('token-change-confirmation').showModal();
	return;
}
function gameHasStarted() {
	return board.some((cell) => cell !== null);
}

function closeDialog(dialogId) {
	const dialog = document.getElementById(dialogId);

	if (dialog.open) {
		dialog.close();
	}
}

function cancelTokenChange() {
	if (pendingPlayerForm) {
		const $form = $(pendingPlayerForm);
		const playerIndex = Number($form.attr('data-player'));

		$form
			.find('[name="icon"]')
			.prop('checked', false)
			.filter(function () {
				return $(this).val() === players[playerIndex].icon;
			})
			.prop('checked', true);
	}

	pendingPlayerForm = null;
	closeDialog('token-change-confirmation');

	if (playersAreReady() && !roundIsOver) {
		beginTurn();
	}
}

function confirmTokenChange() {
	const playerForm = pendingPlayerForm;

	if (!playerForm) {
		return;
	}

	pendingPlayerForm = null;
	confirmedTokenChangeForm = playerForm;
	closeDialog('token-change-confirmation');

	try {
		$(playerForm).trigger('submit');
	} finally {
		confirmedTokenChangeForm = null;
	}
}

Canceling restores the radio selection to the saved player token. Confirming resubmits the original form, and confirmedTokenChangeForm tells setPlayer() not to ask the same question again. The finally block clears that temporary permission even if a future edit makes the submit handler throw an error.

Finishing a round now chooses exactly one modal: the difficulty suggestion when a streak reaches its threshold, otherwise the normal replay dialog.

// End a round. A difficulty question takes priority over the replay dialog.
function finishRound(message, winnerIndex = null) {
	roundIsOver = true;
	$('#game-board .cell')
		.prop('disabled', true)
		.attr('aria-disabled', 'true');
	if (winnerIndex === null) {
		$('#game-status').text(message);
	} else {
		showPlayerStatus(winnerIndex, ' wins this round!');
	}
	$('#round-result').text(message);

	if (!updateWinStreaks(winnerIndex)) {
		document.getElementById('round-actions').showModal();
	}
}

Connecting the new events

The last change is the event wiring at the bottom of tic_tac_toe.js:

// Connect page events after the HTML is ready.
$(function () {
	$('.player-form').on('submit', setPlayer);
	$('#player-2-form [name="opponent-type"]').on(
		'change',
		toggleOpponentSettings
	);
	$('#size-form').on('submit', setBoardSize);
	$('#board-size').on('input', previewBoardSize);
	$('#game-board').on('click', '.cell', playHumanMove);
	$('#restart-game').on('click', startNewRound);
	$('#confirm-token-change').on('click', confirmTokenChange);
	$('#cancel-token-change').on('click', cancelTokenChange);
	$('#token-change-confirmation').on('cancel', function (event) {
		event.preventDefault();
		cancelTokenChange();
	});
	$('#round-actions').on('cancel', function (event) {
		// Escape has the same result as choosing to play another round.
		event.preventDefault();
		startNewRound();
	});
	$('#accept-difficulty').on('click', acceptDifficultySuggestion);
	$('#keep-difficulty, #close-difficulty').on(
		'click',
		refuseDifficultySuggestion
	);
	$('#difficulty-suggestion').on('cancel', function (event) {
		// Prevent the browser's automatic close so every refusal uses the
		// same counter-reset and preference logic.
		event.preventDefault();
		refuseDifficultySuggestion();
	});

	toggleOpponentSettings();
	startNewRound();
});

The board still uses event delegation, but it now calls playHumanMove instead of the old all-purpose click function. Calling toggleOpponentSettings() once on startup makes the controls agree with the initially checked AI radio button before anyone touches the form.

Notice that every dialog’s button and Escape key lead to the same named function. Centralizing that cleanup instead of sprinkling dialog.close() calls everywhere prevents a closed dialog from leaving streak counters, selected radios, or pending forms in a half-finished state.

Ready to play

That is our old two-player game upgraded with a computer opponent, without throwing away the code that already worked. Humans and the AI share one move pipeline, Minimax supplies strategic moves, random choices keep easier levels friendly, and the larger boards use a depth limit so “Expert” does not turn the browser into a small space heater.

The most useful pattern here is not specific to tic-tac-toe: keep the rules in shared functions, then let different controllers—mouse clicks, keyboard-friendly buttons, or an AI—choose the inputs. That approach can also help us add online play later, because a move received over the network could call the same playMoveAt() function.

Try the finished demo, then make it misbehave on purpose. Change the difficulty probabilities. Make the AI dramatically announce its thoughts. Adjust the search depths and watch the performance. Small games are still wonderful laboratories, especially when your lab partner is named Quinn and insists on being a boot.

Have fun!

Be the first to comment

Name, email, and comment are required. Your email address will not be published.