> For the complete documentation index, see [llms.txt](https://docs.overdare.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.overdare.com/manual/script-manual/advanced-gameplay-systems/ordereddatastore.md).

# Custom Leaderboard

## Overview

Use OrderedDataStore to permanently save numeric scores, such as a player's best record, total eliminations, or clear time, and **retrieve them sorted by rank**. This allows you to build your own leaderboard, such as an all-time ranking shared across every server or a seasonal ranking.

OrderedDataStore is a key-value store like DataStore, but its values are **limited to integers** in exchange for sorted retrieval.

## Which Feature Should You Use?

<table><thead><tr><th width="300">What you want to do</th><th>Feature to use</th></tr></thead><tbody><tr><td>Permanently save scores, sort and retrieve them as you like, and build your own leaderboard UI</td><td><strong>OrderedDataStore</strong></td></tr><tr><td>Save various forms of player data, such as level and inventory</td><td><a href="/manual/script-manual/advanced-gameplay-systems/datastore.md">DataStore</a></td></tr><tr><td>Display rankings in the Top Scorer section of the app and above the character</td><td><a href="/manual/script-manual/advanced-gameplay-systems/worldrankservice.md">WorldRankService</a></td></tr></tbody></table>

## How to Use

### Supported Data Types

OrderedDataStore only stores **integers**.

<table><thead><tr><th width="146.66668701171875">Data Type</th><th>Supported O/X</th></tr></thead><tbody><tr><td>Integer (number)</td><td>O</td></tr><tr><td>Decimal (number)</td><td>Not supported.</td></tr><tr><td>string</td><td>Not supported.</td></tr><tr><td>bool</td><td>Not supported.</td></tr><tr><td>table</td><td>Not supported.</td></tr></tbody></table>

Saving an unsupported value raises an error and leaves the existing value unchanged. NaN and Inf cannot be saved either.

For scores with decimal points, such as a clear time of 12.34 seconds, multiply by a **fixed scale to store them as integers** and divide again when you display them.

```lua
local ClearTime = 12.34
local SaveValue = math.floor(ClearTime * 100)
local DisplayTime = SaveValue / 100
```

### Default Structure

Use the GetOrderedDataStore function of DataStoreService to retrieve an OrderedDataStore object with a designated name, and save scores in a **key-value format**.

* **Key**: A string of 1 to 50 characters that identifies who the score belongs to (e.g. the player's UserId converted to a string)
* **Value**: The integer score used as the sorting criterion (e.g. 1,500)

Unlike DataStore, **additional information such as UserIds and Metadata, as well as version history, is not supported.** Once a value is overwritten or deleted, the previous value cannot be recovered.

<table><thead><tr><th width="330">Function</th><th>Description</th></tr></thead><tbody><tr><td>DataStoreService:GetOrderedDataStore(name, scope<em>(*optional)</em>)</td><td>Retrieve the OrderedDataStore object that corresponds to the name</td></tr><tr><td>GetSortedAsync(ascending, pagesize, minValue<em>(*optional)</em>, maxValue<em>(*optional)</em>)</td><td>Retrieve saved scores sorted and divided into pages (returns DataStorePages)</td></tr><tr><td>GetAsync(key)</td><td>Retrieve the score that corresponds to the key</td></tr><tr><td>SetAsync(key, value)</td><td>Save (overwrite) a score in the key</td></tr><tr><td>IncrementAsync(key, delta<em>(*optional)</em>)</td><td>Increase/decrease the score that corresponds to the key by delta (1 if delta is omitted)</td></tr><tr><td>UpdateAsync(key, callback)</td><td>Update the score that corresponds to the key through the callback</td></tr><tr><td>RemoveAsync(key)</td><td>Delete the score that corresponds to the key</td></tr></tbody></table>

All of these functions can only be used in **server scripts**. Accessing them from the client raises an error.

### Data Store Retrieval

```lua
local DataStoreService = game:GetService("DataStoreService")
local LeaderboardStore = DataStoreService:GetOrderedDataStore("KillLeaderboard")
```

* Calling it again with the same name returns the **same object**, so scripts that retrieve the same name all share one store.
* DataStore and OrderedDataStore are **separate stores** even when the name is identical. The values of `GetDataStore("Score")` and `GetOrderedDataStore("Score")` never mix.
* Names, scopes, and keys are all **case sensitive**. `KillLeaderboard` and `killLeaderboard` are different stores.

#### Separating Storage with Scope

Provide the second argument, scope, to divide the storage space within one name. If scope is omitted, `global` is used.

```lua
local DataStoreService = game:GetService("DataStoreService")

local Season1Store = DataStoreService:GetOrderedDataStore("KillLeaderboard", "Season1")
local Season2Store = DataStoreService:GetOrderedDataStore("KillLeaderboard", "Season2")
```

### Saving Scores

Keys are strings, so convert a UserId with `tostring()`. Passing a number also works because it is converted to a string, but retrieved keys always come back as strings, so it is clearer to treat them as strings from the start.

```lua
local function SaveScore(player, score)
    local success, errorMessage = pcall(function()
        LeaderboardStore:SetAsync(tostring(player.UserId), score)
    end)

    if not success then
        print("errorMessage : ", errorMessage)
    end
end
```

### Accumulating Scores

To simply add to or subtract from a score, use IncrementAsync. If the key does not exist, the delta value is saved as the initial value, and the updated score is returned.

```lua
local function AddKill(player)
    local success, errorMessageOrNewValue = pcall(function()
        return LeaderboardStore:IncrementAsync(tostring(player.UserId), 1)
    end)

    if not success then
        print("errorMessage : ", errorMessageOrNewValue)
    else
        print(player.Name, "Kill : ", errorMessageOrNewValue)
    end
end
```

### Updating Only the Best Record

When you need to **decide based on the current value**, such as "save only if it is higher than the existing record," use UpdateAsync. Even if multiple servers update the same key at the same time, the decision is made against the latest value, so records are not overwritten.

```lua
local function SaveBestScore(player, score)
    local success, errorMessageOrNewValue = pcall(function()
        return LeaderboardStore:UpdateAsync(tostring(player.UserId), function(currentBest)
            if currentBest ~= nil and currentBest >= score then
                return nil
            end
            return score
        end)
    end)

    if not success then
        print("errorMessage : ", errorMessageOrNewValue)
    elseif errorMessageOrNewValue == nil then
        print(player.Name, "kept the existing record")
    else
        print(player.Name, "new best record : ", errorMessageOrNewValue)
    end
end
```

UpdateAsync on OrderedDataStore differs from DataStore in the following ways.

* The callback receives **only the current value** as its argument. DataStoreKeyInfo is not passed.
* The callback returns the **new integer value directly**, not a table.
* It returns the `updated value` when the value is saved, and `nil` when the callback returns nil and the write is canceled. A canceled call does not return the current value, so the return value tells you whether the write happened.

### Retrieving Scores

GetAsync returns the saved score, or nil if the key does not exist. OrderedDataStore does not support DataStoreKeyInfo, so the second return value is always nil.

```lua
local function LoadScore(player)
    local success, errorMessageOrValue = pcall(function()
        return LeaderboardStore:GetAsync(tostring(player.UserId))
    end)

    if not success then
        print("errorMessage : ", errorMessageOrValue)
    else
        local score = errorMessageOrValue or 0
        print(player.Name, "Score : ", score)
    end
end
```

### Retrieving Rankings

Use GetSortedAsync to retrieve saved scores in sorted order. The result is returned as a **DataStorePages** object, and GetCurrentPage() gives you the array of entries on the current page.

| Argument  | Description                                                                                                                   |
| --------- | ----------------------------------------------------------------------------------------------------------------------------- |
| ascending | Sorts from the lowest score (ascending) when true, and from the highest score (descending) when false. **Cannot be omitted.** |
| pagesize  | The maximum number of entries per page. Must be between 1 and 100, and **cannot be omitted.**                                 |
| minValue  | (Optional) Excludes scores lower than this value. The boundary value is included.                                             |
| maxValue  | (Optional) Excludes scores higher than this value. The boundary value is included.                                            |

Each entry is a table with a `key` (string) field and a `value` (integer) field.

```lua
local function PrintTop10()
    local success, errorMessageOrPages = pcall(function()
        return LeaderboardStore:GetSortedAsync(false, 10)
    end)

    if not success then
        print("errorMessage : ", errorMessageOrPages)
        return
    end

    local pages = errorMessageOrPages
    for rank, entry in ipairs(pages:GetCurrentPage()) do
        print(rank, " / UserId : ", entry.key, " / Score : ", entry.value)
    end
end
```

If no scores are saved at all, GetCurrentPage() returns an **empty table** rather than nil, so you can iterate over it without a separate nil check.

#### Retrieving a Score Range

Provide minValue and maxValue together to retrieve only the entries within a specific score range. An error occurs if maxValue is smaller than minValue.

```lua
local success, errorMessageOrPages = pcall(function()
    return LeaderboardStore:GetSortedAsync(false, 50, 1000, 5000)
end)
```

#### Reading Multiple Pages in Order

If IsFinished is false, more pages remain. Use AdvanceToNextPageAsync() to load the next page, and wrap it in pcall because this call can also fail.

```lua
local function ReadAllPages(pages)
    while true do
        for _, entry in ipairs(pages:GetCurrentPage()) do
            print(entry.key, entry.value)
        end

        if pages.IsFinished then
            break
        end

        local success, errorMessage = pcall(function()
            pages:AdvanceToNextPageAsync()
        end)

        if not success then
            print("errorMessage : ", errorMessage)
            break
        end
    end
end
```

* Calling AdvanceToNextPageAsync() on the last page raises an error, and the current page and IsFinished remain unchanged.
* If you call it again on the same DataStorePages while the next page is still loading, the later call **fails immediately**. Call it in a single sequential flow as in the example above. Once the preceding call ends, whether by success, failure, or timeout, you can call it again.
* Different DataStorePages objects can advance at the same time without affecting each other.

### Deleting Scores

Use RemoveAsync to delete a key, which returns the deleted score. The deleted key is excluded from subsequent ranking results.

```lua
local function RemoveScore(userId)
    local success, errorMessageOrRemovedValue = pcall(function()
        return LeaderboardStore:RemoveAsync(tostring(userId))
    end)

    if not success then
        print("errorMessage : ", errorMessageOrRemovedValue)
    end
end
```

Deletion is **permanent** and cannot be undone.

## Full Code Example

The following code **saves each player's best score** and has the server **retrieve the top 10 periodically** and send the result to every client.

Ranking retrieval has the narrowest request limit, so instead of querying on every client request, the server **retrieves once and shares the stored result**.

Place a RemoteEvent named **S2C\_UpdateLeaderboard** under ReplicatedStorage before using this code.

**Script (ServerScriptService)**

```lua
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local DataStoreService = game:GetService("DataStoreService")

local S2C_UpdateLeaderboard = ReplicatedStorage:WaitForChild("S2C_UpdateLeaderboard")
local LeaderboardStore = DataStoreService:GetOrderedDataStore("BestScoreLeaderboard")

local TOP_COUNT = 10
local REFRESH_INTERVAL = 60

local TopEntries = {}

local function SaveBestScore(player, score)
    local success, errorMessageOrNewValue = pcall(function()
        return LeaderboardStore:UpdateAsync(tostring(player.UserId), function(currentBest)
            if currentBest ~= nil and currentBest >= score then
                return nil
            end
            return score
        end)
    end)

    if not success then
        warn(player.Name, "SaveBestScore failed : ", errorMessageOrNewValue)
    end
end

local function RefreshTopEntries()
    local success, errorMessageOrPages = pcall(function()
        return LeaderboardStore:GetSortedAsync(false, TOP_COUNT)
    end)

    if not success then
        warn("RefreshTopEntries failed : ", errorMessageOrPages)
        return
    end

    local newEntries = {}
    for rank, entry in ipairs(errorMessageOrPages:GetCurrentPage()) do
        local userId = tonumber(entry.key)
        local player = Players:GetPlayerByUserId(userId)

        local displayName = entry.key
        if player then
            displayName = player.Name
        end

        table.insert(newEntries, {
            Rank = rank,
            Name = displayName,
            Score = entry.value,
        })
    end

    TopEntries = newEntries
    S2C_UpdateLeaderboard:FireAllClients(TopEntries)
end

task.spawn(function()
    while true do
        RefreshTopEntries()
        task.wait(REFRESH_INTERVAL)
    end
end)

Players.PlayerAdded:Connect(function(player)
    S2C_UpdateLeaderboard:FireClient(player, TopEntries)
end)

local function EndRound(player, roundScore)
    SaveBestScore(player, roundScore)
end
```

**LocalScript (StarterPlayerScripts)**

```lua
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local S2C_UpdateLeaderboard = ReplicatedStorage:WaitForChild("S2C_UpdateLeaderboard")

local function OnUpdateLeaderboard(entries)
    for _, entry in ipairs(entries) do
        print(entry.Rank, " / ", entry.Name, " / ", entry.Score)
    end
end
S2C_UpdateLeaderboard.OnClientEvent:Connect(OnUpdateLeaderboard)
```

* **SaveBestScore(player, score)**
  * Uses UpdateAsync to save only when the score is higher than the existing record
* **RefreshTopEntries()**
  * Uses GetSortedAsync to retrieve the top 10, stores them in TopEntries, and sends them to every client
  * Keeps the previous result and retries on the next cycle if the retrieval fails
  * Since the saved keys are UserIds, players connected to this server are displayed by name and the rest are displayed by UserId
* **PlayerAdded**
  * Sends the stored result to a joining player instead of retrieving the rankings again
* **OnUpdateLeaderboard(entries)**
  * Prints the received rankings as an example, so replace print with code that updates your leaderboard UI

## Difference Between Actions in Published and Test Environments

After publishing, scores are saved on the live server in mobile environments, and every server shares the same leaderboard.

However, in studio test environments, a **temporary store** is used without any additional setup, and the saved data is automatically deleted when the test session ends. Verify actual saving and behavior across multiple servers in the published environment.

## Important Notes

### Every Call Can Fail

OrderedDataStore functions can fail depending on network conditions or request limits, and failures are delivered as errors. **Wrap every call in pcall** and decide what should happen on failure.

If you ignore failures, rankings can silently go stale while displaying outdated information.

### Do Not Retry a Failed IncrementAsync As Is

A failed save call means **the result was not received, not that the value was not saved.** Calling IncrementAsync again when the score has already been added can add the score twice.

If you need to retry, saving the final score the server knows about with SetAsync is the safer approach.

### Account for Request Limits

The number of requests OrderedDataStore can send within a given period is limited.

* Limits apply at two levels, the **entire world** and **each server**, and exceeding either one prevents the request from being processed. Limits grow with the number of players, but splitting players into more servers does not raise the world-wide limit.
* **Ranking retrieval (GetSortedAsync, AdvanceToNextPageAsync) has the narrowest limit.** Instead of querying on every client request, share a result the server retrieves periodically, as in the full code example.
* Ranking retrieval **fails immediately** when the limit is exceeded. Try again after a short while.
* Saving, deleting, and retrieving a single key do not fail when the limit is exceeded; they **wait until they can be processed**. The call takes longer than usual, and new requests fail once too many requests are waiting.
* GetAsync on OrderedDataStore **shares the retrieval limit with GetAsync on DataStore**. Frequently loading player data also reduces the headroom available for retrieving scores.
* There is no function for checking the remaining number of requests in advance. Write your code assuming requests can be rejected, using pcall and retry intervals.

### Retrieved Values Are Not Cached

GetAsync and GetSortedAsync send a new request and use the request limit every time they are called. When the same value is used in several places, retrieve it once, store it in a variable, and share it.

In addition, a value saved on another server **may briefly return the previous value.**

### The Range of Integers Saved Exactly

Only integers within **-9,007,199,254,740,991 to 9,007,199,254,740,991** (±(2^53 - 1)) are saved and retrieved exactly.

* If your script uses a number outside this range, the value can already be rounded before it is saved, and the original value cannot be recovered.
* If a saved value cannot be represented exactly in a script, **the retrieval fails** rather than returning an approximate value.

### Write Only Calculations in the UpdateAsync Callback

When multiple servers update the same key at the same time, the callback **can run again with the latest value**, and the number of executions is not guaranteed.

* **Do not perform external actions** inside the callback, such as firing a RemoteEvent or writing to another store. They can run more than once.
* **Do not call waiting functions** such as `task.wait()` inside the callback. The callback is interrupted at that point and the score is not saved. The call itself returns nil without an error, so **pcall cannot detect the failure** — you can only confirm it through the error log in the Output window and the nil return value.

### The Order of Tied Entries Is Not Guaranteed

Entries with the same score can be ordered differently on each retrieval. If you need a unique rank among tied players, **build the criterion into the value itself**, such as adding a secondary factor so that the record achieved first ranks higher.

### Rankings Can Change While You Page Through Them

The result of GetSortedAsync is not a fixed snapshot taken at retrieval time. If scores are added or deleted after you read the first page, those changes are reflected in the next page, so **the same entry can appear twice or be missed.** Remove duplicates by key when displaying multiple pages.

### Saves Right Before Server Shutdown May Be Partially Applied

Once the server starts shutting down, new requests fail immediately. Among the waiting requests, **only the last request per key** is processed, and requests not processed within a limited time are discarded.

* If several IncrementAsync calls were waiting on the same key, only the last delta is applied and **the earlier accumulated amounts are lost.**
* Instead of saving scores all at once when a player leaves, **save them incrementally during gameplay.** This reduces how much is lost at shutdown.

## Usage Examples

* Accumulate records such as eliminations or collected coins with IncrementAsync to build an all-time cumulative ranking.
* For games where a lower record is better, such as speedruns, convert the clear time into an integer and retrieve it in ascending order with `GetSortedAsync(true, ...)`.
* Specify a season name as the scope to run a new leaderboard each season while keeping previous seasons available for retrieval.
* Retrieve score ranges with minValue and maxValue to build tier-based rankings such as bronze, silver, and gold.
* Use WorldRankService for the app and above-character ranking display, and OrderedDataStore for the detailed leaderboard inside the world.
