Модератор проверяет сохранения самым простым способом: открывает игру, проходит уровень и нажимает F5. Если прогресс не вернулся — это отказ по пункту 1.9, и он входит в собственный список частых причин отказа от Яндекса. Хорошая новость в том, что SDK закрывает эту задачу двумя группами методов, и написать надёжный модуль сохранений — работа на один вечер. Плохая — у методов есть лимиты по объёму и по частоте, и наивная реализация «сохраняем при каждом изменении» упирается в них в первый же час живого трафика.
Что требует платформа
Формулировка пункта 1.9: в играх с прогрессом изменения сохраняются сразу после действия игрока или по специальной кнопке, а после обновления страницы или смены ориентации экрана прогресс не теряется. Дополнительно:
- сохранение должно работать независимо от того, авторизован пользователь или нет (пункт 1.2.2 — гостевой вход или игра без авторизации с сохранением прогресса);
- если игра использует облачные сохранения, это нужно отметить галочкой в черновике (пункт 1.11);
- для игр с инап-покупками облачные сохранения обязательны: прогресс должен быть доступен одному пользователю с разных устройств (пункт 1.13.3).
Сохранение нужно не всем. Документация приводит понятный критерий:
| Сохранение обязательно | Сохранение необязательно |
|---|---|
| Новый уровень открывается только после прохождения предыдущего | Все уровни доступны сразу |
| Можно поставить рекорд, получить достижение, выиграть | Рекордов, достижений и выигрыша нет |
| Сложность растёт по мере прохождения | Сложность не зависит от прогресса |
| Уровень проходится за несколько игровых сессий | Любой уровень проходится за одну сессию |
Если у вас другая механика сохранения (например, ручное сохранение по кнопке в определённых точках), опишите её в комментарии разработчика при отправке на модерацию — иначе проверяющий будет считать, что сохранения просто сломаны.
Два хранилища SDK
| Что | Методы | Лимит объёма | Лимит частоты |
|---|---|---|---|
| Произвольные данные | player.setData(), player.getData() | 200 КБ на игрока | 100 запросов / 5 минут |
| Числовая статистика | player.setStats(), player.getStats(), player.incrementStats() | 10 КБ на игрока | 60 запросов / 1 минуту |
| Инициализация игрока | ysdk.getPlayer() | — | 20 запросов / 5 минут |
Значения лимитов приведены по официальной документации, проверено в сентябре 2026 года. Документация не уточняет, как именно измеряются 200 КБ и 10 КБ (до или после сериализации), поэтому закладывайте запас и не пытайтесь упаковать в сохранение всё состояние мира байт в байт. Если данных объективно больше — документация прямо отправляет вас на собственный сервер.
player.setData(data, flush)
function setData(data: object, flush: boolean) => Promise<void> {}data — объект пар «ключ — значение». flush определяет очередность отправки: true — данные уходят на сервер немедленно, false (значение по умолчанию) — запрос ставится в очередь. При flush: false возвращённый промис подтверждает только валидность данных, но player.getData() всё равно вернёт последнее записанное значение, даже если оно ещё не отправлено.
Частая ошибка
Метода player.flush() не существует. Если вы видите его в туториале или в сгенерированном коде — это выдумка: flush был и остаётся вторым аргументом setData(). Вызов player.flush() упадёт с TypeError уже на первом сохранении.
const ysdk = await YaGames.init();
const player = await ysdk.getPlayer();
// Обычное сохранение — запрос встанет в очередь.
await player.setData({ save: { level: 7, coins: 340 } });
// Критичный момент (покупка, конец уровня) — отправляем немедленно.
await player.setData({ save: { level: 8, coins: 0 } }, true);Держите весь прогресс в одном ключе (save) или в нескольких крупных — так проще версионировать формат и не плодить лишние записи.
player.getData(keys)
// Все данные игрока.
const all = await player.getData();
// Только нужные ключи.
const { save } = await player.getData(['save']);Если игрок запускает игру впервые, ключа в ответе не будет — читайте данные через значения по умолчанию, а не через прямой доступ к вложенным полям.
Статистика: setStats, incrementStats, getStats
Документация прямо рекомендует использовать статистику вместо setData() для часто изменяемых числовых значений: баллов, очков опыта, внутриигровой валюты. Это отдельное хранилище со своим лимитом и вдвое более щедрой частотой (60 запросов в минуту против 100 за 5 минут).
// Начисляем: передаётся дельта, а не итоговое число.
const updated = await player.incrementStats({ coins: 25, xp: 10 });
console.log(updated); // { coins: 365, xp: 120 } — изменённые пары
// Рекорд — это не приращение, его пишем абсолютным значением.
if (score > best) {
best = score;
await player.setStats({ bestScore: best });
}
// Чтение на старте.
const stats = await player.getStats(['coins', 'xp', 'bestScore']);
const coins = stats.coins ?? 0;Разница между incrementStats и setStats важнее, чем кажется: incrementStats отправляет приращение, поэтому не затирает значение, записанное из другой вкладки или с другого устройства, устаревшим локальным числом. Для валюты и счётчиков берите его.
Стратегия сохранения
Главная ошибка — вызывать setData() из игрового цикла. Лимит 100 запросов за 5 минут — это в среднем один запрос в 3 секунды; аркада, сохраняющая счёт при каждом подобранном предмете, выберет его за полминуты, и дальше запросы будут отклоняться с ошибкой.
Рабочая схема состоит из четырёх правил:
- Состояние живёт в памяти. Игра меняет обычный JS-объект, а не хранилище.
- Облачная запись происходит по событиям: конец уровня, покупка, новое достижение, открытие меню, уход со вкладки.
- Частые изменения склеиваются таймером с минимальным интервалом между облачными запросами.
- Локальная копия пишется всегда и синхронно — она спасает при обрыве сети и при быстром закрытии вкладки.
Осторожно
В таблице примеров к пункту 1.9 строка «Прогресс сохраняется периодически» стоит в графе «Неправильно». Троттлинг — это не автосейв по таймеру: значимые события (пройден уровень, получено достижение, совершена покупка) должны уходить на сервер сразу, а склейка нужна только для потока мелких изменений между ними.
Вот модуль, который можно положить в игру целиком. Он подключается обычным <script src="saves.js"></script> и не требует сборки.
// saves.js
const Saves = (() => {
const LOCAL_KEY = 'mygame:save:v1';
const CLOUD_KEY = 'save';
const DEBOUNCE_MS = 1000; // склеиваем мелкие изменения
const MIN_INTERVAL_MS = 5000; // не чаще одного облачного запроса в 5 с
let player = null;
let state = {};
let timer = null;
let dirty = false;
let lastWrite = 0;
async function init(ysdk, defaults) {
try {
player = await ysdk.getPlayer();
} catch (e) {
player = null; // без облака, но игра обязана работать
console.warn('getPlayer failed', e);
}
state = Object.assign({}, defaults, await load());
window.addEventListener('pagehide', () => saveNow());
window.addEventListener('orientationchange', () => saveNow());
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') saveNow();
});
return state;
}
async function load() {
if (player) {
try {
const data = await player.getData([CLOUD_KEY]);
if (data && data[CLOUD_KEY]) return data[CLOUD_KEY];
} catch (e) {
console.warn('getData failed', e);
}
}
try {
return JSON.parse(localStorage.getItem(LOCAL_KEY)) || {};
} catch (e) {
return {};
}
}
function get() {
return state;
}
// Обычное изменение: локально сразу, в облако — со склейкой.
function mark() {
dirty = true;
try {
localStorage.setItem(LOCAL_KEY, JSON.stringify(state));
} catch (e) { /* приватный режим или переполнение */ }
if (timer) return; // таймер не сбрасываем, иначе запись отложится навсегда
const wait = Math.max(DEBOUNCE_MS, MIN_INTERVAL_MS - (Date.now() - lastWrite));
timer = setTimeout(() => { timer = null; push(false); }, wait);
}
// Значимое событие: пишем немедленно.
async function saveNow() {
if (timer) { clearTimeout(timer); timer = null; }
dirty = true;
try {
localStorage.setItem(LOCAL_KEY, JSON.stringify(state));
} catch (e) { /* игнорируем */ }
await push(true);
}
async function push(flush) {
if (!dirty || !player) return;
dirty = false;
lastWrite = Date.now();
try {
await player.setData({ [CLOUD_KEY]: state }, flush);
} catch (e) {
dirty = true; // не потеряем изменения, повторим при следующем mark()
console.warn('setData failed', e);
}
}
return { init, get, mark, saveNow };
})();Использование:
const ysdk = await YaGames.init();
const save = await Saves.init(ysdk, { level: 1, coins: 0, unlocked: ['forest'] });
// Мелочь во время игры.
save.coins += 5;
Saves.mark();
// Значимое событие.
save.level = 8;
save.unlocked.push('cave');
await Saves.saveNow();Обработчик pagehide — это подстраховка, а не основной механизм: браузер не обязан дождаться завершения сетевого запроса при закрытии вкладки. Именно поэтому локальная копия пишется синхронно, а важные события сохраняются в момент, когда они произошли.
Важно
Любой вызов SDK может отклониться: превышен лимит частоты, нет сети, игрок открыл игру в трёх вкладках. Каждый setData/getData/setStats оборачивайте в try/catch. Непойманное исключение в промисе — это ошибка в консоли, а «JS-ошибки в консоли при запуске или во время игры» тоже стоят в списке частых причин отказа.
Версия формата
Формат сохранения обязательно изменится: вы добавите новую валюту, переименуете поле, поменяете структуру уровней. У игроков в облаке при этом лежит старый объект, и обновление не должно ломать им игру. Дешёвый способ — хранить номер версии внутри самих данных и прогонять миграции при загрузке.
const CURRENT_VERSION = 3;
function migrate(save) {
let s = Object.assign({}, save);
if (!s.version) { // первая версия номера не имела
s.coins = s.money || 0;
delete s.money;
s.version = 2;
}
if (s.version === 2) {
s.unlocked = s.unlocked || ['forest'];
s.version = 3;
}
return s;
}Вызовите migrate() сразу после getData() и сохраните результат — тогда следующая загрузка будет уже в новом формате. И не кладите в сохранение то, что вычисляется: уровень персонажа из опыта, список доступных уровней из максимально пройденного. Меньше полей — меньше миграций и меньше килобайт.
Гостевые сохранения
setData и getData работают без авторизации: ysdk.getPlayer() возвращает объект Player с идентификатором пользователя для всех, а требование авторизации документация отдельно оговаривает только для player.getIDsPerGame() и методов лидербордов. Поэтому «гость играет — прогресс сохраняется» реализуется тем же кодом, что и выше, без единого условия.
Предлагать вход стоит по пункту 1.2.1: только после осознанного нажатия кнопки и с объяснением выгоды — прогресс в облаке и доступ к покупкам на любом устройстве. Отказ должен оставлять игру играбельной.
Документация не описывает, как долго живут данные гостя и переносятся ли они автоматически при входе в аккаунт. Не стройте на этом логику — сливайте прогресс явно:
async function login(ysdk) {
const guest = JSON.parse(JSON.stringify(Saves.get()));
try {
await ysdk.auth.openAuthDialog();
} catch (e) {
return; // игрок отказался — просто продолжаем игру
}
// Объект Player не «повышается» на месте, его нужно получить заново.
const authorized = await ysdk.getPlayer();
const cloud = (await authorized.getData(['save'])).save || {};
if ((cloud.level || 0) > (guest.level || 0)) {
// В облаке прогресс дальше — предложите игроку выбор, а не молча перезапишите.
showRestoreDialog(cloud, guest);
} else {
await authorized.setData({ save: guest }, true);
}
}Правило слияния выбирайте сами, но выбирайте осознанно: молчаливая перезапись более далёкого прогресса — самый обидный баг из возможных.
localStorage, iOS и ysdk.getStorage()
На новых версиях iOS localStorage может часто сбрасываться. SDK даёт защищённое хранилище с тем же интерфейсом:
const ysdk = await YaGames.init();
const safeStorage = await ysdk.getStorage();
safeStorage.setItem('key', 'safe storage is working');
console.log(safeStorage.getItem('key'));Чтобы не переписывать код, документация предлагает подменить localStorage глобально — при условии, что до подмены он ещё не использовался:
Object.defineProperty(window, 'localStorage', { get: () => safeStorage });Совет
Это нужно только при интеграции через собственный домен. Если вы загружаете игру архивом в консоль (обычный сценарий), обёртка в SDK делает localStorage надёжным автоматически — ничего менять не надо.
Проверка перед отправкой
- Пройти уровень, нажать F5 — прогресс на месте.
- Повернуть телефон — состояние то же, что до поворота.
- Играть гостем, обновить страницу — прогресс сохранился.
- Открыть консоль: ни одной необработанной ошибки от
setData/setStats. - Проверить сброс: кнопка ☁️ Clear cloud data на debug-панели (URL игры +
?debug-mode=16) очищает данные и статистику игрока, вызываяsetData()иsetStats()с пустыми значениями. После сброса игра должна запускаться как в первый раз — заодно проверите онбординг. - Не забыть включить в черновике опцию «Игра использует облачные сохранения» (пункт 1.11).
Что дальше
Сохранения — половина работы с данными игрока. Вторая половина — публичные результаты: лидерборды, где действует другой набор методов (ysdk.leaderboards.*), обязательная авторизация и собственные лимиты частоты. Разберём их в следующей главе.