How to Add an Online Leaderboard to Your Game in 15 Minutes (Unity & Godot)
A real, copy-paste tutorial: create a free backend account, grab your API key, create a leaderboard, then submit and fetch scores from both Unity (C#) and Godot (GDScript / HTTPRequest) against the live Crux API. No servers to run, no invented endpoints - the exact HTTP contract, for free.
A leaderboard is the smallest online feature that makes a single-player game feel alive - and it is the number-one reason indies reach for a backend. The good news: you do not need to run a server, stand up a database, or write auth. This is a real, copy-paste tutorial that gets a working online leaderboard into a Unity or Godot game in about 15 minutes, using the live Crux API. Every endpoint below is the actual contract - copy it and it runs.
We make Crux, so yes, this funnels toward our free tier. But the code is real and the pattern is the same for any HTTP backend, so you learn something portable either way.
Step 1 - create a free account and grab your keys (3 minutes)
- Sign up for Crux - it is free (10,000 monthly active users, 2 million API calls a month, no credit card). A project and a
devenvironment are created for you automatically. - In the dashboard, copy your project ID and environment ID (both are UUIDs), then create an API key. Important: the key is a bare secret string shown once at creation - copy it the moment it appears, because you can't view it again.
- Right there in the dashboard, hit "Run your first call" - a one-click button that fires a real authenticated request so you can see a
200 OKbefore you write any code.
Optional: prove the key from your terminal
If you would rather see it work from a shell first, this single call mints a guest player and proves your key is live:
# Prove your API key works - mint a guest player
curl -X POST https://crux.supercraft.host/v1/auth/anonymous \
-H "Authorization: ApiKey <YOUR_API_KEY>" \
-H "Content-Type: application/json" \
-d '{"anonymous_id":"device-abc-123","display_name":"Ada"}'
# 200 OK -> { "player_id": "7f3c1e2a-...", "access_token": "eyJhbGci...", ... }
Step 2 - create a leaderboard (2 minutes)
In the dashboard, create a leaderboard: give it a key like high_scores and a sort order of desc (highest score first). Copy its ID - a UUID. That is the last value you need. You now have four things: base URL, project ID, environment ID, and leaderboard ID, plus your API key.
The whole contract, in curl
Before the engine code, here is the entire leaderboard API - two calls. Everything below is just these wrapped in Unity or Godot:
CRUX=https://crux.supercraft.host
PROJECT=<PROJECT_ID> # UUID from the dashboard
ENV=<ENV_ID> # UUID from the dashboard
KEY=<YOUR_API_KEY> # the bare secret
BOARD=<BOARD_ID> # your leaderboard's UUID
# Submit a score
curl -X POST "$CRUX/v1/projects/$PROJECT/environments/$ENV/leaderboards/$BOARD/entries/" \
-H "Authorization: ApiKey $KEY" -H "Content-Type: application/json" \
-d '{"player_id":"ada","score":12750}'
# Read the top 10
curl "$CRUX/v1/projects/$PROJECT/environments/$ENV/leaderboards/$BOARD/entries/top?limit=10" \
-H "Authorization: ApiKey $KEY"
# [ {"player_id":"ada","score":12750,"rank":1}, ... ]
Notice the player_id is just a string you pick - here it's a name. No login required to appear on the board. (More on when you do want real identity at the end.)
Step 3a - submit and fetch from Unity (C#)
Drop this MonoBehaviour into your project. It uses Unity's built-in UnityWebRequest - no package to install - and hits the exact two endpoints from above.
using UnityEngine;
using UnityEngine.Networking;
using System.Collections;
using System.Text;
public class Leaderboard : MonoBehaviour
{
const string CRUX = "https://crux.supercraft.host";
const string PROJECT = "<PROJECT_ID>"; // a UUID from the dashboard
const string ENV = "<ENV_ID>"; // a UUID from the dashboard
const string API_KEY = "<YOUR_API_KEY>"; // the bare secret, shown once
const string BOARD = "<BOARD_ID>"; // your leaderboard's UUID
string EntriesUrl(string suffix) =>
CRUX + "/v1/projects/" + PROJECT + "/environments/" + ENV +
"/leaderboards/" + BOARD + "/entries" + suffix;
[System.Serializable]
class ScoreSubmit { public string player_id; public float score; }
// Submit a score: POST .../entries/
public IEnumerator SubmitScore(string playerId, float score)
{
string json = JsonUtility.ToJson(new ScoreSubmit { player_id = playerId, score = score });
using var req = new UnityWebRequest(EntriesUrl("/"), "POST");
req.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json));
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Authorization", "ApiKey " + API_KEY);
req.SetRequestHeader("Content-Type", "application/json");
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
Debug.LogError("submit failed: " + req.responseCode + " " + req.error);
}
// Fetch the top N: GET .../entries/top?limit=N
public IEnumerator GetTop(int limit = 10)
{
using var req = UnityWebRequest.Get(EntriesUrl("/top?limit=" + limit));
req.SetRequestHeader("Authorization", "ApiKey " + API_KEY);
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
{
Debug.LogError("get top failed: " + req.responseCode + " " + req.error);
yield break;
}
// Response is a JSON array: [ {"player_id","score","rank"}, ... ]
Debug.Log(req.downloadHandler.text);
// Parse with Newtonsoft (handles top-level arrays directly), or wrap
// the string for Unity's JsonUtility. Each row has player_id, score, rank.
}
}
// Call them from anywhere:
// StartCoroutine(leaderboard.SubmitScore("ada", 12750));
// StartCoroutine(leaderboard.GetTop(10));
Prefer a typed SDK over raw HTTP? The same two calls are SubmitScoreAsync and GetTopAsync in the official Unity package - see the SDK quickstart.
Step 3b - submit and fetch from Godot (GDScript / HTTPRequest)
Add this as an autoload named Leaderboard (Project → Project Settings → Autoload). It uses Godot's built-in HTTPRequest - no addon required - and hits the identical endpoints.
extends Node
## A tiny leaderboard client for Crux using Godot's HTTPRequest.
const BASE := "https://crux.supercraft.host"
const PROJECT := "<PROJECT_ID>" # a UUID from the dashboard
const ENV := "<ENV_ID>" # a UUID from the dashboard
const API_KEY := "<YOUR_API_KEY>" # the bare secret, shown once at creation
const BOARD := "<BOARD_ID>" # your leaderboard's UUID
signal scores_received(scores) # Array of { player_id, score, rank }
func _entries(suffix: String) -> String:
return "%s/v1/projects/%s/environments/%s/leaderboards/%s/entries%s" % [BASE, PROJECT, ENV, BOARD, suffix]
func _headers() -> PackedStringArray:
return PackedStringArray([
"Authorization: ApiKey " + API_KEY,
"Content-Type: application/json",
])
# Submit a score: POST .../entries/
func submit_score(player_name: String, score: float) -> void:
var http := HTTPRequest.new()
add_child(http)
http.request_completed.connect(func(_result, code, _headers, _body):
http.queue_free()
if code >= 400:
push_error("submit failed: HTTP %d" % code)
)
var payload := JSON.stringify({ "player_id": player_name, "score": score })
http.request(_entries("/"), _headers(), HTTPClient.METHOD_POST, payload)
# Fetch the top N: GET .../entries/top?limit=N
func get_top(limit: int = 10) -> void:
var http := HTTPRequest.new()
add_child(http)
http.request_completed.connect(func(_result, code, _headers, body):
http.queue_free()
if code >= 400:
emit_signal("scores_received", [])
return
var scores = JSON.parse_string(body.get_string_from_utf8())
emit_signal("scores_received", scores if scores is Array else [])
)
http.request(_entries("/top?limit=%d" % limit), _headers(), HTTPClient.METHOD_GET)
Then submit a score and print the board:
func _ready() -> void:
Leaderboard.scores_received.connect(_on_scores)
Leaderboard.submit_score("ada", 12750)
Leaderboard.get_top(10)
func _on_scores(scores: Array) -> void:
for row in scores:
print("#%d %s %d" % [row.rank, row.player_id, int(row.score)])
That's it - you have an online leaderboard
Fifteen minutes, two endpoints, no servers to run. If you are coming off a shut-down Godot backend, this is the same shape as the SilentWolf migration - a name, a score, and the top N.
When you outgrow "just a string"
Two upgrades you will likely want later:
- Real player identity. Swap the arbitrary
player_idstring for a real player: callPOST /v1/auth/anonymous(guest) or register with email/OAuth, and use the returnedplayer_id. Then a score follows the same human across devices. The SDK quickstart shows the auth call. - Anti-cheat. A client-embedded API key means a determined player can extract it and post fake scores - fine for casual boards, not for competitive ones. For those, submit from a trusted server using a server token and validate the run server-side. Pattern: stopping leaderboard cheating with server-side validation.
Start free
Everything above runs on the free Dev tier - 10,000 MAU, every feature, no credit card. The fastest path is the 2-minute SDK quickstart, and remember the dashboard's one-click "Run your first call" button proves your key before you write a line of engine code. And because backends have a habit of changing the deal (see below), it is worth knowing your data is portable from the first call - the export guarantee spells that out.
Start free - the 2-minute quickstart →
Related reading
- SilentWolf shut down: migrate your Godot leaderboards in 15 minutes
- The best Godot backend in 2026
- PlayFab alternatives for indie games in 2026
- Will your game backend still be alive next year?
- The Crux sunset & export guarantee
Shipping to the browser instead? WebGL builds have their own constraints - no files, no raw sockets, no native plugins - and they change how you wire this up.