Чтобы добавить крестики-нолики на сайт, не нужны библиотеки и сервер: хватает одного блока из трех частей. Это HTML-контейнер для поля 3×3, несколько правил CSS и скрипт на JavaScript, который хранит состояние доски, чередует ходы, проверяет 8 выигрышных линий и при желании ходит за соперника. Блок вставляется в страницу перед </body>, в HTML-виджет конструктора или открывается отдельным файлом через iframe.
Содержание
Ниже — готовый код целиком, способы вставки, разбор логики, простой бот и бот, которого нельзя обыграть. Код проверен 24.09.2026: логика — в Node.js 24.21, поведение страницы (клики, ничья, сброс) — в эмуляции браузера jsdom.
Готовый код: скопируйте и откройте в браузере
Сохраните файл как tic-tac-toe.html и откройте двойным щелчком. Первым ходит X (вы), бот отвечает ноликом через 0,3 секунды.
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Крестики-нолики</title>
<style>
.ttt { max-width: 320px; font-family: system-ui, sans-serif; }
.ttt-board { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin: 12px 0; }
.ttt-cell { aspect-ratio: 1; font-size: 2.5rem; font-weight: 700; color: #222;
border: 2px solid #454fce; border-radius: 8px; background: #fff; cursor: pointer; }
.ttt-cell:disabled { cursor: default; color: #222; }
.ttt-cell.win { background: #ffe082; }
</style>
</head>
<body>
<div class="ttt" id="ttt">
<p class="ttt-status" aria-live="polite"></p>
<div class="ttt-board"></div>
<label><input type="checkbox" class="ttt-bot" checked> Бот играет за O</label>
<button type="button" class="ttt-reset">Новая игра</button>
</div>
<script>
(function (root) {
const LINES = [
[0, 1, 2], [3, 4, 5], [6, 7, 8], // строки
[0, 3, 6], [1, 4, 7], [2, 5, 8], // столбцы
[0, 4, 8], [2, 4, 6] // диагонали
];
function getWinner(board) {
for (const [a, b, c] of LINES) {
if (board[a] && board[a] === board[b] && board[a] === board[c]) {
return { player: board[a], line: [a, b, c] };
}
}
return null;
}
// Клетка, которая замыкает линию из двух знаков player, или -1
function findLineMove(board, player) {
for (const line of LINES) {
const mine = line.filter(i => board[i] === player).length;
const empty = line.filter(i => board[i] === null);
if (mine === 2 && empty.length === 1) return empty[0];
}
return -1;
}
function botMove(board, bot, human) {
let move = findLineMove(board, bot); // 1. выиграть
if (move === -1) move = findLineMove(board, human); // 2. заблокировать
if (move === -1 && board[4] === null) move = 4; // 3. центр
if (move === -1) { // 4. угол, иначе любая
const free = board.map((v, i) => v === null ? i : -1).filter(i => i !== -1);
const corners = free.filter(i => [0, 2, 6, 8].includes(i));
const pool = corners.length ? corners : free;
move = pool[Math.floor(Math.random() * pool.length)];
}
return move;
}
const board = Array(9).fill(null);
const statusEl = root.querySelector('.ttt-status');
const boardEl = root.querySelector('.ttt-board');
const botBox = root.querySelector('.ttt-bot');
const cells = [];
let current, finished, botTimer = null;
for (let i = 0; i < 9; i++) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ttt-cell';
btn.setAttribute('aria-label', 'Клетка ' + (i + 1));
btn.addEventListener('click', () => onCellClick(i));
boardEl.append(btn);
cells.push(btn);
}
function play(i) {
board[i] = current;
cells[i].textContent = current;
cells[i].disabled = true;
const win = getWinner(board);
if (win) {
win.line.forEach(k => cells[k].classList.add('win'));
return finish('Победил ' + win.player);
}
if (board.every(Boolean)) return finish('Ничья');
current = current === 'X' ? 'O' : 'X';
statusEl.textContent = 'Ходит ' + current;
}
function finish(text) {
finished = true;
statusEl.textContent = text;
cells.forEach(c => { c.disabled = true; });
}
function onCellClick(i) {
if (finished || board[i] !== null || botTimer !== null) return;
play(i);
if (!finished && botBox.checked && current === 'O') {
botTimer = setTimeout(() => {
botTimer = null;
play(botMove(board, 'O', 'X'));
}, 300);
}
}
function reset() {
clearTimeout(botTimer);
botTimer = null;
board.fill(null);
current = 'X';
finished = false;
cells.forEach(c => { c.textContent = ''; c.disabled = false; c.classList.remove('win'); });
statusEl.textContent = 'Ходит X';
}
root.querySelector('.ttt-reset').addEventListener('click', reset);
botBox.addEventListener('change', reset); // смена режима = новая партия
reset();
})(document.getElementById('ttt'));
</script>
</body>
</html>
Что увидите: над полем строка «Ходит X», девять пустых клеток и флажок «Бот играет за O». После победы выигрышная тройка подсвечивается желтым, статус меняется на «Победил X» или «Победил O», поле блокируется. Если клетки заполнены без тройки — «Ничья». Кнопка «Новая игра» начинает заново. Снимите флажок — и можно играть вдвоем за одним экраном. Переключение флажка начинает новую партию: иначе при включении бота посреди игры за O успел бы сходить человек, а при выключении во время паузы бот все равно сделал бы уже запланированный ход.
Как вставить игру на сайт
Код самодостаточный: стили начинаются с префикса .ttt, а скрипт обернут в функцию и не создает глобальных переменных, поэтому он не конфликтует с остальной страницей. Способ вставки зависит от того, как сделан сайт.
| Где сайт | Что сделать | На что обратить внимание |
|---|---|---|
| Свой HTML-файл | Скопировать <style> в <head>, контейнер <div class="ttt"> — в нужное место, <script> — после контейнера или перед </body> |
Скрипт должен выполниться после появления разметки |
| Конструктор или CMS с HTML-блоком | Вставить контейнер, <style> и <script> в блок произвольного HTML |
CMS может вырезать <script> по правам пользователя или настройкам фильтра |
| Любой сайт, где нужна изоляция | Загрузить tic-tac-toe.html на хостинг и встроить: <iframe src="/games/tic-tac-toe.html" width="340" height="460" title="Крестики-нолики"> |
Стили сайта не влияют на игру и наоборот; путь к файлу замените на свой |
Если на странице нужно две игры, у второго контейнера должен быть другой id, а в последней строке скрипта — document.getElementById с этим id.
Как устроен код
Состояние: массив из 9 клеток
Доска хранится в массиве board из 9 элементов: null — клетка пуста, 'X' или 'O' — занята. Индекс клетки считается как строка * 3 + столбец:
0 | 1 | 2
3 | 4 | 5
6 | 7 | 8
Кнопки клеток создаются в цикле, и каждая получает свой индекс через замыкание: btn.addEventListener('click', () => onCellClick(i)). Клетки сделаны элементами <button>, а не <div>: по ним можно пройти клавишей Tab, нажать Enter, а атрибут disabled сам запрещает повторный клик.
Ход игрока: функция play
play(i) записывает знак в массив, показывает его в кнопке и блокирует клетку. Знак выводится через textContent, а не innerHTML: так в разметку не попадет чужой HTML, даже если позже вы начнете подставлять имена игроков. Затем идут проверки — победа, ничья — и только потом смена хода.
Проверка победы: 8 линий
Выигрышных линий в поле 3×3 ровно восемь: 3 строки, 3 столбца и 2 диагонали. Они перечислены в LINES, а getWinner проверяет, стоят ли во всех трех клетках линии одинаковые непустые знаки. Функция возвращает победителя и саму линию — по ней подсвечиваются клетки.
Порядок проверок важен: сначала победа, потом ничья. Последний, девятый ход может одновременно заполнить поле и замкнуть тройку.
// Неверный порядок: сначала ничья, потом победа
function resultWrong(board) {
if (board.every(Boolean)) return 'Ничья';
const win = getWinner(board);
return win ? 'Победил ' + win.player : 'Игра идет';
}
// Последний, девятый ход X в клетку 8 замыкает диагональ 0-4-8
const board = ['X', 'O', 'X', 'O', 'X', 'O', 'O', 'X', 'X'];
console.log(resultWrong(board));
Результат прогона — Ничья, хотя X выиграл. Исправление — как в play: сначала getWinner, и только если победителя нет, проверка board.every(Boolean).
Простой бот-соперник
botMove выбирает ход по четырем правилам в порядке приоритета:
- Если у бота две клетки в линии и третья свободна — выиграть.
- Если такая линия у игрока — занять третью клетку и заблокировать.
- Занять центр, если он свободен.
- Иначе — случайный свободный угол, а если углов нет — любая свободная клетка.
Обе первые проверки делает одна функция findLineMove(board, player): она ищет линию, где два знака player и одна пустая клетка. Бот ходит через setTimeout с задержкой 300 мс, чтобы ответ был заметен. Пока таймер активен, botTimer не равен null, и onCellClick игнорирует клики — иначе игрок успел бы сходить за бота. reset вызывает clearTimeout, чтобы запоздалый ход не попал в новую партию.
Граница этого бота: X всегда за человеком, O — за ботом, и бот не видит ловушек на два хода вперед.
Непобедимый бот на минимаксе
Простой бот проигрывает известной комбинации. X ставит в угол 0, бот занимает центр, X ставит в противоположный угол 8. По правилу 4 бот идет в угол 2 или 6 — игрок блокирует и получает две угрозы сразу (вилку), закрыть обе нельзя. Правильный ответ здесь — ход на край, и его находит полный перебор.
Минимакс перебирает все продолжения партии: бот выбирает ход с максимальной оценкой, за соперника считается ход с минимальной. Победа бота оценивается как 10 - depth, поражение как depth - 10, ничья — 0. Глубина нужна, чтобы из двух выигрышей бот брал более быстрый. Для поля 3×3 перебор занимает доли секунды.
// Непобедимый бот: перебирает все продолжения партии (минимакс)
function bestMove(board, bot, human) {
function score(b, turn, depth) {
const w = getWinner(b);
if (w) return w.player === bot ? 10 - depth : depth - 10;
if (b.every(Boolean)) return 0;
let best = turn === bot ? -Infinity : Infinity;
for (let i = 0; i < 9; i++) {
if (b[i] !== null) continue;
b[i] = turn;
const s = score(b, turn === bot ? human : bot, depth + 1);
b[i] = null;
best = turn === bot ? Math.max(best, s) : Math.min(best, s);
}
return best;
}
let move = -1, best = -Infinity;
for (let i = 0; i < 9; i++) {
if (board[i] !== null) continue;
board[i] = bot;
const s = score(board, human, 1);
board[i] = null;
if (s > best) { best = s; move = i; }
}
return move;
}
Вставьте функцию в скрипт после botMove и замените в onCellClick вызов botMove(board, 'O', 'X') на bestMove(board, 'O', 'X'). Я проверил перебором всех партий: человек за X пробовал каждый возможный ход, бот отвечал bestMove. Итог — { X: 0, O: 386, draw: 183 }: ни одной победы X. Обратная сторона — играть с таким ботом скучно, поэтому на сайте удобно дать выбор уровня.
Проверка логики в Node.js
Логику удобно проверить без браузера. Создайте check.js, скопируйте в него LINES, getWinner, findLineMove, botMove из примера и bestMove, затем добавьте:
const _ = null;
console.log(getWinner(['X', 'X', 'X', 'O', 'O', _, _, _, _]));
console.log(getWinner(['X', 'O', 'X', 'X', 'O', 'O', 'O', 'X', 'X']));
console.log(botMove(['O', 'O', _, 'X', 'X', _, _, _, _], 'O', 'X')); // выиграть
console.log(botMove(['X', 'X', _, _, 'O', _, _, _, _], 'O', 'X')); // заблокировать
// Ловушка простого бота: X в углу, O в центре, X в противоположном углу
const trap = ['X', _, _, _, 'O', _, _, _, 'X'];
console.log([2, 6].includes(botMove(trap, 'O', 'X'))); // бот всегда идет в угол
console.log(bestMove(trap, 'O', 'X')); // минимакс идет на край
Запуск node check.js (Node.js 24.21):
{ player: 'X', line: [ 0, 1, 2 ] }
null
2
2
true
1
Первая доска — победа X по верхней строке, вторая — заполненное поле без тройки. В позиции с двумя O бот замыкает линию клеткой 2, в позиции с двумя X — блокирует той же клеткой. На ловушке простой бот выбирает угол: true выводится при любом исходе случайного выбора, потому что свободны только углы 2 и 6. Минимакс выбирает край 1.
Если не получилось
| Симптом | Причина | Что сделать |
|---|---|---|
Поле пустое, в консоли Chrome Cannot read properties of null (reading 'querySelector') (в Firefox текст другой, с root is null) |
Скрипт выполнился раньше разметки или id контейнера не совпадает с getElementById |
Поставить <script> после контейнера, сверить id |
| Поле видно, но стили сайта его ломают | Правила темы действуют на button |
Повысить специфичность .ttt .ttt-cell или вставить игру через iframe |
| Игра работает локально, но пропадает в CMS | Редактор вырезал <script> |
Вставлять HTML-блоком с правом на скрипты или через iframe |
| Бот сходил в новой партии сразу после сброса | Не отменен таймер | Проверить, что reset вызывает clearTimeout(botTimer) |
| Бот ходит дважды или за игрока | Нет проверки botTimer в обработчике клика |
Игнорировать клики, пока botTimer !== null |
Консоль открывается клавишей F12 (вкладка Console) — ошибки скрипта видны там.
Выводы
- Для крестиков-ноликов на сайте достаточно одного блока HTML, CSS и JavaScript без библиотек; изолированный вариант — отдельный файл в
iframe. - Состояние доски — массив из 9 клеток, индекс =
строка * 3 + столбец; победа проверяется по 8 линиям. - Сначала проверяется победа, потом ничья: иначе выигрыш последним ходом засчитается как ничья.
- Простой бот (выиграть, заблокировать, центр, угол) проигрывает вилке; минимакс для поля 3×3 не проигрывает ни одной партии.
Где применяется / связь с практикой
Освойте тему на практике
Крестики-нолики — удобная учебная задача: в ней есть работа с DOM и событиями, хранение состояния, отделение логики от отображения и простой алгоритм поиска. По той же схеме делаются квизы, мини-игры для лендингов и интерактивные виджеты. Эти темы системно разбираются на курсе «JavaScript-разработчик. Базовый уровень», а попробовать формат можно на открытых уроках Otus.
FAQ
Как сделать, чтобы бот ходил первым?
В reset после сброса поля вызовите ход бота: если флажок включен, передайте боту знак X, а человеку O, и поменяйте проверку current === 'O' в onCellClick на знак бота.
Как сделать поле 4×4 или 5×5?
Замените 9 на n * n, repeat(3, 1fr) на repeat(n, 1fr) и генерируйте LINES циклом, а не списком. Полный минимакс на больших полях перебирает слишком много позиций — понадобится ограничение глубины или отсечения.
Как сохранить счет между перезагрузками страницы?
Храните счетчики побед в localStorage и обновляйте их в finish. Чтение оберните в try/catch: в приватном режиме или при запрете хранилища доступ может завершиться ошибкой.



