# Bonus Round: full documentation Generated from https://bonusround.io/docs/ for AI agents and offline reading. The integration recipe for coding agents comes first; the human docs follow, one section per page. Source pages: https://bonusround.io/docs/, https://bonusround.io/docs/install, https://bonusround.io/docs/agents, https://bonusround.io/docs/attach, https://bonusround.io/docs/formats, https://bonusround.io/docs/test-mode, https://bonusround.io/docs/going-live, https://bonusround.io/docs/events, https://bonusround.io/docs/brand-safety, https://bonusround.io/docs/payouts, https://bonusround.io/docs/api, https://bonusround.io/docs/faq --- # Recipe: Integrate Bonus Round into a three.js game (recipe for AI coding agents) You are an AI coding agent. The user asked you to integrate **Bonus Round**, an ad network for three.js games, into their game. Follow these steps in order. Each step says what to look for, what to change, and how to know it worked. The integration is small: one script tag, one attach line, and an `await` at each natural break. Don't refactor the user's game. What the user gets: at natural breaks a 15-second branded Bonus Round takes over the game, with the player's own character and controls, plus a branded prop in the world. They earn 70% of net ad revenue (what brands spend, minus any disclosed agency commission). New games start in **test mode**, which always serves the fictional **Fizzpop Soda** test ad and is never billed, so the user sees a real round right away. Base URL: `https://bonusround.io` (or `$BONUSROUND_URL` if set). REST auth: `Authorization: Bearer br_sk_…` (an API key from https://bonusround.io/app/developers/). If the `bonusround_*` MCP tools are available, use them instead of raw HTTP; they call the same endpoints. ## Rules - Show the user your plan and your diff. Keep changes minimal and match the project's code style. - Never put a `br_sk_` API key in game code, HTML or git. The game only needs the public `pub_` id. - Never turn test mode off, change floors, block categories or pause a game unless the user asks. - Every call into the SDK from game code must be safe when the SDK didn't load (ad blockers): use the `bonusround` queue for `attach`, and `window.BonusRound?.` for everything else. - Don't put a `break()` in the middle of active play. Only at natural breaks. ## Step 1. Detect the three.js setup Find out how the game loads three.js and where its HTML entry is. Check, in order: 1. `package.json` dependencies: `three` (vanilla or bundled), `@react-three/fiber` (R3F), and the bundler (`vite`, `webpack`/`react-scripts`, `parcel`, `next`). 2. HTML files with an import map mapping `"three"`, a CDN ` ``` If the project has no HTML template (the build generates it), inject the same tag from the game's entry module: ```js const s = document.createElement('script'); s.src = 'https://bonusround.io/v1/br.js'; s.async = true; s.dataset.pub = 'pub_XXXXXXXX'; document.head.append(s); ``` ## Step 3. Find the renderer, scene and camera, and attach Search the source (skip `node_modules`, `dist`, `build`) for: - the renderer: `new THREE.WebGLRenderer(` or `new WebGLRenderer(` (note the variable it's assigned to: `renderer`, `this.renderer`, …) - the scene: `new THREE.Scene(` / `new Scene(` - the camera: `new THREE.PerspectiveCamera(` / `OrthographicCamera(`, the one players see the game through Insert the attach line once, right after all three exist, in the same scope (usually after the last of the three is created): ```js // Bonus Round: ambient branded props + Bonus Round takeovers. Docs: https://bonusround.io/docs/attach (window.bonusround = window.bonusround || []).push((BR) => BR.attach({ THREE, scene, camera, renderer })); ``` Use the game's real identifiers, e.g. `{ THREE, scene: this.scene, camera: this.camera, renderer: this.renderer }`. The queue form runs the call whenever the async `br.js` loads, before or after this line. `BonusRound.attach(...)` is the same call when you know the SDK has loaded. **React Three Fiber**: there's no `new WebGLRenderer`. Create a component and render it inside ``: ```jsx import { useEffect } from 'react'; import { useThree } from '@react-three/fiber'; import * as THREE from 'three'; export function BonusRoundAttach() { const { scene, camera, gl } = useThree(); useEffect(() => { (window.bonusround = window.bonusround || []).push((BR) => BR.attach({ THREE, scene, camera, renderer: gl })); }, [scene, camera, gl]); return null; } ``` **Native takeover (optional, better)**: if the game has a clear player object and a frame loop, pass a host adapter so the round runs in the game's own world with its own controls and physics. Only do this if you can implement all three required methods correctly: ```js const frameCallbacks = []; (window.bonusround = window.bonusround || []).push((BR) => BR.attach({ THREE, scene, camera, renderer, worldRoot: level, // the Group holding the level; hidden during the round host: { getPlayerPosition: () => player.position.clone(), // THREE.Vector3, feet, meters teleport: (v) => { player.position.copy(v); }, // also zero the player's velocity if it has one setBounds: (b) => { arenaBounds = b; }, // b = { center: Vector3, radius, obstacles? } or null; clamp movement to it onFrame: (cb) => frameCallbacks.push(cb), // then call each cb(dt) once per frame in the game loop }, })); ``` Then in the game loop add `for (const cb of frameCallbacks) cb(dt);` before rendering, and make the player's movement respect `arenaBounds` when it isn't null (stay within `radius` of `center` on the x/z plane). If any of that is unclear in this codebase, use the plain overlay-mode attach instead and mention native mode to the user as a follow-up. ## Step 4. Add `break()` at natural breaks Find the moments a player already expects a pause. Search function names, state machines and UI code for: - round or match end: `roundEnd`, `endRound`, `onRoundOver`, `matchOver`, a `phase`/`state` changing to `results`/`intermission` - level complete: `levelComplete`, `nextLevel`, `stageClear`, `onWin` - death and respawn: `gameOver`, `onDeath`, `die`, `respawn`, a "Game Over" / "Try again" screen - returning to a lobby, menu or pause screen between sessions At each one (start with the single most common break; two or three call sites is plenty), pause gameplay, await the break, then continue: ```js async function onRoundEnd() { // pause gameplay rules here if the game doesn't already (timers, enemies, scoring) await window.BonusRound?.break('intermission'); // ...existing code that starts the next round... } ``` - The function must be `async` (or use `.then`). Make it async only if its callers don't depend on a synchronous return value; otherwise use `window.BonusRound?.break('intermission').then(() => { … })`. - `break()` resolves when the round ends, or immediately (`{ filled: false, reason }`) when there's no ad, so the game flow is unchanged when unfilled. - Overlay mode covers the canvas, so pausing everything is fine. In native mode keep the render loop and player movement running and pause only the rules. - If the break is inside a per-frame update, make sure it runs once (guard with a flag), not every frame. - Don't call `break()` on page load, during active play or on every UI click. Optional, only where they fit the game: - **Rewarded**: if the game has a currency, extra lives or revives, offer a round for a reward. `onReward` runs only if the player finishes the round. ```js // SDK shows an entry button. It needs attach() to have finished, so do it in the queue callback (this replaces the plain attach line): (window.bonusround = window.bonusround || []).push(async (BR) => { await BR.attach({ THREE, scene, camera, renderer }); BR.rewarded({ label: 'Play for 50 coins', onReward: () => giveCoins(50) }); }); // Or from the game's own UI (a click, so attach has long finished): reviveButton.onclick = () => window.BonusRound?.rewarded({ button: false, onReward: () => revive() }); ``` A bare `window.BonusRound?.rewarded({ label, … })` at startup runs before `attach()` resolves and silently shows no button (it resolves to `{ filled: false, reason: 'not_attached' }`). - **Ambient placement hint**: if the level has an obvious open, visible spot (a plaza, a spawn area edge), suggest it in meters, world space, feet on the ground: ```js window.BonusRound?.placeAmbient({ position: [12, 0, -6], rotationY: Math.PI / 2 }); ``` - **Interval safety**: around boss fights or cutscenes, `window.BonusRound?.safe(false)`; afterwards `window.BonusRound?.safe(null)`. - **Pause on any round**: interval and portal rounds start without your code. If the game must pause for them, listen: `window.BonusRound?.on('start', pause).on('end', resume)` inside the queue callback (`(BR) => { BR.attach(…); BR.on('start', pause); BR.on('end', resume); }`). ## Step 5. Verify 1. Run the game the way the project does (`npm run dev`, `npx vite`, a static server…) and open it in a browser (use a headless browser if you have one). `localhost` is always allowed. 2. In the page, check the SDK: `window.BonusRound?.version` is a string, and `await BonusRound.debug()` shows `attached: true` and a `mode` (`overlay`, `native-local` or `native-net`). Check the console for `[bonusround]` warnings and for errors your change introduced. 3. Confirm the game itself still runs: it renders, the controls work, no new console errors. 4. Confirm Bonus Round saw it: poll `bonusround_integration_status { gameId, waitSeconds: 60 }` or `GET /api/games/:id` every few seconds until `integration.lastSeenAt` is set (the loader pings after the page's `load` event). `integration.origins` lists where it was seen from. 5. Confirm learning: `status` moves `pending → detected → learning → ready`. If it stays `detected` for more than a minute, call `bonusround_start_learning` / `POST /api/games/:id/learn`. If the game needs a login to play, tell the user to add a test login in the dashboard (Game → Settings); never ask for their password yourself. 6. Optional: trigger a break in the running game (play to the break, or call `await BonusRound.break('intermission')` in the console). In test mode it plays the Fizzpop round and resolves to `{ filled: true, completed: … }`. If `lastSeenAt` stays null: the tag isn't in the page that actually loads, `data-pub` is wrong, the page is on a domain the game doesn't list (only for non-localhost; add it under the game's domains), or a content security policy blocks `https://bonusround.io` (add it to `script-src` and `connect-src`). ## Step 6. Report back Tell the user, briefly: - **Changed**: each file and what you added (script tag, attach line, which break call sites, any rewarded/placement hooks). Mention overlay vs native mode. - **Verified**: SDK loaded (`version`, `mode`), `integration.lastSeenAt` time, current `status` (learning/ready), test mode on. - **What they'll see**: the Fizzpop Soda test Bonus Round at the next break, while test mode is on. - **Next steps for them**: watch the game being learned in the dashboard (https://bonusround.io/app/games/), add the ads.txt line `bonusround.io, pub_XXXXXXXX, DIRECT` to `https:///ads.txt`, and turn test mode off when they're ready to earn (only they should do that, or you on their explicit instruction). - Anything you weren't sure about (another renderer, an unclear break, native mode you skipped). ## Reference - SDK: `BonusRound.attach({ THREE, scene, camera, renderer, worldRoot?, host? }) → Promise<{ mode }>`, `await BonusRound.break('intermission' | 'test') → { filled, completed, reason?, score? }`, `BonusRound.rewarded({ onReward, label?, button? })`, `BonusRound.safe(true|false|null)`, `BonusRound.placeAmbient({ position, rotationY } | null)`, `BonusRound.on(type, cb)` (`attach`, `start`, `end`, `reward`, `ambient`, `event`), `BonusRound.config({ muted })`, `await BonusRound.debug()`. - REST: `GET /api/v1/whoami`, `GET|POST /api/games`, `GET|PATCH /api/games/:id`, `POST /api/games/:id/learn`, `GET /api/games/:id/stats?days=30`. Errors are `{ error }` with a 4xx/5xx status. Money is integer micros (1 USD = 1,000,000). - Docs: https://bonusround.io/docs/ · full text: https://bonusround.io/llms-full.txt --- # Quickstart Five minutes from a plain three.js game to a test Bonus Round playing in it. You add one script tag, one line after your renderer, and one `await` at a natural break. Developers earn 70% of net ad revenue. > **Using Claude Code, Cursor or Codex?** Tell your agent **“Integrate Bonus Round”** and point it at [bonusround.io/integrate.md](https://bonusround.io/integrate.md). It follows the same steps as this page and checks that the game is live. See [Integrate with AI agents](https://bonusround.io/docs/agents). ## What you get - **The Bonus Round**: at a natural break, a 15-second branded Bonus Round takes over the game. Players collect branded items, see a sponsored leaderboard, and return to your game. With a [host adapter](https://bonusround.io/docs/attach#host-adapter) they keep their own character, controls and physics. - **An ambient prop**: a branded portal, statue or billboard placed in an open spot in your world, paired with the round. - **Game-native creative**: our agents play your game to learn its controls, scale and art style. Each brand's round is generated for your game specifically. ## Five steps - ### Create an account and add your game Sign up, open [Games](https://bonusround.io/app/games/) and add the URL where your game runs. You get a public publisher id like `pub_7f3a9c1e2b4d6f80`. You can also do this from the terminal with `npx bonusround init`, or from your AI agent with the MCP server. - ### Add the script tag Put it in the `` of the HTML page that runs your game. It's `async`, small, and loads the rest of the SDK lazily. ```html ``` - ### Attach after your renderer, scene and camera exist ```js import * as THREE from 'three'; const renderer = new THREE.WebGLRenderer({ antialias: true }); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 500); // Bonus Round: works whether br.js has loaded yet or not (window.bonusround = window.bonusround || []).push((BR) => BR.attach({ THREE, scene, camera, renderer })); ``` That queue line is the safe form of `BonusRound.attach({ THREE, scene, camera, renderer })`. Because the script tag is async, your game code may run before it has loaded; the queue runs your call as soon as it does. Pass the same `THREE` your game imports, so the SDK uses your three.js version. - ### Call `break()` at natural breaks Round over, level complete, death and respawn, back to the lobby: wherever a player would accept a short pause. ```js async function onRoundEnd() { pauseGameplay(); // stop timers, enemies, scoring await window.BonusRound?.break('intermission'); // plays a Bonus Round, or resolves at once if there's no ad startNextRound(); } ``` The `?.` means an ad blocker can never break your game: if the SDK didn't load, the line does nothing. - ### Load your game and watch it go live Open the game in a browser. The SDK pings Bonus Round on load and your game flips from `pending` to `detected` in the dashboard. Our play agent then starts learning the game (`learning`, then `ready`). New games start in **test mode**, so your next `break()` plays the **Fizzpop Soda** test Bonus Round right away. Test traffic is never billed and never paid. [More about test mode](https://bonusround.io/docs/test-mode). ## Check it worked In the browser console on your game's page: ```js BonusRound.version // "1.0.0": the loader is on the page await BonusRound.debug() // the SDK's state: mode, attached, testMode, last ad requests and events await BonusRound.break('intermission') // in test mode: plays the Fizzpop test round right now ``` Or from a terminal: `npx bonusround status --wait 60`. Or ask your agent to call `bonusround_integration_status`. ## Next - [Install for your stack](https://bonusround.io/docs/install): Script tag, Vite, webpack, React Three Fiber, vanilla. - [Native takeover](https://bonusround.io/docs/attach): Pass a host adapter so the round runs in your world, with your physics. - [Formats & triggers](https://bonusround.io/docs/formats): Intermission, interval, rewarded, ambient props and portals. - [Go live](https://bonusround.io/docs/going-live): Turn off test mode, add ads.txt, get paid. --- # Install There is one SDK, loaded from one script tag. There's no npm package to install or keep updated: bundlers just load the same tag. What changes per stack is where you find `scene`, `camera` and `renderer`. ## Script tag Every install starts here. Use the tag from your game's page in the dashboard; it already has your `data-pub`. ```html ``` - **Where:** the `` of the page that runs the game. If the game runs in an iframe (itch.io, Poki, CrazyGames, your own embed), it goes in the page *inside* the iframe. - **What it does:** defines `window.BonusRound`, pings Bonus Round once the page has loaded (that's how your game gets detected), and imports the rest of the SDK only when it's needed. - **Optional config:** set `window.bonusroundConfig` before the tag. `{ muted: true }` starts the round's music and sound effects muted; `{ pub }` works in place of `data-pub`; `{ consent: false }` (or `data-storage="none"` on the tag) keeps the anonymous player id in memory only. See [Privacy](https://bonusround.io/docs/events#privacy). ### Calling the SDK before it has loaded The tag is `async`, so your game code may run first. Push a function onto the `bonusround` queue and it runs as soon as the SDK is there (or right away if it already is): ```js (window.bonusround = window.bonusround || []).push((BR) => BR.attach({ THREE, scene, camera, renderer })); ``` Use the queue for `attach()`. For `break()`, which runs later in the game, `await window.BonusRound?.break('intermission')` is enough. ## Vanilla three.js (import map or CDN) A plain HTML page with an import map is the simplest setup. Add the tag to `index.html` and the attach line to your main module, after the renderer, scene and camera are created. ```html ``` ```js // main.js import * as THREE from 'three'; const renderer = new THREE.WebGLRenderer({ antialias: true }); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 500); (window.bonusround = window.bonusround || []).push((BR) => BR.attach({ THREE, scene, camera, renderer })); ``` Using the old global build (``)? Pass the global: `BR.attach({ THREE: window.THREE, scene, camera, renderer })`. ## npm and bundlers (Vite, webpack, Parcel, Next.js) Keep using `three` from npm as you do now. Load the SDK with the script tag in your HTML template, and call it from your bundled code. #### Vite Vite's HTML entry is `index.html` in the project root. Add the tag to its ``, then attach in the module that creates the renderer (often `src/main.js` or `src/main.ts`). ```html ``` ```ts // src/main.ts import * as THREE from 'three'; // ...create renderer, scene, camera... (window.bonusround = window.bonusround || []).push((BR) => BR.attach({ THREE, scene, camera, renderer })); ``` #### webpack With `html-webpack-plugin`, add the tag to your template (often `public/index.html` or `src/index.html`). Create React App uses `public/index.html`. ```html ``` ```js import * as THREE from 'three'; // ...create renderer, scene, camera... (window.bonusround = window.bonusround || []).push((BR) => BR.attach({ THREE, scene, camera, renderer })); ``` #### No HTML template If your build owns the HTML (some Next.js or game-engine setups), inject the tag from code. This is the same tag, created at runtime: ```js import * as THREE from 'three'; const s = document.createElement('script'); s.src = 'https://bonusround.io/v1/br.js'; s.async = true; s.dataset.pub = 'pub_XXXXXXXX'; document.head.append(s); // The queue works even though the script was just added: (window.bonusround = window.bonusround || []).push((BR) => BR.attach({ THREE, scene, camera, renderer })); ``` ### TypeScript Add a small declaration so `window.BonusRound` and the queue type-check: ```ts // src/bonusround.d.ts type BonusRoundResult = { filled: boolean; completed: boolean; reason?: string; score?: number }; interface BonusRoundSDK { version: string; attach(opts: { THREE: unknown; scene: unknown; camera: unknown; renderer: unknown; worldRoot?: unknown; host?: unknown }): Promise<{ mode: 'overlay' | 'native-local' | 'native-net'; playerId?: string; already?: boolean }>; break(trigger?: 'intermission' | 'test'): Promise; rewarded(opts: { onReward: (r: BonusRoundResult) => void; label?: string; button?: boolean }): Promise<(BonusRoundResult & { rewarded?: boolean }) | { shown: true; hide(): void; start(): Promise }>; safe(v?: boolean | null): BonusRoundSDK; placeAmbient(hint: { position: [number, number, number]; rotationY?: number } | null): BonusRoundSDK; on(type: string, cb: (data: any) => void): BonusRoundSDK; off(type: string, cb: (data: any) => void): BonusRoundSDK; config(o: { muted?: boolean; test?: boolean; storage?: false | null }): BonusRoundSDK; consent(v: boolean): BonusRoundSDK; debug(): Promise; ready(): Promise; } declare global { interface Window { BonusRound?: BonusRoundSDK; bonusround?: Array<(br: BonusRoundSDK) => void> | { push: (fn: (br: BonusRoundSDK) => void) => number }; bonusroundConfig?: { pub?: string; muted?: boolean; consent?: boolean; storage?: false }; } } export {}; ``` ## React Three Fiber In R3F you never call `new WebGLRenderer()` yourself. `useThree()` gives you all three: `scene`, `camera` and `gl` (the `WebGLRenderer`). Add this component *inside* your ``, and the script tag to your `index.html` as above. ```jsx // BonusRoundAttach.jsx import { useEffect } from 'react'; import { useThree } from '@react-three/fiber'; import * as THREE from 'three'; export function BonusRoundAttach() { const { scene, camera, gl } = useThree(); useEffect(() => { (window.bonusround = window.bonusround || []).push((BR) => BR.attach({ THREE, scene, camera, renderer: gl })); }, [scene, camera, gl]); return null; } ``` ```jsx // App.jsx ``` Call `break()` from your game state (a Zustand store, a reducer, a `useEffect` on `phase === 'roundOver'`): it's a plain promise and doesn't need to be inside the Canvas. > Switching cameras at runtime (a cutscene camera, say)? The SDK keeps the camera you attached. Attach the camera players actually see during play. ## Let the CLI do it `npx bonusround init` finds your three.js setup (package.json, import map or CDN), finds `new THREE.WebGLRenderer(`, your scene and camera, and shows you a diff that adds the script tag and the attach line. Nothing is written until you say yes. It also marks a likely natural break with a `TODO(bonusround)` comment. ```bash npx bonusround login # paste an API key from /app/developers npx bonusround init # shows the diff, asks before writing npx bonusround status --wait 60 ``` ## Requirements - three.js with `WebGLRenderer`. The SDK uses the `THREE` you pass, so it renders with your version and reports it to the dashboard. - Models in Bonus Rounds are plain glTF/GLB (no Draco, meshopt or KTX2 decoders needed on your side). - A page served over `https://` in production. `localhost` always works for development. --- # Integrate with AI agents Bonus Round is built so your coding agent can do the whole integration: register the game, add the SDK, find the natural breaks in your game loop, load the game to verify it's live, and report back. You review the diff. ## The fastest way Paste this to Claude Code, Cursor, Codex or any agent that can edit your project and fetch a URL: ```text Integrate Bonus Round into this three.js game. Follow https://bonusround.io/integrate.md exactly. My Bonus Round publisher id is pub_XXXXXXXX. Verify the SDK is live, then tell me what you changed. ``` Don't have a publisher id yet? Leave that line out and connect the MCP server below: the agent will create the game for you. Your exact prompt, with your id filled in, is on the [Developers page](https://bonusround.io/app/developers/). ## The MCP server With the Bonus Round MCP server connected, your agent can manage games directly instead of asking you to copy ids from the dashboard. Create an API key on the [Developers page](https://bonusround.io/app/developers/) first. #### Claude Code Hosted server, nothing to install: ```bash claude mcp add --transport http bonusround https://bonusround.io/mcp --header "Authorization: Bearer br_sk_…" ``` Or run it locally over stdio: ```bash claude mcp add --transport stdio --env BONUSROUND_API_KEY=br_sk_… bonusround -- npx -y @bonusround/mcp ``` Then start a session in your game's folder and run the prompt: `/mcp__bonusround__integrate-bonus-round`, or just say “Integrate Bonus Round”. #### Cursor Add to `~/.cursor/mcp.json` (all projects) or `.cursor/mcp.json` (this project; don't commit the key): ```json { "mcpServers": { "bonusround": { "url": "https://bonusround.io/mcp", "headers": { "Authorization": "Bearer br_sk_…" } } } } ``` Or the local stdio server: ```json { "mcpServers": { "bonusround": { "command": "npx", "args": ["-y", "@bonusround/mcp"], "env": { "BONUSROUND_API_KEY": "br_sk_…" } } } } ``` #### Codex From the terminal: ```bash codex mcp add bonusround --env BONUSROUND_API_KEY=br_sk_… -- npx -y @bonusround/mcp ``` Or in `~/.codex/config.toml`: ```toml [mcp_servers.bonusround] command = "npx" args = ["-y", "@bonusround/mcp"] env = { BONUSROUND_API_KEY = "br_sk_…" } ``` #### Other clients Any MCP client that speaks streamable HTTP can use `https://bonusround.io/mcp` with the header `Authorization: Bearer br_sk_…`. Stdio clients run `npx -y @bonusround/mcp` with `BONUSROUND_API_KEY` in the environment. Point `BONUSROUND_URL` somewhere else to use a different server. If you've run `npx bonusround login`, the stdio server reads that saved key too. ### Tools | Tool | What it does | | --- | --- | | `bonusround_integration_guide` | Returns [integrate.md](https://bonusround.io/integrate.md), the step-by-step recipe. Agents call this first. | | `bonusround_list_games` | Your games with their `pub_` ids, status, test mode and when the SDK was last seen. | | `bonusround_create_game` | Registers a game by URL; returns its pub id and snippet. | | `bonusround_get_snippet` | The exact script tag, attach line, break/rewarded examples and ads.txt line. | | `bonusround_integration_status` | Has the SDK been seen (`integration.lastSeenAt`)? Is the agent learning? With `waitSeconds` it polls until the SDK shows up. | | `bonusround_start_learning` | (Re)starts the play agent that learns your game. | | `bonusround_get_stats` | Requests, fill rate, impressions, engagements, clicks, earnings, eCPM. | | `bonusround_update_settings` | Name, test mode, rating, categories, formats, triggers, interval, floors, blocked categories, frequency cap. | There's also a prompt, `integrate-bonus-round`, that loads the recipe into the conversation. The server's instructions tell agents never to turn off test mode or change floors unless you ask. ## The recipe: integrate.md [/integrate.md](https://bonusround.io/integrate.md) is written for AI agents: how to detect the three.js setup, where to put the tag, how to find the renderer, scene and camera, how to recognise natural breaks in a game loop, and how to verify the install by loading the game and checking `GET /api/games/:id` until `integration.lastSeenAt` is set. It ends with the report the agent should give you. Also for agents: [/llms.txt](https://bonusround.io/llms.txt) (an index in the [llms.txt](https://llmstxt.org) format) and [/llms-full.txt](https://bonusround.io/llms-full.txt) (all of these docs in one plain-text file). ## Claude Code skill Prefer a skill to an MCP server? Install the `integrate-bonus-round` skill and Claude Code will pick it up whenever you ask to add Bonus Round or ads to a three.js game: ```bash mkdir -p ~/.claude/skills/integrate-bonus-round curl -fsSL https://bonusround.io/docs/skill/SKILL.md -o ~/.claude/skills/integrate-bonus-round/SKILL.md ``` ## The CLI Agents that prefer shell commands (and humans) can use the CLI. `init` shows a diff and asks before writing; `--yes` skips the question, which is what an agent should use after showing you the diff with `--dry-run`. ```bash npx bonusround login --key br_sk_… # saved to ~/.config/bonusround/credentials (0600) npx bonusround init --dry-run # show the changes npx bonusround init --yes # write them npx bonusround status --wait 60 # wait for the first SDK ping ``` ## Keys and safety - An API key can do anything your account can, including turning test mode off. Create one key per agent or machine, name it (“Claude Code laptop”), and revoke it on the Developers page when you're done. - Never put a `br_sk_` key in client-side code. The game only ever needs its public `pub_` id. - Keys are stored hashed. We can't show a key again after it's created. --- # attach() & the host adapter `attach()` hands the SDK your three.js objects. With just those, Bonus Rounds play in **overlay mode**. Add a `worldRoot` and a `host` adapter and they play in **native mode**: inside your own scene, with your player, your controls and your physics. ## Signature ```js BonusRound.attach({ THREE, scene, camera, renderer }); // overlay mode BonusRound.attach({ THREE, scene, camera, renderer, worldRoot, host }); // native takeover // → Promise<{ mode: 'overlay' | 'native-local' | 'native-net', playerId }> ``` | Option | Required | What it is | | --- | --- | --- | | `THREE` | yes | The three.js namespace your game uses (`import * as THREE from 'three'`). The SDK builds everything with it, so there's never a second copy of three.js. | | `scene` | yes | Your main `THREE.Scene`. The ambient prop is added here (or to `worldRoot` when you pass one). | | `camera` | yes | The camera players see the game through. Used for viewability: a prop only counts as seen when it's on screen. | | `renderer` | yes | Your `THREE.WebGLRenderer` (`gl` in React Three Fiber). | | `worldRoot` | native, recommended | The `Group` that holds your level. It's hidden while the Bonus Round arena is shown, then restored. The ambient prop is added to it, so it moves with your level. | | `host` | native | The host adapter below: how the SDK moves your player and hooks your frame loop. Native mode turns on when `host` has `getPlayerPosition`, `teleport` and `setBounds`. | Call it once, after all four objects exist. Later calls are ignored and resolve to `{ mode, already: true }`, so a hot reload or a re-mounted React component can't attach twice. The resolved `mode` tells you which takeover you got: `overlay`, `native-local` (host adapter, the round runs in the page) or `native-net` (host adapter with `net`, your server coordinates the round). ## Overlay mode vs native mode | | Overlay mode | Native mode | | --- | --- | --- | | You pass | `THREE, scene, camera, renderer` | …plus `worldRoot, host` | | Where the round runs | A full-screen layer over your canvas, drawn by the SDK | Inside your scene, in a pocket arena far from your level | | The player's character | A stand-in character in the brand's colours, at your game's scale, with your camera style (first or third person) | Your actual player object, moved by your own code | | Controls and physics | WASD/arrows, jump and sprint, at the walking speed measured in your game (or learned by our play agent) | Yours, unchanged | | Integration work | One line | One line plus a ~10-line adapter | Start with overlay mode: it's one line and works in any three.js game. Move to native mode when you want players to keep their own character, animations, controls and collision in the round, which is what makes a Bonus Round feel like part of your game. ## The host adapter (native takeover) During a native round the SDK builds a branded pocket arena at `[0, 0, 2000]`, far away from your level. It hides `worldRoot`, moves your player into the arena, keeps them inside it, runs the 15-second round, shows a sponsored leaderboard, and moves them back. Your camera and player code keep working unchanged because nothing about them changes except the position. ```js const frameCallbacks = []; BonusRound.attach({ THREE, scene, camera, renderer, worldRoot: level, // your level's Group host: { getPlayerPosition: () => player.position.clone(), // THREE.Vector3, feet position getPlayerId: () => myPlayerId, // optional: needed for multiplayer teleport: (v) => { player.position.copy(v); player.velocity?.set(0, 0, 0); }, setBounds: (b) => { arenaBounds = b; }, // { center, radius, obstacles? } or null onFrame: (cb) => frameCallbacks.push(cb), // call cb(dt) every frame, in seconds net: { send: socket.send, on: socket.on }, // optional: multiplayer only }, }); function animate(now) { const dt = clock.getDelta(); updatePlayer(dt); // your controls + physics, clamped to arenaBounds for (const cb of frameCallbacks) cb(dt); renderer.render(scene, camera); requestAnimationFrame(animate); } ``` | Host method | Contract | | --- | --- | | `getPlayerPosition()` | Returns the local player's position as a `THREE.Vector3` (the SDK reads it every frame to detect pickups). Units are meters; your player is assumed to be about 1.8 m tall. The arena is scaled to what our agent measured in your game. | | `teleport(vec3)` | Move the player there instantly. Reset velocity so they don't fly off. Called when the round starts and again when it ends. | | `setBounds(b)` | While in the arena, `b = { center: Vector3, radius, obstacles?: [{ x, z, r }] }`: keep the player within `radius` of `center` on the ground plane, and outside each obstacle circle if you can (the hero's pedestal). `null` means no limit; restore your normal limits. | | `onFrame(cb)` | Optional but recommended. Register `cb(dt)` to run once per frame, before you render; `dt` is in seconds. Without it the SDK runs its own `requestAnimationFrame` loop. | | `getPlayerId()` | Optional. The local player's id, so the SDK knows which pickups and leaderboard rows are “you”. | | `net` | Optional, multiplayer only: `{ send(msg), on(type, cb) }` over your game's existing socket. Relay Bonus Round messages through your server so every player sees the same round and the same item layout. Single-player games leave it out and the SDK runs the round locally. | > **Keep your loop running** During a native round your render loop, input and movement must keep running: the player is playing. Pause your *rules* instead: round timers, enemies, damage, scoring. In overlay mode your game is covered, so pausing everything is fine. ## The rest of the SDK | Call | What it does | | --- | --- | | `await BonusRound.break('intermission')` | Plays a Bonus Round at a natural break. Resolves to `{ filled, completed, reason?, score? }` when the round ends, or immediately when there is no ad, the player is frequency-capped, or the format is off. [Details](https://bonusround.io/docs/formats#intermission). | | `await BonusRound.break('test')` | Always plays the Fizzpop test round, even on a live game. Never billed. | | `BonusRound.rewarded({ onReward, label, button })` | Player-chosen round for a reward you define. `onReward` runs only if the player completes the round. [Details](https://bonusround.io/docs/formats#rewarded). | | `BonusRound.safe(true \| false \| null)` | Tell the SDK whether an *interval* offer may appear right now. `null` (default) means automatic: after 1.5 s without input. | | `BonusRound.placeAmbient(hint)` | Suggest where the ambient prop should go. [Details](https://bonusround.io/docs/formats#placement-hints). | | `BonusRound.on(type, cb)` / `off` | Listen to SDK events: `attach`, `start`, `end`, `reward`, `ambient`, `event`. [Details](https://bonusround.io/docs/events#sdk-events). | | `BonusRound.config({ muted })` | Change settings at runtime, e.g. follow your game's mute button. | | `BonusRound.consent(true \| false)` | From your consent tool: `false` keeps the anonymous player id in memory only, nothing in `localStorage`. [Privacy](https://bonusround.io/docs/events#privacy). | | `await BonusRound.ready()` | Resolves once the SDK core has loaded. | | `await BonusRound.debug()` | A JSON snapshot for support and for AI agents verifying an install: `mode`, `attached`, `testMode`, `settings`, the current `run`, `ambient`, the three.js revision, and the last ad requests and events. | | `BonusRound.version`, `.pub` | Loader version and the publisher id it read from the tag. | ## Safety guarantees - Every SDK entry point is wrapped: an error inside the SDK is logged with a `[bonusround]` prefix and never thrown into your game. - Every asset has a procedural fallback, so a round always renders even if a model or texture fails to load. - If the SDK can't load at all (ad blocker, offline), `break()` resolves at once with `{ filled: false, reason: 'sdk_unavailable' }`, and `window.BonusRound?.…` calls do nothing. --- # Formats & triggers There are two formats. The **takeover** is the Bonus Round itself: 15 seconds of branded play. **Ambient** is a branded prop that lives in your world. A takeover starts from one of three triggers, and each trigger trades reach for engagement differently. | Format · trigger | Who starts it | You add | Pays | | --- | --- | --- | --- | | `takeover` · `intermission` | You, at a natural break | `await BonusRound.break('intermission')` | The best-quality slot | | `takeover` · `interval` | The SDK, every N minutes at a calm moment, as an offer | Nothing (optionally `safe()`) | Most volume, cheapest per view | | `takeover` · `rewarded` | The player, for a reward you define | `BonusRound.rewarded({ onReward })` | Highest engagement and eCPM | | `ambient` | Always on once attached | Nothing (optionally `placeAmbient()`) | Viewable impressions | Turn any of these on or off per game in the dashboard, or with `PATCH /api/games/:id` under `settings.formats`. All are on by default. ## Anatomy of a Bonus Round - **Brand card**: a short transition presents the sponsor. - **Intro** (2 s): the round's instruction appears (“Grab 14 cans!”) in a branded pocket arena with the brand's hero (mascot or giant product) and banners. - **Play** (15 s): players run around with their own character and collect, pass through or smash branded items. Touching an item scores it. - **Sponsored leaderboard** (about 3.5 s): scores, the brand's message and its call to action. - Back to your game, exactly where the player was. ## Intermission: `break()` Call `break()` wherever your game already pauses: a round ends, a level is complete, the player dies and respawns, the match returns to the lobby. It returns a promise that resolves when the round is over, so your code reads top to bottom. ```js async function onLevelComplete() { game.pause(); // timers, enemies, input you don't want during the round const r = await window.BonusRound?.break('intermission'); // { filled, completed, reason?, score? } if (r?.filled) analytics.track('bonus_round', { completed: r.completed }); game.loadNextLevel(); } ``` - When there's no ad (no eligible brand, below your floor, the player hit the hourly frequency cap, takeovers turned off), it resolves right away with `filled: false` and a `reason`. Your break just continues as before. - Don't call it in the middle of play. Breaks that interrupt players hurt engagement, and engagement is what brands pay most for. - Call it as often as you have natural breaks. The ad server applies the frequency cap (`settings.frequencyCap.perHour`, default 4 per player per hour). ## Interval With the `interval` trigger on, the SDK offers a Bonus Round at most every `intervalSec` seconds (default 300, minimum 60), counting from the last round or offer. It waits for a calm moment, then shows a small offer card in the corner with **Play** and **Not now**. Nothing takes over unless the player chooses to play. ```js // Tell the SDK when an offer would be welcome or not: BonusRound.safe(false); // boss fight: no offers BonusRound.safe(true); // in a menu or a calm stretch: offers are welcome BonusRound.safe(null); // back to automatic: "calm" = 1.5 s without input ``` ```bash curl -X PATCH https://bonusround.io/api/games/gm_… \ -H "Authorization: Bearer $BONUSROUND_API_KEY" -H "content-type: application/json" \ -d '{ "settings": { "formats": { "takeover": { "intervalSec": 600 } } } }' ``` ## Rewarded: `rewarded({ onReward })` The player opts in for something you give them: coins, a revive, a skin. Because they chose to play, rewarded rounds have the highest engagement and pay the most. ```js // Show the SDK's own entry button ("Bonus Round · 15 s") in the corner, once attach() has finished: (window.bonusround = window.bonusround || []).push(async (BR) => { await BR.attach({ THREE, scene, camera, renderer }); BR.rewarded({ label: 'Play for 50 coins', onReward: () => giveCoins(50) }); }); // Or start it from your own UI, e.g. on a "Watch for a revive" button: reviveButton.onclick = () => window.BonusRound?.rewarded({ button: false, onReward: () => revivePlayer() }); ``` - `onReward` is your code, and it runs only when the player finishes the round. Grant the reward there, and only there. - The entry button only appears once `attach()` has resolved. Called before that (for example on the line after a queued attach), `rewarded()` resolves to `{ filled: false, reason: 'not_attached' }` and shows nothing, so await `attach()` first as above. Its `attach()` is the same one-time call as your normal attach line: a second call just resolves to `{ mode, already: true }`. - `label` is the text on the entry button (default “Play a Bonus Round for a reward”). The call returns `{ shown, hide(), start() }` so you can remove the button later. With `button: false` the round starts immediately and resolves to `{ filled, completed, rewarded, … }`, so call it from a click. - If no round is available, the player sees a short “No Bonus Round available right now” toast and nothing is granted. ## Ambient props Once attached, the SDK can place one branded prop in your world: a **portal arch**, a **statue** of the brand's hero, or a **billboard**. It's paired with the brand's Bonus Round, built at the scale our play agent measured in your game, and placed at an open spot it found while playing. It's billed by viewable impressions under the [IAB/MRC in-game rule](https://bonusround.io/docs/events#viewability) (half its pixels in view, at least 1.5% of the screen, at most 55° off-axis, for a continuous second), so it only counts while players can really see it. ### Placement hints You know your level better than our agent does. Suggest a spot with `placeAmbient()`: open ground, visible from where players spend time, not blocking a path. ```js BonusRound.placeAmbient({ position: [12, 0, -6], rotationY: Math.PI / 2 }); // meters, in world space, feet on the ground BonusRound.placeAmbient(null); // forget the hint, use the agent's spot ``` Without a hint the SDK uses the spot from the ad package, then the spot our agent picked; if there's neither, it waits for your hint. Calling `placeAmbient()` again moves the prop. The prop is added to your `worldRoot` if you passed one (so it hides during rounds and moves with the level), otherwise to the scene. Turn ambient props off per game with `settings.formats.ambient.enabled = false`. ### Portals A portal arch is an ambient prop players can walk into. Walking into it counts as an engagement. If you have registered a reward with `rewarded({ onReward })`, it also starts a rewarded Bonus Round, and finishing that round runs your `onReward`. Portals need native mode, because the SDK has to know where the player is (`host.getPlayerPosition`). --- # Test mode & the Fizzpop test ad Every new game starts in test mode. In test mode every ad request is filled with **Fizzpop Soda**, a fictional brand we made so you can see a real Bonus Round in your own game straight away, before any brand has bought a slot. ## What test mode does - **Every request fills** with the Fizzpop house ad: `break()`, interval offers, rewarded rounds and the ambient prop. - **Nothing is billed and nothing is paid.** Test traffic is recorded, but counted separately from real traffic: it shows on your game's page and under `test` in `GET /api/games/:id/stats`, never in the totals, earnings or eCPM. - **No brand ads.** A game in test mode is only ever served the house test ad. - Your game still gets detected and learned. Test mode doesn't hold back the play agent. ## Meet Fizzpop Soda Fizzpop is a full ad package, the same kind of package a real brand gets: a hero mascot, a collectible soda can, a tiled arena floor, a banner, a 20-second music bed and three sound effects. Playing it is the best way to check that a round fits your game: the scale of the arena, the camera, your controls, the transition in and out. > Fizzpop is fictional on purpose. It isn't a real product, and its call to action (“Pop the fun”) points at `fizzpop.example`, a reserved domain that goes nowhere. Show it on stream, in screenshots and to your playtesters. ## Force a test round on any game Even after you go live you can play the test ad on demand. The `test` trigger is always filled with Fizzpop and never billed: ```js await BonusRound.break('test'); ``` Handy for a debug menu, QA builds, or checking an update didn't break the round. ## Turning test mode off When you're happy with how rounds look, switch the game to live in the dashboard ([Games](https://bonusround.io/app/games/) → your game → Settings), or via the API: ```bash curl -X PATCH https://bonusround.io/api/games/gm_… \ -H "Authorization: Bearer $BONUSROUND_API_KEY" -H "content-type: application/json" \ -d '{ "testMode": false }' ``` Read [Going live](https://bonusround.io/docs/going-live) first: there's an ads.txt line to add, and brands need a little time to generate rounds for your game. ## Troubleshooting: “I don't see the test round” - **Is the SDK on the page?** In the console, `BonusRound.version` should print a version. If it's `undefined`, the script tag isn't in the HTML that loads (or an ad blocker removed it; try a clean profile). - **Was `attach()` called?** `await BonusRound.debug()` shows whether the SDK is attached and in which mode. - **Is `break()` reached?** Put a `console.log` just before it, or call `BonusRound.break('test')` from the console. - **What did it return?** `reason` tells you why a round didn't fill. From the SDK: `not_attached` (`attach()` hasn't finished yet), `busy`, `takeover_disabled`, `trigger_disabled`, `frequency_cap`, `ad_server_unreachable`, `load_timeout`, `sdk_unavailable`. From the ad server: `game_paused`, `format_disabled`, `origin_not_allowed`, `game_frequency_cap`, `no_eligible_campaigns`, `below_floor`. The [FAQ](https://bonusround.io/docs/faq#no-fill) explains each one. - **Was the game detected?** The dashboard shows the last SDK ping. `npx bonusround status` shows the same. --- # Going live & ads.txt Going live means real brands can buy Bonus Rounds in your game and you start earning 70% of net ad revenue. It takes a few minutes of setup. ## Checklist - ### Your game is detected and learned The game's status should be `ready`: the SDK has pinged from your domain and our play agent has finished learning the game. If the game needs an account to play, give the agent a test login (Game → Settings → Agent login). It's stored encrypted and typed by the agent through a secret tool, so the model never sees it. - ### Your domains are registered Ads are only served to pages on your game's domains. The domain of the URL you registered is included automatically, along with its subdomains (`play.mygame.com` counts for `mygame.com`). Add other hosts (an itch.io page, the CDN host that serves your game iframe) in Settings, or via the API with `PATCH /api/games/:id { "domains": ["mygame.com", "mygame.itch.io"] }`. `localhost` always works for development. - ### Say whether your game is made for kids Game → Settings → **Is this game made for kids?** Answer **Yes**, **Mixed audience** or **No**. You can't go live until you've answered. See [Games for kids](#kids) below for what each answer changes. - ### Add the ads.txt lines [ads.txt](https://iabtechlab.com/ads-txt/) (IAB Tech Lab, version 1.1) is the industry standard that tells brands which ad networks are allowed to sell your inventory. Add these lines to `https://yourdomain.com/ads.txt` (create the file if you don't have one; it lives at the root of your domain): ```text # Bonus Round (bonusround.io) bonusround.io, pub_XXXXXXXX, DIRECT OWNERDOMAIN=yourdomain.com ``` `DIRECT` means you sell through your own Bonus Round account. - `OWNERDOMAIN` is your own business domain. It appears once per file: if your file already has an `OWNERDOMAIN` line, keep yours and skip ours. - Don't add a `MANAGERDOMAIN` line for us. We're the ad system, not your manager; `MANAGERDOMAIN` is only for a company that runs your whole ad stack for you. Your exact lines are on your game's Integration tab, and at `GET https://bonusround.io/v1/ads.txt?pub=pub_XXXXXXXX` (the record line alone is `game.snippet.adsTxt` in the API). We fetch your `/ads.txt` about once a day (user agent `BonusRoundAdsTxtBot/1.0`) and show on the Integration tab whether we found the line; **Check now** re-checks on demand. Games on shared hosts such as itch.io or GitHub Pages can't host the file at the domain root; that's fine. - ### Turn test mode off Dashboard → your game → Settings → Test mode off. Or `PATCH /api/games/:id { "testMode": false }`. From now on requests go to the auction instead of the Fizzpop test ad. `break('test')` still plays Fizzpop whenever you want. - ### Check your settings Formats and triggers, the hourly frequency cap, floors and blocked categories. The defaults are sensible: every format on, interval every 5 minutes, 4 takeovers per player per hour, floors of $2.00 CPM (takeover) and $0.50 CPM (ambient). ## sellers.json We publish [`https://bonusround.io/sellers.json`](https://bonusround.io/sellers.json) (IAB Tech Lab sellers.json 1.0), the public list of everyone we sell inventory for, so brands can check your ads.txt line against it. Your seller ID is your publisher ID (`pub_…`) and your seller type is `PUBLISHER`. Games appear once they're live. Individuals are listed as **confidential** by default: just the seller ID, no name or domain. Businesses can choose to be named (legal name and business domain, which should match your `OWNERDOMAIN`). Change it on your game's Integration tab under **sellers.json listing**. ## Games for kids COPPA (US), GDPR-K (EU) and the UK Children's Code protect players under 13. Bonus Round serves those games **contextual ads only**, and so does any game where kids may be playing: | Your game | Ads | | --- | --- | | Made for kids: **Yes** | Contextual only, and restricted brands (alcohol, gambling, energy drinks and the rest) are blocked | | **Mixed audience** (kids are one of your audiences) | Contextual only, restricted brands blocked | | Not answered yet | Contextual only | | **No**, but rated Everyone / Everyone 10+, not rated yet, or our agent sees kid-directed signals | Contextual only | | **No**, and rated Teen or above | Standard: an anonymous player id for frequency caps, where the browser allows storage | Contextual only means: no player IDs, cookies or local storage; no behavioural targeting or cross-game profiles; frequency caps per play session only (the "support for internal operations" COPPA allows); IP addresses never stored. Details in [Events & measurement → Privacy](https://bonusround.io/docs/events#privacy). Set it in Game → Settings, or `PATCH /api/games/:id { "settings": { "madeForKids": "yes" | "mixed" | "no" } }` (the older `directedToChildren: true` still means Yes). ## Portals (Poki, CrazyGames, GameDistribution, Y8) These portals don't allow third-party ads in the games they host. If your game also runs on one of them, you don't need a separate build: the SDK notices (from the page's hostname, the embedding page's origin or the referrer) and switches itself off there. That's **portal mode**: - No ad requests, no SDK code beyond the small loader, nothing drawn. `attach()` resolves `{ mode: 'off', portal }` and every `break()` or `rewarded()` resolves `{ filled: false, reason: 'portal' }`, so your game carries on. - `BonusRound.debug().warnings` says which portal was detected and how. - The loader sends one ping marked with the portal, so your game's dashboard can show a notice. It doesn't count as an integration and never starts our agent. Covered: Poki (including its game CDN), CrazyGames, GameDistribution and Y8, and their subdomains. The list lives in one place in the SDK and we keep it up to date; tell us if you see a portal we missed. ## When do real ads start? Bonus Rounds are generated per game. When your game is `ready`, it becomes a match for brands whose audience, safety settings and style fit it. Each brand that picks your game has its agents generate a round specifically for it and reviews it in your game before it can run. Expect fill to start low and rise as brands find your game. Until then, `break()` resolves immediately with `filled: false` and `reason: 'no_eligible_campaigns'`, and your game carries on as usual. ## Floors and the auction - Every request runs a second-price auction among brands with an approved round for your game. The winner pays just enough to beat the runner-up, never less than your floor. - Brands bid in one of three ways: per 1,000 viewable impressions (vCPM), per engaged player (CPE) or per click (CPC). Everything is converted to an effective CPM using predicted rates for your game, so they compete fairly. - Your floor (`settings.floorCpmMicros`) is in micros per 1,000 impressions: `2000000` = $2.00 CPM. A higher floor means higher prices and lower fill. --- # Events & measurement The SDK measures everything for you; you never send an event by hand. This page explains what each event means, which ones brands pay for, and how to listen to them in your own code. ## The events Every filled ad request gets a signed token. The SDK reports events against that token to `POST /v1/event`, and each event type counts at most **once per ad**, so a player who collects ten cans is one engagement, not ten. | Event | Bonus Round (takeover) | Ambient prop | | --- | --- | --- | | `request` | The SDK asked for an ad. Counted whether or not it filled; fill rate is fills ÷ requests. | | | `impression` | The round started on screen. | The prop was first rendered on screen (any visible part, not hidden behind something). | | `viewable` | The round (which fills the whole screen) was showing in a visible tab for **2 continuous seconds**. | The [IAB/MRC in-game rule](#viewability): at least **50% of the prop's pixels** in view and not hidden, the visible prop at least **1.5% of the screen**, a viewing angle of at most **55°**, for **1 continuous second** in a visible tab (2 seconds for video or animated surfaces). | | `start` | The round began (sent with the impression). | n/a | | `engagement` | The player entered the round and touched at least one item. | The player walked up to the prop (needs native mode, where the SDK knows the player's position). | | `complete` | The round played to the end and the sponsored leaderboard showed. | n/a | | `click` | The player clicked the brand's call to action. | | | `reward` | A rewarded round was completed and your `onReward` ran. | n/a | ## What's billed Brands choose how they pay. Only that one event type is billed for their ads; everything else is measured for reporting. | Bid strategy | Billed on | Typical use | | --- | --- | --- | | `vcpm` | `viewable`, priced per 1,000 | Awareness; ambient props | | `cpe` | `engagement`: a player who actually played | The default for Bonus Rounds | | `cpc` | `click` on the call to action | Traffic | - For every billed event, you earn 70% of the net revenue and Bonus Round keeps 30%. Net revenue is the cost, minus a disclosed 15% agency commission when an ad agency placed the campaign. It's written to an append-only ledger at the moment it happens. - Test-mode traffic and `break('test')` rounds are recorded, but never billed and never paid. - A brand's spend stops at a zero balance, so you're never credited for an event nobody paid for. ## Viewability We follow the **IAB / IAB Tech Lab / MRC Intrinsic In-Game Measurement Guidelines 2.0** (2022). The SDK measures in your game, and the ad server checks the measurement again before a `viewable` counts or bills. We say "aligned with IIG 2.0", not "accredited": no third party has audited us yet. | Ad | Pixels in view | Size on screen | Viewing angle | Continuous time | | --- | --- | --- | --- | --- | | Ambient prop (billboard, statue, portal arch) | ≥ 50% | ≥ 1.5% of the screen | ≤ 55° on any axis | 1 second | | Video or animated surface | ≥ 50% | ≥ 1.5% of the screen | ≤ 55° on any axis | 2 seconds | | Bonus Round (full takeover) | The round is the whole screen, so it's always 100% in view and face-on | 2 seconds, in a visible tab | | | - **How it's measured.** The SDK projects the prop's real 3D bounds through your camera to get its on-screen area, clips it to the viewport, and casts sample rays across it every 200 ms to tell the prop's pixels from empty space and to find anything in front of it (walls, terrain, the player's avatar). See-through and invisible objects don't count as blocking. - **Angle.** The angle between the line of sight and the front of the ad, horizontally and vertically. Billboards count from the front only, portal surfaces from both sides, and 3D statues from any side. - **Continuous.** Any 200 ms check that fails, a hidden tab or a stalled frame loop restarts the clock. Each ad counts as viewable at most once. - **Takeovers.** IIG 2.0 leaves interstitials and advergames out of scope, so this is our own definition: the round covers the screen, and it counts after 2 continuous seconds on screen (the video duration, because rounds are animated). Completion is the main quality metric for rounds. - **Custom integrations.** A `viewable` sent to `POST /v1/event` from a browser must carry the measurement, `vw: { std: "iig2", rule: "display" | "video" | "takeover", ms, px, cov, ang }` (pixels and screen share as 0–1 fractions, angle in degrees). Anything below the rule is refused with a 422 and never billed. - Viewable events recorded before 6 Oct 2026 keep their original values and are marked as pre-IIG in our data; they aren't recomputed. ## Listening to SDK events Use `BonusRound.on(type, cb)` to react in your game: duck your music, pause a timer, log to your own analytics. ```js BonusRound.on('start', (e) => { music.duck(); console.log('Bonus Round by', e.brand); }); BonusRound.on('end', (e) => { music.unduck(); analytics.track('bonus_round', e); }); BonusRound.on('reward', (e) => console.log('reward granted', e)); ``` | Type | When | Payload | | --- | --- | --- | | `attach` | `attach()` finished | `{ mode }` | | `start` | A Bonus Round started | `{ format, trigger, brand, requestId, test }` | | `end` | A Bonus Round ended (whatever started it) | `{ filled, completed, score, trigger, requestId, brand }` | | `reward` | A rewarded round was completed | the same shape as `end` | | `ambient` | The ambient prop was placed | `{ placed, kind, brand }` | | `event` | Any measurement event was reported | `{ type, value }` | `start` and `end` also fire for interval rounds and portal rounds that your code didn't start, which makes them the right place to pause and resume game rules. ## Reports Your game's dashboard and `GET /api/games/:id/stats?days=30` show requests, fills, fill rate, impressions, viewable impressions, engagements, clicks, earnings and eCPM, by day, by brand and by format. Money is in micros (1 USD = 1,000,000). Live, the `game:` event stream (`GET /api/live/game:`, server-sent events) carries `sdk_ping`, `status`, `test_ad` and `earning` events as they happen. ## Privacy The SDK sets no cookies. In **contextual-only mode** it stores nothing at all and uses a random id that lives for one page load (`s_…`), only to cap how often the same player sees a round in that session. The ad server keeps only a daily-rotating hash of it, never puts it on events, and uses no user-level or behavioural signals. IP addresses are never stored. Contextual-only mode is on when any of these is true: - you answered ["Is this game made for kids?"](https://bonusround.io/docs/going-live#kids) with **Yes** or **Mixed audience**, or haven't answered yet; - the game is rated Everyone or Everyone 10+, or has no content rating yet, or our agent saw signs it's made for kids; - your consent tool calls `BonusRound.consent(false)` (or sets `window.bonusroundConfig = { consent: false }` before the tag), the tag has `data-storage="none"`, or the browser sends Global Privacy Control and you haven't called `consent(true)`. Otherwise (you said No, and the game is rated Teen or above), the SDK keeps an anonymous player id (`p_…`) in `localStorage` for frequency caps. It isn't tied to an account, an email or a device fingerprint, and `consent(false)` deletes it. `BonusRound.debug().privacy` shows which mode a page is in, and why. --- # Brand safety & categories Brand safety works both ways. You decide which kinds of brands can appear in your game. Brands decide which kinds of games they appear in. Every Bonus Round is reviewed before it runs. ## Your game's rating Each game has a content rating: `everyone`, `teen` or `mature`. Our play agent proposes one after it plays your game; you can change it and your choice sticks (later learning runs won't overwrite it). Brands target ratings, so an accurate rating gets you better-matched ads. ## Game categories Categories describe your game to brands, e.g. `["arcade", "casual", "party"]`. The agent derives them from what it saw; you can edit them (up to 12). Available categories: `party` `casual` `arcade` `racing` `sports` `scifi` `shooter` `sim` `cozy` `puzzle` `rpg` `adventure` `horror` `strategy` ## Blocking advertiser categories Block whole categories of brands from your game with `settings.blockedCategories`. Blocking applies to every format, and takes effect on the next ad request. Each brand is assigned one category when our agent learns it. | Slug | Category | Limits | | --- | --- | --- | | `alcohol` | Alcohol | Only in mature-rated games for adults | | `gambling` | Gambling & betting | Only in mature-rated games for adults | | `tobacco` | Tobacco & vaping | Only in mature-rated games for adults | | `cannabis` | Cannabis & CBD | Only in mature-rated games for adults | | `adult` | Adult content | Only in mature-rated games for adults | | `weapons` | Weapons | Only in mature-rated games for adults | | `dating` | Dating | Only in mature-rated games for adults | | `energy-drinks` | Energy drinks | Not in games for kids | | `pharma` | Pharma & health claims | Not in games for kids | | `finance` | Finance | Not in games for kids | | `beverage` | Soft drinks | | | `snacks` | Snacks & candy | | | `food` | Food & restaurants | | | `pet` | Pets | | | `sportswear` | Sportswear | | | `outdoor` | Outdoor | | | `tech` | Tech | | | `gaming` | Gaming & hardware | | | `toys` | Toys | | | `apparel` | Fashion | | | `beauty` | Beauty | | | `auto` | Auto | | | `travel` | Travel | | | `entertainment` | Movies, TV & music | | | `education` | Education | | ```bash curl -X PATCH https://bonusround.io/api/games/gm_… \ -H "Authorization: Bearer $BONUSROUND_API_KEY" -H "content-type: application/json" \ -d '{ "settings": { "blockedCategories": ["energy-drinks", "beverage"] } }' ``` The list replaces the previous one. Use the slugs above exactly (`GET /api/games/_meta` returns the current list as `brandCategories`, plus the defaults for every setting); a slug that isn't on the list blocks nothing. Some categories are limited for everyone, whatever you block: brands in the first seven rows only run in `mature`-rated games with no kids or teens in the audience, and the first ten never run in a game for kids. Crypto and political ads aren't accepted on Bonus Round at all. > **Games for kids** Answer **Is this game made for kids?** in Game → Settings (or `PATCH /api/games/:id { "settings": { "madeForKids": "yes" | "mixed" | "no" } }`). **Yes** and **Mixed audience** block the restricted categories above automatically. Those games, games you haven't answered for, and Everyone-rated or unrated games get contextual ads only: no player IDs, cookies or local storage, no behavioural targeting, frequency caps per session. Our agent suggests Yes when it sees a game made for children under 13; you confirm it. [Full rules](https://bonusround.io/docs/going-live#kids). ## What brands control - Brands target by game category and rating, and can include or exclude specific games. - Before a brand sees your game as a match, it's scored on audience, safety, category, style, performance and reach. ## Creative review Every Bonus Round is generated for one brand and one game, and it can only serve once it has been **approved**. The brand plays it in your game first, as a preview, and approves or rejects it. Our generation pipeline also play-tests each round against a QA checklist (scale, readability, the round fits the arena) before it reaches review. A round's assets are self-contained: models, textures, music and sound effects are loaded from Bonus Round, and the round runs no third-party code in your page. ## Report a problem See something in your game that shouldn't be there? Pause the format right away from the dashboard (Settings → formats), or pause the whole game with `PATCH /api/games/:id { "paused": true }`, and tell us which brand it was. The `end` event and your stats both name the brand. --- # Payouts You keep **70% of net ad revenue** in your game: what brands spend, minus any disclosed agency commission. Earnings become available 30 days after they're earned, and you can withdraw once you have at least $50 available. ## Revenue share - Every billed event (a viewable impression, an engaged player or a click, depending on how the brand bids) splits 70% to you and 30% to Bonus Round. - When an ad agency places a campaign for a brand, the agency earns a disclosed 15% commission on that campaign's spend first, and the 70/30 split applies to the rest: on $100, $15 to the agency, $59.50 to you and $25.50 to Bonus Round. Your earnings page shows agency-placed revenue separately. See [net revenue](https://bonusround.io/legal/publisher-terms#net-revenue) in the Publisher Terms. - The 30% covers everything on our side: agents learning your game, generating each brand's Bonus Round for your game, hosting, serving and measurement. There are no setup fees and no separate charges. - Brands prepay, so the money for every event you're credited for already exists. Spend stops at a zero balance. ## When money becomes available | State | Meaning | | --- | --- | | **Pending** | Earned in the last 30 days. Payouts are net-30. | | **Available** | Earned more than 30 days ago (net-30) and not yet paid out. | | **Paid** | Sent to your bank through Stripe. | The earnings page shows all three, plus your next payout date: the first day your available balance reaches the $50 minimum. ## Set up payouts - ### Open Earnings & payouts In the dashboard: [Earnings & payouts](https://bonusround.io/app/billing/earnings.html). - ### Connect with Stripe Payouts go through **Stripe Connect Express**. Stripe collects your identity and bank details directly; Bonus Round never sees your bank account number. - ### Withdraw Once at least $50 is available, request a payout. Stripe usually lands it in your bank within a few business days. ## Earnings via the API ```bash curl https://bonusround.io/api/billing/earnings -H "Authorization: Bearer $BONUSROUND_API_KEY" ``` ```json { "balanceMicros": 81250000, // available now: $81.25 "pendingMicros": 23400000, // inside the 30-day window "paidMicros": 150000000, "nextPayoutAt": 1791244800000, "minPayoutMicros": 50000000, "netDays": 30, "history": [ … ] } ``` All money in the API is integer **micros**: 1 USD = 1,000,000. ## What isn't paid - Test-mode traffic and `break('test')` rounds. - Events the ad server rejects (expired or tampered tokens, duplicates), and requests from pages that aren't on your game's domains, which are never filled. - Demo-account data. It's labelled “Demo data” everywhere it appears. --- # REST API Everything in the dashboard is available over a JSON API: register games, fetch the install snippet, verify the SDK is live, change settings and read stats. The CLI and the MCP server are built on exactly these endpoints. ## Basics - **Base URL:** `https://bonusround.io` - **Auth:** `Authorization: Bearer br_sk_…`. Create keys on the [Developers page](https://bonusround.io/app/developers/). A key acts as your account, so keep it out of client-side code and out of git. (The dashboard itself uses a session cookie.) - **Format:** JSON in and out. Send `content-type: application/json` on requests with a body. - **Errors:** a 4xx/5xx status with `{ "error": "Human-readable message" }`. `401` means a missing or revoked key, `404` means not found or not yours, `429` means slow down. - **Money** is integer micros: 1 USD = 1,000,000. **Times** are milliseconds since the epoch. ```bash export BONUSROUND_API_KEY=br_sk_… curl https://bonusround.io/api/v1/whoami -H "Authorization: Bearer $BONUSROUND_API_KEY" ``` ## Account & API keys #### GET /api/v1/whoami Which account a key belongs to, plus a summary of its games. The quickest way to check a key works. ```json { "user": { "id": "usr_…", "email": "you@studio.com", "name": "You" }, "auth": "api_key", "key": { "id": "key_…", "name": "Claude Code", "prefix": "br_sk_AbCdEf", "createdAt": 1791000000000, "lastUsedAt": 1791000100000 }, "games": [{ "id": "gm_…", "pubId": "pub_…", "name": "Orb Dash", "url": "https://orbdash.example", "status": "ready", "testMode": true, "lastSeenAt": 1791000050000 }], "urls": { "developers": "https://bonusround.io/app/developers/", "docs": "https://bonusround.io/docs/", "integrate": "https://bonusround.io/integrate.md", "mcp": "https://bonusround.io/mcp" } } ``` #### GET /api/keys Your keys: `[{ id, name, prefix, created_at, last_used_at, revoked_at }]`. The full key is never returned again. #### POST /api/keys { name } Creates a key. The response is the **only time** the full key appears: `{ id, key: "br_sk_…", prefix, note }`. #### DELETE /api/keys/:id Revokes a key immediately. `{ ok: true }` ## Games #### POST /api/games { url, name? } Registers a game and creates its public `pub_` id. `url` is where the game runs; its domain (and subdomains) are allowed to serve ads. Returns `{ game }`. ```bash curl -X POST https://bonusround.io/api/games \ -H "Authorization: Bearer $BONUSROUND_API_KEY" -H "content-type: application/json" \ -d '{ "url": "https://orbdash.example/play", "name": "Orb Dash" }' ``` #### GET /api/games All your games, newest first: `[game]` (a lighter shape, plus `last7` earnings). #### GET /api/games/:id One game. The fields you'll use most: ```json { "id": "gm_…", "pubId": "pub_…", "name": "Orb Dash", "url": "https://orbdash.example/play", "status": "learning", // pending → detected → learning → ready (or paused) "testMode": true, "settings": { … }, // see PATCH below "rating": "everyone", "categories": ["casual", "platformer"], "integration": { "firstSeenAt": 1791000000000, "lastSeenAt": 1791000050000, // null until the SDK has pinged from an allowed domain "sdkVersion": "1.0.0", "threeRevision": "186", "origins": ["orbdash.example"] }, "world": { … } , // what the play agent learned, or null "snippet": { "scriptTag": "", "attachLine": "BonusRound.attach({ THREE, scene, camera, renderer });", "attachQueued": "(window.bonusround = window.bonusround || []).push((BR) => BR.attach({ THREE, scene, camera, renderer }));", "breakLine": "await window.BonusRound?.break('intermission');", "testLine": "BonusRound.break('test');", "adsTxt": "bonusround.io, pub_…, DIRECT" } } ``` > **Verifying an install** Put `snippet.attachQueued` in game code, not `attachLine`: the tag is async, so the bare call can run before `window.BonusRound` exists. Load the game in a browser, then poll `GET /api/games/:id` until `integration.lastSeenAt` is set. The first ping also moves `status` from `pending` to `detected` and starts learning. #### PATCH /api/games/:id Change any of: `name`, `testMode`, `rating` (`everyone` | `teen` | `mature`), `categories`, `url`, `domains` (hostnames allowed to serve ads; replaces the list), `paused`, and `settings`. `settings` is merged into the current settings, so send only what changes. Returns the game. ```json { "settings": { "formats": { "takeover": { "enabled": true, "triggers": ["intermission", "interval", "rewarded"], "intervalSec": 300 }, "ambient": { "enabled": true } }, "floorCpmMicros": { "takeover": 2000000, "ambient": 500000 }, "blockedCategories": ["alcohol", "gambling"], "frequencyCap": { "perHour": 4 }, "madeForKids": "no" } } ``` `settings.madeForKids` is `"yes"`, `"mixed"`, `"no"` or `null` (not answered yet). Anything but `"no"` on a Teen-or-above game serves contextual ads only ([Games for kids](https://bonusround.io/docs/going-live#kids)); the read-only `directedToChildren` is true for yes and mixed. `GET /api/games/:id/compliance` returns the game's privacy mode, its ads.txt lines and detection status, and its sellers.json listing. #### GET /api/games/_meta Valid triggers, ratings, game categories, blockable brand categories and default settings. #### POST /api/games/:id/learn (Re)starts the play agent: `{ ok, runId }`. It starts on its own after the first SDK ping; call this after big changes to your game. #### PUT /api/games/:id/agent-login { loginUrl?, username, password, notes? } A test account for games that need a login to play. Stored encrypted and never returned; the agent types it through a secret tool so the model never sees it. `{ ok }`. `DELETE` the same path to remove it. #### GET /api/games/:id/stats?days=30 ```json { "totals": { "requests": 1840, "fills": 1210, "impressions": 1180, "viewable": 1090, "engagements": 520, "clicks": 31, "earningsMicros": 41800000, "ecpmMicros": 35400000, "fillRate": 0.66 }, "daily": [{ "day": "2026-10-05", "requests": 61, "impressions": 40, "engagements": 18, "clicks": 1, "earningsMicros": 1400000 }], "byBrand": [ … ], "byFormat": [ … ], "byTrigger": [ … ], "test": { "requests": 6, "impressions": 6, "engagements": 0, "completions": 4, "lastAt": 1791000200000 } } ``` Real traffic only. Test-mode traffic (the Fizzpop test ad) is never in `totals`, `daily` or earnings; it's counted on its own under `test`, so you can see your test rounds firing. ## Live events #### GET /api/live/game: Server-sent events for one of your games: `sdk_ping`, `status`, `agent_log`, `agent_image`, `agent_done`, `test_ad`, `earning`. The last 200 events are replayed when you connect. ```bash curl -N https://bonusround.io/api/live/game:gm_… -H "Authorization: Bearer $BONUSROUND_API_KEY" ``` ## Earnings & payouts #### GET /api/billing/earnings `{ balanceMicros, pendingMicros, paidMicros, nextPayoutAt, history }`. See [Payouts](https://bonusround.io/docs/payouts). #### POST /api/billing/connect Returns `{ url }` to Stripe Connect Express onboarding. #### POST /api/billing/payouts { amountMicros } Requests a payout of available earnings (minimum $50). ## Brands & campaigns For advertisers. The same keys work. | Endpoint | What | | --- | --- | | `POST /api/brands { url, assets? }` | Start learning a brand from its website → `{ brand }` | | `GET /api/brands`, `GET /api/brands/:id` | Brands, and one brand's `{ brand, kit }` | | `GET /api/brands/:id/matches` | Matched games with scores and a breakdown | | `POST /api/creatives { brandId, gameId, campaignId? }` | Generate a Bonus Round for one game | | `GET /api/creatives/:id`, `POST …/approve`, `POST …/reject { reason }` | Review generated rounds | | `POST /api/campaigns { brandId, name, objective, formats, bidStrategy, bidMicros, dailyBudgetMicros, targeting, frequencyCap, cta }` | Create a campaign | | `GET /api/campaigns?brandId=`, `GET\|PATCH /api/campaigns/:id`, `POST /api/campaigns/:id/status { status }` | Manage campaigns | | `GET /api/campaigns/:id/stats?days=30` | Impressions, viewable, engagements, completions, clicks, spend, CTR, eCPM | | `GET /api/billing/accounts`, `POST /api/billing/checkout { accountId, amountMicros }` | Balances and prepaid top-ups (minimum $50) | ## Ad serving (public) These are called by the SDK from your players' browsers. They're CORS-open, keyed by `pub`, and need no API key. You shouldn't need to call them yourself, but they're useful for debugging. | Endpoint | Body → response | | --- | --- | | `POST /v1/ping` | `{ pub, origin, sdkVersion, threeRevision }` → `{ ok, status, testMode, first, settings, world }`. Marks the game as seen. From a page that isn't on the game's domains: `{ ok: false, status: 'origin_not_allowed' }`. | | `POST /v1/ad` | `{ pub, format, trigger, playerId }` → `{ fill, requestId, manifestUrl, cta, token, brand, test }`, or `{ fill: false, reason }` | | `POST /v1/event` | `{ token, type, value }` → `{ ok, billed }` (a repeat of the same type for the same ad is `{ ok, duplicate: true }`, not counted again). | | `GET /v1/ads.txt?pub=` | Your ads.txt line, as text | | `GET /v1/br.js` | The SDK loader | ## MCP endpoint #### POST /mcp A hosted [Model Context Protocol](https://modelcontextprotocol.io) server (streamable HTTP, stateless) with the same tools as the `@bonusround/mcp` package. Authenticate with the same `Authorization: Bearer br_sk_…` header. See [Integrate with AI agents](https://bonusround.io/docs/agents). --- # FAQ ## Will it slow my game down? The loader is a small async script, and the rest of the SDK is imported only when it's needed (and warmed up once the page is idle, so the first `break()` doesn't wait). Bonus Round models are budgeted (about 10k triangles for a hero, 3k for a collectible) with 1K textures. Nothing loads until there's an ad to show. ## What happens with an ad blocker? The script doesn't load, `window.BonusRound` is undefined, and if you wrote `window.BonusRound?.break('intermission')` the line does nothing and your game carries on. Use the `bonusround` queue for `attach()` and `?.` for everything else and an ad blocker can never break your game. ## Which three.js versions work? The SDK builds everything with the `THREE` you pass to `attach()`, so it uses your version and never loads a second copy. It reports your revision to the dashboard. Use `WebGLRenderer`. ## I use React Three Fiber / a framework on top of three.js. Fine. You need access to the scene, the camera and the renderer; R3F gives you all three from `useThree()`. See [Install → React Three Fiber](https://bonusround.io/docs/install#r3f). Engines that hide three.js entirely may need a native-mode adapter; get in touch. ## Does it work in multiplayer games? Yes. In overlay mode and native mode without `net`, each player's round runs locally in their browser. To have everyone drop into the *same* round with the same item layout and a shared leaderboard, pass `host.net` and relay Bonus Round messages through your game server (`native-net` mode). Our demo game, [Hop Isle](https://hopisle.com/), works this way. ## Does it work on phones? Rounds use the same controls the game does. In native mode that's your own touch controls. In overlay mode the round uses the control scheme our agent learned from your game, so make sure your game's touch controls work in a browser. ## My game runs in an iframe (itch.io, a CDN host). Put the script tag in the page inside the iframe, the one that runs three.js. Add the iframe's host to your game's domains so ads are served there. ## My game is also on Poki, CrazyGames, GameDistribution or Y8. Keep the same build. Those portals don't allow third-party ads, so the SDK detects them and switches itself off there (no ad requests, `break()` resolves with `reason: 'portal'`), and your dashboard shows a notice. See [portal mode](https://bonusround.io/docs/going-live#portals). ## Will rounds interrupt my players mid-game? Only if you call `break()` mid-game. Intermission rounds happen where you call them; interval rounds are offers the player can decline (and you can block them with `BonusRound.safe(false)`); rewarded rounds are opt-in. ## What should my game do while a round plays? In overlay mode, pause what you like: your canvas is covered. In native mode, keep rendering and keep player movement running (the player is playing the round in your world), but pause timers, enemies, damage and scoring. Use the `start` and `end` events for rounds you didn't start yourself. ## How much will I earn? It depends on your audience, your game's engagement and how many brands match it. You get 70% of net ad revenue (spend, minus any disclosed agency commission). Rewarded and intermission rounds earn the most per player; the interval trigger and ambient props add volume. Real numbers appear in your dashboard as soon as there's real traffic. Test traffic never counts. ## Why does `break()` resolve immediately? There was nothing to show. Check the `reason` in the result: `no_eligible_campaigns` (no brand has an approved round for your game yet), `below_floor`, `game_frequency_cap` or `frequency_cap`, `takeover_disabled`/`trigger_disabled`/`format_disabled`, `origin_not_allowed` (the page isn't on your game's domains), `game_paused`, `not_attached` (`attach()` hasn't finished: call it first, and wait for it when you call the SDK right after), `busy` (a round is already playing), `ad_server_unreachable` or `load_timeout` (network trouble), or `sdk_unavailable` (the script didn't load). ## Can I test on localhost? Yes. `localhost`, `127.0.0.1` and `*.localhost` are always allowed, and a game in test mode always gets the Fizzpop test round. ## How do I remove it? Delete the script tag and the lines that call `BonusRound`. Nothing else in your game depends on it. To stop ads without a deploy, pause the game in the dashboard. ## Something's wrong. Run `await BonusRound.debug()` in the console on your game's page and include the output when you contact us. It has the SDK mode, whether it's attached, test mode, settings, the current round, the ambient prop, and the last ad requests (with the reason each one didn't fill) and events. Warnings and errors from the SDK are in the console with a `[bonusround]` prefix.