Files
vsix-hpl-toolbox/aiAssets/GUIDE.md
2026-05-28 11:38:41 +08:00

25 KiB
Raw Blame History

AGENTS.md — Playable Ads

Stack

Phaser 3.90 · TypeScript · Vite + vite-plugin-singlefile · WebP · MP3 96kbps Networks: Applovin (al) · GoogleAds (gg) · Ironsource (is) · Mintegral (mtg) · Facebook (fb) · Unity (un) · Vungle (vu) · Moloco (mo) · TikTok (tt)

Porting to a new project

  1. Update naming fields (3LC, vendor, concept name) in scripts/build-all.mjs — drives the output filename
  2. Update store URLs in constants.ts (iOS + Android)
  3. Adjust the Depth Map below to match the project's layer needs
  4. Update iteration.ts A/B config shape for this project's variants
  5. Replace Assets/ with project assets; update asset manifests in BootScene.ts
  6. Rewrite game/ modules for the new mechanic(s) — keep the single-responsibility split
  7. Update this AGENTS.md's Depth Map and any project-specific pitfalls

Structure

src/
  main.ts              # Bootstrap, resize, MRAID gating, lifecycle stubs on window
  constants.ts         # Design coords (1080×1920), store URLs
  networks.ts          # triggerCTA(), initMraid(), bindLifecycle(), notifyGameX()
  analytics.ts         # trackEvent() → SDK
  iteration.ts         # A/B iteration config
  utils/
    responsive.ts      # sx(), sy(), sd() — coordinate helpers
  scenes/
    BootScene.ts       # Critical preload + deferred asset manifest
    GameScene.ts       # Orchestrator only — wires game/ modules, handles lifecycle
    cta.ts             # End-card layout + redirectToStore() → notifyGameClose() + triggerCTA()
  game/                # Single-responsibility modules (no direct ad-SDK calls)
    # …one file per distinct mechanic or UI widget
Assets/
scripts/build-all.mjs  # Single build → per-network HTML variants

Responsibility split

  • game/ modules each own a single concern (one mechanic, one widget, one system). They receive the Phaser scene as a constructor arg and own their own game objects. They never call ad-SDK functions (triggerCTA, notifyGameX, trackEvent).
  • GameScene.ts is the wiring layer: creates modules, passes data between them, and calls SDK helpers at the correct lifecycle moments.
  • scenes/cta.ts owns end-card presentation and the store-redirect sequence.

Rules

  • 60 FPS target; never below 30 on mid-range Android
  • dist/index.html < 5 MB; WebP images, short audio
  • No hardcoded px — sx(), sy(), sd() only
  • No audio autoplay — unmute on first pointerdown
  • for loops in game logic; pool objects; no GC
  • setDisplaySize(sd()) on images — never setScale
  • Never dim via body/page background

Phaser Config

{ type: Phaser.AUTO, transparent: true,
  scale:  { mode: Phaser.Scale.NONE, width: 1080, height: 1920 },
  render: { antialias: true, pixelArt: false } }

Scale.NONE — manual sizing; CSS = viewport px; internal = viewport × DPR. antialias: true — required to avoid jagged edges on scaled WebP assets (end-card logo, CTA, score pill). Never set to false.

Responsive (1080×1920 ref)

const s    = Math.min(viewW / 1080, viewH / 1920)
const offX = (viewW - 1080 * s) / 2
const offY = (viewH - 1920 * s) / 2
// sx/sy/sd apply offX, offY, s
  • update() polls visualViewport per-frame for rotation
  • On change: game.scale.resize(vw*dpr, vh*dpr)relayout()
  • bindResponsiveResize(): rAF debounce + 100/300/600ms retries

CTA — SDK Priority (triggerCTA fallback chain)

1. ExitApi.exit()             → GoogleAds (gg)
2. FbPlayableAd.onCTAClick()  → Facebook (fb) · Moloco (mo)
3. Luna.Unity.Playable        → Unity (un)
4. playableSDK.openAppStore() → (runtime fallback if SDK present)
5. window.install()           → Mintegral (mtg)
6. window.openAppStore()      → (runtime fallback)
7. window.clickTag            → Moloco (mo) fallback
8. window.__VUNGLE__          → Vungle (vu) via parent.postMessage
9. window.__TIKTOK__          → TikTok (tt) → openAppStore() or window.open fallback
10. mraid.open(url)           → Applovin (al) / Ironsource (is) / Unity (un fallback after Luna)
11. window.open(storeUrl)     → Fallback (all others)

notifyGameClose() fires before every CTA redirect (all networks, no-op when SDK absent). MRAID CTA calls must be guarded: require typeof mraid.open === 'function'; treat missing mraid.getState() as ready; if state is still loading, fall through to window.open(storeUrl).

Analytics — SDK Priority

ALPlayableAnalytics.trackEvent(e)  → Applovin (al)
playableSDK.reportEvent(e)        → (runtime fallback if SDK present)
console.log('[Analytics]', e)      → All other networks

Events: DISPLAYED · CTA_CLICKED · ENDCARD_SHOWN · CHALLENGE_STARTED · CHALLENGE_SOLVED

Game Lifecycle (all networks)

main.ts exposes stubs on window so every network's preview tool detects them. Stubs only set if the SDK hasn't already provided its own (typeof guard).

gameReady()  → stub in main.ts; Mintegral also gets onload body attr from build
gameStart()  → notifyGameStart() in GameScene.startPlaying()
gameEnd()    → notifyGameEnd()  in GameScene.triggerFail() / triggerWin()
gameClose()  → notifyGameClose() in cta.ts redirectToStore(), before triggerCTA()

Pause / Resume / Mute (bindLifecycle in networks.ts)

Network Mechanism
Unity luna:mute / luna:unmute / luna:pause / luna:resume events; also shared MRAID exposure/viewability when mraid.js is present
Mintegral window.messageonPause / onResume
Vungle ad-event-pause / ad-event-resume events
Applovin / Ironsource / Unity MRAID 3.0 exposureChange, MRAID 2.0 fallback viewableChange / isViewable, and audioVolumeChange (gated in initMraid)
GoogleAds / Facebook / Moloco No SDK pause — browser handles visibility

MRAID 3.0 Requirements & Best Practices

MRAID networks are Applovin (al), Ironsource (is), and Unity (un). All three must request <script src="mraid.js"></script> early in the generated HTML. Unity still uses Luna first for CTA/lifecycle where available, but MRAID must exist as the standards fallback.

Creative requirements from the MRAID 3.0 spec and Best Practices Guide:

  • Request mraid.js as early as possible, exactly once, either with <script src="mraid.js"></script> or DOM insertion. Do not rely on the container to inject it automatically.
  • Wait for both DOM readiness and MRAID readiness before starting rich media behavior. Use document.readyState / DOMContentLoaded or load for the DOM side.
  • Use mraid.getState() together with mraid.addEventListener('ready', ...) so the creative still starts when the container fires ready before the listener attaches.
  • Guard access to mraid and MRAID methods with typeof checks. Treat missing optional methods as unsupported and fall back gracefully.
  • Use mraid.open(url) for MRAID clickthroughs. Avoid <a href>, location.href / assign / replace, and unguarded window.open.
  • Include <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"> or an equivalent mobile viewport tag.
  • Do not assume nested iframes can access native MRAID. If nested iframe access is required, the outer frame must provide its own bridge.
  • Use exposureChange as the MRAID 3.0 viewability signal, then degrade to viewableChange / isViewable() for MRAID 2.0 compatibility.
  • Listen for audioVolumeChange; null is valid and must be ignored. Only apply volume math when the value is numeric.
  • Listen for error; if the ad cannot run or assets fail, gracefully degrade or call mraid.unload() instead of showing a broken/blank ad.
  • Call setResizeProperties() before resize(). Do not use resize() for full-screen takeovers; use expand() for that case.
  • Avoid resizing or expanding interstitials. Check placement with getPlacementType() when one creative can serve inline and interstitial placements.
  • Avoid useCustomClose() and custom close indicators in MRAID 3.0. The host provides the close control.
  • Avoid two-part expandable ads (expand(url)); use self-contained one-part expandables.
  • Call supports() before optional/native features such as storePicture, createCalendarEvent, getLocation, VPAID, SMS/tel, or inline video behavior.
  • Use playVideo() for native video playback cases instead of mraid.open(videoUrl). Use HTML5 <video> for inline playback when supported.

Implementation requirements for networks.ts / networks.js:

  • Export initMraid(timeoutMs = 2000, detectTimeoutMs = 500) and call it at module load.
  • If window.mraid is missing at module load, poll briefly (detectTimeoutMs, default 500 ms) for late container injection before resolving as no-MRAID.
  • If MRAID exists and mraid.getState() === 'loading', wait for mraid.addEventListener('ready', ...); self-resolve after timeoutMs (default 2000 ms) so startup never hangs.
  • main / boot must await initMraid() before constructing new Phaser.Game(...).
  • On setup, seed cached visibility from mraid.isViewable() when available.
  • Register MRAID callbacks once, before any Phaser scene is ready:
    • mraid.addEventListener('error', (message, action) => ...) — log or gracefully handle failed/unsupported MRAID calls.
    • mraid.addEventListener('stateChange', state => ...) — required when using state-changing MRAID behavior such as close, expand, or resize.
    • mraid.addEventListener('exposureChange', exposedPercentage => ...) — MRAID 3.0 primary viewability. Pause when exposedPercentage <= 0; resume when positive.
    • mraid.addEventListener('viewableChange', viewable => ...) — MRAID 2.0 compatibility fallback.
    • mraid.addEventListener('audioVolumeChange', pct => ...) — only apply pct / 100 when typeof pct === 'number'.
  • In bindLifecycle(scene), apply cached hidden/non-exposed MRAID state immediately once a scene exists.
  • If the creative renders its own close control, wire that control to a guarded mraid.close() helper and call notifyGameClose() before closing.

Build

Vite outputs IIFE format (format: 'iife', modulePreload: false). Build script strips type="module" and crossorigin — ad networks reject ES modules. MRAID networks (al, is, un) inject exactly one <script src="mraid.js"></script> request. If built output contains console.error from Phaser or bundled node modules, prefer dropping it at bundle time instead of editing library source. Add this option to the shared Vite config, usually vite.shared.js:

export default {
  esbuild: {
    pure: ['console.error'],
  },
};

Keep project-authored error handling meaningful: use visible fallback UI, console.warn, or guarded debug logging where needed.

Network Tag Injected Zipped Included
Applovin al mraid.js
Google gg exitapi.js
Ironsource is mraid.js
Mintegral mtg onload="gameReady()"
Facebook fb
Unity un mraid.js
Vungle vu window.__VUNGLE__=true
Moloco mo
TikTok tt window.__TIKTOK__=true

Networks marked Zipped ingest zipped creatives — scripts/build-all.mjs writes a .zip containing the HTML (one zip per variant, containing just that HTML at the root named index.html). Toggled per-network via zip: true in the NETWORKS array.

Networks marked Included are built by default. Networks not marked are prepared for but not built. Toggle per-network via included: true in the NETWORKS array.

Output is grouped by iteration length (e.g. 2words/, 4words/, full/) then by network (e.g. Applovin/, Google/, Mintegral/), with all per-network HTML or ZIP variants for that iteration sitting together inside the folder:

dist/<length>/<3lc>_<creative-type>_<vendor>_<concept-name>_<concept-num>_<gameplay>_<ugc>_<seasonal>_<lang>_<length>_<size>_<network>

Example layout:

dist/
  2words/
    Applovin/
      ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_al.html
    Google/
      ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_gg.zip
    Ironsource/
      ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_is.html
    …one HTML or ZIP per network…
  4words/
  full/
Field Description Example
3LC 3-letter project code ws (Wordscapes), pp (PeoplePuzzle)
Creative Type Format identifier mip
Vendor Studio / vendor code grhpl
Concept Name Creative concept (append var1, var2… for variants) wbinspiredvar1, wbinspiredvar2, burglar
Concept # Iteration number, zero-padded 01, 02, 03
Gameplay Gameplay style real, cartoon
UGC UGC presence na, ugc, nougc
Seasonal Seasonal tie-in noseason, xmas
Language Locale code en
Length Run length 2words, 4words, 7words, full (Wordscapes); 2clk, 10clk, 120sec (other projects)
Size Region / size code na
Network Network tag (last segment) al, gg, is, mtg, fb, un, vu, mo, tt

Examples (this project):

  • ws_mip_grhpl_wbinspiredvar1_01_real_na_noseason_en_2words_na_al.html (cta2, default theme)
  • ws_mip_grhpl_wbinspiredvar1_04_real_na_noseason_en_full_na_fb.html (full, default theme)
  • ws_mip_grhpl_wbinspiredvar2_01_real_na_noseason_en_2words_na_gg.html (cta2, v1 theme)

Naming fields are defined in scripts/build-all.mjs — update them when porting.

Asset Loading

  • Critical (BootScene): first-frame assets, target < 200 KB
  • Deferred: audio, overlays, secondary UI — loads during intro
  • Gameplay start waits on a deferred-ready flag set when the secondary manifest finishes loading

Depth Map (adjust per project)

Define depths as named constants in constants.ts — never use magic numbers in scene code.

Layer Depth
Background 0
Game objects 110
Logo 17
Dim overlay 20
Fail/win UI 21
EndCard input 25

Ship Checklist

  • < 5 MB, all WebP
  • FPS ≥ 55 under CPU throttle
  • Portrait + landscape pass
  • MRAID ready before gameplay
  • Audio muted by default
  • CTA fires in fallback
  • No console.error
  • No type="module" or crossorigin on <script> in output HTML
  • Lifecycle: gameReady / gameStart / gameEnd / gameClose detected by all network preview tools

Pitfalls

Issue Fix
Rotation stuck Scale.NONE + per-frame poll
Mobile aliasing Canvas = viewport × DPR
FPS drop on resize rAF debounce
MRAID crash typeof mraid !== 'undefined' guard
Assets not inlined assetsInlineLimit: 100_000_000
Bundle > 5 MB WebP q75, shorter audio
Built output contains library console.error If console.error comes from Phaser or bundled node modules, add esbuild: { pure: ['console.error'] } in vite.shared.js so it is dropped at bundle time. Do not edit library source.
Audio blocked Gate on pointerdown
Letterbox Avoid Scale.FIT/EXPAND/CENTER_BOTH
CORS / module error format: 'iife' + strip type="module" and crossorigin
Network preview checklist fail Expose lifecycle stubs on window + call notifyX() with typeof guards
playableSDK.reportEvent is not a function Applovin preview injects playableSDK without reportEvent — guard with typeof playableSDK.reportEvent === 'function', not just typeof playableSDK !== 'undefined'
iPhone black screen (Android works) Only one Phaser.Game per page, ever. iOS WKWebView — used by Applovin/Ironsource preview apps and most in-app ad containers — caps or outright rejects the 2nd WebGL context. If a background scene is spun up as a separate new Phaser.Game() synchronously at module load, it can throw before await initMraid() and before the font promise resolves, so the rest of main.ts never runs → black screen with no visible error. Render the background as a CSS background-image on #bg-container (cover-sized) instead — zero WebGL contexts for the background, and nothing to throw at module-load time. iOS-debug path: enable Safari → Advanced → Web Inspector on the iPhone, connect to a Mac, open Develop → iPhone → preview WebView, read the Console. Without a Mac, temporarily install top-of-main.ts window.addEventListener('error',…) and unhandledrejection handlers that write the error into document.body.innerHTML so the failure is visible on-screen.
iOS hangs on font load document.fonts.load() silently stalls in WKWebView when @font-face uses base64 data: URLs. Race against a 1.5 s timeout: Promise.race([Promise.all([…fonts]), new Promise(r=>setTimeout(r,1500))]).then(startGame)
iOS hangs on MRAID ready mraid.addEventListener('ready', …) can miss the event if the container fires it before the listener attaches. initMraid() must self-resolve after ~2 s so startGame() always runs; also check mraid.getState() !== 'loading' first.
External src= survives build vite-plugin-singlefile only inlines assets imported from JS. A raw <img src="src/assets/…"> in index.html ships as a literal relative path and fails in ad sandboxes, producing inconsistent per-device load failures. Leave DOM <img> elements with no src; assign via JS from assets.ts at boot (setEndcardHtmlAssets()).
Audio keeps playing when ad hides Add visibilitychange listener in the sound module — mute Phaser's sound system and suspend() the AudioContext when document.hidden, resume on return. Spec requires audio stop on hide/close.
mraid.open() silently no-ops Guard with mraid.getState() !== 'loading' before calling; fall through to window.open() if still loading.
Previewer black screen with Cannot read properties of null (reading 'appendChild') in Phaser boot Two compounding causes: (1) vite-plugin-singlefile hoists the bundled script into <head>, and the build strips type="module", so the script runs synchronously before <body> is parseddocument.body is null. Wrap main.ts in a DOMContentLoaded gate (if (document.readyState === 'loading') addEventListener('DOMContentLoaded', boot) else boot()). (2) parent: 'game' (string selector) fails when the previewer sandbox strips custom DOM; create <div id="game"> at runtime inside boot() and pass the element reference (not the string) to Phaser.Game({ parent }). The cascading ScaleManager.resize → Cannot set properties of undefined (setting 'width') errors all trace back to game.canvas never being created when either of these fail.
NineSlice won't shrink in landscape Phaser's NineSlice silently clamps its minimum dimensions to (leftWidth + rightWidth) × (topHeight + bottomHeight) in source pixels. Symptom: one UI chrome element stays huge in landscape while every other sd()-driven element rescales correctly — because the requested sd(H) is below the inset total. Shrinking the insets fixes the scale clamp but stretches the art's rounded corners. The right fix: operate the NineSlice in source-pixel space and scale it uniformly with setScale(sd(1)). Keep setSize(wSrc, hSrc) in source px so the center stretches horizontally with text growth while corners stay intact; the sd(1) scale is what makes the whole object shrink in landscape, bypassing the min-dim clamp entirely. When reading this.preview.width (which is in scaled px) to decide wSrc, divide by sd(1) to convert back to source space. Tween callers that reset setScale(1) also need updating — the bg's base scale is now sd(1), not 1. See SelectionLine.ts for the pattern.
Unity (un) submission: "not allowed to use window.top" Luna's static scan flags the literal window.top, which Phaser 3 ships internally. In build-all.mjs, rewrite window.topwindow.self for the un HTML only (other networks keep the original). Safe inside the playable's own frame because the iframe-vs-top check collapses to always-top.
MRAID network missing mraid.js MRAID 3.0 creatives must request mraid.js early. Inject <script src="mraid.js"></script> exactly once for Applovin (al), Ironsource (is), and Unity (un), even if the container may also expose runtime MRAID objects.
MRAID listener wired too late Previewers can probe MRAID before any Phaser scene is ready — if listeners are wired only inside bindLifecycle / scene create(), the creative can miss ready, exposureChange, or viewableChange. Fix: call initMraid() at networks.ts module-load time, guarded by getState() === 'loading'addEventListener('ready', setupMraid) vs immediate call. Cache _mraidViewable / _mraidExposed; in bindLifecycle apply cached hidden/non-exposed state immediately.
MRAID 3.0 exposureChange not handled MRAID 3.0 containers dispatch exposureChange with exposedPercentage directly instead of — or in addition to — viewableChange. If only listening to viewableChange, the ad may never receive pause signals on newer AL/IS/Unity builds. Register both: mraid.addEventListener('exposureChange', exposedPercentage => onExposure(exposedPercentage > 0)) and mraid.addEventListener('viewableChange', onViewable) inside initMraid(). Seed initial state from mraid.isViewable() on ready.
MRAID 3.0 audioVolumeChange not handled MRAID 3.0 containers fire audioVolumeChange with a volume level (0100) or null when unavailable. Without a listener, the ad ignores host-level mute/unmute signals; if null is divided by 100, the ad can mute accidentally. Register mraid.addEventListener('audioVolumeChange', v => { if (typeof v === 'number') scene.sound.setVolume(v / 100) }) in initMraid().
MRAID error listener missing Validators expect failed or unsupported MRAID calls to be observable. Register mraid.addEventListener('error', (message, action) => console.warn('[MRAID error]', { message, action })) during early MRAID setup.
MRAID stateChange listener missing If the creative uses state-changing container behavior such as mraid.close(), expand, or resize, validators expect a stateChange listener. Register mraid.addEventListener('stateChange', state => console.log('[MRAID stateChange]', state)) with the other early MRAID callbacks.
Custom close control not wired to MRAID If the creative renders its own close button, the button must call a guarded close helper: notifyGameClose() then mraid.close() when typeof mraid.close === 'function' and mraid.getState() !== 'loading'. Do not use CTA/install buttons as close controls.
MRAID init not awaited before Phaser boot If new Phaser.Game() is created before initMraid() resolves, the scene may fire create() before MRAID state is known — bindLifecycle then cannot apply a cached hidden/paused state. Make boot() async and await initMraid() before constructing the Phaser game. initMraid() must self-resolve after a timeout (~2 s) so a missing or non-firing MRAID container never blocks startup.
Late MRAID injection not handled Some containers inject window.mraid after the script executes — a synchronous typeof window.mraid !== 'undefined' check at module load returns false even though MRAID will be present. After the immediate check fails, poll briefly (~500 ms) before concluding no-MRAID, then proceed.
Unsafe optional MRAID API calls useCustomClose() was removed in MRAID 3.0, and APIs such as expand, resize, storePicture, and createCalendarEvent are optional/capability-dependent. Do not call them unless the project intentionally supports the feature and guards every call with typeof mraid.method === 'function' / mraid.supports(...) where relevant.

Ironsource — Runtime Analysis

Ironsource requires every _is.html build to pass LevelPlay → Creative Management → Playable Workshop (Runtime Analysis / Playable Validator) before launch. Skipping this can get the ad disabled. The validator runs in an iOS-like WKWebView sandbox and exposes console errors — use it as a free diagnostic channel for iPhone-only failures when a Mac + Safari remote-debug isn't available.

Moloco — Upload Checklist & CTA Implementation

Creative specs: HTML5 (.html / .htm), < 5 MB, portrait + landscape.

Before uploading to Moloco Ads, confirm all three:

  • No XMLHttpRequest anywhere in the ad (Moloco rejects it outright)
  • CTA calls FbPlayableAd.onCTAClick() with no parameters — Moloco uses this for store redirect (not window.clickTag)
  • No JavaScript redirects in ad file assets

CTA implementation options

Option 1 — event listener

<button id="ctaButton">Install</button>
<script>
  document.getElementById('ctaButton').addEventListener('click', function() {
    FbPlayableAd.onCTAClick();
  });
</script>

Option 2 — inline onClick

<button onclick="FbPlayableAd.onCTAClick()">Install</button>

Moloco's validator requires FbPlayableAd.onCTAClick() — this is why it shares priority #2 in the CTA chain with Facebook. window.clickTag is kept as a fallback at priority #7.

EOF [05/28/2026]