crux-sdk
v0.6.0
Published
Supercraft Crux Game Services Backend SDK - player auth, cloud-save documents, leaderboards, economy, matchmaking, server registry, and config delivery.
Maintainers
Readme
Supercraft Crux - JavaScript / TypeScript SDK
Fetch-based SDK for Crux (Game Services Backend). Works in Node.js 18+, browsers, and any runtime with a native fetch API. The JavaScript quickstart walks through the same first call.
Installation
npm install crux-sdkCreate a free project and open the Crux JavaScript quickstart.
Works from both ESM (import) and CommonJS (require), Node 18+.
Getting your credentials
Create a project in the Crux dashboard, then copy:
Project ID and Environment ID - UUIDs shown on the Projects and Environments pages.
Publishable key - safe to ship inside a game client. It authenticates players (
/v1/auth/*) and does nothing else: it cannot change project configuration, and it cannot address a player directly. After login, the SDK uses the player's own token.Secret key - the same shape, server-side only. Use it for automation, CI, and curl. Never put it in a build you distribute.
Both are created with the project and shown on the Credentials page. Until 2026-08-12 there was only one kind, and this file said it was safe to embed while it also reached the whole project control plane - anyone who ran
stringson a released build could ban players, delete credentials, and rewrite live config. Used legacy keys are marked for rotation and now have publishable-level authority only. If you shipped one, treat it as exposed: rotate it immediately and replace the client copy with a publishable key.Server token - for trusted backends and dedicated servers. Keep it server-side; never ship it in a client build.
Leaderboard ID or key - either the UUID of a leaderboard you created on the Leaderboards page, or the key you gave it (unique per environment). Both work on runtime leaderboard calls.
Quick start - game client (browser / web game)
import { CruxClient } from "crux-sdk";
const crux = CruxClient.forPlayer(
"https://crux.supercraft.host",
"<PROJECT_ID>", // UUID
"<ENVIRONMENT_ID>", // UUID
"<PUBLISHABLE_KEY>", // safe to ship in a player build
);
// Both ids are checked here: a missing or mistyped one throws a CruxError
// naming the value, rather than 404ing on every call you make later.
// Log in (guest)
const auth = await crux.loginAnonymous();
console.log("Player:", auth.player_id);
// Save data. "" as the player id means the player who just logged in, so the
// id never has to be threaded through your own code.
await crux.setPlayerDocument("", "settings", { volume: 0.8 });
// Submit a score. The first argument is the leaderboard's key (or its UUID).
const LEADERBOARD_ID = "<LEADERBOARD_UUID>";
await crux.submitScore(LEADERBOARD_ID, "", 9900);
// Get leaderboard
const top = await crux.getTop(LEADERBOARD_ID, 10);
top.forEach(e => console.log(`#${e.rank} ${e.player_id}: ${e.score}`));Quick start - server (Node.js backend / game server)
import { CruxClient } from "crux-sdk";
const crux = CruxClient.forServer(
"https://crux.supercraft.host",
"<PROJECT_ID>", // UUID
"<ENVIRONMENT_ID>", // UUID
"<SERVER_TOKEN>", // secret from the Credentials page - keep server-side
);
// Admit a player into a realtime server using the access token the player
// received from login. Crux validates expiry/revocation/bans online and returns
// the trusted identity; the SDK also rejects tokens from another project.
const identity = await crux.verifyPlayerToken(playerAccessToken);
console.log(identity.player_id);
// Register
const server = await crux.registerServer({
server_id: "node-01",
name: "Deathmatch EU #1",
region: "eu-west",
map_name: "forest",
game_mode: "deathmatch",
player_count: 0,
ip_address: "198.51.100.1",
port: 7777,
server_version: "1.0.0",
});
// Those are all the fields the registry stores. There is no capacity field -
// keep max players in a project document if your browser needs it.
// Heartbeat (call every 30 s)
setInterval(() => crux.heartbeat(server.server_id), 30_000);
// Clean deregister on shutdown
process.on("SIGTERM", async () => {
await crux.deregisterServer(server.server_id);
process.exit(0);
});API reference
Every playerId below may be "", which means the player from the last login.
Pass one explicitly to act for a different player, which is what a dedicated
server does. With neither, the call throws a CruxError saying what is missing
and makes no request.
Static factories
| Call | Description |
|------|-------------|
| CruxClient.forServer(url, projectId, envId, serverToken) | Server-side mode. Throws CruxError on a missing or malformed id or token |
| CruxClient.forPlayer(url, projectId, envId, apiKey) | Client-side mode. Throws CruxError on a missing or malformed id or key |
| CruxClient.forApiKey(url, projectId, envId, secretApiKey) | Trusted backend/automation mode with a secret API key; works on trusted runtime and project/KV routes. Never ship this credential in a player build |
Auth
| Method | Description |
|--------|-------------|
| loginAnonymous(anonymousId?) | Guest login. The device id is persisted in localStorage and reused, so the player returns to the same account - and the same saves - on the next launch. Pass your own id if your game already has a stable device identifier. |
| CruxClient.clearAnonymousId() | Forget the stored guest id ("reset progress"). |
| loginEmail(email, password) | Email login |
| registerEmail(email, password) | Email registration |
| loginSteam(ticketHex) | Verify a short-lived Steam GetAuthTicketForWebApi ticket through Crux and start/recover the linked player session. The Steam publisher key stays server-side in the Crux project config. |
| linkSteam(ticketHex) | Link Steam to the currently logged-in Crux player. Requires a player session; use this after guest/email onboarding to avoid creating a second account. |
| refreshAccessToken() | Refresh access token |
| verifyPlayerToken(token) | Server-mode only: validate a player access token online via /v1/auth/me and recover its trusted project/player identity. |
| logout() | Revoke the session |
Player Documents
| Method | Description |
|--------|-------------|
| getPlayerDocument<T>(playerId, key) | Get a typed document, or null when the key is missing |
| setPlayerDocument<T>(playerId, key, value, version?) | Write a document |
| deletePlayerDocument(playerId, key) | Delete a document |
| batchGetPlayerDocuments<T>(playerId, keys[]) | Fetch multiple keys |
| patchPlayerDocument<T>(playerId, key, operations[], version?) | Apply JSON-patch style operations to one document |
| batchWritePlayerDocuments<T>(playerId, writes[]) | Write multiple keys atomically and return the written documents with authoritative versions |
Shared Spaces
Persistent client-safe state for a known set of players: parties, guilds, co-op saves, private rooms, and similar group state. A player-created space is owned by that player; a server-created space remains server-managed. Existing player/project document semantics are unchanged.
| Method | Description |
|--------|-------------|
| createSharedSpace(metadata?) | Create a player-owned or server-owned space, depending on client mode |
| listSharedSpaces() | Player memberships, or all environment spaces in server mode |
| getSharedSpace(spaceId) | Get one visible space, or null |
| deleteSharedSpace(spaceId) | Delete an owned/server-managed space |
| listSharedSpaceMembers(spaceId) | List members |
| setSharedSpaceMember(spaceId, playerId, canWrite?) | Add/update membership |
| removeSharedSpaceMember(spaceId, playerId) | Remove a member or leave yourself |
| listSharedDocumentKeys(spaceId) | List shared document metadata |
| getSharedDocument<T>(spaceId, key) | Read shared state |
| setSharedDocument<T>(spaceId, key, value, version?) | Versioned write |
| patchSharedDocument<T>(spaceId, key, operations, version?) | Patch shared state |
| deleteSharedDocument(spaceId, key) | Delete shared state |
| waitSharedDocument<T>(spaceId, key, afterVersion, timeoutSeconds?) | Long-poll; returns the monotonic cursor even after deletion |
Project Documents
Shared by every player in the environment - server capacity, event flags, drop tables, seasonal switches. Reading needs any authenticated caller; writing needs a secret API key or a server token, so a game client cannot rewrite the rules it is handed.
| Method | Description |
|--------|-------------|
| listProjectDocumentKeys() | Keys with size and version, no bodies |
| getProjectDocument<T>(key) | Get a shared document, or null when the key is missing |
| setProjectDocument<T>(key, value, version?) | Write one |
| deleteProjectDocument(key) | Delete one |
| batchGetProjectDocuments<T>(keys[]) | Fetch multiple keys |
| batchWriteProjectDocuments<T>(writes[]) | Write multiple keys atomically |
If you think in KV rather than documents, the SDK exposes exact aliases over the same storage: kvListKeys(), kvGet(key), kvSet(key, value, version?), kvDelete(key), kvMGet(keys) and kvMSet(writes). There is no duplicate store or migration step.
Stats
Named 64-bit counters the backend owns, so progression is queryable instead of buried in a save blob. Writing a stat settles every achievement bound to it in the same transaction - the write response tells you what was earned, exactly once, so a reward can never pay twice.
| Method | Description |
|--------|-------------|
| listPlayerStats(playerId) | Every stat for a player |
| getPlayerStat(playerId, key) | One stat |
| setPlayerStat(playerId, key, value) | Set to an absolute value (server token) |
| incrementPlayerStat(playerId, key, delta) | Add a delta, negative to subtract (server token) |
Both writes return { stat, unlocked }, where unlocked is the achievements that crossed their threshold on this call.
Stat writes and direct unlocks require a server token. Reading takes any runtime credential, but a player token gets 403 server token required on a write - progression is server-authoritative, so a modified client cannot award itself anything. Call these from your dedicated server or a trusted backend, not from the game client.
Achievements
| Method | Description |
|--------|-------------|
| listAchievements() | The catalogue defined for this environment |
| listPlayerAchievements(playerId) | What this player has unlocked |
| unlockAchievement(playerId, key) | Grant one directly (server token) |
unlockAchievement is idempotent: unlocked is true on the call that actually granted it and false on every repeat, so it is safe to call from a retrying client.
Leaderboards
| Method | Description |
|--------|-------------|
| submitScore(leaderboardId, playerId, score, metadata?) | Submit a score |
| getTop(leaderboardId, limit) | Top N entries |
| getPlayerStanding(leaderboardId, playerId) | Player's rank + score (null if unranked) |
| getAroundPlayer(leaderboardId, playerId, radius) | Neighbours in the ranking |
leaderboardIdaccepts either the leaderboard's UUID (returned by create / shown in the dashboard) or its key, e.g."weekly". A UUID-shaped value is resolved as an id first.
Economy
| Method | Description |
|--------|-------------|
| getPlayerEconomy(playerId) | Balances + inventory |
| adjustEconomy(playerId, balances?, inventory?) | Atomic credit/deduct (server token) |
Social
| Method | Description |
|--------|-------------|
| listFriends(playerId) | Accepted friendships |
| listPendingFriendRequests(playerId) | Incoming requests |
| sendFriendRequest(playerId, friendId) | Send a request |
| acceptFriendRequest(playerId, friendId) | Accept one |
| removeFriend(playerId, friendId) | Remove a friendship |
| blockPlayer(playerId, targetId) | Block |
| unblockPlayer(playerId, targetId) | Unblock |
Matchmaking
| Method | Description |
|--------|-------------|
| joinMatchmaking(playerId, gameMode, region) | Join a queue |
| getMatchmakingStatus() | Poll for result |
| leaveMatchmaking(playerId) | Leave the queue |
| startMatch(matchId) | Mark a formed match as in-progress (server token) |
| completeMatch(matchId) | Mark a match finished, releasing its slots (server token) |
A formed match carries the live server it was placed on, drawn from your own registry - matchmaking only picks a server that is currently heartbeating, so a crashed host is never handed out as a destination. Call startMatch when the session begins and completeMatch when it ends; a match nobody completes is swept to expired rather than pinning that server's capacity forever.
Server Registry (server token required)
| Method | Description |
|--------|-------------|
| registerServer(registration) | Register this instance |
| heartbeat(serverId) | Send a keepalive |
| deregisterServer(serverId) | Remove on shutdown |
| listServers({region?, mapName?, gameMode?}) | Browse servers |
Config
| Method | Description |
|--------|-------------|
| downloadActiveConfigBundle() | Returns Promise<ArrayBuffer> |
Error handling
Missing player and project documents resolve to null. Other HTTP errors throw
CruxError:
import { CruxClient, CruxError } from "crux-sdk";
try {
const doc = await crux.getPlayerDocument(playerId, "inventory");
if (!doc) {
console.log("First run: use the default inventory");
}
} catch (e) {
if (e instanceof CruxError) {
console.error(`HTTP ${e.statusCode}:`, e.message);
}
}