Building a Complete Roblox Game
Building a Roblox game from scratch means wiring together server scripts, client scripts, and data persistence into a single coherent project. This tutorial walks through building a complete Roblox game: a coin collection game with persistent scores, a leaderboard, and a simple UI.
We’ll use Luau (Roblox’s Lua dialect), work inside Roblox Studio, and cover the core architecture patterns you’ll use in almost every Roblox game: server scripts, local scripts, RemoteEvents, leaderstats, and DataStoreService. By the end, you’ll have a working game that saves player data between sessions and responds to player actions in real time.
Prerequisites
Before starting, make sure you have Roblox Studio installed and you’re comfortable with basic Luau syntax — variables, functions, tables, and events. If you’re new to Roblox scripting, work through the Getting Started with Roblox Lua tutorial first. You’ll also want a basic understanding of the Roblox object model covered in Scripting Game Objects and Services.
What we’re building
A game where:
- Players spawn and see their coin balance on a leaderboard
- Coins are scattered around the map
- Touching a coin collects it and adds to the score
- The score persists between sessions (quit and come back, your coins are still there)
- Players see a floating feedback message when they collect a coin
Project Setup
Open Roblox Studio and create a new Baseplate project. In the Explorer panel, set up this folder structure:
game
├── ServerScriptService
│ ├── GameSetup.server.lua
│ └── CoinManager.server.lua
├── ReplicatedStorage
│ └── CoinCollected ← RemoteEvent
└── StarterPlayer
└── StarterPlayerScripts
└── PlayerUI.client.lua
Create these files before writing any code. Right-click each service in the Explorer → Insert Object → choose the right type.
Part 1: Player setup with leaderstats
Every Roblox game that tracks player scores uses leaderstats. It’s a folder named leaderstats parented to the player object, containing value objects (IntValue, NumberValue, etc.) that Roblox automatically displays in the leaderboard.
Write this in ServerScriptService/GameSetup.server.lua:
local Players = game:GetService("Players")
local DataStoreService = game:GetService("DataStoreService")
local playerDataStore = DataStoreService:GetDataStore("PlayerCoins_v1")
local function setupLeaderstats(player)
local leaderstats = Instance.new("Folder")
leaderstats.Name = "leaderstats"
leaderstats.Parent = player
local coins = Instance.new("IntValue")
coins.Name = "Coins"
coins.Value = 0
coins.Parent = leaderstats
end
local function loadPlayerData(player)
local playerId = player.UserId
local data
local success, err = pcall(function()
data = playerDataStore:GetAsync(playerId)
end)
if success and data then
return data
else
return 0
end
end
local function savePlayerData(player)
local playerId = player.UserId
local coins = player.leaderstats and player.leaderstats:FindFirstChild("Coins")
if not coins then return end
local success, err = pcall(function()
playerDataStore:SetAsync(playerId, coins.Value)
end)
if not success then
warn("Failed to save coins for " .. player.Name .. ": " .. tostring(err))
end
end
Players.PlayerAdded:Connect(function(player)
setupLeaderstats(player)
local savedCoins = loadPlayerData(player)
local coins = player.leaderstats:FindFirstChild("Coins")
if coins then
coins.Value = savedCoins
end
end)
Players.PlayerRemoving:Connect(function(player)
savePlayerData(player)
end)
Three things are happening here. setupLeaderstats() creates the folder and an IntValue named Coins for every player who joins. loadPlayerData() reads their saved coin count from the DataStore — it returns 0 for new players. savePlayerData() fires when a player leaves, writing their current coin count back to the DataStore.
We wrap every DataStore call in pcall because DataStore can fail if Roblox’s service is under heavy load. If we didn’t catch that, the error would crash our script.
Part 2: Spawning coins
Coins in the workspace are just Part objects with a specific name and a script attached. Write this in ServerScriptService/CoinManager.server.lua:
local ServerScriptService = game:GetService("ServerScriptService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local COIN_VALUE = 1
local COIN_TEMPLATE = script:FindFirstChild("CoinTemplate") or error("CoinTemplate not found")
local activeCoins = {}
local function createCoin(position)
local coin = COIN_TEMPLATE:Clone()
coin.Position = position
coin.Parent = workspace
table.insert(activeCoins, coin)
return coin
end
local function spawnCoins()
for _, coin in ipairs(activeCoins) do
if coin and coin.Parent then
coin:Destroy()
end
end
activeCoins = {}
local spawnLocation = Vector3.new(0, 5, 0)
for i = 1, 20 do
local offset = Vector3.new(
math.random(-50, 50),
0,
math.random(-50, 50)
)
createCoin(spawnLocation + offset)
end
end
local function respawnCoin(originalCoin)
task.wait(3)
if originalCoin and originalCoin.Parent then return end
local position = Vector3.new(
math.random(-50, 50),
5,
math.random(-50, 50)
)
createCoin(position)
end
spawnCoins()
This script assumes there’s a Part named CoinTemplate parented to the script itself (a template we can clone). In practice, create a Part in Studio, make it gold and shiny, disable CanCollide, and put it inside the script object as a template.
Even after spawning coins, nothing happens until a player touches one. The spawning logic only handles placement and respawning; the actual gameplay interaction lives in a separate section of the script. That separation keeps the spawning system reusable — you could swap out the coin model for gems or stars without touching the collection logic at all.
Part 3: Collecting coins
Now the interaction: when a player touches a coin, remove the coin, update their score, and notify them:
local CoinCollectedEvent = ReplicatedStorage:WaitForChild("CoinCollected")
local function onCoinTouched(coin, player)
if not coin or not coin.Parent then return end
if not player or not player:IsA("Player") then return end
coin:Destroy()
local leaderstats = player:FindFirstChild("leaderstats")
if not leaderstats then return end
local coins = leaderstats:FindFirstChild("Coins")
if not coins then return end
coins.Value = coins.Value + COIN_VALUE
CoinCollectedEvent:FireClient(player, COIN_VALUE)
task.spawn(function()
respawnCoin(coin)
end)
end
workspace.DescendantAdded:Connect(function(descendant)
if descendant:IsA("BasePart") and descendant.Name == "Coin" then
descendant.Touched:Connect(function(hit)
local player = game.Players:GetPlayerFromCharacter(hit.Parent)
onCoinTouched(descendant, player)
end)
end
end)
We use task.spawn() to run the respawn asynchronously. We don’t want the player’s Touched event to block. We check player:IsA("Player") because Touched fires for any part that touches the coin.
Once the server updates the coin count in leaderstats, the client needs to know about it so the player sees the change. Roblox doesn’t automatically sync leaderstats values to the client’s UI — you have to send that data yourself. That’s where the CoinCollectedEvent RemoteEvent comes in. It carries the coin amount from the server to the specific player who collected it, and the client-side script we’re about to write picks it up and displays it on screen.
Part 4: player UI feedback
Write this in StarterPlayerScripts/PlayerUI.client.lua:
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TweenService = game:GetService("TweenService")
local player = Players.LocalPlayer
local playerGui = player:WaitForChild("PlayerGui")
local CoinCollectedEvent = ReplicatedStorage:WaitForChild("CoinCollected")
local screenGui = Instance.new("ScreenGui")
screenGui.Name = "CoinCounterGui"
screenGui.ResetOnSpawn = false
screenGui.Parent = playerGui
local coinLabel = Instance.new("TextLabel")
coinLabel.Name = "CoinLabel"
coinLabel.Size = UDim2.new(0, 200, 0, 50)
coinLabel.Position = UDim2.new(0.5, -100, 0, 10)
coinLabel.BackgroundColor3 = Color3.fromRGB(40, 40, 40)
coinLabel.TextColor3 = Color3.fromRGB(255, 215, 0)
coinLabel.Font = Enum.Font.GothamBold
coinLabel.TextSize = 28
coinLabel.Text = "Coins: 0"
coinLabel.Parent = screenGui
local function showCoinFeedback(amount)
local feedback = Instance.new("TextLabel")
feedback.Size = UDim2.new(0, 100, 0, 40)
feedback.Position = UDim2.new(0.5, -50, 0.4, 0)
feedback.BackgroundTransparency = 1
feedback.TextColor3 = Color3.fromRGB(255, 215, 0)
feedback.Font = Enum.Font.GothamBold
feedback.TextSize = 32
feedback.Text = "+" .. tostring(amount)
feedback.Parent = screenGui
local tween = TweenService:Create(
feedback,
TweenInfo.new(1, Enum.EasingStyle.Quad, Enum.EasingDirection.Out),
{Position = UDim2.new(0.5, -50, 0.3, 0), TextTransparency = 1}
)
tween:Play()
tween.Completed:Connect(function()
feedback:Destroy()
end)
end
local function updateCoinDisplay()
local leaderstats = player:FindFirstChild("leaderstats")
if leaderstats then
local coins = leaderstats:FindFirstChild("Coins")
if coins then
coinLabel.Text = "Coins: " .. tostring(coins.Value)
end
end
end
CoinCollectedEvent.OnClientEvent:Connect(function(amount)
showCoinFeedback(amount or 1)
updateCoinDisplay()
end)
player.CharacterAdded:Connect(function()
task.wait(0.5)
updateCoinDisplay()
end)
The script creates the coin counter at the top of the screen, listens for CoinCollectedEvent, and animates a floating “+1” message using TweenService. It also updates the counter on character respawn.
The UI handles visual feedback, but the coin data only lives in memory and in leaderstats so far. If a player disconnects without a proper save, all their progress is gone. DataStoreService lets us persist coin balances to Roblox’s cloud storage so players can return days later and pick up right where they left off. Each player’s data is stored under their unique UserId, which means progress follows them across servers and sessions. Roblox enforces rate limits on DataStore operations, so the auto-save interval you choose is a balance between data freshness and avoiding throttling.
Part 5: Saving on quit
Add periodic auto-save to GameSetup.server.lua:
task.spawn(function()
while true do
task.wait(60)
for _, player in ipairs(Players:GetPlayers()) do
savePlayerData(player)
end
end
end)
Without periodic saves, a player who crashes loses their last minute of progress. With auto-save, the worst case is losing at most 60 seconds of coins.
Now let’s step back and look at how all the pieces fit together. The architecture diagram below maps every script to its role: where it lives in the hierarchy, what it connects to, and how data flows between them. This overview is useful when you come back to the project weeks later or when another developer joins — it’s the map that makes the code navigable.
Architecture overview
[ServerScriptService]
GameSetup.server.lua
→ Creates leaderstats on PlayerAdded
→ Loads data from DataStoreService on PlayerAdded
→ Saves data on PlayerRemoving + every 60 seconds
CoinManager.server.lua
→ Spawns coins into workspace on startup
→ Listens to workspace.Touched events on coins
→ Updates player Coins value
→ Fires RemoteEvent to client on collection
→ Respawns coins after collection
[ReplicatedStorage]
CoinCollected (RemoteEvent)
→ Fired by server → received by specific client
[StarterPlayerScripts]
PlayerUI.client.lua
→ Creates on-screen coin counter
→ Listens for RemoteEvent → shows "+1" animation
→ Updates counter on character respawn
The separation between server and client matters. The coin collection logic runs on the server, so players can’t cheat by firing the RemoteEvent manually. The client only handles UI feedback.
Next steps
Now that you’ve built a complete game, try experimenting with these extensions:
- Add different coin types with varying values — create multiple templates in CoinManager and pass different COIN_VALUE amounts
- Build a shop GUI where players spend coins on items, using RemoteFunctions to validate purchases server-side
- Add a leaderboard that ranks all players by coin count using
DataStoreService:GetSortedAsync()
These patterns — persistence, client-server communication, and UI — form the core of nearly every Roblox game you’ll build.
See Also
- Remote Events in Roblox: the communication channel between server and client scripts
- Data Stores in Roblox: deeper coverage of DataStoreService patterns
- Getting Started with Roblox Lua: setting up scripts and understanding the Explorer