# 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) · 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.message` → `onPause` / `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 `` 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 `` 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 ``, `location.href` / `assign` / `replace`, and unguarded `window.open`.
- Include `` 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 `