Дальше будет много про SDK, модерацию и монетизацию. Но всё это надстраивается над одной вещью — работающей игрой. В этой главе мы её напишем: два файла, никаких сборщиков, никаких зависимостей, открывается в браузере и играется. Код ниже проверен целиком, а не собран из кусков: он запускается, реагирует на клавиатуру и на палец, считает очки и заканчивается проигрышем.
Заодно мы сразу заложим в него несколько мелочей, из-за которых чаще всего заворачивают игры на модерации. Их дешевле сделать сейчас, в тридцати строках, чем выковыривать потом из готового проекта.
Что мы строим
«Ловец»: сверху падают шарики, внизу ездит корзина. Зелёные надо ловить, розовые — пропускать. Три жизни, счёт, рекорд, экран проигрыша. Механика примитивная намеренно — важен не геймплей, а каркас: игровой цикл, состояние, ввод, отрисовка. На этот каркас потом ложится что угодно, от аркады до головоломки.
Почему именно canvas и чистый JavaScript, без Phaser и без сборки. Во-первых, у платформы жёсткие требования к архиву: ZIP, в корне ровно один index.html, содержимое в распакованном виде не больше 100 МБ, а в именах файлов и папок — ни пробелов, ни кириллицы (требования 1.21 и 1.22). Два файла без сборки укладываются в это по определению. Во-вторых, пока вы не понимаете, из чего состоит игровой цикл, любой движок будет чёрным ящиком, а отлаживать чёрный ящик под чужие требования — худшее из возможных занятий.
Структура
lovets/
index.html
game.jsindex.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 года. Формулировки требований со временем меняются — перед отправкой на модерацию сверяйтесь с актуальной версией.