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.
-- 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
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.
How daily rewards and streaks work
| Time since last claim | Result |
|---|---|
| Less than 24 hours | Not ready. The button counts down. |
| 24 to 48 hours | Claim allowed, streak goes up one day. |
| More than 48 hours | Claim allowed, streak starts over at day 1. |
| After day 7 | The 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.
-- 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.
-- 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
- Set
CLAIM_EVERYto30andSTREAK_LOST_AFTERto60for testing. - Press Play. The button says "Claim day 1!". Press it: +50 coins.
- The button counts down 30 seconds. Claim again within the next 30 seconds: day 2, +75.
- Wait more than a minute: the next claim is day 1 again.
- 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.
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.
- 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
- Roblox Creator Docs: GlobalDataStore:UpdateAsync. the update function and cancelling with nil
- Roblox Creator Docs: Data stores. Studio access and data store basics
- Roblox Creator Docs: Remote events and functions. RemoteFunction
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.