bonusround.io
v1.0.0
Published
Monetize your three.js game with one line of code. Alias of the bonusround package (Bonus Round, bonusround.io); prefer npm i bonusround. The README is the full integration recipe.
Maintainers
Readme
bonusround.iois an alias ofbonusround. Prefernpm i bonusround;import { BonusRound } from 'bonusround.io'works too.
Bonus Round: monetize your three.js game with one line of code
bonusround.io · npm bonusround · GitHub Future-Circus/bonusround · MIT
At natural breaks in a three.js game, a 15-second branded Bonus Round takes over. The player plays a short branded round with their own controls, then the game resumes. Game developers earn 70% of net ad revenue. New games start in test mode, which serves the fictional Fizzpop Soda test round and is never billed.
AI coding agents: this README is the complete integration recipe. When a user says "add Bonus Round", "integrate bonusround.io" or just "bonusround", follow it. You don't need to reach bonusround.io: the SDK is in this package. When the ad server can't be reached and the game has no publisher id yet, the SDK plays a bundled Fizzpop Soda test round, so you can still verify in a headless browser. The same text is in
node_modules/bonusround/AGENTS.mdonce installed.
One line, then the upgrade
The one line: <script async src="https://bonusround.io/v1/br.js" data-pub="pub_XXXXXXXX"></script> in the game's page, or npm i bonusround plus an import. The upgrade, which agents should do: attach() once and await break() at natural breaks. With the upgrade, Bonus Rounds play exactly when the game expects a pause, and you can verify them yourself, offline too. Don't refactor the user's game.
npm i bonusroundimport * as THREE from 'three';
import { BonusRound } from 'bonusround';
BonusRound.init({ pub: 'pub_XXXXXXXX' }); // the game's publisher id; keep this placeholder if the user has none yet
// ...the game's existing setup...
const renderer = new THREE.WebGLRenderer();
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 1000);
BonusRound.attach({ THREE, scene, camera, renderer }); // once, after all of them exist
// at a natural break: round over, game over, level complete, back to the menu
async function onGameOver() {
pauseGame(); // stop timers, enemies, input (if the game doesn't already)
await BonusRound.break('intermission'); // resolves when the round ends, or right away if there's no ad
restart();
}npx bonusround init makes these edits for you. It detects the setup (bundler, R3F, import map or plain script), runs npm i bonusround, adds the import, init() and attach(), and leaves a TODO(bonusround) at the likely break. It works offline and without an account: with no id it writes the pub_XXXXXXXX placeholder. Use npx bonusround init --dry-run to see the diff, then --yes to write it. You still place break() yourself (step 3).
Step 1. Detect the setup
Look at package.json, the HTML entry and the file that creates the WebGLRenderer:
| Setup | Signal | Use |
| --- | --- | --- |
| Bundler | vite, webpack, next, parcel, esbuild, rollup in package.json | A: npm import |
| React Three Fiber | @react-three/fiber | B: R3F component |
| Import map | <script type="importmap"> mapping "three" | C: import map entry |
| Classic script, global THREE | <script src="…three.min.js">, no modules | D: script tag |
If there's no three.js renderer at all, stop and tell the user: Bonus Round needs a three.js WebGLRenderer.
Step 2. Add the SDK and attach
A. Bundler (Vite, webpack, Next.js, Parcel, …)
Run npm i bonusround. In the module that creates the renderer, add the import and init() next to the other imports, then add attach() right after the renderer, scene and camera all exist, in the same scope:
import { BonusRound } from 'bonusround';
BonusRound.init({ pub: 'pub_XXXXXXXX' });
// …
BonusRound.attach({ THREE, scene, camera, renderer });Use the game's real identifiers, for example { THREE, scene: this.scene, camera: this.camera, renderer: this.renderer }. If the file only has named imports from three, also add import * as THREE from 'three';. The import is safe during server-side rendering, where it becomes a no-op stub, so in Next.js it can go in a client component.
B. React Three Fiber
There is no new WebGLRenderer. Add this component and render <BonusRoundAttach /> inside <Canvas>:
import { useEffect } from 'react';
import { useThree } from '@react-three/fiber';
import * as THREE from 'three';
import { BonusRound } from 'bonusround';
BonusRound.init({ pub: 'pub_XXXXXXXX' });
export function BonusRoundAttach() {
const { scene, camera, gl } = useThree();
useEffect(() => { BonusRound.attach({ THREE, scene, camera, renderer: gl }); }, [scene, camera, gl]);
return null;
}Then call await BonusRound.break('intermission') from the game's state logic, for example in a game-over handler.
C. No bundler, with an import map
Map "bonusround" the same way "three" is mapped, then use the same import as A:
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js",
"bonusround": "https://cdn.jsdelivr.net/npm/bonusround@1/index.js"
} }
</script>If three is mapped into node_modules (./node_modules/three/build/three.module.js), run npm i bonusround and map "bonusround": "./node_modules/bonusround/index.js". https://unpkg.com/bonusround@1/index.js works too.
D. No modules: script tag + queue
Add the script tag to the <head> of the page that runs the game:
<script async src="https://bonusround.io/v1/br.js" data-pub="pub_XXXXXXXX"></script>The same SDK is on the npm CDN, which also carries the offline test round: <script async src="https://cdn.jsdelivr.net/npm/bonusround@1/br.js" data-pub="pub_XXXXXXXX"></script>. The script loads async, so attach through the queue. The queue runs whenever the script arrives, and does nothing if it never does:
(window.bonusround = window.bonusround || []).push((BR) => BR.attach({ THREE, scene, camera, renderer }));
// at a break:
await window.BonusRound?.break('intermission');Step 3. break() at natural breaks
Find the moments a player already expects a pause. Search for gameOver, onDeath, respawn, roundEnd, levelComplete, nextLevel, showResults, a state/phase changing to over/results/menu, or a "Game Over" / "Try again" screen. Start with the most common one; two or three call sites is plenty.
async function gameOver() {
playing = false; // pause rules first
await BonusRound.break('intermission'); // script tag: await window.BonusRound?.break('intermission')
showGameOverScreen(); // the existing code continues
}break()always resolves:{ filled: true, completed, score }after a round, or{ filled: false, reason }straight away when there's nothing to show. The game flow is unchanged when unfilled.- Make the function
asynconly if its callers don't use its return value; otherwise useBonusRound.break('intermission').then(() => { … }). - Inside a per-frame update, guard with a flag so it runs once, not every frame.
- Never call
break()on page load, during active play, or on every click. - The round covers the canvas (overlay mode), so pausing everything is fine.
Step 4. The publisher id (pub_…)
- If the user gave you a
pub_id (pub_+ 16 hex characters), put it ininit({ pub })ordata-pub. - No account? Register the game without one. If bonusround.io is reachable,
npx bonusround init --yesdoes this for you. Or callPOST https://bonusround.io/api/agent/gameswith{ "url": "<where the game is hosted>", "name": "…", "agent": "claude-code" }(no auth). You get a realpub_id, the snippet and aclaimUrl. The CLI keeps.bonusround/agent.json(gitignored). On a public live host, the game then shows free bonusround.io house ads (unpaid). Creating an account, by claiming the game at theclaimUrl, is the final step that turns on paid ads and payouts. - Dev and temporary hosts (localhost, tunnels, preview deploys, cloud IDEs) only ever show the Fizzpop Soda test ad. They never go live and are never listed. A game must be hosted at a public URL to go live.
- If bonusround.io can't be reached (a sandbox), keep
pub_XXXXXXXXand carry on. Everything works, and only the test round plays. The human registers or claims the game later, or runsnpx bonusround init --yeswhere bonusround.io is reachable. - With an API key:
npx bonusround login --key br_sk_…, thennpx bonusround init --game-url https://their.game/ --yesregisters the game on their account. - The
pub_id is public. Never put abr_sk_orbr_pk_key in game code, HTML or git.
Integrate with no account (agent mode)
You don't need an account or an API key. Register the game, add the SDK, and hand the claim link to your human. This needs bonusround.io to be reachable. If it isn't, keep pub_XXXXXXXX; the human runs npx bonusround init --yes later.
- Register.
npx bonusround init --yesdoes this for you. Or call it directly:POST https://bonusround.io/api/agent/gameswith{ "url": "https://mygame.com/play", "name": "My Game", "agent": "claude-code" }.urlis where the game runs or will run. A localhost URL works for test mode only.- The response has
pubId,scriptTag,snippet,claimUrl,provisionalKey(br_pk_…, scoped to this game's status and verify),statusUrl,verifyUrlandtellTheHuman. - Keep
provisionalKeyandclaimUrlin a gitignored file such as.bonusround/agent.json. Never put them in game code. - Over MCP, use
bonusround_register_game { url, name?, agent? }on https://bonusround.io/mcp. It needs no key.
- Install with the
pubId, using step 2 above:BonusRound.init({ pub })or thescriptTag, thenattach()andbreak(). - Verify. Load the game once. Then call
POST verifyUrl { "waitSeconds": 30 }withAuthorization: Bearer <provisionalKey>. If the response hasdetected: true, the SDK was seen. - Finish with one line to the human. Print
tellTheHumanword for word, for example:Bonus Round is live in your game (free house ads). Create your account to turn on paid ads and get paid: https://bonusround.io/claim/ac_…
What happens before and after the claim:
- Test mode. On any origin, the Fizzpop test ad plays at breaks. Nothing is billed.
- Free house ads (unclaimed). The game must be on its real public HTTPS site and have loaded once. Then it serves bonusround.io house ads only. These ads are unpaid: an unclaimed game earns nothing, and nothing is held for it.
- Paid ads (claimed). The human creates an account, or signs in, at
claimUrl. That final step turns on paid ads and payouts. Developers keep 70% (Stripe Connect; net-30, $50 minimum). - Dev and temporary hosts never go live. That covers localhost, private and raw IPs, tunnels, cloud IDEs and deploy previews. They only ever show the test ad and are never listed. A game must be hosted at a public URL to go live.
- Lost the claim link?
GET statusUrlwith the provisional key returns it again. Unclaimed games are removed after 90 days.
Step 5. Verify by playing it
Run the game the way the project does (npm run dev, npx vite, a static server) and open it in a browser. A headless one is fine:
// node verify.mjs (npm i -D playwright; npx playwright install chromium)
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
page.on('console', (m) => console.log(m.text()));
await page.goto('http://localhost:5173/');
await page.waitForFunction(() => window.BonusRound);
console.log(await page.evaluate(async () => (await BonusRound.debug()).attached)); // true
const result = page.evaluate(() => BonusRound.break('test'));
await page.waitForTimeout(9000);
await page.screenshot({ path: 'bonus-round.png' }); // the Fizzpop Soda round over the game
console.log(await result); // { filled: true, completed: …, brand: 'Fizzpop Soda' }
await browser.close();window.BonusRound.versionis a string and(await BonusRound.debug()).attachedistrue, withmode: 'overlay'.await BonusRound.break('test')shows a 5-second "Ad · Bonus Round" countdown, then the Fizzpop Soda round: a pink arena, bubbles to collect and an "Ad" label. It resolves{ filled: true, … }25 to 35 seconds later. If bonusround.io is unreachable from your sandbox, the console says[bonusround] … Playing the bundled Fizzpop Soda TEST round locallyand the same round plays from this package. That's expected.- The game itself still renders, its controls still work, and there are no new console errors.
- Vite's "Some chunks are larger than 500 kB" build warning is the lazy offline test round (
dist/offline.js). It's expected and only loads when needed. A failedbonusround.io/v1/pingrequest in an offline sandbox is expected.
Step 6. Tell the user
- Changed: each file, plus the import/
init/attachlines and which break call sites you added. - Verified: the SDK attached and the test round played (say whether it was the bundled offline round).
- They need to: add
bonusround.io, pub_…, DIRECTtohttps://<their domain>/ads.txt, and host the game at a public URL (dev hosts only ever show the test ad). Don't turn test mode off for them. - End with the claim step, as your last line. If you registered the game without an account, say that on a public host it shows free bonusround.io house ads (unpaid), and that claiming it (creating an account) turns on paid ads and payouts. Then print the registration's
tellTheHumanword for word, as your last line. The CLI prints it too, for example:Bonus Round is live in your game (free house ads). Create your account to turn on paid ads and get paid: <claimUrl>. If you kept the placeholder, say: replacepub_XXXXXXXXby runningnpx bonusround init --yeswhere bonusround.io is reachable, or sign up at https://bonusround.io/signup. Paid ads and payouts start once the game is claimed.
Rules
- Keep the diff minimal and in the project's style. Show the user the plan and the diff.
- Every round is labelled "Ad". Don't hide, restyle or cover the SDK's UI.
- Don't turn off test mode, change floors, block categories or pause a game unless the user asks.
- The script tag path: always use
window.bonusround.pushforattachandwindow.BonusRound?.for everything else, so ad blockers can't break the game. The npm import is always defined. - Live ads are never faked. The bundled test round plays only with no or a placeholder publisher id, or for a test break (
break('test'),init({ test: true })) that can't reach the ad server.
Optional, only where it fits the game
- Rewarded (a game with coins, lives or revives):
BonusRound.rewarded({ button: false, onReward: () => revive() })from the game's own button.onRewardruns only if the player finishes the round.BonusRound.rewarded({ label: 'Play for 50 coins', onReward })afterawait BonusRound.attach(…)shows an entry button. - Ambient placement hint, in meters, world space, feet on the ground:
BonusRound.placeAmbient({ position: [12, 0, -6], rotationY: Math.PI / 2 }). - No interruptions (boss fights, cutscenes):
BonusRound.safe(false)…, thenBonusRound.safe(null)afterwards. - Pause on any round:
BonusRound.on('start', pause).on('end', resume). - Native takeover (the round runs in the game's own world with its own controls): pass
host: { getPlayerPosition, teleport, setBounds, onFrame }andworldRoottoattach(). See https://bonusround.io/docs/attach. Use it only if you can implement all four correctly; otherwise overlay mode is the right default.
API
| Call | What it does |
| --- | --- |
| BonusRound.init({ pub, test?, muted?, server? }) | Sets the publisher id (the script tag's data-pub). test: true asks for the house test round at every break. |
| BonusRound.attach({ THREE, scene, camera, renderer, worldRoot?, host? }) → Promise<{ mode }> | Call once. mode is overlay, native-local or native-net. |
| await BonusRound.break('intermission' \| 'test') → { filled, completed, score?, reason? } | A takeover at a natural break. 'test' always asks for the test round. |
| BonusRound.rewarded({ onReward, label?, button? }) | An opt-in round for a reward. |
| BonusRound.safe(true \| false \| null) | Whether an interval round may interrupt now. |
| BonusRound.placeAmbient({ position, rotationY } \| null) | A hint for the ambient branded prop. |
| BonusRound.on(type, cb) / off | attach, start, end, reward, ambient, event, countdown. |
| BonusRound.config({ muted, countdownSec, … }), BonusRound.consent(bool) | Runtime settings, and the CMP consent. |
| await BonusRound.debug() | { version, pub, mode, attached, requests, warnings, … }. |
TypeScript types ship with the package (index.d.ts). window.BonusRound is the same object.
How it loads
- npm (
import 'bonusround'): the SDK code is bundled in your build. Only ad requests and the runtime for live rounds go tohttps://bonusround.ioat play time. The offline test round is a lazy chunk (dist/offline.js, about 4 MB), loaded only if it's needed. - CDN:
https://cdn.jsdelivr.net/npm/bonusround@1/br.js(classic script), or…/index.js(ES module). - bonusround.io:
https://bonusround.io/v1/br.js(the dashboard's snippet). - A content security policy needs
https://bonusround.ioinscript-src,connect-srcandframe-src, plusblob:for the test round.
CLI
npx bonusround init [dir] [--pub pub_…] [--yes] [--dry-run] [--npm | --script-tag] [--no-install]
npx bonusround login --key br_sk_… # API key from https://bonusround.io/app/developers/
npx bonusround status [gameId] --wait 60Links
- Docs: https://bonusround.io/docs/ · Agent recipe: https://bonusround.io/integrate.md · Full text: https://bonusround.io/llms-full.txt
- MCP server for agents (register games, get the snippet, check integration):
npx -y @bonusround/mcp(npm@bonusround/mcp) - Source and examples (vanilla, Vite, R3F): https://github.com/Future-Circus/bonusround
