Игра «Найди пару» на JavaScript: пошаговая разработка без библиотек

JS / DOM и события

Игра «Найди пару» на JavaScript: пошаговая разработка без библиотек

Создаём доступную игру на совпадение карточек: HTML-каркас, адаптивный CSS, перемешивание, состояния, счётчик ходов и перезапуск.

Опубликовано 04.09.2026 7 минут чтения
Содержание статьи

В игре на поле лежат закрытые карточки. Пользователь открывает две: совпавшая пара остаётся открытой, несовпавшая закрывается. На этом проекте удобно потренировать массивы, DOM, события и управление состоянием.

Соберём версию из трёх файлов. Карточки будут настоящими кнопками, игра — доступной с клавиатуры, а алгоритм перемешивания — равномерным Fisher–Yates.

Структура проекта

Код
memory-game/├── index.html├── styles.css└── game.js​

Открывайте страницу через локальный HTTP-сервер, потому что JavaScript подключается как ES-модуль.

HTML-каркас

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="styles.css"></head><body>  <main class="game">    <h1 class="game__title">Найди пару</h1>    <p class="game__rules">Открывайте по две карточки и запоминайте символы.</p>​    <div class="game__toolbar">      <p>Ходы: <output id="moves">0</output></p>      <button id="restart" type="button">Начать заново</button>    </div>​    <div      class="game__board"      id="board"      aria-label="Поле игры"    ></div>​    <p class="game__status" id="status" aria-live="polite"></p>  </main>​  <script type="module" src="game.js"></script></body></html>​

output показывает счётчик, aria-live="polite" сообщает о завершении без резкого перехвата речи. Само поле не получает роль grid: набор обычных кнопок уже имеет понятную клавиатурную модель.

Адаптивные стили

CSS
*,*::before,*::after {  box-sizing: border-box;}​body {  margin: 0;  font-family: system-ui, sans-serif;  line-height: 1.5;  color: #f8f7ff;  background-color: #171329;}​.game {  inline-size: min(100% - 2rem, 42rem);  margin-inline: auto;  padding-block: 2rem;}​.game__title,.game__rules,.game__status {  text-align: center;}​.game__toolbar {  display: flex;  flex-wrap: wrap;  gap: 1rem;  align-items: center;  justify-content: space-between;  margin-block: 2rem 1rem;}​.game__toolbar p {  margin: 0;}​.game__toolbar button {  min-block-size: 2.75rem;  padding-inline: 1rem;  font: inherit;}​.game__board {  display: grid;  grid-template-columns: repeat(4, minmax(0, 1fr));  gap: clamp(0.5rem, 2vw, 1rem);}​.card {  display: grid;  place-items: center;  aspect-ratio: 1;  padding: 0;  font: inherit;  font-size: clamp(1.5rem, 8vw, 3rem);  color: #ffffff;  background-color: #5b21b6;  border: 2px solid #8b5cf6;  border-radius: 0.75rem;  cursor: pointer;  transition:    background-color 160ms ease,    transform 160ms ease;}​.card:hover {  background-color: #6d28d9;}​.card:focus-visible {  outline: 3px solid #fbbf24;  outline-offset: 3px;}​.card[data-state="open"],.card[data-state="matched"] {  color: #24163f;  background-color: #ede9fe;  transform: rotateY(180deg);}​.card[data-state="matched"] {  border-color: #34d399;  cursor: default;}​@media (prefers-reduced-motion: reduce) {  .card {    transition: none;  }​  .card[data-state="open"],  .card[data-state="matched"] {    transform: none;  }}​

aspect-ratio создаёт квадратные карточки без фиксированной высоты. На очень узком экране четыре колонки остаются компактными; для большего набора можно менять число колонок медиазапросом.

Данные карточек

JavaScript
const symbols = [  { value: '🍎', name: 'яблоко' },  { value: '🚀', name: 'ракета' },  { value: '🎸', name: 'гитара' },  { value: '🌿', name: 'лист' },  { value: '⚽', name: 'мяч' },  { value: '☕', name: 'чашка кофе' },  { value: '💡', name: 'лампа' },  { value: '🐋', name: 'кит' },];​

У каждого символа есть текстовое название для доступного имени кнопки.

Перемешивание Fisher–Yates

Сортировка через array.sort(() => Math.random() - 0.5) не даёт равномерного распределения. Используем алгоритм Fisher–Yates:

JavaScript
function shuffle(items) {  const result = [...items];​  for (let index = result.length - 1; index > 0; index -= 1) {    const randomIndex = Math.floor(Math.random() * (index + 1));    [result[index], result[randomIndex]] = [result[randomIndex], result[index]];  }​  return result;}​

Функция возвращает новый массив и не меняет аргумент.

Создание поля

JavaScript
const board = document.querySelector('#board');const movesOutput = document.querySelector('#moves');const restartButton = document.querySelector('#restart');const status = document.querySelector('#status');​if (!board || !movesOutput || !restartButton || !status) {  throw new Error('Не найдены обязательные элементы игры');}​let openCards = [];let moves = 0;let isLocked = false;let mismatchTimer = null;​function createDeck() {  return shuffle([...symbols, ...symbols]);}​function createCard(symbol, index) {  const button = document.createElement('button');  const visibleSymbol = document.createElement('span');​  button.type = 'button';  button.className = 'card';  button.dataset.state = 'closed';  button.dataset.symbol = symbol.value;  button.setAttribute('aria-label', `Закрытая карточка ${index + 1}`);​  visibleSymbol.setAttribute('aria-hidden', 'true');  button.append(visibleSymbol);​  return button;}​function renderBoard() {  const fragment = document.createDocumentFragment();​  createDeck().forEach((symbol, index) => {    fragment.append(createCard(symbol, index));  });​  board.replaceChildren(fragment);}​

Символ не записывается через innerHTML. DocumentFragment позволяет добавить поле одной операцией.

Открытие и закрытие

JavaScript
function getSymbolName(value) {  return symbols.find((symbol) => symbol.value === value)?.name ?? 'неизвестный символ';}​function openCard(card) {  card.dataset.state = 'open';  card.firstElementChild.textContent = card.dataset.symbol;  card.setAttribute('aria-label', `Открытая карточка: ${getSymbolName(card.dataset.symbol)}`);}​function closeCard(card) {  card.dataset.state = 'closed';  card.firstElementChild.textContent = '';  card.setAttribute('aria-label', 'Закрытая карточка');}​function matchCard(card) {  card.dataset.state = 'matched';  card.disabled = true;  card.setAttribute('aria-label', `Найденная пара: ${getSymbolName(card.dataset.symbol)}`);}​

Совпавшая карточка становится disabled и больше не участвует в фокусе. При большом поле можно оставить её фокусируемой и блокировать только повторный выбор, но тогда нужно ясно сообщать состояние.

Проверка пары

JavaScript
function finishMove() {  const [first, second] = openCards;  const isMatch = first.dataset.symbol === second.dataset.symbol;​  moves += 1;  movesOutput.value = String(moves);​  if (isMatch) {    matchCard(first);    matchCard(second);    openCards = [];​    if (board.querySelectorAll('[data-state="matched"]').length === symbols.length * 2) {      status.textContent = `Все пары найдены за ${moves} ходов.`;    }​    return;  }​  isLocked = true;  mismatchTimer = window.setTimeout(() => {    closeCard(first);    closeCard(second);    openCards = [];    isLocked = false;    mismatchTimer = null;  }, 700);}​

Блокировка не позволяет открыть третью карточку, пока несовпавшая пара видна.

Обработчик клика и перезапуск

JavaScript
board.addEventListener('click', (event) => {  const card = event.target.closest('.card');​  if (!card || !board.contains(card) || isLocked || card.dataset.state !== 'closed') {    return;  }​  openCard(card);  openCards.push(card);​  if (openCards.length === 2) {    finishMove();  }});​function startGame() {  if (mismatchTimer !== null) {    window.clearTimeout(mismatchTimer);    mismatchTimer = null;  }​  openCards = [];  moves = 0;  isLocked = false;  movesOutput.value = '0';  status.textContent = '';  renderBoard();}​restartButton.addEventListener('click', startGame);startGame();​

Таймер очищается при перезапуске, поэтому старый callback не изменит новое поле.

Что проверить

  • каждая пара встречается ровно два раза;
  • нельзя дважды выбрать одну карточку или открыть третью во время ожидания;
  • быстрый перезапуск не вызывает ошибок от старого таймера;
  • игра полностью управляется Tab, Enter и пробелом;
  • фокус хорошо виден;
  • счётчик увеличивается один раз на пару;
  • reduced motion убирает переворот;
  • раскладка не переполняет экран шириной 320 px.

Идеи для развития

Добавьте выбор размера поля, лучший результат в localStorage, таймер с возможностью паузы или наборы символов. Новые функции вводите по одной и сначала определяйте источник состояния: это предотвращает расхождение интерфейса и данных.

Сообщить об ошибке в статье

Рекомендации редакции

Другие публикации из этого раздела.

Новые материалы

Получайте практические разборы без информационного шума

Одна общая рассылка о новых статьях и инструментах. Адрес будет активироваться только после подтверждения.

Подписка появится после подключения сервиса рассылок и Yandex SmartCaptcha.