Why single-html mode exists

Luna's default export is folder mode: an index.html that loads engine/scripts.js by relative path, alongside an assets/ folder and a cache/ folder full of Luna's own precompiled data. That's a perfectly normal web build, and several ad networks are fine receiving it as a ZIP of multiple files.

Others aren't. AppLovin, Meta, Unity Ads, and Vungle expect a single HTML file with zero external requests — no relative <script src>, no separate asset files the browser has to fetch after load. Single-html mode is Luna's answer to that: instead of referencing engine/scripts.js and a cache/ folder by path, it inlines all of it directly into the document as text, so the one file runs standalone.

The real file structure behind it

Inside every Luna/Playworks export there's a cache/ directory containing one or more numbered subfolders — cache/<id>/ — and a complete one contains exactly three files:

  • scripts.js — Luna's compiled game code
  • jsons.js — bundle manifests and shader data, assigned onto window.jsons[path]
  • blobs.js — raw binary assets: audio clips onto window.sounds[path], and other binary bundles onto window.blobs[path]

To build the single-html output, this tool picks the first cache folder where all three files exist, reads each one, and wraps it in its own inline <script> tag placed where engine/scripts.js used to be referenced. Nothing about the game logic changes — the same three files that would otherwise sit on disk in cache/<id>/ just travel inside the HTML document instead of next to it.

How the assets actually get embedded

Each of those three cache files is really just a long list of calls like this (unminified for clarity):

decompressString("<payload>", false).then(function (sound) {
  window.sounds["assets/bundles/-1/38478.mp3"] = sound;
});

and, for binary bundles:

decompressArrayBuffer("<payload>", false).then(function (blob) {
  window.blobs["assets/bundles/-1/data.blob"] = blob;
});

Every one of those promises gets pushed onto a shared window._compressedAssets array. The game doesn't actually start until all of them resolve — there's a listener that waits for Promise.all(assets) before firing the event that tells Luna's runtime it's safe to build the scene. That's the mechanism that makes "one file, no network requests" work at all: every sound and binary blob the game needs is sitting in memory as encoded text before a single frame renders.

The "<payload>" string itself is compressed data, and the trailing false/true argument tells the decoder how it's text-encoded: false means plain base64, true means base122 — a denser ASCII-safe encoding that packs 7 bits per character instead of 6, at the cost of emitting a handful of multi-byte characters for illegal byte values. In Luna's own export, the underlying compressed bytes are brotli. Because ad-network serving layers (Google's and AppLovin's in particular) don't reliably survive multi-byte UTF-8 in an inlined script even with a correct charset meta tag — the creative can render a black screen — this tool decodes each payload, brotli-decompresses it back to raw bytes, then re-compresses with gzip and re-encodes strictly as base64 before writing it back into the exact same decompressString(...)/decompressArrayBuffer(...) call shape. The function calls Luna wrote stay identical; only the underlying bytes' compression and text encoding change, from brotli/base122 to gzip/base64, so every target network gets pure ASCII.

Because folder mode's engine/scripts.js is what normally defines decompressString and decompressArrayBuffer, and single-html mode removes that external file entirely, a small standalone implementation of both functions has to be inlined too — built on fflate (a compact gzip/gunzip library) plus a matching base122-to-bytes decoder, so the browser can unpack what got re-encoded into the cache scripts without needing Luna's engine bundle at all.

Separately, raw media files sitting in the export's assets/ folder — images and video, as opposed to the audio and binary data living inside cache/ — get their own embedding pass: base64 data URIs directly on <img>/<video> tags for networks that require it (Google, Meta), or a hidden element with a data-b122 attribute decoded into a Blob URL at runtime for everyone else. It's a different code path from the cache files, but the same underlying goal — nothing the browser needs is ever a separate HTTP request.

Why this matters for conversion

The practical upshot is that this tool doesn't invent its own scheme for embedding audio or binary assets into a single file — Luna already defined one, in the form of window.sounds/window.jsons/window.blobs assignments inside cache/<id>/. What actually needs doing per network is narrower than it looks: find the complete cache folder, re-encode each payload's compression and text format so it survives that specific network's serving layer, and inline the three cache files plus a small decoder runtime where the external script used to be. The asset-embedding logic itself is Luna's; converting for a network is really just making Luna's own embedding survive that network's pipe.

Worth knowing: if a single-html export renders a black screen on one network but works fine on another, the base122/UTF-8 serving issue described above is one of the more common root causes — not a missing asset. PlayableKit's Playable Ad Validator checks for this and other structural issues for free with no signup.