Creating a Tic-Tac-Toe Game with jQuery
A long time ago, I wrote a tutorial about building tic-tac-toe with jQuery. It did the job, but web development has moved on a little since then. We no longer need layout-clearing <div> elements, mouse events just to fake a hover effect, or code written with Internet Explorer 6 peeking over our shoulder. Phew.
So, let’s rebuild the game with modern HTML, CSS, JavaScript, and jQuery. It will still be the friendly little game we know, but this version gets a few toys: player names, choose-your-own SVG tokens, boards from 3 × 3 to 6 × 6, a running score, and a replay button.
You can open the finished demo whenever you want to see where we are heading. Peeking is allowed. This is a tutorial, not an exam.
Before we begin
I’m going to assume that you:
- know how tic-tac-toe works;
- know a little semantic HTML and basic CSS;
- have met JavaScript variables, arrays, functions, and loops before;
- know that SVG is an image format, even if you have never written an SVG by hand.
You do not need to be a jQuery wizard. We will meet each bit of jQuery as it appears.
Create a folder with these files:
creating-a-tic-tac-toe-game-with-jquery/ ├── index.html ├── style.css ├── tic_tac_toe.js ├── jquery-4.0.0.min.js └── token-icons.svg
The demo keeps its own local copy of jQuery, so it does not depend on a CDN or an internet connection. Connect the files in the page’s <head>:
<link rel="stylesheet" href="style.css" /> <!-- Defer keeps scripts from blocking the page while it loads. --> <script src="jquery-4.0.0.min.js" defer></script> <script src="tic_tac_toe.js" defer></script>
Order matters: our game uses jQuery, so jQuery must load first. The defer attribute tells the browser to download both scripts without holding up the HTML, then run them in order after the page has been parsed.
What’s in a game?
Before we start flinging curly brackets around, let’s decide what our game actually has to do.
The requirements
- Start with a 3 × 3 board, with an option to choose a size up to 6 × 6.
- Let two local players choose a display name and different token icons.
- Let each player mark exactly one empty cell per turn.
- Win when one player fills a complete row, column, or diagonal.
- End in a draw when every cell is occupied and nobody has won.
- Keep score and let the players begin another round.
Perhaps later
- Play against the computer.
- Add sound effects.
- Let friends play over the internet.
Those “later” ideas are parked safely outside today’s scope. Otherwise our tiny tutorial will put on a fake moustache and become a game engine.
Building the page shell
Our layout has three main pieces: Player 1 on the left, the game in the middle, and Player 2 on the right.
<header class="site-header"> <h1>Tic-Tac-Toe Demo</h1> </header> <main class="game-layout"> <section class="player-card player-one" aria-labelledby="player-1-heading"> <!-- Player 1 settings go here. --> </section> <section class="game-panel" aria-labelledby="game-heading"> <h2 id="game-heading">Game board</h2> <!-- Size control, status, board, and replay button go here. --> </section> <section class="player-card player-two" aria-labelledby="player-2-heading"> <!-- Player 2 settings go here. --> </section> </main>
CSS Grid is a very natural match for that structure:
.game-layout {
display: grid;
grid-template-columns: minmax(14rem, 0.8fr) minmax(21rem, 1.5fr) minmax(
14rem,
0.8fr
);
align-items: start;
gap: 1.25rem;
width: min(100% - 2rem, 76rem);
margin: 0 auto;
padding-bottom: 3rem;
}
.player-card,
.game-panel {
border: 1px solid var(--panel-line);
border-radius: var(--radius);
background: var(--panel-surface);
box-shadow: var(--shadow);
}
The middle column receives a little more room than the two player cards. On narrower screens, we turn the three columns into one:
@media (max-width: 56rem) {
.game-layout {
grid-template-columns: 1fr;
width: min(100% - 2rem, 38rem);
}
}
That is our stage built. Time to invite the players.
Requirement 1: two players with names and tokens
Each player gets a form inside a <details> element. <details> gives us an expandable panel without needing JavaScript for the opening and closing behavior.
Here is Player 1’s setup. We will use the same structure for Player 2, changing the IDs, data-player value, button text, and initially selected icon.
<details class="player-settings" open> <summary class="player-card__heading"> <span> <span class="player-number">Player 1</span> <span id="player-1-heading" class="player-settings__title"> Choose your token </span> </span> </summary> <form class="player-form" id="player-1-form" data-player="0"> <label for="player-1-name">Display name</label> <input id="player-1-name" name="name" type="text" value="Player 1" maxlength="20" required /> <fieldset> <legend>Token icon</legend> <div class="icon-picker"> <!-- Token choices go here. --> </div> </fieldset> <p class="form-message" aria-live="polite"></p> <button class="button button--player" type="submit"> Set Player 1 </button> </form> </details> <div class="player-summary" hidden> <p class="summary-label">Current setup</p> <div class="summary-token" aria-hidden="true"></div> <strong class="summary-name"></strong> <p>Wins: <span class="win-count">0</span></p> </div>
The data-player="0" attribute connects this form to player zero in our JavaScript array. Programmers usually start counting at zero; we are fun at parties.
The empty .form-message will hold validation errors. aria-live="polite" asks screen readers to announce new messages without rudely interrupting whatever they are already reading. The summary begins with the Boolean hidden attribute and appears after the form is successfully submitted.
Making an SVG token picker
The icons live in token-icons.svg as SVG <symbol> elements. Here is the top hat:
<svg xmlns="http://www.w3.org/2000/svg"> <symbol id="token-hat" viewBox="0 0 64 64"> <g fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" > <path d="M20 43 24 16h16l4 27"></path> <path d="M22 34h20"></path> <path d="M12 43c7 4 33 4 40 0l-2 7H14z"></path> </g> </symbol> </svg>
currentColor is a handy little trick: the icon’s stroke inherits the CSS color of wherever we display it. One drawing can therefore be red for Player 1, green for Player 2, and another color when selected.
Each icon choice is still an ordinary radio button with an ordinary label:
<label class="icon-option" title="Top hat"> <input type="radio" name="icon" value="hat" checked /> <span> <svg aria-hidden="true"> <use href="token-icons.svg#token-hat"></use> </svg> <span class="visually-hidden">Top hat</span> </span> </label>
The visible drawing is decorative, while “Top hat” remains available to a screen reader. Repeat this label for car, ship, and boot, changing both the radio value and the symbol after #token-.
We hide the small native radio circle and style its neighboring <span> as the big clickable choice:
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.icon-picker {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
}
.icon-option {
position: relative;
}
.icon-option input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.icon-option > span {
display: grid;
place-items: center;
min-height: 3rem;
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--muted);
background: var(--paper);
}
.icon-option input:checked + span {
border-color: currentColor;
color: var(--accent-dark);
background: var(--surface-soft);
}
.icon-option input:focus-visible + span {
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
}
Notice that the radio input itself is invisible but not display: none. It can still receive keyboard focus, and the final rule draws that focus around our custom-looking control.
Our first JavaScript: players and game state
Now open tic_tac_toe.js. We wrap everything in a function so our variables do not leak into the global scope:
// Wrap the demo so its variables do not become global variables.
(function ($) {
'use strict';
// Only these local SVG symbols may be used as player tokens.
const allowedIcons = new Set(['hat', 'car', 'ship', 'boot']);
const boardSizeClasses =
'board-size-3 board-size-4 board-size-5 board-size-6';
// Player data stays together in one small array.
const players = [
{ name: 'Player 1', icon: 'hat', wins: 0, isReady: false },
{ name: 'Player 2', icon: 'car', wins: 0, isReady: false },
];
let boardSize = 3;
let board = [];
let currentPlayer = 0;
let roundIsOver = false;
// The rest of the code...
})(jQuery);
The $ parameter is our private nickname for jQuery. The four let variables describe the changing state of one round. Keeping that state in JavaScript—rather than trying to reconstruct it from colors and classes in the HTML—will make the game logic much easier to reason about.
We also need to create token images from JavaScript when somebody plays a move or saves their settings:
// Make a reusable SVG that points to the local icon file.
function createIcon(iconName) {
const svg = document.createElementNS(
'http://www.w3.org/2000/svg',
'svg'
);
const use = document.createElementNS(
'http://www.w3.org/2000/svg',
'use'
);
svg.setAttribute('aria-hidden', 'true');
use.setAttribute('href', `token-icons.svg#token-${iconName}`);
svg.appendChild(use);
return svg;
}
SVG elements have their own XML namespace, which is why we use createElementNS() rather than jQuery’s shorter $('<svg>') form here.
Saving a player
When a setup form is submitted, we validate it, update the matching player object, and fill the summary:
// 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 name = String($form.find('[name="name"]').val()).trim();
const icon = String($form.find('[name="icon"]:checked').val());
const $message = $form.find('.form-message');
$message.text('');
if (!name) {
$message.text('Please enter a display name.');
$form.find('[name="name"]').trigger('focus');
return;
}
if (!allowedIcons.has(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.'
);
return;
}
players[playerIndex].name = name;
players[playerIndex].icon = icon;
players[playerIndex].isReady = true;
const $playerCard = $form.closest('.player-card');
const $summary = $playerCard.find('.player-summary');
$summary.find('.summary-token').empty().append(createIcon(icon));
$summary.find('.summary-name').text(name);
$summary.find('.win-count').text(players[playerIndex].wins);
$summary.prop('hidden', false);
$form.find('.button--player').text(`Update Player ${playerIndex + 1}`);
$playerCard.find('.player-settings').prop('open', false);
// Changing a setup starts a fair, empty round.
startNewRound();
}
There are two different kinds of validation here. HTML’s required attribute helps the user, but JavaScript still checks the submitted data. The allowedIcons set also makes sure an unexpected token name cannot be turned into an SVG file reference. Finally, the two players cannot choose the same icon; a hat-versus-hat battle would get confusing rather quickly.
The names beginning with $, such as $form and $summary, are not special syntax. They are simply a convention that reminds us those variables contain jQuery objects.
Requirement 2: a board from 3 × 3 to 6 × 6
The board starts empty in the HTML because JavaScript is going to build the correct number of cells. Around it we place a size control, a status message, and the end-of-round controls:
<form class="size-control" id="size-form"> <div class="range-heading"> <label for="board-size">Board size</label> <output id="board-size-output" for="board-size">3 × 3</output> </div> <input id="board-size" name="size" type="range" min="3" max="6" step="1" value="3" list="board-size-ticks" /> <datalist id="board-size-ticks"> <option value="3" label="3"></option> <option value="4" label="4"></option> <option value="5" label="5"></option> <option value="6" label="6"></option> </datalist> <button class="button button--secondary" type="submit"> Set board size </button> </form> <p class="game-status" id="game-status" aria-live="polite"> Set both players to begin. </p> <div class="board board-size-3" id="game-board" role="grid" aria-label="3 by 3 tic-tac-toe board" ></div> <div class="round-actions" id="round-actions" hidden> <p id="round-result"></p> <button class="button" id="restart-game" type="button"> Play another round </button> </div>
<output> is the semantic place to show the range’s current value. The board status is another polite live region, so changes such as “Ada, choose an empty square” and “Grace wins this round!” can be announced by assistive technology.
Drawing boards with CSS Grid
The board stays square thanks to aspect-ratio. A size class decides how many rows and columns it has:
.board {
display: grid;
gap: 0.4rem;
width: 100%;
max-width: 34rem;
aspect-ratio: 1;
margin: 0 auto;
}
.board-size-3 {
grid-template: repeat(3, minmax(0, 1fr)) / repeat(3, minmax(0, 1fr));
}
.board-size-4 {
grid-template: repeat(4, minmax(0, 1fr)) / repeat(4, minmax(0, 1fr));
}
.board-size-5 {
grid-template: repeat(5, minmax(0, 1fr)) / repeat(5, minmax(0, 1fr));
}
.board-size-6 {
grid-template: repeat(6, minmax(0, 1fr)) / repeat(6, minmax(0, 1fr));
}
For example, repeat(4, minmax(0, 1fr)) means “make four equal tracks, and allow them to shrink all the way down to zero if necessary.” We apply it once before and once after the slash: rows first, columns second.
Each cell is a real <button>, not a clickable <div>. Buttons already work with a mouse, keyboard, and touch, and support the disabled property we need:
.cell {
display: grid;
place-items: center;
min-width: 0;
min-height: 0;
width: 100%;
height: 100%;
aspect-ratio: 1;
overflow: hidden;
padding: 12%;
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--muted);
background: var(--paper);
box-shadow: 0 0.2rem 0.6rem var(--shadow-soft);
}
.cell:not(:disabled):hover {
border-color: var(--accent);
background: var(--surface-hover);
transform: translateY(-2px);
}
.cell svg {
display: block;
width: 76%;
height: 76%;
max-width: 100%;
max-height: 100%;
}
Creating the cells
First we need one small question answered: are both players ready?
// A round can begin only after both setup forms are submitted.
function playersAreReady() {
return players.every((player) => player.isReady);
}
every() returns true only if the callback returns true for every item in the array. With that answer, renderBoard() can build the buttons:
// Create a fresh board for the selected size.
function renderBoard() {
const $board = $('#game-board');
const canPlay = playersAreReady() && !roundIsOver;
$board
.empty()
.removeClass(boardSizeClasses)
.addClass(`board-size-${boardSize}`)
.attr(
'aria-label',
`${boardSize} by ${boardSize} tic-tac-toe board`
);
for (let index = 0; index < board.length; index += 1) {
const row = Math.floor(index / boardSize) + 1;
const column = (index % boardSize) + 1;
const $cell = $('<button>', {
type: 'button',
class: 'cell',
'data-index': index,
'aria-label': `Row ${row}, column ${column}, empty`,
'aria-disabled': String(!canPlay),
disabled: !canPlay,
});
$board.append($cell);
}
}
The data-index on each button points to the same position in our board array. On a 3 × 3 board, the indexes look like this:
0 1 2 3 4 5 6 7 8
To find a human-friendly row, we divide by the board size, discard the decimal with Math.floor(), then add one. To find a column, % gives us the remainder after division. That little operator is about to earn its keep.
Starting a clean round
// Reset moves but keep names, tokens, and scores.
function startNewRound() {
board = Array(boardSize * boardSize).fill(null);
currentPlayer = 0;
roundIsOver = false;
$('#round-actions').prop('hidden', true);
renderBoard();
if (playersAreReady()) {
showTurn();
} else {
$('#game-status').text('Set both players to begin.');
}
}
For a 3 × 3 game, boardSize * boardSize is 9, so we create an array of nine null values. null means an empty cell; later, 0 will mean Player 1 owns it and 1 will mean Player 2 owns it.
The range slider has two jobs. It previews the size while it moves, then applies that size when its form is submitted:
// Show the range value while the user moves its thumb.
function previewBoardSize() {
const size = Number($(this).val());
$('#board-size-output').text(`${size} × ${size}`);
}
// Apply the range value and rebuild the board.
function setBoardSize(event) {
event.preventDefault();
const boardSizeEl = $('#board-size');
const requestedSize = Number(boardSizeEl.val());
boardSize = Math.min(6, Math.max(3, requestedSize));
boardSizeEl.val(boardSize);
$('#board-size-output').text(`${boardSize} × ${boardSize}`);
startNewRound();
}
The HTML slider already has min="3" and max="6", but the JavaScript clamps the value too. Never assume that HTML controls are the only way data can reach your function.
Requirement 3: exactly one move per turn
Showing whose turn it is takes only a few lines:
// Update the sentence above the board for the next turn.
function showTurn() {
const player = players[currentPlayer];
$('#game-status').text(`${player.name}, choose an empty square.`);
}
The interesting work happens when a cell is selected:
// Put the current player's token into the selected cell.
function playMove() {
const $cell = $(this);
const cellIndex = Number($cell.attr('data-index'));
if (roundIsOver || board[cellIndex] !== null || !playersAreReady()) {
return;
}
const player = players[currentPlayer];
board[cellIndex] = currentPlayer;
$cell
.append(createIcon(player.icon))
.addClass(`is-marked player-${currentPlayer === 0 ? 'one' : 'two'}`)
.attr('aria-label', `${player.name} marked this square`)
.attr('aria-disabled', 'true')
.prop('disabled', true);
const winningLine = findWinningLine(currentPlayer);
if (winningLine) {
winningLine.forEach((index) => {
$(`#game-board .cell[data-index="${index}"]`).addClass(
'is-winning'
);
});
player.wins += 1;
$(`.player-card:eq(${currentPlayer}) .win-count`).text(player.wins);
finishRound(`${player.name} wins this round!`);
return;
}
if (board.every((cell) => cell !== null)) {
finishRound('This round is a draw.');
return;
}
currentPlayer = (currentPlayer + 1) % players.length;
showTurn();
}
The opening if is a guard clause. If the round is over, the cell is already occupied, or the players are not ready, the function stops immediately. Disabling buttons in the interface is friendly; checking the state again in the function keeps our actual game rules safe.
Once a move is accepted, we update both versions of reality:
board[cellIndex] = currentPlayerrecords the move in our data;- the jQuery chain updates what the person sees and what assistive technology announces.
Then we check for a win and a draw. Only after both checks fail do we rotate to the next player. (currentPlayer + 1) % players.length produces 1 after 0, then wraps back to 0 after 1. A two-player merry-go-round, powered by arithmetic.
Player colors come from two small modifier classes:
.cell.player-one {
color: var(--player-one);
}
.cell.player-two {
color: var(--player-two);
}
Because the SVG uses currentColor, changing the cell’s color also changes the token.
Requirement 4: detect rows, columns, and diagonals
This is the game’s brainiest part, but it becomes pleasantly small when we split it into two functions.
First, getWinningLines() builds a list of every possible winning sequence for the current board size:
// Build one list for every possible winning row, column, and diagonal.
function getWinningLines() {
const lines = [];
for (let row = 0; row < boardSize; row += 1) {
const rowLine = [];
const columnLine = [];
for (let column = 0; column < boardSize; column += 1) {
rowLine.push(row * boardSize + column);
columnLine.push(column * boardSize + row);
}
lines.push(rowLine, columnLine);
}
const diagonalDown = [];
const diagonalUp = [];
for (let index = 0; index < boardSize; index += 1) {
diagonalDown.push(index * boardSize + index);
diagonalUp.push(index * boardSize + (boardSize - index - 1));
}
lines.push(diagonalDown, diagonalUp);
return lines;
}
For a 3 × 3 board, it returns the equivalent of:
[ [0, 1, 2], // top row [0, 3, 6], // left column [3, 4, 5], // middle row [1, 4, 7], // middle column [6, 7, 8], // bottom row [2, 5, 8], // right column [0, 4, 8], // diagonal down [2, 4, 6], // diagonal up ];
The same formulas also work for 4 × 4, 5 × 5, and 6 × 6. That is much nicer than maintaining four hand-written piles of winning combinations.
Now we ask whether every cell in any line belongs to one player:
// Return the winning positions or return undefined when no line matches.
function findWinningLine(playerIndex) {
return getWinningLines().find((line) =>
line.every((cellIndex) => board[cellIndex] === playerIndex)
);
}
Read that inside-out:
every()asks, “Does this player own every position in this line?”find()returns the first line for which the answer is yes.- If no line matches,
find()returnsundefined, which is false-like in ourif (winningLine)check.
This also explains why we return the winning line rather than just true: playMove() uses those indexes to decorate the victorious cells.
.cell.is-winning {
border-color: var(--winner-line);
background: var(--winner);
animation: winner-pop 420ms ease both;
}
@keyframes winner-pop {
50% {
transform: scale(1.06);
}
}
Tiny victory dance: unlocked.
Requirement 5: finish wins and draws cleanly
Whether we have a winner or a draw, the ending behavior is the same. Lock the remaining cells, announce the result, and reveal the replay controls:
// End a round and show a replay button.
function finishRound(message) {
roundIsOver = true;
$('#game-board .cell')
.prop('disabled', true)
.attr('aria-disabled', 'true');
$('#game-status').text(message);
$('#round-result').text(message);
$('#round-actions').prop('hidden', false);
}
We used .attr() for text attributes and .prop() for live Boolean properties such as disabled and hidden. That distinction is worth remembering when working with forms in jQuery.
The draw check was already tucked into playMove():
if (board.every((cell) => cell !== null)) {
finishRound('This round is a draw.');
return;
}
It runs only after the win check. That order matters because the final empty cell might both fill the board and complete a winning line. A win should not be reported as a draw just because the board happens to be full.
Our reusable startNewRound() resets the moves but deliberately leaves players[playerIndex].wins alone. That is how “Play another round” clears the board without losing the match score.
Connecting all the events
We now have a collection of useful functions, but nothing calls them yet. Add this at the bottom of tic_tac_toe.js:
// Connect page events after the HTML is ready.
$(function () {
$('.player-form').on('submit', setPlayer);
$('#size-form').on('submit', setBoardSize);
$('#board-size').on('input', previewBoardSize);
$('#game-board').on('click', '.cell', playMove);
$('#restart-game').on('click', startNewRound);
startNewRound();
});
$(function () { ... }) waits until the document structure is ready. The player forms share a class, so one line connects both of them.
The board click uses event delegation:
$('#game-board').on('click', '.cell', playMove);
We attach one listener to the permanent board and ask it to handle clicks coming from .cell buttons. The cells themselves are repeatedly destroyed and rebuilt when the board size or round changes, but this listener survives because #game-board survives. It is a small line with excellent job security.
Finally, the first startNewRound() creates our initial nine-cell board in its disabled state. Once both player forms have been submitted, the same function rebuilds it as a playable board.
A few finishing CSS details
The demo keeps its colors and repeated measurements in custom properties. You can change the whole mood of the game from one place:
:root {
color-scheme: light;
--ink: oklch(24% 0.025 145);
--muted: oklch(39% 0.025 145);
--paper: oklch(100% 0 0);
--page: oklch(98.5% 0 none);
--line: oklch(82% 0.018 145);
--transparent: transparent;
--accent: oklch(46.9% 0.118 144.856);
--accent-dark: color-mix(in oklch, var(--accent), var(--ink) 28%);
--player-one: oklch(40% 0.13 25);
--player-two: var(--accent);
--highlight: oklch(57.564% 0.16657 44.302);
--winner: color-mix(in oklch, var(--accent), var(--panel-surface) 95%);
--winner-line: color-mix(in oklch, var(--accent), var(--ink) 18%);
--surface-soft: oklch(97% 0 none);
--surface-hover: color-mix(in oklch, var(--paper), var(--accent) 8%);
--panel-surface: color-mix(in srgb, var(--paper) 92%, var(--transparent));
--panel-line: color-mix(in srgb, var(--line) 85%, var(--transparent));
--focus-ring: color-mix(in srgb, var(--ink) 35%, var(--transparent));
--shadow-color: color-mix(in srgb, var(--ink) 10%, var(--transparent));
--shadow-soft: color-mix(in srgb, var(--ink) 8%, var(--transparent));
--button-shadow: color-mix(in srgb, var(--accent) 20%, var(--transparent));
--shadow: 0 1rem 2.5rem var(--shadow-color);
--radius: 0.25rem;
--radius-round: 50%;
font-family: Verdana, Arial, ui-sans-serif, "Segoe UI", sans-serif;
}
The demo also has one broad focus rule so keyboard users can always see where they are:
input[type="text"]:focus-visible,
input[type="range"]:focus-visible,
.player-card__heading:focus-visible,
.icon-option input:focus-visible + span,
.button:focus-visible,
.cell:focus-visible {
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
}
And because some CSS elsewhere on a site might try to give hidden elements a display value, the demo makes the meaning of hidden non-negotiable:
[hidden] {
display: none !important;
}
That is one of the rare places where !important is doing a clear, tightly scoped job instead of starting a specificity food fight.
Game over—in the good way
We now have a complete local two-player game. The HTML gives it meaningful controls, CSS Grid handles both the page and board layouts, our JavaScript array holds the truth about each move, and jQuery keeps the interface updates and event wiring pleasantly compact.
More importantly, the “extra” features did not require separate versions of the game. A 6 × 6 board travels through the same code as a 3 × 3 board. A boot token travels through the same code as a top hat. That is the quiet superpower of keeping state and rules separate from the pixels on screen.
Try the finished tic-tac-toe demo, then make it yours. Give the tokens ridiculous colors. Add a best-of-five message. Make the winning cells wobble with entirely unreasonable confidence. Small games are wonderful laboratories because breaking them is cheap—and fixing them teaches you a lot.
Have fun!