Crux SDK Quickstart
Pick curl or your engine SDK. Sign in a guest player, load and save state, and submit a leaderboard score, from inside your actual game build. Every call on this page matches the real, current Crux API. Add email, Steam or OAuth sign-in afterwards, on the same player_id: a guest identity is the recommended starting point, not the demo one.
Before you start (2 minutes)
- Sign up at crux.supercraft.host/signup/: a project and a
devenvironment are created for you automatically. - Open your project dashboard and copy your project ID, environment ID, and publishable API key. The publishable key is safe for player clients; never substitute the secret API key in client code.
- Use
Authorization: ApiKey YOUR_PUBLISHABLE_KEYonly to log the player in. Follow-on player calls use the returnedBearertoken. Server-side administration uses a separate secret key.
๐ Coming from PlayFab
The hard part of leaving PlayFab is rarely the backend. It is the thousand call
sites. crux-playfab-compat keeps them. It takes PlayFab’s request
shapes, returns PlayFab’s { code, status, data } envelope, and works
with callbacks or promises, so the diff is which client you construct.
npm install crux-playfab-compat
const PlayFabClientAPI = new PlayFabClientCompat(
CruxClient.forPlayer(baseUrl, projectId, environmentId, apiKey),
);
// unchanged from your PlayFab codebase
PlayFabClientAPI.GetUserData({ Keys: ["save"] }, (res, err) => { /* ... */ }); Available for JavaScript/TypeScript, Unity (C#) and Godot (GDScript). It covers the calls an ordinary title makes every session: login, user data, title data, leaderboards, statistics, inventory, virtual currency and friends.
Anything outside that set fails explicitly instead of quietly doing
nothing: JavaScript and C# throw an unsupported-method error; GDScript returns
a non-success error envelope. That is deliberate: a stub that looks like it worked is
worse than a failure, and on a currency call it is somebody’s money.
ExecuteCloudScript is not supported. Crux does not run customer code, so
that logic moves behind your own endpoint.
Full method-by-method coverage, the methods with no Crux equivalent, and the three behaviours that differ on purpose are in the migration guide. If you are still deciding whether Crux fits the title, start with the PlayFab alternative landing page instead.
๐ curl - your first authenticated call
The single call that proves your key works. It mints a guest player and
returns a player_id plus a player token. Works from any HTTP
client, CI, or a C++ dedicated server.
# First authenticated call - mint a guest player. Proves your API key works.
curl -X POST https://crux.supercraft.host/v1/auth/anonymous \
-H "Authorization: ApiKey YOUR_PUBLISHABLE_KEY" \
-H "Content-Type: application/json" \
-d '{"anonymous_id":"device-abc-123","display_name":"Ada"}'
# 200 OK
# {
# "player_id": "7f3c1e2a-...",
# "access_token": "eyJhbGciOi...", # a player JWT (Bearer)
# "token_type": "Bearer",
# "expires_in": 86400,
# "refresh_token": "...",
# "refresh_token_expires_in": 2592000
# } Now the three calls every game needs - a saved document and a leaderboard. These use the returned player token against the real nested routes:
# Values you copy once from your project dashboard:
CRUX=https://crux.supercraft.host
PROJECT=YOUR_PROJECT_ID # project ID
ENV=YOUR_ENV_ID # environment ID (a "dev" env is created on signup)
TOKEN=PLAYER_ACCESS_TOKEN # access_token returned by /v1/auth/anonymous
PLAYER=7f3c1e2a-... # the player_id returned by /v1/auth/anonymous
BOARD=YOUR_LEADERBOARD_ID # create a leaderboard in the dashboard, copy its ID
# 1. Save a player document (JSON whose shape you own). "save" is the document key.
curl -X PUT "$CRUX/v1/projects/$PROJECT/environments/$ENV/players/$PLAYER/documents/save" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"value":{"level":4,"coins":250,"unlocks":["sword","shield"]}}'
# 1b. Read it back with GET on the same URL.
# Reading a key you have never written returns 404, and that is the correct
# answer, not a broken token - a bad credential is 401 or 403. Treat it as
# "no save yet" and fall back to your defaults. The JS SDK already does this
# for you: getPlayerDocument() returns null instead of throwing.
curl "$CRUX/v1/projects/$PROJECT/environments/$ENV/players/$PLAYER/documents/save" \
-H "Authorization: Bearer $TOKEN"
# 2. WAIT for the next change instead of polling for it.
# This is the call that makes Crux multiplayer rather than a key-value store.
# It blocks until the document is newer than after_version, then returns it.
# A key nobody has written yet is version 0, so after_version=0 waits for the
# FIRST write rather than 404ing - point it at a key your other client is
# about to create and it simply arrives.
#
# Every response carries X-Crux-Document-Version, including the 204 you get
# when nothing happened before the timeout. Feed that straight back into
# after_version and loop: you never miss a write and you never poll.
curl -i "$CRUX/v1/projects/$PROJECT/environments/$ENV/players/$PLAYER/documents/save/wait?after_version=1&timeout=25" \
-H "Authorization: Bearer $TOKEN"
# 200 + the document when it changed; 204 when it did not. Loop either way.
# 3. Say who is here, and find out who else is.
# Presence is a lease, not a document: it expires unless renewed, so a player
# who crashes or closes the lid disappears on their own. Send this on a timer
# - join and heartbeat are the same call, so a client that missed a beat just
# rejoins instead of needing any "am I still in?" bookkeeping.
curl -X PUT "$CRUX/v1/projects/$PROJECT/environments/$ENV/players/$PLAYER/presence/lobby" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"ttl_seconds":45,"meta":{"name":"Astrid","team":"red"}}'
# Read it with the player's own token as here, or server-side with a server
# token or secret key. The publishable key cannot read a roster: it ships
# inside your game, and this is other players' data.
curl "$CRUX/v1/projects/$PROJECT/environments/$ENV/presence/lobby" \
-H "Authorization: Bearer $TOKEN"
# {"channel":"lobby","count":2,"has_more":false,"members":[{"player_id":"...","meta":{...}}, ...]}
# 4. Submit a leaderboard score
curl -X POST "$CRUX/v1/projects/$PROJECT/environments/$ENV/leaderboards/$BOARD/scores" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"player_id":"7f3c1e2a-...","score":12750}'
# 5. Read the top of the board
curl "$CRUX/v1/projects/$PROJECT/environments/$ENV/leaderboards/$BOARD/top?limit=10" \
-H "Authorization: Bearer $TOKEN"
# [ {"player_id":"7f3c1e2a-...","score":12750,"rank":1}, ... ] ๐ฆ JavaScript / TypeScript - crux-sdk
Fetch-based, zero dependencies. Works in Node 18+, browsers, Electron,
and Cloudflare Workers. Install: npm install crux-sdk · source on GitHub
import { CruxClient } from "crux-sdk";
// Copy projectId, environmentId, and a PUBLISHABLE key from your
// dashboard. A "dev" environment is created for you on signup.
const gsb = CruxClient.forPlayer(
"https://crux.supercraft.host",
"YOUR_PROJECT_ID",
"YOUR_ENV_ID",
"YOUR_PUBLISHABLE_KEY",
);
// 1. Sign in a guest player: no email, no password, no login UI. The device
// id is stored locally, so the same player returns on the next launch.
const auth = await gsb.loginAnonymous();
console.log("player:", auth.player_id);
// 2. Load the save. A brand-new player does not have one yet, and
// getPlayerDocument returns null for that. Missing is the normal
// first-run answer, not a failure: seed the starting state and carry on.
const save = await gsb.getPlayerDocument(auth.player_id, "save");
const state = save ? save.value : { level: 1, coins: 0 };
console.log("loaded:", state);
// 3. Save player data - any JSON shape you like
await gsb.setPlayerDocument(auth.player_id, "save", { level: 4, coins: 250 });
// 4. Submit and read a leaderboard. leaderboardId is the board's ID
// (create a board in the dashboard and copy its ID).
await gsb.submitScore("YOUR_LEADERBOARD_ID", auth.player_id, 12750);
const top = await gsb.getTop("YOUR_LEADERBOARD_ID", 10);
top.forEach(e => console.log("#" + e.rank + " " + e.player_id + ": " + e.score));
// 5. React to changes instead of asking for them on a timer. This blocks
// until the document is newer than your cursor, then hands it back with
// the next cursor. Loop on it; never poll a key in a tight loop.
let cursor = save ? save.version : 0;
const change = await gsb.waitPlayerDocument(auth.player_id, "save", cursor);
cursor = change.version;
// 6. Only once the game works: give this same guest an email login, keeping
// player_id and everything saved against it.
// Not registerEmail() - that makes a SECOND, empty player.
await gsb.upgradeGuestEmail("player@example.com", "a-strong-password"); ๐ฎ Steam ticket authentication
Steam is not OAuth. Configure the Steam App ID, publisher Web API key and ticket identity in Credentials. Your client creates a GetAuthTicketForWebApi ticket with that exact identity; Crux verifies it with Valve's publisher-only API and returns the same normal Crux player token pair used by every other login method.
Never ship the publisher Web API key. It is stored write-only in the Crux project configuration and used only from Crux servers. The player build contains the publishable Crux key and the short-lived Steam ticket.
// One-time dashboard setup (Credentials):
// Steam App ID + publisher Web API key + ticket identity (example: "crux")
// The publisher key NEVER belongs in the game build.
// In the Steam client, obtain a Web API ticket with your Steam integration:
// ticketBytes = GetAuthTicketForWebApi("crux")
// Convert the binary ticket to hex, then hand only that ticket to Crux.
const ticketHex = toHex(ticketBytes);
const crux = CruxClient.forPlayer(
"https://crux.supercraft.host",
"YOUR_PROJECT_ID",
"YOUR_ENV_ID",
"YOUR_PUBLISHABLE_KEY",
);
const auth = await crux.loginSteam(ticketHex);
console.log(auth.player_id); // stable Crux player mapped to verified SteamID64
// If this player already signed in as guest/email/OAuth, preserve that account:
// await crux.linkSteam(ticketHex);
// Raw HTTP equivalent:
// POST /v1/auth/steam Authorization: ApiKey YOUR_PUBLISHABLE_KEY
// POST /v1/auth/steam/link Authorization: Bearer PLAYER_ACCESS_TOKEN
// body: { "ticket": "HEX_FROM_GETAUTHTICKETFORWEBAPI" } The SDK helper names are loginSteam / linkSteam in JavaScript, LoginSteamAsync / LinkSteamAsync in Unity, login_steam / link_steam in Godot, and LoginSteam / LinkSteam in Unreal.
๐ค Godot 4 (GDScript)
Drop the addons/crux autoload into your project. Same API,
idiomatic GDScript. Great for migrating a leaderboard off a shuttered
Godot backend: see the
SilentWolf migration guide.
# Copy the addon into res://addons/crux and autoload it
# (Project -> Project Settings -> Autoload) as "Crux".
Crux.init_player("https://crux.supercraft.host",
"YOUR_PROJECT_ID", "YOUR_ENV_ID", "YOUR_PUBLISHABLE_KEY")
# 1. Sign in a guest player: no email, no password, no login UI. The device
# id is stored locally, so the same player returns on the next launch.
var auth = await Crux.login_anonymous()
print("player: ", auth.player_id)
# 2. Load the save. A brand-new player does not have one yet, and "no save"
# is the normal first-run answer, not a failure. The _or_null variant
# reports that as null instead of an error in your console.
var save = await Crux.get_player_document_or_null(auth.player_id, "save")
var state = save["value"] if save != null else { "level": 1, "coins": 0 }
print("loaded: ", state)
# 3. Save player data
await Crux.set_player_document(auth.player_id, "save", { "level": 4, "coins": 250 })
# 4. Submit a leaderboard score (leaderboard_id is the board's ID from the dashboard)
await Crux.submit_score("YOUR_LEADERBOARD_ID", auth.player_id, 12750.0)
# 5. React to changes instead of asking for them on a timer. This blocks
# until the document is newer than your cursor, then hands it back with
# the next cursor. Loop on it; never poll a key in a tight loop.
var cursor: int = save["version"] if save != null else 0
var change = await Crux.wait_player_document(auth.player_id, "save", cursor)
cursor = change["version"]
# 6. Only once the game works: give this same guest an email login, keeping
# player_id and everything saved against it.
# Not register_email() - that makes a SECOND, empty player.
await Crux.upgrade_guest_email("player@example.com", "a-strong-password") Want a complete project instead of snippets? Relay Zero is an open-source Godot 4 co-op demo wired to Crux end to end: an authoritative headless server that verifies player tokens, versioned live-config bundles with a last-known-good fallback, and server-owned rewards and leaderboards, with Docker deploy files included.
Want the product path rather than just SDK syntax? See Crux for Godot for the backend + Runtime split and the shortest evaluation flow.
๐ฏ Unity (C#)
UPM package under Supercraft.Crux. async/await
throughout; works in the editor, standalone, dedicated server builds, and WebGL.
using Supercraft.Crux;
// Copy projectId, environmentId, and a PUBLISHABLE key from your dashboard.
var gsb = ServerToolkitClient.ForPlayer(
"https://crux.supercraft.host", "YOUR_PROJECT_ID", "YOUR_ENV_ID", "YOUR_PUBLISHABLE_KEY");
// 1. Sign in a guest player: no email, no password, no login UI. The device
// id is stored locally, so the same player returns on the next launch.
var auth = await gsb.LoginAnonymousAsync();
Debug.Log($"player: {auth.player_id}");
// 2. Load the save. A brand-new player does not have one yet, and "no save"
// is the normal first-run answer, not a failure. The OrNull variant
// reports that as null instead of an exception in your gameplay code.
var save = await gsb.GetPlayerDocumentOrNullAsync(auth.player_id, "save");
if (save == null)
await gsb.SetPlayerDocumentAsync(auth.player_id, "save", "{\"level\":1,\"coins\":0}");
// 3. Save player data (raw JSON string)
await gsb.SetPlayerDocumentAsync(auth.player_id, "save", "{\"level\":4,\"coins\":250}");
// 4. Submit + read a leaderboard (leaderboardId is the board's ID from the dashboard)
await gsb.SubmitScoreAsync("YOUR_LEADERBOARD_ID", auth.player_id, 12750);
var top = await gsb.GetTopAsync("YOUR_LEADERBOARD_ID", 10);
foreach (var e in top) Debug.Log($"#{e.rank} {e.player_id}: {e.score}");
// 5. React to changes instead of asking for them on a timer. This blocks
// until the document is newer than your cursor, then hands it back with
// the next cursor. Loop on it; never poll a key in a tight loop.
var cursor = save != null ? save.version : 0;
var change = await gsb.WaitPlayerDocumentAsync(auth.player_id, "save", cursor);
cursor = change.version;
// 6. Only once the game works: give this same guest an email login, keeping
// player_id and everything saved against it.
// Not RegisterEmailAsync() - that makes a SECOND, empty player.
await gsb.UpgradeGuestEmailAsync("player@example.com", "a-strong-password"); ๐งฑ Unreal Engine 5 (C++ plugin, beta)
Crux now ships a first-party C++ plugin under sdks/sdk-unreal/Crux.
Status matters: its protocol tests and structural checks pass, but the plugin has
not yet been compiled with Unreal Build Tool against the engine. Treat the first real
UBT build and live request as part of evaluation, not as already-proven compatibility.
Copy Crux/ into your project's Plugins/ directory, add Crux to the
target dependencies, and keep REST/OpenAPI + FHttpModule
as the verified fallback if an engine-version issue appears. The plugin includes player/server
credentials, Steam ticket login/linking, documents, stats, achievements, economy, friends,
matchmaking, config and server-registry calls.
#include "CruxClient.h"
TSharedPtr<FCruxClient> Crux = FCruxClient::ForPlayer(
TEXT("https://crux.supercraft.host"),
TEXT("YOUR_PROJECT_ID"),
TEXT("YOUR_ENV_ID"),
TEXT("YOUR_PUBLISHABLE_KEY")
);
Crux->LoginAnonymous(FString(), [Crux](const FCruxResult& Result)
{
if (!Result.bSuccess) return;
UE_LOG(LogTemp, Log, TEXT("player %s"), *Crux->GetPlayerId());
}); See the Unreal backend path and dedicated-server architecture guide.
๐ฎ Roblox & any other engine
Roblox talks to Crux from a ServerScriptService script over
HttpService using a server token: walk through it in the
Roblox backend path or jump straight to the working Roblox quickstart. Any engine that can
make an HTTPS request works the same way: the API is plain HTTP + JSON, and the
curl block above is the whole contract.
๐ Documents are state. To watch them, use /wait
Documents are durable state: saves, profiles, settings, progression, shared world state. They keep their value until something overwrites it.
To learn when that happens, do not re-request the key on a
timer. Every document has a /wait endpoint that blocks
until its version passes after_version, and it exists for
both player documents and shared documents. The curl section above
shows the full loop. A key nobody has written yet is version 0, so
after_version=0 waits for the first write instead of
answering 404, which is exactly what a plain GET would do
forever.
All three SDKs wrap it: waitPlayerDocument() /
waitSharedDocument() in JavaScript,
wait_player_document() / wait_shared_document()
in Godot, and WaitPlayerDocumentAsync() /
WaitSharedDocumentAsync() in Unity. This is long-polling;
there is no WebSocket transport.
For state that several players share, use a
shared space and its shared documents
(createSharedSpace, setSharedDocument,
getSharedDocument) rather than writing into one player's
document and reading it from another. Chat, lobby and party state belong
there, watched with /wait, not in a per-player key polled
in a loop.