Первое обновление после релиза всегда выходит быстрее, чем стоило бы. Нашлась опечатка, баг на конкретном телефоне, слишком сложный третий уровень — хочется поправить сегодня. Механика обновления в Яндекс Играх этому не мешает: черновик создаётся в два клика, а пока идёт проверка, в каталоге остаётся предыдущая версия.
Опасность в другом. У ваших игроков уже есть прогресс, и он лежит не у них на диске, а в облаке платформы — в формате, который придумали вы месяц назад. Любое изменение структуры данных, сделанное без миграции, стирает этот прогресс. Не «может стереть» — стирает, у всех сразу, молча. Об этом основная часть главы.
Как выпустить обновление
Порядок из документации:
- Проверить, соответствует ли игра требованиям (они могли измениться с прошлой проверки).
- Консоль → Игры → нужная игра.
- Открыть черновик: если вы редактируете игру впервые, нажать Создать черновик; если черновик есть, он откроется сам; если у игры несколько неопубликованных версий — выбрать одну из них.
- Отредактировать поля и загрузить новый архив.
- Нажать Отправить на модерацию — черновик перейдёт в статус «Ожидает модерации».
Поле Версия заполняется вами (по умолчанию первая версия называется 0.0.0.1). Платформа его не проверяет и ни на что не влияет — это ваша внутренняя маркировка. Заводите привычку сразу: через год «версия от 14 марта» ничего вам не скажет, а 1.4.0 — миграция сохранений на v3 скажет всё.
После того как статус сменится на «Опубликовано», новая версия появится в каталоге, черновик исчезнет из дерева игры, а на вкладке «Черновик» снова появится кнопка Создать черновик.
Важно
Во время проверки в каталоге остаётся ранее загруженная версия. Игроки не видят «технических работ» и не теряют доступ к игре — обновление подменяет билд только в момент публикации. Поэтому отправлять сырую сборку «пусть пока проверяют» безопасно с точки зрения игроков, но опасно с точки зрения кулдауна: отказ по обновлению так же удваивает время ожидания.
Какая модерация нужна
Правило одно и зависит только от того, трогали ли вы файлы игры.
| Что изменилось | Вид модерации | Срок |
|---|---|---|
| Файлы игры (архив) | Полная — тестируют билд | 3–5 рабочих дней |
| Только промоматериалы: иконка, обложка, скриншоты, тексты | Контентная — билд не тестируют | 1–2 рабочих дня |
Отсюда практический вывод: не смешивайте правки промо с правками кода, если правка промо срочная. Замена одной обложки пройдёт за пару дней, а тот же черновик с новым архивом — за неделю.
Два ограничения, которые важны именно для обновлений:
- Лимит в две одновременные заявки на публикацию новых игр на обновления не распространяется. Вы можете обновлять опубликованные игры сколько угодно, даже когда две новые игры уже стоят в очереди.
- Единовременно у одной игры может проходить только одна модерация — черновика, A/B-теста креативов или A/B-теста версий. Пока идёт проверка черновика, остальные типы недоступны.
Миграция сохранений
Это главный технический риск обновления, и он не имеет отношения к платформе: платформа честно хранит то, что вы записали. Проблема в том, что старый объект прилетит из getData() в новый код.
Почему без миграции прогресс исчезает
Типичный сценарий. В первой версии сохранение выглядело так:
{ level: 12, money: 340, sound: true }Во второй вы переименовали money в coins, добавили инвентарь и разложили настройки в отдельный объект. Новый код читает save.coins, получает undefined, подставляет значение по умолчанию — и записывает поверх. Игрок, накопивший 340 монет за неделю, открывает игру и видит ноль. Восстановить нечего: старое значение уже перезаписано.
Ошибка здесь не в переименовании поля, а в отсутствии слоя, который приводит любые старые данные к текущему формату до того, как игра к ним прикоснётся.
Версия внутри данных и цепочка миграций
Схема, которая работает и не требует ничего, кроме дисциплины: номер версии хранится внутри самого сохранения, а миграции применяются последовательно.
// migrations.js
const CURRENT_VERSION = 4;
const MIGRATIONS = {
// 1 → 2: у первой версии номера не было вовсе.
1: (s) => {
s.coins = s.money ?? 0;
delete s.money;
return s;
},
// 2 → 3: настройки собраны в объект.
2: (s) => {
s.settings = { sound: s.sound !== false, music: s.music !== false };
delete s.sound;
delete s.music;
return s;
},
// 3 → 4: инвентарь стал массивом идентификаторов.
3: (s) => {
s.inventory = Array.isArray(s.items) ? s.items.map((i) => i.id) : [];
delete s.items;
return s;
},
};
export function migrate(raw) {
if (!raw || typeof raw !== 'object') return createDefaultSave();
let s = JSON.parse(JSON.stringify(raw)); // не мутируем оригинал
let v = Number(s.version) || 1;
if (v > CURRENT_VERSION) {
// Данные из будущего: игрок успел поиграть в новую версию на другом
// устройстве, а тут открылась старая. Ничего не ломаем и не пишем.
console.warn('[save] версия из будущего:', v);
return { ...s, __readOnly: true };
}
while (v < CURRENT_VERSION) {
const step = MIGRATIONS[v];
if (!step) {
console.error('[save] нет миграции с версии', v);
return createDefaultSave();
}
s = step(s);
v += 1;
s.version = v;
}
return s;
}Применяется сразу после чтения и до любой игровой логики:
const raw = (await player.getData(['save'])).save;
const save = migrate(raw);Четыре свойства этого кода стоят внимания:
- Шаги атомарны и однонаправлены. Каждая функция переводит ровно на одну версию вперёд. Добавить пятую версию — значит дописать
4: (s) => ...и поднять константу, не трогая остальное. - Миграция идемпотентна. Повторный прогон уже мигрированных данных ничего не изменит, потому что
versionуже равен текущей. - Версия из будущего не ломает игру. Это реальный сценарий: игрок открыл обновлённую версию на телефоне, а на десктопе у него закешировалась старая. Помечаем данные как read-only и не перезаписываем — иначе старая сборка затрёт новый прогресс.
- Отсутствие миграции — явная ошибка, а не тихий сброс. Лучше увидеть строку в консоли на тесте, чем узнать о проблеме из отзывов.
Правила, которые экономят нервы
Не удаляйте поля в том же обновлении, где перестали их использовать. Оставьте их лежать одну-две версии. Если обновление придётся откатывать, старая сборка найдёт свои данные на месте.
Сделайте резервную копию перед первой записью нового формата. Одно поле в сохранении, один раз:
if (!save.backupV3 && rawVersionWas(3)) {
save.backupV3 = raw; // сырой снимок старых данных
await player.setData({ save }, true); // flush: true — пишем сразу
}Помните про лимит: максимальный размер данных на игрока — 200 КБ. Бэкап должен быть маленьким, и держать его вечно не нужно — удалите в следующем обновлении.
Не переносите миграцию на сервер и не завязывайте на сеть. Она должна отработать на том, что вернул getData(), даже если это undefined.
Проверяйте миграцию на реальных старых данных. Порядок такой:
- Открыть игру в режиме черновика на старой версии, наиграть прогресс.
- Загрузить новый архив в черновик.
- Открыть черновик снова тем же аккаунтом — старое сохранение в облаке никуда не делось, миграция отработает на настоящих данных.
- Отдельно проверить чистый старт: debug-панель → SDK mocks ⚒️ → Clear cloud data, игра запускается как в первый раз.
Осторожно
Тестировать миграцию на объекте, который вы сами написали руками, недостаточно. Реальные сохранения бывают неполными: игрок вышел в момент записи, часть полей не успела дописаться, у кого-то лежат данные версии, которую вы уже забыли. Пишите миграции так, будто на входе может быть что угодно, — с ??, Array.isArray() и проверками типов.
Что ещё меняется вместе с форматом
Помимо setData, есть setStats — числовые данные с лимитом 10 КБ. Их формат тоже версионируется, но проще: ключи независимы, и появление нового ключа никого не ломает. Переименование — ломает. Если переименовываете счётчик, прочитайте старый ключ, запишите новый и оставьте старый на месте до следующего обновления.
Флаги вместо обновлений
Не всякое изменение требует новой сборки. Через ysdk.getFlags() игра может получить удалённую конфигурацию — плоский объект «ключ — значение», заданный в Консоли. Документация рекомендует запрашивать флаги один раз на старте.
const flags = await ysdk.getFlags({
defaultFlags: { newShop: 'false', dailyBonus: '10' },
});
if (flags.newShop === 'true') openNewShop();defaultFlags — локальная конфигурация на случай, если удалённая недоступна. Практическая польза для обновлений: спорную фичу можно выпустить выключенной и включить позже, без прохождения модерации заново. И выключить, если она себя не оправдала.
Если обновление сломало игру
Кнопки «откатить на предыдущую версию» в Консоли нет — это надо знать заранее, до того, как она понадобится. Варианты в порядке предпочтения:
- Выключить проблемную фичу флагом, если она была под флагом. Мгновенно и без модерации — ради этого флаги и нужны.
- Загрузить предыдущий архив как новое обновление. Работает всегда, но это полная модерация: 3–5 рабочих дней, в течение которых сломанная версия остаётся в каталоге.
- Снять игру с публикации. Крайняя мера: вкладка «Опубликовано» → Удалить. Последняя удалённая версия попадёт на вкладку «Черновик», и вернуть игру можно повторной отправкой на модерацию. Пока игра снята, трафика нет вовсе, а метрики проседают.
Именно поэтому обновления, затрагивающие формат данных, стоит выпускать отдельно от всего остального — небольшим изменением, которое легко проверить и не страшно откатывать.
Совет
Для крупных изменений в геймплее есть A/B-тесты версий: вы загружаете экспериментальную сборку, задаёте процент аудитории и продолжительность, а по окончании сравниваете метрики с основной версией. Запустить эксперимент можно только у уже опубликованной игры, и параллельно с ним нельзя публиковать новые версии — иначе результаты будут недостоверными. Это способ не гадать, стало ли лучше.
Что дальше
Обновления имеет смысл выпускать, опираясь на данные, а не на ощущения. Значит, данные нужно уметь читать: какие метрики Консоль считает сама, что означают показы, сессии и удержание, когда цифра — сигнал, а когда шум, как подключить внешнюю аналитику с учётом ограничений CSP и как при этом не собирать лишнего о своих игроках. Об этом — следующая глава.