Search documentation

Find pages, sections, and content across all docs.

WavedashDocs

Leaderboards

Create competitive scoreboards, submit scores, and query player rankings

Leaderboards let players compete by ranking scores on shared scoreboards. Each leaderboard has a name you pick (e.g. "speedrun-times"), a sort direction, and an update policy.

Name vs. ID. Only getLeaderboard(name) and getOrCreateLeaderboard(name, ...) take the leaderboard's name. Every other call — uploadLeaderboardScore, listLeaderboardEntries, getMyLeaderboardEntries, etc. — takes the ID returned from those lookups (response.data.id)

Getting or creating a leaderboard

Call this once per leaderboard (for example during startup) and reuse the returned ID for every other leaderboard call.

func setup_leaderboard():
    var leaderboard = await WavedashSDK.get_or_create_leaderboard(
        "speedrun-times",
        WavedashSDK.Constants.LEADERBOARD_SORT_ASCENDING,
        WavedashSDK.Constants.LEADERBOARD_DISPLAY_TYPE_TIME_MILLISECONDS
    )
    var leaderboard_id = leaderboard.data.id if leaderboard.success else ""
    print("Leaderboard ID: ", leaderboard_id)
var leaderboard = await Wavedash.SDK.GetOrCreateLeaderboard(
    "speedrun-times",
    sortMethod: WavedashConstants.LeaderboardSortMethod.ASCENDING,
    displayType: WavedashConstants.LeaderboardDisplayType.TIME_MILLISECONDS
);
string leaderboardId = leaderboard != null ? (string)leaderboard["id"] : null;
Debug.Log($"Leaderboard ID: {leaderboardId}");
local co = coroutine.create(function()
    local leaderboard = wavedash.get_or_create_leaderboard_async(
        "speedrun-times",
        wavedash.LEADERBOARD_SORT_ORDER_ASC,
        wavedash.LEADERBOARD_DISPLAY_TYPETIME_MILLISECONDS
    )
    local leaderboard_id = leaderboard.success and leaderboard.data.id or nil
    print("Leaderboard ID:", leaderboard_id)
end)
assert(coroutine.resume(co))
const leaderboard = await Wavedash.getOrCreateLeaderboard(
  "speedrun-times",
  Wavedash.LeaderboardSortOrder.ASC,
  Wavedash.LeaderboardDisplayType.TIME_MILLISECONDS
);
if (!leaderboard.success) throw new Error(leaderboard.message);
const leaderboardId = leaderboard.data.id;

If the leaderboard already exists and you just need its ID, use getLeaderboard(name) instead — it fails if the leaderboard doesn't exist yet.

Godot signal alternative. If you prefer signal-based flow over await, every async leaderboard call also emits a signal when its response arrives:

func _ready():
    WavedashSDK.got_leaderboard.connect(_on_got_leaderboard)
    WavedashSDK.got_leaderboard_entries.connect(_on_got_entries)
    WavedashSDK.posted_leaderboard_score.connect(_on_posted_score)

func _on_got_leaderboard(response):
    print("ID: ", response.data.id if response.success else "?")

func _on_got_entries(response):
    if response.success:
        for entry in response.data:
            print(entry.globalRank, ": ", entry.score)

func _on_posted_score(response):
    if response.success:
        print("Rank: ", response.data.globalRank)

Sort order

ValueConstantDescriptionBest for
0ASCLower scores rank higherTime trials, golf
1DESCHigher scores rank higherPoints, high scores

In JavaScript use Wavedash.LeaderboardSortOrder.ASC / DESC. In Godot use WavedashSDK.Constants.LEADERBOARD_SORT_ASCENDING / LEADERBOARD_SORT_DESCENDING. In Unity use WavedashConstants.LeaderboardSortMethod.ASCENDING / DESCENDING. In Defold use wavedash.LEADERBOARD_SORT_ORDER_ASC / LEADERBOARD_SORT_ORDER_DESC.

In the Unity samples on this page, != null checks only guard non-WebGL builds (the Editor or a standalone player), where the SDK returns null. In a WebGL build a failed call throws an Exception (Request failed: …) — wrap awaits in try/catch.

Display type

ValueConstantDescription
0NUMERICDisplay as a number
1TIME_SECONDSDisplay as time in seconds
2TIME_MILLISECONDSDisplay as time with milliseconds
3TIME_GAME_TICKSDisplay as game ticks (assumes 60fps)

In JavaScript use Wavedash.LeaderboardDisplayType.NUMERIC / TIME_SECONDS / TIME_MILLISECONDS / TIME_GAME_TICKS. In Defold use wavedash.LEADERBOARD_DISPLAY_TYPENUMERIC / LEADERBOARD_DISPLAY_TYPETIME_SECONDS / wavedash.LEADERBOARD_DISPLAY_TYPETIME_MILLISECONDS / wavedash.LEADERBOARD_DISPLAY_TYPETIME_GAME_TICKS.

Visibility

Every leaderboard is either Visible — shown publicly on your game page's Leaderboards tab — or Hidden.

  • Leaderboards created by you (from the developer portal, or via getOrCreateLeaderboard while playing as a member of your game's team) default to Visible.
  • Leaderboards created by your players default to Hidden, so clients calling getOrCreateLeaderboard with arbitrary names can't spam your game page.

You can toggle visibility at any time from the developer portal's Leaderboards tab, from your game page (team members can view and toggle hidden leaderboards on the game page), or from your own server with the HTTP API.

Display name, sort order, and display type can also be changed after creation from your own server with the HTTP API.

Submitting a score

Resolve the name to an ID first, then pass that ID to uploadLeaderboardScore.

func submit_score(score: int):
    var leaderboard = await WavedashSDK.get_leaderboard("speedrun-times")
    if not leaderboard.success:
        return
    var leaderboard_id = leaderboard.data.id

    var result = await WavedashSDK.post_leaderboard_score(leaderboard_id, score, true)
    if result.success:
        print("Your leaderboard rank: ", result.data.globalRank)
        # Where this run placed, even if your leaderboard standing didn't change
        print("This run ranked: ", result.data.submittedRank)
var leaderboard = await Wavedash.SDK.GetLeaderboard("speedrun-times");
string leaderboardId = leaderboard != null ? (string)leaderboard["id"] : null;

var result = await Wavedash.SDK.UploadLeaderboardScore(
    leaderboardId, score, keepBest: true
);
if (result != null)
{
    Debug.Log($"Your leaderboard rank: {result["globalRank"]}");
    // Where this run placed, even if your leaderboard standing didn't change
    Debug.Log($"This run ranked: {result["submittedRank"]}");
}
local co = coroutine.create(function()
    local leaderboard = wavedash.get_leaderboard_async("speedrun-times")
    if not leaderboard.success then
        return
    end

    local leaderboard_id = leaderboard.data.id
    local result = wavedash.upload_leaderboard_score_async(leaderboard_id, score, true)
    if result.success then
        print("Your leaderboard rank:", result.data.globalRank)
        -- Where this run placed, even if your leaderboard standing didn't change
        print("This run ranked:", result.data.submittedRank)
    end
end)
assert(coroutine.resume(co))
const leaderboard = await Wavedash.getLeaderboard("speedrun-times");
if (!leaderboard.success) throw new Error(leaderboard.message);
const leaderboardId = leaderboard.data.id;

const response = await Wavedash.uploadLeaderboardScore(
  leaderboardId, 1500, true
);
if (response.success) {
  console.log(`Your rank: ${response.data.globalRank}`);
  // Where this run placed, even if your saved standing didn't change
  console.log(`This run ranked: ${response.data.submittedRank}`);
}
ParameterTypeRequiredDescription
leaderboardIdLeaderboardIdYesThe leaderboard's ID (from getLeaderboard / getOrCreateLeaderboard)
scorenumberYesThe score to submit
keepBestbooleanYesIf true, only updates if score is better
ugcIdUGCIdNoOptional UGC attachment (replay, screenshot)
metadataobjectNoSmall key/value data to store with the entry — see Attaching metadata

Use keepBest: true for competitive leaderboards. The SDK handles personal best tracking automatically.

Fetching scores

Top scores

func get_top_scores():
    var leaderboard = await WavedashSDK.get_leaderboard("speedrun-times")
    if not leaderboard.success:
        return
    var leaderboard_id = leaderboard.data.id

    var response = await WavedashSDK.get_leaderboard_entries(leaderboard_id, 0, 10, false)
    if response.success:
        for entry in response.data:
            print("#", entry.globalRank, " ", entry.username, ": ", entry.score)
var leaderboard = await Wavedash.SDK.GetLeaderboard("speedrun-times");
string leaderboardId = leaderboard != null ? (string)leaderboard["id"] : null;

var entries = await Wavedash.SDK.ListLeaderboardEntries(leaderboardId, 0, 10);
foreach (var entry in entries)
    Debug.Log($"#{entry["globalRank"]} {entry["username"]}: {entry["score"]}");
local co = coroutine.create(function()
    local leaderboard = wavedash.get_leaderboard_async("speedrun-times")
    if not leaderboard.success then
        return
    end

    local leaderboard_id = leaderboard.data.id
    local response = wavedash.list_leaderboard_entries_async(leaderboard_id, 0, 10, false)
    if response.success then
        for _, entry in ipairs(response.data) do
            print("#" .. entry.globalRank, entry.username .. ":", entry.score)
        end
    end
end)
assert(coroutine.resume(co))
const leaderboard = await Wavedash.getLeaderboard("speedrun-times");
if (!leaderboard.success) throw new Error(leaderboard.message);
const leaderboardId = leaderboard.data.id;

const response = await Wavedash.listLeaderboardEntries(
  leaderboardId, 0, 10, false
);
if (response.success) {
  response.data.forEach(entry => {
    console.log(`#${entry.globalRank} ${entry.username}: ${entry.score}`);
  });
}

Nearby scores

func get_scores_around_me():
    var leaderboard = await WavedashSDK.get_leaderboard("speedrun-times")
    if not leaderboard.success:
        return
    var leaderboard_id = leaderboard.data.id

    var nearby = await WavedashSDK.get_leaderboard_entries_around_player(leaderboard_id, 5, 5, false)
var leaderboard = await Wavedash.SDK.GetLeaderboard("speedrun-times");
string leaderboardId = leaderboard != null ? (string)leaderboard["id"] : null;

var nearby = await Wavedash.SDK.ListLeaderboardEntriesAroundUser(
    leaderboardId, countAhead: 5, countBehind: 5
);
local co = coroutine.create(function()
    local leaderboard = wavedash.get_leaderboard_async("speedrun-times")
    if not leaderboard.success then
        return
    end

    local leaderboard_id = leaderboard.data.id
    local nearby = wavedash.list_leaderboard_entries_around_user_async(leaderboard_id, 5, 5, false)
end)
assert(coroutine.resume(co))
const leaderboard = await Wavedash.getLeaderboard("speedrun-times");
if (!leaderboard.success) throw new Error(leaderboard.message);
const leaderboardId = leaderboard.data.id;

const nearby = await Wavedash.listLeaderboardEntriesAroundUser(
  leaderboardId, 5, 5, false
);

Player's own entry

func get_my_entry():
    var leaderboard = await WavedashSDK.get_leaderboard("speedrun-times")
    if not leaderboard.success:
        return
    var leaderboard_id = leaderboard.data.id

    var entries = await WavedashSDK.get_my_leaderboard_entries(leaderboard_id)
var leaderboard = await Wavedash.SDK.GetLeaderboard("speedrun-times");
string leaderboardId = leaderboard != null ? (string)leaderboard["id"] : null;

var entries = await Wavedash.SDK.GetMyLeaderboardEntries(leaderboardId);
local co = coroutine.create(function()
    local leaderboard = wavedash.get_leaderboard_async("speedrun-times")
    if not leaderboard.success then
        return
    end

    local leaderboard_id = leaderboard.data.id
    local entries = wavedash.get_my_leaderboard_entries_async(leaderboard_id)
end)
assert(coroutine.resume(co))
const leaderboard = await Wavedash.getLeaderboard("speedrun-times");
if (!leaderboard.success) throw new Error(leaderboard.message);
const leaderboardId = leaderboard.data.id;

const response = await Wavedash.getMyLeaderboardEntries(leaderboardId);

Entry count

getLeaderboardEntryCount is a synchronous accessor that returns the cached total from the last fetch — but it still takes the leaderboard ID, not the name, so you must resolve the name first.

func get_count():
    var leaderboard = await WavedashSDK.get_leaderboard("speedrun-times")
    if not leaderboard.success:
        return
    var leaderboard_id = leaderboard.data.id

    var count = WavedashSDK.get_leaderboard_entry_count(leaderboard_id)
var leaderboard = await Wavedash.SDK.GetLeaderboard("speedrun-times");
string leaderboardId = leaderboard != null ? (string)leaderboard["id"] : null;

var count = Wavedash.SDK.GetLeaderboardEntryCount(leaderboardId);
local co = coroutine.create(function()
    local leaderboard = wavedash.get_leaderboard_async("speedrun-times")
    if not leaderboard.success then
        return
    end

    local leaderboard_id = leaderboard.data.id
    local count = wavedash.get_leaderboard_entry_count(leaderboard_id)
end)
assert(coroutine.resume(co))
const leaderboard = await Wavedash.getLeaderboard("speedrun-times");
if (!leaderboard.success) throw new Error(leaderboard.message);
const leaderboardId = leaderboard.data.id;

const count = Wavedash.getLeaderboardEntryCount(leaderboardId);

Returns a cached value from the last query. Returns -1 if the leaderboard has not been queried yet.

Attaching metadata to an entry

Pass a metadata map to store a little context alongside the score — the character used, deaths taken, whether it was a no-hit run. It comes back on every read of the entry, so your UI can show it without a second round trip.

func submit_run(leaderboard_id: String, score: int):
    var result = await WavedashSDK.post_leaderboard_score(leaderboard_id, score, true, "", {
        "character": "knight",
        "deaths": 3,
        "no_hit": true
    })
    if result.success:
        print("Saved with character: ", result.data.metadata.character)
using Newtonsoft.Json.Linq;

var result = await Wavedash.SDK.UploadLeaderboardScore(
    leaderboardId, score, keepBest: true,
    metadata: new Dictionary<string, object>
    {
        { "character", "knight" },
        { "deaths", 3 },
        { "noHit", true }
    }
);
if (result != null && result.TryGetValue("metadata", out var raw) && raw is JObject metadata)
{
    Debug.Log($"Saved with character: {metadata.Value<string>("character")}");
}
local co = coroutine.create(function()
    local result = wavedash.upload_leaderboard_score_async(leaderboard_id, score, true, nil, {
        character = "knight",
        deaths = 3,
        no_hit = true
    })
    if result.success then
        print("Saved with character:", result.data.metadata.character)
    end
end)
assert(coroutine.resume(co))
const response = await Wavedash.uploadLeaderboardScore(
  leaderboardId, 1500, true, undefined, {
    character: "knight",
    deaths: 3,
    noHit: true
  }
);
if (response.success) {
  console.log(`Saved with character: ${response.data.metadata?.character}`);
}

metadata is the fifth argument, after ugcId. To send metadata without a UGC attachment, pass an empty ugcId — "" in Godot, nil in Defold, undefined in JavaScript — or, in Unity, skip it with a named argument as above.

What you can store

Keys are strings; values are strings, numbers, or booleans. Nested objects, arrays, and null are rejected — flatten them, or upload the payload as UGC and attach it with ugcId.

LimitValue
Keys per entry16
Key length64 characters
String value length256 characters
Total size2048 bytes, for the whole map encoded as JSON

Total size counts the UTF-8 bytes of the JSON encoding, keys and punctuation included.

Breaking any of these fails the upload with success false and a message naming the limit (in Unity the call throws an Exception with that message), and the score is not saved — even when keepBest would have discarded it anyway. Clamp game-generated strings (player names, level titles) before submitting so an oversized value can't cost a player their score.

Booleans need JavaScript SDK 1.3.44+, Unity 1.2.10+, Godot addon 1.2.12+, or Defold SDK 1.2.0+. Older SDKs reject them client-side.

In Unity, metadata reads back as a Newtonsoft JObject — add using Newtonsoft.Json.Linq; and read values with metadata.Value<int>("deaths"). In Godot, numbers read back as float, so cast with int(...).

Metadata belongs to the score

Metadata is stored on the entry rather than accumulated across submissions:

  • A submission that gets saved replaces the previous metadata wholesale.
  • A submission that gets saved and omits metadata clears it. In the Unity, Godot, and Defold bindings an empty map counts as omitted; in JavaScript pass undefined rather than {}, which is stored as an empty map.
  • A submission that keepBest rejects leaves the existing entry untouched, metadata included — so the metadata on a leaderboard always describes the run that set the saved score.

There's no partial update, so send the complete map with every score you submit.

Reading it back

metadata comes back on every read path — listLeaderboardEntries, listLeaderboardEntriesAroundUser, getMyLeaderboardEntries — and on the uploadLeaderboardScore response. The field is omitted on entries with no metadata, so guard for it:

using Newtonsoft.Json.Linq;

var entries = await Wavedash.SDK.ListLeaderboardEntries(leaderboardId, 0, 10);
foreach (var entry in entries)
{
    var character = entry.TryGetValue("metadata", out var raw) && raw is JObject metadata
        ? metadata.Value<string>("character") ?? "unknown"
        : "unknown";
    Debug.Log($"#{entry["globalRank"]} {entry["username"]}: {entry["score"]} ({character})");
}
const response = await Wavedash.listLeaderboardEntries(leaderboardId, 0, 10, false);
if (response.success) {
  response.data.forEach(entry => {
    const character = entry.metadata?.character ?? "unknown";
    console.log(`#${entry.globalRank} ${entry.username}: ${entry.score} (${character})`);
  });
}

Attaching replays or screenshots

For anything bigger than a handful of key/value pairs — a replay file, a screenshot, a ghost recording — upload it as UGC and attach it instead of squeezing it into metadata.

Pass a ugcId from UGC into the score upload call. Note that uploadLeaderboardScore takes lb.data.id (the ID), not the name:

const lb = await Wavedash.getOrCreateLeaderboard(
  "speedrun-times",
  Wavedash.LeaderboardSortOrder.ASC,
  Wavedash.LeaderboardDisplayType.TIME_MILLISECONDS
);
const ugc = await Wavedash.createUGCItem(
  Wavedash.UGCType.GAME_MANAGED,
  "Replay",
  "replay of level 1",
  Wavedash.UGCVisibility.PUBLIC,
  "replays/run.dat"
);
if (lb.success && ugc.success) {
  await Wavedash.uploadLeaderboardScore(lb.data.id, 12345, true, ugc.data);
}

Return types

Leaderboard

Returned from getLeaderboard and getOrCreateLeaderboard. Use id for every subsequent leaderboard call.

interface Leaderboard {
  id: LeaderboardId;   // pass this to uploadLeaderboardScore, listLeaderboardEntries, etc.
  name: string;
  totalEntries: number;
}

getOrCreateLeaderboard's result also carries created: boolean (true if the leaderboard was just created) at runtime, but it isn't on the exported type — read it with (response.data as Leaderboard & { created?: boolean }).created.

LeaderboardEntry

An entry in the list returned by listLeaderboardEntries, listLeaderboardEntriesAroundUser, and getMyLeaderboardEntries.

interface LeaderboardEntry {
  userId: UserId;
  username: string;
  userAvatarUrl?: string;
  score: number;
  globalRank: number;
  timestamp: number;
  metadata?: Record<string, string | number | boolean>;  // omitted if the score was submitted without metadata
  ugcId?: UGCId;
}

UpsertedLeaderboardEntry

Returned from uploadLeaderboardScore. Note that this is a different shape from LeaderboardEntry — it reports what changed on upload rather than the full entry.

It carries two pairs of fields: your saved standing (score / globalRank) and the submitted attempt (submittedScore / submittedRank). These differ when keepBest is true and the run you just submitted was worse than your saved best — score/globalRank stay at your best, while submittedScore/submittedRank reflect where this particular run would have placed.

interface UpsertedLeaderboardEntry {
  entryId: LeaderboardEntryId;
  score: number; // your saved standing's score
  scoreChanged: boolean; // false if keepBest was true and the pre-existing score was better
  globalRank: number; // your saved standing's rank
  submittedScore: number; // the score of the run you just submitted
  submittedRank: number; // where that submitted run would rank
  timestamp: number; // when the saved entry was recorded
  ugcId?: UGCId; // UGC attached to the saved entry
  metadata?: Record<string, string | number | boolean>; // the metadata now stored on the entry
  userId: UserId;
  username: string;
  userAvatarUrl?: string;
}