Building a Complete Roblox Game
This tutorial walks through building a complete Roblox game from scratch: a coin collection game with persistent scores, a leaderboard, and a simple UI. By the end, you’ll have a working game that saves player data between sessions and responds to player actions in real time.
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.
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.
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.
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.
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.
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.
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