Tutorial · Player data

How to make a leaderboard in Roblox Studio

The player list in the top right of a Roblox game is driven by one folder called leaderstats. This guide builds it from scratch: the server script, a safe way to award points, saving between sessions, and a global top 10.

Updated 30 September 20266 min readBeginnerLuau

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
  1. How leaderstats works
  2. 1. Create leaderstats
  3. 2. Award points
  4. 3. Save stats
  5. 4. Global top 10
  6. Common errors
  7. With RoCode
  8. Questions
  9. Sources

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.

Value objects you can put in leaderstats
ObjectHoldsGood for
IntValueWhole numbersCoins, kills, wins, level
NumberValueDecimal numbersBest lap time, distance
StringValueTextRank 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

  1. In the Explorer, hover over ServerScriptService, click the + button and insert a Script.
  2. Rename it Leaderstats so you can find it later.
  3. Replace its contents with the code below and press Play. Your name appears in the player list with Coins and Wins at 0.
Script in ServerScriptService named Leaderstats
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.

Script in a Part in Workspace named CoinPad
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.

Script in ServerScriptService named Leaderstats
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.

  1. 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.
  2. Insert a Script into ServerScriptService named GlobalWinsBoard with the code below.
  3. Play. The board fills once the first refresh has run, and updates every minute.
Script in ServerScriptService named GlobalWinsBoard
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.

You type

Add a leaderboard with Coins and Wins that saves between sessions, and a top 10 wins board on a part near the spawn.

RoCode does
  • 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

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.

Try RoCode on your own place.

Free every day, no card required. Install the plugin, describe the feature, and review what lands in Studio.