Крестики-нолики на JavaScript: как добавить игру на сайт

Крестики-нолики на JavaScript: как добавить игру на сайт Про IT

Чтобы добавить крестики-нолики на сайт, не нужны библиотеки и сервер: хватает одного блока из трех частей. Это 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 выбирает ход по четырем правилам в порядке приоритета:

  1. Если у бота две клетки в линии и третья свободна — выиграть.
  2. Если такая линия у игрока — занять третью клетку и заблокировать.
  3. Занять центр, если он свободен.
  4. Иначе — случайный свободный угол, а если углов нет — любая свободная клетка.

Обе первые проверки делает одна функция 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: в приватном режиме или при запрете хранилища доступ может завершиться ошибкой.

OTUS Журнал
Скидка 5% 14-20 сентября на курсы (popup)