Игра для Яндекс Игр — это ZIP-архив со статическими файлами. Никакого сервера, никакого рантайма: вы загружаете архив в консоль разработчика, Яндекс распаковывает его на своих серверах и отдаёт браузеру. Из-за этого структура папок перестаёт быть вопросом вкуса — часть требований к ней жёсткая, и нарушение любого из них означает либо отказ на модерации, либо игру, которая не запускается вообще.
Эта глава про то, как разложить файлы так, чтобы архив собирался одной командой и не ломался при каждом обновлении.
Жёсткие требования к архиву
Всё, что ниже, взято из документации Яндекса (проверено в сентябре 2026 года). Это не рекомендации — это условия приёмки.
| Требование | Формулировка | Номер |
|---|---|---|
| Формат | ZIP-архив | — |
| Точка входа | В корне архива ровно один index.html | — |
| Размер | До 100 МБ в распакованном виде | 1.21 |
| Имена файлов и папок | Без пробелов и без кириллицы | 1.22 |
| Ссылки | Никаких абсолютных URL на серверы S3 Яндекса в коде | 1.7 |
| Независимость от адреса | Игра не должна зависеть от URL, по которому её открыли | 1.18 |
| Внешние хосты | Любой внешний домен — только через вкладку CSP в консоли | — |
Отдельно стоит запомнить: одновременно к игре прикреплён только один архив, повторная загрузка заменяет предыдущий. Так что «залью второй архив с патчем» не сработает — собирайте полный билд каждый раз.
Про максимальный размер самого ZIP (сжатого) в документации ничего не сказано — ограничение сформулировано только для распакованного содержимого. Ориентируйтесь на 100 МБ после распаковки.
Частая ошибка
Самая частая ошибка первой загрузки — заархивировать папку, а не её содержимое. Тогда внутри архива получается my-game/index.html, а в корне архива index.html нет. Консоль такой архив не примет. Проверяйте: откройте готовый ZIP и убедитесь, что index.html виден сразу, без единой папки над ним.
Рабочая структура папок
Для игры без бандлера достаточно четырёх сущностей: точка входа, стили, код, ресурсы.
my-game/
├── index.html ← ровно один, строго в корне
├── styles/
│ └── main.css
├── src/
│ ├── main.js ← точка входа, склеивает всё вместе
│ ├── game.js ← игровой цикл, состояние
│ ├── yandex.js ← вся работа с SDK в одном месте
│ ├── storage.js ← сохранения (setData/getData + локальный фолбэк)
│ └── ui.js
└── assets/
├── images/
├── audio/
└── fonts/Почему именно так:
src/yandex.jsотдельным файлом. Весь SDK — инициализация, реклама, лидерборды, сохранения — живёт в одном модуле с понятным интерфейсом (initYandex(),showRewarded(),saveProgress()). Остальной код не должен знать, что он работает под Яндексом. Это окупается сразу: игра запускается локально без SDK, и её можно портировать на другую площадку, переписав один файл.assets/по типам, а не по уровням. Ресурсы вы будете чистить и сжимать пачками — легче, когда все звуки в одной папке.- Плоско, а не глубоко. Три уровня вложенности — потолок. Чем длиннее относительные пути, тем больше шансов промахнуться.
Обратите внимание: sdk.js в этом дереве нет и быть не должно. В документации это сказано прямо — скачивать файл sdk.js и класть его в архив не нужно, лоадер отдаёт платформа.
index.html
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>My Game</title>
<link rel="stylesheet" href="./styles/main.css">
<!-- Yandex Games SDK -->
<script src="/sdk.js"></script>
</head>
<body>
<canvas id="game"></canvas>
<script type="module" src="./src/main.js"></script>
</body>
</html>// src/main.js
import { createGame } from './game.js';
import { initYandex } from './yandex.js';
const canvas = document.getElementById('game');
const game = createGame(canvas);
initYandex(game).catch((error) => {
// SDK может быть недоступен — локально или при сетевом сбое.
// Игра обязана запуститься в любом случае.
console.warn('SDK недоступен, играем без него', error);
game.start();
});ES-модули работают в браузере без сборки, но с двумя условиями: расширение в импорте обязательно (./game.js, а не ./game), и страница должна открываться по HTTP, а не двойным кликом по файлу (file:// модули блокирует). Для локального запуска используйте официальный npx @yandex-games/sdk-dev-proxy -p <путь к папке игры> — он и статику раздаст, и SDK подставит.
Важно
/sdk.js — единственный путь в вашем HTML, который начинается со слэша, и так и должно быть. Лоадер отдаётся с корня хоста, на котором крутится игра. Все ваши файлы подключаются относительными путями через ./. Если игра интегрируется через собственный домен или iframe, вместо /sdk.js документация предписывает абсолютный https://sdk.games.s3.yandex.net/sdk.js — это тоже штатный вариант, а не исключение из правила 1.7.
Относительные пути: где легко промахнуться
Правило простое: всё своё — через ./, никаких путей от корня и никаких абсолютных URL. Но «относительно чего» зависит от того, где вы этот путь написали, и это регулярно ломает игры:
| Где написан путь | Относительно чего резолвится |
|---|---|
Атрибуты в HTML (src, href) | адрес документа, то есть index.html |
url() внутри CSS | сам CSS-файл |
import в ES-модуле | сам JS-модуль |
img.src, fetch(), new Audio() в JS | адрес страницы, а не JS-файла |
Последняя строка — источник классического бага. В src/game.js вы пишете img.src = './assets/images/hero.png' — и это правильно, потому что путь считается от index.html, а не от src/. Интуиция подсказывает '../assets/...', и вот это как раз не сработает. Если путаетесь, соберите пути в один модуль-константу или используйте new URL('../assets/images/hero.png', import.meta.url) — эта форма считается от модуля и не зависит от того, откуда её вызвали.
Осторожно
Требование 1.7 запрещает абсолютные URL на серверы S3 Яндекса в коде игры. Соблазн возникает после первой публикации: вы открываете опубликованную игру, копируете из DevTools адрес своей картинки и вставляете его в код. Так делать нельзя — адрес меняется при следующей загрузке архива, игра ломается, а модерация такое отклоняет. То же самое касается путей от корня вида /assets/hero.png: они привязывают игру к конкретному адресу размещения, а требование 1.18 прямо запрещает зависеть от URL.
Внешние домены — отдельная история. Подключить шрифт с публичного CDN или ходить за данными на свой API не запрещено, но каждый внешний хост нужно заранее заявить на вкладке CSP в консоли с обоснованием и дождаться согласования. Правила там строгие: без путей, без портов, без IP-адресов, только https и wss, и основная масса данных игры всё равно должна лежать в архиве. Для шрифтов и картинок это лишняя работа и лишняя точка отказа — положите их в assets/.
Имена файлов
Требование 1.22 запрещает пробелы и кириллицу. Практика добавляет ещё одно правило, которого в документации нет, но которое ломает игры чаще всего остального: только нижний регистр и дефисы.
Причина в том, что Windows не различает регистр в именах файлов, а сервер, который раздаёт вашу распакованную игру, различает. Локально img.src = 'assets/images/hero.png' найдёт файл Hero.png без единого возражения. После публикации это станет 404 — и, скорее всего, ошибкой в консоли, а JS-ошибки при запуске входят в список типовых причин отказа (требование 1.14).
Совет
Договоритесь с собой один раз: nizhniy-registr-cherez-defis.png. Никаких Фон уровня 2.png, Sprite Sheet (final).png, герой_v2.PNG. Это дешевле, чем потом искать один файл из трёхсот, который сломал сборку. Если проект уже разросся — переименование делается скриптом, а Claude Code хорошо справляется с задачей «переименуй все файлы в assets по kebab-case и почини все ссылки на них в коде».
Заодно проследите, чтобы в архив не попали .git/, node_modules/, исходники PSD/Aseprite, README.md и .DS_Store. Они съедают лимит в 100 МБ, а среди них легко проскакивает файл с пробелом в имени.
Нужен ли бандлер
Короткий ответ: для простой игры на чистом JavaScript — нет.
Отсутствие сборки — это реальное преимущество, а не аскеза. Папка проекта совпадает с содержимым архива: что видите в редакторе, то и уедет на модерацию. Нет промежуточного шага, который может подставить не тот путь, не докопировать ресурс или сгенерировать имя файла с хешем. Отладка идёт по вашему же коду, без сорсмапов. Правка — это F5, а не пересборка.
Для игры уровня «арканоид, три-в-ряд, кликер, платформер на canvas» этого достаточно: несколько ES-модулей, один CSS, папка с ассетами.
Есть один нюанс совместимости. Требование 1.20 перечисляет поддерживаемые платформы, включая iOS 9.0+, а ES-модули появились в Safari только начиная с iOS 10.3. Если вы хотите буквального соответствия этому пункту без сборки — подключайте скрипты классическими тегами в нужном порядке:
<script defer src="./src/game.js"></script>
<script defer src="./src/yandex.js"></script>
<script defer src="./src/main.js"></script>defer гарантирует и порядок выполнения, и запуск после парсинга HTML. Модульность вы при этом потеряете, но для трёх-пяти файлов это терпимо. Альтернатива — бандлер с транспиляцией под старый таргет.
Когда бандлер действительно нужен
- Зависимости из npm. Phaser, three.js, matter.js, howler — их нужно во что-то собрать.
- TypeScript. В том числе если хотите официальные типы SDK:
npm install --save @types/ysdk. - Много кода. С двух-трёх десятков модулей ручное подключение и отсутствие минификации начинают ощущаться.
- Конвейер ассетов. Автоматическая упаковка атласов, сжатие картинок, инлайн мелких файлов — когда вы упираетесь в 100 МБ.
Практический выбор по умолчанию — Vite. Но с одной обязательной настройкой:
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
base: './', // без этого пути будут вида /assets/... — игра сломается
build: {
outDir: 'dist',
assetsDir: 'assets',
},
});base: './' — не украшение. По умолчанию Vite генерирует ссылки от корня (/assets/index-a1b2c3.js), а это ровно тот случай, который запрещает требование 1.18: игра начинает зависеть от адреса размещения. С base: './' пути становятся относительными и архив работает где угодно.
И главное про бандлер: в архив идёт содержимое dist/, а не папка проекта. index.html должен оказаться в корне dist — у Vite так и происходит по умолчанию, но проверьте после первой сборки.
Проверка перед упаковкой
Три вещи, которые стоит прогонять каждый раз. PowerShell, из папки со сборкой:
# 1. Имена с пробелами или не-ASCII символами (должно быть пусто)
Get-ChildItem -Recurse | Where-Object { $_.Name -match '[^!-~]' } |
Select-Object -ExpandProperty FullName
# 2. Размер в распакованном виде (лимит — 100 МБ)
'{0:N1} МБ' -f ((Get-ChildItem -Recurse -File | Measure-Object Length -Sum).Sum / 1MB)
# 3. Упаковка содержимого папки, а не самой папки
Compress-Archive -Path .\* -DestinationPath ..\build.zip -ForceТо же самое в bash:
find . | LC_ALL=C grep '[^!-~]' # 1. плохие имена — должно быть пусто
du -sh --exclude=.git . # 2. размер
zip -r ../build.zip . -x '.*' -x '__MACOSX/*' -x '*/.DS_Store' # 3. архивДиапазон !-~ — это все печатаемые ASCII-символы кроме пробела, поэтому одна проверка ловит сразу и пробелы, и кириллицу, и эмодзи в именах.
Дальше — откройте получившийся ZIP любым архиватором и посмотрите на первый экран. Видите index.html без папки над ним — можно грузить.
Как поручить это Claude Code
Проверка структуры — идеальная задача для агента, потому что она скучная и полностью формальная. Разовый запрос выглядит примерно так:
Проверь папку
distперед загрузкой в Яндекс Игры: в корне должен быть ровно одинindex.html; в именах файлов и папок не должно быть пробелов и не-ASCII символов; распакованный размер — до 100 МБ; в коде не должно быть путей, начинающихся со слэша (кроме/sdk.js), и абсолютных URL на домены Яндекса. Выведи список нарушений с путями к файлам, ничего не исправляя.
Ещё лучше — вынести эти правила в CLAUDE.md проекта, чтобы они действовали в каждой сессии и агент не создавал файлы с пробелами в именах с самого начала. О том, как устроен CLAUDE.md и что в него имеет смысл класть, речь пойдёт в главах про рабочий процесс с Claude Code.
Что дальше
Структура готова, архив собирается — пора наполнять его содержимым. В следующей главе подключаем SDK: правильный тег загрузчика (тот, который проходит требование 1.19.1, а не тот, что кочует по устаревшим туториалам), инициализация, порядок вызовов в жизненном цикле игры и проверка через debug-панель.