In short
Create a Script in ServerScriptService that listens to Players.PlayerAdded, makes a Folder named exactly leaderstats (all lowercase) and parents it to the player. Every IntValue, NumberValue or StringValue inside that folder becomes a column in the player list.
- Create and change stats on the server only. A change made by a LocalScript is seen by that one player and nobody else.
- leaderstats is not saved. Load and save the values with a data store if they should survive the next session.
- For a ranking across every player, write each score to an ordered data store and show it on a board in your world, or in the player list's Global view.
On this page
How leaderstats works
Roblox shows a player list in every game. When a player has a child Folder named leaderstats, the list adds a column for each value object inside it and shows that player's value in their row. The column title is the value object's Name.
| Object | Holds | Good for |
|---|---|---|
IntValue | Whole numbers | Coins, kills, wins, level |
NumberValue | Decimal numbers | Best lap time, distance |
StringValue | Text | Rank or team title |
Two rules cause almost every broken leaderboard. The folder name must be leaderstats exactly: Leaderstats or leaderStats is ignored. And the folder must be made by a server Script, because the server is the only place a change replicates to every player. If you are unsure where code runs, read Script vs LocalScript vs ModuleScript first.
Step 1: Create the leaderstats script
- In the Explorer, hover over ServerScriptService, click the + button and insert a Script.
- Rename it
Leaderstatsso you can find it later. - Replace its contents with the code below and press Play. Your name appears in the player list with Coins and Wins at 0.
local Players = game:GetService("Players")
local function setupLeaderstats(player: Player)
local leaderstats = Instance.new("Folder")
leaderstats.Name = "leaderstats" -- must be exactly this name, all lowercase
local coins = Instance.new("IntValue")
coins.Name = "Coins"
coins.Value = 0
coins.Parent = leaderstats
local wins = Instance.new("IntValue")
wins.Name = "Wins"
wins.Value = 0
wins.Parent = leaderstats
leaderstats.Parent = player -- parent last, once the values are inside
end
Players.PlayerAdded:Connect(setupLeaderstats)
-- In Studio you can join before this script runs, so set up anyone already here
for _, player in Players:GetPlayers() do
setupLeaderstats(player)
end
The loop at the end matters in Studio testing: the first player sometimes joins before PlayerAdded is connected, and that player would get no stats.
Step 2: Give players points, safely
To change a stat, set its Value from a server script. This coin pad pays 10 coins when a player touches it. Touched fires many times a second while a character stands on a part, so the script keeps a short cooldown per player.
local Players = game:GetService("Players")
local part = script.Parent :: BasePart
local REWARD = 10
local COOLDOWN_SECONDS = 1
local lastReward: { [Player]: number } = {}
part.Touched:Connect(function(hit: BasePart)
local character = hit:FindFirstAncestorOfClass("Model")
local player = character and Players:GetPlayerFromCharacter(character)
if not player then
return
end
local now = os.clock()
local last = lastReward[player]
if last and now - last < COOLDOWN_SECONDS then
return -- Touched fires many times per step; this stops one touch paying out ten times
end
lastReward[player] = now
local leaderstats = player:FindFirstChild("leaderstats")
local coins = leaderstats and leaderstats:FindFirstChild("Coins")
if coins and coins:IsA("IntValue") then
coins.Value += REWARD
end
end)
Players.PlayerRemoving:Connect(function(player: Player)
lastReward[player] = nil
end)
FindFirstAncestorOfClass("Model") finds the character even when the touching part is inside an accessory, which a plain hit.Parent check misses.
If points come from something the player does on their own screen, such as clicking a button, the LocalScript should ask the server with a RemoteEvent and the server should decide whether to pay. Never let the client send the amount. The RemoteEvents guide shows the pattern with validation.
Step 3: Save the stats between sessions
leaderstats lives only as long as the server. To keep coins and wins, load them from a data store when the player joins and save them when they leave. This version replaces the Step 1 script. It never saves a player whose data failed to load, which is the mistake that wipes progress in most copied save scripts.
local Players = game:GetService("Players")
local DataStoreService = game:GetService("DataStoreService")
local statsStore = DataStoreService:GetDataStore("PlayerStats_v1")
-- Players whose saved stats loaded. Anyone missing from here is never saved,
-- so a failed load cannot overwrite real progress with zeros.
local loaded: { [Player]: boolean } = {}
local function keyFor(player: Player): string
return "player_" .. player.UserId
end
local function onPlayerAdded(player: Player)
local leaderstats = Instance.new("Folder")
leaderstats.Name = "leaderstats"
local coins = Instance.new("IntValue")
coins.Name = "Coins"
coins.Parent = leaderstats
local wins = Instance.new("IntValue")
wins.Name = "Wins"
wins.Parent = leaderstats
local ok, data = pcall(function()
return statsStore:GetAsync(keyFor(player))
end)
if ok then
if type(data) == "table" then
coins.Value = tonumber(data.Coins) or 0
wins.Value = tonumber(data.Wins) or 0
end
loaded[player] = true
else
warn(`Could not load stats for {player.Name}: {data}`)
end
leaderstats.Parent = player
end
local function save(player: Player)
if not loaded[player] then
return
end
local leaderstats = player:FindFirstChild("leaderstats")
local coins = leaderstats and leaderstats:FindFirstChild("Coins")
local wins = leaderstats and leaderstats:FindFirstChild("Wins")
if not (coins and coins:IsA("IntValue") and wins and wins:IsA("IntValue")) then
return
end
local ok, err = pcall(function()
statsStore:SetAsync(keyFor(player), { Coins = coins.Value, Wins = wins.Value })
end)
if not ok then
warn(`Could not save stats for {player.Name}: {err}`)
end
end
Players.PlayerAdded:Connect(onPlayerAdded)
for _, player in Players:GetPlayers() do
task.spawn(onPlayerAdded, player)
end
Players.PlayerRemoving:Connect(function(player: Player)
save(player)
loaded[player] = nil
end)
-- When the server shuts down, save everyone before it closes
game:BindToClose(function()
local pending = 0
for _, player in Players:GetPlayers() do
pending += 1
task.spawn(function()
save(player)
pending -= 1
end)
end
while pending > 0 do
task.wait()
end
end)
This is enough for a small game. For retries, UpdateAsync, request limits and protecting against two servers writing the same player, continue with the DataStore tutorial.
Step 4: A global top 10
leaderstats only compares players in the same server. A global ranking needs an ordered data store: a data store that keeps one number per key and can return the keys sorted by value. Write each player's score to it from the server, then read the top entries back.
You have two ways to show it. Roblox's player list has Friends and Global views that read from an ordered data store you register in Creator Hub (a beta feature at the time of writing, one active leaderboard per game). Or you can draw the ranking yourself on a board in your world, which works in every game and lets you style it. The script below does the second, and the scores it writes use the player's user ID as the key, which is also the format the Creator Hub leaderboard expects.
- Add an anchored Part to Workspace, name it
WinsBoard, and size it like a sign (for example 12 by 8 by 1 studs), front face towards your spawn. - Insert a Script into ServerScriptService named
GlobalWinsBoardwith the code below. - Play. The board fills once the first refresh has run, and updates every minute.
local Players = game:GetService("Players")
local DataStoreService = game:GetService("DataStoreService")
local winsBoard = DataStoreService:GetOrderedDataStore("GlobalWins_v1")
local TOP_N = 10
local REFRESH_SECONDS = 60
-- The board: an anchored Part named WinsBoard in Workspace. The script adds the
-- SurfaceGui and the rows itself, so the Part is all you have to build.
local boardPart = workspace:WaitForChild("WinsBoard") :: BasePart
local gui = Instance.new("SurfaceGui")
gui.Face = Enum.NormalId.Front
gui.SizingMode = Enum.SurfaceGuiSizingMode.PixelsPerStud
gui.PixelsPerStud = 50
gui.Parent = boardPart
local list = Instance.new("Frame")
list.Size = UDim2.fromScale(1, 1)
list.BackgroundColor3 = Color3.fromRGB(18, 11, 31)
list.Parent = gui
local layout = Instance.new("UIListLayout")
layout.SortOrder = Enum.SortOrder.LayoutOrder
layout.Parent = list
local nameCache: { [number]: string } = {}
local function nameFor(userId: number): string
local cached = nameCache[userId]
if cached then
return cached
end
local ok, name = pcall(function()
return Players:GetNameFromUserIdAsync(userId)
end)
local result = if ok then name else "Unknown player"
nameCache[userId] = result
return result
end
local function recordWins(player: Player)
local leaderstats = player:FindFirstChild("leaderstats")
local wins = leaderstats and leaderstats:FindFirstChild("Wins")
if wins and wins:IsA("IntValue") then
local ok, err = pcall(function()
winsBoard:SetAsync(tostring(player.UserId), wins.Value)
end)
if not ok then
warn(`Could not record wins for {player.Name}: {err}`)
end
end
end
local function refresh()
local ok, pages = pcall(function()
return winsBoard:GetSortedAsync(false, TOP_N)
end)
if not ok then
warn(`Could not read the wins board: {pages}`)
return
end
for _, child in list:GetChildren() do
if child:IsA("TextLabel") then
child:Destroy()
end
end
for rank, entry in pages:GetCurrentPage() do
local label = Instance.new("TextLabel")
label.Size = UDim2.new(1, 0, 1 / TOP_N, 0)
label.BackgroundTransparency = 1
label.TextColor3 = Color3.new(1, 1, 1)
label.TextScaled = true
label.Text = `{rank}. {nameFor(tonumber(entry.key) or 0)} {entry.value}`
label.LayoutOrder = rank
label.Parent = list
end
end
while true do
for _, player in Players:GetPlayers() do
recordWins(player)
end
refresh()
task.wait(REFRESH_SECONDS)
end
GetSortedAsync(false, 10) returns the 10 highest values first. Ordered data stores hold whole numbers only, so store scores as integers. The script writes each player once a minute and reads the top 10 once a minute, well inside the default per-server limits (30 + 5 per player per minute for ordered writes and 5 + 2 per player for sorted reads, as documented on 30 September 2026).
Common errors and fixes
The problems below account for most "my leaderboard doesn't work" posts. Check the Output window first (open it from Studio's Window menu or the Script tab): if a script errored, the line number is there.
The leaderboard does not show at all
Check the folder name is leaderstats in lowercase, that it is parented to the Player (not the character), and that the code is a Script in ServerScriptService, not a LocalScript.
Other players do not see my stat change
The value was changed on the client. Move the change into a server Script, and if a button or key press triggers it, send a RemoteEvent and let the server change the value.
attempt to index nil with 'Coins'
Code tried to read player.leaderstats.Coins before the folder existed, or the name is spelled differently. On the server use FindFirstChild and check the result; in a LocalScript use player:WaitForChild("leaderstats"). The script errors guide explains this error in detail.
Coins go up by 10, 20 or 50 from one touch
Touched fires repeatedly while parts overlap. Add a per-player cooldown as in Step 2, or remove the pickup when it is collected.
Stats reset every time I rejoin
leaderstats is not saved by Roblox. Add the Step 3 script. To test saving in Studio, publish the place and enable Studio Access to API Services in Experience Settings.
Build it with RoCode
If you would rather not wire this up by hand, RoCode can build it inside the place you have open. It is an AI agent that works through a Roblox Studio plugin, so it creates the scripts and parts in your Explorer instead of giving you code to paste.
Add a leaderboard with Coins and Wins that saves between sessions, and a top 10 wins board on a part near the spawn.
- Can search your place for existing leaderstats or DataStore code first, so it can extend what you have rather than add a second leaderboard
- Creates the server script in ServerScriptService and the board part in Workspace
- Compile-checks the scripts it wrote inside Studio before it finishes
- Sends its changes in batches, and each batch is marked as an undo point in Studio's history
You still review the result and play-test it yourself: RoCode does not start playtests. How RoCode connects to Studio.
Questions
Does Roblox save leaderstats automatically?
No. leaderstats only exists while the player is in the server. Save the values with a data store when the player leaves and load them when they join, as in Step 3.
Can I hide a value from the leaderboard?
Yes. Only values inside the leaderstats folder are shown. Keep private data in another folder on the player, or in a table in a server script.
How do I hide the whole player list?
From a LocalScript, call StarterGui:SetCoreGuiEnabled(Enum.CoreGuiType.PlayerList, false). Pass true to show it again.
Can I use a NumberValue for coins?
You can, but an IntValue is the better fit for whole-number currency, and ordered data stores only accept whole numbers if you later rank by it.
Sources and further reading
- Roblox Creator Docs: Leaderboards. leaderstats, stat order, and the persistent Friends and Global views
- Roblox Creator Docs: Data stores. GetAsync, SetAsync, ordered data stores and limits
How the code was checked: every script on this page passes Luau's strict type checker against Roblox's API definitions (luau-lsp, 30 September 2026). A type check catches misspelt APIs and wrong types, not game logic, so play-test in Studio before you publish.