GameDev Guide
Глава 418 мин чтенияПроверено: 5 сентября 2026 г.

Создание первой HTML5-игры

Полностью рабочая игра на canvas из двух файлов: игровой цикл на requestAnimationFrame, клавиатура и тач, счёт и проигрыш. Код можно скопировать и запустить локально за минуту.

Содержание главы

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

Заодно мы сразу заложим в него несколько мелочей, из-за которых чаще всего заворачивают игры на модерации. Их дешевле сделать сейчас, в тридцати строках, чем выковыривать потом из готового проекта.

Что мы строим

«Ловец»: сверху падают шарики, внизу ездит корзина. Зелёные надо ловить, розовые — пропускать. Три жизни, счёт, рекорд, экран проигрыша. Механика примитивная намеренно — важен не геймплей, а каркас: игровой цикл, состояние, ввод, отрисовка. На этот каркас потом ложится что угодно, от аркады до головоломки.

Почему именно canvas и чистый JavaScript, без Phaser и без сборки. Во-первых, у платформы жёсткие требования к архиву: ZIP, в корне ровно один index.html, содержимое в распакованном виде не больше 100 МБ, а в именах файлов и папок — ни пробелов, ни кириллицы (требования 1.21 и 1.22). Два файла без сборки укладываются в это по определению. Во-вторых, пока вы не понимаете, из чего состоит игровой цикл, любой движок будет чёрным ящиком, а отлаживать чёрный ящик под чужие требования — худшее из возможных занятий.

Структура

lovets/
  index.html
  game.js

index.html обязан лежать в корне — не в подпапке build/ или dist/. Имя папки латиницей. Всё, никаких других правил на этом этапе нет.

index.html

Файл короткий, но почти каждая строка в нём стоит там не случайно.

<!DOCTYPE html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
  <title>Ловец</title>
  <style>
    html, body {
      margin: 0;
      padding: 0;
      height: 100%;
      background: #10131a;
      overflow: hidden;              /* никаких полос прокрутки */
      overscroll-behavior: none;     /* никакого «потянуть, чтобы обновить» */
      touch-action: none;            /* иначе браузер съест drag и pointermove оборвётся */
      user-select: none;
      -webkit-user-select: none;
      -webkit-tap-highlight-color: transparent;
    }
    #game {
      position: fixed;
      inset: 0;
      display: block;
      width: 100%;
      height: 100%;
    }
  </style>
</head>
<body>
  <canvas id="game"></canvas>
  <script src="game.js"></script>
</body>
</html>

touch-action: none — самая важная строка для мобильных. Без неё браузер решит, что ваше движение пальцем по холсту это скролл, перехватит жест и оборвёт поток событий pointermove. Игра будет «залипать» на телефоне и прекрасно работать на десктопе, из-за чего баг находят в последнюю очередь.

position: fixed; inset: 0 вместо width: 100% на потоке — потому что на мобильных адресная строка то появляется, то исчезает, и высота обычного блока прыгает вместе с ней.

Важно

overflow: hidden и overscroll-behavior: none — не косметика. Требование 1.10.2 прямо запрещает полосы прокрутки браузера и жест «потянуть, чтобы обновить» внутри игры.

game.js

Это весь код игры. Скопируйте его целиком — он рабочий, ничего дописывать не нужно.

'use strict';

// Логический размер игрового поля. Вся игровая математика живёт в этих
// единицах — экран может быть любым, мы просто масштабируем картинку.
const W = 360;
const H = 640;

const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');

// Как логическое поле вписано в реальный холст.
const view = { scale: 1, dx: 0, dy: 0 };

function resize() {
  // Выше 2 нет смысла: разница не видна, а пикселей рисуем вчетверо больше.
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  const cssW = canvas.clientWidth;
  const cssH = canvas.clientHeight;

  canvas.width = Math.round(cssW * dpr);
  canvas.height = Math.round(cssH * dpr);

  view.scale = Math.min(canvas.width / W, canvas.height / H);
  view.dx = (canvas.width - W * view.scale) / 2;
  view.dy = (canvas.height - H * view.scale) / 2;
}

// Точка экрана -> точка игрового поля.
function toWorldX(clientX) {
  const rect = canvas.getBoundingClientRect();
  const pxPerCss = canvas.width / rect.width;
  return ((clientX - rect.left) * pxPerCss - view.dx) / view.scale;
}

const clamp = (v, min, max) => (v < min ? min : v > max ? max : v);

// ---------- Состояние ----------

const BEST_KEY = 'catcher.best';

const state = {
  phase: 'menu',   // 'menu' | 'play' | 'over'
  paused: false,
  score: 0,
  best: readBest(),
  lives: 3,
  items: [],
  spawnIn: 0,
  spawnEvery: 0.9,
  fallSpeed: 130,
};

const basket = { x: W / 2, y: H - 96, w: 78, h: 16, speed: 400 };

function readBest() {
  // localStorage может быть недоступен (приватный режим, отключённые куки).
  try { return Number(localStorage.getItem(BEST_KEY)) || 0; } catch (e) { return 0; }
}
function writeBest(value) {
  try { localStorage.setItem(BEST_KEY, String(value)); } catch (e) { /* не критично */ }
}

function startGame() {
  state.phase = 'play';
  state.paused = false;
  state.score = 0;
  state.lives = 3;
  state.items.length = 0;
  state.spawnIn = 0.3;
  state.spawnEvery = 0.9;
  state.fallSpeed = 130;
  basket.x = W / 2;
}

function endGame() {
  state.phase = 'over';
  if (state.score > state.best) {
    state.best = state.score;
    writeBest(state.best);
  }
}

// ---------- Ввод ----------

const keys = new Set();
let pointerX = null;
let pointerDown = false;

window.addEventListener('keydown', (e) => {
  keys.add(e.code);
  if (['ArrowLeft', 'ArrowRight', 'Space', 'ArrowUp', 'ArrowDown'].includes(e.code)) {
    e.preventDefault();
  }
  if (e.code === 'Space' || e.code === 'Enter') act();
  if (e.code === 'KeyP' && state.phase === 'play') state.paused = !state.paused;
});
window.addEventListener('keyup', (e) => keys.delete(e.code));

// Pointer Events — один API на мышь, палец и стилус.
canvas.addEventListener('pointerdown', (e) => {
  canvas.setPointerCapture(e.pointerId);
  pointerDown = true;
  pointerX = toWorldX(e.clientX);
  act();
});
canvas.addEventListener('pointermove', (e) => {
  if (pointerDown || e.pointerType === 'mouse') pointerX = toWorldX(e.clientX);
});
const releasePointer = () => { pointerDown = false; };
canvas.addEventListener('pointerup', releasePointer);
canvas.addEventListener('pointercancel', releasePointer);

// Контекстное меню поверх игры — частая причина отклонения на модерации.
document.addEventListener('contextmenu', (e) => e.preventDefault());

// Тап/пробел: начать, продолжить или переиграть.
function act() {
  if (state.phase === 'play') {
    if (state.paused) state.paused = false;
  } else {
    startGame();
  }
}

// Свернули вкладку — ставим паузу. Игра не должна идти «вслепую».
document.addEventListener('visibilitychange', () => {
  if (document.hidden && state.phase === 'play') state.paused = true;
});
window.addEventListener('blur', () => {
  if (state.phase === 'play') state.paused = true;
});
window.addEventListener('resize', resize);

// ---------- Логика ----------

function spawn() {
  const bad = Math.random() < 0.25;
  const r = bad ? 13 : 11;
  state.items.push({
    x: r + 8 + Math.random() * (W - 2 * r - 16),
    y: -r,
    r,
    bad,
    vy: state.fallSpeed * (0.85 + Math.random() * 0.4),
  });
}

function caught(item) {
  const top = basket.y;
  const left = basket.x - basket.w / 2;
  const right = basket.x + basket.w / 2;
  return item.y + item.r >= top &&
         item.y - item.r <= top + basket.h &&
         item.x >= left - item.r &&
         item.x <= right + item.r;
}

function update(dt) {
  if (state.phase !== 'play' || state.paused) return;

  // Клавиатура важнее указателя: если игрок держит стрелку, курсор игнорируем.
  let dir = 0;
  if (keys.has('ArrowLeft') || keys.has('KeyA')) dir -= 1;
  if (keys.has('ArrowRight') || keys.has('KeyD')) dir += 1;

  if (dir !== 0) {
    basket.x += dir * basket.speed * dt;
  } else if (pointerX !== null) {
    // Тянемся к пальцу, а не телепортируемся — так движение читается глазом.
    basket.x += (pointerX - basket.x) * Math.min(1, dt * 16);
  }
  basket.x = clamp(basket.x, basket.w / 2, W - basket.w / 2);

  state.spawnIn -= dt;
  if (state.spawnIn <= 0) {
    spawn();
    state.spawnIn = state.spawnEvery;
  }

  for (let i = state.items.length - 1; i >= 0; i--) {
    const it = state.items[i];
    it.y += it.vy * dt;

    if (caught(it)) {
      state.items.splice(i, 1);
      if (it.bad) {
        state.lives -= 1;
      } else {
        state.score += 1;
        // Плавный разгон вместо «уровней»: сложность растёт незаметно.
        state.fallSpeed = Math.min(430, state.fallSpeed + 4);
        state.spawnEvery = Math.max(0.34, state.spawnEvery - 0.012);
      }
    } else if (it.y - it.r > H) {
      state.items.splice(i, 1);
      if (!it.bad) state.lives -= 1;
    }
  }

  if (state.lives <= 0) endGame();
}

// ---------- Отрисовка ----------

function text(str, x, y, size, color, align = 'center', weight = 600) {
  ctx.fillStyle = color;
  ctx.textAlign = align;
  ctx.textBaseline = 'middle';
  ctx.font = `${weight} ${size}px system-ui, "Segoe UI", Roboto, sans-serif`;
  ctx.fillText(str, x, y);
}

function render() {
  // Поля вокруг игрового поля — тем же цветом, что и фон страницы.
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.fillStyle = '#10131a';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  ctx.setTransform(view.scale, 0, 0, view.scale, view.dx, view.dy);
  ctx.save();
  ctx.beginPath();
  ctx.rect(0, 0, W, H);
  ctx.clip();

  const sky = ctx.createLinearGradient(0, 0, 0, H);
  sky.addColorStop(0, '#1b2030');
  sky.addColorStop(1, '#0e1119');
  ctx.fillStyle = sky;
  ctx.fillRect(0, 0, W, H);

  for (const it of state.items) {
    ctx.beginPath();
    ctx.arc(it.x, it.y, it.r, 0, Math.PI * 2);
    ctx.fillStyle = it.bad ? '#e75e8d' : '#7fd6a4';
    ctx.fill();
    if (it.bad) {
      ctx.strokeStyle = '#10131a';
      ctx.lineWidth = 2.5;
      ctx.beginPath();
      ctx.moveTo(it.x - 5, it.y - 5); ctx.lineTo(it.x + 5, it.y + 5);
      ctx.moveTo(it.x + 5, it.y - 5); ctx.lineTo(it.x - 5, it.y + 5);
      ctx.stroke();
    }
  }

  // Корзина: прямоугольник плюс два круга вместо roundRect —
  // roundRect есть не во всех браузерах из списка поддержки.
  const bx = basket.x - basket.w / 2;
  const cy = basket.y + basket.h / 2;
  ctx.fillStyle = '#e8ecf1';
  ctx.fillRect(bx, basket.y, basket.w, basket.h);
  ctx.beginPath();
  ctx.arc(bx, cy, basket.h / 2, 0, Math.PI * 2);
  ctx.arc(bx + basket.w, cy, basket.h / 2, 0, Math.PI * 2);
  ctx.fill();

  text(`Счёт: ${state.score}`, 14, 26, 18, '#e8ecf1', 'left');
  text('♥'.repeat(Math.max(0, state.lives)), W - 14, 26, 18, '#e75e8d', 'right');
  text(`Рекорд: ${state.best}`, W - 14, 50, 13, '#9aa0a6', 'right', 400);

  if (state.phase !== 'play' || state.paused) {
    ctx.fillStyle = 'rgba(16, 19, 26, 0.78)';
    ctx.fillRect(0, 0, W, H);

    if (state.phase === 'menu') {
      text('ЛОВЕЦ', W / 2, H / 2 - 60, 42, '#ec6090');
      text('Ловите зелёные, уворачивайтесь от розовых', W / 2, H / 2 - 6, 14, '#9aa0a6', 'center', 400);
      text('Стрелки, A/D или палец', W / 2, H / 2 + 18, 14, '#9aa0a6', 'center', 400);
      text('Нажмите, чтобы начать', W / 2, H / 2 + 70, 18, '#e8ecf1');
    } else if (state.paused) {
      text('Пауза', W / 2, H / 2 - 10, 34, '#e8ecf1');
      text('Нажмите, чтобы продолжить', W / 2, H / 2 + 34, 15, '#9aa0a6', 'center', 400);
    } else {
      text('Игра окончена', W / 2, H / 2 - 56, 30, '#ec6090');
      text(`Счёт: ${state.score}`, W / 2, H / 2 - 6, 22, '#e8ecf1');
      text(`Рекорд: ${state.best}`, W / 2, H / 2 + 26, 16, '#9aa0a6', 'center', 400);
      text('Нажмите, чтобы сыграть ещё', W / 2, H / 2 + 76, 17, '#e8ecf1');
    }
  }

  ctx.restore();
}

// ---------- Цикл ----------

let last = performance.now();

function frame(now) {
  // Вкладка была свёрнута — придёт дельта в несколько секунд. Ограничиваем,
  // иначе шарики за один кадр «телепортируются» сквозь корзину.
  const dt = Math.min((now - last) / 1000, 0.05);
  last = now;
  update(dt);
  render();
  requestAnimationFrame(frame);
}

resize();
requestAnimationFrame(frame);

Разбор: четыре решения, которые тут главные

Игровой цикл и дельта времени

requestAnimationFrame вызывает frame перед каждой перерисовкой экрана и передаёт в неё метку времени. Разница между текущей и предыдущей меткой — dt, сколько секунд прошло. Вся физика умножается на dt, поэтому скорость задаётся в «единицах в секунду» и не зависит от частоты кадров.

Осторожно

Если написать it.y += 3 вместо it.y += it.vy * dt, на мониторе 60 Гц игра будет нормальной, а на 144 Гц — в два с лишним раза быстрее. Это не редкий случай: у половины игровых ноутбуков экран 120 Гц и выше. Любое движение — только через dt.

Ограничение Math.min(..., 0.05) тоже обязательно. Когда вкладка неактивна, браузер не вызывает requestAnimationFrame; при возвращении придёт dt в несколько секунд, и объекты за один кадр пролетят сквозь корзину, не задев её. Верхняя граница дельты — стандартная защита от такого «туннелирования».

Логические координаты вместо пиксельных

Игра всегда рисуется в поле 360 × 640 условных единиц. resize() считает, во сколько раз это поле надо увеличить, чтобы вписать в текущий холст, и ctx.setTransform применяет масштаб. Плюс в том, что вся игровая логика написана один раз и не знает про экраны: basket.x = 180 — это середина и на телефоне, и на телевизоре.

Обратный перевод — toWorldX(): из координаты события мыши или пальца в игровую координату. Без него корзина будет уезжать не туда на любом экране, кроме вашего.

devicePixelRatio ограничен двойкой: на телефоне с DPR 3 холст был бы девятикратным по числу пикселей, и слабое устройство начало бы проседать по кадрам ради разницы, которую глаз не различает.

Ввод: одна ветка на мышь и на палец

Pointer Events — единый API: pointerdown, pointermove, pointerup приходят и от мыши, и от касания, и от стилуса, а e.pointerType говорит, что именно это было. Отдельные обработчики mousedown и touchstart писать не нужно, и «двойных срабатываний» (когда мобильный браузер после касания шлёт ещё и события мыши) тоже не будет.

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

Состояние как явная фаза

state.phase со значениями menu, play, over плюс отдельный флаг paused — вместо разбросанных булевых переменных. Это не эстетика: когда мы будем подключать SDK, паузу придётся ставить по внешним событиям платформы и по показу рекламы, а размечать геймплей — сообщать платформе, где он начинается и заканчивается. Если фаза одна и явная, это две строки. Если состояние размазано по пяти флагам — это переписывание половины игры.

Как запустить локально

Двойной клик по index.html сработает: конкретно эта игра откроется по file://. Но как только вы добавите загрузку JSON, спрайтов через fetch или ES-модули, браузер заблокирует их политикой безопасности. Поэтому привыкайте сразу поднимать локальный сервер:

cd lovets
npx serve .

Дальше откройте адрес, который он напечатает (обычно http://localhost:3000). Подойдёт и расширение Live Server для VS Code, и любой другой статический сервер — важен сам факт http:// вместо file://.

Для запуска с уже подключённым SDK Яндекс предлагает отдельный инструмент — npx @yandex-games/sdk-dev-proxy. Он понадобится в главе про SDK; сейчас в нём нет смысла, потому что подключать пока нечего.

Как поручить это Claude Code

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

Плохой запрос: «напиши простую игру на canvas».

Рабочий запрос выглядит примерно так:

Сделай в папке lovets игру на canvas: два файла, index.html и game.js,
без сборки и без внешних библиотек.

Механика: сверху падают шарики, внизу горизонтально движется корзина.
Зелёные ловим (+1 очко), розовые ловить нельзя (-1 жизнь), пропущенный
зелёный тоже -1 жизнь. Три жизни, потом экран проигрыша с рекордом.

Требования к реализации:
- игровой цикл на requestAnimationFrame, всё движение через dt в секундах,
  dt ограничен сверху 0.05;
- логическое поле фиксированного размера, вписанное в холст с сохранением
  пропорций; девайс-пиксель-рейшо не больше 2;
- управление: стрелки и A/D, плюс Pointer Events для мыши и пальца;
- контекстное меню в области игры отключено;
- при уходе со вкладки игра встаёт на паузу;
- CSS: страница не скроллится, touch-action: none.

Комментарии в коде — на русском, объясняют «почему», а не «что».
Запусти локальный сервер и проверь, что в консоли нет ошибок.

Обратите внимание на последнюю строку. Claude Code умеет запускать команды и открывать страницу сам — просьба проверить результат превращает генерацию кода в цикл «написал → запустил → починил». Без неё вы получите код, который никто не выполнял.

Совет

Когда игра уже работает и вы хотите её менять, описывайте наблюдаемое поведение, а не предполагаемую причину. «На телефоне корзина перестаёт двигаться, если вести палец быстро» даст лучший результат, чем «добавь touch-action»: во втором случае вы навязываете гипотезу, которая может быть неверной.

Как проверить результат

Неважно, писали вы код сами или получили от модели, — проверять надо одинаково. Минимальный прогон занимает пять минут.

ПроверкаКакЧто должно быть
ОшибкиDevTools → Console, поиграть минутуПусто. Ни одной красной строки
Мобильный экранDevTools → режим устройства (Ctrl+Shift+M), выбрать телефонПоле вписано целиком, HUD не обрезан
ТачВ режиме устройства провести «пальцем» по холстуКорзина следует непрерывно, не залипает
КлавиатураИграть только со стрелок, мышь не трогатьУправление полное, страница не скроллится
Правый кликКликнуть правой кнопкой по игреМеню браузера не появляется
Уход со вкладкиПереключиться на другую вкладку на 15 секундВернулись — игра на паузе, счёт не изменился
Изменение размераПотянуть за угол окна во время игрыНичего не рвётся, пропорции сохраняются
Слабое устройствоDevTools → Performance → CPU throttling 6×Игра замедляется плавно, а не рассыпается

Отдельно — тест на «дельту»: поставьте throttling, поиграйте, снимите. Если после снятия шарики скакнули вниз рывком, значит dt где-то не ограничен.

Частая ошибка

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

Что дальше

Игра работает, но пока она ничего не знает о платформе: не сообщает, что загрузилась, не размечает геймплей, не сохраняет прогресс в аккаунт игрока. Всё это делает Yandex Games SDK, и подключается он буквально одним тегом — правда, не тем, который вам покажет большинство статей в интернете. Следующие главы про то, как устроен путь от локальной папки до записи в консоли разработчика, и как подключить SDK так, чтобы это прошло модерацию.

Требования и ограничения платформы, упомянутые в главе, сверены с официальной документацией Яндекс Игр в сентябре 2026 года. Формулировки требований со временем меняются — перед отправкой на модерацию сверяйтесь с актуальной версией.

Источники

Официальная документация платформы — единственный источник истины по её требованиям.

Нужно подробнее?

Полное обучение разбирает те же темы глубже: уроки с примерами, готовые структуры проектов, развёрнутые чек-листы и файл инструкций для Claude Code — он входит в стоимость. Бесплатные главы при этом остаются открытыми и продолжают дополняться.

Что входит в полное обучение