Tutorial · Game systems

How to make a simulator game in Roblox Studio

Most simulator games run the same loop: do something simple to earn a currency, spend it on upgrades that earn it faster, then rebirth to start again with a permanent multiplier. This guide builds that loop as a clicker in three scripts, with the server deciding every number, and saves it with the PlayerData module from the DataStore tutorial.

Updated 13 October 202619 min readBeginnerLuau

In short

Keep the prices and formulas in a ModuleScript in ReplicatedStorage. A Script in ServerScriptService owns each player's Coins, Click Power level and Rebirths: a Click RemoteEvent adds Click Power × (Rebirths + 1) coins, counting at most 15 clicks in each one-second window, and two RemoteFunctions sell the next Click Power level and the rebirth after checking the price. A LocalScript builds the Click, Upgrade and Rebirth buttons and only displays the numbers.

  • A click sends nothing but the click. The server works out what it is worth, so an edited client cannot give itself coins.
  • Rate limit clicks on the server. An exploiter can fire a remote as often as they like, and Roblox's security guide names awarding currency as something to limit.
  • Save the whole table on a timer and when players leave, never on each click. Roblox allows each server 60 + 40 × players data store writes a minute.
On this page
  1. How it works
  2. 1. Config module
  3. 2. Server script
  4. 3. Buttons
  5. 4. Save progress
  6. Why the server decides
  7. 5. Test it
  8. Extending it
  9. Common errors
  10. With RoCode
  11. Questions
  12. Sources

How a simulator's core loop works

IllustrationThe player buys Click Power level 2 for 10 coins, then (sped up) reaches 1,000 coins and rebirths: coins and upgrades reset and every click is worth x2.

A simulator is a loop of three actions. The player does something simple over and over to earn a currency, spends it on upgrades that earn it faster, and when the upgrades get expensive, rebirths: they give up their coins and upgrades for a bonus that never goes away, then climb again faster. Other simulators replace the click with lifting, mining or collecting, but the loop underneath is the same.

The three actions in this guide's loop
ActionWhat it costsWhat it givesWho decides
ClickNothingCoins equal to Click Power × (Rebirths + 1)The server, counting at most 15 clicks in each one-second window
Upgrade10 coins, then 1.5 times the last price for each levelOne more Click Power level, up to level 50The server, from its own copy of the prices
Rebirth1,000 coins, then 3 times the last price for each rebirthCoins back to 0 and Click Power back to level 1, plus a permanent +1 to the multiplierThe server

Every number lives on the server. The player's device draws the buttons and asks for things ("I clicked", "I want an upgrade"); the server decides what that is worth and whether it can happen. Clicker scripts that get exploited often add coins in a LocalScript, or let the client send how many coins to add.

The pieces of the simulator
PieceWhere it livesWhat it does
SimulatorConfig ModuleScriptReplicatedStoragePrices, limits and the formulas for a click's value, the upgrade price and the rebirth price. Both sides read it.
SimulatorService ScriptServerScriptServiceHolds each player's numbers, handles clicks, upgrades and rebirths, and fills leaderstats.
SimulatorRemotes FolderReplicatedStorage (SimulatorService creates it)The Click RemoteEvent and the BuyUpgrade and Rebirth RemoteFunctions.
SimulatorClient LocalScriptStarterPlayerScriptsBuilds the buttons, sends clicks and requests, and shows the server's numbers.
PlayerData ModuleScript (optional)ServerScriptServiceSaves progress between visits. It comes from the DataStore tutorial and is only needed for Step 4.
Explorer while the game runs. SimulatorService creates SimulatorRemotes and its three remotes when the game starts, and PlayerData is only there once you add saving in Step 4.
ReplicatedStorage
  SimulatorConfig ModuleScript new
  SimulatorRemotes Folder new
    Click RemoteEvent new
    BuyUpgrade RemoteFunction new
    Rebirth RemoteFunction new
ServerScriptService
  SimulatorService Script new
  PlayerData ModuleScript
StarterPlayer
  StarterPlayerScripts
    SimulatorClient LocalScript new

Step 1: Put the prices and formulas in a module

Every number you might want to tune goes in one ModuleScript: what a click is worth, what each upgrade and rebirth costs, and the click limit. The server uses it to decide, and the LocalScript uses it to show prices on the buttons, so a change is one edit in one place.

  1. In the Explorer, hover over ReplicatedStorage, click the + button and insert a ModuleScript.
  2. Rename it SimulatorConfig (names are case-sensitive) and replace its contents with the code below.
ModuleScript in ReplicatedStorage named SimulatorConfig
-- Every number in the core loop, and the formulas both sides use. The client only
-- uses these to show prices; the server uses its own copy to decide.

local SimulatorConfig = {}

SimulatorConfig.UPGRADE_BASE_COST = 10 -- price of Click Power level 2
SimulatorConfig.UPGRADE_COST_GROWTH = 1.5 -- each level costs 1.5 times the last
SimulatorConfig.MAX_POWER_LEVEL = 50
SimulatorConfig.REBIRTH_BASE_COST = 1000 -- coins needed for the first rebirth
SimulatorConfig.REBIRTH_COST_GROWTH = 3 -- each rebirth costs 3 times the last
SimulatorConfig.MAX_CLICKS_PER_SECOND = 15 -- clicks past this in one second are ignored

-- Coins for one click: the Click Power level, multiplied by 1 + the number of rebirths
function SimulatorConfig.clickValue(powerLevel: number, rebirths: number): number
	return powerLevel * (rebirths + 1)
end

-- Price of the next Click Power level
function SimulatorConfig.upgradeCost(powerLevel: number): number
	return math.floor(SimulatorConfig.UPGRADE_BASE_COST * SimulatorConfig.UPGRADE_COST_GROWTH ^ (powerLevel - 1))
end

-- Coins needed for the next rebirth
function SimulatorConfig.rebirthCost(rebirths: number): number
	return SimulatorConfig.REBIRTH_BASE_COST * SimulatorConfig.REBIRTH_COST_GROWTH ^ rebirths
end

return SimulatorConfig

Players can read a ModuleScript in ReplicatedStorage, so these prices are public. That is fine: they are on the buttons anyway. The client and the server each run their own copy of the module, so a player who edits the numbers on their device changes nothing on the server.

What the formulas give with these settings
Click Power levelPrice of the next levelCoins a click, no rebirthsCoins a click, 1 rebirth
11012
21524
32236
43348
550510

Rebirths cost 1,000 coins, then 3,000, then 9,000, because rebirthCost multiplies the price by 3 each time. After one rebirth every click is worth twice as much, after two rebirths three times as much, and so on.

Tuning the growth

The two _GROWTH numbers set how fast prices climb. At 1.5, each upgrade costs half as much again as the one before, while each level adds only one coin a click (before the rebirth multiplier), so every new level takes longer to pay for itself. That slowdown is what makes a rebirth worth taking. Raise the growth for a longer climb, lower it for a faster one, and keep REBIRTH_BASE_COST high enough that players buy a few upgrades before their first rebirth.

Step 2: Add the server script

This Script owns every number. It creates the remotes, gives each player a leaderstats folder with Coins and Rebirths, and answers the three requests. Hover over ServerScriptService, insert a Script, rename it SimulatorService and replace its contents with this:

Script in ServerScriptService named SimulatorService
-- Owns every number: clicks earn coins here, upgrades and rebirths are bought here,
-- and leaderstats only display the result.

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")

-- false: everyone starts fresh each visit. true: load and save with the PlayerData module
-- from the DataStore tutorial (add PowerLevel = 1 and Rebirths = 0 to its DEFAULTS first).
local SAVE_PROGRESS = false

type Config = {
	MAX_POWER_LEVEL: number,
	MAX_CLICKS_PER_SECOND: number,
	clickValue: (powerLevel: number, rebirths: number) -> number,
	upgradeCost: (powerLevel: number) -> number,
	rebirthCost: (rebirths: number) -> number,
}
local Config = require(ReplicatedStorage:WaitForChild("SimulatorConfig")) :: Config

type Stats = { Coins: number, PowerLevel: number, Rebirths: number }

local remotes = Instance.new("Folder")
remotes.Name = "SimulatorRemotes"
local clickEvent = Instance.new("RemoteEvent")
clickEvent.Name = "Click"
clickEvent.Parent = remotes
local buyUpgrade = Instance.new("RemoteFunction")
buyUpgrade.Name = "BuyUpgrade"
buyUpgrade.Parent = remotes
local rebirthRemote = Instance.new("RemoteFunction")
rebirthRemote.Name = "Rebirth"
rebirthRemote.Parent = remotes
remotes.Parent = ReplicatedStorage

local playerStats: { [Player]: Stats } = {}
local windowStart: { [Player]: number } = {} -- when this player's current one-second window began
local clickCount: { [Player]: number } = {} -- clicks counted in that window

-- Copies the server's numbers to what players see: leaderstats and one attribute
local function refresh(player: Player)
	local data = playerStats[player]
	local leaderstats = player:FindFirstChild("leaderstats")
	if not (data and leaderstats) then
		return
	end
	local coins = leaderstats:FindFirstChild("Coins")
	local rebirths = leaderstats:FindFirstChild("Rebirths")
	if coins and coins:IsA("IntValue") then
		coins.Value = data.Coins
	end
	if rebirths and rebirths:IsA("IntValue") then
		rebirths.Value = data.Rebirths
	end
	player:SetAttribute("PowerLevel", data.PowerLevel)
end

-- Starts a player with the data passed in (fresh numbers, or a saved table)
local function setup(player: Player, data: Stats)
	playerStats[player] = data
	local leaderstats = Instance.new("Folder")
	leaderstats.Name = "leaderstats"
	for _, name in { "Coins", "Rebirths" } do
		local value = Instance.new("IntValue")
		value.Name = name
		value.Parent = leaderstats
	end
	leaderstats.Parent = player
	refresh(player)
end

-- A click carries no numbers: the server works out what it is worth
clickEvent.OnServerEvent:Connect(function(player: Player)
	local data = playerStats[player]
	if not data then
		return
	end
	local now = os.clock()
	local start = windowStart[player]
	if not start or now - start >= 1 then
		windowStart[player] = now
		clickCount[player] = 0
	end
	local count = clickCount[player] or 0
	if count >= Config.MAX_CLICKS_PER_SECOND then
		return -- an auto-clicker or a script firing the remote in a loop
	end
	clickCount[player] = count + 1
	data.Coins += Config.clickValue(data.PowerLevel, data.Rebirths)
	refresh(player)
end)

buyUpgrade.OnServerInvoke = function(player: Player): (boolean, string)
	local data = playerStats[player]
	if not data then
		return false, "Your stats have not loaded yet."
	end
	if data.PowerLevel >= Config.MAX_POWER_LEVEL then
		return false, "Click Power is maxed out."
	end
	local cost = Config.upgradeCost(data.PowerLevel)
	if data.Coins < cost then
		return false, `You need {cost - data.Coins} more coins.`
	end
	data.Coins -= cost
	data.PowerLevel += 1
	refresh(player)
	return true, `Click Power is now level {data.PowerLevel}.`
end

rebirthRemote.OnServerInvoke = function(player: Player): (boolean, string)
	local data = playerStats[player]
	if not data then
		return false, "Your stats have not loaded yet."
	end
	local cost = Config.rebirthCost(data.Rebirths)
	if data.Coins < cost then
		return false, `You need {cost - data.Coins} more coins to rebirth.`
	end
	-- A rebirth trades coins and upgrades for a permanent multiplier
	data.Coins = 0
	data.PowerLevel = 1
	data.Rebirths += 1
	refresh(player)
	return true, `Rebirth {data.Rebirths}! Every click is now worth x{data.Rebirths + 1}.`
end

-- Starting numbers: fresh ones, or the table the PlayerData module loads and saves
if SAVE_PROGRESS then
	type PlayerDataModule = {
		Loaded: RBXScriptSignal,
		Get: (player: Player) -> any,
	}
	local PlayerData = require(ServerScriptService:WaitForChild("PlayerData")) :: PlayerDataModule
	local function onLoaded(player: Player)
		local data = PlayerData.Get(player)
		if not data or playerStats[player] then
			return
		end
		if type(data.Coins) ~= "number" or type(data.PowerLevel) ~= "number" or type(data.Rebirths) ~= "number" then
			warn("SimulatorService: add PowerLevel = 1 and Rebirths = 0 to DEFAULTS in the PlayerData module")
			return
		end
		setup(player, data) -- the same table PlayerData saves, so every change is saved
	end
	PlayerData.Loaded:Connect(onLoaded)
	for _, player in Players:GetPlayers() do
		onLoaded(player) -- anyone whose data loaded before this script started
	end
else
	local function onPlayerAdded(player: Player)
		setup(player, { Coins = 0, PowerLevel = 1, Rebirths = 0 })
	end
	Players.PlayerAdded:Connect(onPlayerAdded)
	for _, player in Players:GetPlayers() do
		onPlayerAdded(player)
	end
end

Players.PlayerRemoving:Connect(function(player: Player)
	playerStats[player] = nil
	windowStart[player] = nil
	clickCount[player] = nil
end)

Click Power reaches the player as a PowerLevel attribute on their Player object, not as a third leaderstats value, so the player list shows only Coins and Rebirths. Attributes the server sets replicate to the player's device; a change a player makes on their own device stays there.

Clicks and the rate limit

The Click remote carries no arguments. On the server, the first parameter of an OnServerEvent handler is always the player who fired it, so the server knows who clicked, looks up their Click Power level and Rebirths, and adds clickValue to their Coins. There is nothing in the request for a cheater to change.

Roblox's security guide says never to rely only on a rate limit in the client, and it lists awarding currency among the actions to limit. The server counts each player's clicks in one-second windows: the first click starts a window, and once 15 clicks are counted, any more are ignored until a second has passed since the window began. A script that fires the remote in a loop gets no more than 15 clicks counted in each window. To allow faster or slower clicking, change MAX_CLICKS_PER_SECOND in SimulatorConfig.

Upgrades and rebirths

BuyUpgrade and Rebirth are RemoteFunctions, so the button can wait for the answer and show it. Each handler reads the price from the server's copy of SimulatorConfig, checks the player's Coins, and only then changes anything. It returns true or false and a message such as "You need 5 more coins.", which the LocalScript shows in a status line.

Nothing between the price check and the change waits or yields, so two requests from the same player cannot both pass the check before the first one takes the coins. A rebirth sets Coins to 0 and Click Power to level 1 and adds 1 to Rebirths, which the click formula turns into a multiplier of Rebirths + 1.

The SAVE_PROGRESS switch

With SAVE_PROGRESS = false, every player starts at 0 coins each visit, which is what you want while you build. Step 4 turns it on. The script then waits for the PlayerData module to load each player and works directly on the table that module saves, so every coin and rebirth is saved without any extra code.

Step 3: Build the buttons

This LocalScript builds the whole screen in code: the coin count at the top, a large Click button in the middle, Upgrade and Rebirth buttons at the bottom, and a status line for the server's answers. It never changes a number itself.

  1. In the Explorer, expand StarterPlayer, hover over StarterPlayerScripts and insert a LocalScript named SimulatorClient.
  2. Replace its contents with the code below and press Play.
LocalScript in StarterPlayerScripts named SimulatorClient
-- Builds the simulator buttons and shows the server's numbers. It never changes them.

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TweenService = game:GetService("TweenService")

type Config = {
	MAX_POWER_LEVEL: number,
	clickValue: (powerLevel: number, rebirths: number) -> number,
	upgradeCost: (powerLevel: number) -> number,
	rebirthCost: (rebirths: number) -> number,
}
local Config = require(ReplicatedStorage:WaitForChild("SimulatorConfig")) :: Config
local remotes = ReplicatedStorage:WaitForChild("SimulatorRemotes")
local clickEvent = remotes:WaitForChild("Click") :: RemoteEvent
local buyUpgrade = remotes:WaitForChild("BuyUpgrade") :: RemoteFunction
local rebirthRemote = remotes:WaitForChild("Rebirth") :: RemoteFunction

local player = Players.LocalPlayer
local leaderstats = player:WaitForChild("leaderstats")
local coins = leaderstats:WaitForChild("Coins") :: IntValue
local rebirths = leaderstats:WaitForChild("Rebirths") :: IntValue

local BOLD = Font.fromEnum(Enum.Font.BuilderSansBold)

-- 1234 -> "1.2K", 5600000 -> "5.6M"
local UNITS = {
	{ size = 1e12, suffix = "T" },
	{ size = 1e9, suffix = "B" },
	{ size = 1e6, suffix = "M" },
	{ size = 1e3, suffix = "K" },
}
local function short(n: number): string
	for _, unit in UNITS do
		if n >= unit.size then
			return string.format("%.1f%s", math.floor(n / unit.size * 10) / 10, unit.suffix)
		end
	end
	return tostring(n)
end

local function newButton(parent: Instance, text: string, position: UDim2, size: UDim2, color: Color3): TextButton
	local button = Instance.new("TextButton")
	button.AnchorPoint = Vector2.new(0.5, 0.5)
	button.Position = position
	button.Size = size
	button.BackgroundColor3 = color
	button.TextColor3 = Color3.new(1, 1, 1)
	button.FontFace = BOLD
	button.TextScaled = true
	button.Text = text
	button.Parent = parent
	local corner = Instance.new("UICorner")
	corner.CornerRadius = UDim.new(0, 12)
	corner.Parent = button
	return button
end

local gui = Instance.new("ScreenGui")
gui.Name = "SimulatorGui"
gui.ResetOnSpawn = false

local coinsLabel = Instance.new("TextLabel")
coinsLabel.AnchorPoint = Vector2.new(0.5, 0)
coinsLabel.Position = UDim2.new(0.5, 0, 0, 12)
coinsLabel.Size = UDim2.fromOffset(320, 48)
coinsLabel.BackgroundTransparency = 1
coinsLabel.TextColor3 = Color3.fromRGB(255, 214, 90)
coinsLabel.FontFace = BOLD
coinsLabel.TextScaled = true
coinsLabel.Parent = gui

local clickButton = newButton(gui, "Click!", UDim2.new(0.5, 0, 0.5, -30), UDim2.fromScale(0.38, 0.38), Color3.fromRGB(124, 77, 255))
-- Keep the Click button square, sized to the screen and never bigger than 200 by 200,
-- so on a phone it fits between the coin count and the buttons at the bottom
local square = Instance.new("UIAspectRatioConstraint")
square.Parent = clickButton
local maxSize = Instance.new("UISizeConstraint")
maxSize.MaxSize = Vector2.new(200, 200)
maxSize.Parent = clickButton

local upgradeButton = newButton(gui, "", UDim2.new(0.5, -110, 1, -60), UDim2.fromOffset(200, 56), Color3.fromRGB(40, 150, 90))
local rebirthButton = newButton(gui, "", UDim2.new(0.5, 110, 1, -60), UDim2.fromOffset(200, 56), Color3.fromRGB(200, 90, 40))

local status = Instance.new("TextLabel")
status.AnchorPoint = Vector2.new(0.5, 1)
status.Position = UDim2.new(0.5, 0, 1, -96)
status.Size = UDim2.fromOffset(480, 28)
status.BackgroundTransparency = 1
status.TextColor3 = Color3.new(1, 1, 1)
status.FontFace = BOLD
status.TextSize = 20
status.Text = ""
status.Parent = gui

gui.Parent = player:WaitForChild("PlayerGui")

local function powerLevel(): number
	local level = player:GetAttribute("PowerLevel")
	return if type(level) == "number" then level else 1
end

local function update()
	coinsLabel.Text = `{short(coins.Value)} coins`
	local level = powerLevel()
	upgradeButton.Text = if level >= Config.MAX_POWER_LEVEL
		then "Click Power maxed"
		else `Upgrade: {short(Config.upgradeCost(level))}`
	rebirthButton.Text = `Rebirth: {short(Config.rebirthCost(rebirths.Value))}`
end
coins.Changed:Connect(update)
rebirths.Changed:Connect(update)
player:GetAttributeChangedSignal("PowerLevel"):Connect(update)
update()

-- A "+N" that floats up from the button. The number is a guess for show only;
-- the server decides what the click is really worth.
local function popUp()
	local pop = Instance.new("TextLabel")
	pop.AnchorPoint = Vector2.new(0.5, 0.5)
	pop.Position = UDim2.fromScale(0.5 + (math.random() - 0.5) * 0.1, 0.42)
	pop.Size = UDim2.fromOffset(120, 40)
	pop.BackgroundTransparency = 1
	pop.TextColor3 = Color3.fromRGB(255, 214, 90)
	pop.FontFace = BOLD
	pop.TextScaled = true
	pop.Text = `+{short(Config.clickValue(powerLevel(), rebirths.Value))}`
	pop.Parent = gui
	local tween = TweenService:Create(pop, TweenInfo.new(0.6), {
		Position = pop.Position - UDim2.fromOffset(0, 60),
		TextTransparency = 1,
	})
	tween.Completed:Once(function()
		pop:Destroy()
	end)
	tween:Play()
end

clickButton.Activated:Connect(function()
	clickEvent:FireServer()
	popUp()
end)

local function ask(remote: RemoteFunction)
	local ok, success, message = pcall(function()
		return remote:InvokeServer()
	end)
	status.Text = if ok and type(message) == "string" then message else "No answer from the server. Try again."
	status.TextColor3 = if ok and success == true then Color3.fromRGB(120, 220, 150) else Color3.new(1, 1, 1)
end

upgradeButton.Activated:Connect(function()
	ask(buyUpgrade)
end)
rebirthButton.Activated:Connect(function()
	ask(rebirthRemote)
end)

The buttons use Activated, which Roblox fires for a left click on desktop, a tap on mobile, and A (or cross) on a controller in UI navigation mode.

On a controller, A only presses a button that is selected. With GuiService.AutoSelectGuiEnabled on, the gamepad's Select button selects your on-screen buttons; players can then move between them and press A. If you would rather a controller button clicked straight away, bind one with ContextActionService and fire the same Click remote from it.

Four details in the script:

  • The Click button scales with the screen. Its size is a fraction of the screen, a UIAspectRatioConstraint keeps it square and a UISizeConstraint caps it at 200 by 200 pixels. On a monitor it is 200 pixels across; on a phone it shrinks to fit between the coin count and the buttons at the bottom. Keep it clear of them if you move things around: Rebirth has no confirmation, so a stray tap there can reset the player's coins and upgrades.
  • The "+N" pop is only for show. It uses the client's copy of the formula. If the server ignores a click because of the rate limit, the pop still appears but the coins do not change.
  • short() keeps big numbers readable. 1,234 shows as 1.2K and 5,600,000 as 5.6M. It rounds down, so the coin count never shows more than you have. Add entries to UNITS for numbers past T (trillions).
  • The screen updates from events. It listens to the Coins and Rebirths values and the PowerLevel attribute, so the button prices are right however the numbers change.

The ScreenGui has ResetOnSpawn set to false, so it stays when the character respawns. That matters here: a LocalScript in StarterPlayerScripts is copied to the player once, when they join, so it would not build the screen again.

Step 4: Save progress with PlayerData

So far everything resets when a player leaves. Rather than add a second saving script, the simulator plugs into the PlayerData module from the DataStore tutorial. That module already retries failed requests, autosaves every 180 seconds, saves when a player leaves and when the server shuts down, and never saves a player whose data failed to load.

  1. Follow Step 1 of the DataStore tutorial to add the PlayerData ModuleScript to ServerScriptService. To test saving in Studio, turn on Studio access to API services in a test copy of your game, as its first section explains. SimulatorService requires the module itself, so you do not need the tutorial's Main script for this.
  2. In PlayerData, add PowerLevel = 1, and Rebirths = 0, inside the DEFAULTS table, next to Coins = 0,.
  3. In the same module, add PowerLevel: number, and Rebirths: number, to the Data type, so strict type checking knows about the new fields.
  4. In SimulatorService, change local SAVE_PROGRESS = false to local SAVE_PROGRESS = true.

Players who saved before you added the fields get them automatically: PlayerData's migrate function fills in any DEFAULTS field a saved table is missing, so they start at Click Power level 1 and 0 rebirths and keep their Coins. If you skip step 2, SimulatorService prints a warning saying which fields to add and does not set the player up.

Why the simulator writes into PlayerData's table

With saving on, SimulatorService does not copy the numbers anywhere. PlayerData.Get(player) returns the live table the module saves, and the simulator changes Coins, PowerLevel and Rebirths on that same table. There is one copy of each number and one script that writes to the data store. Two scripts saving the same key overwrite each other, and one of them can save older numbers over the other's.

Do not also build leaderstats in a Loaded handler, as the DataStore tutorial suggests for other games: SimulatorService already makes Coins and Rebirths from the loaded table. If a leaderboard script already makes leaderstats, delete it.

Why not save on every click

Roblox allows each server 60 + 40 × players standard data store writes a minute (the DataStore tutorial's limits table has the rest). Saving on every click at 15 clicks a second would be 900 writes a minute for one player, far over the 100 a one-player server gets. Saving the whole table every 180 seconds, when the player leaves and when the server shuts down, costs only a few writes per player, and a crash loses at most the progress since the last autosave.

If a load fails, the module lets the player play on fresh numbers but never saves them, so a bad load cannot replace real progress. The tutorial explains how the module handles that.

Why the server decides every number

Exploiters control their own device. Roblox's security guide says they can change anything in their local copy of the game, and fire or invoke any RemoteEvent or RemoteFunction as often as they like with any arguments. A simulator's economy is only as safe as its server script.

What an exploiter can try, and what SimulatorService does
An exploiter tries toIf the client decidedWhat SimulatorService does
Fire Click in a loop as fast as possibleUnlimited coinsCounts at most 15 clicks in each one-second window and ignores the rest.
Send a coin amount with the clickThey choose their own rewardIgnores any arguments. A click's value comes from the server's numbers.
Buy an upgrade or a rebirth without enough coinsFree upgradesChecks the price against the server's Coins and replies "You need N more coins."
Edit Coins in leaderstats on their own screenSpending coins they do not haveNever reads leaderstats. It only writes to it, from its own table.
Set the PowerLevel attribute on their PlayerA bigger clickNever reads the attribute, and the change stays on their device.
Invoke Rebirth twice at the same momentTwo rebirths for the price of oneChecks the price again on each call, and nothing yields between the check and the change.
Change the prices in their copy of SimulatorConfigCheaper upgradesUses its own copy of the module.

The rule carries over to every remote in a simulator: send what the player wants to do, never what it is worth. The RemoteEvents guide covers validating remote arguments in more detail.

Patterns to avoid in copied clicker scripts

Common patterns in older clicker tutorials, and what this guide does instead
PatternThe problemThis guide
A LocalScript adds coins with leaderstats.Coins.Value += 1The change stays on that one device, and exploiters control their deviceThe server adds coins
clickEvent:FireServer(amount)The client picks its own rewardClick sends no arguments
No rate limit on the serverRoblox's docs say never to rely only on a limit in the clientAt most 15 clicks in each one-second window
SetAsync on every click or upgradeRuns through the per-server write budgetPlayerData saves every 180 seconds, on leave and on shutdown. Save at once with PlayerData.SaveNow only after something that must not be lost, such as a Robux purchase.
A second data store script just for the simulatorTwo scripts saving one key overwrite each otherWrites into PlayerData's table
while wait(1) do loops for passive incomeRoblox marks the global wait as deprecated, superseded by task.waittask.wait (see Extending it)
MouseButton1Click on buttonsDocumented for mouse clicksActivated, for mouse, touch and controller

Step 5: Test it

Run steps 1 to 8 with SAVE_PROGRESS = false in SimulatorService, so every playtest starts from fresh numbers. If you already turned saving on in Step 4, set it to false for now and back to true for step 9.

  1. Press Play. The top of the screen reads 0 coins, the green button reads Upgrade: 10 and the orange one reads Rebirth: 1.0K.
  2. Click the purple Click! button ten times. Each click shows a gold +1, and Coins in the player list goes up by 1.
  3. Press Upgrade. Coins go back to 0, the status line turns green with "Click Power is now level 2." and the button now reads Upgrade: 15. From now on each click gives 2 coins.
  4. Before clicking again, press Rebirth. The status line says "You need 1000 more coins to rebirth." and nothing changes.
  5. To try a rebirth without 1,000 clicks, stop the test, change Coins = 0 to Coins = 1000 in the line near the bottom of SimulatorService that sets up fresh players, and press Play. Press Rebirth: Coins go to 0, Rebirths in the player list goes to 1, the status line says "Rebirth 1! Every click is now worth x2." and the button now reads Rebirth: 3.0K. Each click now gives 2 coins at Click Power level 1. Change the line back to 0 afterwards.
  6. To see the rate limit, set MAX_CLICKS_PER_SECOND to 2 in SimulatorConfig and click quickly. The pops keep appearing, but Coins rise by at most 2 clicks' worth in each second. Set it back to 15.
  7. Choose a phone in Studio's Device Simulator (a Studio beta; the older Device Emulator works too) and press Play. Check that the Click button overlaps neither the coin count nor the Upgrade and Rebirth buttons, and that the status line shows clear of it when you press Upgrade.
  8. Connect a controller, or open Studio's Controller Emulator (Controller, on the Emulation tab) during the playtest, press Select, and check that you can reach the three buttons and press them with A.
  9. With saving on (Step 4) and SAVE_PROGRESS = true again, earn some coins, press Stop, then press Play again. Your Coins, Click Power level and Rebirths come back.

Extending it: pets, zones, auto-click and leaderboards

Pets as multipliers

A common design makes each pet a multiplier. Keep the list of owned pet ids in the player's saved table, and have the server multiply clickValue by the bonus of each equipped pet. Hatch eggs on the server: the client asks to open an egg, and the server checks and takes the price, then picks the pet with a weighted random roll. Never let the client roll the egg or name the pet it got. The inventory guide shows how to hold, save and display owned items, and pets work the same way.

Zones unlocked by rebirths

A zone is an area of the map with better rewards behind a barrier. Gate the reward, not only the door: whatever pays out inside the zone should check the player's Rebirths in the server's table before it pays. A door that a LocalScript opens for players who qualify is fine for looks, but exploiters can change parts and move their character on their own device.

Auto-click as a game pass

An auto-clicker sold as a game pass should run on the server. Move the body of the Click handler into a function such as addClick(player), connect the remote to it, and for pass owners call it from a server loop with task.wait(0.5), not the deprecated wait. Auto-clicks then count towards the same per-second limit as manual clicks. Check ownership on the server with MarketplaceService:UserOwnsGamePassAsync, as the game pass guide does for its perks. A 2x coins pass works the same way: multiply clickValue on the server for owners.

A global top 10 of rebirths

leaderstats only compares players in the same server. To rank every player's Rebirths, write them to an ordered data store and read the top entries back, as in Step 4 of the leaderboard guide. Ordered data stores allow fewer writes than standard ones, 30 + 5 × players a minute per server, so write when a player leaves and on a timer.

Very big numbers

Luau numbers are doubles, which keep about 15 significant digits. An IntValue holds whole numbers up to about 9.2 quintillion, but Roblox notes that values above 2^53 (about 9 quadrillion) lose precision in scripts. With these settings the rebirth price passes that point at the 29th rebirth. If your game goes further, switch the leaderstats values to NumberValue (change IntValue in both scripts), which holds far larger numbers at the same 15 or so digits of precision, and add suffixes to short().

Common errors and fixes

Open the Output window (Window menu, then Output) before you press Play. Most problems print a warning or an error there, with the script name and the line.

Infinite yield possible on 'ReplicatedStorage:WaitForChild("SimulatorRemotes")'

The LocalScript is waiting for the remotes, which SimulatorService creates when it starts. Check that SimulatorService is a Script, not a LocalScript, that it is inside ServerScriptService, and that Output shows no error from it. The same warning for SimulatorConfig means the module is missing from ReplicatedStorage or named differently.

The buttons never appear

The LocalScript waits for your leaderstats before it builds the screen. If Output shows Infinite yield possible on leaderstats, SimulatorService has not set you up yet. With SAVE_PROGRESS = true it waits for PlayerData, so check that PlayerData is in ServerScriptService and loads without errors.

Output says "SimulatorService: add PowerLevel = 1 and Rebirths = 0 to DEFAULTS in the PlayerData module"

Saving is on, but the PlayerData module does not have the new fields yet. Add them to its DEFAULTS as in Step 4 and press Play again.

The "+1" pops appear but my coins do not go up

The server is not counting the clicks. Either SimulatorService hit an error (look for red text from it in Output), or clicks arrive faster than MAX_CLICKS_PER_SECOND allows. If players reach the limit in normal play, raise it in SimulatorConfig.

The status line says "No answer from the server. Try again."

The call to BuyUpgrade or Rebirth failed instead of returning an answer. The usual cause is an error inside SimulatorService while it handled the request, so look for a red error from it in Output: it names the line.

The coin count on screen and the player list show different numbers

Another script is also making a leaderstats folder, so each player has two, and the LocalScript may be reading the other one. Delete the extra script: SimulatorService makes Coins and Rebirths itself.

My coins reset every time I rejoin

Saving is off. Follow Step 4. If it is on and coins still reset in Studio, check that Studio access to API services is turned on in your test copy, as the DataStore tutorial explains.

Build it with RoCode

If you would rather not paste this in by hand, RoCode can build it in the place you have open. It is an independent AI coding agent, not made by Roblox: you describe the system in its web chat, and its Roblox Studio plugin creates the scripts in your Explorer, so you are not copying code out of a chat window.

You type

Make a clicker simulator: a big Click button that gives coins equal to my Click Power level times (rebirths + 1), an Upgrade button that costs 10 coins and grows 1.5 times each level, and a Rebirth button at 1,000 coins that resets coins and upgrades. Keep the numbers on the server and limit clicks to 15 a second. I already have a PlayerData module.

RoCode does
  • Can read your PlayerData module and any leaderstats script first with Search Scripts and Read Script, so it adds fields to your existing data instead of writing a second save script
  • Can scaffold the screen with Build UI's HUD layout (with plugin 3.9 or newer, in its simulator style), or build the buttons in a LocalScript, and writes the ModuleScript, the server Script and the LocalScript with Create Script
  • Is told to compile-check each script it writes with Check Script, which compiles it in Studio without running it
  • Sets an undo point before and after each batch of changes, so Ctrl+Z steps back one batch at a time

RoCode cannot create game passes or developer products, or upload pet meshes and icons: do those in Creator Hub and Studio. With plugin 3.9 or newer it can run a short playtest and read the errors, but it does not click buttons, so you test the loop yourself. A whole simulator is several prompts, one system at a time: how building one system per prompt works with RoCode. How RoCode connects to Studio.

Questions

How do I make a simulator game on Roblox without scripting?

A working simulator needs scripts: something has to add coins, check prices and save progress. You can paste the three scripts from this guide as they are and only change the numbers in SimulatorConfig.

How does a rebirth system work?

A rebirth trades your coins and upgrades for a permanent bonus. In this guide the first rebirth costs 1,000 coins; it sets Coins to 0 and Click Power to level 1 and adds 1 to Rebirths, and every click is then multiplied by Rebirths + 1. Each rebirth costs three times as much as the one before.

How do I stop auto clickers?

The server counts at most 15 clicks per player in each one-second window and ignores the rest. Below that limit a script looks the same as a fast player, so the cap is what protects your economy. You can also sell auto-click as a game pass that runs on the server.

Why do my coins reset when I rejoin?

Nothing saves them yet. Add the PlayerData module from the DataStore tutorial, add PowerLevel and Rebirths to its DEFAULTS, and set SAVE_PROGRESS = true in SimulatorService, as in Step 4.

How do I add pets to my simulator?

Make each pet a multiplier that the server applies to clicks. Hatch eggs on the server with a weighted random pick, save the owned pet ids with the rest of the player's data, and show them with an inventory GUI like the one in the inventory guide.

Should players click a part or a button?

Either works. A screen button suits phones, and a part with a ClickDetector suits a game set in the world. Roblox's docs say the server does no checks at all on ClickDetector events, so rate limit those clicks on the server the same way.

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, 13 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.