Crux Kata #1: Ship a Cross-Device Cloud Save in 30 Minutes
Hands-on kata: add anonymous player auth and cross-device cloud saves to a web or Unity game with Crux player documents, including atomic multi-document writes and a conflict strategy that survives offline play.
Crux Kata is a series of short, hands-on exercises: one real multiplayer-backend feature per kata, built end to end with working code, in under an hour. No slideware. This is Kata #1; the series continues with leaderboards, the server browser, and matchmaking.
The first thing every game outgrows is PlayerPrefs. The moment a player asks "why is my progress gone on my laptop?", you need three things you do not have: a player identity that exists without a signup form, a server-side place to put their data, and a story for what happens when two devices disagree. That is this kata.
The kata: anonymous login, save and load structured player data, write multiple documents atomically, and handle the two-devices conflict. Time: ~30 minutes. You need: a free Crux project (create one, it takes a minute) and your project ID, environment ID, and an API key from the dashboard.
Step 1: a player identity with zero friction
Players abandon games that open with a registration form. Crux's anonymous auth gives every player a stable identity on first contact, no email, no password, and you can attach a real account later without losing anything (the player ID never changes).
import { GSBClient } from "@supercraft/gsb";
const crux = GSBClient.forPlayer(
CRUX_API_URL, // your API base, from the dashboard
"proj_...", // project ID
"env_...", // environment ID (use a dev env for this kata)
"gsb_apikey_..." // client API key
);
const auth = await crux.loginAnonymous();
console.log("player:", auth.player_id);
Store the returned credentials with the device (localStorage on web, the platform keystore elsewhere): the same device should resolve to the same player on the next launch. That single line is the whole identity system for a soft launch, and it is rate-limited and ban-controllable from the dashboard, so it is not a liability later.
Step 2: the first save
Player data in Crux lives in documents: JSON blobs under string keys, scoped per player. The mental model is a per-player key-value store with structure, not a SQL schema you have to design on day one.
// Save
await crux.setPlayerDocument(auth.player_id, "progress", {
level: 12,
xp: 48210,
checkpoint: "ridge_overlook",
updated_at: Date.now()
});
// Load (next session, any device)
const progress = await crux.getPlayerDocument(auth.player_id, "progress");
Two design rules that save you pain at scale. First, split documents by write cadence: settings change rarely, progress changes per checkpoint, and a "session" doc might change every minute; separate keys mean small writes and no false conflicts between unrelated systems. Second, put a timestamp inside every payload (the updated_at above); you will want it in step 4.
Step 3: atomic multi-document saves
The classic corruption bug: the game saves inventory, crashes, and never saves progress, so the player has the loot from a checkpoint they are no longer past. Crux's batch write makes the checkpoint a single atomic operation:
await crux.batchWritePlayerDocuments(auth.player_id, [
{ key: "progress", value: { level: 13, checkpoint: "summit", updated_at: Date.now() } },
{ key: "inventory", value: { gold: 320, items: ["rope", "lantern", "map_fragment_3"] } },
{ key: "stats", value: { deaths: 7, playtime_min: 412 } }
]);
Either every document in the batch lands or none do. Use batch writes at every "the player would be angry to lose this" moment (checkpoints, purchases, end of match) and single-document writes for everything else. There is a matching batch-read for loading a whole profile in one round trip at session start, which matters more than it sounds on mobile networks.
Step 4: the two-devices problem
Sooner or later: the player plays offline on a plane (desktop), then opens the game on a phone. Both have divergent local state; the phone's server copy is now behind the desktop's local copy. There is no universally correct merge, but there is a universally correct discipline, and it fits in one function:
async function loadWithConflictCheck(playerId, key, localCopy) {
const server = await crux.getPlayerDocument(playerId, key);
if (!server) return localCopy; // first run on a fresh account
if (!localCopy) return server; // fresh device, server wins
if (server.updated_at > localCopy.updated_at) {
return server; // another device progressed further
}
if (localCopy.updated_at > server.updated_at) {
await crux.setPlayerDocument(playerId, key, localCopy); // push offline progress
return localCopy;
}
return server; // identical, no-op
}
Last-writer-wins on a per-document timestamp is the honest baseline: simple, explainable to the player, and right far more often than it is wrong if you kept documents split by system in step 2 (the plane session's progress does not clobber the phone's unrelated settings change). Games with merge-able state (collection games, incremental games) can upgrade specific documents to field-level merges later; the document split means you can do that per key without touching the rest.
Step 5: prove it worked
The kata's acceptance test, run it for real: save on one browser profile, open a second browser (same stored credentials), load, and watch the checkpoint travel. Then kill the network mid-batch-write and confirm the partial save did not land. The dashboard's player-data browser shows every document live, which makes both checks visual: you can watch keys appear as your code writes them.
Production notes before you ship it
Mind the document budget. Documents are sized for game state, not media: keep payloads lean (kilobytes, not megabytes), and if a designer asks to stuff a replay file into a save, route that to object storage and store the reference. Lean documents also make the batch operations fast enough to run at every checkpoint without a loading spinner.
Decide what the client may write. Settings and cosmetic state are safe client writes. Anything an economy depends on (currency, entitlements) should be written by trusted code, the same server-token boundary Kata #2 introduces for scores; the admin document API exists precisely so your backend can correct player state without shipping a client patch.
Plan deletion on day one. Privacy regulation makes "delete this player" a feature, not a support ticket. The admin API can remove a single document or every document a player owns in one call, which turns a GDPR erasure request into one line of your support tooling rather than a database archaeology project.
Version your schema informally. Add a v: 1 field to each document now. When the save format changes in update five, the loader checks v and migrates old shapes on read, and you will silently thank your past self.
Kata variations to try
- The settings-sync variation: sync only a
settingsdocument across devices and leave progress local, the minimum viable cloud feature for a single-player game, shippable in an evening. - The save-slots variation: keys like
save_slot_1,save_slot_2with aslots_indexdocument listing them; teaches key design and batch reads. - The hardcore variation: a dead character writes a
graveyardentry and deletes the run document atomically in one batch, permadeath that survives a rage-quit.
Kata FAQ
Does anonymous auth work on consoles and mobile? Yes, the pattern is platform-agnostic: the SDKs (JS, Unity, Godot, Roblox) all expose the same anonymous login, and the credential storage just moves to the platform's keystore. The upgrade path to a real account (registerEmail) is identical everywhere.
What happens to saves if the player clears local storage? The server copy survives; only the device's claim to the player identity is lost. This is exactly why step 1 says to upgrade engaged players to an email login: a recoverable identity makes local storage disposable.
How do I inspect a player's save when support asks? The dashboard's player browser shows every document for any player ID, live, which turns "my save is broken" tickets into a thirty-second lookup instead of a database session. The admin API exposes the same view for your own support tooling.
Can I migrate existing saves from PlayerPrefs or my own backend? Yes, trivially: on first login after the update ships, read the legacy local save and batch-write it as the player's initial documents. For server-to-server migrations, the admin document API does bulk writes per player; see the PlayFab migration guide for the full pattern.
What you just avoided building
The do-it-yourself version of this kata is a database, a migrations story, an auth service with token rotation, rate limiting so a buggy client cannot hammer your saves, an admin view for support ("can you check my save?"), and a ban system for the player who finds your endpoints. That is weeks before the first gameplay feature, which is exactly the trade we wrote up in self-hosted vs managed backends. The kata took thirty minutes because the platform half already exists; your half was four function calls and one design discipline.
Run this kata: create a free Crux project, grab your keys from the dashboard, and paste the snippets above into any Node 18+ script or browser console. SDKs also ship for Unity, Godot, and Roblox with the same call shapes (see the SDK page), and the cost calculator shows what your player count would actually cost. When the player is ready for a real account, registerEmail / loginEmail upgrade the same player ID in place, progress intact. Next: Kata #2, the leaderboard your players will actually look at.