Вимоги Unity Ads до playable 2026: чек-лист для модерації
Playable для Unity Ads: один index.html до 5 МБ, base64, MRAID 3.0, без мережі, стор лише по тапу на CTA. Чек-лист за групами й що перевіряє модерація.
PlayableМодерація6 хв читання

Як зібрати HTML5-гру на Vite і Phaser в один index.html для Unity Ads: vite-plugin-singlefile, текстури з base64 і перевірка файлу за списком Unity.

Щоб зробити playable для Unity Ads з HTML5-гри, на виході потрібен один index.html менше 5 МБ, де вся графіка й звук лежать усередині як base64. На Vite це робить плагін vite-plugin-singlefile, а у Phaser замість this.load, який тягне файли за URL, текстури можна подати через addBase64. Ми самі такий конвеєр не збирали й не тестували: нижче рецепт із документації інструментів і вимог Unity.
Вимоги беремо зі специфікації playable в доках Unity. Це один файл index.html менше 5 МБ. Усі картинки, аудіо й відео вшиті в нього як base64. Посилань на інші файли й теки за відносними шляхами чи віддаленими URL бути не повинно, код мусить бути мінімізований. Playable працює без мережевих запитів (XHR) і не підтягує картинки чи конфіг із сервера під час показу.
Плюс одна вимога з best practices: фінальна збірка має запускатися й без MRAID, щоб повністю перевірити її в десктопному браузері. Повний список вимог — у статті про вимоги Unity Ads до playable.
Ставимо плагін:
npm install -D vite-plugin-singlefile
Конфіг:
// vite.config.ts
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [viteSingleFile()],
});
Збірка (npx vite build) кладе результат у dist/index.html. За документацією плагіна, він вбудовує весь JavaScript і CSS прямо в цей файл. З рекомендованими налаштуваннями збірки він ще задає build.assetsInlineLimit так, що кожна адреса асета стає data URI, вимикає розбиття CSS (build.cssCodeSplit = false), ставить base = "./" і збирає один JS-чанк.
Без плагіна Vite вбудовує в base64 лише маленькі файли. За документацією Vite, поріг build.assetsInlineLimit за замовчуванням 4096 байт (4 КіБ): усе дрібніше вбудовується, усе більше лишається окремим файлом. Картинка на 200 КБ у такій збірці стане файлом поруч з index.html, а посилання на неї — тим самим відносним шляхом, через який у рекламному юніті буває порожній екран. Плагін прибирає поріг, але три місця лишаються на вас:
public. Vite копіює її вміст як є, не вбудовуючи, і плагін цього теж не робить. Усе, що має опинитися всередині файлу, імпортуйте з src, а не кладіть у public. Посилання на public в index.html (наприклад, на іконку) видаліть.Імпорт картинки в коді виглядає так, і після збірки hero містить рядок data:image/png;base64,...:
import hero from "./assets/hero.png";
Завантажувач Phaser (this.load.image, this.load.json та інші) забирає файли за URL: у документації його типи файлів приймають налаштування XHR. Для playable в Unity Ads це заборонені мережеві запити. Замість них викликаємо TextureManager.addBase64(key, data): він створює DOM-обʼєкт Image і виставляє джерело асинхронно. Документація Phaser просить слухати події методу (ADD, ERROR, LOAD), перш ніж користуватися текстурою. Чи приймає load.image посилання виду data:, ми не перевіряли.
Робочий варіант: окрема сцена, яка по одній додає текстури, чекає на LOAD і лише потім запускає гру.
import Phaser from "phaser";
import hero from "./assets/hero.png";
import bg from "./assets/bg.jpg";
const images = { hero, bg };
function addBase64(tm: Phaser.Textures.TextureManager, key: string, data: string) {
return new Promise<void>((resolve, reject) => {
tm.once(Phaser.Textures.Events.LOAD, () => resolve());
tm.once(Phaser.Textures.Events.ERROR, () => reject(new Error(`texture ${key}`)));
tm.addBase64(key, data);
});
}
export class Boot extends Phaser.Scene {
async create() {
// По одній, щоб не зіставляти події з ключами.
for (const [key, data] of Object.entries(images)) {
await addBase64(this.textures, key, data);
}
this.scene.start("Game");
}
}
Слухач другої події після завершення промісу лишається, але він нешкідливий: повторний виклик resolve чи reject нічого не змінює. Якщо текстур багато, додавайте їх паралельно й розрізняйте події за ключем, а сигнатури подій звіряйте з документацією вашої версії Phaser.
Чого ще не можна: this.load.* зі шляхами, fetch і XHR по JSON чи шрифт, веб-шрифти за URL. JSON і атласи імпортуйте як модулі: вони потраплять у JS-бандл. Звук і відео в цій статті не розбираємо: як подавати їх у Phaser з base64, розглянута документація не описує. З вимог Unity відомо лише, що звук має вимикатися за перемикачем на iOS: основний шлях — Web Audio API, HTMLAudioElement лише запасний (докладніше в статті про вимоги).
Звіряємо dist із пунктами специфікації:
ls dist # має лишитися один index.html, без теки assets
wc -c < dist/index.html # розмір у байтах: менше 5 МБ, краще із запасом
grep -noE "(src|href)=\"[^\"]{0,60}|url\([^)]{0,60}" dist/index.html | grep -v "data:"
Остання команда шукає атрибути й url(...), що не починаються з data:, і в ідеалі друкує порожнечу. Вона не ловить шляхи, зібрані в JavaScript, тож додатково пошукайте в бандлі fetch(, XMLHttpRequest і виклики load.. Як визначати 5 МБ (десяткові чи двійкові), Unity не уточнює, тому краще вкластися із запасом. Мінімізацію в продакшен-збірці Vite робить за замовчуванням, але перевірте очима, що файл не читається як звичайний код з відступами. І відкрийте його в DevTools на вкладці Network у режимі Offline: у чистому playable нічого не має запитуватися.
Unity вставляє MRAID у webview, тож на рекламному юніті він є, а в десктопному браузері його немає. Одна й та сама збірка має працювати в обох випадках, тому в точці входу додайте гілку без MRAID:
declare const mraid: {
getState(): string;
isViewable(): boolean;
addEventListener(event: string, listener: (...args: unknown[]) => void): void;
};
export function boot(start: () => void, pause: () => void) {
// Десктоп: MRAID немає, запускаємо одразу.
if (typeof mraid === "undefined") return start();
const onReady = () => {
mraid.addEventListener("viewableChange", (v) => (v ? start() : pause()));
if (mraid.isViewable()) start();
};
if (mraid.getState() === "loading") mraid.addEventListener("ready", onReady);
else onReady();
}
Це скорочена версія прикладу зі специфікації Unity (адаптовано). start має переживати повторний виклик: у прикладі Unity startPlayable() викликається і для першого запуску, і для відновлення після паузи. Після десктопного тесту все одно потрібна перевірка на реальному пристрої через застосунок Ad Testing: десктоп її не замінює.
load.image посилання data:.У DarkCore Ramet є Playable Builder. Відео в розділі креативів завантажується один раз і йде в усі потрібні апки.
Відео завантажується один раз і йде в усі потрібні апки
DarkCore Ramet заливає відео в Unity Ads через офіційний API: завантажене раз іде в усі потрібні апки.
Playable для Unity Ads: один index.html до 5 МБ, base64, MRAID 3.0, без мережі, стор лише по тапу на CTA. Чек-лист за групами й що перевіряє модерація.
PlayableМодерація6 хв читання

MRAID для playable: Unity вбудовує його сам. Каркас ready → viewableChange → старт і пауза → mraid.open по тапу на CTA, плюс запуск без MRAID.
MRAIDPlayable5 хв читання

Playable працює локально, а в Unity Ads порожній екран: Unity прямо називає одну причину — посилання на асет. Ще сім перевірок зі специфікації.
PlayableMRAID7 хв читання
