Содержание
  1. Основы игры «Найди пару»: что именно делает код
  2. Технологический стек: за что отвечают HTML, CSS и JavaScript
  3. Полный код игры на HTML: один файл, который сразу работает
  4. Разработка игры по шагам: разбор кода
  5. DOM, Canvas или движок: какой код игры на HTML выбрать
  6. Пять ошибок в коде игры, из-за которых она не запускается
  7. Как выложить готовую игру в интернет
  8. Что доработать в игре дальше
  9. Частые вопросы про код игры на HTML
ГайдыОбновлено · 08.2026

Создание игры «Найди пару» на HTML и JS: пошаговое руководство

8 апреля 2024 · 18 минут чтения

Код игры на HTML — это три файла: разметка поля в HTML, оформление карт в CSS и логика в JavaScript. Ниже — полный листинг игры «Найди пару»: скопируйте его в файл index.html, откройте двойным щелчком в браузере, и игра запустится без сборщика, библиотек и локального сервера.

Что стоит знать до первой строки (код проверен в августе 2026 года в Chromium):

  • Поле 4×4 — это 8 пар и 16 карт. Разметку карт создаёт JavaScript, поэтому в HTML лежит один пустой контейнер.
  • Колода перемешивается алгоритмом Фишера — Йетса. Однострочник array.sort(() => Math.random() - 0.5) для этого не годится: документация MDN требует от функции сравнения чистоты, устойчивости, рефлексивности, антисимметричности и транзитивности, иначе «поведение программы не определено».
  • Переворот карты рисует CSS — transform: rotateY(180deg) плюс backface-visibility: hidden. JavaScript только добавляет и снимает класс.
  • Несовпавшая пара закрывается через 800 мс, и на это время поле блокируется: без блокировки третий клик ломает состояние партии.
  • Тот же каркас держит «Змейку», «Крестики-нолики» и другие игры на HTML — меняется правило хода, структура кода остаётся.

Основы игры «Найди пару»: что именно делает код

Один ход в игре «Найди пару»: что происходит после клика
Один ход в игре «Найди пару»: что происходит после клика

Игра «Найди пару» тренирует внимание и память: на поле лежат перевёрнутые карты, у каждой есть двойник, задача — собрать все пары за наименьшее число ходов. С точки зрения кода партия описывается четырьмя состояниями карты: закрыта, открыта первой, открыта второй, найдена. Всё остальное — переходы между ними.

Логика хода помещается в пять строк смысла: игрок кликает по карте, карта переворачивается, программа сравнивает её со второй открытой картой, совпавшие остаются лежать лицом вверх, несовпавшие закрываются через паузу. Счётчик ходов растёт на каждой второй открытой карте: если считать каждый клик, цифра врёт.

Технологический стек: за что отвечают HTML, CSS и JavaScript

Игры на HTML собираются из трёх слоёв, и путать их зоны ответственности — главная причина запутанного кода.

  • HTML — структура: контейнер поля, строка счёта, кнопка перезапуска. Сами карты в разметке отсутствуют, их создаёт скрипт.
  • CSS — оформление и анимация: сетка grid на 4 колонки, размер карты, поворот через 3D-трансформацию.
  • JavaScript — логика: перемешивание колоды, обработка кликов, сравнение пар, счёт ходов, перезапуск партии.

Для запуска хватает браузера и текстового редактора: ни Node.js, ни сборщик, ни фреймворк здесь не нужны. Писать такой код можно даже с телефона — как это устроить, разобрано в статье как сделать из телефона компьютер.

Полный код игры на HTML: один файл, который сразу работает

Это законченная игра в одном файле. Создайте index.html, вставьте листинг целиком, сохраните, откройте в браузере — поле соберётся само.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Найди пару</title>
<style>
  body { margin: 0; padding: 24px; font-family: system-ui, Arial, sans-serif;
         background: #f0f0f0; display: flex; flex-direction: column;
         align-items: center; gap: 16px; }
  #stats { font-size: 18px; margin: 0; }
  #game { display: grid; grid-template-columns: repeat(4, 96px); gap: 10px; }
  .card { width: 96px; height: 96px; padding: 0; border: 0; background: none;
          position: relative; overflow: visible; cursor: pointer;
          perspective: 600px; }
  .card-inner { position: absolute; inset: 0; transform-style: preserve-3d;
                transition: transform 0.4s; }
  .card.flipped .card-inner { transform: rotateY(180deg); }
  .face { position: absolute; inset: 0; display: flex; align-items: center;
          justify-content: center; font-size: 40px; border-radius: 12px;
          backface-visibility: hidden; }
  .back { background: #009688; }
  .front { background: #fff; border: 2px solid #009688; transform: rotateY(180deg); }
  .card.matched { cursor: default; opacity: 0.55; }
  #restart { padding: 10px 18px; font-size: 16px; border: 0; border-radius: 8px;
             background: #009688; color: #fff; cursor: pointer; }
</style>
</head>
<body>
  <p id="stats"></p>
  <div id="game"></div>
  <button id="restart" type="button">Начать заново</button>

<script>
const SYMBOLS = ['🍎', '🍋', '🍇', '🍓', '🥝', '🍑', '🍒', '🍍'];
const board = document.getElementById('game');
const stats = document.getElementById('stats');
const restart = document.getElementById('restart');

let firstCard = null;
let lockBoard = false;
let moves = 0;
let matched = 0;

function shuffle(source) {
  const array = source.slice();
  for (let i = array.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [array[i], array[j]] = [array[j], array[i]];
  }
  return array;
}

function createCard(symbol) {
  const card = document.createElement('button');
  card.type = 'button';
  card.className = 'card';
  card.dataset.symbol = symbol;
  card.innerHTML =
    '<span class="card-inner">' +
      '<span class="face back"></span>' +
      '<span class="face front">' + symbol + '</span>' +
    '</span>';
  card.addEventListener('click', () => flipCard(card));
  return card;
}

function flipCard(card) {
  if (lockBoard) return;
  if (card === firstCard) return;
  if (card.classList.contains('matched')) return;

  card.classList.add('flipped');

  if (firstCard === null) {
    firstCard = card;
    return;
  }

  moves += 1;
  checkForMatch(firstCard, card);
}

function checkForMatch(a, b) {
  if (a.dataset.symbol === b.dataset.symbol) {
    a.classList.add('matched');
    b.classList.add('matched');
    matched += 1;
    firstCard = null;
    updateStats();
    return;
  }

  lockBoard = true;
  updateStats();
  setTimeout(() => {
    a.classList.remove('flipped');
    b.classList.remove('flipped');
    firstCard = null;
    lockBoard = false;
  }, 800);
}

function updateStats() {
  if (matched === SYMBOLS.length) {
    stats.textContent = 'Все пары собраны за ' + moves + ' ходов';
    return;
  }
  stats.textContent = 'Ходов: ' + moves + ' · Пар найдено: ' + matched + ' из ' + SYMBOLS.length;
}

function startGame() {
  board.innerHTML = '';
  firstCard = null;
  lockBoard = false;
  moves = 0;
  matched = 0;
  shuffle(SYMBOLS.concat(SYMBOLS)).forEach(symbol => board.appendChild(createCard(symbol)));
  updateStats();
}

restart.addEventListener('click', startGame);
startGame();
</script>
</body>
</html>

Как проверялся этот код: мы прогнали страницу в Chromium через Playwright, кликая карты программно. Результат — 16 карт и 8 пар на поле, три перезапуска подряд дали три разных расклада, несовпавшая пара закрылась через 800 мс, клик во время паузы игнорировался, полное прохождение вывело строку «Все пары собраны за 8 ходов», ошибок в консоли — ноль.

Если хочется разнести код по файлам, вынесите содержимое <style> в style.css, содержимое <script> — в script.js и подключите их из <head> и конца <body>. Поведение игры от этого не изменится.

Разработка игры по шагам: разбор кода

Дальше тот же листинг по частям — чтобы код можно было менять осознанно.

Шаг 1. Создание структуры страницы

В HTML остаются только три вещи: строка со счётом, пустой контейнер поля и кнопка перезапуска. Карты появятся в контейнере из скрипта. Вариант с раздельными файлами выглядит так:

<!DOCTYPE html>
<html lang="ru">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Найди пару</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <p id="stats"></p>
  <div id="game"></div>
  <button id="restart" type="button">Начать заново</button>
  <script src="script.js"></script>
</body>
</html>

Тег <script> стоит перед закрывающим </body>: к моменту запуска скрипта контейнер #game уже существует в дереве документа, и getElementById находит его.

Шаг 2. Стилизация карт в CSS

Поле — это CSS-сетка из четырёх колонок. Карта состоит из двух граней: рубашка и лицо. Обе лежат друг на друге, у обеих скрыта обратная сторона, а поворот на 180° показывает нужную:

body { margin: 0; padding: 24px; font-family: system-ui, Arial, sans-serif;
       background: #f0f0f0; display: flex; flex-direction: column;
       align-items: center; gap: 16px; }
#stats { font-size: 18px; margin: 0; }
#game { display: grid; grid-template-columns: repeat(4, 96px); gap: 10px; }
.card { width: 96px; height: 96px; padding: 0; border: 0; background: none;
        position: relative; overflow: visible; cursor: pointer;
        perspective: 600px; }
.card-inner { position: absolute; inset: 0; transform-style: preserve-3d;
              transition: transform 0.4s; }
.card.flipped .card-inner { transform: rotateY(180deg); }
.face { position: absolute; inset: 0; display: flex; align-items: center;
        justify-content: center; font-size: 40px; border-radius: 12px;
        backface-visibility: hidden; }
.back { background: #009688; }
.front { background: #fff; border: 2px solid #009688; transform: rotateY(180deg); }
.card.matched { cursor: default; opacity: 0.55; }
#restart { padding: 10px 18px; font-size: 16px; border: 0; border-radius: 8px;
           background: #009688; color: #fff; cursor: pointer; }

Три свойства делают всю анимацию. perspective на карте задаёт глубину сцены, transform-style: preserve-3d на внутренней обёртке сохраняет объём для дочерних элементов, backface-visibility: hidden прячет грань, повёрнутую от зрителя. Класс flipped добавляет скрипт, всё остальное браузер отрисовывает сам.

Шаг 3. Логика игры на JavaScript: перемешивание колоды

Колода — это массив из восьми символов, продублированный дважды. Перемешивание идёт по алгоритму Фишера — Йетса: проходим массив с конца и меняем текущий элемент со случайным из необработанной части.

function shuffle(source) {
  const array = source.slice();
  for (let i = array.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [array[i], array[j]] = [array[j], array[i]];
  }
  return array;
}

Функция возвращает новый массив: исходная колода остаётся нетронутой, и перезапуск партии не зависит от предыдущей. По документации MDN, Math.random() отдаёт «псевдослучайное число с плавающей запятой от 0 (включительно) до 1 (не включая)» — умножение на i + 1 и округление вниз дают корректный индекс.

Шаг 4. Обработка выбора карты и поиска пары

Каждая карта — это <button>: браузер бесплатно даёт ей фокус, нажатие с клавиатуры и правильную семантику. Символ хранится в data-symbol, по нему потом сравниваются пары.

function createCard(symbol) {
  const card = document.createElement('button');
  card.type = 'button';
  card.className = 'card';
  card.dataset.symbol = symbol;
  card.innerHTML =
    '<span class="card-inner">' +
      '<span class="face back"></span>' +
      '<span class="face front">' + symbol + '</span>' +
    '</span>';
  card.addEventListener('click', () => flipCard(card));
  return card;
}

Обработчик клика вешается сразу при создании карты, поэтому отдельный проход по элементам не нужен. Теперь сама логика хода:

function flipCard(card) {
  if (lockBoard) return;
  if (card === firstCard) return;
  if (card.classList.contains('matched')) return;

  card.classList.add('flipped');

  if (firstCard === null) {
    firstCard = card;
    return;
  }

  moves += 1;
  checkForMatch(firstCard, card);
}

function checkForMatch(a, b) {
  if (a.dataset.symbol === b.dataset.symbol) {
    a.classList.add('matched');
    b.classList.add('matched');
    matched += 1;
    firstCard = null;
    updateStats();
    return;
  }

  lockBoard = true;
  updateStats();
  setTimeout(() => {
    a.classList.remove('flipped');
    b.classList.remove('flipped');
    firstCard = null;
    lockBoard = false;
  }, 800);
}

Здесь три защиты, без которых игра рассыпается. lockBoard запрещает клики, пока закрывается несовпавшая пара. Проверка card === firstCard отсекает повторный клик по той же карте. Проверка на класс matched не даёт трогать уже собранные пары. Счётчик ходов увеличивается ровно один раз — в момент открытия второй карты.

Шаг 5. Добавление изображений на карты вместо символов

Символы удобны для проверки, но настоящая игра обычно ходит картинками. Замените массив символов на массив объектов с идентификатором и путём к файлу, а в карту вставьте <img>:

const CARDS = [
  { id: 'apple',      image: 'images/apple.png' },
  { id: 'lemon',      image: 'images/lemon.png' },
  { id: 'grapes',     image: 'images/grapes.png' },
  { id: 'strawberry', image: 'images/strawberry.png' },
  { id: 'kiwi',       image: 'images/kiwi.png' },
  { id: 'peach',      image: 'images/peach.png' },
  { id: 'cherry',     image: 'images/cherry.png' },
  { id: 'pineapple',  image: 'images/pineapple.png' }
];

function createCard(item) {
  const card = document.createElement('button');
  card.type = 'button';
  card.className = 'card';
  card.dataset.symbol = item.id;
  card.innerHTML =
    '<span class="card-inner">' +
      '<span class="face back"></span>' +
      '<span class="face front"><img src="' + item.image + '" alt=""></span>' +
    '</span>';
  card.addEventListener('click', () => flipCard(card));
  return card;
}

// в startGame() колода собирается из объектов:
// shuffle(CARDS.concat(CARDS)).forEach(item => board.appendChild(createCard(item)));
// в updateStats() оба вхождения SYMBOLS.length становятся CARDS.length:
// if (matched === CARDS.length) { ... }   и   'из ' + CARDS.length
// и в CSS добавляется размер картинки:
// .front img { width: 70%; height: 70%; object-fit: contain; }

Старое имя массива остаётся ещё в двух функциях: строка сборки поля в startGame и обе проверки длины колоды в updateStats. Пропущенное вхождение обрывает счётчик ходов и проверку победы, хотя карты при этом рисуются. Поле id служит ключом сравнения пар, image хранит путь к файлу. Атрибут alt оставлен пустым намеренно: подпись к закрытой карте раскрыла бы её содержимое для скринридера. Формат подойдёт любой — PNG, JPEG, SVG или WebP, размер лучше держать в районе 200×200 пикселей.

Шаг 6. Завершение логики игры и перезапуск

Остались счёт, проверка победы и новая партия:

function updateStats() {
  if (matched === SYMBOLS.length) {
    stats.textContent = 'Все пары собраны за ' + moves + ' ходов';
    return;
  }
  stats.textContent = 'Ходов: ' + moves + ' · Пар найдено: ' + matched + ' из ' + SYMBOLS.length;
}

function startGame() {
  board.innerHTML = '';
  firstCard = null;
  lockBoard = false;
  moves = 0;
  matched = 0;
  shuffle(SYMBOLS.concat(SYMBOLS)).forEach(symbol => board.appendChild(createCard(symbol)));
  updateStats();
}

restart.addEventListener('click', startGame);
startGame();

Победа определяется просто: счётчик найденных пар сравнялся с длиной колоды символов. Функция startGame чистит контейнер, обнуляет переменные состояния и собирает поле заново — та же функция вызывается кнопкой «Начать заново» и при первой загрузке страницы.

DOM, Canvas или движок: какой код игры на HTML выбрать

Три способа написать игру на HTML и когда какой брать
Три способа написать игру на HTML и когда какой брать

«Найди пару» выше собрана на обычных элементах страницы. Это один из трёх способов написать игру на HTML, и выбор способа определяет, сколько кода придётся написать дальше. В выдаче по запросу способы лежат вперемешку: «Змейка» на Журнале «Код» и учебник MDN Breakout идут через <canvas>, статья на Хабре — через движок Phaser, карточные игры — через DOM.

Способ Как рисуется картинка Каким играм подходит Что нужно поставить
DOM + CSS Каждый объект — элемент страницы, движение и анимация делаются CSS-трансформациями Карточные и пошаговые: «Найди пару», крестики-нолики, викторины, судоку, пятнашки Ничего сверх браузера и редактора
Canvas 2D Один холст, картинка перерисовывается кадр за кадром из JavaScript Аркады с непрерывным движением: «Змейка», «Арканоид», платформеры, стрелялки Ничего сверх браузера, но нужен игровой цикл и своя обработка столкновений
Движок (Phaser и аналоги) Движок берёт на себя рендеринг, спрайты, сцены и физику Игры с десятками объектов, уровнями, физикой и звуком Подключение библиотеки строкой с CDN или через npm

Ориентир при выборе: объект похож на элемент интерфейса и меняется по клику — берите DOM. Объект движется сам, без участия игрока, каждый кадр — берите Canvas. Объектов десятки, нужны физика, сцены и уровни — берите движок.

По документации MDN, элемент <canvas> «может быть использован для отрисовки графики через скрипты (обычно используется JavaScript)» — графиков, композиций из фотографий и анимаций. Про Phaser официальный README проекта на GitHub говорит: это «быстрый, бесплатный и весёлый опенсорсный фреймворк для HTML5-игр», который рендерит через WebGL и Canvas на десктопных и мобильных браузерах и развивается больше 13 лет. В README подключение с CDN показано на версии 4.2.1.

Для «Найди пару» DOM выигрывает: 16 карт — это 16 элементов, их состояние читается прямо из классов, а анимацию переворота браузер делает сам.

Пять ошибок в коде игры, из-за которых она не запускается

Готовые листинги «Найди пару» часто собраны из кусков разных версий и вместе не работают. Пять поломок ниже встречаются чаще всего — включая ранние версии этого руководства.

  1. Функция перемешивания ничего не возвращает. Код вида function shuffle(array) { array.sort(...) } отдаёт undefined, и следующая строка cards = shuffle(cards) обнуляет колоду. Дальше падает всё: «Cannot read properties of undefined». По документации MDN, sort() «сортирует элементы массива на месте и возвращает ссылку на тот же массив» — значит функцию-обёртку нужно завершать явным return.
  2. Перемешивание через sort(() => Math.random() - 0.5). Функция сравнения отдаёт разный ответ на одну и ту же пару карт, поэтому из пяти требований MDN к компаратору нарушаются четыре: устойчивость, рефлексивность, антисимметричность и транзитивность. Итог — перекошенный расклад и поведение, зависящее от браузера. Алгоритм Фишера — Йетса из шага 3 занимает столько же строк и работает предсказуемо.
  3. Обработчик передаёт объект колоды туда, где ждут элемент страницы. Если addEventListener вызывает handleClick(card), где card — объект колоды, то внутри не окажется ни classList, ни dataset. Через обработчик передавайте DOM-узел, как в шаге 4.
  4. У карт нет атрибута для сравнения. Проверка firstCard.dataset.id === secondCard.dataset.id при отсутствующем data-id сравнивает undefined с undefined и возвращает true: любые две карты объявляются парой, и поле разбирается за восемь ходов подряд без единого совпадения по картинке. Атрибут data-symbol проставляется в момент создания карты.
  5. Проверка победы описана, но не вызывается. Функция checkWin в тексте есть, а из логики совпадения к ней никто не обращается — партия не завершается никогда. В нашем варианте эту роль берёт updateStats, и вызывается она после каждого совпадения.

Шестая по частоте поломка — открытое поле во время паузы. Пока несовпавшая пара ждёт закрытия, игрок успевает щёлкнуть третью карту, и она попадает в сравнение как «первая». Флаг lockBoard закрывает эту дыру одной строкой.

Как выложить готовую игру в интернет

Путь от файла на диске до ссылки на игру
Путь от файла на диске до ссылки на игру

Локально игра работает сразу: файл открывается двойным щелчком, браузер отдаёт его по протоколу file://, и этого хватает для проверки. Чтобы дать ссылку другому человеку, нужен хостинг статических файлов — например GitHub Pages, он бесплатный.

Порядок такой: положите index.html в корень репозитория, откройте настройки репозитория и, по инструкции GitHub, «в разделе «Код и автоматизация» боковой панели щелкните Pages», затем выберите ветку публикации. Точка входа определяется автоматически: «GitHub Pages будет искать файл в формате index.html, index.md или README.md». Одно ограничение из той же справки GitHub: «Если учетная запись, которой принадлежит репозиторий, использует GitHub Free или GitHub Free для организаций, репозиторий должен быть общедоступным».

Игра на трёх файлах без сборки укладывается в эту схему целиком: ни серверной части, ни базы данных у неё нет.

Что доработать в игре дальше

Каркас из этой статьи расширяется по частям, и каждая доработка касается одного места в коде:

  • Таймер. Запускается в startGame, останавливается там же, где показывается финальная строка.
  • Рекорд. Минимальное число ходов кладётся в localStorage и читается при загрузке страницы.
  • Уровни сложности. Меняется длина массива символов и число колонок в grid-template-columns: 6×6 — это 18 пар.
  • Звук. Короткий Audio на совпадение и на промах, вызывается из checkForMatch.
  • Счёт на двоих. Переменная активного игрока переключается в момент промаха.

Писать эти куски можно с помощью ИИ-ассистента: он неплохо справляется с типовой логикой на JavaScript, а проверять результат вы уже умеете — по разделу с ошибками выше. Как это выглядит на практике, разобрано в материалах как в Claude создать интерактивное приложение и как GPT-5 в GitHub Copilot помогает разработчикам.

Готовая игра занимает 126 строк в одном файле и обходится без библиотек и сборки. Растить её удобно по частям: сначала таймер и рекорд, потом размер поля, а если карты когда-нибудь сменятся движущимися объектами — переход на <canvas> и игровой цикл.

Частые вопросы про код игры на HTML

Можно ли написать игру только на HTML, без JavaScript?

HTML задаёт структуру страницы, а реакцию на действия игрока и правила описывает JavaScript. На чистых HTML и CSS получаются отдельные интерактивные эффекты: переворот карты при наведении, подсветка выбранной клетки, скрытие блока через чекбокс. Подсчёт ходов, сравнение пар и завершение партии без скрипта не описать, поэтому минимальный рабочий стек игры — HTML, CSS и JavaScript.

Где взять готовый код игры на HTML?

Полный листинг игры «Найди пару» лежит в этой статье: он копируется в один файл и запускается без установки чего-либо. Кроме того, у MDN есть пошаговый учебник «2D игра на чистом JavaScript», где Breakout собирается на элементе canvas, а на GitHub исходники браузерных игр собраны под темой html-game.

Почему карты не переворачиваются, хотя код скопирован целиком?

Три типовые причины. Первая: скрипт не добавляет карте класс flipped, потому что обработчик клика повешен на другой элемент. Вторая: у граней карты нет свойства backface-visibility со значением hidden, и лицевая сторона видна сквозь рубашку. Третья: у внутренней обёртки нет transform-style со значением preserve-3d, из-за чего браузер расплющивает трёхмерный поворот в плоский.

Нужен ли сервер, чтобы запустить игру на HTML?

Для локальной проверки сервер не нужен: файл открывается двойным щелчком, браузер отдаёт его по протоколу file://, и игра работает. Сервер или хостинг статики понадобится только для того, чтобы дать ссылку другим людям — подойдёт GitHub Pages, который ищет в репозитории файл index.html.

Что выбрать для игры — DOM или canvas?

DOM подходит, когда объект похож на элемент интерфейса и меняется по клику: карточные игры, крестики-нолики, викторины, пятнашки. Canvas нужен, когда картинка перерисовывается каждый кадр и объекты движутся сами: «Змейка», «Арканоид», платформеры. Если объектов десятки и нужны физика, сцены и звук, дешевле взять готовый движок вроде Phaser.

Читайте также

3 материала