luaguides

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