Playable-ad export
A VITE_PLAYABLE=1 build that collapses one game into a single self-contained index.html (≤ build.playableMaxBytes, default 5 MB) for ad networks (AppLovin/ironSource) — fully offline (no network), loaded in a strict ad webview, gated by an injected window.mraid. Trigger it from the editor's Build → Playable Ad, or MODOKI_PROJECT=games/<id> VITE_PLAYABLE=1 npm run build → games/<id>/ads/index.html. (Grew out of the advideo-playable-export-plan tracker, now landed.)
Key files
| File | Role |
|---|---|
engine/vite.config.ts (the isPlayable branch) | outDir=ads/, inlineDynamicImports (single JS chunk), sets MODOKI_PLAYABLE=1, the __MODOKI_PLAYABLE__ / __MODOKI_PLAYABLE_CLICK_URL__ defines, and the playable-only aliases (@zappar/msdf-generator + @<game>/app-services → stubs) |
engine/plugins/playable-profile.ts | isPlayableBuild() (reads MODOKI_PLAYABLE) + the asset-shrink overrides — WebP @ ≤512, downscaled HDR, KTX2-transcoder skip |
engine/plugins/inlinePlayable.ts | The single-file inliner — gzip+base64 the {js,css,assets} payload, a self-extract bootstrap (DecompressionStream + inlined fflate fallback) that rehydrates assets as blob: URLs on __PLAYABLE_ASSETS__, and the hard ≤ playableMaxBytes gate |
engine/plugins/vite-asset-scanner.ts | Applies the playable profile inside computeKeptAssets().kept copy loops; bakes loadType:'buffer' for all audio in a playable |
engine/app/main.tsx | Behind __MODOKI_PLAYABLE__, dynamically imports bootPlayable; the debug-bridge import is !__MODOKI_PLAYABLE__-gated so it DCEs |
engine/app/playable/bootPlayable.tsx | The runtime entry — audio gate, overlay mount, playable:end latch |
engine/app/playable/mraid.ts | MRAID v2 shim — whenReady/whenViewable/onViewableChange/installClick/startTimeCap/isInAdContainer |
engine/app/playable/PlayableOverlay.tsx | The CTA — a persistent Install pill + an end-card (Install + Replay) |
engine/app/playable/playableEnd.ts | Latches window 'playable:end' so an end fired before the overlay mounts isn't lost |
engine/scripts/smoke-playable.mjs | npm run smoke:playable — the headless-Chromium artifact smoke |
How it works
Build. MODOKI_PLAYABLE=1 layers aggressive asset overrides (WebP-only textures @ 512, downscaled HDR) on the reachable set, and inlineDynamicImports collapses the whole graph — including the flag-gated lazy renderers — into ONE JS chunk. The asset scanner copies the reachable assets into ads/; the inliner then JSON-stringifies {js, css, assets}, gzips + base64s it into a <script> bootstrap, deletes the now-inlined files, and fails the build if the result exceeds the byte cap.
Runtime. The bootstrap inflates the payload (DecompressionStream, or the inlined fflate.gunzipSync fallback), turns every asset into a blob: URL on globalThis.__PLAYABLE_ASSETS__, and imports the JS. assetUrl() resolves a root-absolute path to that blob, so THREE's loaders / fetch / img.src all load offline uniformly. main.tsx (behind __MODOKI_PLAYABLE__) runs bootPlayable; App.tsx skips registerAppServices() (no native SDKs in an ad).
Gating. bootPlayable mutes audio at boot and unmutes only when the ad is both viewable AND the user has interacted (re-muting whenever it scrolls off-screen); it withholds the CTA overlay until viewable, routes Install through mraid.open(storeUrl), caps a rewarded playable at 30 s, and shows the end-card on the cap or a game-dispatched window 'playable:end'.
Engine module toggles (build.modules)
A build can include/exclude the heavy engine SDKs (three.js, PixiJS, Rapier 2D/3D, NPR, GPU particles) so an unused one is dead-code-eliminated. This is a general build feature (it shrinks web builds too), but its headline win is fitting a game under the playable's 5 MB ceiling. project.config.jsonbuild.modules.<key> is 'auto' | boolean per module (render3d, render2d, physics2d, physics3d, npr, gpuParticles; all 'auto' by default).
- Resolution (
engine/plugins/detect-modules.ts, Node-only):resolveModulesturns each toggle into a concrete boolean.'auto'→detectModulesscans the project's included scene JSON for trait signals (Renderable3D/Light/Camera/Environment/ModelSource→render3d;Canvas2D/Renderable2D/Sprite→render2d;RigidBody2D/Collider2D→physics2d;layer:'3d'|'2d'onEntityAttributes; NPR + GPU-particles ride along withrender3d). Broad on purpose — a false-positive just ships an unused SDK (safe); a false-negative is loud (a build-time warn + the guard below). An explicittrue/falseforces it and logs a warning iffalsecontradicts a used module. - Wiring: the resolved booleans become
__MODOKI_MODULE_RENDER3D__/…_RENDER2D__/ … Vite defines (vite.config.ts), which flag-gate the renderers' lazy imports inApp.tsx(Scene3D/Game) so Rolldown DCEs the excluded SDK — the same mechanism the debug menu + journal use. - UI: Project Settings → Rendering & Physics → Engine Modules — a tri-state Auto | On | Off per module (
ModuleTogglesEditor, the'module-toggles'field), persisted through/api/project-settings.
Gotchas (the load-bearing, hard-won ones)
- Single chunk =
inlineDynamicImports, NOTcodeSplitting.codeSplittingis not a real Rollup option — Rollup silently ignores it, the lazy renderer chunk stays split, and the inliner's stray-JS guard aborts every 3D-game playable. OnlyinlineDynamicImports:truefolds dynamic imports into the entry. - You cannot
grepthe artifact. The payload is gzip+base64 inside the bootstrap — plaintext search finds nothing (two false "no audio inlined" diagnoses came from this). Decompress it to inspect (seesmoke-playable.mjs/ theinlinePlayable.test.tsround-trip). String.replace(x, str)$-corruption. The replacement STRING (especially the minifiedfflateUMD, full of$idents) has$&/$1/$`treated as substitution patterns → corrupted JS → "Invalid regular expression flags" at load, dead fflate fallback. Use function replacers (() => str).DecompressionStreamisn't universal (iOS < 16.4 / old Android WebView) — the bootstrap inlinesfflate.gunzipSyncas a feature-detected fallback and wraps in try/catch (surfacesdata-playable-errorinstead of a silent blank).- Audio must never auto-play. The browser's autoplay policy is NOT a reliable "wait for a tap" gate (a
file://open or lenient webview starts the AudioContext with no gesture). Unmute is gated on viewable AND the firstpointerdown/touchstart/keydown. - Stream audio is unreliable in ad webviews. A
streamclip plays viaHTMLMediaElement, whose gesture-gatedplay()+resume()re-kick is flaky (Android WebView: music stayed silent until a full reload). Playable builds forceloadType:'buffer'for every clip (decodeAudioData → the same path the SFX use) — the source.meta.jsonis untouched, so the real game still streams. - KTX2 needs a transcoder the profile skips. The WebP-only profile drops the Basis/pixi-ktx transcoders, so rigged/skeletal GLBs and sprite-atlas pages MUST also take the playable WebP override or they bake KTX2 (
KHR_texture_basisu) with no transcoder → black textures offline. - PixiJS 2D textures need a FORCED parser for
blob:URLs. Pixi v8 picks its texture loadParser by EXTENSION (loadTextures.test→checkExtension→path.extname, which strips BOTH?queryand#hash— a URL hint can't smuggle it in), and an inlined asset is an extension-lessblob:URL → "we don't know how to parse it" → the 2D render callback reads a null texture, frameDriver killsrender2d, the whole game renders blank. All Pixi loads go throughloadPixiTexture(runtime/rendering/), which forcesAssets.load({src, parser:'texture'})forblob:URLs (playable textures are always browser-decodable — WebP/PNG, never KTX2). 3D is unaffected (THREE uses explicit loaders). file://blob loads must decode on the MAIN thread. Opening the builtads/index.htmlstraight from Finder (the "reveal ads/" step invites it) is afile://NULL origin → the inlined assets becomeblob:null/…URLs, and Pixi's default texture worker cannotfetcha null-origin blob ("Failed to fetch") even though the same blob fetches fine on the main thread (so http:// served fine, the double-click didn't).loadPixiTexturecallsAssets.setPreferences({preferWorkers:false})once before the first blob load.- ISOLATE the game + overlay stacking, or the container's chrome hides behind you. An ad container (verified against AppLovin's preview) appends its OWN UI to the creative's
<body>atz-index:auto— its close/info button AND its "You have successfully clicked" confirmation. The engine layers renderers with z-index (the 2D Canvas host isposition:absolute; z-index:2) and the CTA overlay used a near-max z-index; since#rootisn't positioned, those LEAK into the<body>stacking context and paint over the container's chrome — somraid.openfires but its confirmation (and, on a real device, the close button) is invisible.bootPlayablesetsisolation:isolateon#rootand the overlay host, collapsing each to onez-auto<body>layer under the container's chrome. Internal 3D<2D<UI ordering is unchanged. - Dead SDK / debug weight. A game's
@<game>/app-services(AppLovin/Adjust/Firebase) and the debug/MCP bridge are inlined into the one chunk unless explicitly cut — the app-services package is aliased to a no-op stub, and the bridge import is!__MODOKI_PLAYABLE__-gated, so both DCE out. - The editor renders two Canvas2D canvases (GameView + SceneView UI-preview), so a game that maps raw
windowpointer events to design space must pick the canvas UNDER the pointer (@modoki/engine/runtimehostCanvasUnder), notquerySelector's first match.
Testing
npm run smoke:playable— builds thespace-invaderartifact and drives it in headless Chromium: self-extract, WebGL render, thefflatefallback, no-autoplay + unmute-on-tap, the MRAID viewable gate,mraid.openCTA, and orientation reflow. Keep it in the loop for changes underinlinePlayable.ts,app/playable/**, or theVITE_PLAYABLEpath invite.config.ts— it has caught bugs the unit suite missed.- Unit:
inlinePlayable.test.ts,bootPlayable.test.tsx,mraid.test.ts,playableOverlay.test.tsx,hostCanvas.test.tsx,audioCueRetry.test.ts. - On device: upload
index.htmlto the AppLovin preview at https://p.applov.in/playablePreview?create=1 (the?create=1query is required — the bare path doesn't show the upload flow), or serveads/over the LAN and open it in the device browser (a good render/audio/touch proxy; no MRAID container → standalone path).
Deferred — per-network adapters (gated on demand)
Only applovin is wired today (build.playableNetwork). Full per-network coverage — installClick() adapters for ironSource / Meta (FbPlayableAd) / TikTok (dapi) / Pangle, plus the ZIP packagers each SDK wants — is deliberately deferred until a campaign actually targets those networks.
Related
- audio-plan.md — the audio subsystem (
AudioSource, cue bus, buffer/stream fork) - textures.md · model-pipeline.md — the asset conversion the profile overrides
- build.md — the
MODOKI_PROJECTbuild pipeline this rides on