Official JavaScript / TypeScript SDK for Scorezilla — focused leaderboard infrastructure for indie games, browser games, and AI-vibe-coded games.
- Tiny. ~4 KB gzipped. No runtime dependencies.
- Universal. Browser, Node ≥ 20, Cloudflare Workers, Bun, Deno.
- Typed. First-class TypeScript with strict types and rich JSDoc.
- Safe-by-default. Automatic retries on transient failures with idempotency
keys; per-request timeouts; cancellation via
AbortSignal. - Private. No cookies, no
localStorage, no fingerprinting beyond runtime detection — see COMPATIBILITY.md.
Status: v0.1.0 ships the public-key client (browser-safe). v0.2.0 ships the HMAC server adapter (
scorezilla/server) for game backends that need cheat-resistant submissions. React (scorezilla/react) lands in v0.3.0; Phaser (scorezilla/phaser) in v0.4.0. The first preview is on thenextdist-tag — install withnpm install scorezilla@next. See CHANGELOG.md and VERSIONING.md.
Commercial context. Scorezilla is a hosted leaderboard service with free and paid tiers — see scorezilla.dev/pricing. This SDK is MIT-licensed and works identically across every plan; get your API key from the operator dashboard.
npm install scorezilla
# or
pnpm add scorezilla
# or
yarn add scorezilla
# or
bun add scorezillaThe Quickstart below needs two values: a publicKey and a boardId. Both come from the dashboard:
- Sign in at dashboard.scorezilla.dev (magic-link email — no password).
- Open the Tutorial Game that's created for you on first sign-in (or create a new game).
- Open Keys → Issue public key. Copy the
pk_*string (shown once — also visible in the keys list afterwards). - Open Boards → New board (or use the auto-created "High Scores"). Copy the
boardIdUUID.
You're now ready for the Quickstart below. Public keys are safe to ship in client code — they only authorize submits, not key rotation or board admin.
Using AI to scaffold? Skip the manual steps: install the Scorezilla MCP server in Claude Code / Cursor and run
bootstrap_leaderboard— the AI gets your keys + board id + a ready-to-paste integration snippet in one tool call.
import { Scorezilla, ScorezillaError } from 'scorezilla';
const sz = new Scorezilla({ publicKey: 'pk_mygame_aBcDeF…' });
try {
const r = await sz.submitScore({
boardId: 'board-uuid',
playerId: 'player-uuid',
score: 9001,
metadata: { level: 'hard' },
});
if (r.isPersonalBest) {
console.log(`🏆 New personal best! Rank ${r.rank} of ${r.totalEntries}`);
}
} catch (e) {
if (e instanceof ScorezillaError && e.isRateLimited()) {
console.warn(`Rate-limited. Retry after ${e.retryAfter}s.`);
} else throw e;
}The four public methods:
await sz.submitScore({ boardId, playerId, score, metadata? });
await sz.getLeaderboard({ boardId, top?, offset? });
await sz.getPlayerRank({ boardId, playerId });
await sz.getWindowAround({ boardId, playerId, before?, after? });See API.md for the full reference, including every response field, every error code, and advanced patterns.
For embedded games that want the same API on every host and never want a
failed call to throw, wrap the client in the headless facade. Failures collapse
to null (submit) or [] (leaderboard), so a dropped network call can't crash
a game loop:
import { createHeadlessClient, isCrossOrigin } from 'scorezilla/headless';
const sz = createHeadlessClient({ publicKey: 'pk_mygame_…' });
// Never throws — `null` means "didn't land", any result means it did.
const result = await sz.submit({ boardId, playerId, score: 4200, name: 'Ada' });
if (result) console.log(`rank ${result.rank} of ${result.totalEntries}`);
// Never throws — `[]` on any failure. Each entry has at least
// { rank, playerId, name?, score }.
const top = await sz.getLeaderboard({ boardId, top: 10 });When a game is embedded cross-site (itch.io, a portal) and the board has
Turnstile gating on, pass a turnstileToken your host obtains (e.g. via a
hidden broker iframe on a trusted origin). Use isCrossOrigin(homeOrigin) to
decide whether that path is needed; same-origin submits need nothing extra.
Browser-side helpers that produce the playerId you pass to submitScore:
import {
useAnonymousPlayer, // mints a UUID, persists in localStorage — no prompt
usePromptedPlayer, // asks once for a nickname, saves it, silent thereafter
useAuthProvider, // OAuth sign-in (Google + GitHub)
} from 'scorezilla/identity';
const player = await useAuthProvider({
provider: 'google',
clientId: 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com',
storageKey: 'mygame:player',
});
if (player) await sz.submitScore({ boardId, playerId: player.id, score: 9001 });GitHub needs one extra ingredient: GitHub's token exchange requires your
OAuth app's client secret, so you deploy a one-line exchange endpoint
(createGitHubOAuthHandler from scorezilla/server) and point the client at
it:
// Client — opens a GitHub sign-in popup; resolves github:<id> (or null on decline).
const player = await useAuthProvider({
provider: 'github',
clientId: 'YOUR_GITHUB_OAUTH_CLIENT_ID',
exchangeUrl: '/api/github-oauth',
storageKey: 'mygame:player',
});
// Server — deploy at /api/github-oauth and register that URL as the OAuth
// app's callback URL on GitHub. The secret + access token never reach the browser.
import { createGitHubOAuthHandler } from 'scorezilla/server';
export const GET = createGitHubOAuthHandler({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
allowedOrigin: 'https://mygame.example', // your game page's origin
});Trust boundary — all of these are client-authoritative.
useAuthProviderproves the player's identity to the browser, not to the leaderboard: the derived id is computed client-side and submitted with the public key, so it is exactly as forgeable as any other public-key write. OAuth identity is sign-in convenience for casual/vanity boards — not anti-forgery. For competitive or ranking-sensitive boards, submit through the secure path below (createScoreSubmitHandler+ a server-verified identity); see RECIPES.md for the combined "OAuth identity + secure submission" recipe.
Public-key submissions are client-authoritative — anyone with your pk_ can
submit any score from devtools. For games where ranking matters, sign each
submission server-side with a sk_live_* secret:
import { Scorezilla, ScorezillaError } from 'scorezilla/server';
// Single self-contained token from the dashboard. Format:
// sk_live_<keyId>_<random>
// The SDK parses the keyId out internally; you only manage one value.
const sz = new Scorezilla({
secretKey: process.env.SCOREZILLA_SECRET_KEY!, // never ship to a browser
});
await sz.submitScore({ boardId, playerId, score, metadata });Same method shape as the public-key client — submitScore, getLeaderboard,
getPlayerRank, getWindowAround. The adapter signs every request with
HMAC-SHA256 over a canonical string (method + path + ts + nonce +
sha256(body)), and the API verifies before any state change. Replay
protection is enforced server-side via a 10-minute nonce window.
The scorezilla/server subpath is server-only — importing it from the
browser throws at module evaluation. Use environment variables (or your
secret manager) to load the sk_live_* value; never embed it in a build
that ships to clients.
Wiring the secure path by hand means verifying your auth, deriving the player
id from it, validating the body, signing, and mapping errors.
createScoreSubmitHandler does all of that — you supply only your auth. It
returns a standard (Request) => Promise<Response>, so it drops into a
Cloudflare Worker, a Next.js route handler, Hono, Deno, or Bun.
import { createScoreSubmitHandler } from 'scorezilla/server';
export const POST = createScoreSubmitHandler({
secretKey: process.env.SCOREZILLA_SECRET_KEY!,
boardId: process.env.SCOREZILLA_BOARD_ID!,
// The one app-specific bit: prove identity, return the TRUSTED playerId.
// The submitted playerId comes from here — never from the request body.
verify: async (req) => {
const user = await myAuth(req);
return user ? { playerId: user.id } : null;
},
cors: { origin: 'https://mygame.example' }, // omit for same-origin
});Your client just POSTs { score, metadata? } (plus its auth header) to the
endpoint; the handler signs and forwards it.
Supabase? One line. Built-in verifiers do the JWKS verification for you.
(jose is an optional peer dependency, loaded only when you use a built-in
verifier — npm i jose.)
import { createScoreSubmitHandler, verifySupabaseJwt } from 'scorezilla/server';
export const POST = createScoreSubmitHandler({
secretKey: process.env.SCOREZILLA_SECRET_KEY!,
boardId: process.env.SCOREZILLA_BOARD_ID!,
verify: verifySupabaseJwt({ supabaseUrl: process.env.SUPABASE_URL! }),
});Clerk, Auth0, and Firebase have presets too:
import { verifyClerkJwt, verifyAuth0Jwt, verifyFirebaseIdToken } from 'scorezilla/server';
verify: verifyClerkJwt({ issuer: 'https://clerk.your-app.com' });
verify: verifyAuth0Jwt({ domain: 'you.us.auth0.com', audience: 'your-api' });
verify: verifyFirebaseIdToken({ projectId: 'your-firebase-project' });Any other JWKS provider uses the generic verifyJwt:
import { verifyJwt } from 'scorezilla/server';
verify: verifyJwt({
jwksUrl: 'https://your-issuer/.well-known/jwks.json',
issuer: 'https://your-issuer',
audience: 'your-api', // claim: 'sub' (default) → playerId
});Anything else — the verify callback is the universal seam. For
Auth.js/NextAuth (encrypted JWE sessions), Better Auth, opaque session
cookies, or a provider backend SDK, anything that returns { playerId }
works:
verify: async (req) => {
const session = await validateSession(req); // your DB / SDK
return session ? { playerId: session.userId } : null;
};Worked recipes for each of those live in RECIPES.md.
Every failure path — HTTP non-2xx, network error, timeout, abort, JSON parse
error — throws a single ScorezillaError. Branch on code
(machine-stable), never on message (English-only, may change without a major
bump):
import { ScorezillaError } from 'scorezilla';
try {
await sz.submitScore({ boardId, playerId, score });
} catch (e) {
if (!(e instanceof ScorezillaError)) throw e;
if (e.isRateLimited()) await sleep((e.retryAfter ?? 30) * 1000);
else if (e.isAuth()) throw new Error('SDK misconfigured');
else if (e.code === 'out_of_bounds')
console.warn(`Score crosses ${e.reason} bound (limit ${e.bound})`);
else if (e.isTransient()) /* automatic retries already exhausted */ throw e;
else throw e;
}The error class carries the request ID, status, and the underlying cause for support tickets:
console.error(`Scorezilla ${eScan report · 2026-09-16
- ✓ Prohibited terms or links
- ✓ Repository eligibility
- ✓ slopscore.md paperwork
- ✓ Content policy
- ✓ Risk review
report this listing
— log in to report
0 comments
log in to comment.