Tutorial · Game systems

How to make a tycoon in Roblox Studio: dropper, conveyor, collector and buttons

In a tycoon you claim a plot, droppers make parts, a conveyor carries them to a collector that pays you, and you spend the money on buttons that build the next upgrade. This guide builds a small tycoon from scratch with six short server scripts. It is built to grow: more droppers, more buttons and more plots need new parts and attributes, not new code.

Updated 9 October 202614 min readIntermediateLuau

In short

A tycoon is five pieces run by server Scripts: a claim pad that sets an owner, droppers that spawn parts on a timer, a conveyor (an anchored part whose AssemblyLinearVelocity carries whatever sits on it), a collector that destroys each drop and adds its value to the owner's Cash, and buttons that check the owner and the price on the server before they unlock the next upgrade.

  • Store the owner as an OwnerId attribute on the tycoon model, allow one tycoon per player, and set it back to 0 in Players.PlayerRemoving so the plot frees up.
  • Pay only for drops this tycoon made: keep them in the tycoon's own Drops folder, give each a Value attribute, and let the server own their physics.
  • Cap the number of live drops, destroy every drop the collector takes, and save Cash with a data store when the player leaves.
On this page
  1. How it works
  2. 1. Build the plot
  3. 2. Claim pad
  4. 3. Droppers
  5. 4. Conveyor
  6. 5. Collector
  7. 6. Upgrade buttons
  8. 7. Save Cash
  9. Test it
  10. Performance
  11. Common errors
  12. With RoCode
  13. Questions
  14. Sources

How a tycoon works

Every tycoon runs the same loop. A player claims an empty plot, the plot starts making money, and the player spends it on upgrades that make money faster. Each piece of that loop is one part (or folder of parts) and one short script.

The pieces of the tycoon in this guide
PieceWhat it doesScript
Claim padThe first player to step on it owns the plot until they leaveTycoonOwnership
DroppersEach one spawns a drop every few seconds while the plot has an ownerTycoonDroppers
ConveyorAn anchored part with a velocity that slides drops to the collectorTycoonConveyors
CollectorDestroys each drop that reaches it and adds the drop's value to the owner's CashTycoonCollectors
ButtonsTake Cash and reveal the next item, after the server checks owner and priceTycoonButtons
CashA leaderstats value, saved with a data storeLeaderstats

The scripts share one piece of state: an OwnerId attribute on each tycoon model. The ownership script writes it and the others read it, so each script stays short and you can change one without touching the rest. Each script also handles every plot in a Tycoons folder, so a second plot is a copy of the first, not a copy of the code.

All of it runs on the server. Cash, purchases and drops are worth something, and anything a LocalScript changes is seen by that one player only, so the server must make every decision about money. If the split between server and client is new to you, read Script vs LocalScript vs ModuleScript first.

Step 1: Build the plot and the Cash stat

  1. In the Explorer, hover over Workspace, click the + button and insert a Folder named Tycoons. Inside it, insert a Model named Tycoon1. This model is one plot.
  2. Inside Tycoon1, add the parts and folders from the tree below. Turn on Anchored for every part you build, including the parts inside Walls.
  3. Lay out the line: Conveyor is a long flat part (for example 4 by 1 by 20 studs), both droppers hang 3 or 4 studs above one end of it, and Collector sits against the other end with its top level with the conveyor's top, so drops slide onto it. Put ClaimPad and the two buttons where a player can walk onto them.
  4. Build everything you plan to sell where it should finally stand, such as Dropper2 and a Walls model. The buttons script hides each one until it is bought.
  5. Add the attributes from the table: select the object, scroll to Attributes at the bottom of the Properties window, click +, enter the name and type, click Save, then set the value.
Explorer after this guide
Workspace
  Tycoons Folder new
    Tycoon1 Model new
      ClaimPad Part new
      Conveyor Part new
      Collector Part new
      Droppers Folder new
        Dropper1 Part new
        Dropper2 Part new
      Buttons Folder new
        BuyDropper2 Part new
        BuyWalls Part new
      Walls Model new
ServerScriptService
  Leaderstats Script new
  TycoonOwnership Script new
  TycoonDroppers Script new
  TycoonConveyors Script new
  TycoonCollectors Script new
  TycoonButtons Script new
Attributes the scripts read
ObjectAttribute (type)ExampleWhat it does
Each dropperInterval (number)2Seconds between drops. Optional, default 2.
Each dropperDropValue (number)1Cash each drop pays at the collector. Optional, default 1.
ConveyorSpeed (number)8Studs per second towards the part's front face. A negative number runs it backwards. Optional, default 8.
BuyDropper2Price (number)50Cash the button costs.
BuyDropper2Unlocks (string)Dropper2Name of the object in this plot that the button reveals.
BuyWallsPrice, Unlocks150, WallsAs above.
BuyWallsRequires (string)BuyDropper2Optional. The button stays hidden until the named button is bought.

Give everything a button can unlock a name that is unique inside its plot, because the buttons script finds it by name. You do not add OwnerId yourself: the ownership script creates it.

Cash is a normal leaderstats value, so it appears in the player list. Insert a Script in ServerScriptService named Leaderstats. The leaderboard guide explains how leaderstats works.

Script in ServerScriptService named Leaderstats
local Players = game:GetService("Players")

local STARTING_CASH = 0

local function setupLeaderstats(player: Player)
	local leaderstats = Instance.new("Folder")
	leaderstats.Name = "leaderstats" -- must be exactly this name, all lowercase

	local cash = Instance.new("IntValue")
	cash.Name = "Cash"
	cash.Value = STARTING_CASH
	cash.Parent = leaderstats

	leaderstats.Parent = player
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

If your game already has a leaderstats script, add the Cash value to it instead. Two scripts that each make a leaderstats folder is a common reason a stat never changes on screen.

Step 2: Claim a tycoon, one per player

The claim pad gives the plot to the first player who steps on it, as long as that player does not already own one. It writes their UserId to the model's OwnerId attribute, puts their name on a sign above the pad, and frees the plot again when they leave. Insert a Script in ServerScriptService named TycoonOwnership.

Script in ServerScriptService named TycoonOwnership
local Players = game:GetService("Players")

local tycoons = workspace:WaitForChild("Tycoons")

local function ownerIdOf(tycoon: Instance): number
	local id = tycoon:GetAttribute("OwnerId")
	return if typeof(id) == "number" then id else 0
end

-- The living player whose character touched the part, or nil for anything else
local function playerFromHit(hit: BasePart): Player?
	local character = hit:FindFirstAncestorOfClass("Model")
	if not character then
		return nil
	end
	local humanoid = character:FindFirstChildOfClass("Humanoid")
	if not humanoid or humanoid.Health <= 0 then
		return nil
	end
	return Players:GetPlayerFromCharacter(character)
end

local function ownedTycoon(player: Player): Model?
	for _, tycoon in tycoons:GetChildren() do
		if tycoon:IsA("Model") and ownerIdOf(tycoon) == player.UserId then
			return tycoon
		end
	end
	return nil
end

-- The sign above the pad, created the first time it is needed
local function signLabel(pad: BasePart): TextLabel
	local existing = pad:FindFirstChild("OwnerLabel", true)
	if existing and existing:IsA("TextLabel") then
		return existing
	end
	local billboard = Instance.new("BillboardGui")
	billboard.Size = UDim2.fromOffset(240, 50)
	billboard.StudsOffset = Vector3.new(0, 4, 0)
	billboard.MaxDistance = 80

	local label = Instance.new("TextLabel")
	label.Name = "OwnerLabel"
	label.Size = UDim2.fromScale(1, 1)
	label.BackgroundTransparency = 1
	label.TextColor3 = Color3.new(1, 1, 1)
	label.TextStrokeTransparency = 0.4
	label.TextScaled = true
	label.Parent = billboard
	billboard.Parent = pad
	return label
end

local function release(tycoon: Model, pad: BasePart)
	tycoon:SetAttribute("OwnerId", 0)
	pad.Transparency = 0
	signLabel(pad).Text = "Free tycoon: step here to claim"
end

local function claim(tycoon: Model, pad: BasePart, player: Player)
	tycoon:SetAttribute("OwnerId", player.UserId)
	pad.Transparency = 0.7
	signLabel(pad).Text = `{player.DisplayName}'s tycoon`
end

local function setUpPad(tycoon: Model, pad: BasePart)
	release(tycoon, pad) -- every plot starts free
	pad.Touched:Connect(function(hit: BasePart)
		if ownerIdOf(tycoon) ~= 0 then
			return -- someone owns it already
		end
		local player = playerFromHit(hit)
		if player and not ownedTycoon(player) then -- one tycoon per player
			claim(tycoon, pad, player)
		end
	end)
end

for _, tycoon in tycoons:GetChildren() do
	local pad = tycoon:FindFirstChild("ClaimPad")
	if tycoon:IsA("Model") and pad and pad:IsA("BasePart") then
		setUpPad(tycoon, pad)
	else
		warn(`{tycoon:GetFullName()} needs to be a Model with a Part named ClaimPad`)
	end
end

-- Free the plot when its owner leaves, so the next player can claim it
Players.PlayerRemoving:Connect(function(player: Player)
	local tycoon = ownedTycoon(player)
	local pad = tycoon and tycoon:FindFirstChild("ClaimPad")
	if tycoon and pad and pad:IsA("BasePart") then
		release(tycoon, pad)
	end
end)

Touched can fire many times while a character stands on the pad. That is harmless here: after the first touch the plot is owned, and every later touch returns at the first check.

The owner is stored as a number, not a Player, because attributes cannot hold an object. A value of 0 means the plot is free. Attributes replicate, so a LocalScript can read OwnerId too, for example to point the player towards their own plot.

Step 3: Make a tycoon dropper that spawns drops

One script runs every dropper in every plot. It loops with task.wait, and on each pass it spawns a drop under each dropper whose interval is up, but only in plots that have an owner. Insert a Script in ServerScriptService named TycoonDroppers.

Script in ServerScriptService named TycoonDroppers
local Debris = game:GetService("Debris")

local tycoons = workspace:WaitForChild("Tycoons")

local MAX_DROPS_PER_TYCOON = 50 -- live drops at once; a dropper skips its turn at the cap
local DROP_LIFETIME = 30 -- seconds; removes drops that fall off or get stuck
local TICK = 0.2 -- how often the loop checks the droppers

-- When each dropper may drop next. Bought droppers are hidden, never destroyed,
-- so these entries stay valid for the life of the server.
local nextDropAt: { [BasePart]: number } = {}

local function positiveNumber(instance: Instance, name: string, default: number): number
	local value = instance:GetAttribute(name)
	if typeof(value) == "number" and value > 0 then
		return value
	end
	return default
end

local function ownerIdOf(tycoon: Instance): number
	local id = tycoon:GetAttribute("OwnerId")
	return if typeof(id) == "number" then id else 0
end

-- Each plot keeps its drops in its own folder. The collector pays only for drops in it.
local function dropsFolder(tycoon: Model): Folder
	local existing = tycoon:FindFirstChild("Drops")
	if existing and existing:IsA("Folder") then
		return existing
	end
	local folder = Instance.new("Folder")
	folder.Name = "Drops"
	folder.Parent = tycoon
	return folder
end

local function spawnDrop(tycoon: Model, dropper: BasePart)
	local drops = dropsFolder(tycoon)
	if #drops:GetChildren() >= MAX_DROPS_PER_TYCOON then
		return
	end

	local drop = Instance.new("Part")
	drop.Name = "Drop"
	drop.Size = Vector3.new(1, 1, 1)
	drop.Color = dropper.Color
	drop.Material = Enum.Material.SmoothPlastic
	drop.CFrame = dropper.CFrame * CFrame.new(0, -(dropper.Size.Y / 2 + 0.6), 0) -- just under the dropper
	drop:SetAttribute("Value", positiveNumber(dropper, "DropValue", 1))
	drop.Parent = drops

	-- The server simulates the drop, so no player's device can move it or fake its touches
	if drop:CanSetNetworkOwnership() then
		drop:SetNetworkOwner(nil)
	end
	Debris:AddItem(drop, DROP_LIFETIME)
end

-- Clear a plot's drops when its owner leaves
local function watchOwner(tycoon: Model)
	tycoon:GetAttributeChangedSignal("OwnerId"):Connect(function()
		if ownerIdOf(tycoon) == 0 then
			dropsFolder(tycoon):ClearAllChildren()
		end
	end)
end

for _, tycoon in tycoons:GetChildren() do
	if tycoon:IsA("Model") then
		watchOwner(tycoon)
	end
end

while true do
	local now = os.clock()
	for _, tycoon in tycoons:GetChildren() do
		local droppers = tycoon:FindFirstChild("Droppers")
		if tycoon:IsA("Model") and droppers and ownerIdOf(tycoon) ~= 0 then
			for _, dropper in droppers:GetChildren() do
				if dropper:IsA("BasePart") and now >= (nextDropAt[dropper] or 0) then
					nextDropAt[dropper] = now + positiveNumber(dropper, "Interval", 2)
					spawnDrop(tycoon, dropper)
				end
			end
		end
	end
	task.wait(TICK)
end

The loop checks five times a second, so an Interval below 0.2 behaves like 0.2. Only droppers inside the Droppers folder run: an unbought Dropper2 is in ServerStorage until its button is bought, so the loop never sees it.

Three details in spawnDrop matter later. The Value attribute is what the collector pays. The Drops folder is how the collector knows the drop came from this plot. And SetNetworkOwner(nil) keeps the drop's physics on the server: otherwise Roblox can hand an unanchored part near a player's character to that player's device to simulate, and a device that simulates a part can teleport it or report touches that never happened.

Step 4: Move drops with a conveyor

A conveyor is an anchored part with a velocity. An anchored part never moves, but Roblox's API reference notes, on the older Velocity property, that giving an anchored part a velocity makes it act like a conveyor belt: anything touching it starts to move with that velocity. Velocity is now deprecated, and its deprecation message says to use AssemblyLinearVelocity instead, so that is the property this guide sets. Older tutorials still set Velocity.

Set it from a server Script. Anchored parts are always owned by the server, and the reference says a server-owned part's AssemblyLinearVelocity must be changed from a server Script, not a LocalScript. Insert a Script in ServerScriptService named TycoonConveyors.

Script in ServerScriptService named TycoonConveyors
local tycoons = workspace:WaitForChild("Tycoons")

local DEFAULT_SPEED = 8 -- studs per second

-- An anchored part never moves, but a velocity on it carries whatever rests on top
local function startConveyor(part: BasePart)
	local speed = part:GetAttribute("Speed")
	local studsPerSecond = if typeof(speed) == "number" then speed else DEFAULT_SPEED
	part.Anchored = true
	part.AssemblyLinearVelocity = part.CFrame.LookVector * studsPerSecond
end

local function onDescendant(instance: Instance)
	if instance.Name == "Conveyor" and instance:IsA("BasePart") then
		startConveyor(instance)
	end
end

for _, instance in tycoons:GetDescendants() do
	onDescendant(instance)
end

-- A conveyor sold by a button comes back into its plot later, so watch for it too
tycoons.DescendantAdded:Connect(onDescendant)

LookVector is the direction of the part's front face, so the belt runs the way the part faces. If drops travel the wrong way, rotate the conveyor 180 degrees or make its Speed negative.

Because the direction comes from the part, you can build a bend from two or more Conveyor parts, each turned to face the next. Every part named Conveyor anywhere inside Tycoons is picked up.

Step 5: Pay the owner at the collector

The collector listens for Touched, checks that the part is a drop from this plot, destroys it and pays its value to the plot's owner. It pays the owner, not whoever touches the collector, so walking onto it does nothing and a neighbour's stray drop earns nobody anything. Insert a Script in ServerScriptService named TycoonCollectors.

Script in ServerScriptService named TycoonCollectors
local Players = game:GetService("Players")

local tycoons = workspace:WaitForChild("Tycoons")

local function ownerIdOf(tycoon: Instance): number
	local id = tycoon:GetAttribute("OwnerId")
	return if typeof(id) == "number" then id else 0
end

local function cashOf(player: Player): IntValue?
	local leaderstats = player:FindFirstChild("leaderstats")
	local cash = leaderstats and leaderstats:FindFirstChild("Cash")
	if cash and cash:IsA("IntValue") then
		return cash
	end
	return nil
end

local function setUpCollector(tycoon: Model, collector: BasePart)
	collector.Touched:Connect(function(hit: BasePart)
		-- Only drops made by this plot's droppers count. Characters, tools and
		-- drops from a neighbour's plot are ignored.
		local drops = tycoon:FindFirstChild("Drops")
		if drops == nil or hit.Parent ~= drops then
			return
		end

		local value = hit:GetAttribute("Value")
		-- Destroy sets Parent to nil, so a second touch from the same drop fails the check above
		hit:Destroy()

		local owner = Players:GetPlayerByUserId(ownerIdOf(tycoon))
		local cash = owner and cashOf(owner)
		if cash and typeof(value) == "number" then
			cash.Value += math.floor(value)
		end
	end)
end

for _, tycoon in tycoons:GetChildren() do
	local collector = tycoon:FindFirstChild("Collector")
	if tycoon:IsA("Model") and collector and collector:IsA("BasePart") then
		setUpCollector(tycoon, collector)
	else
		warn(`{tycoon:GetFullName()} needs a Part named Collector`)
	end
end

Touched only fires when at least one of the two parts is not anchored. The collector is anchored, so this works only because drops are not. An anchored drop never fires Touched on the collector, and never rides the conveyor either.

Reading the value from the drop is safe because the server set it. A client that changes an attribute changes its own copy only; the server's copy, which this script reads, stays as it was.

Step 6: Make tycoon buttons that buy the next upgrade

At startup the buttons script moves every item a button can unlock into a folder in ServerStorage, where nobody can see or touch it. When the owner steps on a button, the server checks that they own this plot, that the button is showing, and that they have enough Cash. Only then does it take the Cash, put the item back where you built it, and show any button that was waiting for this one. When the owner leaves, everything goes back into storage. Insert a Script in ServerScriptService named TycoonButtons.

Script in ServerScriptService named TycoonButtons
local Players = game:GetService("Players")
local ServerStorage = game:GetService("ServerStorage")

local tycoons = workspace:WaitForChild("Tycoons")

type Button = {
	part: BasePart,
	price: number,
	requires: string?,
	item: Instance,
	itemHome: Instance?,
	bought: boolean,
}

type Tycoon = {
	model: Model,
	buttonsFolder: Instance,
	stash: Folder,
	buttons: { [string]: Button },
}

-- Unbought items and hidden buttons wait here, out of the world
local stashRoot = Instance.new("Folder")
stashRoot.Name = "TycoonStash"
stashRoot.Parent = ServerStorage

local function ownerIdOf(model: Instance): number
	local id = model:GetAttribute("OwnerId")
	return if typeof(id) == "number" then id else 0
end

local function playerFromHit(hit: BasePart): Player?
	local character = hit:FindFirstAncestorOfClass("Model")
	if not character then
		return nil
	end
	local humanoid = character:FindFirstChildOfClass("Humanoid")
	if not humanoid or humanoid.Health <= 0 then
		return nil
	end
	return Players:GetPlayerFromCharacter(character)
end

local function cashOf(player: Player): IntValue?
	local leaderstats = player:FindFirstChild("leaderstats")
	local cash = leaderstats and leaderstats:FindFirstChild("Cash")
	if cash and cash:IsA("IntValue") then
		return cash
	end
	return nil
end

local function readString(instance: Instance, name: string): string?
	local value = instance:GetAttribute(name)
	return if typeof(value) == "string" and value ~= "" then value else nil
end

local function moveTo(instance: Instance, parent: Instance?)
	if instance.Parent ~= parent then
		instance.Parent = parent
	end
end

local function addPriceLabel(part: BasePart, text: string)
	local billboard = Instance.new("BillboardGui")
	billboard.Size = UDim2.fromOffset(220, 50)
	billboard.StudsOffset = Vector3.new(0, 3, 0)
	billboard.MaxDistance = 60

	local label = Instance.new("TextLabel")
	label.Size = UDim2.fromScale(1, 1)
	label.BackgroundTransparency = 1
	label.TextColor3 = Color3.new(1, 1, 1)
	label.TextStrokeTransparency = 0.4
	label.TextScaled = true
	label.Text = text
	label.Parent = billboard
	billboard.Parent = part
end

-- A button shows when the plot has an owner, it is not bought yet, and the button
-- it requires (if any) is bought. An item shows once its button is bought.
local function refresh(tycoon: Tycoon)
	local owned = ownerIdOf(tycoon.model) ~= 0
	for _, button in tycoon.buttons do
		local requiredName = button.requires
		local required = if requiredName then tycoon.buttons[requiredName] else nil
		local unlocked = requiredName == nil or (required ~= nil and required.bought)
		local show = owned and unlocked and not button.bought
		moveTo(button.part, if show then tycoon.buttonsFolder else tycoon.stash)
		moveTo(button.item, if button.bought then button.itemHome else tycoon.stash)
	end
end

-- A touch only asks to buy. Every check happens here, on the server.
local function tryBuy(tycoon: Tycoon, button: Button, player: Player)
	if ownerIdOf(tycoon.model) ~= player.UserId then
		return -- not this player's plot
	end
	if button.bought or button.part.Parent ~= tycoon.buttonsFolder then
		return -- already bought, or still hidden
	end
	local cash = cashOf(player)
	if not cash or cash.Value < button.price then
		return -- not enough Cash
	end
	cash.Value -= button.price
	button.bought = true
	refresh(tycoon)
end

local function addButton(tycoon: Tycoon, part: BasePart)
	local price = part:GetAttribute("Price")
	local itemName = readString(part, "Unlocks")
	local item = if itemName then tycoon.model:FindFirstChild(itemName, true) else nil
	if typeof(price) ~= "number" or price < 0 or item == nil then
		warn(`{part:GetFullName()} needs a Price number and an Unlocks name that exists in {tycoon.model.Name}`)
		return
	end

	local button: Button = {
		part = part,
		price = math.floor(price),
		requires = readString(part, "Requires"),
		item = item,
		itemHome = item.Parent,
		bought = false,
	}
	tycoon.buttons[part.Name] = button
	addPriceLabel(part, `{itemName}: {button.price} Cash`)

	part.Touched:Connect(function(hit: BasePart)
		local player = playerFromHit(hit)
		if player then
			tryBuy(tycoon, button, player)
		end
	end)
end

local function setUpTycoon(model: Model, buttonsFolder: Instance)
	local stash = Instance.new("Folder")
	stash.Name = model.Name
	stash.Parent = stashRoot

	local tycoon: Tycoon = { model = model, buttonsFolder = buttonsFolder, stash = stash, buttons = {} }
	for _, part in buttonsFolder:GetChildren() do
		if part:IsA("BasePart") then
			addButton(tycoon, part)
		end
	end
	for name, button in tycoon.buttons do
		local requiredName = button.requires
		if requiredName and tycoon.buttons[requiredName] == nil then
			warn(`{name} requires {requiredName}, which is not a button in {model.Name}`)
		end
	end

	-- A new owner starts from nothing; when the owner leaves, everything goes back
	model:GetAttributeChangedSignal("OwnerId"):Connect(function()
		if ownerIdOf(model) == 0 then
			for _, button in tycoon.buttons do
				button.bought = false
			end
		end
		refresh(tycoon)
	end)
	refresh(tycoon)
end

for _, model in tycoons:GetChildren() do
	local buttonsFolder = model:FindFirstChild("Buttons")
	if model:IsA("Model") and buttonsFolder then
		setUpTycoon(model, buttonsFolder)
	end
end

Each button reads its price on the server, from an attribute the server can see. The touch carries no price or amount, so there is nothing for a client to change.

Two things make the button safe to spam. tryBuy runs from start to finish without waiting, so two touches in a row cannot both pass the Cash check. And after the first purchase bought is true and the button has gone into storage, so every later touch returns early.

Selling upgrades from a GUI instead of pads? Have the LocalScript fire a RemoteEvent with the button's name only, then look the button up on the server and run the same tryBuy checks. Never accept a price or an amount from the client. The RemoteEvents guide shows the validation pattern, and the shop GUI guide builds the menu.

Step 7: Save Cash between sessions

leaderstats only lasts as long as the server. To keep Cash, load it from a data store when the player joins and save it when they leave or the server shuts down. Replace the contents of the Leaderstats script from Step 1 with this version. It never saves a player whose Cash failed to load, so a data store outage cannot overwrite real progress with 0.

Script in ServerScriptService named Leaderstats
local Players = game:GetService("Players")
local DataStoreService = game:GetService("DataStoreService")

local cashStore = DataStoreService:GetDataStore("TycoonCash_v1")

-- Players whose Cash loaded. Anyone missing from here is never saved,
-- so a failed load cannot overwrite their real Cash with 0.
local loaded: { [Player]: boolean } = {}
local savesInProgress = 0

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 cash = Instance.new("IntValue")
	cash.Name = "Cash"
	cash.Parent = leaderstats

	local ok, saved = pcall(function()
		return cashStore:GetAsync(keyFor(player))
	end)
	if player.Parent ~= Players then
		return -- they left while their Cash was loading
	end
	if ok then
		cash.Value = if typeof(saved) == "number" then math.floor(saved) else 0
		loaded[player] = true
	else
		warn(`Could not load Cash for {player.Name}: {saved}`)
	end
	leaderstats.Parent = player
end

local function save(player: Player)
	if not loaded[player] then
		return
	end
	loaded[player] = nil -- save once, whether leaving or shutdown comes first

	local leaderstats = player:FindFirstChild("leaderstats")
	local cash = leaderstats and leaderstats:FindFirstChild("Cash")
	if not (cash and cash:IsA("IntValue")) then
		return
	end
	local amount = cash.Value

	savesInProgress += 1
	local ok, err = pcall(function()
		cashStore:SetAsync(keyFor(player), amount)
	end)
	savesInProgress -= 1
	if not ok then
		warn(`Could not save Cash for {player.Name}: {err}`)
	end
end

Players.PlayerAdded:Connect(onPlayerAdded)
for _, player in Players:GetPlayers() do
	task.spawn(onPlayerAdded, player)
end

Players.PlayerRemoving:Connect(save)

-- When the server shuts down, save everyone still here and wait for every save to finish
game:BindToClose(function()
	for _, player in Players:GetPlayers() do
		task.spawn(save, player)
	end
	while savesInProgress > 0 do
		task.wait()
	end
end)

If a load fails, the player keeps playing with 0 Cash that is never saved. In a real game, tell them, as the DataStore tutorial shows, so they can rejoin before they spend time earning.

This is enough for Cash in a small game. For retries, UpdateAsync, request limits and stopping two servers from writing the same player, continue with the DataStore tutorial. To save what a player bought as well, see the questions at the end of this page.

Test the tycoon

  1. Press Play. The sign above the pad reads "Free tycoon". Step on the pad: the sign changes to your name, Dropper1 starts dropping and the BuyDropper2 button appears with its price.
  2. Watch a few drops ride the conveyor and vanish at the collector. Cash in the player list goes up by each drop's DropValue.
  3. At 50 Cash, step on BuyDropper2. Cash drops by 50, Dropper2 appears and starts dropping, and BuyWalls appears.
  4. Duplicate Tycoon1 (Ctrl+D, or Cmd+D on Mac), move the copy aside and rename it Tycoon2. In the testing modes drop-down choose Server & Clients with 2 clients and press Play. A player who owns a plot cannot claim the second one, and nobody can take a plot someone else owns.

If something does not happen, open the Output window first. Every script above warns by name when a part is missing or a button is set up wrong.

Keep a tycoon fast: drops, parts and cleanup

A tycoon makes new parts for as long as the server runs, so a missing Destroy makes the server slower the longer it runs. The scripts above already do the following. Keep doing them as the game grows.

  • Destroy every drop. The collector destroys each drop it pays for, and Debris:AddItem removes any drop that falls off after 30 seconds. Debris has a fixed limit of 1,000 items: when more are added, the oldest are destroyed at once, even if they are still riding the conveyor. If you add many fast droppers, keep the number of drops the whole server makes in 30 seconds well under 1,000.
  • Cap live drops. Every unanchored drop is one more assembly for the physics engine, and Roblox names the number of simulated assemblies as a cause of slow physics frames. MAX_DROPS_PER_TYCOON stops a fast dropper from filling the plot when the conveyor jams. With 8 plots and a cap of 50, the worst case is 400 drops.
  • Anchor everything else. The conveyor, collector, droppers, buttons and decorations never need physics. Roblox's performance guide recommends anchoring every part that does not need simulating.
  • Keep drops on the server. SetNetworkOwner(nil) stops players' devices from simulating drops. Roblox advises using it sparingly because it can make physics look jittery to players, which is a fair trade for parts that are worth money.
  • Use one loop for all droppers. A single script checks every dropper five times a second, so adding a dropper means adding a part, not another script with its own loop.
  • Clean up per player and per plot. When an owner leaves, their plot's drops are cleared, their purchases go back into storage and the save script removes them from its table. Tables keyed by player that are never cleared are a common slow memory leak on long-running servers.

Common errors and fixes

These are common reasons a tycoon does not work. Check the Output window first: a script that errored names the line, and the scripts above warn when a part is missing.

Drops do not move on the conveyor

Check that the part is named exactly Conveyor, is inside a plot in Tycoons, and that TycoonConveyors is a Script in ServerScriptService. If drops move the wrong way, rotate the conveyor 180 degrees or make Speed negative. Older tutorials set Velocity, which Roblox has deprecated: use AssemblyLinearVelocity.

Drops reach the collector but Cash does not go up

Touched needs at least one unanchored part, so drops must not be anchored, and CanTouch must be on for both parts. The collector only pays for parts inside its own plot's Drops folder, so drops made by another script are ignored. The owner also needs a leaderstats folder with an IntValue named exactly Cash.

Cash goes to whoever walks onto the collector

The collector script is paying whoever touches it. Pay the plot's owner instead, looked up from OwnerId, and pay only for parts in the plot's Drops folder, as in Step 5.

A player can own two tycoons, or a plot stays locked after its owner leaves

The claim needs a check that the player owns no other plot, and Players.PlayerRemoving must set OwnerId back to 0. Both are in Step 2.

A button takes Cash twice, or buys when the player cannot afford it

The purchase must happen in a server Script that checks Cash and marks the button bought before anything can run again. A LocalScript that changes Cash only changes it on that player's screen, which is why the number jumps back.

A button never appears

Buttons only show after a player claims the plot. If it still does not appear, its Requires attribute names a button that does not exist or is not bought yet; the Output window shows a warning for a missing one.

Infinite yield possible on 'Workspace:WaitForChild("Tycoons")'

The scripts cannot find the folder. It must be a Folder named exactly Tycoons directly inside Workspace. The script errors guide explains this warning.

The game gets slower the longer it runs

Drops are not being destroyed. Make sure the collector destroys each drop, every drop goes to Debris:AddItem, and the live-drop cap is in place.

Build it with RoCode

If you would rather not place every part and attribute by hand, RoCode can build this in the place you have open. It is an AI agent that works through a Roblox Studio plugin, so it creates the scripts, parts and attributes in your Explorer instead of giving you code to paste.

You type

Build a tycoon plot in Workspace with a claim pad, two droppers, a conveyor and a collector that pays Cash to the owner, plus buttons that unlock the second dropper and then some walls. Save Cash between sessions.

RoCode does
  • Can search your place first for an existing leaderstats script or tycoon parts, so it extends what you have instead of adding a second Cash stat
  • Creates the server scripts in ServerScriptService and the parts in Workspace, and sets attributes such as Price and Interval on them
  • Compile-checks each script 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 playtest it yourself: RoCode works in edit mode and does not press Play. If drops get stuck, describe what you saw and it can read the Output from your last playtest. How RoCode connects to Studio.

Questions

Can I make a tycoon without scripting?

Ready-made tycoon kits and templates are shared by other creators, and they let you start without writing code. They are still scripts someone else wrote, so read every script in a kit before you publish, and expect to read them anyway when you want to change prices or add droppers. Building it yourself, as in this guide, takes six short scripts.

How do I add more plots?

Select Tycoon1, duplicate it with Ctrl+D (Cmd+D on Mac), move the copy and rename it. Every script handles every Model inside Tycoons, so no code changes are needed.

How do I save what a player bought?

Save a table instead of a number, such as Cash plus a list of bought button names. When that player claims a plot, mark those buttons as bought and refresh the plot; if they claim before their data has loaded, apply it when it arrives. The DataStore tutorial covers saving tables safely.

How do I add rebirths?

Add a Rebirths IntValue to leaderstats. A rebirth button checks on the server that the player has enough Cash, sets Cash to 0, resets their plot's purchases (the same reset that runs when an owner leaves) and adds 1 to Rebirths. The collector then multiplies each drop's value, for example by 1 + 0.5 × Rebirths. Save Rebirths with Cash.

How do I add a 2x Cash game pass?

Check pass ownership on the server, as the game pass guide does: its server script sets an attribute on the player once it has confirmed they own the pass or have just bought it. In the collector, look up the plot's owner as before and double the drop's value when that attribute is true. Read it only in a server Script: a player can change the attribute on their own copy, but that change never reaches the server.

Can a LocalScript run the dropper?

No. A part made by a LocalScript exists only on that player's device. The server never sees it, so the collector never pays for it and other players never see it. Droppers, the collector and buttons all belong in server Scripts.

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