DOCS · SDK

The badgames.io SDK

One script tag wires your game into the platform - sign-in, cross-game leaderboards, anti-cheat round tokens, token-gated play, cloud save, and analytics. Same surface from JavaScript, Godot, Unity, Bevy, or any Emscripten target.

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.

Start a round
// 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(); });
Submit a score
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.

window.badgames.me()
const me = await window.badgames.me();
// {
//   authenticated: true,
//   user: { id, name, avatar }
// }
//
// or { authenticated: false } for guests
window.badgames.onSignIn(cb)
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.

window.badgames.redeemToken(code)
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.

window.badgames.leaderboard()
const board = await window.badgames.leaderboard({
  limit: 25,
  window: 'week'   // 'all' | 'week' | 'month'
});
showLeaderboard() · hideLeaderboard()
// 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).

saveState · loadState
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.

1. badgames.json
{
  "categories": [
    { "key": "rock",    "label": "Rock" },
    { "key": "golf",    "label": "Golf" },
    { "key": "frisbee", "label": "Frisbee" }
  ]
}
2. Game code
// 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.

window.badgames.activeTournaments()
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").

POST /api/v1/games/<slug>/sessions
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(event, props?)
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.

Anthropic turn
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
ElevenLabs TTS
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.io subdomain 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 data field) 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.