Why this is a problem in the first place
Browsers fetch images, audio, and WASM binaries as separate files over HTTP by default. Playable ad specs remove that option: Meta's spec mandates base64 data-URI embedding, and AppLovin, Unity Ads, Vungle, Mintegral, and TikTok all expect a single HTML file with zero external calls once served. Every binary asset a Unity/Luna build produces — textures, audio, the Unity engine data blob itself — has to become text that can sit inside HTML or a JS string. The naive version of that is base64: map raw bytes onto 64 printable characters. It works, but doing it for an entire Unity asset cache rather than one icon adds up fast.
What brotli does in this pipeline
Playworks/Luna's own export already runs its cache through brotli before text-encoding it. In the exported cache folder (cache/<id>/scripts.js, jsons.js, blobs.js), asset payloads appear as calls like decompressString("...", false) and decompressArrayBuffer("...", false), where the string argument is a brotli-compressed, then base64-or-base122-encoded, blob. Compression happens before text-encoding, since compressing already-encoded text gains far less than compressing the raw binary first.
PlayableKit's single-HTML build step reads those calls, brotli-decompresses the payload back to raw bytes with Node's built-in zlib.brotliDecompressSync, then re-compresses it as gzip at level 9 and re-encodes as base64. The swap happens because the in-browser decoder bundled into the ad (via fflate) exposes a fast gunzipSync, not a brotli decompressor — normalizing to gzip lets one lightweight decoder handle every asset at runtime, regardless of what Luna originally used.
Why base122 exists as an alternative to base64
Base64 maps every 6 bits of source data onto one of 64 printable ASCII characters — simple and universal, but a fixed 33% size increase over raw bytes, regardless of content. Base122 (implemented in src/cli.js as base122Encode/base122Decode) narrows that gap by packing 7 bits per character instead of 6, using nearly the full printable range rather than 64 symbols. The catch: a handful of byte values — 0, 10 (LF), 13 (CR), 34 ("), 38 (&), and 92 (\) — would break out of an HTML attribute or quoted JS string if emitted raw, so those get escaped into a 2-byte sequence instead of 1. Net result: meaningfully smaller than base64, at the cost of multi-byte UTF-8 output instead of pure ASCII.
That tradeoff is why PlayableKit doesn't use base122 everywhere. Media assets inlined for networks other than Google and Meta are base122-encoded via data-b122 attributes and decoded back into blob URLs at runtime. But the bulk Unity cache — the largest payload — is deliberately normalized to base64 in transformCompressedCache, per a comment in the code itself: base122's multi-byte UTF-8 output doesn't reliably survive every network's serving layer, even behind a UTF-8 charset meta tag, and a corrupted byte there renders a black screen instead of a playable. Base64's pure-ASCII output renders everywhere, so the largest, riskiest payload gets the safer encoding while smaller media keeps the size win. Google and Meta media skip base122 entirely and use base64 data URIs directly, matching what both networks' pipelines expect.
How Luna's runtime reverses all of this
None of this is visible to the playable's own game code — it resolves before anything else runs. decompressString and decompressArrayBuffer (in the inlined fflate runtime script) take the encoded payload plus an isBase122 flag: base122 payloads route through _base122ToArrayBuffer, base64 through atob, and either path lands in fflate.gunzipSync to reverse the gzip step. decompressArrayBuffer returns raw bytes; decompressString runs the same bytes through TextDecoder("utf-8") for JSON and script payloads. Every call registers against a shared window._compressedAssets promise array, and the playable doesn't build its scene until that batch resolves on the luna:ready event — decompression is a real, timed boot step, not a free abstraction.
What this means practically for playable ad developers
A 2 MB source asset going into this pipeline doesn't stay 2 MB, and rarely ends up smaller once it's embedded text. Brotli-to-gzip recompression claws back some size, but base64 still adds roughly a third on top of whatever the compressor leaves, and base122 only closes part of that gap for the assets it's used on. The embedded size in the final HTML is routinely larger than the source file on disk, even after real compression, because the caps networks publish (5 MB hard on most, 2 MB recommended on some) are measured against the delivered file, not the source art.
That's the gap teams get surprised by: a build that looks comfortably under budget in the Unity project folder can clear a network's cap once every texture, clip, and the Unity data blob is compressed and re-expanded into embeddable text. Checking the delivered package size — after encoding, not before — is the only reliable way to know before submission.