# 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](scripts/build-all.mjs) — drives the output filename 2. Update store URLs in [constants.ts](src/constants.ts) (iOS + Android) 3. Adjust the Depth Map below to match the project's layer needs 4. Update [iteration.ts](src/iteration.ts) A/B config shape for this project's variants 5. Replace `Assets/` with project assets; update asset manifests in [BootScene.ts](src/scenes/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 ```ts { 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) ```ts 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) 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) 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) 11. window.open(storeUrl) → Fallback (all others) ``` `notifyGameClose()` fires before every CTA redirect (all networks, no-op when SDK absent). ## 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 | | Mintegral | `window.message` → `onPause` / `onResume` | | Vungle | `ad-event-pause` / `ad-event-resume` events | | Applovin / Ironsource | MRAID viewable state (gated in initMraid) | | GoogleAds / Facebook / Moloco | No SDK pause — browser handles visibility | ## Build Vite outputs IIFE format (`format: 'iife'`, `modulePreload: false`). Build script strips `type="module"` and `crossorigin` — ad networks reject ES modules. | 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 | — | — | ✓ | | Vungle | vu | `window.__VUNGLE__=true` | ✓ | ✓ | | Moloco | mo | — | — | ✓ | | TikTok | tt | `window.__TIKTOK__=true` | — | — | Networks marked **Zipped** ingest zipped creatives — [scripts/build-all.mjs](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//<3lc>___________` 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 index.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](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](src/constants.ts) — never use magic numbers in scene code. | Layer | Depth | |---|---| | Background | 0 | | Game objects | 1–10 | | 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 `