Unity Ads creative upload limit: 1 per second, 30 per 30 min
Unity Ads allows 1 creative per second and 30 per 30 minutes to each app. Each creative in a batch counts. Here is the math for a big launch.
CreativesUnity API4 min read

One index.html under 5 MB from a Vite or Phaser game: vite-plugin-singlefile, base64 textures, no network calls. Docs-based, untested by us.

To get a single-file playable out of a Vite project, add vite-plugin-singlefile, import every asset instead of putting it in public, and give Phaser its textures as base64 data instead of loading them by URL. The result is one dist/index.html that you then check against Unity’s list: under 5 MB, no relative or remote references, no network requests. We have not built or tested this pipeline ourselves. The recipe comes from the documentation of the tools and of Unity.
Unity’s playable specifications page asks for a single HTML file named index.html, under 5 MB and minified, with every image, audio and video asset inlined as base64 data. It allows no references to other files, folders or assets through relative paths or remote URLs. The playable also runs without network requests: it can’t load images, configuration or other content from a server at runtime. The rest of this article is about meeting those lines from an HTML5 game.
Install the plugin:
npm install -D vite-plugin-singlefile
According to the plugin’s README, it inlines all JavaScript and CSS resources into a single dist/index.html. The config is short:
// vite.config.ts
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [viteSingleFile()],
});
With its recommended build settings the plugin sets build.assetsInlineLimit so that every asset URL becomes a data URI. It also turns off CSS code splitting, sets base to ./ and produces a single JS chunk. Don’t add your own values for those options. Vite minifies production builds by default, but open the output and confirm it.
According to the Vite docs, build.assetsInlineLimit defaults to 4096 bytes (4 KiB). Assets smaller than that are inlined as base64. Larger ones become separate files in dist/assets/, and Unity ignores anything outside the HTML file. The plugin raises the limit so that nothing stays outside. If your dist still has an assets folder after the build, something bypasses the plugin.
Setting the limit by hand would inline images, but JS and CSS would still be separate files. That is the gap the plugin fills.
Two cases are documented as not inlined:
public folder. Vite copies it as-is and doesn’t inline it, and the plugin doesn’t change that. Move such files into src and import them. This includes the favicon: remove a <link rel="icon"> that points at a file in public, because that link is a reference to another file.The README also warns that other situations may leave referenced files un-inlined. That is why the output check below is not optional. Web fonts from a CDN are a remote reference and can’t be used, so use a system font or embed the font as an imported file.
Phaser’s this.load.image("ship", "ship.png") goes through the loader, and the loader’s file types take XHR settings in Phaser’s API docs. It fetches by URL, and a playable can’t make that request. Instead, import the image (after the singlefile build the import is a data: URI) and pass it to the texture manager.
According to the Phaser docs, addBase64(key, data) creates an Image DOM object and sets its source asynchronously. You must listen for the events it dispatches before using the texture. They are ADD, ERROR and LOAD. So wait for the event:
import Phaser from "phaser";
import shipUrl from "./assets/ship.png"; // a data: URI in the singlefile build
function addBase64(scene: Phaser.Scene, key: string, data: string): Promise<void> {
return new Promise((resolve) => {
const onAdd = (added: string) => {
if (added !== key) return;
scene.textures.off(Phaser.Textures.Events.ADD, onAdd);
resolve();
};
scene.textures.on(Phaser.Textures.Events.ADD, onAdd);
scene.textures.addBase64(key, data);
});
}
class Boot extends Phaser.Scene {
constructor() {
super("Boot");
}
async create() {
await Promise.all([addBase64(this, "ship", shipUrl)]);
this.scene.start("Play"); // textures exist from here on
}
}
The helper never rejects: if an image fails to decode, the ERROR event fires and the promise stays pending. Add an ERROR listener before you rely on this. A separate Boot scene keeps update from running before the textures exist. Atlases and audio need the same treatment (data bundled into the JS, not fetched), but the Phaser calls for them are outside this article. We did not verify whether load.image accepts a data: URL, so don’t rely on it either way.
this.load.* from files, fetch for level JSON, scripts or fonts from a CDN. Import JSON as a module so it lands in the JS bundle.Run these on the final build:
ls -l dist
wc -c dist/index.html
grep -noE "(src|href)=\"[^\"]{0,40}|url\([^)]{0,40}" dist/index.html
grep -noE "https?://[^\"' )]{0,50}" dist/index.html
ls: expect only index.html. Any other file is ignored by the ad unit.wc -c: stay clearly under 5 MB. Base64 makes assets about a third larger, so roughly 3.75 MB of raw assets already uses the whole budget (arithmetic, not a Unity statement). Unity’s pages don’t say whether MB means decimal or binary.grep: every src, href and url(...) should start with data: or #.grep: libraries carry URL strings (XML namespaces, links in comments). Read each match and confirm nothing is fetched. The Network tab is the final word: open the file alone in an empty folder, play to the end, and expect one request.Unity’s best practices page says the final build should also run without MRAID, so you can test the complete playable in a desktop browser. Don’t keep a separate debug build. Branch on whether mraid exists. The ready and viewability logic below is adapted from Unity’s example on the specifications page:
declare const mraid: any; // injected by the Unity Ads webview, absent on desktop
function boot(start: () => void, pause: () => void) {
if (typeof mraid === "undefined") {
start(); // desktop test: no MRAID
return;
}
const onReady = () => {
mraid.addEventListener("viewableChange", (v: boolean) => (v ? start() : pause()));
if (mraid.isViewable()) start();
};
if (mraid.getState() === "loading") mraid.addEventListener("ready", onReady);
else onReady();
}
start must be safe to call twice, because it runs again when the ad becomes viewable after a pause. The store redirect has the same split: call mraid.open only after a player tap on the call to action, and guard it for the desktop build.
A desktop run proves the file works, not that the ad unit accepts it. Test on a device before you submit; why a playable goes blank in the ad unit lists the checks. The full requirements are in Unity Ads playable requirements.
data: URL behavior of load.image, Phaser’s audio and atlas APIs, and pause hooks are not checked here.DarkCore Ramet has a Playable Builder. For the creative side, see creatives: a video uploaded once goes to every app that needs it.
Upload a video once, use it in every app
DarkCore Ramet sends a video you upload once to every app that needs it, through the official Unity API.
Unity Ads allows 1 creative per second and 30 per 30 minutes to each app. Each creative in a batch counts. Here is the math for a big launch.
CreativesUnity API4 min read

Unity's playable checklist: one HTML file under 5 MB, base64 assets, no network, MRAID 3.0, both orientations, store only after a CTA tap.
PlayablesModeration7 min read

Playable specs for Unity, AppLovin, Meta and Mintegral side by side. Size limits, MRAID version and CTA call differ across the docs.
PlayablesMRAID5 min read
