Tutorial · Rewards

How to make daily rewards in Roblox Studio

A daily reward gives players a reason to open your game tomorrow, and a streak gives them a reason not to miss a day. The logic is a saved timestamp: when did this player last claim, and how many days in a row. This guide stores both safely, so a player cannot claim twice by rejoining or by having two servers open, and adds a button that counts down to the next reward.

Updated 23 October 20265 min readIntermediateLuau

In short

Save two numbers per player in a DataStore: last, the os.time() of the last claim, and streak. A claim is allowed when 24 hours have passed. If more than 48 hours passed, the streak starts over at day 1; otherwise it goes up by one, cycling through 7 days of rising rewards. Do the check inside UpdateAsync, so the decision and the save happen together.

  • Use os.time() on the server, never a time sent by the client.
  • Inside UpdateAsync, return nil when the claim is not allowed: the save is cancelled and nothing is paid.
  • The client only asks for the status and for a claim; the server answers.

Get the script

Daily rewards script with a streak

A Script in ServerScriptService: one claim every 24 hours, a 7-day streak of rising coin rewards that resets after 48 hours, saved with UpdateAsync.

Script in ServerScriptService named DailyRewards
-- Script in ServerScriptService named DailyRewards.
-- One reward every 24 hours. Claiming again within 48 hours keeps the streak going
-- and the rewards grow each day for a week, then the week starts over.
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local REWARDS = { 50, 75, 100, 150, 200, 300, 500 } -- coins for day 1 to day 7 of a streak
local CLAIM_EVERY = 24 * 60 * 60 -- seconds between claims
local STREAK_LOST_AFTER = 48 * 60 * 60 -- miss a whole day and the streak starts over

type Record = { last: number, streak: number }

local store = DataStoreService:GetDataStore("DailyRewards_v1")
local statusFunction = Instance.new("RemoteFunction")
statusFunction.Name = "GetDailyStatus"
statusFunction.Parent = ReplicatedStorage
local claimFunction = Instance.new("RemoteFunction")
claimFunction.Name = "ClaimDaily"
claimFunction.Parent = ReplicatedStorage

local records: { [Player]: Record } = {}
local claiming: { [Player]: boolean } = {}

local function readRecord(data: any): Record
	if type(data) == "table" and type(data.last) == "number" and type(data.streak) == "number" then
		return { last = data.last, streak = data.streak }
	end
	return { last = 0, streak = 0 }
end

-- The streak day the next claim will be, from 1 to 7
local function nextDay(record: Record, now: number): number
	if now - record.last >= STREAK_LOST_AFTER then
		return 1
	end
	return record.streak % #REWARDS + 1
end

local function addCoins(player: Player, amount: number)
	local leaderstats = player:FindFirstChild("leaderstats")
	local coins = leaderstats and leaderstats:FindFirstChild("Coins")
	if coins and coins:IsA("IntValue") then
		coins.Value += amount
	end
end

Players.PlayerAdded:Connect(function(player: Player)
	local ok, data = pcall(function()
		return store:GetAsync(tostring(player.UserId))
	end)
	if ok then
		records[player] = readRecord(data)
	else
		warn("DailyRewards: could not load", player.Name, data)
	end
end)

Players.PlayerRemoving:Connect(function(player: Player)
	records[player] = nil
	claiming[player] = nil
end)

-- What the menu shows: seconds until the next claim (0 = ready now) and which day it is
statusFunction.OnServerInvoke = function(player: Player)
	local record = records[player]
	if not record then
		return nil
	end
	local now = os.time()
	return {
		wait = math.max(0, record.last + CLAIM_EVERY - now),
		day = nextDay(record, now),
		rewards = REWARDS,
	}
end

claimFunction.OnServerInvoke = function(player: Player): string
	local record = records[player]
	if not record then
		return "Still loading, try again in a moment"
	end
	if claiming[player] then
		return "One moment..."
	end
	claiming[player] = true
	local now = os.time()
	local claimedDay: number? = nil
	-- UpdateAsync reads the newest saved record and decides inside the update, so a
	-- second server (or a second click) cannot claim the same day twice
	local ok, err = pcall(function()
		store:UpdateAsync(tostring(player.UserId), function(old: any)
			-- UpdateAsync can run this more than once: only the last run counts
			claimedDay = nil
			local saved = readRecord(old)
			if now - saved.last < CLAIM_EVERY then
				return nil -- not ready: returning nil cancels the save
			end
			local day = nextDay(saved, now)
			claimedDay = day
			return { last = now, streak = day }
		end)
	end)
	claiming[player] = nil
	if not ok then
		warn("DailyRewards: could not save", player.Name, err)
		return "Could not save, try again later"
	end
	local day = claimedDay
	if not day then
		return "Come back later for your next reward"
	end
	records[player] = { last = now, streak = day }
	addCoins(player, REWARDS[day])
	return string.format("Day %d reward: +%d coins!", day, REWARDS[day])
end

RoCode builds it in the place you have open in Roblox Studio, on Windows or macOS with a desktop browser; on a phone the button sends the prompt and the link to your computer. Free to start, with free credits every day. More Roblox Studio scripts

Where to put it

Explorer: the server script and the button
ServerScriptService
  DailyRewards Script new
StarterPlayer
  StarterPlayerScripts
    DailyRewardsMenu LocalScript new

Settings you can change

REWARDSIn the script
Coins for day 1 to day 7 of a streak. Add or remove numbers to change the length of the cycle. Default: { 50, 75, 100, 150, 200, 300, 500 }.
CLAIM_EVERYIn the script
Seconds between claims. Lower it while testing. Default: 24 * 60 * 60.

Turn on Enable Studio Access to API Services (Game Settings, Security) to test in Studio. Players need a Coins leaderstat.

What a cheater can and cannot doThe server uses its own clock and decides inside UpdateAsync, which reads the newest saved value, so rejoining, clicking fast or playing on two servers at once cannot claim the same day twice.

Type-checked with luau-lsp strict against Roblox's API definitions on 23 October 2026. Not playtested in Studio yet: press Play and watch the Output window before you publish.

Prompt for RoCode

Add daily rewards: a server Script that lets each player claim coins once every 24 hours with a 7-day streak (50, 75, 100, 150, 200, 300, 500) that resets if they miss more than 48 hours, saved with UpdateAsync so it cannot be claimed twice, and a button that shows a countdown and says Claim when ready.

On this page
  1. Get the script
  2. How it works
  3. 1. Server
  4. 2. Button
  5. Common errors
  6. With RoCode
  7. Questions
  8. Sources

How daily rewards and streaks work

What a claim does, by time since the last claim
Time since last claimResult
Less than 24 hoursNot ready. The button counts down.
24 to 48 hoursClaim allowed, streak goes up one day.
More than 48 hoursClaim allowed, streak starts over at day 1.
After day 7The next claim is day 1 of a new week.

os.time() on the server returns the current Unix time, in seconds and in UTC, so every server agrees on it. The player's computer clock plays no part, which is why changing the date on a phone does nothing.

Step 1: The rewards server

Turn on Enable Studio Access to API Services in Game Settings, Security (the place must be published). Then insert a Script in ServerScriptService named DailyRewards:

Script in ServerScriptService named DailyRewards
-- Script in ServerScriptService named DailyRewards.
-- One reward every 24 hours. Claiming again within 48 hours keeps the streak going
-- and the rewards grow each day for a week, then the week starts over.
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local REWARDS = { 50, 75, 100, 150, 200, 300, 500 } -- coins for day 1 to day 7 of a streak
local CLAIM_EVERY = 24 * 60 * 60 -- seconds between claims
local STREAK_LOST_AFTER = 48 * 60 * 60 -- miss a whole day and the streak starts over

type Record = { last: number, streak: number }

local store = DataStoreService:GetDataStore("DailyRewards_v1")
local statusFunction = Instance.new("RemoteFunction")
statusFunction.Name = "GetDailyStatus"
statusFunction.Parent = ReplicatedStorage
local claimFunction = Instance.new("RemoteFunction")
claimFunction.Name = "ClaimDaily"
claimFunction.Parent = ReplicatedStorage

local records: { [Player]: Record } = {}
local claiming: { [Player]: boolean } = {}

local function readRecord(data: any): Record
	if type(data) == "table" and type(data.last) == "number" and type(data.streak) == "number" then
		return { last = data.last, streak = data.streak }
	end
	return { last = 0, streak = 0 }
end

-- The streak day the next claim will be, from 1 to 7
local function nextDay(record: Record, now: number): number
	if now - record.last >= STREAK_LOST_AFTER then
		return 1
	end
	return record.streak % #REWARDS + 1
end

local function addCoins(player: Player, amount: number)
	local leaderstats = player:FindFirstChild("leaderstats")
	local coins = leaderstats and leaderstats:FindFirstChild("Coins")
	if coins and coins:IsA("IntValue") then
		coins.Value += amount
	end
end

Players.PlayerAdded:Connect(function(player: Player)
	local ok, data = pcall(function()
		return store:GetAsync(tostring(player.UserId))
	end)
	if ok then
		records[player] = readRecord(data)
	else
		warn("DailyRewards: could not load", player.Name, data)
	end
end)

Players.PlayerRemoving:Connect(function(player: Player)
	records[player] = nil
	claiming[player] = nil
end)

-- What the menu shows: seconds until the next claim (0 = ready now) and which day it is
statusFunction.OnServerInvoke = function(player: Player)
	local record = records[player]
	if not record then
		return nil
	end
	local now = os.time()
	return {
		wait = math.max(0, record.last + CLAIM_EVERY - now),
		day = nextDay(record, now),
		rewards = REWARDS,
	}
end

claimFunction.OnServerInvoke = function(player: Player): string
	local record = records[player]
	if not record then
		return "Still loading, try again in a moment"
	end
	if claiming[player] then
		return "One moment..."
	end
	claiming[player] = true
	local now = os.time()
	local claimedDay: number? = nil
	-- UpdateAsync reads the newest saved record and decides inside the update, so a
	-- second server (or a second click) cannot claim the same day twice
	local ok, err = pcall(function()
		store:UpdateAsync(tostring(player.UserId), function(old: any)
			-- UpdateAsync can run this more than once: only the last run counts
			claimedDay = nil
			local saved = readRecord(old)
			if now - saved.last < CLAIM_EVERY then
				return nil -- not ready: returning nil cancels the save
			end
			local day = nextDay(saved, now)
			claimedDay = day
			return { last = now, streak = day }
		end)
	end)
	claiming[player] = nil
	if not ok then
		warn("DailyRewards: could not save", player.Name, err)
		return "Could not save, try again later"
	end
	local day = claimedDay
	if not day then
		return "Come back later for your next reward"
	end
	records[player] = { last = now, streak = day }
	addCoins(player, REWARDS[day])
	return string.format("Day %d reward: +%d coins!", day, REWARDS[day])
end

The claim happens inside the function passed to UpdateAsync. Roblox calls it with the newest saved record. If it is too soon, the function returns nil and Roblox cancels the write. Otherwise it returns the new record, and the script remembers which day was claimed. Only after UpdateAsync finishes without an error are coins added. If another server changed the key in the meantime, Roblox may call the function again with the newer value, which is why the decision is made inside it.

Step 2: The countdown button

Insert a LocalScript in StarterPlayer > StarterPlayerScripts named DailyRewardsMenu:

LocalScript in StarterPlayerScripts named DailyRewardsMenu
-- LocalScript in StarterPlayerScripts named DailyRewardsMenu.
-- A Daily button with a countdown. When the reward is ready it says Claim.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local statusFunction = ReplicatedStorage:WaitForChild("GetDailyStatus") :: RemoteFunction
local claimFunction = ReplicatedStorage:WaitForChild("ClaimDaily") :: RemoteFunction

local gui = Instance.new("ScreenGui")
gui.Name = "DailyGui"
gui.ResetOnSpawn = false
local button = Instance.new("TextButton")
button.Size = UDim2.fromOffset(170, 48)
button.Position = UDim2.new(0, 12, 0.5, 52)
button.BackgroundColor3 = Color3.fromRGB(240, 170, 40)
button.TextColor3 = Color3.new(1, 1, 1)
button.Font = Enum.Font.GothamBold
button.TextSize = 17
button.Text = "Daily reward"
local corner = Instance.new("UICorner")
corner.CornerRadius = UDim.new(0, 10)
corner.Parent = button
button.Parent = gui
gui.Parent = Players.LocalPlayer:WaitForChild("PlayerGui")

local readyAt: number? = nil -- os.clock() time when the reward is ready
local day = 1
local busy = false

local function refresh()
	local ok, status = pcall(function()
		return statusFunction:InvokeServer()
	end)
	if ok and type(status) == "table" and type(status.wait) == "number" then
		readyAt = os.clock() + status.wait
		day = if type(status.day) == "number" then status.day else 1
	end
end

local function format(seconds: number): string
	local h = seconds // 3600
	local m = (seconds % 3600) // 60
	local s = seconds % 60
	return string.format("%02d:%02d:%02d", h, m, s)
end

button.Activated:Connect(function()
	if busy or not readyAt or os.clock() < readyAt then
		return
	end
	busy = true
	button.Text = "Claiming..."
	local ok, message = pcall(function()
		return claimFunction:InvokeServer()
	end)
	button.Text = if ok and type(message) == "string" then message else "Try again"
	task.wait(2)
	refresh()
	busy = false
end)

refresh()
while true do
	if not busy then
		if not readyAt then
			button.Text = "Loading..."
			refresh()
		elseif os.clock() >= readyAt then
			button.Text = string.format("Claim day %d!", day)
		else
			button.Text = "Next: " .. format(math.ceil(readyAt - os.clock()))
		end
	end
	task.wait(1)
end

The button asks the server once how long to wait, then counts down on its own every second with os.clock(), so it does not call the server every second. After a claim it asks again. The countdown is only for show: the server still checks the time when the player presses Claim.

Test it

  1. Set CLAIM_EVERY to 30 and STREAK_LOST_AFTER to 60 for testing.
  2. Press Play. The button says "Claim day 1!". Press it: +50 coins.
  3. The button counts down 30 seconds. Claim again within the next 30 seconds: day 2, +75.
  4. Wait more than a minute: the next claim is day 1 again.
  5. Put both values back to the 24 and 48 hour lines before publishing.

Common errors and fixes

Watch the Output window on the Server view.

The button stays on Loading...

The player's record did not load. In Studio, turn on Enable Studio Access to API Services. The Output shows the error after "could not load".

Claiming says come back later even though the button said Claim

The saved record was newer than the button knew, for example after a claim on another server. The server's answer is the correct one.

The streak resets every day

Players are claiming more than 48 hours apart, or STREAK_LOST_AFTER was left at a test value.

Coins are not added

The player needs a Coins IntValue in leaderstats made by a server script.

Build it with RoCode

If you would rather not set this up by hand, RoCode can build it in the place you have open. RoCode is an independent AI agent for Roblox Studio, not made by Roblox: you describe the change in a web chat, and its Studio plugin creates the scripts in your Explorer.

You type

Add daily rewards with a 7-day streak: coins on days 1 to 6 and a gem on day 7, a countdown button, saved and checked on the server.

RoCode does
  • Can insert a ready-made, animated daily rewards screen from its premium UI kits in one step, in one of five themes (candy, neon, gold, blocky, pastel), with working server scripts
  • Or writes the server script and button from this guide, wired to your existing currency
  • Is told to compile-check each script with Check Script, and sets undo points around each batch

After a build, RoCode can run its test robot: it plays your game in Studio, clicks buttons and reports PASS or FAIL. It checks only what it chose to test, and a 24-hour timer needs your own test with short values. How RoCode connects to Studio.

Questions

How do I make daily rewards in Roblox?

Save each player's last claim time with os.time() on the server, allow a claim after 24 hours, and save the claim inside UpdateAsync before paying. Step 1 has the script.

How do I make a daily login streak?

Save a streak count with the claim time. If the next claim comes within 48 hours, add one; if later, start over at 1.

Can players cheat daily rewards by changing their clock?

Not with this script: it only uses the server's os.time().

Should the reward reset at midnight instead?

You can count calendar days in UTC instead of 24 hours. A rolling 24 hours is simpler and treats every time zone the same.

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, 23 October 2026). A type check catches misspelt APIs and wrong types, not game logic, so playtest 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.