QUICK START
Add one tag to your game.
Drop this into your exported game's index.html.
Replace your-slug with the slug your game is
registered under in the dev portal.
<script type="module"
src="https://badgames.io/sdk/embed.js?game=your-slug"></script>
That's it for the default integration. The SDK mounts a thin
top banner with sign-in state, a bottom-right leaderboard toggle,
and exposes window.badgames for your game to call.
Need to render your own UI? Use the
/sdk/v1.js module variant - same methods, no
auto-mounted chrome.
CORE · ROUND + SCORE
Three calls for a complete round.
Call startRound() when the player actually
begins playing (after they press Start, not on page load). Submit the score when the round ends.
The round must run at least 5 seconds before submitScore() will be accepted.
// after the player presses Play await window.badgames.startRound();
JavaScriptBridge.eval(
"window.badgames.startRound()"
)
// .jslib plugin StartBadgamesRound: function() { window.badgames.startRound(); }
use wasm_bindgen::prelude::*; #[wasm_bindgen] extern "C" { #[wasm_bindgen(js_namespace = ["window", "badgames"])] async fn startRound() -> JsValue; }
EM_ASM({ window.badgames.startRound(); });
const result = await window.badgames.submitScore(score); // { ok: true, id: 99, place: 3 }
JavaScriptBridge.eval( "window.badgames.submitScore(" + str(score) + ")" )
SubmitBadgamesScore: function(score) {
window.badgames.submitScore(score);
}
#[wasm_bindgen(js_namespace = ["window", "badgames"])] async fn submitScore(score: i32) -> JsValue;
EM_ASM({ window.badgames.submitScore($0); }, score);
AUTH
One sign-in across every game.
Players sign in once at badgames.io. The session cookie is scoped to *.badgames.io
so it follows them into every game. Read who's playing - or subscribe to changes - without your game owning any auth code.
const me = await window.badgames.me(); // { // authenticated: true, // user: { id, name, avatar } // } // // or { authenticated: false } for guests
const off = window.badgames.onSignIn(user => { if (user) renderForPlayer(user); else renderForGuest(); }); // later: off() to unsubscribe
Other helpers:
getUser() (synchronous getter, null for guests),
openSignIn() (opens login in a new tab).
TOKEN-GATED GAMES
Six-character codes unlock one play.
Games that cost real money to run (AI voices, premium APIs)
accept play tokens minted by an admin or sold in packs.
Redeeming a token returns the same round shape as startRound() -
you keep playing normally.
try { await window.badgames.redeemToken('K7F-A2X'); startGame(); } catch (e) { if (e.status === 410) showError('Code already used.'); else showError('Code not valid.'); }
LEADERBOARDS
Per-game and cross-game, same API.
const board = await window.badgames.leaderboard({ limit: 25, window: 'week' // 'all' | 'week' | 'month' });
// Open / close the built-in panel window.badgames.showLeaderboard(); window.badgames.hideLeaderboard();
CLOUD SAVE
Small JSON blobs, per-user, per-game.
For games that need to remember progress between sessions. Each blob is scoped to one user and one game, keyed by a string of your choice. Size cap is 16 KB per key, 64 KB total per (user, game).
await window.badgames.saveState('progress', { level: 3, hp: 80, items: ['compass', 'rope'], }); const state = await window.badgames.loadState('progress'); // → the object you saved, or null
Note: cloud save is only persisted for signed-in players. Guests get a silent no-op on save and null on load.
CATEGORIES
Multiple leaderboards per game.
Some games have several modes that aren't comparable - Jumper's Standard vs Extended, Bridge Babe's rock / golf / frisbee. Declare them once in your manifest, pass the key on every round, and badgames.io maintains a separate leaderboard per category.
{
"categories": [
{ "key": "rock", "label": "Rock" },
{ "key": "golf", "label": "Golf" },
{ "key": "frisbee", "label": "Frisbee" }
]
}
// Pick category when the round starts await window.badgames.startRound({ category: 'rock' }); // submitScore inherits it - no need to repeat await window.badgames.submitScore(score); // Read a single category's board const board = await window.badgames.leaderboard({ category: 'rock', limit: 25, });
The platform's /leaderboard page
auto-splits per category when a game has 2+. The focused per-game page
/leaderboard/<slug>?category=<key>
accepts a query param so you can deep-link from your debrief screen.
TOURNAMENTS
Time-windowed leaderboards with token payouts.
Admins create tournaments at /admin/tournaments
- pick a game, a category (or all), a start + end time, a prize in
play tokens. Scores submitted in that window count for the tournament's
leaderboard. When the tournament ends, top-3 winners auto-receive
play tokens (full / half / quarter split).
Your game can surface live tournaments from the SDK so players see "Tournament: Top score wins 10 tokens · ends in 4h" before they start a round.
const tourneys = await window.badgames.activeTournaments(); // Array of { id, name, starts_at, ends_at, status, // category, prize_description, prize_tokens, // leader: { name, score } | null, // you: { rank, score } | null (signed-in only), // players:} // status ∈ "live" | "ended" | "scheduled" for (const t of tourneys.filter(t => t.status === 'live')) { showBanner(`${t.name} · prize ${t.prize_tokens} tokens · ` + `leader: ${t.leader?.name ?? '—'}`); }
Scores automatically count toward whichever live tournament's window + category they fall under. No extra API call needed at submit time.
SESSIONS
Persist the full round, not just the score.
A score is a single number. A session is the rest of the round - the transcript, the move log, the per-turn state, whatever rich data your game wants to keep so you can replay, debug, or analyze later. The platform stores it opaquely; your game renders it however it likes from the GET endpoint.
Use sessions for: replay views, cheat-detection inspection (rounds the game flagged but didn't submit a score for), prompt- iteration analysis, support tickets ("show me what this player saw").
const round = window.badgames.activeRound(); const res = await fetch(`/api/v1/games/${slug}/sessions`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...round, // round_id, started_at, sig, category session_id: crypto.randomUUID(), score_id: window.badgames.lastSubmit()?.id ?? null, category: round.category, outcome: 'comes_down', // your string. Platform doesn't interpret. flagged: false, flag_reason: null, duration_ms: 187234, data: { /* anything you want. 32 KB cap. */ }, }), }); // → 201 { ok:true, id, session_id, created_at }
Auth. Same as
submitScore() - a
valid round tuple from activeRound()
plus the signed-in session cookie. The platform verifies the HMAC,
attributes the row to the user, and writes it to game_sessions.
Top-level fields the platform queries on:
session_id,
category,
outcome,
flagged,
flag_reason,
duration_ms,
score_id.
These are sortable + filterable in the dev portal's Sessions table.
Everything else goes in data.
The platform never reads inside data; it's an
opaque JSON blob. Hard cap 32 KB - anything larger returns 413 so
your game can log the failure instead of silently truncating.
Idempotency. session_id is unique per game.
Retrying the same POST is safe - the second call returns the existing
row with idempotent: true.
Cheat-flagged rounds. When your game
decides not to call submitScore(),
still POST a session with flagged: true
and score_id: null. You can review
them later under "Sessions → Flagged" on the dev portal.
Reading back
// Game owner / admin: list + filter GET /api/v1/games/<slug>/sessions ?limit=50&cursor=&since=&flagged_only=true&category=&outcome=&user_id= // Game owner / admin / the player themselves GET /api/v1/games/<slug>/sessions/<id> // Player's own history across games GET /api/v1/me/sessions?game=<slug>&limit=50 // Admin only DELETE /api/v1/games/<slug>/sessions/<id>
Build whatever bespoke session-detail UI you want on your own subdomain - the portal just shows a generic JSON inspector.
ANALYTICS
Fire-and-forget gameplay events.
Track named events with optional properties. We aggregate them into your game's stats on the dev portal (plays, completion rates, etc.). Never throws or blocks - safe to sprinkle.
window.badgames.track('level_complete', { level: 3, time_s: 47, combo: 12, });
PAID-API PROXIES
Call Anthropic & ElevenLabs through us.
Games that need AI keep their keys on the platform - never in
the browser. Configure your provider keys at
/dev/games/{slug} → "Paid-API credentials",
then call window.badgames.proxyFetch() from your game.
Each call is gated by the active round token + the game's
rolling 24h daily_cap_cents budget.
const r = await window.badgames.proxyFetch( 'anthropic/turn', { system_prompt: 'You are ...', history: [{role:'user', content:'hi'}], max_tokens: 512, } ); // r is the parsed Anthropic /v1/messages JSON // r.usage.input_tokens / r.usage.output_tokens // r.content[0].text
const resp = await window.badgames.proxyFetch( 'elevenlabs/tts', { voice_id: '...', text: 'hello' }, { raw: true } // binary response ); const blob = await resp.blob(); const cached = resp.headers.get('X-Tts-Cached'); // Same (voice_id, text) across players = cache hit
Errors: 403 bad/missing round token ·
429 daily budget exceeded ·
503 game has no credentials configured ·
502 upstream error (Anthropic / ElevenLabs returned non-2xx).
METHOD INDEX
| Method | Returns | What it does |
|---|---|---|
| ready() | Promise<{round,config}> | Resolves after SDK boot (auth + magic-link redeem + config load). |
| config | object | null | Runtime game config: { kind, requires_token, score_scale, categories, paid_apis, version }. |
| version | string | null | Current deployed semver of this game (e.g. "0.0.24"). |
| me({force}) | Promise<{auth,user?}> | Cached /api/me. force:true refetches. |
| getUser() | User | null | Sync getter for the user object. |
| onSignIn(cb) | unsubscribe fn | Fire on auth state changes. |
| openSignIn() | void | Open login page in a new tab. |
| startRound({category?}) | Promise<round> | Issue an anti-cheat round token. Pass category for multi-mode games. |
| redeemToken(code) | Promise<round> | Consume a play token for token-gated games. |
| activeRound() | round | null | The currently-active round, or null. |
| submitScore(s, opts?) | Promise<result> | Submit final score; requires 5s+ round. |
| lastSubmit() | result | null | Most recent submitScore() return ({ ok, id, place }). |
| leaderboard(opts?) | Promise<board> | Read per-game top scores. Pass {category} to filter. |
| activeTournaments() | Promise<Tournament[]> | Live + recently-ended tournaments for this game. |
| saveState(key, val) | Promise<void> | Cloud save (signed-in players only). |
| loadState(key) | Promise<value|null> | Read a previously saved blob. |
| track(event, props?) | Promise<void> | Fire-and-forget analytics event. |
| proxyFetch(path, body) | Promise<json|Response> | Call paid-API proxy (anthropic/turn, elevenlabs/tts). |
| tools[slug][method]() | Promise<json|Response> | Auto-populated subscribed-tool endpoints. |
| slug | string | The game slug this embed is bound to. |
NOTES
-
·
The SDK requires your game to live on a
*.badgames.iosubdomain so the shared session cookie can authenticate. Off-platform games can call the API but every player will be anonymous. -
·
Round tokens are single-use and signed with HMAC. They must be at least 5 seconds old when
submitScore()is called. -
·
Score data (the
datafield) is free-form JSON, capped at 4 KB per submission. Use it for combos, accuracy, build hashes, anything. - · Need a starter you can fork? See the reference game repo (coming soon) for a single-file example using every method on this page.