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",
        WavedashConstants.LEADERBOARD_SORT_ASCENDING,
        WavedashConstants.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
);
const leaderboardId = leaderboard.success ? leaderboard.data.id : null;

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 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 WavedashConstants.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.

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");
const leaderboardId = leaderboard.success ? leaderboard.data.id : null;

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
leaderboardIdId<"leaderboards">YesThe leaderboard's ID (from getLeaderboard / getOrCreateLeaderboard)
scorenumberYesThe score to submit
keepBestbooleanYesIf true, only updates if score is better
ugcIdId<"userGeneratedContent">NoOptional 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");
const leaderboardId = leaderboard.success ? leaderboard.data.id : null;

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");
const leaderboardId = leaderboard.success ? leaderboard.data.id : null;

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");
const leaderboardId = leaderboard.success ? leaderboard.data.id : null;

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");
const leaderboardId = leaderboard.success ? leaderboard.data.id : null;

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 a run used, how many deaths it took, the seed that generated the level. It comes back on every read of that entry, so your leaderboard UI can show it next to the score 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
    })
    if result.success:
        print("Saved with character: ", result.data.metadata.character)
var result = await Wavedash.SDK.UploadLeaderboardScore(
    leaderboardId, score, keepBest: true,
    metadata: new Dictionary<string, object>
    {
        { "character", "knight" },
        { "deaths", 3 }
    }
);
if (result != null && result.TryGetValue("metadata", out var raw) && raw is JObject metadata)
{
    Debug.Log($"Saved with character: {metadata["character"]}");
}
local co = coroutine.create(function()
    local result = wavedash.upload_leaderboard_score_async(leaderboard_id, score, true, nil, {
        character = "knight",
        deaths = 3
    })
    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
  }
);
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, and values are strings or numbers. Booleans, nested objects, and arrays are rejected — flatten them first (store a perfect run as 1 or "yes" rather than true), or upload the payload as UGC and attach it with ugcId instead.

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

Breaking any of these fails the upload: the response comes back with success false and a message naming the limit, and the score is not saved. Validate or clamp game-generated strings (player-entered names, level titles) before submitting them, so an oversized value can't cost a player their score.

In Unity, metadata arrives as a Newtonsoft JObject nested inside the response dictionary — add using Newtonsoft.Json.Linq; to read it. The key is absent entirely on entries with no metadata, so prefer TryGetValue over indexing.

If you're calling window.Wavedash through hand-written glue from a language that can't build a JS object literal, metadata also accepts a JSON string ('{"character":"knight"}'), which the SDK parses for you — this is how the engine SDKs pass it across the bridge. Malformed JSON fails the call with a message rather than submitting the score.

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.
  • 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 entries that have it from every read path — listLeaderboardEntries, listLeaderboardEntriesAroundUser, getMyLeaderboardEntries — as well as on the uploadLeaderboardScore response itself. The field is omitted on entries with no metadata, so guard for it:

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: Id<"leaderboards">;   // pass this to uploadLeaderboardScore, listLeaderboardEntries, etc.
  name: string;
  totalEntries: number;
  created?: boolean;        // only set by getOrCreateLeaderboard — true if newly created
}

LeaderboardEntry

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

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

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: Id<"leaderboardEntries">;
  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
  metadata?: Record<string, string | number>; // the metadata now stored on the entry
  userId: Id<"users">;
  username: string;
  userAvatarUrl?: string;
}