Game SDK · v1

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 runsHow 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 testingDev 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
Cheating. A client can only ever submit for its own wallet, but it can submit any number. Treat boards as social, not authoritative; the portal has a one-click wipe per player or per board. If you need trusted scores, keep the scoring logic server-side in your own game and submit from there with the same ticket.

REST endpoints

Base https://droprate.xyz/sdk/v1/. All POST, JSON body, CORS-enabled. Header Authorization: Bearer <ticket> unless marked public.

EndpointBodyReturns
whoami—wallet, product_id, source, expires_at
ach-list—achievements[], total, unlocked, points
ach-unlockkeyunlocked, new, achievement
save-putslot, data(base64), version?, label?slot, version, bytes · 409 on conflict
save-getslotexists, data, version, label, updated_at
save-list—slots[]
save-deleteslotdeleted
score-submitboard, 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

ThingLimit
Achievements per game200
Leaderboards per game50
Save slots per player per game8, 1 MB each
Ticket lifetime12 h (launcher / web) · 1 h (dev test)
Keysa-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.

DropRate Game SDK v1 · docs · whitepaper