luaguides

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