candyads — Technical PRD v1.1

Doc

  1. Doc: "candyads" technical PRD v1.1, written 2026-10-19, owner Gil, status Draft.
  2. Doc: Scope: v1 in full technical detail. v2 and v3 are a short roadmap and get their own spec later.
  3. Doc: Replaces v0.3. Older versions are archived outside the product repo.
  4. Doc: Business content (strategy, moat, competitors, glossary) is in Appendix A.
  5. Doc: "(proposed)" = open for review. "(decided)" = fixed. Unmarked = follows from a decided item.

1. v1 summary

  1. What: A browser-only editor. The user places mechanic blocks on a four-step timeline, styles them, previews live, and exports a package per ad network.
  2. Not in v1: AI, accounts, billing, network uploads, variant testing, video playables, a free-form object editor.
  3. Constraint: No server. The editor is static on Cloudflare Pages (decided); export runs in the browser.
  4. Constraint: The exported ad makes zero network requests, except the SDK the network injects.
  5. Target: Runtime + all v1 blocks ≤ 1 MB minified, uncompressed (measured in M1).
  6. Target: A typical export ≤ 2 MB; hard caps come from each network's rule sheet.
  7. Target: 60 fps on a 2021 mid-range Android phone at 1080×1920.
  8. Target: Editor first load ≤ 3 s on broadband; an export of one network ≤ 5 s for a 2 MB project on an M1 Mac.

2. Stack

  1. Language (proposed): TypeScript 5, strict mode. Ad code targets ES2019 for older Android WebViews.
  2. Monorepo (proposed): pnpm workspaces.
  3. Runtime (proposed): PixiJS v8 for rendering, GSAP 3 for tweens.
  4. Editor (decided): React 19 + Vite.
  5. Editor state (proposed): Zustand + Immer; Immer patches drive undo/redo.
  6. Schemas (proposed): Block settings are written as Zod 4 schemas; z.toJSONSchema emits JSON Schema for the forms.
  7. Forms (proposed): @rjsf/core renders forms from the JSON Schema, with custom widgets for colour, asset and array fields.
  8. Timeline UI (proposed): React Flow for the boxes and wires.
  9. Ad packaging (proposed): Vite + vite-plugin-singlefile prebuilds one HTML shell per network at dev time.
  10. Files (proposed): JSZip for .candy files and network zips; idb-keyval over IndexedDB for local storage.
  11. Images (proposed): UPNG.js for PNG quantising; OffscreenCanvas for JPEG re-encoding.
  12. Security (proposed): DOMPurify sanitises uploaded SVGs.
  13. Tests (proposed): Vitest for unit tests, Playwright for export end-to-end tests.

3. File tree

  1. Repo: Proposed layout. Each folder under packages/ is one workspace package.
candyads/
├─ package.json              # pnpm workspaces + root scripts
├─ pnpm-workspace.yaml
├─ tsconfig.base.json
├─ packages/
│  ├─ schema/                # project format, Zod schemas, migrations
│  │  └─ src/
│  │     ├─ project.ts       # Project, Step, Wire, AssetRef, BrandKit
│  │     ├─ validate.ts      # schema + flow validation
│  │     └─ migrations/      # one file per format version
│  ├─ runtime/               # PixiJS + GSAP player that ships inside every ad
│  │  ├─ src/
│  │  │  ├─ boot.ts          # read project, load assets, start flow
│  │  │  ├─ flow.ts          # step/wire engine
│  │  │  ├─ assets.ts        # data-URI loader → Textures, sounds, fonts
│  │  │  ├─ audio.ts         # unlock on first touch, music loop
│  │  │  ├─ layout.ts        # design size, scaling, orientation
│  │  │  └─ edit-api.ts      # postMessage bridge to the editor
│  │  └─ shells/             # build output: shell-<network>.html
│  ├─ blocks/
│  │  ├─ src/types.ts        # BlockDefinition, BlockContext, ArtSlot
│  │  ├─ tap/                # each block: index.ts, schema.ts, block.ts, thumb.webp
│  │  ├─ maze-chomp/
│  │  ├─ scratch-card/
│  │  ├─ spin-wheel/
│  │  ├─ message/
│  │  └─ end-card/
│  ├─ adapters/
│  │  ├─ src/                # types.ts + one file per network
│  │  └─ rules/              # <network>.json rule sheets
│  ├─ exporter/
│  │  └─ src/
│  │     ├─ worker.ts        # Web Worker entry
│  │     ├─ inline.ts        # inject project + assets into a shell
│  │     ├─ compress.ts      # optional image compression
│  │     ├─ size.ts          # size estimate + cap check
│  │     └─ cli.ts           # Node CLI wrapper
│  └─ editor/
│     └─ src/
│        ├─ app/             # routes, layout, top bar
│        ├─ timeline/        # four-step board + wires
│        ├─ gallery/         # block picker with live thumbnails
│        ├─ inspector/       # schema forms, brand kit
│        ├─ preview/         # iframe host + device frame
│        ├─ assets/          # library panel, upload, packs
│        ├─ history/         # snapshots
│        ├─ export/          # network picker, size report, download
│        └─ store/           # Zustand store, undo/redo, autosave
├─ packs/                    # built-in asset packs (own art only)
├─ templates/                # three .candy presets
├─ tests/
│  ├─ unit/
│  ├─ golden/                # expected export hashes
│  └─ e2e/                   # Playwright
└─ docs/PRD.md

4. Project format

  1. Format: A project is one JSON document plus asset files. Every format change bumps version and adds a migration; old projects upgrade on load.
  2. Format: The timeline always has four steps in this order: hook, play, result, endcard. The result step holds a win block and a fail block.
// packages/schema/src/project.ts
export type Orientation = 'portrait' | 'landscape';
export type StepId = 'hook' | 'play' | 'result' | 'endcard';
export type AssetId = string;

export interface Project {
  format: 'candyads.project';
  version: 1;
  id: string;                              // uuid
  name: string;
  store: { androidUrl: string; iosUrl: string };
  brand: BrandKit;
  steps: Step[];                           // always 4, in StepId order
  wires: Wire[];
  assets: Record<AssetId, AssetRef>;
  music?: { asset: AssetId; loop: boolean };
  strings?: Record<string, Record<string, string>>; // locale → key → text
  meta: { createdAt: string; updatedAt: string; templateId?: string };
}

export interface Step {
  id: StepId;
  blocks: BlockInstance[];                 // 1 block; 'result' has 2
}

export interface BlockInstance {
  id: string;
  type: string;                            // e.g. 'spin-wheel'
  typeVersion: number;
  settings: Record<string, unknown>;       // validated by the block's schema
  art: Record<string, AssetId>;            // art slot key → asset
  role?: 'win' | 'fail';                   // only in 'result'
}

export interface Wire {
  from: { block: string; event: string };  // e.g. { block: 'b2', event: 'won' }
  to: { block: string } | { action: 'cta' | 'restart' | 'end' };
  delayMs?: number;
}

export interface AssetRef {
  kind: 'image' | 'spritesheet' | 'audio' | 'font';
  file: string;                            // 'assets/<sha256>.<ext>' in the .candy zip
  mime: string;
  bytes: number;
  frames?: { cols: number; rows: number; count: number; fps: number };
}

export interface BrandKit {
  logo?: AssetId;
  font?: AssetId;
  colors: { primary: string; secondary: string; accent: string; text: string; background: string };
  locked: Array<'colors' | 'font' | 'logo'>; // locked values override block settings
}
  1. File: A .candy file is a ZIP with project.json at the root and assets in assets/. Asset files are named by their SHA-256, so identical uploads are stored once.
  2. File: On import, unknown future versions are refused with a clear message; older versions are migrated.

5. Block API

  1. Block: A block is one small reusable piece of gameplay. It declares its settings, art slots and events; the runtime hosts it inside a PixiJS container.
  2. Block: Blocks never call the network adapter directly. They only emit events; the flow engine decides what happens next.
// packages/blocks/src/types.ts
import type { Container, Texture } from 'pixi.js';
import type { ZodType } from 'zod';

export interface BlockDefinition<S> {
  type: string;                            // 'spin-wheel'
  version: number;                         // bump + migrate settings on change
  name: string;
  tags: { mechanic: string[]; genre: string[]; style: string[] };
  allowedSteps: StepId[];
  settings: ZodType<S>;
  defaults: S;
  art: ArtSlot[];
  emits: string[];                         // e.g. ['won', 'failed', 'progress']
  create(ctx: BlockContext, settings: S): BlockRuntime<S>;
}

export interface ArtSlot {
  key: string;
  label: string;
  kind: 'image' | 'spritesheet' | 'audio';
  required: boolean;
  recommendedPx?: [number, number];
  fallback?: 'brand-logo' | 'pack';        // used when the user sets nothing
}

export interface BlockContext {
  root: Container;                         // sized to the safe area
  viewport: { w: number; h: number; orientation: Orientation };
  texture(slot: string): Texture | undefined;
  sound(slot: string): { play(): void; stop(): void } | undefined;
  brand: BrandKit;
  text(key: string): string;               // localised string
  emit(event: string, payload?: unknown): void;
  tween: typeof import('gsap').gsap;
  editMode: boolean;
}

export interface BlockRuntime<S> {
  start(): void;
  layout(viewport: BlockContext['viewport']): void;
  update?(dtMs: number): void;
  applySettings?(next: S): void;           // live edit without a restart
  destroy(): void;                         // must kill its tweens and listeners
}

6. v1 blocks

  1. tap (hook): Settings: prompt text, pulse speed. Art: hand pointer, background. Emits done on first tap.
  2. maze-chomp (play): Settings: grid cols and rows, enemy count and speed, lives, time limit, win at % of dots eaten. Art: player spritesheet, enemy, dot, wall tile, background. Input: swipe or drag. Emits won, failed, progress.
  3. scratch-card (play): Settings: brush radius, reveal threshold 0–1, prize text. Art: cover, prize, background. Emits won at the threshold, plus progress.
  4. spin-wheel (play): Settings: segments (label, colour, isWin), spins allowed, forced result index (optional). Art: wheel, pointer, background. Emits won, failed, spun.
  5. message (result): Settings: headline, body, auto-advance ms. Art: image, background. Emits done. New in v1.1: the result step needs a simple win and fail screen.
  6. end-card (endcard): Settings: headline, CTA text, show rating. Art: logo, app icon, background. Emits cta.
  7. Default wiring: Placing blocks creates these wires automatically: hook done → play; play won → win; play failed → fail; win/fail done → end card; end card cta → action cta. The user can change them.

7. Runtime

  1. Boot: Read the project JSON from <script id="candy-project" type="application/json"> → decode assets from data URIs → create the PixiJS app → wait for the adapter's ready signal → start the hook step.
  2. Layout: Design size 1080×1920 (portrait) or 1920×1080 (landscape). The stage scales to fit and letterboxes with the brand background; blocks re-layout on resize and orientation change.
  3. Flow engine: One block is live at a time. When it emits an event, the engine finds the matching wires in order, waits delayMs, destroys the current block and creates the target, or runs the action.
  4. Actions: cta → adapter.openStore(); end → adapter.gameEnd(); restart → back to the hook step.
  5. Audio: Muted until the first touch (browser autoplay rules), then the music starts. It pauses when the adapter reports the ad hidden.
  6. Fonts: The brand font loads as an inline FontFace before the first block starts.
  7. Strings: Pick a locale from navigator.language; fall back to the project default.
  8. Errors: A block that throws is destroyed and the flow jumps to the end card, so the ad never shows a blank screen.

8. Editor ↔ runtime bridge

  1. Bridge: The preview is an iframe running a dev shell of the same runtime. All traffic goes through postMessage with these messages:
// packages/runtime/src/edit-api.ts
export type EditorToRuntime =
  | { t: 'load'; project: Project; assetUrls: Record<AssetId, string> } // blob URLs
  | { t: 'patch'; ops: JsonPatchOp[] }                                  // from Immer
  | { t: 'gotoStep'; step: StepId; role?: 'win' | 'fail' }
  | { t: 'orientation'; value: Orientation }
  | { t: 'mode'; value: 'edit' | 'play' };

export type RuntimeToEditor =
  | { t: 'ready' }
  | { t: 'event'; block: string; event: string }
  | { t: 'error'; message: string; block?: string }
  | { t: 'fps'; value: number };
  1. Edit mode: The cta and end actions are logged, not run. Settings patches call applySettings when the block has it; otherwise the block restarts.
  2. Play mode: Runs the ad as a player sees it, from the hook step, with a restart button.

9. Network adapters

  1. Adapter: One adapter per network behind one interface. Each network's shell is prebuilt with PLATFORM set, so the other adapters are tree-shaken out.
// packages/adapters/src/types.ts
export type NetworkId =
  | 'web' | 'mraid' | 'applovin' | 'unity'
  | 'ironsource' | 'mintegral' | 'google' | 'meta';

export type TrackEvent =
  | 'LOADED' | 'DISPLAYED' | 'FIRST_TOUCH'
  | 'CHALLENGE_STARTED' | 'CHALLENGE_FAILED' | 'CHALLENGE_SOLVED'
  | 'ENDCARD_SHOWN' | 'CTA_CLICKED';

export interface NetworkAdapter {
  id: NetworkId;
  init(h: { ready(): void; visible(v: boolean): void }): void;
  firstTouch(): void;
  openStore(): void;                       // picks Android or iOS URL from the user agent
  gameEnd(): void;
  track(e: TrackEvent): void;
}

export interface NetworkRules {
  id: NetworkId;
  maxBytes: number;
  output: 'single-html' | 'zip';
  zipFiles?: string[];                     // e.g. ['index.html']
  requires: string[];                      // e.g. ['mraid.open']
  forbids: string[];                       // e.g. ['external-requests']
  lastVerified: string;                    // ISO date
}
  1. MRAID: Waits for mraid.ready and viewability before starting; opens the store with mraid.open.
  2. Rule sheets: One JSON file per network in adapters/rules/, filled from each network's public docs before its adapter ships, and re-checked before every release.
  3. Networks first (decided): AppLovin, Mintegral, Unity. Then web, mraid, ironsource, google, meta.

10. Exporter

  1. Interface:
// packages/exporter/src/worker.ts
export interface ExportRequest {
  project: Project;
  files: Record<AssetId, Uint8Array>;
  networks: NetworkId[];
  compress: { images: boolean };
}

export interface ExportResult {
  network: NetworkId;
  filename: string;
  bytes: number;
  maxBytes: number;
  ok: boolean;                             // false = over cap or failed checks
  issues: string[];
  file: Blob;
}
  1. Step 1: Validate the project (schema + flow: every step reachable, a cta wire exists, store URLs set, required art slots filled).
  2. Step 2: Drop assets no block uses.
  3. Step 3: Optional compression: PNG quantise, JPEG re-encode. Audio is copied as-is in v1.
  4. Step 4: Load shell-<network>.html and inject the project JSON and every asset as a base64 data URI.
  5. Step 5: Write a single HTML or a zip, as the network's rule sheet says.
  6. Step 6: Check size and rule sheet; over-cap results are ok: false and shown red, never downloaded silently.
  7. Determinism: Sorted JSON keys and fixed zip timestamps, so the same input gives byte-identical output.
  8. Size estimate: Shown live in the editor: shell size + used assets × 1.37 (base64 overhead).
  9. CLI: candyads export project.candy --networks applovin,mintegral,unity --out dist/.

11. Editor

  1. Layout: Top bar (name, undo, redo, play/edit, orientation, export). Left: block gallery. Centre: timeline above the phone preview. Right: inspector.
  2. Timeline: Four fixed boxes. Drop a block on a box; drag from an event port to another box or to an action to wire it.
  3. Inspector: Forms generated from the selected block's JSON Schema, plus its art slots. Locked brand values show as read-only.
  4. Assets: Drag-and-drop upload (multiple files), built-in packs, preview, replace everywhere, see where an asset is used. Spritesheets: the user sets cols and rows.
  5. Storage: IndexedDB holds projects, assets (by SHA-256) and snapshots. Autosave 1 s after the last change.
  6. Undo: Immer patches, at least 100 steps; covers every project change.
  7. Version history: Named snapshots { id, name, createdAt, project, assetHashes }, with restore. Auto-snapshot before every export.
  8. Brand kit: Logo, font and five colours, set once per project; each can be locked.
  9. Checklist: Runs the exporter's step 1 live and lists problems before export.
  10. Platform: Desktop only, minimum width 1280 px. Works offline after first load (PWA). English UI, strings in an i18n file.

12. Quality

  1. Unit: Schema migrations, flow engine, wire validation, exporter determinism.
  2. Golden: Export each template for each v1 network and compare output hashes.
  3. End-to-end: Playwright opens each export at 390×844 and 844×390, taps through, and asserts CTA_CLICKED fires and zero external requests are made.
  4. Networks: Each adapter passes the network's own preview or validator where one exists, before it ships.
  5. Clean: No console errors in the editor or in exported ads.

13. Milestones (v1)

  1. M1: Schema + migrations, runtime, flow engine, tap and maze-chomp blocks, web shell, CLI export.
  2. M2: Editor: timeline, gallery, inspector forms, live preview, assets, save/load, undo.
  3. M3: scratch-card, spin-wheel, message, end-card blocks; AppLovin, Mintegral and Unity adapters with validator passes.
  4. M4: Remaining v1 adapters, brand kit, version history, three templates, deploy the editor to Cloudflare Pages.
  5. Done when: A non-coder builds a branded ad from blocks in under 10 minutes and exports it for AppLovin, Mintegral and Unity, all passing validation and under each cap.

14. Risks (technical)

  1. Size: PixiJS is the biggest part of the budget. If M1 measures over 1 MB, use a custom PixiJS build with only the needed modules.
  2. WebViews: Old Android WebViews; target ES2019 and test on a real low-end device before M3 ends.
  3. Rules: Network rules change often; the rule sheets carry a "last verified" date.
  4. Block API: Every block depends on it; freeze it after M1 and change it only with a version bump.
  5. Legal: Never copy PM's code, text, icons or templates; keep reverse-engineering notes out of this repo.
  6. Licences: ⚠️ Do not bundle or link AGPL-licensed code into the product; review any open-source licence before reuse.

15. Roadmap (not specced here)

  1. v2 (AI): Input = store link + gameplay video. Claude vision tags genre, mechanic, style. Blocks matched by tags; if none fits, a 4–6 frame storyboard with AI images, then an AI-written sandboxed block.
  2. v2 (editor): Plain-language chat edits feeding the live preview. Approvals via share links (needs accounts).
  3. v2 (art): Crop, pull video frames, remove backgrounds, image-model calls; style LoRA trained on 10–30 client images via fal.ai or Replicate.
  4. v2 (licences): Commercial use OK: SDXL, FLUX schnell, Qwen-Image. Paid licence needed: FLUX dev, BRIA. ⚠️ Verify before use; the default pipeline uses only commercial-use models.
  5. v3 (variants): Generate → Run → Measure → Learn. Results from the networks and AppsFlyer/Adjust; metric IPM.
  6. v3 (integrations): Upload to AppLovin, Unity and Meta through their APIs ⚠️ (check each API's terms). Real-device tests with Appium on BrowserStack or AWS Device Farm.
  7. Later networks: tiktok, moloco, liftoff, snapchat, remerge, revx, adikteev, tencent.

16. Open questions

  1. Open: Who pays for AI runs in v2, and do exports stay free and unlimited? Decide later.
  2. Open: Review the "(proposed)" stack items before M1 starts.

Appendix A. Business context

  1. Thesis: Platforms sell a full system around the code. Claude Code already writes working HTML5 playables, so code alone is not a moat.
  2. Moat meter: Network compliance = weak (1/3). No-code editor = weak (1/3). Network integrations = medium (2/3). Variant testing = strong (3/3). Art assets = strongest (3/3).
  3. Takeaway: The platforms' edge is the data loop and art quality. Claude Code covers code, compliance and the editor; candyads must build toward DATA and ART.
  4. Order: v1 = compliance + editor. v2 = AI + art. v3 = variant loop + network integrations.
  5. Glossary: LoRA = a small add-on that teaches an image model a specific style.
  6. Glossary: IPM = installs per 1,000 impressions.
  7. Glossary: MRAID = the standard that lets an HTML5 ad talk to its host app.