Skip to content

HTML5 game to playable ad: build one index.html with Vite

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.

Published
7 min read
7 min read
Русский
Українська
On this page
  1. What Unity wants in the file
  2. Step 1: bundle JS, CSS and assets into one index.html
  3. Why assetsInlineLimit matters
  4. What stays outside the file: public and SVG
  5. Feed Phaser textures from base64
  6. What you can’t do in a playable
  7. Check dist/index.html against Unity’s list
  8. Make the same build run without MRAID
  9. What we have not verified
  10. Doing this in DarkCore Ramet

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.

What Unity wants in the file

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.

Step 1: bundle JS, CSS and assets into one index.html

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.

Why assetsInlineLimit matters

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.

What stays outside the file: public and SVG

Two cases are documented as not inlined:

  • The 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.
  • SVG. The README says Vite doesn’t inline SVG directly, so the plugin doesn’t either. Put the SVG markup into the HTML template or use an SVG loader plugin.

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.

Feed Phaser textures from base64

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.

What you can’t do in a playable

  • Load anything by URL or XHR at runtime: 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.
  • Fire your own attribution events. The spec says Unity handles start, view and click attribution on the server side.
  • Add your own analytics calls. The spec says Unity can permit analytics calls that don’t contain personal data. It doesn’t say how a call gets permitted.

Check dist/index.html against Unity’s list

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.
  • First grep: every src, href and url(...) should start with data: or #.
  • Second 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.

Make the same build run without MRAID

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.

What we have not verified

  • We have not built a Phaser game into a Unity Ads playable and run it in the ad unit.
  • The data: URL behavior of load.image, Phaser’s audio and atlas APIs, and pause hooks are not checked here.
  • Whether Unity’s moderation accepts a given output is not something the docs let us predict.

Doing this in DarkCore Ramet

DarkCore Ramet has a Playable Builder. For the creative side, see creatives: a video uploaded once goes to every app that needs it.

In DarkCore Ramet

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.

All articles