Players & worlds
Every Unbase project includes an online layer for browser games, next to Leaderboards. A static game (itch.io, your own site, GitHub Pages…) gets, with the anon key and no backend of its own:
- players: a lasting anonymous id per device, a display name, and more devices linked with the same save codes leaderboards use,
- cloud saves: one versioned JSON blob per player, which an old device can't overwrite, and which friends can visit,
- a world claims registry: an endless grid of cells (levels, islands, archipelagos…) handed out on a spiral from
0,0, one decision point so two players can't win the same cell, - a public world map, friend codes, mailboxes (gifts, bottles, guestbook entries) and scoped leaderboards (
beam:3,-2).
The server never stores your world: if your game generates a cell from its coordinates, all Unbase keeps is who holds it.
Who can do what
| Caller | Sends | Can |
|---|---|---|
| Anyone with the anon key | apikey: <anon key> |
Create or sign in a player. Read public profiles, the map, claims, and saves whose owner allows visits. |
| A player | apikey + Authorization: Bearer <player token> |
Everything above, plus write their own rows: profile, save, claims, friends and mailbox. |
| You, the project owner | apikey: <secret key> |
Read mail reports and mute players. Skips the origin allowlist. |
There is no route that writes another player's rows: every write goes to the player the token belongs to, so the anon key alone can't change anyone's data. The origin allowlist from your leaderboard config (including the itch keyword) applies to every route here.
Base URL: https://api.unbase.dev/v1/projects/:id. Errors use the usual { "error": { "code", "message" } } body.
Quick start
const API = "https://api.unbase.dev/v1/projects/YOUR_PROJECT_ID";
const KEY = "YOUR_ANON_KEY";
async function api(method, path, body) {
const headers = { apikey: KEY, "content-type": "application/json" };
const token = localStorage.getItem("player-token");
if (token) headers.authorization = "Bearer " + token;
const res = await fetch(API + path, { method, headers, body: body && JSON.stringify(body) });
return { status: res.status, ...(await res.json()) };
}
// 1. Sign in (creates the player the first time).
const { player, token } = await api("POST", "/players", { name: "Wren" });
localStorage.setItem("player-token", token);
// 2. Claim an archipelago.
const claim = await api("POST", "/claims/next");
startGame(claim.archipelago); // e.g. "1,-1"
// 3. Save on meaningful events.
let version = player.save ? player.save.version : 0;
async function save(state) {
const res = await api("PUT", "/players/me/save", { version, data: state });
if (res.status === 409) return reloadNewerSave(res.error.currentVersion);
version = res.version;
}
Players
POST /players{ name?, device? }signs a device in and returns{ created, player, token, device }(201when the player is new).- With no
device, it creates a new player with a fresh random device id. - With a
device(at least 16 random characters), it returns that device's player, creating it the first time, with a new token. So a game that loses its storage gets the same player back on every load. Use the id the leaderboard SDK already keeps (await UnbaseLeaderboard.ready()), which survives in a partitioned cookie on itch.io even whenlocalStorageis blocked. Treat a device id like a password: whoever has it can sign in as that player. - Each sign-in issues a token. A player keeps their 20 newest tokens; older ones stop working.
- Creating new players is limited to 60 per IP per hour (
429withRetry-After); signing an existing device back in isn't.
- With no
GET /players/mereturns your own view:{ id, name, friendCode, visits, mail, home, save: { version, updatedAt } | null, createdAt }.homeis your active claim, else the one you finished last.PATCH /players/me{ name?, visits?, mail? }:visits: who can read your save."public"(anyone),"friends"(default) or"private".mail: who can message you."anyone"(default),"friends"or"off".- Renaming also renames you on the map.
GET /players/:playerIdreturns the public view:{ id, name, visits, home, createdAt }.POST /players/me/pingis a heartbeat that keeps your claim from going idle. Saving does this too.
More devices: save codes
POST /players/me/link-code→{ code: "XXXX-XXXX", expiresAt }. The code is single-use and lasts 15 minutes. Limited to 5 per hour.- On the other device:
POST /players/link{ code }→{ player, token, device }. Store the token and adopt thedeviceid (for leaderboards too). Limited to 10 attempts per IP per hour.
These are the same codes as the leaderboard SDK's linkCode(), so a code from either one signs the new device in to both.
Cloud saves
One save per player: any JSON up to 256 KB, stored in R2. Saves share the project's file-storage quota with uploaded files.
PUT /players/me/save{ version, data }.versionis the version this save is based on:0for the first save, then the version the last read or write returned.200 { version, updatedAt, size }when it was the latest. The new version isversion + 1.409 save_conflictwhen the stored save is newer (another device saved in between). The error body also hascurrentVersionandupdatedAt, so the game can load the newer save, merge, and write again.- If two devices write from the same version at the same time, exactly one wins.
GET /players/me/save→{ playerId, version, updatedAt, data }. Add?since=<version>to get{ …, unchanged: true }with nodatawhen there's nothing newer, which is a cheap check on startup.GET /players/:playerId/save(optionally with your token) reads someone else's save if theirvisitssetting allows you. Otherwise you get403 visits_not_allowed.404 no_savemeans they haven't saved yet.
Save on meaningful events (a relic, a build, landing, sleeping), not on a timer. Each save is one R2 write and a few rows.
Claims registry
The world is an endless grid of cells named "x,y" (integers within ±1,000,000). All claims for a project are decided by one Durable Object, one at a time, so there are no races.
Every claim call answers with the same shape:
{
"archipelago": "1,0",
"claim": { "archipelago": "1,0", "x": 1, "y": 0, "status": "active", "owner": { "id": "p_…", "name": "Wren" }, "claimedAt": 1760000000000, "completedAt": null },
"requested": "5,5",
"granted": false,
"reason": "taken"
}
archipelago is always the cell the player holds now. granted says whether that's what they asked for; if not, reason says why:
| reason | meaning |
|---|---|
null |
You got what you asked for. |
"taken" |
Someone else holds the cell you asked for, so you got the next free one. Move the player there. |
"has_active_claim" |
You already hold an unfinished cell (this one). Complete or release it first. |
Routes (all need the player token except reading a cell):
POST /claims/next{ near? }hands out the next uncharted cell on a square spiral out from0,0, reusing released cells first. Withnear: "<friend code>", it lands next to that friend's home instead (within 6 rings, falling back to the spiral). Calling it again while you hold an unfinished cell returns that cell, so it's safe to retry.POST /claims/:cell{ near? }claims a specific cell, e.g. one the game already started offline. If it's free, you get it. If someone else got there first, you get the next free one withreason: "taken": your "claim lost, here's your new archipelago" answer. Re-sending a claim you already hold just confirms it.POST /claims/:cell/completemarks your cell complete. It then stays yours forever, with your name, and you can claim another.POST /claims/:cell/releasegives an unfinished cell back.GET /claims/melists your claims, oldest first.GET /claims/:cellreturns the claim, or{ archipelago, x, y, status: "uncharted" }.
One active claim per player: you can hold any number of completed cells, but only one unfinished one.
Idle claims go back to the sea. An active claim with no save or ping for 30 days becomes uncharted again. Completed claims never do. This runs as a daily job, and also a few at a time whenever a claim is handed out.
Offline first: let the game claim locally, then confirm with POST /claims/:cell when it's back online and follow archipelago in the answer.
World map
GET /map?x0=-10&y0=-10&x1=10&y1=10 → { x0, y0, x1, y1, archipelagos: [claim, …] } lists every claimed cell in the rectangle (corners inclusive), sorted by row. It can read at most 64 cells along each axis per request; page through larger areas.
Friend codes
Every player has a 6-character friend code (player.friendCode, no 0/O/1/I).
GET /friends/:coderesolves a code to a public profile.POST /players/me/friends{ code }links you and that player both ways. The code works like an invitation: it's what they chose to share. Limited to 20 attempts per hour and 200 friends.GET /players/me/friendslists your friends.DELETE /players/me/friends/:playerIdunlinks both ways.POST /players/me/friend-code→{ friendCode }replaces your code, so the old one stops working (existing friends stay).
Friends can visit a "friends" save, message a "friends" mailbox, and near puts new players beside them.
Mailboxes
Append-only messages to another player: gifts, messages in bottles, guestbook entries.
POST /mail/:playerId{ kind, body }(player token) →{ id, sentAt }.kindis a short lowercase word you choose (gift,bottle,guestbook…).bodyis any JSON up to 2 KB. You can send 30 messages an hour, and 5 an hour to the same player. Respects the recipient'smailsetting (403 mail_closed).GET /mail/me?limit=50&before=→{ messages: [{ id, kind, body, from: { id, name }, sentAt }], next }, newest first. Passnextasbeforefor older messages.DELETE /mail/me/:iddeletes one of your messages.POST /mail/me/:id/reportremoves it and queues a copy for you, the project owner.
Senders can't edit or delete what they sent. A mailbox keeps its newest 200 messages.
Moderation (secret key in apikey):
GET /mail/reportslists reported messages with the sender.PUT /players/:playerId/mutemutes a sender: their sends still get a normal answer, but nothing is delivered.DELETE /players/:playerId/mutelifts it.
Scoped leaderboards
A leaderboard can be scoped: one board per scope your game names, such as one per archipelago. Add "scoped": true to a board in the leaderboard config and pass scope when submitting:
lb.submit(run, { name, values: { beam: 42 }, scope: "3,-2" });
lb.top("beam:3,-2"); // board "beam", scope "3,-2"
Per-week boards are ordinary "period": "weekly" boards (fish with period key 2026-W41). A scoped board can have a period too, which gives one board per scope per week. See scoped boards.
Limits and billing
Everything here counts against your project's plan like other traffic: rows read and written, plus save blobs against the file-storage quota. Rough costs: a save writes 3 rows plus one R2 object, a claim about 3 rows, a message 3 rows. The data lives in reserved tables your own SQL can't see, it isn't part of SQL exports, and it is deleted with the project.
Not here yet: real-time rooms (live positions over WebSockets) and per-project schedules beyond the built-in daily idle-claim job.