Большая часть трафика Яндекс Игр — это телефоны, и требования платформы написаны так, что мобильная версия проверяется отдельно и придирчиво. При этом почти все отказы по мобильной части не про геймплей, а про три-четыре строчки CSS, которые никто не написал: страница скроллится, при долгом нажатии выскакивает контекстное меню браузера, интерфейс размазан по краю экрана под «чёлкой», canvas мылит на ретине.
В этой главе — полный набор того, что нужно сделать, чтобы игра вела себя на телефоне как приложение, а не как веб-страница. Все пункты требований приведены с номерами: их удобно давать Claude Code как формальный чек-лист.
Что именно проверяет модерация
Мобильная адаптация раскидана по нескольким разделам требований. Ниже — те, что относятся к теме этой главы (сверено с официальными требованиями в сентябре 2026 года).
| Пункт | Что требует |
|---|---|
| 1.6.1.1 | Игра находится в полноэкранном режиме во время игрового процесса или запуска |
| 1.6.1.2 | При нажатии на поле ввода автоматически показывается клавиатура |
| 1.6.1.5 | Игра полностью управляется жестами (акселерометр — только дополнение) |
| 1.6.1.7 | Отсутствует уведомление о WebGL при открытии игры |
| 1.6.1.8 | Лонгтап по игровому полю не приводит к выделению или открытию контекстного меню |
| 1.6.2.7 | То же для десктопа: взаимодействие с полем не выделяет его и не открывает контекстное меню |
| 1.8 | Интерактивные элементы адаптированы под размер экрана и удобны для взаимодействия |
| 1.9 | После смены ориентации экрана прогресс не теряется |
| 1.10.1 | Элементы не обрезаются и не выходят за рамки экрана |
| 1.10.2 | Отсутствует браузерная прокрутка страницы и swipe-to-refresh (собственная прокрутка средствами игры допустима) |
| 1.10.3 | Элементы и тексты не накладываются друг на друга |
| 1.10.4 | Игрой можно управлять одной рукой; главный экран не требует прокрутки и смахиваний |
| 4.3 | Все рекламные блоки имеют ориентацию, соответствующую ориентации игры |
Отдельно стоит знать методику проверки из документации к пункту 1.10: модерация меняет размер окна и масштаб браузера (примерно от 80% до 125%), проверяет обе ориентации и набор популярных разрешений вплоть до 4K. То есть «у меня на моём телефоне всё нормально» — не аргумент; проверять надо диапазон.
Определяем устройство: ysdk.deviceInfo
SDK отдаёт тип устройства сам — парсить navigator.userAgent не нужно.
const ysdk = await YaGames.init();
console.log(ysdk.deviceInfo.type); // "desktop" | "mobile" | "tablet" | "tv"Кроме поля type есть четыре метода-предиката, каждый возвращает boolean:
| Метод | true, когда |
|---|---|
ysdk.deviceInfo.isDesktop() | компьютер |
ysdk.deviceInfo.isMobile() | мобильное устройство |
ysdk.deviceInfo.isTablet() | планшет |
ysdk.deviceInfo.isTV() | телевизор |
Важная архитектурная деталь: deviceInfo доступен только после await YaGames.init(), а первый кадр загрузчика вы рисуете раньше. Поэтому разделите ответственность:
- раскладка интерфейса — на CSS-медиазапросах и на текущих размерах вьюпорта. Она должна работать вообще без SDK;
- модель ввода — на Pointer Events, которые одинаково обрабатывают мышь и палец, так что ветвиться по устройству чаще всего не нужно;
deviceInfo— для решений, которые нельзя вывести из размеров: показать ли виртуальный джойстик, писать ли «нажмите» вместо «кликните», включать ли подсказки по клавиатуре, отправлять ли событие в аналитику.
Если решение нужно до инициализации SDK, есть синхронный запасной вариант — медиазапрос по типу указателя:
const isCoarsePointer = window.matchMedia('(pointer: coarse)').matches;Про телевизоры: isTV() существует не просто так — у ТВ нет ни мыши, ни тача, управление идёт стрелками пульта и кнопкой «ОК», и для этой платформы есть свой набор требований (пункт 1.6.3). Если вы не собираетесь поддерживать ТВ, просто не отмечайте эту платформу в черновике.
Ориентация экрана
Ориентация выбирается не в коде, а в черновике игры в Консоли разработчика, для мобильной платформы. Вариантов три: Портретная, Альбомная, Любая.
Если выбрана «Портретная» или «Альбомная», то в неподдерживаемой ориентации пользователь увидит встроенную заглушку от SDK с просьбой повернуть экран. Вам не нужно её рисовать самим.
Частая ошибка
Собственный экран «поверните телефон» поверх заглушки SDK. Пользователь увидит две таблички сразу — это выглядит как баг и попадает под пункт 1.10.3 (элементы накладываются друг на друга). Выбрали фиксированную ориентацию в Консоли — доверьте заглушку платформе.
Если выбираете «Любую», обе ориентации должны быть действительно рабочими: модерация проверит и портрет, и альбом. И помните про пункт 1.9 — при повороте экрана прогресс не теряется. Это не абстракция: смена ориентации у многих движков триггерит пересоздание контекста, поэтому сохраняйте состояние по факту действия игрока, а не по таймеру.
Про screen.orientation.lock() в коде игры лучше забыть: игра открывается внутри страницы платформы, и блокировка ориентации из вложенного документа в большинстве браузеров не работает. Настройка в Консоли — единственный надёжный способ.
Каркас страницы: viewport, прокрутка, масштабирование
Это тот самый блок, из-за которого чаще всего прилетает 1.10.2. Начинаем с <head>:
<meta name="viewport"
content="width=device-width, height=device-height, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">viewport-fit=cover здесь обязателен — без него env(safe-area-inset-*) в CSS всегда вернёт ноль, и раздел про safe area ниже не заработает.
Теперь CSS. Задача — сделать страницу нескроллируемой в принципе:
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
overscroll-behavior: none; /* убирает pull-to-refresh и цепную прокрутку */
background: #000;
}
body {
position: fixed; /* страховка для старых Safari, где overscroll-behavior нет */
inset: 0;
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none; /* iOS: не показывать меню по лонгтапу */
-webkit-tap-highlight-color: transparent;
}
#game { /* игровое поле / canvas */
display: block;
width: 100%;
height: 100%;
touch-action: none; /* браузер не перехватывает свайпы и пинч на канвасе */
}
button, .ui-tap {
touch-action: manipulation; /* убирает задержку ~300 мс от double-tap-to-zoom */
}Три свойства делают основную работу:
overscroll-behavior: noneнаhtml/body— гасит swipe-to-refresh и «резинку»;overflow: hidden+position: fixed— убирают саму возможность прокрутки, включая старые версии Safari, гдеoverscroll-behaviorне поддерживается;touch-action: noneна игровом поле — отдаёт все жесты вам, а не браузеру.
Осторожно
Не вешайте touch-action: none на body целиком. Собственная прокрутка внутри игры разрешена (это прямо сказано в пункте 1.10.2), но с глобальным touch-action: none перестанут скроллиться и ваши собственные списки — магазин, настройки, таблица рекордов. Ставьте none на канвас, а спискам оставляйте touch-action: pan-y.
Запрет масштабирования
Атрибут user-scalable=no в meta работает не везде: Safari на iOS сознательно игнорирует его начиная с iOS 10, потому что считает запрет зума проблемой доступности. Формально пункт 1.10.2 говорит про прокрутку и swipe-to-refresh, а не про зум, — но отмасштабированная страница немедленно начинает скроллиться, и вы получаете тот же отказ.
Что действительно работает на iOS — перехват жеста двумя пальцами:
['gesturestart', 'gesturechange', 'gestureend'].forEach((type) => {
document.addEventListener(type, (e) => e.preventDefault(), { passive: false });
});Плюс touch-action: none на канвасе, который убирает пинч именно там, где играют. Этой пары достаточно.
Контекстное меню и выделение по лонгтапу
Пункты 1.6.1.8 и 1.6.2.7 — в собственном списке частых причин отказа от Яндекса. Проверяется элементарно: модератор жмёт правой кнопкой на игровое поле или держит палец секунду. CSS выше (user-select: none, -webkit-touch-callout: none) закрывает выделение, но меню нужно погасить и в JS:
document.addEventListener('contextmenu', (e) => {
// поля ввода оставляем в покое: там меню «вставить» нужно пользователю
if (e.target.closest('input, textarea, [contenteditable]')) return;
e.preventDefault();
});Важно
Проверьте не только канвас, но и всё, что лежит поверх него: логотип, аватар игрока, иконки, HTML-кнопки. Долгое нажатие на <img> в мобильном Chrome открывает меню «Скачать изображение» — это ровно тот случай, из-за которого отклоняют по 1.6.1.8. Глобальный обработчик на document закрывает все элементы разом, поэтому вешайте его именно на документ, а не на канвас.
Safe area: «чёлки», вырезы и жестовая полоса
На современных телефонах часть экрана занята камерой сверху и полосой навигации снизу. Если прибить счёт очков к top: 0, на части устройств он уедет под вырез — а это пункт 1.10.1 (элементы обрезаются).
:root {
--safe-top: env(safe-area-inset-top, 0px);
--safe-right: env(safe-area-inset-right, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
}
.hud {
position: absolute;
top: calc(12px + var(--safe-top));
left: calc(12px + var(--safe-left));
right: calc(12px + var(--safe-right));
}
.hud-bottom {
bottom: calc(12px + var(--safe-bottom));
}Значения по умолчанию 0px во втором аргументе env() обязательны: на устройствах и в браузерах без выреза переменная иначе окажется пустой и calc() сломается целиком.
Если интерфейс рисуется внутри канваса, а не HTML-слоем, прочитайте отступы через getComputedStyle и прибавьте их к своим координатам:
const cs = getComputedStyle(document.documentElement);
const safeTop = parseFloat(cs.getPropertyValue('--safe-top')) || 0;Canvas и devicePixelRatio
Классическая ошибка: канвас растянут на весь экран через CSS, а его атрибуты width/height остались 800×600. Браузер растягивает картинку — на телефоне получается мыло, особенно на тексте.
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const MAX_DPR = 2; // потолок: на dpr=3 нагрузка растёт ещё в 2,25 раза
function resizeCanvas() {
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
const cssW = canvas.clientWidth;
const cssH = canvas.clientHeight;
const pxW = Math.round(cssW * dpr);
const pxH = Math.round(cssH * dpr);
if (canvas.width === pxW && canvas.height === pxH) return; // ничего не изменилось
canvas.width = pxW;
canvas.height = pxH;
// после смены размера контекст сбрасывается — трансформацию ставим заново
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
layout(cssW, cssH); // пересчёт позиций интерфейса в CSS-пикселях
}
// На iOS размеры сразу после поворота бывают устаревшими — ждём два кадра
function scheduleResize() {
requestAnimationFrame(() => requestAnimationFrame(resizeCanvas));
}
window.addEventListener('resize', scheduleResize);
window.addEventListener('orientationchange', scheduleResize);
resizeCanvas();После setTransform(dpr, 0, 0, dpr, 0, 0) вся отрисовка идёт в CSS-пикселях: логика игры не знает про плотность экрана, а картинка остаётся чёткой.
Потолок MAX_DPR — не каприз. На телефоне с devicePixelRatio = 3 вы рисуете в девять раз больше пикселей, чем при dpr 1, и просадка кадров приводит к отказу по пункту 1.15 (фризы и падения производительности). Начните с 2, а для тяжёлых сцен снижайте до 1.5 — на глаз разница почти незаметна, а нагрузка падает вдвое.
Для пиксель-арта логика другая: там нужен целочисленный масштаб и image-rendering: pixelated, иначе интерполяция размоет спрайты.
Тач-управление
Один код на мышь и палец
Не пишите отдельные ветки на mousedown и touchstart — Pointer Events покрывают мышь, палец и стилус одним набором событий:
const rect = () => canvas.getBoundingClientRect();
function toGame(e) {
const r = rect();
return { x: e.clientX - r.left, y: e.clientY - r.top }; // координаты в CSS-пикселях
}
canvas.addEventListener('pointerdown', (e) => {
canvas.setPointerCapture(e.pointerId); // палец не «потеряется» за краем канваса
onPressStart(e.pointerId, toGame(e));
});
canvas.addEventListener('pointermove', (e) => onPressMove(e.pointerId, toGame(e)));
canvas.addEventListener('pointerup', (e) => onPressEnd(e.pointerId));
canvas.addEventListener('pointercancel', (e) => onPressEnd(e.pointerId));Совет
pointercancel обрабатывать обязательно. Браузер отменяет указатель, когда система перехватывает жест: входящий звонок, свайп системной панели, переключение вкладки. Если вы слушаете только pointerup, персонаж останется бежать вправо навсегда — и это будет выглядеть как зависание игры.
pointerId нужен для мультитача: на телефоне игрок легко ставит два пальца одновременно (джойстик слева, кнопка удара справа). Храните активные указатели в Map, а не в одной переменной.
Помните, что все привычные клавиатурные механики на телефоне недоступны: пункт 1.6.1.5 требует, чтобы игра полностью управлялась жестами. Если у вас платформер на стрелках — на мобильной платформе нужен экранный джойстик или тап-управление, а не подсказка «подключите клавиатуру».
Размер элементов под палец
Пункт 1.8 говорит про «удобны для взаимодействия», но конкретного числа не называет. Отраслевые ориентиры такие: Apple рекомендует минимум 44×44 pt, Google Material — 48×48 dp. Практический вывод для игры: кнопка не меньше 44 CSS-пикселей по каждой стороне, зазор между соседними кнопками не меньше 8 пикселей. Визуальный элемент может быть меньше — увеличивайте невидимую область нажатия через padding или отдельный прозрачный прямоугольник в канвасе.
И не забывайте про пункт 1.10.4: игрой можно управлять одной рукой, а главный экран не требует прокрутки и смахиваний. Кнопки, которые нужны в бою, должны попадать в нижнюю треть экрана — там, где достаёт большой палец.
Ввод текста
Если игрок вводит никнейм, используйте настоящий HTML-элемент <input>. Нарисованное в канвасе «поле ввода» не поднимает системную клавиатуру, а это прямое нарушение пункта 1.6.1.2. Когда клавиатура открывается, вьюпорт на мобильных устройствах сжимается — отслеживайте это через window.visualViewport, чтобы поле не оказалось под клавиатурой:
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => {
document.documentElement.style.setProperty(
'--kb-offset', `${window.innerHeight - window.visualViewport.height}px`
);
});
}Полноэкранный режим
Пункт 1.6.1.1 требует, чтобы на мобильных устройствах игра была в полноэкранном режиме во время запуска или игрового процесса. В SDK для этого есть объект ysdk.screen.fullscreen со свойствами status, константами STATUS_ON ("on") и STATUS_OFF ("off") и промисами request и exit.
Документация при этом прямо предупреждает: в правом верхнем углу Яндекс Игр уже есть собственная кнопка перехода в полноэкранный режим, а многие браузеры вообще запрещают переключать режим без действия пользователя. Поэтому screen.fullscreen используйте только для собственной кнопки внутри игры — вызывать request автоматически при старте бессмысленно, браузер такой запрос отклонит.
Чек-лист перед отправкой
- Страница не скроллится ни в одном направлении, pull-to-refresh не срабатывает.
- Пинч и двойной тап не масштабируют игру.
- Долгое нажатие на канвас, на любую картинку и на любую кнопку не открывает контекстное меню и ничего не выделяет.
- Интерфейс не заезжает под вырез камеры и под жестовую полосу.
- Канвас чёткий:
canvas.widthравенclientWidth × dpr, а не константе из кода. - Поворот экрана: раскладка перестроилась, прогресс на месте (пункт 1.9).
- Проверено в обеих ориентациях, если в черновике выбрана «Любая».
- Все игровые действия доступны жестами, без клавиатуры.
- Кнопки не мельче ~44 CSS-пикселей, между ними есть зазор.
- Поле ввода никнейма — настоящий
<input>, клавиатура поднимается по тапу. - Масштаб браузера 80% и 125% на десктопе не ломает раскладку.
- В консоли нет ошибок после серии поворотов и изменений размера окна.
Отдельно проверьте игру на реальном телефоне через режим черновика, а не только в эмуляции DevTools: devicePixelRatio, safe area, поведение системной клавиатуры и pull-to-refresh в эмуляторе воспроизводятся неточно.
Что дальше
Мобильная адаптация закрывает то, как игра выглядит и управляется. Следующий большой блок требований — как она звучит и как ведёт себя при потере фокуса: звук обязан замолкать при переключении вкладки и на время рекламы, а игровой цикл — вставать на паузу. Этим занимаются события game_api_pause и game_api_resume вместе с разметкой геймплея, и разберём их в следующей главе.