Doc: "candyads" technical PRD v1.1, written 2026-10-19, owner Gil, status Draft.
Doc: Scope: v1 in full technical detail. v2 and v3 are a short roadmap and get their own spec later.
Doc: Replaces v0.3. Older versions are archived outside the product repo.
Doc: Business content (strategy, moat, competitors, glossary) is in Appendix A.
Doc: "(proposed)" = open for review. "(decided)" = fixed. Unmarked = follows from a decided item.
1. v1 summary
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.
Not in v1: AI, accounts, billing, network uploads, variant testing, video playables, a free-form object editor.
Constraint: No server. The editor is static on Cloudflare Pages (decided); export runs in the browser.
Constraint: The exported ad makes zero network requests, except the SDK the network injects.
Target: Runtime + all v1 blocks ≤ 1 MB minified, uncompressed (measured in M1).
Target: A typical export ≤ 2 MB; hard caps come from each network's rule sheet.
Target: 60 fps on a 2021 mid-range Android phone at 1080×1920.
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
Language (proposed): TypeScript 5, strict mode. Ad code targets ES2019 for older Android WebViews.
Monorepo (proposed): pnpm workspaces.
Runtime (proposed): PixiJS v8 for rendering, GSAP 3 for tweens.
Editor (decided): React 19 + Vite.
Editor state (proposed): Zustand + Immer; Immer patches drive undo/redo.
Schemas (proposed): Block settings are written as Zod 4 schemas; z.toJSONSchema emits JSON Schema for the forms.
Forms (proposed):@rjsf/core renders forms from the JSON Schema, with custom widgets for colour, asset and array fields.
Timeline UI (proposed): React Flow for the boxes and wires.
Ad packaging (proposed): Vite + vite-plugin-singlefile prebuilds one HTML shell per network at dev time.
Files (proposed): JSZip for .candy files and network zips; idb-keyval over IndexedDB for local storage.
Images (proposed): UPNG.js for PNG quantising; OffscreenCanvas for JPEG re-encoding.
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.
File: On import, unknown future versions are refused with a clear message; older versions are migrated.
5. Block API
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.
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
tap (hook): Settings: prompt text, pulse speed. Art: hand pointer, background. Emits done on first tap.
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.
scratch-card (play): Settings: brush radius, reveal threshold 0–1, prize text. Art: cover, prize, background. Emits won at the threshold, plus progress.
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.
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
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.
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.
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.
Actions:cta → adapter.openStore(); end → adapter.gameEnd(); restart → back to the hook step.
Audio: Muted until the first touch (browser autoplay rules), then the music starts. It pauses when the adapter reports the ad hidden.
Fonts: The brand font loads as an inline FontFace before the first block starts.
Strings: Pick a locale from navigator.language; fall back to the project default.
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
Bridge: The preview is an iframe running a dev shell of the same runtime. All traffic goes through postMessage with these messages:
Edit mode: The cta and end actions are logged, not run. Settings patches call applySettings when the block has it; otherwise the block restarts.
Play mode: Runs the ad as a player sees it, from the hook step, with a restart button.
9. Network adapters
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
}
MRAID: Waits for mraid.ready and viewability before starting; opens the store with mraid.open.
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.
Networks first (decided): AppLovin, Mintegral, Unity. Then web, mraid, ironsource, google, meta.
Layout: Top bar (name, undo, redo, play/edit, orientation, export). Left: block gallery. Centre: timeline above the phone preview. Right: inspector.
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.
Inspector: Forms generated from the selected block's JSON Schema, plus its art slots. Locked brand values show as read-only.
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.
Storage: IndexedDB holds projects, assets (by SHA-256) and snapshots. Autosave 1 s after the last change.
Undo: Immer patches, at least 100 steps; covers every project change.
Version history: Named snapshots { id, name, createdAt, project, assetHashes }, with restore. Auto-snapshot before every export.
Brand kit: Logo, font and five colours, set once per project; each can be locked.
Checklist: Runs the exporter's step 1 live and lists problems before export.
Platform: Desktop only, minimum width 1280 px. Works offline after first load (PWA). English UI, strings in an i18n file.
M3:scratch-card, spin-wheel, message, end-card blocks; AppLovin, Mintegral and Unity adapters with validator passes.
M4: Remaining v1 adapters, brand kit, version history, three templates, deploy the editor to Cloudflare Pages.
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)
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.
WebViews: Old Android WebViews; target ES2019 and test on a real low-end device before M3 ends.
Rules: Network rules change often; the rule sheets carry a "last verified" date.
Block API: Every block depends on it; freeze it after M1 and change it only with a version bump.
Legal: Never copy PM's code, text, icons or templates; keep reverse-engineering notes out of this repo.
Licences:⚠️ Do not bundle or link AGPL-licensed code into the product; review any open-source licence before reuse.
15. Roadmap (not specced here)
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.
v2 (editor): Plain-language chat edits feeding the live preview. Approvals via share links (needs accounts).
v2 (art): Crop, pull video frames, remove backgrounds, image-model calls; style LoRA trained on 10–30 client images via fal.ai or Replicate.
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.
v3 (variants): Generate → Run → Measure → Learn. Results from the networks and AppsFlyer/Adjust; metric IPM.
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.
Later networks: tiktok, moloco, liftoff, snapchat, remerge, revx, adikteev, tencent.
16. Open questions
Open: Who pays for AI runs in v2, and do exports stay free and unlimited? Decide later.
Open: Review the "(proposed)" stack items before M1 starts.
Appendix A. Business context
Thesis: Platforms sell a full system around the code. Claude Code already writes working HTML5 playables, so code alone is not a moat.
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.