Документация GameDevPartner SDK

Unity SDK v2.20.1 — интеграция за 10 минут

Нужно быстрее? Скопируйте AI-промпт в ChatGPT/Claude — нейросеть сделает интеграцию за вас.

Обзор

SDK отслеживает:

  • Установки (атрибуция по реферальным ссылкам)
  • Покупки в игре (IAP) с серверной валидацией
  • Рекламный доход (rewarded / interstitial / banner)
  • Платные установки (Яндекс Директ, Unity Ads) — без AppMetrica и AppsFlyer
  • Игровую аналитику — сессии, удержание, экономику, воронки офферов и обучения (с v2.8.0)

Платформа по этим данным определяет, какой инфлюенсер привёл игрока, и начисляет ему долю с дохода.

1. Установка через Unity Package Manager

Рекомендуемый способ — через реестр

Пакет появится в My Registries рядом с остальными, с выпадающим списком версий и рабочей кнопкой Update. Обновление — один клик, без правки файлов. Каждый релиз публикуется в реестр автоматически.

Добавьте в Packages/manifest.json блок scopedRegistries рядом с dependencies:

{
  "scopedRegistries": [
    {
      "name": "GameDevPartner",
      "url": "https://gamedevpartner.com/api/v1/upm",
      "scopes": ["com.gamedevpartner"]
    }
  ],
  "dependencies": {
    "com.gamedevpartner.sdk": "2.20.1"
  }
}

Адрес на .com — для сборок под Google Play и App Store; уже настроенные проекты с gamedevpartner.ru работают как раньше. Если пакет не скачивается и Unity пишет connect ETIMEDOUT у com.gamedevpartner.sdk, укажите реестр GitLab напрямую: https://gitlab.com/api/v4/projects/81635489/packages/npm/ — пакет тот же. Пока открыто окно Package Manager, в консоли может появляться InvalidCastException — на сборку не влияет.

Реестр публичный — токен не нужен. После сохранения откройте Window → Package Manager → My Registries.

URL ведёт на наш тонкий прокси к тому же реестру, а не на gitlab.com напрямую. Причина: GitLab не реализует npm-поиск, и при открытом окне Package Manager Unity сыпал бы InvalidCastException каждый тик. Прокси гасит поиск, а версии и кнопка Update работают как обычно. Если в манифесте стоит старый gitlab.com/...-адрес — замените url на строку выше.

Альтернатива — по git-URL

Подходит, если не хотите трогать scopedRegistries. Важное отличие: у такой установки кнопка «Update» работать не будет — см. следующий блок.

// Window → Package Manager → + → Add package from git URL...
https://gitlab.com/nonstop1899/gamedevpartner-unity-sdk.git#v2.20.1

Если установили по git-URL: кнопка «Update» не сработает

Сначала посмотрите строку «Installed From» в Package Manager. Если она заканчивается на #v2.9.0 или другой тег — пакет прибит к этой версии, и кнопка «Update» ничего не сделает: Unity считает, что версия задана явно. Это не сбой, а поведение git-зависимости с фиксированной ревизией.

В этом случае обновление — одно действие: + → Add package from git URLи вставить тот же адрес с новым тегом (запись перезапишется, Remove не нужен):

https://gitlab.com/nonstop1899/gamedevpartner-unity-sdk.git#v2.20.1

Либо поменять хвост #v… в Packages/manifest.json вручную.

Если же адрес без тега (тянется ветка по умолчанию), Package Manager часто кэширует старый commit hash, и «Update» действительно может не подтянуть новый код. Тогда нужен полный ресет:

  1. Package Manager → GameDevPartner SDK → Remove
  2. Закрыть Unity
  3. Удалить вручную папки <project>/Library/PackageCache/com.gamedevpartner.sdk@*
  4. Открыть Unity, дождаться рекомпиляции
  5. Package Manager → + → Add package from git URL → вставить URL заново → Add

Требования

  • Unity 2020.3 или новее
  • Других Unity-пакетов не требуется
  • Unity IAP — опционально. Если установлен (com.unity.purchasing), SDK подхватит и соберёт дополнительную суб-сборку GameDevPartner.SDK.IAP с классом GDPUnityIAP. Если не установлен — суб-сборка игнорируется Unity целиком через defineConstraints, ошибок нет, меню Window → GameDevPartner работает как обычно.

Проверка успешной установки

  1. В консоли нет ошибок из Library/PackageCache/com.gamedevpartner.sdk@...
  2. В меню есть пункт Window → GameDevPartner → Settings
  3. При открытии Settings виден ScriptableObject с полем "API Key"

Если меню Window → GameDevPartner не появилось — значит runtime-ассембли не собралась. Найдите в консоли первую ошибку из папки пакета (Library/PackageCache/com.gamedevpartner.sdk@...). Ошибки о GUID в .meta-файлах ваших ассетов (Assets/Modeling/..., Assets/Textures/...) — не относятся к SDK, игнорируйте их. Если ошибки SDK есть — сделайте полный ресет по шагам выше.

2. Настройка API Key

// 1. Window → GameDevPartner → Settings
// 2. Вставить API Key из ЛК:
//    gamedevpartner.ru → Мои игры → карточка игры → "API-ключ"
//    ОДИН ключ на игру — тот же во всех сборках (RuStore, Google Play, App Store)
// 3. Region: выбирается ПО МАГАЗИНУ этой сборки.
//    RuStore                     → RU    (приём на api.gamedevpartner.ru)
//    Google Play / App Store     → World (приём на api.gamedevpartner.com)
//    Оба контура пишут в ОДНУ базу — кабинет и отчёты общие. Требует SDK 2.18.0+.
// 4. Store: магазин ЭТОЙ сборки — RuStore / GooglePlay / AppStore.
//    По нему аналитика бьётся между магазинами: RuStore и Google Play обе android,
//    по платформе неразличимы — только по Store. Задаётся отдельно в каждом билде.
// 5. Auto Identify Player: ON (рекомендуется — идентификация по device ID)
// 6. Debug Mode: ON при разработке, OFF в продакшене
//
// SDK инициализируется автоматически через [RuntimeInitializeOnLoadMethod].
// Никаких Init() вызовов в коде не требуется.

Одна игра в нескольких магазинах (RuStore + Google Play + App Store)

Ключ один на игру — тот же во всех сборках. Различает магазины поле Store, которое задаётся в настройках каждого билда отдельно. RuStore и Google Play обе android — по платформе неразличимы, только по Store. По нему весь дашборд (доход, DAU, удержание, реклама) бьётся между магазинами в одном проекте — отчёт «Сравнение магазинов».

Store не зависит от способа оплаты: RuStore-сборка может принимать деньги через YooKassa — тогда Store=RuStore, а Source=YooKassa. Обёртка GDPUnityIAPсама берёт источник платежа из настроенного Store.

Region: какой контур выбирать

Сборка для RuStoreRegion = RU → api.gamedevpartner.ru
Сборка для Google PlayRegion = World → api.gamedevpartner.com
Сборка для App StoreRegion = World → api.gamedevpartner.com

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

Нужен SDK 2.18.0+. Сборки на 2.12.2–2.17.3 поле Region игнорируют и всегда шлют на .ru — это была защита тех времён, когда мирового контура ещё не существовало. Если выбрали World, а события идут на RU, значит пакет старый: проверьте строку инициализации в логе — Region=… Url=….

Если сервер недоступен — игра продолжает работать

SDK спроектирован так, что отсутствие аналитики не влияет на игру. Проверяемые гарантии:

  • Конфига в проекте нет — SDK молча не инициализируется, без ошибок и исключений.
  • API-ключ не заполнен — одно предупреждение в лог, дальше тишина.
  • Любой вызов GameDevPartnerSDK.* до инициализации — ранний выход, исключение не летит.
  • Сервер недоступен, отвечает 5xx или запрос завис — события остаются в офлайн-очереди и уходят при следующей попытке. Очередь ограничена (500 событий), память не растёт.
  • Запрос не ждёт вечно: с 2.18.1 стоит таймаут 20 с. До неё зависшее соединение могло тихо остановить отправку аналитики до конца сессии — игра работала, но данные не шли.

Отдельный случай — ответ 4xx (кроме 429): такой батч отбрасывается осознанно, чтобы испорченный payload не заблокировал отправку навсегда.

3. Трекинг покупок

Одна строка для Unity IAP — автоматический трекинг всех покупок:

using GameDevPartner.SDK;

// БЫЛО:
UnityPurchasing.Initialize(this, builder);

// СТАЛО:
GDPUnityIAP.Initialize(this, builder);

// Всё. Каждая успешная покупка через Google Play / App Store
// автоматически отправляется в GameDevPartner с правильным
// TransactionId, Amount, Currency и ReceiptData.

Для других платёжных систем (YooKassa, TBank, RuStore, веб) — ручной вызов:

using GameDevPartner.SDK;

// В callback успешной оплаты:
GameDevPartnerSDK.TrackPurchase(new PurchaseEvent {
    ProductId = "gem_pack_100",
    Amount = 99f,                      // цена без вычета комиссии магазина
    Currency = "RUB",                  // ISO 4217
    Source = PaymentSource.YooKassa,   // см. таблицу ниже
    TransactionId = paymentId,         // уникальный ID транзакции
    ReceiptData = receipt,             // опционально, для валидации
});
Подписки — тоже покупки, шлите их так же. Это самый частый пропуск: игра сообщает о наборах монет и офферах, а про подписку молчит — оплата видна в консоли магазина, но не в кабинете.
  • Сообщайте и первую оплату, и каждое продление, которое видите. Продление списывается на серверах магазина без запуска игры — сами мы о нём не узнаем.
  • TransactionId у подписок бывает пустым: RuStore отдаёт invoiceId/subscriptionToken. Не пропускайте покупку из-за этого — передайте что есть и обязательно ReceiptData, платформа выведет устойчивый идентификатор из чека.
  • Повтор той же покупки безопасен (дедупликация по идентификатору). Опасен пропуск.
В аналитике подписки идут отдельной статьёй дохода — так пробел заметен сразу.

Подписки: три поля в SDK и вебхук магазина

С версии 2.20.1 у PurchaseEvent есть три необязательных поля. Заполните их — и платёж превращается в отслеживаемую подписку с MRR, продлениями и оттоком.

GameDevPartnerSDK.TrackPurchase(new PurchaseEvent {
    ProductId = "vip.monthly",
    Amount = 599f, Currency = "RUB",
    Source = PaymentSource.RuStore,
    ReceiptData = receipt,
    IsSubscription = true,             // это подписка
    SubscriptionPeriodDays = 30,       // 7 / 30 / 365 — нужен, чтобы привести цену к месяцу
    ExpiresAt = "2026-09-01T12:00:00Z" // необязательно: не пришлёте — посчитаем сами
});

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

Чего мы не увидим без вас: автопродление списывается на серверах магазина, когда игра закрыта, — клиент в этот момент не работает и TrackPurchase не вызывается.

ИсточникПервая оплатаПродленияОтмена / истечение
ЮKassa, Т-Банк, веб, ручная оплатаSDKSDK (игрок платит в игре)вебхук
RuStoreSDKнужен вебхукнужен вебхук
Google PlaySDKSDK только если игру открыли; истина — RTDNнужен вебхук
App StoreSDKSDK только если игру открыли; истина — ASSNнужен вебхук

Чтобы закрыть пробел, перешлите вебхук своего магазина со своего бэкенда:

POST https://gamedevpartner.ru/api/v1/subscriptions/event
Authorization: Bearer <API-ключ игры>
Content-Type: application/json

{ "player_id": "...", "product_id": "vip.monthly",
  "event_type": "renewed",   // purchased | renewed | canceled | expired | refunded | grace | on_hold
  "amount": 599, "currency": "RUB", "period_days": 30,
  "transaction_id": "...", "source": "rustore",
  "occurred_at": "2026-09-01T12:00:00Z" }

Одна и та же транзакция, пришедшая и от клиента, и по вебхуку, схлопывается в одну — пересылайте всё, дубли ничего не стоят. Если вебхука нет, на вкладке «Подписки» мы прямо пишем, сколько продлений пришло с сервера: неполнота не должна выглядеть как «мало продлений».

Разовые покупки с сервера (веб-магазины)

Если игрок платит на вашем сайте (веб-магазин, Т-Банк/ЮKassa), а начисление серверное — клиент про покупку не знает, и TrackPurchase не вызовется никогда. Для таких платежей есть серверное зеркало клиентского трека:

POST https://gamedevpartner.ru/api/v1/purchases/event
Authorization: Bearer <API-ключ игры>
Content-Type: application/json

{ "player_id": "тот же, что шлёт клиент",
  "product_id": "web_pack_500",
  "amount": 499, "currency": "RUB",
  "source": "tbank",           // google_play | apple | yookassa | rustore | tbank | web | other
  "transaction_id": "...",
  "occurred_at": "2026-09-01T12:00:00Z" }
  • Обработка — та же, что у клиентской покупки: инфлюенсер-атрибуция, антифрод, валидация и дедуп общие. Одна покупка от клиента и вебхуком схлопнется в одну (повтор отдаёт 200 с dedup: true — пересылайте всё).
  • player_id — тот же идентификатор, что игра шлёт через SDK, иначе покупка не привяжется к приведённому игроку.
  • Добавьте is_subscription и subscription_period_days — и платёж двинет подписочную цепочку ровно как клиентский.

Значения PaymentSource

ЗначениеКогда использовать
PaymentSource.GooglePlayGoogle Play Billing (Unity IAP ставит автоматически)
PaymentSource.AppleApple StoreKit (Unity IAP ставит автоматически)
PaymentSource.YooKassaЮKassa SDK
PaymentSource.TBankТБанк / Тинькофф
PaymentSource.RuStoreRuStore Billing
PaymentSource.WebВеб-оплата (Stripe, PayPal)
PaymentSource.OtherЛюбой другой провайдер

Несколько платёжных рельсов в одной игре (частый случай для РФ)

Обёртка GDPUnityIAP.Initialize покрывает только Unity IAP. Для РФ норма — набор «Unity IAP + YooKassa + TBank + RuStore» одновременно. Не размечайте каждую точку отдельно: заведите одну общую точку и зовите её из success-колбэка каждого рельса, задавая Source явно. Unity IAP при этом остаётся на обёртке (там Source берётся из настройки Store автоматически).

// Одна точка для всех НЕ-Unity-IAP рельсов:
void TrackGdpPurchase(string productId, float amount, string currency,
                      string txId, PaymentSource source) {
    GameDevPartnerSDK.TrackPurchase(new PurchaseEvent {
        ProductId = productId, Amount = amount, Currency = currency,
        TransactionId = txId, Source = source,
    });
}

// В success-колбэке YooKassa:
TrackGdpPurchase(order.productId, order.amount, "RUB", payment.Id, PaymentSource.YooKassa);

// В success-колбэке TBank:
TrackGdpPurchase(order.productId, order.amount, "RUB", tbankId, PaymentSource.TBank);

// В success-колбэке RuStore Billing:
TrackGdpPurchase(sku, price, "RUB", purchaseId, PaymentSource.RuStore);

// Unity IAP — НЕ здесь: он покрыт обёрткой GDPUnityIAP.Initialize(this, builder).

Магазин сборки (Store) и способ оплаты (Source) — разные вещи: RuStore-сборка может принимать деньги через YooKassa, тогда Store=RuStore, а Source=YooKassa. Обёртка Unity IAP берёт Source из Store; для ручных рельсов Source задаёте вы.

4. Трекинг рекламного дохода

Один универсальный метод с позиционными аргументами (нет класса AdRevenueEvent):

GameDevPartnerSDK.TrackAdRevenue(
    double revenue,   // доход за показ; 0 — для Unity Ads standard (см. ниже)
    string currency,  // "USD" / "RUB" / ISO 4217
    string adType,    // "rewarded" | "interstitial" | "banner"
    string adNetwork, // строка (не enum): "yandex_ads" | "unity_ads" | "admob"
                      //                   "ironsource" | "applovin" | "other"
    string adUnitId   // опционально, ID рекламного блока
);
Два сценария

1. Сети с ILAR (AppLovin MAX, IronSource/LevelPlay, AdMob, Yandex с медиацией) — передавайте реальный revenue из callback показа. Данные приходят в реальном времени.

2. Unity Ads standard SDK (без LevelPlay) — revenue в callback не приходит. Передавайте revenue=0, а платформа каждую ночь в 01:15 МСК сама импортирует точный доход через Unity Monetization Stats API и распределит по показам. Для этого разработчик подключает Unity Ads в ЛК (см. раздел 5).

Примеры по сетям

// ═══ Yandex Ads (с медиацией) — Android ═══
rewardedAd.OnAdImpression += (sender, data) => {
    var json = JsonUtility.FromJson<ImpressionJson>(data.rawData);
    GameDevPartnerSDK.TrackAdRevenue(
        json.revenue, json.currency, "rewarded", "yandex_ads", adUnitId);
};

// ═══ AppLovin MAX ═══
MaxSdkCallbacks.Rewarded.OnAdRevenuePaidEvent += (id, info) =>
    GameDevPartnerSDK.TrackAdRevenue(
        info.Revenue, "USD", "rewarded", "applovin", id);

// ═══ IronSource (LevelPlay) ═══
IronSourceEvents.onImpressionDataReadyEvent += (data) =>
    GameDevPartnerSDK.TrackAdRevenue(
        data.revenue ?? 0, "USD", data.adUnit, "ironsource");

// ═══ AdMob (value в micros — делим на 1 000 000) ═══
rewardedAd.OnAdPaid += (adValue) =>
    GameDevPartnerSDK.TrackAdRevenue(
        adValue.Value / 1000000.0, adValue.CurrencyCode, "rewarded", "admob", adUnitId);

// ═══ Unity Ads standard SDK (БЕЗ LevelPlay) ═══
// ВАЖНО: в стандартном Unity Ads SDK revenue в callback не отдаётся.
// Подключите Unity Ads в ЛК (см. раздел 5). Передавайте revenue=0 — это
// нужно, чтобы привязать показ к конкретному игроку.
public class AdController : MonoBehaviour, IUnityAdsShowListener {
    public void OnUnityAdsShowComplete(string placementId, UnityAdsShowCompletionState state) {
        if (state == UnityAdsShowCompletionState.COMPLETED) {
            GameDevPartnerSDK.TrackAdRevenue(0, "USD", "rewarded", "unity_ads", placementId);
        }
    }
}

Неправдоподобный доход за показ обнуляется, и вы об этом узнаете

Если за ОДИН показ приходит ровное целое число при сумме от 60 ₽ — доход не попадает в вашу выручку. Рекламные сети платят долю цента с шестью знаками; ровное целое — это не выручка, а константа, вписанная руками.

Повод реальный: в одной игре нашлись 914 показов ровно по 1,00 $ при медиане 0,0017 и p99 = 0,09 — 27% её рекламного дохода за всю историю, почти все с устройств одной страны, одно устройство дало 514 показов за три сессии. В аналитике это выглядело как выручка, которой нет в кабинете сети.

Отличает подделку не размер, а точность: у другой игры p99 честно 1,27 $, а самый дорогой показ — 57,75 ₽ Яндекса, и это правда. Строка сохраняется целиком (исходное значение остаётся рядом), ответ SDK возвращает число обнулённых, а вам приходит уведомление. Молча ваши деньги никто не вычёркивает.

iOS vs Android могут использовать разные сети. Если на Android — Yandex, а на iOS — Unity Ads, добавляйте TrackAdRevenue в callback каждой сети с правильным adNetwork. Платформа сама определит ОС, а название сети задаёте вы.

5. Подключение Reporting API сетей в ЛК

Для каждой сети, которая не отдаёт revenue через SDK, платформа ночью тянет доход из Reporting API самой сети. Разработчик подключает его один раз: ЛК → Мои игры → разверните карточку игры → секция «Рекламные сети».

СетьЧто вставитьГде взять
Unity AdsOrganization Core ID, Monetization Stats API Key, Game IDs (Android+iOS через запятую)Unity Dashboard → Organization Settings → Organization core ID
Monetization → Setup → API Management → Monetization Stats API Access
Projects → Game ID каждой платформы
AppLovin MAXReport API Key, Package Name (опционально)MAX Dashboard → Account → Keys → Report Key
IronSource / LevelPlaySecret Key, Refresh Token, App Key (опционально)IronSource → My Account → API

Поля "опционально" (Package Name / App Key / Game IDs) — это фильтр "только эта игра". Крайне рекомендуется заполнять, иначе Reporting API суммирует доход по всем играм аккаунта разработчика, что испортит атрибуцию.

6. Серверная валидация покупок

Для серверной валидации чеков платформе нужны ключи платёжных систем. Подключение: ЛК → Мои игры → раздел "Платёжные системы".

СистемаЧто нужноГде получить
Google PlayService Account JSONGoogle Cloud Console → Service Accounts
AppleIssuer ID, Key ID, Private Key (.p8)App Store Connect → Keys
ЮKassaShop ID, Secret KeyЛичный кабинет ЮKassa
ТБанкTerminal Key, Secret KeyТБанк Бизнес
RuStoreCompany ID, Key ID, Private KeyRuStore Console

7. Игровая аналитика (с v2.8.0)

SDK умеет не только приносить деньги в отчёт, но и объяснять, почему их столько. Отчёты живут в кабинете: Аналитика игры.

Нулевой уровень — работает без единой строки кода

Как только заполнен API Key, SDK сам шлёт сессии, версию приложения, признаки нестабильности и паспорт устройства (модель, GPU, ОС — с v2.12). Уже на этом уровне в кабинете появятся DAU, длительность сессий, удержание по когортам, сравнение версий и стабильность по устройствам. Всё остальное ниже — по желанию.

События не задним числом

Того, что не размечено сегодня, за прошлые дни уже не появится — в отличие от денег, которые можно пересчитать. Поэтому размечать выгоднее широко и сразу, а смотреть отчёты — потом. Проверить, что уже размечено, можно в Unity:Window → GameDevPartner → Доктор аналитики (v2.9.0) — окно покажет, какие отчёты заработают, а какие останутся пустыми.

Экономика: откуда берётся и куда уходит валюта

Показывает источники и стоки, инфляцию и долю валюты, появившейся мимо учёта (обычно это или незакрытая дыра в балансе, или читы).

using GameDevPartner.SDK;

// Начислили: source — откуда (quest, level_up, ad_reward, purchase...)
GameDevPartnerSDK.TrackCurrencyEarned("gold", 250, "quest_complete", balanceAfter: 1250);

// Потратили: sink — на что (hero_upgrade, chapter_skip, wardrobe...)
GameDevPartnerSDK.TrackCurrencySpent("gold", 500, "hero_upgrade", balanceAfter: 750);

balanceAfter обязателен: по нему ловится рассинхрон, когда сумма начислений и трат перестаёт сходиться с реальным балансом игрока. Словари source и sink — ваши собственные строки, SDK ничего не навязывает.

Офферы: что и когда продаётся

GameDevPartnerSDK.TrackOfferShown(
    offerId: "starter_pack", productId: "com.game.starter", price: 249, currency: "RUB",
    trigger: "after_3_losses",
    context: new Dictionary<string, object> { { "day_n", 2 }, { "league", "silver" } });

GameDevPartnerSDK.TrackOfferClicked("starter_pack");
GameDevPartnerSDK.TrackOfferPurchased("starter_pack", "com.game.starter", 249, transactionId);
GameDevPartnerSDK.TrackOfferDismissed("starter_pack");
GameDevPartnerSDK.TrackOfferExpired("starter_pack", seenCount: 4);

context — самое ценное поле: кабинет умеет резать воронку «показ → покупка» по любому ключу из него. Кладите туда всё, что знаете о моменте показа. Работает с SDK 2.14.1: более ранние версии молча превращали словарь в строку — код игры при этом был правильным, достаточно пересобрать на 2.14.1.

Три правила, без которых воронка офферов врёт:
  • productId в покупке — тот же, что в показе. Платформа привязывает покупку к последнему показу того же игрока и того же товара: productId — ключ атрибуции. Покупка товара, которого не было в показах, попадёт в явную строку «без показа», а не в чей-то ноль.
  • offerId в покупке — из того же словаря, что в показах.Реальная ошибка живой игры: показы слали PackForBeginner/MilitaryBag, а покупки — место показа (popup/store). Два словаря в одном поле склеить нельзя.
  • Зовите TrackOfferClicked по тапу на оффер — без него ступень «клик» в воронке всегда ноль, и не видно, где именно теряются игроки: на интересе или на оплате.

В кабинете по каждому офферу считаются конверсия показ → покупка (того же игрока и товара) и медиана времени до покупки — сколько думают перед оплатой. Покупка наследует триггер своего показа, поэтому разрез по триггеру тоже настоящий.

Обучение, прогрессия, реклама

// Воронка онбординга — где отваливаются новички
GameDevPartnerSDK.TrackTutorialStep(stepN: 3, name: "first_battle");
GameDevPartnerSDK.TrackTutorialStep(stepN: 4, name: "choose_hero", skipped: true);

// Прогрессия: kind обобщённо — account | hero | battlepass | league
GameDevPartnerSDK.TrackProgression("account", level: 12, cost: 500);

// Уровни с исходом (2.10.0) → отчёт «Уровни»: винрейт, звёзды, время
// победы/поражения раздельно, отвал на следующий. win отличает от Progression.
GameDevPartnerSDK.TrackLevelStart("5");
GameDevPartnerSDK.TrackLevelEnd("5", win: false, stars: 0, durationSec: 42);

// Реклама: события для своих разрезов через events-explorer.
// Деньги и eCPM отчёт ads берёт из TrackAdRevenue, а не отсюда.
GameDevPartnerSDK.TrackAdShown("main_menu", "rewarded", "admob");
GameDevPartnerSDK.TrackAdCompleted("main_menu", rewarded: true);

Своё событие

GameDevPartnerSDK.TrackEvent("boss_defeated", new Dictionary<string, object> {
    { "boss_id", "dragon" }, { "attempts", 7 }, { "hero_level", 12 },
});

События копятся и уходят пачкой; на выходе из игры — принудительно.GameDevPartnerSDK.FlushAnalytics() нужен редко: например, перед экраном, после которого игра гарантированно закроется.

Сравнение магазинов

Если игра выходит в RuStore, Google Play и App Store, отчёт «Сравнение магазинов» показывает игроков, DAU, выручку IAP и доход с рекламы раздельно по магазинам. Работает по полю Store из настроек сборки (см. блок «Одна игра в нескольких магазинах» выше) — нужен SDK v2.11+. Пока сборка не обновлена, выручка в отчёте бьётся по каналу оплаты, а поведение и реклама попадают в «не указан».

Уровни: попытки и винрейт

Отчёт «Уровни» строится из пары TrackLevelStart / TrackLevelEnd(см. блок «Обучение, прогрессия, реклама» выше): число попыток, винрейт, средние звёзды, медиана времени победы и поражения раздельно (долгая победа — гринд-стена, быстрое поражение — спайк-стена), доля брошенных и отвал на следующий уровень. Именно исход попытки (win) отличает его от старого TrackProgression, который показывает только «докуда дошли».

Сравнение версий

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

Отчёт о релизе из CI или от ИИ-ассистента

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

curl -X POST https://gamedevpartner.ru/api/v1/dev-api/releases   -H "Authorization: Bearer gdp_ваш_токен"   -H "Content-Type: application/json"   -d '{"gameId":"...","version":"1.4.0","note":"подняли цену стартового набора"}'

Вызов идемпотентен по паре «игра + версия»: повторный запрос обновит запись, а не создаст дубль. Дату можно не передавать — возьмётся момент вызова. Список своих игр:GET /api/v1/dev-api/games. Написанное сразу появится в отчёте «Влияние релиза», и там же его можно поправить руками.

8. Кабинет игрока (с v2.16.1)

Кабинет, который игрок открывает изнутри игры: достижения, посчитанные платформой из аналитики этой же игры, и награды за них, возвращающиеся в игру. Открывается нативным окном поверх игры — на Android своей Activity, на iOS WKWebView, без сторонних плагинов. В редакторе и на десктопе — системным браузером.

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

В Unity Editor кабинет всегда открывается браузером — это не дефект

Нативного окна в редакторе не существует, поэтому SDK штатно уходит в системный браузер. То же на десктопе. Проверять «поверх игры или в браузере» имеет смысл только на реальном устройстве: в редакторе ответ всегда один и тот же, у любой игры.

Профиль игрока и стена титулов (с 09.08.2026)

У кабинета есть визитка в шапке и отдельный экран профиля. Клиентской работы ноль — всё отдаётся сервером и уже работает у тех, кто установил вашу игру раньше: пересобирать ради этого не нужно.

  • Имя — игрок вводит его сам, необязательно. Без имени он называется «Игрок#4213» и ничего не теряет.
  • Аватар — 12 эмблем, которые рисуются разметкой (работают в первую секунду у любой игры), либо любая уже открытая им иконка достижения, если вы их сгенерировали.
  • Витрина — три ступени, которые игрок выбрал показывать.
  • Личные рекорды — лучшее по каждой линии рядом с текущим.
  • Титулы — список заработанных, с выбором надетого.

Стена титулов — /hub/board, вход с главного экрана кабинета: кто ещё носит титулы вашей игры. Это не рейтинг силы: ни мест, ни очков, ни номера игрока — люди и то, что они носят, самые редкие титулы сверху. Видят её все, попадают в неё только те, кто сам разрешил показывать свой титул; по умолчанию выключено. Пока носителей меньше трёх, стена не открывается: пустая витрина чужого статуса хуже её отсутствия.

Титул виден игроку только в кабинете — если вы не покажете его у себя

Титул считается по реальной редкости среди игроков вашей игры («его носят 4%»), и это самое дефицитное, что умеет платформа. Но пока он живёт только в кабинете, его видит один человек — сам владелец.

С SDK 2.16.6 имя и надетый титул приезжают вместе с бейджем, без лишнего запроса: GameDevPartnerSDK.Profile.Nickname и .Title, плюс событие OnIdentityChanged(nick, title) — игрок мог переименоваться в кабинете, и ваш экран должен это подхватить. Оба поля null, пока игрок не завёл имя или не разрешил показ: это нормальное состояние, а не ошибка — называйте его так же, как называли до нас.

Каталог — ваш, и он редактируется

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

Заголовки машина пишет черновиками и честно их так помечает — прочитайте перед тем, как их увидят игроки.

Чего там нет намеренно: правки порогов и правил. Порог связан с посчитанной долей — «20 глав» показывается игроку как «достигли 11% игроков» именно потому, что это измерено на вашей телеметрии. Поставив порог руками, вы разорвёте связь, и кабинет продолжит показывать долю, которой порог больше не соответствует. Нужен другой порог — пересоберите каталог, тогда пересчитается и доля, и горизонт, и размер награды.

Тексты достижений пишете вы — через API, а не руками

Заголовки, которые генерирует платформа, машинные по построению: «Пройдено уровней: 20». Мы не знаем словаря вашей игры — в ней это может быть «Двадцать дней в Точке», и разница между двумя строками есть разница между системным уведомлением и достижением, которым хвастаются.

Поэтому формулировки и переводы пишете вы, а платформа даёт инструмент. Тот же токен, что для аналитики (кабинет → SDK → Токены автоматизации, область player-hub:write, она выдаётся вместе с аналитической) — его удобно отдать своему ИИ-ассистенту, чтобы он переписал каталог из CI.

# каталог с доказательствами: порог, измеренная доля игроков,
# горизонт в днях, награда — плюс словарь, что каждое поле значит
GET /api/v1/dev-api/player-hub/achievements?gameId=<id>&locale=ru
Authorization: Bearer gdp_...

# записать текст на языке. Идемпотентно по (code, locale)
PUT /api/v1/dev-api/player-hub/achievements/<code>/text
Authorization: Bearer gdp_...
Content-Type: application/json; charset=utf-8

{"locale":"ru","title":"Двадцать дней в Точке",
 "description":"Прожить двадцать дней и не сгинуть"}

# погасить достижение или вернуть его
PATCH /api/v1/dev-api/player-hub/achievements/<code>/state
{"isActive": false}

# имена титулов ЛИНИИ (пишутся на всю линию сразу)
PATCH /api/v1/dev-api/player-hub/achievements/<любой код линии>/state
{"familyTitleText": "Хранитель историй"}   # венец — редкий, за самый конец
{"familyEntryTitleText": "Своя полка"}     # входной — достижимый

# снять перевод — базовый текст останется
DELETE /api/v1/dev-api/player-hub/achievements/<code>/text?locale=ru

Кириллица требует явной кодировки. Заголовокcharset=utf-8 обязателен, а тело отправляйте байтами: из оболочки Windows curl --data «текст» приезжает битым, и API честно сохранит то, что получил. Грабля найдена на живой правке 60 записей.

Повторный вызов обновляет, а не плодит дубли — безопасно гонять из CI. Переименование не трогает прогресс игроков: тексты и состояние лежат в разных таблицах, открытое достижение не может пропасть из-за новой формулировки. Язык у игрока берётся из параметра SDK, иначе изAccept-Language; нет перевода — покажем базовый текст.

Имя титула принадлежит ЛИНИИ, а не ступени

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

Не разбирайте rewardHint, чтобы узнать состояние. Это строка для глаз игрока, производная от других полей. Какая ступень несёт титул, говорит titleRank в ответе GET: crown, entry или null. Там же приезжают familyTitleText и familyEntryTitleText — прочитать, что вы уже задали.

Через это API меняются только формулировки

Пороги, горизонт, редкость и размер награды не принимаются — лишнее поле в теле даст явную ошибку, а не молчаливое отбрасывание. Порог связан с измеренной долей: «20 глав» показывается игроку как «достигли 11% игроков» именно потому, что это посчитано. Дай его крутить — и цифра редкости обесценится сразу у всех игр. Нужен другой порог: пересоберите каталог на /developer/player-hub, тогда пересчитается и доля, и горизонт, и награда.

Достижения идут лестницами, а не поодиночке

Каталог состоит из линий по 4–8 ступеней: «Пройдено уровней: 3 · 6 · 10 · 20 · 50 · 100 · 200 · 500». Игроку горит только следующая ступень, остальные видны тускло. Разовое достижение живёт до первого открытия — выработал каталог, и кабинет пуст навсегда; причём первыми его вырабатывают самые вовлечённые игроки, ровно те, ради кого всё и делается.

Длина линии задаётся временем, а не числом. Мы меряем темп типичного игрока вашей игры и тянем лестницу, пока верхняя ступень не станет целью примерно на год. Поэтому «60 уровней» у одной игры окажется неделей, а у другой полугодом — и это правильно: пороги выводятся из вашего распределения, а не из общего для всех списка.

Горизонт подписан у каждой ступени: короткое (до 3 дней),среднее (до 3 недель), долгое (до 3 месяцев),очень долгое дальше.

Верхние ступени платят титулом, а не валютой

Это защита вашей экономики. Сколько она выдержит, столько за цель длиной в год — обидно мало; больше — разнесёт баланс. Поэтому венец линии даёт титул, который живёт в кабинете: игре не нужно ничего выдавать и ничего обрабатывать, награда при этом даже не создаётся.

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

Редкость — из вашей же статистики

У каждого достижения кабинет показывает ступень и долю: «Очень редкое, 12%». Это не оформление, а измерение — доля игроков вашей игры, которые до этого порога доходят. Пока держателей кабинета мало, доля считается по телеметрии игры; когда их набирается достаточно, платформа переключается на собственные наблюдения. Обе величины — одна и та же, разной остаётся только выборка.

Ступеней четыре — обычное, редкое, очень редкое, легендарное; кольцо медальона меняется вместе с ней. Долю выше 85% не показываем: это не редкость, а сообщение «достижение ничего не значит», и оно обесценивало бы первую награду игрока.

Что именно видят ваши игроки

Только процент по достижениям вашей игры — так же, как это делают Steam и PlayStation. Ни числа игроков, ни выручки, ни сравнения с другими играми в кабинете нет и не будет. Не хотите показывать долю — выключите сами переключателем в разделе «Кабинет игрока»: тогда игрок не увидит ни процента, ни названия ступени.

Отсчёт начинается у каждого игрока свой — и сам

Достижения считаются с момента, когда клиент конкретного игрока впервые обратился к кабинету: раз он спросил число наград, значит сборка с кнопкой у него уже стоит. Никаких дат и никаких действий с вашей стороны.

Так сделано потому, что общей даты не существует: сроки проверки в магазинах непредсказуемы, а раскатка обновления идёт неделями. Игрок, обновившийся через три недели, начнёт счёт тогда же — и не получит разом всё, что «накопилось», пока у него не было кнопки.

Задним числом не считаем никогда: ветеран и новичок стартуют одинаково. Считать прошлое по обрывочной истории событий значило бы выдавать награды наугад.

Без единой строки кода

Повесьте компонент GDPProfileButton на свою кнопку профиля. Дизайн ваш — SDK не создаёт и не перекрашивает ни одного элемента интерфейса.

  • Нажатие подключается само — в Button → On Click() ничего добавлять не надо.
  • Badge Root — любой объект: SDK покажет его, пока награды ждут, и спрячет, когда их нет.
  • On Badge Text — перетащите свою надпись (у TextMeshPro это метод SetText(string)).
  • On Currency Reward — перетащите свой AddCurrency(string currency, int amount).

Вешаете компонент из кода? Сначала выключите объект

Unity вызывает Awake и OnEnable синхронно внутри AddComponent — до следующей строки вашего кода. Если поля вы проставляете после (рефлексией или присваиванием), первый OnEnable уже отработал на значениях по умолчанию, и страж напишет в лог «у кнопки не заполнено On Currency Reward», хотя всё настроено верно.

bool wasActive = go.activeSelf;
go.SetActive(false);                       // Awake/OnEnable откладываются
var hub = go.AddComponent<GDPProfileButton>();
// …здесь проставляем deliverRewardsWhenShown / badgeRoot / onCurrencyReward…
go.SetActive(wasActive);                   // просыпается уже с вашими значениями

Награды при этом выдаются корректно — сообщение чистый шум. Но шум дорогой: в живой игре (август 2026) он дал 4 670 ошибок на 1 585 устройствах, по одной на каждое создание кнопки, стал топ-1 сигнатурой в сборщике ошибок и спрятал под собой настоящие вылеты. Установки через инспектор это не касается.

Или три члена API, если удобнее кодом

GameDevPartnerSDK.Profile.Open();            // открыть кабинет
GameDevPartnerSDK.Profile.PendingCount;      // число для бейджа (0 — бейдж прячем)
GameDevPartnerSDK.Profile.DeliverPending();  // выдать награды — зовёте вы, когда готовы
GameDevPartnerSDK.Profile.Accent = "#ff5588";  // необязательно: цвет кабинета в тон игре
GameDevPartnerSDK.Profile.Language = "en";     // язык кабинета, если в игре есть выбор языка

GameDevPartnerSDK.Profile.OnRewardsReady += codes => {
    foreach (var code in codes)                                   // "hearts_25", "gold_100"
        foreach (var g in GameDevPartnerSDK.Profile.ParseReward(code))
            AddCurrency(g.Currency, g.Amount);                    // ("hearts", 25)
};

Код награды — валюта_количество, возможно составной:hearts_40_ticket_1 разбирается в два начисления. Свой разборщик писать не нужно, список наград зашивать в игру — тем более: каталог меняется на сервере без пересборки.

Показать игроку, ЧТО он сделал (с 2.16.3)

Валюта — это то, что игрок получил, а название достижения — то, что он сделал. Без второго момент выдачи выглядит выпиской по счёту: «+68 сердечек» без повода.

GameDevPartnerSDK.Profile.OnRewardsReadyDetailed += rewards => {
    foreach (var r in rewards) {
        foreach (var g in GameDevPartnerSDK.Profile.ParseReward(r.RewardId))
            AddCurrency(g.Currency, g.Amount);
        ShowToast(r.Title, r.Hint);                 // «Своя полка» · «68 hearts»
        if (r.TitleText != null) ShowTitle(r.TitleText);
        if (r.HasRarity) ShowRarity(r.RarityShare); // смотреть ФЛАГ, а не RarityShare != 0
    }
};

// Уже подписаны на OnRewardsReady? Не переписывайте — берите справку по коду:
var info = GameDevPartnerSDK.Profile.GetRewardInfo(code);   // info?.Title

Подписывайтесь на ОДНО из двух событий

OnRewardsReady и OnRewardsReadyDetailed срабатывают на одной и той же пачке, в одном порядке. Двойная подписка — двойное начисление.

r.Title — тот самый заголовок, который вы написали через API текстов, на языке игрока. Бывает null, если ступень уже снята из каталога: покажите награду без названия, а не прячьте — игрок её заработал.
HasRarity — отдельный флаг не для красоты: JsonUtility не умеет null у чисел, и «доля не измерена» с «доля равна нулю» приехали бы одинаковым нулём. Это разные утверждения.
Показ оборачивайте в свой try/catch отдельно от начисления. Исключение в обработчике мы читаем как «не выдано» и предложим награду снова — а валюта у игрока уже есть. Красивый тост не должен уметь удваивать выдачу.

Титулы — показать внутри игры, рядом с ником

Титул, живущий только в кабинете, не видит никто, а статус без зрителя не статус. Эндпоинт серверный: его дёргает ваш бэкенд (чат, таблица лидеров), клиент не участвует.

GET /api/v1/player-hub/player/<player_id>/titles
Authorization: Bearer <ключ игры>

→ {"nickname":"Алиса","equipped_title":"Своя полка",
   "titles":[{"code":"levels_t20","title_text":"Своя полка",
              "granted_at":"…","rarity_share":0.09}]}

player_id — тот же, что вы шлёте в IdentifyPlayer. Имя и редкость — те, что были на момент выдачи: переименование ступени не меняет того, что игрок уже получил.

nickname и equipped_title — имя, которое игрок дал себе в кабинете, и титул, который он выбрал носить. Приходят только, если игрок сам разрешил показывать их другим; иначе оба null, и это нормальное состояние, а не ошибка. Поставьте их рядом с игроком у себя — кабинет единственное место, где он видит свой статус, и без вашего экрана этот статус не видит больше никто.

Момент выдачи выбирает игра, а не SDK

На старте SDK награды не выдаются вовсе: там кошелёк игры обычно ещё не поднят, начисление упало бы, а по правилу ниже невыданная награда пришла бы снова — в следующей сессии в тот же самый момент, и снова упала. Не задержка, а тупик. Поэтому выдачу запускаете вы: Profile.DeliverPending() оттуда, где живы кошелёк, интерфейс и сохранение. Компонент GDPProfileButtonделает это сам, когда кнопка появляется на экране.

Единственное правило, которое нельзя нарушать

Выдача подтверждается серверу, только если обработчик отработал без исключения. Поэтому не глушите ошибку начисления в try/catch, который завершается нормально: так вы подтвердите выдачу, которой не было, и награда пропадёт. Дайте исключению вылететь — та же награда придёт снова. Свой список «уже выдано» вести не надо: SDK держит его сам (с 2.20 — в PlayerPrefs и в файле, так что и PlayerPrefs.DeleteAll() при загрузке облачного сейва его не сотрёт), поэтому обрыв сети между «начислили» и «подтвердили» не приводит к дублю.

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

  • Кабинет игрока уже подключён (GDPProfileButton с заполненным «On Currency Reward») — делать нечего, бонус приедет через ту же кнопку.
  • Кабинета нет — компонент GDPRewardReceiver (Add Component → GameDevPartner → Reward Receiver) на объект, который живёт, когда кошелёк уже поднят (главное меню, не сплэш); перетащите свой AddCurrency(string currency, int amount). Всё. Из кода (с 2.20) — AddComponent<GDPRewardReceiver>().SetHandler(AddCurrency); слушателей через AddListener компонент не видит.

Настраивается в кабинете разработчика на карточке игры: валюта — из тех, что игра реально шлёт в TrackCurrencyEarned/Spent (опечатка невозможна), потолок на одну ссылку, при желании общий бюджет. Блогер при создании ссылки выбирает сумму до потолка — зрителю есть зачем идти именно по его ссылке. Блогеру за бонус не платится — награда уходит игроку; заработок блогера — только с реальных покупок.

Сервер держит правила сам: один бонус на игрока на игру, только новым установкам по ссылке после антифрода, сумма не выше текущего потолка, бюджет считается атомарно. Не ставьте и кнопку с выдачей, и приёмник вместе — приёмник заметит кнопку и отступит с предупреждением: два подписчика начислили бы дважды.

9. Идентификация игрока (с v2.16.0)

Всё, что платформа считает — покупки, атрибуция, удержание, достижения, — привязано к идентификатору игрока. От того, насколько он стабилен, зависит, увидите ли вы одного человека или двух.

Как это работает по умолчанию

Без единой строки кода SDK опознаёт игрока по идентификатору устройства. Для старта это удобно, но у него есть предел: на iOS он меняется после переустановки, если у игрока не осталось других ваших приложений. Такой игрок станет для платформы новым и потеряет всю историю. На Android идентификатор переустановку переживает.

Как правильно

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

GameDevPartnerSDK.IdentifyPlayer("ваш_стабильный_id");

История при этом не теряется. SDK сам прикладывает идентификатор, под которым работал раньше; платформа связывает их и переносит покупки, атрибуцию, подписки и достижения. Отправляется один раз — в тот запуск, где идентификатор сменился. Вам про миграцию знать не нужно.

Вернуть игроку прежний id (восстановил прогресс из облака, вошёл в старый аккаунт): GameDevPartnerSDK.ResetPlayerId(), затем IdentifyPlayer(прежнийId) — в той же сессии, без перезапуска. Тогда идентификация уйдёт без «прежнего id» и ничего не склеит с временным. Не удаляйте gdp_player_id из PlayerPrefs руками: с 2.20.0 SDK хранит его ещё и в файле (так он переживает PlayerPrefs.DeleteAll()), и ключ вернётся.ResetPlayerId() — с 2.20.1.

Делайте это до релиза

Сшивка происходит в момент, когда SDK видит обе стороны переезда. Если игрок переустановит игру раньше, чем вы переедете, связать историю будет уже нечем.

Если ваш сервер знает связку раньше клиента

Уведомление магазина о продлении подписки часто содержит оба идентификатора, а игрок может не запускать игру неделями. Свяжите их со своего сервера:

POST https://gamedevpartner.ru/api/v1/identity/link
Authorization: Bearer <API Key игры>

{"player_id": "<ваш стабильный>", "alias_value": "<старый>", "alias_type": "device"}

Идемпотентно. Проверить, куда ведёт идентификатор, — GET /api/v1/identity/resolve?player_id=…с тем же ключом.

Ваши серверные вебхуки переключать не нужно

Все входящие точки — приём событий, покупок, вызовы кабинета — принимают любой известный идентификатор и сами приводят его к каноническому. Ваш бэкенд может годами слать старый и попадать в правильного игрока. Синхронно переключать клиент и сервер не требуется.

Одно устройство — два игрока

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

10. Аналитика по API

Все отчёты кабинета доступны как REST — чтобы строить свои дашборды, выгружать в BI или давать доступ ИИ-ассистенту. Авторизация — токен разработчика (начинается с gdp_), выдаётся в кабинете.

Начинайте отсюда, а не с угадывания

GET /api/v1/dev-api/analytics/capabilities?gameId=… честно отвечает, какие отчёты у ЭТОЙ игры уже наполнены, а какие пусты — и что вызвать в игре, чтобы срез появился. Без него легко принять «нет разметки» за «нет проблемы».

# список ваших игр — gameId брать отсюда, а не угадывать
GET /api/v1/dev-api/games
Authorization: Bearer gdp_...

# что готово именно у этой игры
GET /api/v1/dev-api/analytics/capabilities?gameId=<id>

# сам отчёт
GET /api/v1/dev-api/analytics/retention?gameId=<id>&from=2026-07-01&to=2026-07-31

# достижения кабинета: каталог и правка текстов (см. раздел 8)
GET /api/v1/dev-api/player-hub/achievements?gameId=<id>&locale=ru
PUT /api/v1/dev-api/player-hub/achievements/<code>/text
ОтчётЧто показываетЧто разметить в игре
overviewDAU, сессии, версииничего — работает от API Key
retentionудержание по когортамничего
stability, by-deviceпадения и зависания, разрез по моделям и GPUничего
money, by-storeвыручка и покупки; сравнение магазиновTrackPurchase, TrackAdRevenue
economy, vocabularyисточники и стоки валюты; словарь ловит опечатки в названияхTrackCurrencyEarned/Spent
funnelворонка онбордингаTrackTutorialStep
progressionуровни: попытки, винрейт, отвалTrackLevelStart/End
offersпоказ → клик → покупка, ставки CTR и CVRTrackOffer*
adsсети, показы, eCPMTrackAdRevenue
geoгде играют и где платятничего
finance-dailyденьги по дням, Россия и мир раздельно: выручка gross/net по способам оплаты, реклама, расходы на закупку, DAU, новые, платящиеничего
events-explorerлюбые свои события, разрез по параметру; фильтры where по И, limit до 500TrackEvent
release-impactсравнение версий: что изменил релизотметки через POST /dev-api/releases

Фильтр по стране: &country=RU

Работает в retention, progression, funnel,stability, by-device и events-explorer. Заблокированные как фрод установки и тестовые устройства (три и больше покупок оффера без единой оплаты) в отчётах по событиям не учитываются; вернуть их — &includeBlocked=1. Страна берётся из локали устройства — по ней видно, где игрок реально играет.

Зачем он нужен: самый частый вопрос после релиза — «мы сломали продукт или упало качество закупаемого трафика?». Ответить можно, только сопоставив воронку и прогрессию по той же стране с её удержанием. Пока воронка была глобальной, чужой трафик маскировал картину нужной страны, и разделить гипотезы было нечем.

Разрез по версии: &version=1.2.3

Работает в progression, funnel, retention,economy и offers. Отвечает на вопрос, ради которого аналитику и открывают: моя правка сработала или нет? Без него отчёт суммирует все версии сразу, и новая сборка растворяется в старых на недели.

По умолчанию разрез когортный — по версии, на которой игрок пришёл в игру, а не на которой он сейчас. Это принципиально: разрез по текущей версии систематически льстит свежей сборке, потому что в неё сначала обновляются активные, а неактивные подтягиваются неделями. На реальных данных разница была такой: сырой подсчёт давал D1 24,5% у свежей версии против 12–13% у старых, а когортный — ровно те же 12%. Всё «улучшение» было артефактом отбора.

Цена когортного режима: эффект правки виден только на новых установках. Игроки, обновившиеся с прогрессом, остаются в когорте своей первой версии навсегда. Если нужно именно «что происходит у тех, кто сейчас на этой сборке» —&versionMode=current, но это другой вопрос, не «стало ли лучше».

Свежая когорта не созрела. Версия, вышедшая два дня назад, покажет D7 нулём — не потому что удержание плохое, а потому что седьмого дня у неё ещё не было. Сравнивайте когорты одинакового возраста.

Разрыв в метрике зависаний на границе SDK 2.16.1

До 2.16.1 детектор считал зависанием и возврат из фона: свернули игру на минуту — в отчёте «зависание». Замер по пяти играм показал медиану 1,9–5,2 секунды и тысячи «зависаний» дольше 10 секунд, которых в реальности не было. С 2.16.1 считается только настоящая неотзывчивость. Сравнивать зависания до и после этой версии нельзя — считается разное; в отчёте «Влияние релиза» это выглядит как резкое улучшение, которого билд не делал. А вот сравнения версий, обе из которых старше 2.16.1, остаются осмысленными: загрязнение равномерное, испорчены абсолютные числа, а не тренд.

11. Gradle (Android) — с v2.7.4 автоматически

Начиная с SDK v2.7.4 библиотека com.android.installreferrer:installreferrer:2.2 добавляется в сгенерированный gradle-проект автоматически при каждой Android-сборке (идемпотентно — если её уже тянет AppsFlyer/Adjust, дубля не будет). Руками ничего добавлять не нужно.

Только если проект зафиксирован на SDK старше 2.7.4:

// В Plugins/Android/mainTemplate.gradle добавить в dependencies:
implementation 'com.android.installreferrer:installreferrer:2.2'

12. Как работает атрибуция

  • Install Referrer API (Google Play, RuStore) — 100% точность, главный метод
  • GAID (Android) / IDFV (iOS) — высокая точность, fallback
  • Device Fingerprint (IP + UserAgent) — fallback последней инстанции

Окно атрибуции — 7 дней с момента клика по ссылке инфлюенсера. После привязки игрок закреплён за инфлюенсером пожизненно.

Платный трафик — кода в игре не требуется

Атрибуция рекламных кампаний работает поверх той же установки SDK, ничего дополнительно интегрировать не нужно:

  • Яндекс Директ — клик по трекинг-ссылке вшивает yclid в Install Referrer, SDK читает его автоматически.
  • Unity Ads — клики матчатся на сервере по рекламному ID устройства (GAID), постбеки установок уходят в Unity автоматически.

Настройка кампаний — в ЛК → Продвижение → Реклама, результаты — в Аналитика → Платный трафик. Ставить AppMetrica/AppsFlyer ради атрибуции не нужно.

13. Безопасность

  • Все запросы автоматически подписываются HMAC-SHA256 (timestamp + тело + API key)
  • Два режима: Live (боевой) и Test (без реальных выплат)
  • API-ключи хранить в Unity Settings (ScriptableObject), не коммитить в Git
  • Антифрод блокирует подозрительные установки автоматически

14. Чего НЕ делать

  • Не вызывайте IdentifyPlayer() с id устройства или случайным id — это делает Auto Identify. Со своим стабильным id (аккаунт, облачный сейв) — вызывайте, см. «Идентификация игрока»
  • Не трекайте одну покупку дважды — SDK дедуплицирует по TransactionId
  • Не выпускайте билд с тестовым API-ключом
  • Не включайте комиссию магазина в Amount — передавайте gross-цену
  • Не придумывайте revenue для Unity Ads standard — передавайте 0
  • Не пишите new AdRevenueEvent { ... } — такого класса нет, аргументы позиционные
  • Не пишите AdNetwork.Something — параметр adNetwork это строка
  • Не ссылайтесь на ImpressionEventPublisher / data.revenue для Unity Ads standard — этого в SDK нет (есть только в LevelPlay, а это другой SDK)
  • Не кладите в offerId покупки место показа (popup/store) — там должно быть имя оффера из того же словаря, что в TrackOfferShown
  • Не меняйте productId между показом и покупкой оффера — это ключ, по которому покупка привязывается к показу

15. Чеклист интеграции

  1. SDK установлен через UPM, проект компилируется без ошибок
  2. Window → GameDevPartner → Settings появился, API Key вставлен
  3. Покупки трекаются (либо GDPUnityIAP.Initialize, либо ручные TrackPurchase)
  4. В каждом callback показа рекламы вызывается TrackAdRevenue с правильной строкой adNetwork
  5. Для Unity Ads standard: передаётся revenue=0 + в ЛК подключены Core ID + Stats API Key + Game IDs
  6. Android: gradle-правок не требуется (SDK ≥ 2.7.4 добавляет installreferrer сам)
  7. Платёжные системы подключены в ЛК → Платёжные системы
  8. В разделе Депозит внесён баланс
  9. Игра опубликована в каталоге
  10. Тестовая покупка и показ видны в ЛК → Аналитика
  11. Debug Mode выключен в production-сборке

16. Поддержка