# GameSlop: как сделать игру для gameslop.ru

> **Кому это:** нейросети и разработчику, которые делают игру для платформы GameSlop.
> **Что внутри:** упаковка ZIP, манифест, сохранения, мультиплеер, античит и проверка перед загрузкой.
> **Главное правило:** соберите ZIP → проверьте по чек-листу (раздел 12) → загрузите на `gameslop.ru/upload`.
> **Одна строка для ИИ-агента:** `curl -sL https://gameslop.ru/README-FOR-AI.md` — этот файл целиком.
> Если curl недоступен — скачайте его же кнопкой «скачать .md» на gameslop.ru/upload или gameslop.ru/docs.
> Файл — эталон правил упаковки, манифеста, SDK и проверки мультиплеера портала GameSlop.

---

## 0. Что спросить у пользователя ДО начала работы

Это обязательный шаг. Не пропускайте его и не выдумывайте ассеты за пользователя.

### 0.1. Первый вопрос — для какого устройства игра

Задайте его **до** вопроса про ассеты: от ответа зависят управление, вёрстка и то, увидят ли игру в каталоге. Формулировка:

> Для какого устройства делаем игру: **телефон**, **компьютер** или **мультиплатформенная** (запускается и там, и там)?

| Ответ | Управление | Поля манифеста | Кто увидит игру |
|---|---|---|---|
| Только телефон | Экранные кнопки или виртуальный джойстик. Клавиатуры нет | `requirements.supportsMobile: true`, `requirements.supportsDesktop: false` | Те, кто зашёл с телефона |
| Только компьютер | Клавиатура и мышь, подсказки клавиш | `requirements.supportsMobile: false`, `requirements.supportsDesktop: true` | Те, кто зашёл с компьютера |
| Мультиплатформенная | Обе схемы, игра выбирает по устройству | `supportsMobile: true`, `supportsDesktop: true` | Все |

Правила:

1. **Не угадывайте молча.** Нет ответа — переспросите одной строкой. Ответ «не знаю» трактуйте как мультиплатформу: так игру увидят максимум игроков.
2. **Скажите, что изменится.** Пример: «Понял, делаем под телефон. Тогда рисую экранный джойстик и кнопку действия, а подсказки вида "нажмите W" убираю».
3. **Не объявляйте платформу шире, чем есть.** Если управление только с клавиатуры, а в манифесте стоит мультиплатформа, человек запустит игру с телефона и не сможет играть.
4. **Манифест важнее формы загрузки.** Каталог фильтрует игры по `requirements` из манифеста; если автор выбрал в мастере одно, а в манифесте другое — мастер покажет предупреждение.
5. **Телефон — это экранное управление.** Подробности в разделе 3.2; коротко: без нарисованных кнопок игра на телефоне неиграбельна.

### 0.2. Второй вопрос — ассеты

**Если у вас нет готовых скриншотов и логотипа** — не рисуйте заглушки, не оставляйте пустые ссылки и не выкидывайте блоки `media` молча. Скажите об этом пользователю прямым текстом, например:

> У меня нет скриншотов и логотипа для этой игры. Положите, пожалуйста, файлы в папку `media/` внутри проекта игры:
> * `media/cover.webp` — обложка для карточки игры, соотношение 16:9 или 16:10, от 1280 px по ширине
> * `media/screen1.webp`, `media/screen2.webp` — 2–4 скриншота игрового процесса, те же пропорции
> * `media/logo.webp` — логотип с прозрачным фоном (если он есть, иначе пропустите)
> После этого я допишу пути в `manifest.json` и соберу архив.

Правила:

1. **Спросите один раз и ждите.** Формулируйте конкретно: какие файлы, в какую папку, какие пропорции, какой формат.
2. **Указывайте точные пути** — `media/cover.webp`, а не «положи куда-нибудь в проект».
3. **Если файлов нет, а ответа ждать некогда** — оставьте поля `media` в манифесте **пустыми или удалите их целиком** и явно напишите в ответе: «Поля `media` не заполнены, потому что нет файлов». Платформа покажет карточку без обложки — это нормально.
4. **Не подставляйте** чужие картинки из интернета, битые ссылки, base64-заглушки и сгенерированные «похожие» картинки без согласия пользователя.
5. **Не ссылайтесь на файлы, которых нет** в архиве: загрузка пройдёт, но вернётся **предупреждение** `MEDIA_MISSING`, а на карточке окажется сгенерированная обложка вместо вашей. Отказ (`ENTRY_MISSING`) бывает только за отсутствующий файл из `entry` (обычно `index.html`) — см. раздел 3.

Те же правила действуют для музыки/звуков (`assets/audio/…`) и шрифтов — если их нет, скажите, а не изобретайте.

---

## 1. Что вы делаете

Вы создаёте **статическую HTML5-игру** (HTML + JS + CSS + ассеты) и упаковываете её в **ZIP-архив**. Игра запускается на платформе GameSlop в изолированном iframe на отдельном домене `game.gameslop.ru`.

Разрешены любые браузерные технологии: Canvas, WebGL, Three.js, PixiJS, чистый JS, WASM. Все зависимости должны быть **внутри архива** — внешние загрузки запрещены.

### 1.1. Минимум, который точно заработает

Три файла — этого достаточно, чтобы игра опубликовалась и запустилась:

```text
my-game.zip
├── manifest.json     ← обязателен
└── index.html        ← точка входа: вся игра может быть в одном файле
```

Сохраните этот путь как «быстрый старт»: сначала добейтесь, чтобы платформа приняла минимальный архив, и только потом добавляйте сохранения, мультиплеер и ассеты. Так вы не будете искать причину отказа в пяти местах сразу.

---

## 2. Правила упаковки ZIP

1. В **корне архива** обязательно лежат:
   - `manifest.json` — манифест игры (см. раздел 3);
   - файл из `entry` манифеста (рекомендуем `index.html` в корне архива) — **это и есть точка входа**: платформа открывает именно его, а при пустом или некорректном `entry` откатывается к `index.html` — см. раздел 3;
2. Все пути внутри архива — **относительные**, только через `/`, без `..`, без абсолютных путей, без символических ссылок.
3. Запрещены: исполняемые и серверные файлы (`*.exe`, `*.dll`, `*.so`, `*.dylib`, `*.bat`, `*.sh`, `*.cmd`, `*.com`, `*.msi`, `*.apk`, `*.php`, `*.jsp`, `*.asp`, `*.aspx`, `*.py`, `*.rb`, `*.pl`); папка `node_modules/`; **любой файл или папка, имя которого начинается с точки** (`.git`, `.gitignore`, `.env`, `.DS_Store`, `.github`, `.well-known` и любое другое); символические ссылки. Всё лишнее удалите до упаковки — в архиве должно остаться только то, что реально нужно игре.
4. Максимум: 2000 файлов, распакованный размер ≤ 512 МБ, размер ZIP ≤ 200 МБ. Отдельного лимита на один файл нет, но **сканируются только первые 20 МБ каждого текстового файла** — не прячьте код и ссылки в хвост больших файлов: такое нарушение найдёт не загрузка, а CSP в рантайме.
   Дополнительно проверяется защита от «zip-бомбы»: при суммарном распакованном размере больше 512 МБ — `ZIP_BOMB`; отдельно по каждому файлу — если файл больше 10 МБ и сжимается сильнее чем в 100 раз, архив отклоняется с кодом `COMPRESSION_RATIO` (раздел 10).
5. Никаких внешних ресурсов: игра не должна обращаться к внешним сайтам, CDN, шрифтам, аналитике, рекламе. Всё — только внутри архива. Исключение — платформенные WS-соединения мультиплеера (раздел 7).
6. `index.html` подключает скрипты обычными `<script src="...">` или ES-модулями с относительными путями.
7. **В архиве нет папки `sdk/` по умолчанию.** Если игре нужны сохранения или мультиплеер, вы **сами** кладёте файлы SDK в архив (разделы 6.1 и 7.5). Скачать их можно с платформы: `https://gameslop.ru/sdk/gameslop-mp.js`, `https://gameslop.ru/sdk/gameslop-save.js` (раздел 6.1). Внутри архива ссылка обязана быть относительной (`<script src="sdk/gameslop-mp.js">`): абсолютный `/sdk/...` резолвится от домена сборки `game.gameslop.ru`, где папки `sdk/` нет, — это 404.

## 3. Обязательный манифест `manifest.json`

Игра **не публикуется без корректного манифеста**.

**Обязательные поля ровно эти шесть:** `manifestVersion`, `game.title`, `entry`, `version`, `save.enabled`, `multiplayer.enabled`. Объекты `save` и `multiplayer` обязаны присутствовать целиком, даже если функция выключена — тогда пишите `"enabled": false`.

```json
{
  "manifestVersion": 1,
  "game": {
    "title": "Space Arena",
    "slug": "space-arena",
    "shortDescription": "Быстрая аркада на Three.js",
    "description": "Полное описание игры...",
    "tags": ["arcade", "space", "multiplayer"],
    "genre": "action",
    "language": "ru",
    "contentRating": "6+"
  },
  "entry": "index.html",
  "version": "1.0.0",
  "engine": { "name": "threejs", "version": "0.160.0" },
  "ai": { "generator": "DeepSeek Harness", "model": "Claude Opus 5.5", "generatedAt": "2026-06-01T12:00:00Z" },
  "media": {
    "cover": "media/cover.webp",
    "screenshots": ["media/screen1.webp"],
    "video": "media/trailer.mp4"
  },
  "capabilities": {
    "fullscreen": true, "pointerLock": true, "gamepad": false,
    "touch": true, "keyboard": true, "mouse": true, "webgl": true
  },
  "save": {
    "enabled": true,
    "slots": 3,
    "maxBytesPerSlot": 262144,
    "cloudSync": true
  },
  "multiplayer": {
    "enabled": false,
    "required": false,
    "modes": ["server", "local"],
    "transport": ["ws", "webrtc"],
    "maxPlayers": 8,
    "authority": "server-or-host",
    "antiCheat": {
      "clientCan": ["send_input", "send_cosmetic_state", "request_action"],
      "clientCannot": ["set_score", "set_inventory", "teleport", "force_win", "modify_other_players"]
    }
  },
  "requirements": { "minMemoryMB": 1024, "supportsMobile": true, "supportsDesktop": true }
}
```

Поля:

| Поле | Обязательно | Описание |
|---|---|---|
| `manifestVersion` | **да** | всегда `1` (число, не строка) |
| `game` | **да** | объект с `title` (см. ниже) |
| `game.title` | **да** | название, 1–120 символов |
| `entry` | **да** | точка входа: относительный путь внутри архива, максимум 200 символов, без ведущего `/`, без `..` и без обратного слэша. Платформа **открывает именно этот файл** (`apps/api/src/routes/play.ts`, `apps/api/src/routes/mp.ts` собирают URL из `entry`), а если поле пустое или некорректное — откатывается к `index.html`. Файл обязан существовать в архиве, иначе версия отклоняется с `ENTRY_MISSING`. **Рекомендуем `entry: "index.html"` в корне архива** — так игра откроется и в старых сборках платформы |
| `version` | **да** | строго `X.Y.Z`, например `1.0.0` |
| `save.enabled` | **да** | `true`/`false`: использует ли игра сохранения |
| `multiplayer.enabled` | **да** | `true`/`false`: есть ли мультиплеер |
| `game.contentRating` | нет | `6+`, `12+`, `16+`, `18+`; по умолчанию `6+`. Рейтинг `18+` включает обязательное подтверждение возраста на платформе до запуска (`AGE_RESTRICTED`); игра этого не делает и не может |
| `game.slug` | нет | url-адрес, 2–80 символов; если нет — генерируется из названия |
| `game.shortDescription` | нет | до 300 символов, для карточки |
| `game.description` | нет | до 20 000 символов |
| `game.tags` | нет | до 12 меток, до 32 символов каждая |
| `game.genre` | нет | до 40 символов |
| `game.language` | нет | по умолчанию `ru` |
| `ai` | нет | **атрибуция ИИ**: обязательно указывайте, если игру сгенерировала нейросеть. `generator` — харнесс (IDE/инструмент, в котором собрана игра), `model` — модель, работавшая внутри него |
| `media` | нет | `cover`, `screenshots` (до 12), `video` — пути внутри архива; путь в `video` нигде не описан, кладите файл рядом и указывайте относительный путь |
| `engine` | нет | `{name, version}`; список допустимых `name` не зафиксирован, значение из примера — `threejs` |
| `multiplayer.authority` | нет | строка до 64 символов, значение по умолчанию и единственное документированное — `server-or-host` |
| `requirements` | нет | `{minMemoryMB, supportsMobile, supportsDesktop}` — влияет на подбор устройства |
| `capabilities` | нет | подсказка, что игре нужно: `touch`/`keyboard`/`mouse` влияют на вывод платформ, если не задан `requirements`. **На права iframe не влияет**: полный экран, геймпад и pointer lock доступны всем играм независимо от значений |
| `save.slots` | нет | слоты сохранений 1–10, по умолчанию 3. **Значение справочное**: сервер не считает слоты и не отказывает в новых — список слотов держите в игре |
| `save.maxBytesPerSlot` | нет | лимит байт на слот, по умолчанию 262144 (256 КБ), максимум 5 МБ |
| `save.cloudSync` | нет | облачная синхронизация, по умолчанию `false` |
| `multiplayer.required` | нет | если `true`, игра не запускается в одиночном режиме |
| `multiplayer.modes` | нет | `server`, `local`. **Сейчас не влияет на интерфейс**: локальный хост предлагается игроку в любом случае (см. 7.0) |
| `multiplayer.transport` | нет | `ws`, `webrtc` |
| `multiplayer.maxPlayers` | нет | 2–10, по умолчанию 10 |
| `multiplayer.antiCheat` | нет | контракт «что можно/нельзя клиенту» |

---

### 3.1. Платформы: ПК, телефон или мультиплатформа

Платформа описывается в манифесте и напрямую влияет на каталог: игра, которая не запустится на устройстве игрока, в фильтре «ПК» / «Телефон» не показывается.

```jsonc
"requirements": {
  "supportsDesktop": true,   // работает на компьютере: клавиатура и мышь
  "supportsMobile": true,    // работает на телефоне: экранное управление
  "minMemoryMB": 512         // необязательно
}
```

| Что нужно | `supportsDesktop` | `supportsMobile` |
|---|---|---|
| Только компьютер | `true` | `false` |
| Только телефон | `false` | `true` |
| Мультиплатформенная | `true` | `true` |

Порядок разбора на платформе: `requirements` → `capabilities` → «везде».

* Если `requirements` заполнен — берётся он.
* Если `requirements` нет, но заполнены `capabilities`, платформа выводится из ввода: `touch: true` без `keyboard`/`mouse` — телефон; `keyboard`/`mouse` без `touch` — компьютер; оба признака — мультиплатформа.
* Если нет ни того, ни другого — игра считается мультиплатформенной, чтобы не пропасть из каталога.

```jsonc
"capabilities": { "touch": true, "keyboard": true, "mouse": true, "fullscreen": true }
```

**Атрибуция ИИ.** Если игру сгенерировала модель — заполните `ai` (`generator`, `model`). Поля означают разное, и на странице игры они показываются по отдельности:

* `ai.generator` — **харнесс: IDE или инструмент, в котором собрана игра** (например, `DeepSeek Harness`). Именно это значение выводит бейдж на карточке: сперва на странице игры — «Харнесс: <generator>», а в каталоге на обложке — короткий бейдж «Харнесс»;
* `ai.model` / `ai.modelId` — **модель, которая работала внутри этого харнесса** (например, `Claude Opus 5.5`). Она показывается отдельной плашкой «Сгенерировано моделью <модель>» и подбирается по справочнику моделей платформы.

Если игра создана не в харнессе, а другим ИИ-инструментом, всё равно указывайте его в `generator`: бейдж назван «Харнесс» по названию раздела атрибуции, а конкретный инструмент виден после двоеточия.

Если игру сделал человек и ИИ не использовался для генерации — напишите явно:

```jsonc
"ai": { "used": false }
```

Без одного из двух платформа не даст опубликовать игру (ошибка `ATTRIBUTION_REQUIRED`).

`capabilities` описывают, что игре нужно от окружения (полный экран, pointer lock, геймпад), а `requirements` — где игра работает. Заполняйте оба блока: они не заменяют друг друга.

### 3.2. Управление под платформу

**Телефон.** Клавиатуры и мыши нет — управление рисует сама игра поверх канваса.

* Виртуальный джойстик или крестовина плюс 1–3 кнопки действия; тап-цели не меньше 44×44 px.
* Multi-touch: игрок одновременно держит джойстик и жмёт кнопку. Слушайте `touchstart` / `touchmove` / `touchend` с `{ passive: false }` и вызывайте `preventDefault()`, иначе браузер начнёт скроллить или зумить страницу.
* Погасите лишние жесты: `<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">` и `touch-action: none` на канвасе.
* Никакого hover: на телефоне его нет. Все подсказки — видимым текстом и крупно.
* Ориентация: либо требуйте ландшафт (экран «поверните телефон»), либо верстайте HUD под обе.
* Учитывайте вырезы: `env(safe-area-inset-*)`.
* Пауза при `visibilitychange` (свернули браузер — игра встала), звук включайте только после первого касания.
* Бюджет производительности ниже, чем на ПК: цель — 60 FPS на средней модели без тяжёлых постэффектов.

**Компьютер.** Клавиатура и мышь.

* Покажите схему управления на стартовом экране: «WASD — движение, мышь — прицел».
* Читайте `event.code`, а не `event.key`: раскладки и языки разные.
* Колесо и правый клик — только если реально нужны; для захвата мыши просите pointer lock по клику.
* Hover-состояния и курсоры уместны и помогают игроку.

**Мультиплатформа.** Обе схемы, выбор по устройству.

* Определяйте ввод в рантайме: признак телефона — `matchMedia("(pointer: coarse)").matches` вместе с `navigator.maxTouchPoints > 0`. Не разбирайте User-Agent.
* Плеер передаёт устройство запуска сам: `GameSlopSave.device()` → `{ platform: "mobile" | "desktop", mobile, desktop, touch }` (см. раздел 6.2). Используйте это как стартовую подсказку, но переключайтесь на тач, если пришло первое касание: так игра останется играбельной на планшете с клавиатурой.
* HUD верстайте адаптивно: на телефоне крупнее и ближе к краям, на компьютере компактнее.
* Проверьте обе схемы руками: тачем на телефоне и клавиатурой на ПК. Игра, где работает только одна, ломает обещание мультиплатформенности.

### Обложка: можно не присылать

Платформа сама обеспечивает игру обложкой — при обработке версии воркер выбирает её в таком порядке:

1. **обложка автора** — если вы загрузили её на странице «Мои игры», воркер её не трогает;
2. **`media.cover` из манифеста** — если файл реально лежит в архиве;
3. **файл в архиве** с «обложечным» именем: `cover`, `preview`, `thumbnail`, `icon`, `poster`
   с расширением `png`, `jpg`, `jpeg`, `webp` или `gif` — в корне или в папках
   `media/`, `assets/`, `images/`, `img/`, `public/`;
4. **сгенерированная обложка** — векторная (SVG, ~2 КБ), в едином стиле платформы:
   название игры, жанр и возрастной рейтинг. Растр не создаётся, место в хранилище
   почти не расходуется, картинка остаётся резкой на любом экране.

Файл обложки из архива **крупнее 5 МБ не берётся** — платформа молча нарисует свою обложку. Так же пропускаются файлы с неподдерживаемым расширением: обложкой считаются `png`, `jpg`, `jpeg`, `webp`, `gif` (а также `svg`, если он явно указан в `media.cover`); файлы с другими расширениями не берутся. Держите обложку ≤ 5 МБ.

Заменить сгенерированную обложку можно в любой момент: «Мои игры» → «Редактировать» →
«Обложка». Своя обложка всегда приоритетнее автоматической.

## 4. Точка входа и структура файлов

```text
my-game.zip
├── manifest.json
├── index.html
├── sdk/                     ← только если нужны сохранения/мультиплеер
│   ├── gameslop-save.js     ← копия из /sdk/gameslop-save.js
│   └── gameslop-mp.js       ← копия из /sdk/gameslop-mp.js
├── js/
│   ├── main.js
│   └── three.min.js         ← движок включён в архив
├── css/style.css
├── assets/
│   ├── sprites.png
│   └── levels.json
└── media/                   ← см. раздел 0, если файлов нет — спросите пользователя
    ├── cover.webp
    └── screen1.webp
```

## 5. Запрещено клиенту игры (безопасность)

- обращаться к любым внешним доменам (fetch/XHR/WebSocket/img/шрифты). Песочница разрешает только домен сборки `game.gameslop.ru`, `data:`/`blob:` для картинок и WS к `wss://mp.gameslop.ru`. **`api.gameslop.ru` из игры недоступен — CSP его блокирует**, хотя статический сканер его не отметит. Сканер пропускает ещё `localhost` и `*.gameslop.ru` — это послабление для локальной разработки, в проде на него рассчитывать нельзя;
- пытаться получить cookie основной сессии;
- открывать внешние окна/ссылки (`window.open`, `top.location`);
- запрашивать камеру/микрофон/геолокацию;
- менять `document.domain`, трогать `parent` кроме как через postMessage-мост (разделы 6, 7);
- **хранить прогресс игрока только в браузерном хранилище** (`localStorage`, `sessionStorage`, `indexedDB`) — оно общее для всех игр домена и не переживает смену устройства (раздел 6.0);
- делать то, что запрещено в `multiplayer.antiCheat.clientCannot`.

Нарушения обнаруживаются при загрузке (статический анализ) и в рантайме (CSP) — игра будет отклонена или заблокирована.

## 6. Сохранения — `GameSlopSave`

### 6.0. Что игра получает от браузера, а что нет (прочитать до первой строки кода)

Игра запускается в `<iframe sandbox="allow-scripts allow-same-origin allow-pointer-lock">` на домене `game.gameslop.ru`. Отсюда три практических правила.

1. **Основная площадка недоступна.** Куки, `localStorage` и токены `gameslop.ru`/`api.gameslop.ru` игра не видит: это другой origin, а куки сессии и админки — `httpOnly` и без атрибута `domain`. **API платформы тоже недоступен:** `fetch("https://api.gameslop.ru/...")` блокируется CSP песочницы, поэтому все данные игры — внутри архива или через `GameSlopSave`/`GameSlopMP`.

2. **Браузерное хранилище есть, но оно общее.** `localStorage`, `sessionStorage` и `indexedDB` работают, однако их origin — `game.gameslop.ru`, **один на все игры платформы**: то, что вы туда положите, сможет прочитать любая другая игра. Поэтому
   - не храните там секреты, токены, персональные данные и вообще ничего, что нельзя показать чужой игре;
   - держите там только некритичный кэш: настройки графики, выбранный режим управления, черновик состояния сессии;
   - **прогресс игрока сохраняйте только через `GameSlopSave`** (ниже) — он привязан к аккаунту и переживает смену устройства.

3. **Без аккаунта прогресс в облако не уходит.** Гость играет, но его сейв остаётся в браузере, а в плеере висит подсказка «Войдите, чтобы работали мультиплеер и сохранения». Не обещайте игроку сохранение, пока он не вошёл: либо скажите об этом в самой игре, либо дайте экспорт состояния.

Дальше — поддерживаемый способ сохранений. Они идут через пост-мост к плееру: локально на основном сайте плюс облачная синхронизация, если в манифесте стоит `save.cloudSync: true`.

### 6.1. Как подключить SDK (важно)

Файлы SDK **отдаются публично** — их можно скачать прямыми ссылками, без доступа к репозиторию платформы:

```bash
curl -O https://gameslop.ru/sdk/gameslop-save.js
curl -O https://gameslop.ru/sdk/gameslop-mp.js
```

Это байт-в-байт те же файлы, что лежат в репозитории платформы (публичные адреса выше). Положите их в папку `sdk/` внутри проекта игры:

| Что взять | Публичный адрес | Копия из репозитория | Куда в архиве |
|---|---|---|---|
| `gameslop-save.js` | `https://gameslop.ru/sdk/gameslop-save.js` | `/sdk/gameslop-save.js` | `sdk/gameslop-save.js` |
| `gameslop-mp.js` | `https://gameslop.ru/sdk/gameslop-mp.js` | `/sdk/gameslop-mp.js` | `sdk/gameslop-mp.js` |

Внутри архива ссылка на SDK обязана быть **относительной**: игра отдаётся из своей папки сборки (`game.gameslop.ru/builds/...`), и абсолютный `/sdk/...` резолвится от домена сборки, где такой папки нет, — это 404.

Если скачать не удалось (нет сети, ссылка недоступна) — не выдумывайте SDK и не копируйте его из чужих игр: напишите об этом пользователю прямым текстом и не выдавайте свою реализацию за платформенную.

Дальше всё зависит от того, что именно нужно игре:

- **сохранения** — мост можно написать вручную: протокол целиком описан в разделе 6.3, файл `sdk/gameslop-save.js` не обязателен;
- **мультиплеер** — берите готовый `sdk/gameslop-mp.js`: в разделе 7.3 описан только формат сообщений, а SDK делает ещё handshake, фильтрацию эха, heartbeat и офлайн-фолбэк, и расхождение с плеером всплывёт уже в живой партии.

**Важно:** этот README описывает контракт SDK, но не содержит его кода. Файл, написанный заново «по описанию», проходит серверную проверку при загрузке, но **совместимость с реальным плеером это не гарантирует**: в опубликованных играх уже находились самодельные копии, которые расходились с платформенным SDK (другие события, нет `setStateRate`, кадр больше 16 КБ рвал соединение). Единственная гарантированно совместимая версия — скачанная по ссылкам выше.

Дальше подключите его **относительным** путём, без ведущего слэша:

```html
<script src="sdk/gameslop-save.js"></script>
```

```html
<!-- ТАК НЕ РАБОТАЕТ: абсолютный путь ведёт на домен сборки, где папки sdk/ нет -->
<script src="/sdk/gameslop-save.js"></script>
```

Кладите SDK именно в папку `sdk/`: при загрузке предупреждение «прогресс в localStorage» не выдаётся только для файлов из неё. Файл, положенный в `js/`, получит это предупреждение (сама публикация не блокируется).

Вне платформы (локальная разработка через `file://` или свой сервер) SDK прозрачно переключается на `localStorage`, так что игру можно отлаживать с сохранениями без публикации.

### 6.2. API

Глобальный объект — **`GameSlopSave`**. Все методы возвращают Promise и **никогда не бросают исключений**: при ошибке приходит `{ ok: false, error: "КОД" }`. Всегда проверяйте `ok`, а не полагайтесь на `try/catch`.

```js
// Устройство запуска: { platform: "mobile" | "desktop", mobile, desktop, touch }.
// Значение приходит из плеера вместе с приветствием; вне платформы определяется
// по вводу. Используйте, чтобы выбрать схему управления (см. раздел 3.2).
const dev = GameSlopSave.device();
if (dev.mobile) showTouchControls(); else showKeyboardHints();

// Метаданные: включены ли сохранения, облако и сколько слотов.
// Возвращается конверт { ok, meta, error }; сами значения — в объекте meta.
const res0 = await GameSlopSave.meta();
if (res0.ok) console.log(res0.meta.saveEnabled, res0.meta.cloudSync, res0.meta.slots);

// Запись
const res = await GameSlopSave.save("best", { score: 1234, at: Date.now() });
if (!res.ok) console.warn("не сохранилось:", res.error);

// Чтение: данные лежат в поле data
const loaded = await GameSlopSave.load("best");
if (loaded.ok) console.log(loaded.data);         // null, если пусто

// Удаление
await GameSlopSave.remove("best");

// Список слотов: какие вообще есть (локальные + облачные, свежайшая версия каждого)
const all = await GameSlopSave.list();
if (all.ok) console.log(all.slots);              // [{ slotId, updatedAt, sizeBytes }, …]

// Сообщить плееру об ошибке игры (попадёт в аналитику и тосты плеера)
GameSlopSave.reportError("WebGL context lost");
```

| Метод | Возвращает |
|---|---|
| `GameSlopSave.meta()` | `{ ok, meta, error }`, где `meta = { saveEnabled, cloudSync, slots }` |
| `GameSlopSave.save(slotId, data)` | `{ ok, synced, error }` |
| `GameSlopSave.load(slotId)` | `{ ok, data, error }` — сами данные в `data` |
| `GameSlopSave.remove(slotId)` | `{ ok, error }` |
| `GameSlopSave.list()` | `{ ok, slots, error }`, где `slots = [{ slotId, updatedAt, sizeBytes }]` — локальные и облачные слоты, объединённые по свежести. Вне платформы перечисляются слоты `localStorage`, у них `updatedAt: null` |
| `GameSlopSave.ready()` | `{ saveEnabled, cloudSync, slots }` — ждёт приветствие плеера. Вне платформы вернёт `saveEnabled: "local"` (строка, не `true`), а если мост молчит — через 5 с `saveEnabled: false` |
| `GameSlopSave.reportError(message)` | ничего; сообщение уходит плееру |

Если пишете свой SDK и метода `list()` в нём нет — протокол моста можно вызвать вручную:

```js
parent.postMessage({ kind: "list", reqId: "l1" }, "*");
// ответ придёт сообщением: { kind: "list", reqId: "l1", ok: true, slots: [{ slotId, updatedAt, sizeBytes }] }
```

Плеер этот запрос понимает (см. 6.3); в платформенном `gameslop-save.js` он есть с этой версии.

Правила:

- слоты — строковые id: `"best"`, `"1"`, `"2"`, `"auto"` и подобные;
- данные — **JSON-сериализуемые**; размер одного слота ограничен `save.maxBytesPerSlot`;
- общий лимит облачных сохранений: 10 МБ на игру на пользователя (настраивается админкой);
- число слотов сервер не ограничивает: длина `slotId` до 64 символов и байты — единственные ограничения, объявленное `save.slots` уважайте сами;
- локальная запись мгновенная, облачная синхронизация — при авторизации пользователя. Если облако отказало (лимит, сеть), `save()` всё равно вернёт `ok: true`, но `synced: false` — прогресс останется только в этом браузере;
- не храните в сохранениях персональные данные и картинки — только состояние игры.

### 6.3. Протокол моста (если пишете SDK сами)

Если готового `gameslop-save.js` нет, мост реализуется вручную. Envelope: игра шлёт `parent.postMessage(msg, "*")`, ответ приходит сообщением с тем же `reqId`. Плеер валидирует origin игры.

Запросы игры:

| `kind` | Поля | Ответ плеера |
|---|---|---|
| `meta` | `reqId` | `{kind:"meta", reqId, ok, meta?: {saveEnabled, cloudSync, slots, maxBytesPerSlot, usedBytes}, error?}` |
| `save` | `reqId, slotId, data` | `{kind:"save", reqId, ok, synced?, error?}` |
| `load` | `reqId, slotId` | `{kind:"load", reqId, ok, data?, error?}` |
| `list` | `reqId` | `{kind:"list", reqId, ok, slots?: [{slotId, updatedAt, sizeBytes}], error?}` |
| `remove` | `reqId, slotId` | `{kind:"remove", reqId, ok, error?}` |
| `event` | `eventType, payload?` | **зарезервирован: сейчас отбрасывается** и в аналитику не попадает. Для сообщения об ошибке используйте `error` (раздел 8) |
| `error` | `message` | без ответа — ошибка игры в аналитику |

Плеер дополнительно присылает игре приветствие и статус синхронизации:

```jsonc
{ "kind": "ready", "saveEnabled": true, "cloudSync": false, "slots": 3,
  "device": { "platform": "mobile", "mobile": true, "desktop": false, "touch": true } }
{ "kind": "sync_status", "status": "idle" | "saving" | "synced" | "error" }
```

Приветствие приходит несколько раз в первые секунды (0, 0.6, 2 и 5 с после создания кадра) — сборка может загружаться дольше, чем портал успевает поздороваться. Ждите его, а не считайте, что моста нет. Источник истины — `packages/shared/src/save-bridge.ts`.

### 6.4. Типичные ошибки сохранений

0. **Прогресс лежит в `localStorage`/`indexedDB`.** Самая частая ошибка: игра «сохраняется», но её данные не покидают браузер — при смене устройства игрок начинает с нуля, а если бы хранилище было недоступно, в консоли был бы `SecurityError`, а на экране — «Хранилище браузера недоступно». Прогресс — только `GameSlopSave`; браузерное хранилище годится лишь для настроек и кэша (раздел 6.0).
1. **Ожидание исключений.** SDK не бросает — читайте `res.ok` и `res.error`.
2. **Забыли `.data`.** `await GameSlopSave.load("x")` возвращает конверт, а не сами данные: нужен `.data`.
3. **Абсолютный путь `/sdk/...`.** Файл надо положить в архив и звать относительно (раздел 6.1).
4. **Сохранение картинок и больших объектов.** Лимит слота — 256 КБ по умолчанию.
5. **`save.enabled: true`, но SDK не подключён.** Сохранения молча не работают; проверьте, что тег `script` действительно загрузился.
6. **Сейв пишется, но никто его не читает.** На старте вызывайте `load` и применяйте данные — иначе игрок каждый раз начинает заново.

## 7. Мультиплеер

### 7.0.0. Проверка мультиплеера ДО публикации (шаг 3 мастера)

В мастере загрузки есть отдельный шаг **«Тест и мультиплеер»**: он поднимает настоящую комнату и открывает **два окна одной и той же сборки на одной странице** — вы играете за хоста, второе окно подключается синтетическим «Тестовым игроком». Это полноценный mp-сервер, а не мок: тикеты настоящие, WS настоящий, комната живёт 30 минут.

Раскладка окон зависит от режима (переключатель «Окна теста»):

- **ПК (16:9)** — окна идут друг под другом: в широком окне видно и мир, и обоих персонажей;
- **телефон (9:16)** — окна стоят рядом, а в игру уходит признак тача, как у игрока с телефона (на узком экране окна переключаются вкладками).

Что ещё важно знать про режим:

- **Курсор игра не захватывает.** В тестовых окнах выключен pointer lock: иначе при переходе между окнами мышь «залипала» бы в первой. Мышиный обзор и прицел проверяйте обычным запуском игры.
- **Ничего специально подключать не нужно** — если игра читает `#mp=` через `gameslop-mp.js`, она просто заработает в обоих окнах.
- **Синтетический игрок — не аккаунт.** У него нет записи в БД: он не попадает в статистику, онлайн и историю комнат. Хост — ваш аккаунт.
- **Два окна одного аккаунта невозможны.** MP-сервис держит одного игрока в одной комнате и при повторном входе закрывает старый сокет (`socket replaced`). Поэтому в тесте второе окно и получает отдельную личность — не пытайтесь тестировать, открыв игру дважды под собой.
- **Тикеты одноразовые.** Кнопка «Перезапустить окно» выпускает свежий тикет; если открыть окно в новой вкладке и обновить её — вход будет отклонён как «токен уже использован».
- **Сколько живёт тест.** Комната теста живёт 30 минут, тикет — тоже 30 минут (как и в обычной партии). После этого тест нужно запускать заново.
- **Сохранения в тесте выключены** — проверяйте их обычным запуском игры.
- **Если шаг пропустился сам** — в манифесте стоит `multiplayer.enabled: false`. Тест нужен только MP-играм.

Под окнами есть панель «Замечания». Она ловит типовые ошибки и объясняет, что делать:

| Что покажет | Что это значит |
|---|---|
| «не отправил join_room за 20 с» | игра не читает `#mp=` **или** упирается в своё меню/экран создания мира (см. 7.0.1) |
| «вход отклонён: ROOM_FULL» | не хватает `multiplayer.maxPlayers` |
| «тикет протух или уже использован» | окно обновили вместо «Перезапустить» |
| «сокет заменён» | тот же игрок подключился вторым соединением |
| «ошибка протокола» | сообщение не по формату `mp-protocol.ts` или превышены лимиты (30 msg/s, 16 КБ) |
| «MP SDK не найден в сборке» | в архиве нет `sdk/gameslop-mp.js` |

Чего тест **не** проверяет: сохранения, реальный тач-ввод (только признак устройства и размер окна), консоль внутри окна игры (для этого «Открыть в новой вкладке» + DevTools) и смысл игровой логики — что персонажи видят друг друга и мир совпадает, смотрите глазами (чек-лист в 7.0.2).

### 7.0. Обзор — кто за что отвечает

В мультиплеере три участника, у каждого своя роль. Не берите на себя чужую:

| Участник | Отвечает за | Не делает |
|---|---|---|
| **Платформа** (`gameslop.ru`) | выбор режима перед запуском, создание комнат, коды и ссылки-приглашения, ввод кода, выдачу тикетов (REST) | не ретранслирует игровые сообщения |
| **mp-сервер** (`mp.gameslop.ru`) | WS-транспорт: relay сообщений, состав комнаты, вместимость, heartbeat, GC, лимиты | не знает правил игры и не валидирует игровое состояние |
| **Игра** (ваш код) | игровое состояние, логику, валидацию критичных действий (античит) | не создаёт комнаты, не показывает собственное лобби |

Платформа сама показывает выбор режима («Одиночная игра», «Мультиплеер», «Подключиться по коду», локальный хост) и страницы комнат/приглашений. Если `multiplayer.required = true`, игра не запускается в одиночном режиме. Не делайте в игре своё лобби, кнопки «создать комнату», приглашения друзей или поле ввода кода — и не делайте REST-вызовы для комнат: песочница игры не имеет сессии пользователя, тикет выдаёт платформа.

Данные запуска приходят в URL-фрагменте `#mp=` (URI-encoded JSON):

- `{"ticket":{"wsUrl","token","roomCode","playerId"},"mode":...}` — игрок уже создал комнату или подключился по коду;
- `{"room":"КОД"}` — переход по ссылке-приглашению без входа: платформа покажет модалку подключения, тикет выдаётся после join.

Игре достаточно распарсить фрагмент и пройти handshake по `wsUrl` (см. 7.2). Рекомендуемый путь — SDK `/sdk/gameslop-mp.js` (см. 7.5): он делает handshake, heartbeat, фильтрацию эха и офлайн-фолбэк сам.

**Обязательный минимум — режим `hostMode: "SERVER"`.** Это основной и единственный режим, нужный для публикации.

**Важно про `multiplayer.modes`.** Поле сейчас ни на что не влияет: платформа всё равно показывает игроку пункт «Локальный хост (WebRTC)», даже если в манифесте стоит `multiplayer.modes: ["server"]`. Если локальные режимы вашей игре не нужны — просто игнорируйте выбранный режим: он придёт в `room_state.hostMode`, а тикет в обоих случаях настоящий, `#mp=` приходит с полем `mode`.

Транспорт: основной сценарий — серверный хост (`hostMode: "SERVER"`). Платформа также поддерживает `LOCAL_WEBRTC` (обмен `offer`/`answer`/`ice` через тот же WS: `webrtc_signal` с полем `to` = playerId, данные игры — через RTCDataChannel) и `LOCAL_WS` (прямой WS к хосту; сайт работает по HTTPS, поэтому локальный сервер обязан использовать `wss://` с доверенным сертификатом). Актуальный режим приходит в `room_state.hostMode` — но **не в SDK**: в payload события `ready` поля `hostMode` нет (см. 7.5), для него нужен свой WS-клиент.

### 7.0.1. Вход в сетевую партию: базовое меню запрещено

Если в `#mp=` пришёл тикет, игрок **уже** выбрал режим на платформе: он создал комнату или вошёл по коду. Игра в этот момент обязана сразу начинать сетевую партию. Никаких «нажмите Одиночная игра», «создайте мир», «введите код комнаты» — этот путь обрывает сценарий на самом интересном месте.

Реальный случай из аудита: у игры в главном меню была одна кнопка «Одиночная игра», а сетевой слой умел только подсказывать «создайте мир с сидом хоста». Формально мультиплеер был, фактически подключившийся игрок не мог попасть в партию: он видел меню и не знал, куда жать.

| Запуск | Что пришло в `#mp=` | Что обязана сделать игра |
|---|---|---|
| Хост выбрал «Мультиплеер» | тикет, он хост | Сразу поднять мир/лобби: код комнаты, список игроков, «начать». Меню не показывать |
| Игрок вошёл по коду | тикет, он гость | Сразу показать «подключаемся к комнате …» и ждать состояние хоста |
| Переход по ссылке-приглашению | `{"room":"КОД"}`, тикета нет | Показать «комната КОД» (вход обычно выполняет платформа). SDK отдаёт `status: "no-ticket"`, `ready` не будет |
| Обычный запуск | `#mp=` нет | Одиночный/горячий режим, свои меню разрешены (`status: "offline"`) |

Правильная развилка на старте — одна проверка, до отрисовки меню:

```js
var mp = GameSlopMP.connect();                 // SDK читает #mp= сам

if (GameSlopMP.isMultiplayerLaunch()) {
  enterNetworkGame(GameSlopMP.pendingRoomCode()); // меню не показываем вообще
} else {
  showMainMenu();                              // локальный запуск
}

mp.on("ready", function (info) {
  if (info.isHost) hostStartsWorld();          // хост создаёт мир и рассылает снапшот
  else showWaitingForHost(info.roomCode);      // гость ждёт хост, а не жмёт «играть»
});
```

Правила:

- **Хостом может оказаться любой.** Сервер назначает хостом первого вошедшего; если это вы — сразу создавайте мир, комната не должна висеть пустой в ожидании клика.
- **Мир создаёт только хост.** Гость никогда не строит свой мир «на всякий случай»: иначе игроки окажутся в разных мирах с одинаковыми координатами и будут ходить сквозь друг друга.
- **Меню можно не переписывать.** Если мультиплеер — надстройка поверх готовой одиночной игры (свой слой поверх движка), базовое меню разрешено «прокликать» программно на старте (`Одиночная игра` → `Создать мир` → `Войти в мир`) — при условии, что **игрок не нажимает ничего сам**. Гостю до создания мира обязательно подставьте сид хоста, иначе миры разойдутся. Требование относится к опыту игрока, а не к способу реализации: нажатия не должно быть в интерфейсе, а не в коде.
- **Код комнаты показывайте игроку** (`ready.roomCode` или `GameSlopMP.pendingRoomCode()`) — иначе ему нечего переслать другу. Поле ввода кода не нужно: код вводит платформа, а `ticket.roomCode` уже в SDK.
- **Не просите нажать «Подключиться».** Вход уже выполнен: тикет лежит в `#mp=`, SDK проходит handshake сам.
- **Внутри сетевой партии не должно быть экранов создания мира** с выбором сида/карты: карту и сид выбирает хост, остальные получают их в снапшоте (см. 7.0.2).
- **Одиночный режим — только ветка «нет `#mp=`».** Если в манифесте `multiplayer.required = true`, кнопка «Одиночная игра» в меню вообще лишняя.
- Единственный допустимый лобби-UI в игре — минимальный fallback для локальной разработки вне платформы (см. 7.4).

Как это ловится: шаг 3 мастера держит окно открытым 20 секунд и, если игра не вошла в комнату, показывает замечание «Окно открылось, но игра не подключилась к комнате» — самая частая причина именно эта.

### 7.0.2. Что синхронизировать: мир, объекты, персонажи

Мультиплеер «работает» только тогда, когда у обоих игроков одинаковые **мир, объекты и персонажи**. Сервер не знает правил вашей игры — он лишь релеит кадры (≤16 КБ, ≤30 сообщений/с), поэтому всю синхронизацию описываете вы.

| Что | Кто владеет | Как передавать | Как часто |
|---|---|---|---|
| Мир, уровень, генерация | хост | сид/идентификатор карты в состоянии (`{seed}`) + детерминированная генерация | один раз в `ready` и в каждом снапшоте |
| Статические объекты: блоки, двери, кнопки, рычаги, чекпоинты, разрушения, лут на карте | хост | дельты (`[x, y, z, id]`), батчами до 200 за кадр | по событию («сломал», «поставил», «нажал») |
| Динамические объекты: враги, NPC, транспорт, снаряды, падающий лут | хост | по id: позиция, поворот, скорость, анимация, hp | 10–20 Гц, только изменившиеся |
| Свой персонаж: позиция, поворот, анимация | сам игрок | `sendInput({p, yaw, pitch})`; хост ретранслирует в состоянии | 8–15 Гц |
| Внешний вид и имя: ник, цвет, скин, модель, команда | сам игрок | `sendCosmetic({nick, skin, color, team})` | при входе и при смене |
| Разовые события: выстрел, взрыв, звук, смена раунда | инициатор | `sendAction(...)` или состояние | одноразово, ≤10/с |
| Критичные числа: счёт, HP, инвентарь, победитель | **только хост** | в состоянии | по изменению |
| Время раунда | хост | `t` (мс от начала раунда) в состоянии | 1–2 Гц, клиенты считают сами от последнего `t` |

**Обязательный минимум:**

- **Сид/идентификатор мира.** Без него каждый строит свой мир: игроки видят разную геометрию и «плавают» друг сквозь друга.
- **Снапшот для вошедшего позже.** Хост подписывается на `mp.on("join", …)` и рассылает полное состояние с пометкой `{full: true}`; клиент, получив `full`, заменяет локальный мир целиком, а не пытается догнать дельтами.
- **Дельта вместо всего мира.** Полный мир каждый кадр не влезет в 16 КБ даже на маленькой карте; шлите только изменения.
- **Квантизация.** Округляйте координаты (2 знака) и углы (целые градусы), иначе один персонаж съедает сотни байт в секунду.
- **Интерполяция чужих персонажей.** Не «телепортируйте» их к каждой присланной точке: держите буфер 100–200 мс и интерполируйте; свой персонаж двигайте сразу (предсказание).
- **Миграция хоста.** На `mp.on("host", …)` новый хост немедленно рассылает снапшот из своего последнего локального состояния — иначе после ухода хоста мир замирает.
- **Проверка рассинхрона.** Кладите в состояние чек-сумму мира (`h`) — клиент сравнивает и при расхождении просит ресинк через `sendAction({ a: "resync" })`, а хост отвечает снапшотом.
- **Никаких `Date.now()` для игровой логики.** Время раунда, кулдауны и таймеры считайте от `t` хоста, иначе у игроков разъедутся раунды.

Каркас, который закрывает всё перечисленное (хост — мир, гость — ожидание, снапшот вошедшему, миграция хоста):

```js
var mp = GameSlopMP.connect();
mp.setStateRate(20);                       // не чаще 20 состояний в секунду (лимит 30/с)

var world = { seed: null, edits: [], enemies: [], players: {}, me: null };

mp.on("ready", function (info) {
  if (info.isHost) {
    world.seed = world.seed || String(Date.now() % 100000);  // мир задаёт хост
    sendSnapshot();                                          // сразу — снапшот всем
  }
  enterNetworkGame(info.roomCode);
});

// Кто-то вошёл: ему нужен полный мир, а не дельты с середины партии
mp.on("join", function (m) {
  if (mp.isHost) sendSnapshot();
  toast((m.player.name || "Игрок") + " подключился", "info");
});

// Смена хоста: новый хост подхватывает мир со своего последнего состояния
mp.on("host", function (m) {
  if (m.isHost) sendSnapshot();
});

function sendSnapshot() {
  mp.broadcastState({
    full: true,                            // клиент заменит мир целиком
    seed: world.seed,
    t: roundElapsedMs(),                   // время от хоста, не Date.now() у клиента
    edits: world.edits.slice(-200),        // последние правки мира
    enemies: world.enemies,                // динамические объекты: позиция, поворот, hp
    players: world.players,                // персонажи: p, yaw, anim, nick, skin, team
    h: worldHash(),                        // чек-сумма мира для проверки рассинхрона
  });
}

// Состояние от хоста
mp.on("state", function (m) {
  var d = m.data || {};
  if (d.full) applyFullSnapshot(d);        // первый кадр / ресинк / новый хост
  else applyDelta(d);                      // обычная дельта
  if (d.h && d.h !== worldHash()) mp.sendAction({ a: "resync" });
});

// Гость: свой персонаж двигается сразу, чужие — интерполируются
setInterval(function () {
  if (!mp.connected || mp.isHost) return;
  mp.sendInput({ p: myPosition(), yaw: myYaw(), anim: myAnim() });
}, 100);

// Хост: дельты мира и динамика, раз в секунду — контрольный снапшот
setInterval(function () {
  if (!mp.connected || !mp.isHost) return;
  if (world.edits.length) {
    var batch = world.edits.splice(0, 200);
    mp.broadcastState({ edits: batch, players: world.players });
    return;
  }
  mp.broadcastState({ enemies: visibleEnemies(), players: world.players, t: roundElapsedMs() });
}, 50);
```

**Бюджет и лимиты.** 16 КБ на кадр (замер по байтам: кириллица — 2 байта на символ; эмодзи — 4). SDK не отправит кадр больше лимита и пришлёт `error PAYLOAD_TOO_LARGE` — иначе сервер закрыл бы сокет (close 1009) прямо посреди партии. Частота: 30 сообщений/с на соединение, `mp.setStateRate(20)` оставляет запас вводу. Прикидка: 10 персонажей × (id 6 Б + 3 координаты по 6 Б + поворот 4 Б + анимация 2 Б) ≈ 300 Б — такое состояние можно слать 50 раз в секунду, полный мир блоками — нельзя.

**Античит.** Позиция, присланная клиентом, — это **не** истина для попаданий, сбора предметов и счёта: хост валидирует скорость, телепорты, кулдауны и всё, что влияет на результат (`multiplayer.antiCheat` в манифесте, см. 7.4). Внешний вид, ник и анимации — косметика, их можно принимать как есть (`sendCosmetic`).

**Как проверить.** Шаг 3 мастера открывает две сборки одной версии: хост и синтетический игрок. Смотрите на четыре вещи: (1) персонажи видят друг друга и двигаются у обоих; (2) правка мира у одного появляется у второго (одинаковый сид); (3) тот, кто вошёл позже, попадает в **тот же** мир, а не в свежесозданный; (4) после выхода хоста второй игрок продолжает играть, а не замирает.

### 7.1. Жизненный цикл комнаты и коды подключения

Комнаты создаёт платформа, игра — никогда:

| Шаг | Кто | Что происходит |
|---|---|---|
| Создание | платформа | `POST /api/mp/rooms {gameId}` → `{room, ticket: {wsUrl, token, roomCode, playerId}}`; создатель становится хостом. Вызывается сайтом — игре этот REST недоступен |
| Вход по коду | платформа | `POST /api/mp/rooms/:code/join` → тикет; игрок попадает в игру с `#mp={ticket, mode}` |
| Инвайт | платформа | ссылка-приглашение ведёт на запуск с `#mp={"room":"КОД"}`; тикет выдаётся после join |

Правила:

- **Вместимость фиксируется при создании комнаты**: `manifest.multiplayer.maxPlayers` клампится в 2–10 (по умолчанию 10) и ограничивается сверху платформенной настройкой `limit_mp_players_per_game` (по умолчанию 10). Превышение → `ROOM_FULL`. Комнат на игру может быть сколько угодно.
- **Хост — первый вошедший в пустую комнату.** При выходе хоста сервер назначает нового и рассылает свежий `room_state` — пересчитайте `isHost` у себя (см. 7.4 и 7.6). Новый хост назначается **только в режиме `SERVER`**: в `LOCAL_WEBRTC`/`LOCAL_WS` при выходе хоста нового не будет — обрабатывайте это как «партия распалась».
- **Один пользователь = одна комната.** Попытка войти во вторую → `ALREADY_IN_ROOM`. Повторный коннект того же пользователя в ту же комнату закрывает его старый сокет (close 4000 «replaced») — это штатное поведение, не ошибка.
- **GC: пустая комната закрывается через 5 минут** (в том числе после рестарта mp-сервиса). Попытка входа в закрытую → `ROOM_CLOSED`. Восстанавливайтесь повторным join, а не «оживлением» старого кода.
- **Партию можно закончить снаружи, а игрока — исключить.** Хост или модератор закрывает комнату (`POST /api/mp/rooms/:id/close`): всем игрокам приходит `error { code: "ROOM_CLOSED" }`, сокеты закрываются кодом **4002**, комната удаляется — кадры после этого не доставляются. Исключение игрока (`POST /api/mp/rooms/:id/kick`) присылает ему `error { code: "KICKED" }` и закрывает только его сокет кодом **4001**, остальные продолжают партию. Для игры это штатный конец (или смена состава): уводите игрока на экран выхода (7.4) и не пытайтесь переподключиться.

### 7.2. Тикеты и handshake

Тикет — JWT (HS256) с payload `{userId, roomId, type: "mp_ticket", jti, exp}`:

- TTL — **30 минут**;
- **одноразовый**: `jti` помечается в Redis при первом использовании; повторное использование → `UNAUTHORIZED`.

Handshake по шагам (SDK делает сам; вручную — строго по этой схеме):

1. Клиент открывает `WebSocket(wsUrl)`. Сервер **сразу** шлёт `hello_ok` с временным guest-id — это не ваш настоящий id.
2. Клиент шлёт `hello {protocolVersion: 1}` → сервер шлёт второй `hello_ok` (в обоих `hello_ok` есть поле `protocolVersion` — версия сервера). **Версия проверяется:** если она не поддерживается (сейчас совместим только v1), сервер отвечает `error { code: "PROTOCOL_MISMATCH" }` и закрывает сокет кодом **4003**. Молчаливая несовместимость форматов кадров хуже явного отказа.
3. Клиент шлёт `join_room {roomCode, token}` **ровно один раз**. Повторный `join_room` в том же соединении сервер просто проигнорирует, а вот новое соединение с тем же тикетом получит `UNAUTHORIZED` «токен уже использован» — тикет одноразовый.
4. При успехе сервер шлёт `room_state` (вошедшему) и `player_joined` (остальным). Момент `ready` в SDK = получение `room_state`.

```js
// Вручную (без SDK); SDK повторяет эту схему сам:
const ws = new WebSocket(ticket.wsUrl);
ws.onopen = () => ws.send(JSON.stringify({ type: "hello", protocolVersion: 1 }));
// на первый hello_ok — один раз:
ws.send(JSON.stringify({ type: "join_room", roomCode: ticket.roomCode, token: ticket.token }));
```

**При обрыве соединения НЕ переиспользуйте старый токен.** Он одноразовый и уже сожжён — повторные `hello` + `join_room` с ним дадут `UNAUTHORIZED`. Автопереподключения в SDK нет: `closed` — финальное событие.

**Новый тикет игра получить не может.** Эндпоинт `POST /api/mp/rooms/:code/join` требует сессии сайта (`Authorization: Bearer`), а у сборки в песочнице её нет; к тому же CSP запрещает игре запросы к `api.gameslop.ru` (раздел 5). Поэтому при `closed` покажите экран «соединение потеряно» с кнопкой, которая возвращает игрока на страницу игры (`gameslop.ru/game/…`) — платформа выдаст свежий тикет при новом запуске. Хосту можно предложить создать новую комнату и позвать друзей заново.

### 7.3. Сообщения и лимиты

Все сообщения — JSON поверх WS. Клиент → сервер:

| Тип | Поля | Назначение |
|---|---|---|
| `hello` | `protocolVersion` | первый шаг handshake. Нет поля, не число или версия вне диапазона → `PROTOCOL_MISMATCH` и закрытие сокета кодом **4003** |
| `join_room` | `roomCode`, `token` | вход в комнату, ровно один раз |
| `leave_room` | — | выход из комнаты |
| `game_event` | `event`, `data` | событие игры (имя из белого списка) |
| `state_update` | `seq`, `data` | состояние (релеится всем; авторитет — хост, см. 7.4) |
| `ping` | `ts` | app-level ping → в ответ `pong` |
| `chat` | `text` | чат комнаты |
| `webrtc_signal` | `to`, `signal` | signaling для WebRTC, адресат — поле `to` |

Сервер → клиент:

| Тип | Поля | Значение |
|---|---|---|
| `hello_ok` | `playerId`, `serverTime` | приходит дважды: при открытии сокета (временный guest-id) и в ответ на `hello` |
| `room_state` | `roomCode`, `players[{id,name,isHost}]`, `maxPlayers`, `hostMode` | полный состав; также рассылается при смене хоста |
| `player_joined` | `player` | новый участник |
| `player_left` | `playerId`, `reason?` | участник вышел/отключился |
| `game_event` | `from`, `event`, `data` | релей события от другого игрока |
| `state_update` | `from`, `seq`, `data` | релей состояния |
| `pong` | `ts` | ответ на `ping` |
| `webrtc_signal` | `from`, `signal` | релей signaling |
| `chat` | `from`, `name`, `text` | сообщение чата |
| `error` | `{code, message}` | ошибка протокола (таблица ниже). SDK добавляет в payload поле `fatal` (см. 7.3 и 7.5) |

Белый список имён `game_event` (иначе `FORBIDDEN_EVENT`): `player_input`, `request_action`, `cosmetic_state`, `state_prediction`, `local_error`.

Лимиты рантайма:

| Ограничение | Значение | При нарушении |
|---|---|---|
| Размер кадра | ≤ 16 КБ | `error PAYLOAD_TOO_LARGE`, close 1009 |
| Частота | ≤ 30 сообщений/с на соединение | `error RATE_LIMITED`, close 1008 |
| Чат | ≤ 500 символов | сообщение отклоняется (SDK сам обрезает до 300) |
| Бинарные кадры | запрещены | close 1003 |
| Невалидный JSON | — | close 1003 |

Чат ведёт себя не совсем как обычное сообщение:

- пустой (после `trim`) чат и чат от замьюченного игрока **игнорируются молча** — ни ошибки, ни уведомления;
- системные сообщения приходят в тот же канал `chat` с `from: "system"` и `name: "system"` (например, «… теперь хост комнаты») — не показывайте их как сообщение игрока;
- сообщение длиннее 500 символов отклоняется ошибкой `PAYLOAD_TOO_LARGE`, но соединение остаётся живым.

Коды ошибок сервера (см. `packages/shared/src/mp-protocol.ts`):

| Код | Причина |
|---|---|
| `ROOM_NOT_FOUND` | код комнаты не найден |
| `UNAUTHORIZED` | токен неверен, истёк или **уже использован** (повторный join) |
| `BANNED` | пользователь заблокирован |
| `ALREADY_IN_ROOM` | пользователь уже в другой комнате |
| `ROOM_CLOSED` | комната закрыта: GC, хостом или модератором. Рассылается всем игрокам, сокеты закрываются кодом **4002** |
| `ROOM_FULL` | комната заполнена |
| `NOT_IN_ROOM` | действие требует входа в комнату |
| `FORBIDDEN_EVENT` | событие вне белого списка |
| `PAYLOAD_TOO_LARGE` | кадр > 16 КБ (close 1009) |
| `RATE_LIMITED` | > 30 сообщений/с (close 1008) |
| `BAD_HELLO` | пришло сообщение без поля `type` (не по формату `mp-protocol.ts`) |
| `PROTOCOL_MISMATCH` | `hello` с несовместимым `protocolVersion`: поля нет, не число или версия вне диапазона v1..v1. Сокет закрывается кодом **4003** |
| `INVALID_PAYLOAD` | `join_room` с отсутствующим/пустым `roomCode` или `token`; или сообщение не сериализуется в JSON |
| `KICKED` | игрока исключили из комнаты (хост или модератор); его сокет закрывается кодом **4001** |
| `INTERNAL` | внутренняя ошибка сервера («Сервис временно недоступен») |

**Терминальные ошибки и `fatal`.** В payload события `error` есть поле `fatal`:

| `fatal` | Что это значит | Что делает SDK |
|---|---|---|
| `true` | сессию продолжать нельзя, `ready` уже не будет | закрывает сокет и следом эмитит `closed { code: 4000, reason: "<код ошибки>" }`. Исключение — `RATE_LIMITED`: сокет закрывает сам сервер, поэтому `closed` придёт с его кодом `1008` |
| `false` | ошибка не ломает соединение | ничего не закрывает, партия продолжается |

Терминальные коды **сервера**: `PROTOCOL_MISMATCH`, `UNAUTHORIZED`, `ROOM_CLOSED`, `KICKED`, `BANNED`, `ROOM_NOT_FOUND`, `ROOM_FULL`, `ALREADY_IN_ROOM`, `NOT_IN_ROOM`, `INTERNAL`, `RATE_LIMITED`.

Коды, которые **генерирует сам SDK** (`gameslop-mp.js`); при этом `PROTOCOL_MISMATCH`, `PAYLOAD_TOO_LARGE` и `INVALID_PAYLOAD` сервер присылает и сам — их серверные версии описаны в таблице выше. Здесь важно, что в этих случаях делает SDK:

| Код | Когда | `fatal` |
|---|---|---|
| `PROTOCOL_MISMATCH` | то же расхождение версий, но пойманное на стороне SDK: он получил `hello_ok` с чужой `protocolVersion` и рвёт сессию сам, не дожидаясь сервера | `true` |
| `CONNECT_FAILED` | не удалось открыть WebSocket (`wsUrl` недоступен, запрещён CSP и т.п.) | `true` |
| `PAYLOAD_TOO_LARGE` | кадр не влез в 16 КБ и не был отправлен (сервер такую же ошибку присылает с close 1009) | `false` |
| `INVALID_PAYLOAD` | данные не сериализуются в JSON | `false` |

Сценарий выхода для игры: показать экран «соединение потеряно» по `closed` **или** по `error.fatal === true` и вернуть игрока на страницу игры за новым запуском (см. 7.2) — ждать `ready` после терминальной ошибки нечего.

Heartbeat: сервер пингует WS каждые 30 с; соединение, молчащее 60 с, принудительно отключается (остальные увидят `player_left`). SDK сам шлёт app-level `ping` каждые 25 с — через SDK ничего делать не нужно.

### 7.4. Ответственность игры

Сервер — только транспорт: он не проверяет корректность игрового состояния. Клиент — **не доверенный** источник критичных данных.

- **Авторитет — хост.** Счёт, инвентарь, победа вычисляются у хоста; клиенты отправляют только ввод (`sendInput`/`sendAction`) и косметику. Сервер релеит `state_update` от любого участника, не проверяя, хост ли он, — контроль за тем, кто рассылает состояние, лежит на игре (античит-контракт: `multiplayer.antiCheat` в манифесте).
- **Запуск с тикетом — сразу сетевая партия** (`GameSlopMP.isMultiplayerLaunch()`), без базового меню и экранов создания мира: см. 7.0.1.
- **Синхронизация мира, объектов и персонажей — на игре**, и это не только позиции игроков: см. таблицу и каркас в 7.0.2.
- **Обрабатывайте `player_joined`/`player_left`.** При выходе хоста последний `room_state` несёт новый состав: пересчитайте `isHost`; если хостом стали вы — продолжайте со своего последнего локального состояния, рассылая `broadcastState`.
- **`selfId` берите только из `ready`** (он равен `ticket.playerId`). `playerId` из первого `hello_ok` — временный guest-id, не совпадающий с настоящим.
- **Офлайн-фолбэк.** Без `#mp=` сессия переходит в `offline`, события `ready` не будет никогда — показывайте локальный/горячий режим, не ждите сеть. Единственный допустимый лобби-UI в игре — минимальный fallback ввода кода комнаты для локальной разработки вне платформы.
- **Выход — через `mp.leave()`** (отправляет `leave_room` и закрывает сокет). Иначе вас отключит heartbeat, а комната будет висеть до GC.
- **Терминальная ошибка — это конец сессии.** По `closed` (или `error.fatal === true`, см. 7.3) показывайте экран «соединение потеряно» и возвращайте игрока на страницу игры за новым запуском; ждать `ready` после неё нечего.
- **До `ready` отправлять нечего.** `sendInput`/`sendAction`/`sendCosmetic`/`sendLocalError`/`sendChat`/`broadcastState` возвращают `false`, пока не пришёл `room_state`: кадры, отправленные во время рукопожатия, сервер отклонил бы как `NOT_IN_ROOM`. Запускайте игровой цикл из `mp.on("ready")`.
- **Heartbeat** (см. 7.3): через SDK — автоматически; на сыром WS пингуйте сами, иначе через 60 с молчания сервер отключит соединение.

### 7.5. SDK: API-справка (`/sdk/gameslop-mp.js`)

> **Справка описывает актуальный платформенный SDK; копия файла внутри старой сборки игры может вести себя иначе (нет `mp.status`, `fatal`, `setStateRate`) — берите SDK по ссылке из 6.1.**

```html
<script src="sdk/gameslop-mp.js"></script>
```

```js
const mp = GameSlopMP.connect(); // читает #mp= сам; без фрагмента — offline-сессия
if (GameSlopMP.isMultiplayerLaunch()) skipMainMenuAndEnterRoom(); // см. 7.0.1
mp.on("ready", (info) => { /* { selfId, isHost, players, roomCode, maxPlayers } — можно стартовать партию */ });
mp.on("join",  (m) => { if (mp.isHost) sendFullSnapshot(); });    // вошедшему позже — весь мир, см. 7.0.2
mp.on("closed", () => showRejoinScreen()); // автопереподключения нет — closed финален
mp.setStateRate(20);                        // состояние не чаще 20 раз в секунду
```

События (`mp.on(имя, fn)`):

| Событие | Payload | Когда |
|---|---|---|
| `ready` | `{selfId, isHost, players, roomCode, maxPlayers}` | получен `room_state` после join — старт сетевой партии. `room_state` отдаётся не целиком: поля `hostMode` в payload нет (нужен — читайте протокол сами, 7.2) |
| `players` | `players` | обновлённый состав (join/left, смена хоста) |
| `join` | `{player, players}` | кто-то вошёл — хост шлёт ему полный снапшот мира (7.0.2) |
| `leave` | `{playerId, players}` | кто-то вышел/отключился |
| `host` | `{isHost, players}` | сменился хост: новый хост немедленно рассылает снапшот |
| `state` | `{from, seq, data}` | состояние от другого игрока (эхо своих сообщений SDK фильтрует) |
| `event` | `{from, event, data}` | событие от другого игрока (эхо отфильтровано) |
| `chat` | `{from, name, text}` | сообщение чата |
| `error` | `{code, message, fatal}` | ошибка протокола (см. 7.3). `fatal: true` — сессию продолжать нельзя: SDK закроет сокет и следом придёт `closed` (для `RATE_LIMITED` сокет закрывает сервер, `closed` придёт с кодом 1008); `fatal: false` — партия продолжается. Локальный код `PAYLOAD_TOO_LARGE` — сообщение не отправлено, соединение живо |
| `closed` | `{code, reason}` | соединение закрыто; событие финальное — автопереподключения нет, игрока нужно вернуть на страницу игры за новым запуском (7.2) |
| `status` | строка | текущее состояние сессии: `connecting` (идёт handshake) \| `ready` (партия готова) \| `offline` (нет `#mp=`) \| `no-ticket` (тикет неполный или пришла ссылка-приглашение `{"room":"КОД"}`) \| `closed` (сессия окончена). Все статусы, включая `connecting`, объявляются **асинхронно** — подписка сразу после `connect()` их не пропустит. `mp.on("status", fn)` дополнительно вызывает `fn` с текущим статусом, если он уже известен, — поздняя подписка тоже узнаёт состояние. Текущее значение всегда доступно в `mp.status` |

Методы:

| Метод | Что делает |
|---|---|
| `GameSlopMP.connect()` | создаёт сессию: handshake, ping каждые 25 с, фильтрация эха. Подключение начинается на следующем тике, поэтому `mp.on("error", …)`/`mp.on("closed", …)`, поставленные сразу после `connect()`, не пропустят `CONNECT_FAILED` |
| `GameSlopMP.isMultiplayerLaunch()` | `true`, если в `#mp=` **полный** тикет (`wsUrl` + `token` + `roomCode`) — игру запустили ради сетевой партии (7.0.1). Тикет без одного из полей сетевым запуском не считается |
| `GameSlopMP.pendingRoomCode()` | код комнаты из `#mp=` (тикет или ссылка-приглашение) либо `null` |
| `GameSlopMP.maxFrameBytes` | лимит кадра в байтах (16384), как у mp-сервера |
| `mp.sendInput(data)` | `game_event player_input` — ввод игрока. До `room_state` возвращает `false`: во время рукопожатия отправлять нечего |
| `mp.sendAction(data)` | `game_event request_action` — запрос действия (рестарт, готовность, `{a:"resync"}`) |
| `mp.sendCosmetic(data)` | `game_event cosmetic_state` — косметика (ник, цвет, скин, команда) |
| `mp.sendLocalError(data)` | `game_event local_error` — локальная ошибка для хоста/аналитики |
| `mp.broadcastState(data)` | `state_update` с авто-`seq`; вызывать **только хосту**. Полный снапшот помечайте сами: `{full: true, …}`. Кадр проверяется по фактической длине (в т.ч. при `setStateRate`), `seq` растёт только когда кадр реально ушёл |
| `mp.setStateRate(hz)` | ограничить частоту рассылки состояния (Гц, ≤25); лишние вызовы схлопываются — уходит последнее состояние |
| `mp.sendChat(text)` | чат (обрезает до 300 символов) |
| `mp.leave()` | `leave_room` + закрытие соединения |
| `mp.connected`, `mp.status`, `mp.isHost`, `mp.selfId`, `mp.players`, `mp.roomCode`, `mp.maxPlayers`, `mp.offline`, `mp.isTest` | текущее состояние сессии (только чтение). `mp.status` — строка статуса из таблицы событий |

### 7.6. Полный рабочий пример мультиплеера

Минимальная сетевая игра: хост рассылает состояние, остальные шлют ввод. Скопируйте это как основу.

```html
<!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <title>Пример мультиплеера</title>
</head>
<body>
  <canvas id="c" width="800" height="500"></canvas>
  <p id="status">Подключаемся…</p>

  <!-- SDK лежит в архиве рядом с index.html, путь относительный -->
  <script src="sdk/gameslop-mp.js"></script>
  <script>
    var statusEl = document.getElementById("status");
    var ctx = document.getElementById("c").getContext("2d");

    var mp = GameSlopMP.connect();          // читает #mp= сам
    var state = { players: {}, ball: { x: 400, y: 250 } };
    var running = false;

    mp.on("status", function (s) {
      statusEl.textContent = "Статус: " + s;
      // "offline" — нет #mp=: это локальная разработка или одиночный режим
      if (s === "offline") { running = true; statusEl.textContent = "Одиночный режим"; }
    });

    mp.on("ready", function (info) {
      // Только здесь появляется настоящий selfId и признак хоста.
      running = true;
      statusEl.textContent = (info.isHost ? "Вы хост" : "Вы гость") +
        " · комната " + info.roomCode + " · игроков " + info.players.length;
      state.players = {};
      for (var i = 0; i < info.players.length; i++) {
        state.players[info.players[i].id] = { x: 100 + i * 60, y: 250 };
      }
      state.selfId = info.selfId;
      state.isHost = info.isHost;
      if (info.isHost) broadcast();
    });

    // Состав изменился. Если хост ушёл, сервер пришлёт новый room_state,
    // и isHost пересчитается — не запоминайте старое значение.
    mp.on("players", function (players) {
      statusEl.textContent = (mp.isHost ? "Вы хост" : "Вы гость") +
        " · игроков " + players.length;
      state.isHost = mp.isHost;
      if (state.isHost) broadcast();
    });

    mp.on("state", function (m) {
      // Авторитетное состояние от хоста. Гость его только применяет.
      if (state.isHost) return;
      state.ball = m.data.ball;
      state.players = m.data.players;
    });

    mp.on("event", function (m) {
      // Ввод другого игрока.
      if (m.event !== "player_input") return;
      var p = state.players[m.from];
      if (p) { p.y = m.data.y; }
    });

    mp.on("error", function (m) {
      console.warn("MP error:", m.code, m.message);
      statusEl.textContent = "Ошибка сети: " + m.code;
    });

    mp.on("closed", function (m) {
      // Автопереподключения нет. Новый тикет из игры получить нельзя
      // (нужна сессия сайта, CSP блокирует api.gameslop.ru):
      // покажите «зайдите в игру заново» и верните игрока на страницу игры.
      running = false;
      statusEl.textContent = "Соединение закрыто: " + (m.reason || m.code);
    });

    function broadcast() {
      // Вызывает только хост, иначе состояние начнут рассылать все.
      if (!mp.isHost || !mp.connected) return;
      mp.broadcastState({ ball: state.ball, players: state.players });
    }

    // Игрок двигает мышь — отправляем только ввод, а не готовое состояние.
    window.addEventListener("mousemove", function (e) {
      if (!mp.connected || mp.isHost) return;
      var rect = document.getElementById("c").getBoundingClientRect();
      mp.sendInput({ y: e.clientY - rect.top });
    });

    // Хост двигает мяч сам и 20 раз в секунду рассылает состояние.
    setInterval(function () {
      if (!running) return;
      state.ball.x += 2;
      if (state.ball.x > 800) state.ball.x = 0;
      draw();
      if (state.isHost) broadcast();
    }, 50);

    function draw() {
      ctx.fillStyle = "#0e1419";
      ctx.fillRect(0, 0, 800, 500);
      ctx.fillStyle = "#5fe4b5";
      ctx.beginPath();
      ctx.arc(state.ball.x, state.ball.y, 10, 0, Math.PI * 2);
      ctx.fill();
      for (var id in state.players) {
        ctx.fillStyle = id === state.selfId ? "#ffffff" : "#2f8f7a";
        ctx.fillRect(60, state.players[id].y - 30, 12, 60);
      }
    }

    // Уходя со страницы, выходим из комнаты: иначе игрок «висит» до таймаута.
    window.addEventListener("pagehide", function () { mp.leave(); });
  </script>
</body>
</html>
```

Что здесь важно и почему:

- `selfId` и `isHost` берутся **только** из `ready`/`players`, не из `hello_ok`;
- `broadcastState` вызывает **только хост** — иначе состояние рассылают все и игра рассинхронизируется;
- клиенты шлют **ввод** (`sendInput`), а не готовый счёт — это требование античита;
- мир, объекты и внешний вид персонажей синхронизируются отдельно и по правилам из 7.0.2 (сид мира, дельта, снапшот вошедшему позже);
- при `offline` игра обязана работать локально: `ready` в этом режиме не наступит никогда;
- `mp.leave()` на `pagehide`, чтобы комната не висела.

### 7.7. Частые ошибки (из реального аудита)

1. **Двойной `join_room`.** Тикет одноразовый: новое соединение с уже использованным тикетом получает `UNAUTHORIZED` (в том же сокете повторный `join_room` просто игнорируется). Шлите `join_room` ровно один раз (SDK делает это сам).
2. **`selfId` до `ready`.** `playerId` из `hello_ok` при открытии сокета — временный guest-id. Настоящий id приходит только в `ready`.
3. **Игнор `room_state` при смене хоста.** Вышедший хост не «вернётся»: последний `room_state` несёт новый состав. Не пересчитали `isHost` — состояние рассылается в никуда.
4. **Reconnect со старым токеном.** Токен одноразовый: повторные `hello` + `join_room` со старым тикетом дадут `UNAUTHORIZED`. Новый тикет из игры не получить (нужна сессия сайта, а CSP запрещает запросы к `api.gameslop.ru`) — уводите игрока на страницу игры за свежим запуском.
5. **Доверие клиентскому `state_update`.** Сервер релеит состояние от любого участника без проверки авторитета. Валидация — ответственность игры (хост).
6. **Нет `leave` при выходе.** Игрок «висит» в комнате до heartbeat-таймаута или GC, у остальных рассинхрон состава. Всегда вызывайте `mp.leave()`.
7. **Игнор чужих событий.** Необработанные `event`/`state` от других игроков — прямой путь к рассинхрону состояния. Обрабатывайте всё.
8. **Ожидание `ready` в офлайне.** Без `#mp=` (локальная разработка, одиночный режим) сессия `offline` и `ready` не наступит. Показывайте локальный режим.
9. **Меню вместо сетевой партии.** Тикет есть, а игра показывает главное меню с одной кнопкой «Одиночная игра» — подключившийся игрок никуда не попадает (7.0.1). Проверка `isMultiplayerLaunch()` на старте обязательна.
10. **Игроки в разных мирах.** Нет общего сида/идентификатора карты: геометрия разная, персонажи «плывут» друг сквозь друга. Сид задаёт хост (7.0.2).
11. **Вошедший позже попал в пустой мир.** Хост рассылает только дельты, а полного снапшота не делает. Подписка на `join` + `{full:true}` закрывает это.
12. **Кадр больше 16 КБ закрыл сокет.** Полный мир «на всякий случай» в каждом кадре: сервер отвечает `PAYLOAD_TOO_LARGE` и close 1009. SDK такой кадр не отправит, но состояние всё равно нужно резать на дельты.
13. **Свой `Date.now()` для раунда и таймеров.** Часы у игроков расходятся: раунд заканчивается в разное время. Время берётся из состояния хоста (`t`).

## 8. Аналитика плеера (автоматически)

Плеер сам отправляет: начало запуска, успешный запуск, heartbeat сессии, вход в полный экран, конец сессии. **Ошибка запуска и события сохранений сейчас не отправляются** — счётчики для них в админке остаются пустыми. От игры требуется только сообщение об ошибке:

```js
parent.postMessage({ kind: "error", message: "WebGL context lost" }, "*");
```

## 9. Производительность

- старт игры после загрузки ассетов — без искусственных задержек;
- держите первый кадр < 3 с; тяжёлые ассеты грузите асинхронно с прогрессом;
- цель — 60 FPS на среднем ноутбуке; мобильная поддержка — через `capabilities.touch`;
- избегайте утечек памяти (долгие сессии с heartbeat);
- тестируйте восстановление из паузы/фуллскрина.

## 10. Коды ошибок платформы

| Код | Где | Значение |
|---|---|---|
| `MANIFEST_MISSING` | загрузка | манифест не найден |
| `MANIFEST_INVALID` | загрузка | схема манифеста нарушена (детали в списке ошибок) |
| `ENTRY_MISSING` | загрузка | entry-файл отсутствует в архиве |
| `FORBIDDEN_FILE` | загрузка | запрещённый файл в архиве |
| `PATH_TRAVERSAL` | загрузка | выход за пределы архива |
| `MANIFEST_NOT_JSON` | загрузка | `manifest.json` не разбирается как JSON |
| `ZIP_TOO_LARGE` | загрузка | архив больше 200 МБ |
| `ZIP_EMPTY` | загрузка | архив пуст или не читается |
| `ZIP_BOMB` | загрузка | суммарный распакованный размер больше 512 МБ |
| `TOO_MANY_FILES` | загрузка | в архиве больше 2000 файлов |
| `COMPRESSION_RATIO` | загрузка | файл больше 10 МБ сжат сильнее чем в 100 раз |
| `SYMLINK_DETECTED` | загрузка | в архиве символическая ссылка |
| `EXTERNAL_URL` | загрузка | найдены внешние ссылки |
| `SUSPICIOUS_CODE` | загрузка | `document.cookie`, `parent.document` или активный контент в SVG |
| `MEDIA_MISSING` | загрузка | предупреждение: файл из `media` не найден в архиве (публикацию не блокирует) |
| `RATE_LIMITED` | загрузка, вход, комментарии, аналитика, мультиплеер | дневной лимит: 3 новые игры и 3 версии на игру в сутки, сброс в 00:00 МСК (в сообщении — сколько минут ждать). Ещё антифлуд: вход и регистрация, восстановление пароля, комментарии, аналитика, поиск комнаты и слишком частые сообщения в мультиплеере |
| `SAVE_TOO_LARGE` | сейвы | слот больше `save.maxBytesPerSlot`; в игре это `{ ok: false, error: "SAVE_TOO_LARGE" }` |
| `TOO_LARGE` | сейвы | (HTTP 413) превышен общий лимит 10 МБ на игру; в игре видно как `{ ok: true, synced: false }` |
| `ROOM_FULL` | мультиплеер | комната заполнена |
| `ROOM_NOT_FOUND` | мультиплеер | код комнаты не найден/истёк |
| `FORBIDDEN_EVENT` | мультиплеер | событие вне белого списка |

Дневные лимиты загрузок (3 игры в сутки и 3 версии на игру; превышение — `RATE_LIMITED` с текстом «Сброс через N мин») не действуют для администраторов: у роли `ADMIN` дневного лимита нет — владелец платформы заливает игры пачками. Другим аккаунтам лимит можно переопределить персонально через `User.limitsOverride`: `{"unlimited": true}` снимает оба лимита, `{"gamesPerDay": N, "versionsPerDay": N}` задаёт свои значения (`0` = без ограничения). Приоритет: персональное переопределение → роль → платформенная настройка (`limit_games_per_day` / `limit_versions_per_day`).

При фолбэках пользователю сообщаются только человекочитаемые формулировки — без внутренних технических деталей.

## 11. Инструкция для нейросети: порядок работы

Работайте строго по шагам. Не переходите к следующему, пока предыдущий не проверен.

1. **Спросите про платформу** (раздел 0.1): телефон, компьютер или мультиплатформа. Это влияет на управление и на то, кто увидит игру в каталоге.
2. **Спросите про ассеты** (раздел 0.2). Нет скриншотов и логотипа — скажите об этом и назовите точные папки. Ждите ответа.
3. **Соберите минимальную версию:** `manifest.json` + `index.html`, где игра запускается и играбельна. Без сохранений, без мультиплеера, без внешних библиотек.
4. **Проверьте манифест** по разделу 3: шесть обязательных полей, `version` в формате `X.Y.Z`, объекты `save` и `multiplayer` присутствуют целиком, `requirements.supportsDesktop` / `supportsMobile` соответствуют ответу из шага 1 (раздел 3.1).
5. **Сделайте управление под платформу** (раздел 3.2): экранный джойстик и кнопки для телефона, клавиатура и мышь для ПК, обе схемы для мультиплатформы.
6. **Добавьте сохранения**, если игра этого требует: возьмите файл `gameslop-save.js` из репозитория платформы (публичной ссылки на него нет — см. 6.1), положите в `sdk/`, подключите относительным путём, работайте через `res.ok` (раздел 6).
7. **Добавьте мультиплеер**, только если он нужен: `multiplayer.enabled: true`, файл SDK `gameslop-mp.js` из репозитория платформы в `sdk/` (см. 6.1), хост рассылает состояние, остальные шлют ввод, офлайн-режим предусмотрен (раздел 7). Обязательно: запуск с тикетом сразу входит в сетевую партию, минуя базовое меню (7.0.1), и синхронизирует мир, объекты и персонажей, а не только позиции игроков (7.0.2).
8. **Проверьте запреты** (раздел 5): нет внешних URL, нет `document.cookie`, нет `parent.document`, нет запрещённых файлов.
9. **Пройдите чек-лист** раздела 12 — целиком, по пунктам.
10. **Прогоните предполётную проверку** (раздел 14): отправьте собранный ZIP на `POST /api/validate` с токеном из раздела 14 и добейтесь `"ok": true`. Это тот же код, которым архив проверяет публикация, но без создания игры и версии.
11. **Соберите ZIP** и отдайте пользователю с коротким отчётом: что внутри, какие поля манифеста заполнены, что осталось на стороне пользователя (например, ассеты).

В ответе пользователю всегда указывайте:

- имя архива и точку входа;
- для какой платформы игра и как это отражено в манифесте;
- включены ли сохранения и мультиплеер;
- какие файлы ассетов отсутствуют и куда их положить;
- какие шаги вы не смогли проверить сами.

---

## 12. Чек-лист перед загрузкой

**Архив**

- [ ] `manifest.json` лежит в корне архива
- [ ] файл из `entry` лежит в архиве (рекомендуем `index.html` в корне) — платформа открывает именно его
- [ ] Все пути относительные, без `..`, без ведущего `/`, без символических ссылок
- [ ] Нет запрещённых файлов (`.exe`, `.sh`, `.php`, `.py`, `.aspx`), папки `node_modules` и любых имён с ведущей точкой (`.git`, `.gitignore`, `.env`, `.DS_Store`)
- [ ] Нет внешних URL: CDN, шрифты, картинки, аналитика, реклама. Запросов к `api.gameslop.ru` тоже нет — CSP их блокирует
- [ ] Нет файлов-«бомб»: степень сжатия в пределах разумного
- [ ] Файлы SDK скопированы внутрь архива, если используются

**Манифест**

- [ ] `manifestVersion: 1`
- [ ] `game.title` заполнен (1–120 символов)
- [ ] `entry` указывает на существующий файл внутри архива (обычно `index.html`)
- [ ] `version` в формате `X.Y.Z`
- [ ] `save` присутствует и содержит `enabled`
- [ ] `multiplayer` присутствует и содержит `enabled`
- [ ] `save.cloudSync` выставлен осознанно (`true` — если нужна синхронизация)
- [ ] `multiplayer.maxPlayers` в диапазоне 2–10, если мультиплеер включён
- [ ] `ai` заполнен, если игру сгенерировала нейросеть: `generator` — харнесс, `model` — модель внутри него
- [ ] Если игру сделал человек — в манифесте стоит `"ai": { "used": false }`, иначе публикация вернёт `ATTRIBUTION_REQUIRED`
- [ ] Пути в `media` указывают на **реально существующие** файлы
- [ ] `requirements.supportsDesktop` и `requirements.supportsMobile` заполнены и совпадают с реальностью
- [ ] Если игра только для телефона — экранное управление нарисовано и проверено
- [ ] Если игра только для ПК — в каталоге с телефона она не появится, и это осознанное решение

**Сохранения**

- [ ] Прогресс игрока сохраняется через `GameSlopSave`, а не в `localStorage`/`indexedDB`
- [ ] `GameSlopSave.list()` — только если платформенный SDK актуальный; своему SDK список слотов придётся запрашивать через `{kind:"list"}` (см. 6.2)
- [ ] Если браузерное хранилище всё же используется, там только настройки/кэш и нет секретов (оно общее для всех игр домена)
- [ ] SDK подключён относительным путём и реально загружается
- [ ] Код читает `res.ok` и `res.data`, а не ждёт исключений
- [ ] Данные в сохранении — JSON-сериализуемые и меньше лимита слота (по умолчанию 256 КБ, суммарно не больше 10 МБ на игру)
- [ ] Игра корректно стартует, когда сохранения нет (`data === null`)
- [ ] Игра не обещает сохранение гостю без аккаунта (или объясняет, что прогресс останется в браузере)

**Мультиплеер**

- [ ] В архив положен **актуальный** `sdk/gameslop-mp.js` (по ссылке из 6.1), а не копия из старой сборки чужой игры: справка 7.5 описывает актуальный SDK, а у старой копии может не быть `mp.status`, `fatal` и `setStateRate`
- [ ] Запуск с тикетом сразу входит в сетевую партию: базовое меню и экраны создания мира не показываются (7.0.1)
- [ ] Хост (в том числе назначенный сервером) сам создаёт мир и начинает партию, не дожидаясь клика
- [ ] Гость ничего не создаёт сам: он ждёт снапшот хоста и видит код комнаты
- [ ] Мир/уровень задаётся сидом или идентификатором карты от хоста (7.0.2)
- [ ] Статические объекты (блоки, двери, кнопки, лут) синхронизируются дельтами, а не всем миром каждый кадр
- [ ] Динамические объекты (враги, NPC, транспорт, снаряды) синхронизируются по id с разумной частотой
- [ ] Внешний вид и имя персонажа (ник, цвет, скин, команда) передаются и видны остальным
- [ ] Вошедшему позже хост отправляет полный снапшот (`mp.on("join")`, пометка `{full:true}`)
- [ ] При смене хоста новый хост продолжает партию и рассылает снапшот (`mp.on("host")`)
- [ ] Время раунда и таймеры считаются от состояния хоста, а не от локальных часов
- [ ] Кадр состояния меньше 16 КБ, есть `mp.setStateRate(...)` или своя частота ≤20 Гц
- [ ] `selfId` берётся из `ready`, а не из `hello_ok`
- [ ] `join_room` отправляется ровно один раз (SDK делает это сам)
- [ ] `broadcastState` вызывает только хост
- [ ] `room_state`/`players` пересчитывают `isHost` при смене хоста
- [ ] Клиенты отправляют только ввод, счёт считает хост
- [ ] Есть ветка `offline`: игра работает без `#mp=` и не ждёт `ready`
- [ ] `mp.leave()` вызывается при выходе со страницы
- [ ] Обработаны `error` и `closed`; при `closed` или `error.fatal === true` игрок уводится на страницу игры за новым запуском (тикет из игры не получить)
- [ ] Игра стартует по событию `ready` (или по `mp.status === "ready"`), а не по своему таймеру (7.5); до `ready` ничего не отправляет

**Игровой опыт**

- [ ] Игра запускается без консольных ошибок
- [ ] Первый кадр появляется быстрее 3 секунд
- [ ] Управление описано и работает на клавиатуре и на тач-экране
- [ ] Нет утечек: длинная сессия не замедляет игру
- [ ] Понятно, что делать игроку в первые 5 секунд

---

## 13. Что дальше

Ссылка на игру после публикации — короткий токен: `gameslop.ru/game/<автор>/<12 hex-символов>` (подробности и превью ссылок — `docs/LINKS-AND-PREVIEWS.md`).

Загрузка: `gameslop.ru/upload`. Мастер начинается с выбора: **«Это новая игра»** создаёт новую карточку, **«Обновить мою игру»** добавляет архив новой версией к уже существующей игре (адрес, статистика, отзывы и сейвы игроков сохраняются, а номер версии мастер подставляет из её истории). Дальше — архив: сначала ZIP, потом шаг «Информация», поля которого уже заполнены из `manifest.json` (`game.title`, `game.shortDescription`, `game.description`, `game.tags`, `game.genre`, `game.contentRating`, `game.language`, `requirements`) — вручную писать их не нужно, достаточно проверить. Дальше платформа распакует архив, проверит манифест и запреты, отсканирует код и опубликует сборку. При отказе вернётся код ошибки из раздела 10 — исправляйте по нему и загружайте новую версию.

Актуальные спецификации и файлы на портале:

- `https://gameslop.ru/README-FOR-AI.md` — этот файл, эталон правил;
- `https://gameslop.ru/docs` — документация SDK портала: сохранения, рекорды, комнаты, упаковка;
- SDK платформы байт-в-байт: `https://gameslop.ru/sdk/gameslop-save.js`, `https://gameslop.ru/sdk/gameslop-mp.js`, `https://gameslop.ru/sdk/gs-storage-shim.js`;
- расширенный SDK портала (сейвы + рекорды + комнаты одним файлом): `https://gameslop.ru/sdk/gameslop.js`.

---

## 14. Рекорды: таблица портала

У платформы GameSlop собственной таблицы рекордов нет, но портал gameslop.ru ведёт
свою — для всех игр и в боевом, и в локальном режиме. Результат сдаётся одним
вызовом расширенного SDK:

```html
<script src="sdk/gameslop.js"></script>
```

```js
var res = await GameSlop.submitScore(score, { level: level });
// res = { rank, best, stored: true } — место в таблице и рекорд игры
```

Файл кладётся в архив относительно (`sdk/gameslop.js`), как и файлы платформы.
Вне портала SDK прозрачно работает без сети. Игра на платформенном диалекте
(`gameslop-save.js`) рекорды не сдаёт: её прогресс живёт в облачных сейвах.
Правила те же: счёт считает игра (хост в мультиплеере), клиент не доверяет сам себе.
