«Игра зависает или тормозит» — отдельная строка в официальной таблице частых причин отказа, с пояснением «заметные фризы или падение производительности делают игру некомфортной для пользователя». Формулировка пункта 1.14 ещё прямее: игра работает без технических ошибок, зависаний и вылетов как в обычном геймплее, так и при повороте экрана, длительном нажатии, жестах, сворачивании браузера и показе рекламы.
Проблема в том, что «тормозит» — не бинарное состояние. На вашем ноутбуке игра идёт идеально, а модератор открывает её на телефоне трёхлетней давности через мобильный интернет. Единственный способ не гадать — измерять. Эта глава про то, чем и что именно.
Бюджет кадра
При 60 кадрах в секунду на кадр приходится 16,7 мс. За это время браузер обязан успеть всё: ваш update(), ваш draw(), композитинг, обработку ввода, сборку мусора. Реально в распоряжении игрового кода — 10–12 мс, остальное съест сам браузер.
Из этого следуют два практических вывода. Первый: 8 мс на кадр на вашей машине — это не «в два раза быстрее нужного», это «на слабом телефоне будет 25 мс и просадка». Второй: важна не средняя частота кадров, а худшая. Стабильные 45 fps ощущаются лучше, чем 60 fps с провалом до 12 fps раз в две секунды — а именно такой провал и есть «фриз», который увидит модератор.
Простейший счётчик, который стоит держать в игре с самого начала:
// perf.js
let last = performance.now();
let worst = 0;
let frames = 0;
let acc = 0;
export function tickPerf(now) {
const dt = now - last;
last = now;
frames += 1;
acc += dt;
if (dt > worst) worst = dt;
if (acc >= 1000) {
console.log(
'fps', Math.round(frames * 1000 / acc),
'| худший кадр', worst.toFixed(1), 'мс'
);
frames = 0; acc = 0; worst = 0;
}
}Вызывайте tickPerf(now) первой строкой игрового цикла и выключайте вывод в релизной сборке (лишние логи в консоли модерация тоже видит). «Худший кадр» за секунду — самая информативная цифра из всех: пока она держится ниже 30 мс, игра ощущается плавной.
Профилирование во вкладке Performance
Платформа отправляет во вкладку Performance две собственные метки, и это документированная возможность, а не хак:
| Метка | Что означает |
|---|---|
| Game Ready | Момент вызова ysdk.features.LoadingAPI.ready() — игра загрузила ресурсы и готова к взаимодействию |
| Time To Interactive (TTI) | Момент, когда завершились все длительные задачи; учитывает загруженность браузера при инициализации |
Порядок работы:
- Запустить игру любым удобным способом — локально через
sdk-dev-proxyили в режиме черновика. - Открыть DevTools (F12 или Ctrl + Shift + I, на macOS Cmd + Opt + I).
- Перейти на вкладку Performance.
- Нажать Record and reload и дождаться сборки профиля.
- В строке Timings появятся жёлтые метки Game Ready и Time To Interactive.
Дальше смотрите, что происходит до метки Game Ready: длинные жёлтые полосы — это ваш JS, фиолетовые — пересчёт стилей и разметки, зелёные — отрисовка. Задачи длиннее 50 мс браузер помечает как long tasks; на старте они неизбежны, в геймплее их быть не должно.
Важно
На debug-панели у Game Ready есть таймаут в 90 секунд. Если LoadingAPI.ready() не вызван за это время, индикатор становится красным и считается, что Game Ready в игре не используется — это отказ по пункту 1.19.2. Медленная загрузка перестаёт быть просто неудобством и становится нарушением.
Профилируйте с включённым троттлингом CPU (в Performance — выпадающий список CPU: 4× slowdown) и сети. Это ближайшее, что есть у вас к «среднему телефону», пока вы не открыли игру на реальном телефоне через режим черновика.
Пять причин просадок в 2D-играх
Список короткий, потому что в 2D на canvas почти всегда виноват один из пяти пунктов.
1. Перерисовка того, что не изменилось. Полная очистка канваса и отрисовка всей сцены каждый кадр — нормально для динамичной игры и расточительно для головоломки, где меняются две клетки. Статичный фон выносится в отдельный слой (второй <canvas> под игровым) и не перерисовывается вовсе.
2. Создание объектов в игровом цикле. Каждый {x, y}, каждый new Vector(), каждый array.map() внутри update() — это мусор, который сборщик потом соберёт одним рывком. Именно так выглядит «фриз раз в несколько секунд».
// Плохо: 500 новых объектов и новый массив каждый кадр.
function update() {
particles = particles
.map((p) => ({ x: p.x + p.vx, y: p.y + p.vy, life: p.life - 1 }))
.filter((p) => p.life > 0);
}
// Хорошо: мутируем на месте, идём с конца, ничего не аллоцируем.
function update() {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vx;
p.y += p.vy;
p.life -= 1;
if (p.life <= 0) {
particles[i] = particles[particles.length - 1];
particles.pop();
}
}
}3. Отсутствие пула объектов. Пули, частицы, всплывающие цифры урона создаются и выбрасываются десятками в секунду. Пул решает это в двадцать строк:
// pool.js
export function createPool(factory, reset, size) {
const free = [];
for (let i = 0; i < size; i++) free.push(factory());
return {
take() {
return free.length ? free.pop() : factory();
},
put(obj) {
reset(obj);
free.push(obj);
},
};
}
const bullets = createPool(
() => ({ x: 0, y: 0, vx: 0, vy: 0, alive: false }),
(b) => { b.alive = false; },
200
);4. Крупные PNG. Фон 4096 × 4096 в PNG — это 10 МБ трафика и, что важнее, около 64 МБ видеопамяти после распаковки, независимо от размера файла. На бюджетном телефоне это гарантированные подтормаживания или падение вкладки. Держите текстуры в размере, в котором они реально рисуются, и складывайте мелкую графику в атласы: один drawImage из атласа дешевле, чем десять из десяти файлов.
5. Тяжёлые эффекты canvas. shadowBlur, filter, globalCompositeOperation и рисование текста каждый кадр стоят дорого. Текст, который меняется редко (название уровня, имя игрока), рисуйте один раз в offscreen-canvas и копируйте готовую картинку.
Совет
Прежде чем оптимизировать, снимите профиль и найдите самую длинную полосу. В 2D-играх интуиция ошибается регулярно: «тормозит физика» на профиле оказывается перерисовкой фона, а «тормозит отрисовка» — сборкой мусора от строк, которые вы клеите в HUD.
Ассеты: что реально уменьшает время до Game Ready
Ограничение на архив — 100 МБ до сжатия, и упереться в него сложно. Гораздо важнее другое: показатель Конверсия в игровую сессию в Консоли считает долю сессий длиннее 60 секунд, и документация прямо советует для его улучшения сокращать размер ресурсов на стадии загрузки. Игрок, который ждёт 15 секунд на пустом экране, уходит до того, как увидит игру.
Что даёт эффект:
- Разделить загрузку на обязательную и отложенную. До Game Ready грузите только то, что нужно для первого экрана и первого уровня. Музыку следующих уровней и графику магазина — после.
- Сжать PNG.
pngquant/oxipngдают минус 50–70 % без видимой разницы. Фотографические фоны — в JPEG. - Осторожно с WebP и AVIF. Требование 1.20.3 обязывает игру работать на iOS 9.0+, а поддержка этих форматов появилась в мобильных браузерах гораздо позже. Без запасного PNG/JPEG вы рискуете получить пустые текстуры у части аудитории.
- Собрать спрайты в атласы. Меньше HTTP-запросов, меньше переключений текстуры при отрисовке.
- Не тянуть весь движок ради двух функций. Библиотека на 300 КБ ради твинов — это лишняя секунда на медленной сети.
Звук — отдельная статья расходов. Длинные WAV в архиве встречаются до сих пор: конвертируйте музыку в MP3 или OGG с битрейтом 96–128 кбит/с, короткие эффекты — в один спрайт-файл со смещениями. Декодирование звука тоже стоит времени: если игра запускает decodeAudioData для сорока файлов на старте, Game Ready сдвинется на секунды.
Требование 1.3: звук обязан замолкать
Это не про производительность, но живёт в том же коде и стоит в списке частых причин отказа: «Звук продолжается при переключении вкладки».
Методика проверки описана буквально. Модерация смотрит, останавливается ли звук:
- при сворачивании окна браузера на компьютере или приложения на телефоне;
- после переключения на другую вкладку в том же окне;
- в браузерном меню выбора вкладок.
Не считается нарушением: задержка до 2 секунд, продолжение звука после клика по рекламному баннеру и перехода на вкладку с предложением, а также звук в меню выбора вкладок на iOS.
Рабочая связка — событие браузера плюс события платформы:
// audio-focus.js
function suspendAll() {
if (audioCtx.state === 'running') audioCtx.suspend();
bgm.pause();
}
function resumeAll() {
if (audioCtx.state === 'suspended') audioCtx.resume();
if (!isPausedByGame) bgm.play().catch(() => {});
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') suspendAll();
else resumeAll();
});
window.addEventListener('blur', suspendAll);
window.addEventListener('focus', resumeAll);
// Реклама, окно покупок, сворачивание — приходят от платформы.
ysdk.on('game_api_pause', suspendAll);
ysdk.on('game_api_resume', resumeAll);visibilitychange покрывает переключение вкладки, blur/focus — сворачивание окна и переход в другое приложение, game_api_pause — рекламу и системные окна платформы. Проверить всё это без реального устройства помогает debug-панель: кнопка с глазом снимает фокус с игры и возвращает его обратно.
Осторожно
Останавливать нужно и игровой цикл, а не только звук. Игра, продолжающая симуляцию в фоновой вкладке, к возвращению игрока успевает «проиграть» за него минуту, а браузер в это время душит requestAnimationFrame — после возврата вы получите гигантский deltaTime и телепортацию персонажа сквозь стены. Ограничивайте шаг симуляции: dt = Math.min(dt, 50).
Чек-лист перед отправкой
- Худший кадр в геймплее — ниже 30 мс на среднем устройстве, не только на вашем.
- Профиль снят с включённым CPU 4× slowdown, метка Game Ready укладывается в несколько секунд.
- В
update()не создаются объекты и массивы; частицы и снаряды идут через пул. - Ни одна текстура не крупнее, чем нужно для реальной отрисовки; графика собрана в атласы.
- Звук останавливается по трём сценариям из методики проверки пункта 1.3 и по
game_api_pause. - Игра проверена на реальном телефоне через режим черновика, а не только в эмуляции DevTools.
- В консоли нет ошибок и предупреждений после десяти минут игры.
Что дальше
Техническая часть на этом заканчивается: код написан, оптимизирован и проверен. Дальше начинается работа с Консолью разработчика — то, что увидит не движок, а живой человек. Следующая глава про страницу игры: название с его запретами, описание, жанр, теги, возрастная категория, платформы и вкладка CSP для внешних хостов.