Содержание
- Основы игры «Найди пару»: что именно делает код
- Технологический стек: за что отвечают HTML, CSS и JavaScript
- Полный код игры на HTML: один файл, который сразу работает
- Разработка игры по шагам: разбор кода
- DOM, Canvas или движок: какой код игры на HTML выбрать
- Пять ошибок в коде игры, из-за которых она не запускается
- Как выложить готовую игру в интернет
- Что доработать в игре дальше
- Частые вопросы про код игры на HTML
Создание игры «Найди пару» на HTML и JS: пошаговое руководство
Черновик готовит редакция с помощью ИИ. За стандарт издания отвечает главный редактор — Валерий Курземнек.
Код игры на 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, и выбор способа определяет, сколько кода придётся написать дальше. В выдаче по запросу способы лежат вперемешку: «Змейка» на Журнале «Код» и учебник 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 элементов, их состояние читается прямо из классов, а анимацию переворота браузер делает сам.
Пять ошибок в коде игры, из-за которых она не запускается
Готовые листинги «Найди пару» часто собраны из кусков разных версий и вместе не работают. Пять поломок ниже встречаются чаще всего — включая ранние версии этого руководства.
- Функция перемешивания ничего не возвращает. Код вида
function shuffle(array) { array.sort(...) }отдаётundefined, и следующая строкаcards = shuffle(cards)обнуляет колоду. Дальше падает всё: «Cannot read properties of undefined». По документации MDN,sort()«сортирует элементы массива на месте и возвращает ссылку на тот же массив» — значит функцию-обёртку нужно завершать явнымreturn. - Перемешивание через
sort(() => Math.random() - 0.5). Функция сравнения отдаёт разный ответ на одну и ту же пару карт, поэтому из пяти требований MDN к компаратору нарушаются четыре: устойчивость, рефлексивность, антисимметричность и транзитивность. Итог — перекошенный расклад и поведение, зависящее от браузера. Алгоритм Фишера — Йетса из шага 3 занимает столько же строк и работает предсказуемо. - Обработчик передаёт объект колоды туда, где ждут элемент страницы. Если
addEventListenerвызываетhandleClick(card), гдеcard— объект колоды, то внутри не окажется ниclassList, ниdataset. Через обработчик передавайте DOM-узел, как в шаге 4. - У карт нет атрибута для сравнения. Проверка
firstCard.dataset.id === secondCard.dataset.idпри отсутствующемdata-idсравниваетundefinedсundefinedи возвращаетtrue: любые две карты объявляются парой, и поле разбирается за восемь ходов подряд без единого совпадения по картинке. Атрибутdata-symbolпроставляется в момент создания карты. - Проверка победы описана, но не вызывается. Функция
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.
