Achievements, saves,
leaderboards.
Three things every game wants and nobody wants to build a backend for. On DropRate they're one HTTPS endpoint and one ticket. Any engine that can make a POST request is done in an afternoon — and your game still runs without us: the SDK is a bonus layer, never a lock.
Achievements
Define them in the portal, unlock with one call. Shown on your store page, in players' libraries, and in the app.
Cloud saves
Up to 8 slots per player, 1 MB each, versioned so two devices never silently overwrite each other.
Leaderboards
Best-score boards, higher-or-lower-wins, with rank lookups and a one-click cheater wipe for you.
How it works
Your game never touches a wallet. When DropRate launches it — through the desktop app or the web player — it hands the game a short-lived ticket that says "this wallet, this game, for the next 12 hours". Every SDK call carries that ticket. The server checks it and refuses anything else: a game can't unlock achievements for another player, read another game's saves, or post to a board it doesn't own.
If there's no ticket (the player isn't signed in, or your game is running outside DropRate), the client library puts itself in guest mode: every write quietly returns {guest:true} instead of throwing, so you don't need two code paths.
The ticket
| Where your game runs | How it gets the ticket |
|---|---|
| DropRate desktop app (native build) | Environment variables at launch: DROPRATE_TICKET, DROPRATE_API, DROPRATE_WALLET, DROPRATE_PRODUCT. Missing = guest. |
| DropRate web player (browser build) | postMessage from the player page. droprate.js handles the handshake for you. |
| Your own testing | Dev Portal → your game → SDK tab → Get test ticket. Valid one hour, tied to your wallet. |
Send it as Authorization: Bearer <ticket>. Tickets expire on their own; when one does, the API answers 401 with "ticket expired" and the player relaunches. There's nothing to store and nothing to revoke.
Quick start
<!-- in your game's index.html -->
<script src="https://droprate.xyz/sdk/droprate.js"></script>
<script>
const dr = await DropRate.ready(); // finds the ticket, or goes guest
if (dr.guest) showSignInHint(); // optional: dr.requestSignIn() opens the wallet
await dr.achievements.unlock("first_blood");
await dr.saves.put("slot1", { level: 3, hp: 80 });
const save = await dr.saves.get("slot1"); // save.value → { level: 3, hp: 80 }
await dr.leaderboards.submit("high_score", 12450);
const top = await dr.leaderboards.top("high_score", 10);
dr.on("unlock", a => toast(`Achievement: ${a.name}`));
</script># every call is POST + JSON + the ticket header
curl -X POST https://droprate.xyz/sdk/v1/ach-unlock \
-H "Authorization: Bearer $DROPRATE_TICKET" \
-H "Content-Type: application/json" \
-d '{"key":"first_blood"}'
# → {"ok":true,"unlocked":true,"new":true,"achievement":{"key":"first_blood","name":"First Blood",…}}Achievements
Define them once in the Dev Portal (key, name, description, icon, points, hidden). Keys are what your code uses: lowercase, a-z 0-9 _ . -, up to 64 characters. Unlocking is idempotent — calling it twice is harmless, and the response tells you whether it was new so you can show a toast only the first time.
Hidden achievements show as "Hidden achievement" on the store page until a player earns them. Points are a display number (default 10) that adds up to a player's score in their library.
// list with this player's progress
const { achievements, total, unlocked, points } = await dr.achievements.list();
// achievements[i] = { key, name, description, icon, hidden, points, unlocked, unlocked_at }
Cloud saves
Per player, per game: up to 8 slots, 1 MB each, any bytes. The JS client accepts an object (stored as JSON), a string, or a Uint8Array, and gives it back the same way. Over HTTP the payload is base64 in a data field.
Versioning. Every write bumps the slot's version. If you pass the version you last read and it's no longer current — the player saved from another PC — the server answers 409 save-conflict with the current version instead of overwriting. Omit version to force.
const cur = await dr.saves.get("auto"); // { exists, value, version, updated_at }
const r = await dr.saves.put("auto", state, { version: cur.version, label: "Chapter 3" });
if (r.error === "save-conflict") askWhichToKeep(cur, r.current_version);
Leaderboards
Define boards in the portal with a key, a name, and a direction: desc (higher is better — points) or asc (lower is better — speedrun times). Submit whatever you like; the server keeps each player's best and tells you their rank. meta is a free 200-char string for things like character name or a replay id.
const r = await dr.leaderboards.submit("any_percent", 4231, "Knight"); // ms, asc board
// r = { accepted: true, best: "4231", rank: 17 }
const t = await dr.leaderboards.top("any_percent", 25);
// t.entries[i] = { rank, player: "3emX…KkJc", score, meta, me } t.me = { rank, score } for this player
REST endpoints
Base https://droprate.xyz/sdk/v1/. All POST, JSON body, CORS-enabled. Header Authorization: Bearer <ticket> unless marked public.
| Endpoint | Body | Returns |
|---|---|---|
whoami | — | wallet, product_id, source, expires_at |
ach-list | — | achievements[], total, unlocked, points |
ach-unlock | key | unlocked, new, achievement |
save-put | slot, data(base64), version?, label? | slot, version, bytes · 409 on conflict |
save-get | slot | exists, data, version, label, updated_at |
save-list | — | slots[] |
save-delete | slot | deleted |
score-submit | board, score, meta? | accepted, best, rank |
score-top (public) | board, limit?, product_id (if no ticket) | entries[], total, me |
board-list (public) | product_id (if no ticket) | boards[] |
ach-public (public) | product_id, wallet? | same as ach-list, read-only |
Errors are {"error":"…"} with a meaningful status: 400 bad input, 401 ticket missing/expired, 403 not yours, 404 undefined key/board, 409 conflict, 413 too big.
Unity · Godot · C
No plugin needed — read the environment, POST JSON. These are complete and drop-in.
// DropRate.cs — drop into Assets/. Requires nothing but UnityEngine.Networking.
using System; using System.Collections; using System.Text;
using UnityEngine; using UnityEngine.Networking;
public static class DropRate {
static readonly string Api = Environment.GetEnvironmentVariable("DROPRATE_API") ?? "https://droprate.xyz/sdk/v1";
static readonly string Ticket = Environment.GetEnvironmentVariable("DROPRATE_TICKET");
public static bool Guest => string.IsNullOrEmpty(Ticket);
public static IEnumerator Unlock(string key) => Post("ach-unlock", "{\"key\":\"" + key + "\"}");
public static IEnumerator Submit(string board, long s) => Post("score-submit", "{\"board\":\"" + board + "\",\"score\":" + s + "}");
public static IEnumerator Save(string slot, string json) =>
Post("save-put", "{\"slot\":\"" + slot + "\",\"data\":\"" + Convert.ToBase64String(Encoding.UTF8.GetBytes(json)) + "\"}");
static IEnumerator Post(string action, string body) {
if (Guest) yield break; // no ticket: silently skip
var req = new UnityWebRequest(Api + "/" + action, "POST");
req.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body));
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", "application/json");
req.SetRequestHeader("Authorization", "Bearer " + Ticket);
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success) Debug.LogWarning("DropRate " + action + ": " + req.downloadHandler.text);
}
}
// usage, from any MonoBehaviour: StartCoroutine(DropRate.Unlock("first_blood"));# droprate.gd — add as an Autoload named DropRate
extends Node
var api: String = OS.get_environment("DROPRATE_API")
var ticket: String = OS.get_environment("DROPRATE_TICKET")
var guest: bool = ticket == ""
func _ready():
if api == "": api = "https://droprate.xyz/sdk/v1"
func unlock(key: String): _post("ach-unlock", {"key": key})
func submit(board: String, score: int): _post("score-submit", {"board": board, "score": score})
func save(slot: String, data: Dictionary):
_post("save-put", {"slot": slot, "data": Marshalls.utf8_to_base64(JSON.stringify(data))})
func _post(action: String, body: Dictionary):
if guest: return
var http := HTTPRequest.new(); add_child(http)
http.request_completed.connect(func(_r, code, _h, b): if code >= 400: push_warning("DropRate %s: %s" % [action, b.get_string_from_utf8()]); http.queue_free())
http.request(api + "/" + action, ["Content-Type: application/json", "Authorization: Bearer " + ticket], HTTPClient.METHOD_POST, JSON.stringify(body))/* droprate.c — libcurl. cc droprate.c -lcurl */
#include <curl/curl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static int droprate_post(const char *action, const char *json) {
const char *ticket = getenv("DROPRATE_TICKET"); if (!ticket) return 0; /* guest */
const char *api = getenv("DROPRATE_API"); if (!api) api = "https://droprate.xyz/sdk/v1";
char url[512], auth[1024];
snprintf(url, sizeof url, "%s/%s", api, action);
snprintf(auth, sizeof auth, "Authorization: Bearer %s", ticket);
CURL *c = curl_easy_init(); if (!c) return -1;
struct curl_slist *h = NULL;
h = curl_slist_append(h, "Content-Type: application/json"); h = curl_slist_append(h, auth);
curl_easy_setopt(c, CURLOPT_URL, url); curl_easy_setopt(c, CURLOPT_HTTPHEADER, h);
curl_easy_setopt(c, CURLOPT_POSTFIELDS, json);
int rc = curl_easy_perform(c) == CURLE_OK ? 0 : -1;
curl_slist_free_all(h); curl_easy_cleanup(c); return rc;
}
/* droprate_post("ach-unlock", "{\"key\":\"first_blood\"}"); */Limits & rules
| Thing | Limit |
|---|---|
| Achievements per game | 200 |
| Leaderboards per game | 50 |
| Save slots per player per game | 8, 1 MB each |
| Ticket lifetime | 12 h (launcher / web) · 1 h (dev test) |
| Keys | a-z 0-9 _ . -, ≤ 64 chars, case-insensitive |
Player data belongs to the player: it's keyed by wallet, follows them across devices, and survives a resale (a new owner starts fresh; the old owner's progress stays with their wallet). You can read every player's achievement counts and wipe leaderboard entries from the portal; you cannot read their saves.
Questions or a feature you need? The Dev Portal has a message box that reaches us directly.