scorezilla
v0.5.2
Published
Official Scorezilla SDK — leaderboards for indie games, browser games, and AI-vibe-coded games.
Downloads
740
Maintainers
Readme
scorezilla
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.
Install
npm install scorezilla
# or
pnpm add scorezilla
# or
yarn add scorezilla
# or
bun add scorezillaGet your keys
The 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.
Quickstart
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.
Headless, never-throws client (scorezilla/headless)
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.
Player identity (scorezilla/identity)
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.
Server-side HMAC (scorezilla/server)
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.
Turnkey endpoint: createScoreSubmitHandler
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.
Error handling
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 ${e.code} (${e.status}) — req ${e.requestId}`);Runtime support
| Runtime | Status | Notes |
| ---------------------- | ----------------------------- | ------------------------------------------------------- |
| Node | ≥ 20 | Hard requirement. Native fetch + crypto.randomUUID. |
| Browsers | All evergreen | Chrome 92+, Firefox 95+, Safari 15.4+, Edge 92+. |
| Cloudflare Workers | ✅ | Detected via navigator.userAgent. |
| Bun | ≥ 1.0 (best-effort in v0.1.0) | Promoted to hard gate in v0.2.0 if stable. |
| Deno | ≥ 1.40 | Native fetch + Web Crypto. |
| React Native | unverified | Requires react-native-get-random-values polyfill. |
See COMPATIBILITY.md for the detailed matrix, the
exactOptionalPropertyTypes workaround, and the privacy invariants.
CDN usage
For zero-build prototyping, import from jsDelivr. Replace <VERSION> with the
exact release you want (see the releases page):
<script type="module">
import { Scorezilla } from 'https://cdn.jsdelivr.net/npm/scorezilla@<VERSION>/dist/index.js';
const sz = new Scorezilla({ publicKey: 'pk_…' });
// …
</script>For production, pair the version pin with Subresource Integrity — the SHA-384 hash for each release ships in the GitHub release notes:
<script
type="module"
src="https://cdn.jsdelivr.net/npm/scorezilla@<VERSION>/dist/index.js"
integrity="sha384-<copy-from-release-notes>"
crossorigin="anonymous"
></script>A complete vanilla example lives at
examples/vanilla/
and a Node CLI demo at
examples/node-cli/.
(The examples are in the source repo only — not in the npm tarball.)
Custom fetch / polyfills
Pass your own fetch for environments where the global is missing or you want
to mock for tests:
import nodeFetch from 'node-fetch';
const sz = new Scorezilla({ publicKey, fetch: nodeFetch });The signature
(input: RequestInfo | URL, init?: RequestInit) => Promise<Response> is
intentionally broader than typeof fetch so node-fetch, undici, vi.fn(),
and jest.fn() all typecheck cleanly.
Per-request timeout and retries
const sz = new Scorezilla({
publicKey,
timeoutMs: 5_000, // default 30_000
maxRetries: 1, // default 2 (retries 5xx / 429 / network)
});Retries automatically reuse the same Idempotency-Key across attempts, so
server-side dedup (when added) is safe by default.
Versioning
Strict SemVer from v0.1.0 onward. The machine-stable surface is code strings
on ScorezillaError, response field names, method signatures, and config
fields. The human-readable message is not part of the contract.
See VERSIONING.md for the full breaking-change rules, deprecation policy, and 0.x → 1.0 exit criteria.
Contributing
Issues and PRs welcome. Local development:
pnpm install
pnpm typecheck
pnpm test # all projects
pnpm test:unit # unit only (fast)
pnpm test:coverage # unit + coverage gate
pnpm build # tsup → dist/
pnpm check:types-resolution # attw — exports map validation
pnpm size # size-limit (6 KB gzip ceiling)Add a release note with pnpm changeset — see .changeset/README.md for the workflow.
