Recipes

Worked games, each a small game and the scenario that plays it, from a game pass to a motto board.

Each is a whole game in plain Luau (its Init scripts), then its scenario. How to drive a world (players, clicks, the clock, and what is modelled) is in Scenarios.

A game pass

A VIP pass bought in the game: the server marks its owners with a Vip attribute, at join and when a pass purchase finishes (Init/Server.shot):

@server enabled

local Players = game:GetService("Players")
local market = game:GetService("MarketplaceService")
local VIP_PASS = 2002

market.PromptGamePassPurchaseFinished:Connect(function(player : Player, passId : number, purchased : boolean)
    if purchased and passId == VIP_PASS then
        player:SetAttribute("Vip", true)
    end
end)

Players.PlayerAdded:Connect(function(player : Player)
    if market:UserOwnsGamePassAsync(player.UserId, VIP_PASS) then
        player:SetAttribute("Vip", true)
    end
end)

The mock accepts every purchase, so a purchase a client starts (as a shop button's script would) buys the pass: its Finished event fires on the server too, and the pass stays owned for the rest of the run, so a rejoin keeps it (Scenarios/VipPass.luau):

-- VipPass: Alice buys the pass from her client and is Vip; after a rejoin she still is.
return function(world)
    world:Boot()
    local alice, client = world:AddPlayer("Alice")
    client.Game:GetService("MarketplaceService"):PromptGamePassPurchase(client.LocalPlayer, 2002)
    world:Step(0.1)
    assert(alice:GetAttribute("Vip") == true, "the server heard the purchase")
    world:RemovePlayer(alice)
    world:Step(0.1)
    local again = world:AddPlayer("Alice", { UserId = alice.UserId })
    world:Step(0.1)
    assert(again:GetAttribute("Vip") == true, "UserOwnsGamePassAsync at rejoin")
end

A sword

A Tool every player carries: a Sword in StarterPack whose Activated, heard on the server, hits the nearest other character within 6 studs for 25 through Humanoid:TakeDamage. The server also makes a Baseplate to stand on (Init/Server.shot):

@server enabled

local Players = game:GetService("Players")
local StarterPack = game:GetService("StarterPack")

local DAMAGE = 25
local RANGE = 6

local sword = Instance.new("Tool")
sword.Name = "Sword"
local handle = Instance.new("Part")
handle.Name = "Handle"
handle.Size = Vector3.new(1, 1, 4)
handle.Parent = sword
sword.Parent = StarterPack

local baseplate = Instance.new("Part")    -- a character with no part under it falls, as in Roblox
baseplate.Name = "Baseplate"
baseplate.Anchored = true
baseplate.Size = Vector3.new(64, 1, 64)
baseplate.Position = Vector3.new(0, -0.5, 0)    -- its top at Y 0, so a character stands at Y 3
baseplate.Parent = workspace

local function swing(swinger : Player)
    local character = swinger.Character
    if character == nil then
        return
    end
    local from = character:GetPivot().Position
    local target : Humanoid? = nil
    local nearest = RANGE
    for _, other in Players:GetPlayers() do
        local otherCharacter = other.Character
        local humanoid = if otherCharacter then otherCharacter:FindFirstChildOfClass("Humanoid") else nil
        if other ~= swinger and otherCharacter and humanoid and humanoid.Health > 0 then
            local distance = (otherCharacter:GetPivot().Position - from).Magnitude
            if distance <= nearest then
                target, nearest = humanoid, distance
            end
        end
    end
    if target then
        target:TakeDamage(DAMAGE)
    end
end

Players.PlayerAdded:Connect(function(player : Player)
    player.CharacterAdded:Connect(function(character : Model)
        character.ChildAdded:Connect(function(child : Instance)
            if child:IsA("Tool") and child.Name == "Sword" and not child:GetAttribute("Hooked") then
                child:SetAttribute("Hooked", true)
                child.Activated:Connect(function()
                    swing(player)
                end)
            end
        end)
    end)
end)

An equipped Tool moves from the Backpack into the character, and back on unequip, so the server hooks it in the character, once per Tool (the Hooked attribute keeps a re-equip from hooking it twice). The scenario equips the sword with world:Equip, as her hotbar would, then clicks with no GUI under the mouse, which activates the held Tool. Bob dies at the fourth swing and respawns Players.RespawnTime (5) seconds later (Scenarios/Sword.luau):

-- Sword: four swings kill Bob 4 studs away; he respawns 5 s later at full Health; from 20 studs a swing misses.
return function(world)
    world:Boot()
    local alice, client = world:AddPlayer("Alice")
    local bob = world:AddPlayer("Bob")
    world:SetPosition(alice, Vector3.new(0, 3, 0))
    world:SetPosition(bob, Vector3.new(4, 3, 0))
    world:Step(0.1)
    world:Equip(alice, "Sword")    -- from her Backpack: Equipped fires on her client and the server
    local uis = client.Game:GetService("UserInputService")
    local function click()
        uis:SimulateInputBegan({ UserInputType = "MouseButton1" })
        uis:SimulateInputEnded({ UserInputType = "MouseButton1" })
        world:Step(0.1)
    end
    local first = bob.Character
    for _ = 1, 4 do
        click()
    end
    assert(first.Humanoid.Health == 0, "four swings of 25 kill Bob")
    world:Step(5.5)
    assert(bob.Character ~= first and bob.Character.Humanoid.Health == 100, "Bob respawned at full Health")
    world:SetPosition(bob, Vector3.new(20, 3, 0))
    world:Step(0.1)
    click()
    assert(bob.Character.Humanoid.Health == 100, "out of reach")
end

Health regenerates 1% of MaxHealth a second (Scenarios, WHAT IS MODELLED), so a scenario asserts damage right after it lands, as here (the swings are 0.1 s apart). A game that wants no regen does it the Roblox ways: a Script named Health in StarterCharacterScripts, or character:FindFirstChild("Health"):Destroy() in CharacterAdded.

A leaderboard

A global board of the top five: every 10 seconds the server saves each player's Coins to an OrderedDataStore and reads it back highest first with GetSortedAsync(false, 2), two entries a page. GetCurrentPage gives the page's entries ({ key, value }) and AdvanceToNextPageAsync turns the page until IsFinished. Players:GetNameFromUserIdAsync names any player who joined in the run, also after leaving, and errors for an id no player had (Init/Server.shot):

@server enabled

local Players = game:GetService("Players")
local DataStoreService = game:GetService("DataStoreService")

local store = DataStoreService:GetOrderedDataStore("GlobalCoins")
local TOP = 5

Players.PlayerAdded:Connect(function(player : Player)
    local stats = Instance.new("Folder")
    stats.Name = "leaderstats"
    local coins = Instance.new("IntValue")
    coins.Name = "Coins"
    coins.Parent = stats
    stats.Parent = player
end)

local function readTop() : {{key : string, value : number}}
    local top = {}
    local pages = store:GetSortedAsync(false, 2)    -- highest first, two entries a page
    while true do
        for _, entry in pages:GetCurrentPage() do
            if #top < TOP then
                table.insert(top, entry)
            end
        end
        if #top == TOP or pages.IsFinished then
            return top
        end
        pages:AdvanceToNextPageAsync()
    end
end

local function refresh()
    for _, player in Players:GetPlayers() do
        local coins = (player:FindFirstChild("leaderstats") :: Folder):FindFirstChild("Coins") :: IntValue
        store:SetAsync(tostring(player.UserId), coins.Value)
    end
    local board = Instance.new("Folder")
    board.Name = "Leaderboard"
    for rank, entry in readTop() do
        local label = Instance.new("StringValue")
        label.Name = tostring(rank)
        label.Value = Players:GetNameFromUserIdAsync(tonumber(entry.key) :: number) .. " " .. entry.value
        label.Parent = board
    end
    local old = workspace:FindFirstChild("Leaderboard")
    if old then
        old:Destroy()
    end
    board.Parent = workspace
end

while true do
    task.wait(10)
    refresh()
end

The scenario sets each player's Coins on the server and Steps past the refresh: DataStore calls yield, as in Roblox, so a refresh takes a few frames. A player who left keeps their place on the board (Scenarios/Leaderboard.luau):

-- Leaderboard: the board reads the top five across pages, highest first; a player who left stays on it.
return function(world)
    world:Boot()
    local players = {}
    for i = 1, 6 do
        players[i] = world:AddPlayer("P" .. i)
    end
    world:Step(0.1)
    for i, player in players do
        player.leaderstats.Coins.Value = i * 100    -- the scenario sets the server's value
    end
    world:Step(11)    -- store calls yield, so the board is up just after the 10 s mark
    local board = world.Server.Game.Workspace.Leaderboard
    assert(board["1"].Value == "P6 600" and board["5"].Value == "P2 200", "top five from three pages")
    assert(board:FindFirstChild("6") == nil, "five entries only")
    world:RemovePlayer(players[6])
    players[1].leaderstats.Coins.Value = 1000
    world:Step(10)
    board = world.Server.Game.Workspace.Leaderboard
    assert(board["1"].Value == "P1 1000" and board["2"].Value == "P6 600", "P1 leads; P6 left and stays")
end

A pet

A pet that trails its owner. The server makes a Baseplate first (a character with no part under it falls), and on CharacterAdded it makes an Anchored, CanCollide false Part in Workspace.Pets, named after the player, and connects a Heartbeat that moves it a fifth of the way to a point 3 studs behind and 2 above the HumanoidRootPart, turned the way the owner faces. Died and PlayerRemoving disconnect the Heartbeat and destroy the pet (Init/Server.shot):

@server enabled

local Players = game:GetService("Players")
local RunService = game:GetService("RunService")

local petsFolder = Instance.new("Folder")
petsFolder.Name = "Pets"
petsFolder.Parent = workspace

local baseplate = Instance.new("Part")    -- a character with no part under it falls, as in Roblox
baseplate.Name = "Baseplate"
baseplate.Anchored = true
baseplate.Size = Vector3.new(64, 1, 64)
baseplate.Position = Vector3.new(0, -0.5, 0)    -- its top at Y 0, so a character stands at Y 3
baseplate.Parent = workspace

local pets : {[Player] : Part} = {}
local beats : {[Player] : RBXScriptConnection} = {}

local function removePet(player : Player)
    local beat = beats[player]
    if beat then
        beat:Disconnect()
        beats[player] = nil
    end
    local pet = pets[player]
    if pet then
        pets[player] = nil
        pet:Destroy()
    end
end

local function givePet(player : Player, character : Model)
    removePet(player)
    local root = character:WaitForChild("HumanoidRootPart") :: BasePart
    local humanoid = character:WaitForChild("Humanoid") :: Humanoid

    local pet = Instance.new("Part")
    pet.Name = player.Name
    pet.Size = Vector3.new(1, 1, 1)
    pet.Anchored = true
    pet.CanCollide = false
    pet.CFrame = root.CFrame * CFrame.new(0, 2, 3)
    pet.Parent = petsFolder
    pets[player] = pet

    beats[player] = RunService.Heartbeat:Connect(function()
        if root.Parent == nil then
            return
        end
        local target = root.CFrame * CFrame.new(0, 2, 3)
        local pos = pet.Position:Lerp(target.Position, 0.2)
        pet.CFrame = CFrame.fromMatrix(pos, target.XVector, target.YVector, target.ZVector)
    end)

    humanoid.Died:Connect(function()
        if pets[player] == pet then
            removePet(player)
        end
    end)
end

local function onPlayer(player : Player)
    player.CharacterAdded:Connect(function(character : Model)
        givePet(player, character)
    end)
    if player.Character then
        givePet(player, player.Character)
    end
end

Players.PlayerAdded:Connect(onPlayer)
for _, player in Players:GetPlayers() do
    onPlayer(player)
end
Players.PlayerRemoving:Connect(removePet)

The scenario spawns Alice with SetPosition, which fires CharacterAdded, and walks her with world:Walk, which turns her to face +X, so her follow point ends behind her at a smaller X (Scenarios/Pet.luau):

-- Pet: a spawned player's pet trails her, behind her along her walk, and is gone when she leaves.
return function(world)
    world:Boot()
    local alice = world:AddPlayer("Alice")
    world:SetPosition(alice, Vector3.new(0, 3, 0))    -- her first spawn: CharacterAdded gives the pet
    world:Step(1)
    local pets = world.Server.Game.Workspace.Pets
    local function gap() : number
        local followPoint = alice.Character.HumanoidRootPart.CFrame * CFrame.new(0, 2, 3)
        return (pets.Alice.Position - followPoint.Position).Magnitude
    end
    assert(gap() < 0.5, "the pet is at its follow point after 1 s")
    world:Walk(alice, Vector3.new(20, 3, 0))    -- she turns to face +X as she walks
    world:Step(0.75)
    assert(gap() < 0.5 and pets.Alice.Position.X < 20, "the pet is back at its follow point, behind her")
    world:RemovePlayer(alice)
    world:Step(0.1)
    assert(pets:FindFirstChild("Alice") == nil, "her pet is gone when she leaves")
end

A badge

A badge for a first win: the server makes WinPad itself and gives each player leaderstats Wins. Each touch of the pad is one win: Roblox fires Touched for every body part and again on small bounces, so a per-player flag that TouchEnded clears keeps it to one. The first win awards badge 2124000001 with BadgeService:AwardBadge, after UserHasBadgeAsync says the player hasn't got it (Init/Server.shot):

@server enabled

local Players = game:GetService("Players")
local BadgeService = game:GetService("BadgeService")

local BADGE_ID = 2124000001

local pad = Instance.new("Part")
pad.Name = "WinPad"
pad.Anchored = true
pad.Size = Vector3.new(10, 1, 10)
pad.Position = Vector3.new(0, 0.5, 0)
pad.Parent = workspace

Players.PlayerAdded:Connect(function(player : Player)
    local stats = Instance.new("Folder")
    stats.Name = "leaderstats"
    local wins = Instance.new("IntValue")
    wins.Name = "Wins"
    wins.Parent = stats
    stats.Parent = player
end)

local onPad : {[Player] : boolean} = {}    -- one win per touch, however many Touched events it brings

local function playerOf(hit : BasePart) : Player?
    local character = hit:FindFirstAncestorOfClass("Model")
    return if character then Players:GetPlayerFromCharacter(character) else nil
end

pad.Touched:Connect(function(hit : BasePart)
    local player = playerOf(hit)
    if player == nil or onPad[player] then
        return
    end
    onPad[player] = true
    local wins = (player:FindFirstChild("leaderstats") :: Folder):FindFirstChild("Wins") :: IntValue
    wins.Value += 1
    if wins.Value == 1 and not BadgeService:UserHasBadgeAsync(player.UserId, BADGE_ID) then
        BadgeService:AwardBadge(player.UserId, BADGE_ID)
    end
end)

pad.TouchEnded:Connect(function(hit : BasePart)
    local player = playerOf(hit)
    if player then
        onPad[player] = nil
    end
end)

The scenario asks the server's BadgeService, as a game would (Scenarios/Badge.luau):

-- Badge: each touch of WinPad is one win, the first win awards badge 2124000001; Bob never won and has none.
return function(world)
    world:Boot()
    local alice = world:AddPlayer("Alice")
    local bob = world:AddPlayer("Bob")
    local badges = world.Server.Game:GetService("BadgeService")
    local BADGE = 2124000001
    local pad = world.Server.Game.Workspace.WinPad
    local on = pad.Position + Vector3.new(0, pad.Size.Y / 2 + 3, 0)
    local off = pad.Position + Vector3.new(0, 3, 50)
    world:SetPosition(alice, off)    -- her first spawn, away from the pad
    for touch = 1, 2 do
        world:SetPosition(alice, on)
        world:Step(0.1)
        assert(alice.leaderstats.Wins.Value == touch, "one win per touch")
        world:SetPosition(alice, off)
        world:Step(0.1)
    end
    assert(badges:UserHasBadgeAsync(alice.UserId, BADGE), "the first win awarded the badge")
    assert(not badges:UserHasBadgeAsync(bob.UserId, BADGE), "Bob has no badge")
end

A round timer

A round loop with an intermission: the server keeps Phase (a StringValue) and TimeLeft (an IntValue) in ReplicatedStorage and counts TimeLeft down with task.wait(1), 10 s of Intermission, then 30 s of Round, in a task.spawn so the script's top level finishes (Init/Server.shot):

@server enabled

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local phase = Instance.new("StringValue")
phase.Name = "Phase"
phase.Value = "Intermission"
phase.Parent = ReplicatedStorage

local timeLeft = Instance.new("IntValue")
timeLeft.Name = "TimeLeft"
timeLeft.Value = 10
timeLeft.Parent = ReplicatedStorage

local function runPhase(name : string, seconds : number)
    phase.Value = name
    for t = seconds, 1, -1 do
        timeLeft.Value = t
        task.wait(1)
    end
end

task.spawn(function()
    while true do
        runPhase("Intermission", 10)
        runPhase("Round", 30)
    end
end)

Each client builds its own TimerGui on joining and shows both values, refreshed on Changed; ResetOnSpawn false keeps it across respawns (Init/Client.shot):

@client enabled

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local player = Players.LocalPlayer
local playerGui = player:WaitForChild("PlayerGui")

local gui = Instance.new("ScreenGui")
gui.Name = "TimerGui"
gui.ResetOnSpawn = false
gui.Parent = playerGui

local label = Instance.new("TextLabel")
label.Name = "Status"
label.Size = UDim2.new(0, 300, 0, 50)
label.Text = ""
label.Parent = gui

local phase = ReplicatedStorage:WaitForChild("Phase") :: StringValue
local timeLeft = ReplicatedStorage:WaitForChild("TimeLeft") :: IntValue

local function refresh()
    label.Text = phase.Value .. " " .. tostring(timeLeft.Value)
end

phase.Changed:Connect(refresh)
timeLeft.Changed:Connect(refresh)
refresh()

Each tick reaches the clients a frame after the server sets it, so the scenario reads past the tick, never on it. A player who joins mid-round sees the round as soon as their client script runs (Scenarios/Rounds.luau):

-- Rounds: 10 s of intermission, then a 30 s round, on every client's label; a late joiner sees the round.
return function(world)
    world:Boot()
    local _, alice = world:AddPlayer("Alice")
    local _, bob = world:AddPlayer("Bob")
    local function status(client) : string
        return client.LocalPlayer.PlayerGui.TimerGui.Status.Text
    end
    world:Step(5.5)    -- half a second past the 5 s tick, so the clients have the server's value
    assert(status(alice) == "Intermission 5" and status(bob) == "Intermission 5", "both count the intermission")
    world:Step(7)    -- 12.5 s: the round started at 10 s
    assert(status(alice) == "Round 28" and status(bob) == "Round 28", "both count the round")
    world:Step(8)    -- 20.5 s
    local _, carol = world:AddPlayer("Carol")
    world:Step(0.25)
    assert(status(carol) == "Round 20", "a late joiner sees the round")
    world:Step(21)    -- 41.75 s: the next intermission
    assert(world.Server.Game:GetService("ReplicatedStorage").Phase.Value == "Intermission", "the loop goes on")
end

A round game

A lobby, an arena and a server round loop: Waiting for players until two are in, 5 s of Intermission, then a Round for the players who have a character, moved onto the arena. The last one standing wins: Wins in leaderstats goes up and is saved to the Wins DataStore at once, and the survivors go back to the lobby. Status (a StringValue in ReplicatedStorage) names the phase; a client shows it as A ROUND TIMER shows Phase.

Lobby and Arena are world parts, and a game project makes its world parts with Instance.new in a server Init script: an .instance file holds only a class name (Files, INSTANCES), so it can't give a Part its Size and Position (Init/Server.shot):

@server enabled

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local DataStoreService = game:GetService("DataStoreService")

local store = DataStoreService:GetDataStore("Wins")

local function makePart(name : string, position : Vector3) : Part
    local part = Instance.new("Part")
    part.Name = name
    part.Anchored = true
    part.Size = Vector3.new(40, 1, 40)
    part.Position = position
    part.Parent = workspace
    return part
end

local lobby = makePart("Lobby", Vector3.new(0, 0, 0))
local arena = makePart("Arena", Vector3.new(0, 0, 200))

local status = Instance.new("StringValue")
status.Name = "Status"
status.Value = "Waiting for players"
status.Parent = ReplicatedStorage

local function keyOf(player : Player) : string
    return "w_" .. player.UserId
end

Players.PlayerAdded:Connect(function(player : Player)
    local stats = Instance.new("Folder")
    stats.Name = "leaderstats"
    local wins = Instance.new("IntValue")
    wins.Name = "Wins"
    wins.Value = 0
    wins.Parent = stats
    stats.Parent = player
    local ok, saved = pcall(function()
        return store:GetAsync(keyOf(player))
    end)
    if ok and typeof(saved) == "number" then
        wins.Value = saved
    end
end)

-- a character stands on a part, 3 studs along X per slot
local function standOn(player : Player, part : Part, slot : number)
    local character = player.Character
    if character then
        local top = part.Position + Vector3.new(slot * 3, part.Size.Y / 2 + 3, 0)
        character:PivotTo(CFrame.new(top))
    end
end

local function humanoidOf(player : Player) : Humanoid?
    local character = player.Character
    if character then
        return character:FindFirstChildOfClass("Humanoid")
    end
    return nil
end

local function playRound()
    -- the round's players: those who have a character now
    local players : {Player} = {}
    local humanoids : {[Player] : Humanoid} = {}
    for _, player in Players:GetPlayers() do
        local humanoid = humanoidOf(player)
        if humanoid then
            table.insert(players, player)
            humanoids[player] = humanoid
        end
    end
    if #players < 2 then
        return
    end
    status.Value = "Round"
    for slot, player in players do
        standOn(player, arena, slot - 1)
    end
    local function alivePlayers() : {Player}
        local alive = {}
        for _, player in players do
            if player.Parent == Players and humanoids[player].Health > 0 then
                table.insert(alive, player)
            end
        end
        return alive
    end
    -- 60 s at most, or until one is left standing
    local elapsed = 0
    while elapsed < 60 and #alivePlayers() > 1 do
        task.wait(0.25)
        elapsed += 0.25
    end
    local alive = alivePlayers()
    if #alive == 1 then
        local winner = alive[1]
        local stats = winner:FindFirstChild("leaderstats")
        local wins = stats and stats:FindFirstChild("Wins")
        if wins and wins:IsA("IntValue") then
            wins.Value += 1
            local value = wins.Value
            pcall(function()
                store:SetAsync(keyOf(winner), value)
            end)
        end
    end
    for i, player in alive do
        standOn(player, lobby, i - 1)
    end
end

task.spawn(function()
    while true do
        if #Players:GetPlayers() < 2 then
            status.Value = "Waiting for players"
            task.wait(0.5)
        else
            status.Value = "Intermission"
            task.wait(5)
            playRound()
        end
    end
end)

Each player gets a character at join (Scenarios, A JOINED PLAYER), at the origin on the lobby since the game has no SpawnLocation, so the scenario only adds the two players. Bob dies, so Alice wins, her Wins is saved and she is back on the lobby (Scenarios/Win.luau):

-- Win: both players spawn on the lobby at join; Bob dies in the round, so Alice wins, her Wins is saved and she
-- is back on the lobby.
return function(world)
    world:Boot()
    local alice = world:AddPlayer("Alice")
    local bob = world:AddPlayer("Bob")
    local status = world.Server.Game:GetService("ReplicatedStorage").Status
    world:StepUntil(function() return status.Value == "Round" end, 20)
    world:Step(0.5)
    assert(math.abs(alice.Character:GetPivot().Position.Z - 200) < 20, "Alice on the arena")
    assert(math.abs(bob.Character:GetPivot().Position.Z - 200) < 20, "Bob on the arena")
    bob.Character.Humanoid.Health = 0
    world:StepUntil(function() return alice.leaderstats.Wins.Value == 1 end, 10)
    world:Step(1)
    assert(world:ReadDataStore("Wins", "w_" .. alice.UserId) == 1, "the win is saved")
    assert(status.Value ~= "Round", "the round is over")
    assert(math.abs(alice.Character:GetPivot().Position.Z) < 20, "Alice back on the lobby")
end

A shop window

A shop of six items in a list that scrolls: each player starts with 100 Coins in leaderstats, and the server makes a BuyItem RemoteFunction that checks the name it is sent, then takes the price or refuses (Init/Server.shot):

@server enabled

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local prices : {[string] : number} = { Sword = 50, Shield = 30, Potion = 10, Boots = 40, Hat = 20, Cape = 60 }

Players.PlayerAdded:Connect(function(player : Player)
    local stats = Instance.new("Folder")
    stats.Name = "leaderstats"
    local coins = Instance.new("IntValue")
    coins.Name = "Coins"
    coins.Value = 100
    coins.Parent = stats
    stats.Parent = player
end)

local buyItem = Instance.new("RemoteFunction")
buyItem.Name = "BuyItem"
buyItem.Parent = ReplicatedStorage

-- the client can send anything: check the name, then the price against the player's coins
buyItem.OnServerInvoke = function(player : Player, itemName : any)
    local price = if type(itemName) == "string" then prices[itemName] else nil
    local stats = player:FindFirstChild("leaderstats")
    local coins = if stats then stats:FindFirstChild("Coins") else nil
    if price == nil or coins == nil or not coins:IsA("IntValue") then
        return false, "no such item"
    end
    if coins.Value < price then
        return false, "too few coins"
    end
    coins.Value -= price
    return true, coins.Value
end

Each client builds a ShopGui: a coins label and a ScrollingFrame 150 px tall over a 300 px canvas, one 50 px row with a Buy button for each item, so three rows show at a time, and a Status label for the server's answer (Init/Client.shot):

@client enabled

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local player = Players.LocalPlayer
local buyItem = ReplicatedStorage:WaitForChild("BuyItem") :: RemoteFunction

local items : {{Name : string, Price : number}} = {
    { Name = "Sword", Price = 50 },
    { Name = "Shield", Price = 30 },
    { Name = "Potion", Price = 10 },
    { Name = "Boots", Price = 40 },
    { Name = "Hat", Price = 20 },
    { Name = "Cape", Price = 60 },
}

local gui = Instance.new("ScreenGui")
gui.Name = "ShopGui"
gui.ResetOnSpawn = false

local coinsLabel = Instance.new("TextLabel")
coinsLabel.Name = "Coins"
coinsLabel.Size = UDim2.fromOffset(300, 40)
coinsLabel.Position = UDim2.fromOffset(10, 10)
coinsLabel.Parent = gui

-- what the server answered to the last Buy, so a refusal shows and a test can tell it from a missed click
local statusLabel = Instance.new("TextLabel")
statusLabel.Name = "Status"
statusLabel.Size = UDim2.fromOffset(300, 30)
statusLabel.Position = UDim2.fromOffset(10, 220)
statusLabel.Text = ""
statusLabel.Parent = gui

-- a 150 px window over a 300 px canvas: three of the six rows show at a time
local list = Instance.new("ScrollingFrame")
list.Name = "Items"
list.Size = UDim2.fromOffset(300, 150)
list.Position = UDim2.fromOffset(10, 60)
list.CanvasSize = UDim2.fromOffset(0, #items * 50)
list.Parent = gui

for index, item in items do
    local row = Instance.new("Frame")
    row.Name = item.Name
    row.Size = UDim2.fromOffset(280, 45)
    row.Position = UDim2.fromOffset(0, (index - 1) * 50)
    row.Parent = list

    local label = Instance.new("TextLabel")
    label.Size = UDim2.fromOffset(180, 45)
    label.Text = item.Name .. " - " .. tostring(item.Price)
    label.Parent = row

    local buy = Instance.new("TextButton")
    buy.Name = "Buy"
    buy.Size = UDim2.fromOffset(90, 45)
    buy.Position = UDim2.fromOffset(185, 0)
    buy.Text = "Buy"
    buy.Parent = row
    buy.Activated:Connect(function()
        local ok, result = buyItem:InvokeServer(item.Name)
        local reason = if ok then "" else ": " .. tostring(result)
        statusLabel.Text = (if ok then "Bought " else "Can't buy ") .. item.Name .. reason
    end)
end

gui.Parent = player:WaitForChild("PlayerGui")

local coins = player:WaitForChild("leaderstats"):WaitForChild("Coins") :: IntValue
local function refresh()
    coinsLabel.Text = "Coins: " .. tostring(coins.Value)
end
refresh()
coins:GetPropertyChangedSignal("Value"):Connect(refresh)

A ScrollingFrame's children sit on its canvas, offset by -CanvasPosition, and it clips them, so a click lands only on a row scrolled into the window. The scenario scrolls to the Cape with world:ScrollTo, then clicks its Buy button with world:Click. The Status label tells a refused Cape from a click that missed (Scenarios/Shop.luau):

-- Shop: Alice buys a Sword, scrolls down to the Cape and can't afford it, then scrolls back and buys a Potion.
return function(world)
    world:Boot()
    local alice, client = world:AddPlayer("Alice")
    world:Step(0.2)
    local gui = client.LocalPlayer.PlayerGui.ShopGui
    local label = gui.Coins
    local status = gui.Status    -- what the server answered to the last Buy
    local list = gui.Items
    assert(label.Text == "Coins: 100", "start: " .. label.Text)
    local function click(name)
        world:Click(alice, list[name].Buy)
        world:Step(0.2)
    end
    click("Sword")
    assert(label.Text == "Coins: 50", "after Sword: " .. label.Text)
    assert(status.Text == "Bought Sword", "after Sword: " .. status.Text)
    assert(list.CanvasSize.Y.Offset > list.AbsoluteSize.Y, "the list is taller than its window")
    world:ScrollTo(alice, list.Cape)    -- the least scroll that shows its whole row
    world:Step(0.1)
    click("Cape")    -- 60 coins: the server refuses
    assert(label.Text == "Coins: 50", "after Cape: " .. label.Text)
    assert(status.Text == "Can't buy Cape: too few coins", "the server refused the Cape: " .. status.Text)
    world:ScrollTo(alice, list.Potion)
    world:Step(0.1)
    click("Potion")
    assert(label.Text == "Coins: 40", "after Potion: " .. label.Text)
    assert(status.Text == "Bought Potion", "after Potion: " .. status.Text)
end

A watchtower

A lookout and a guide. Every 0.5 s the server casts a ray with workspace:Raycast from (0, 4, 0) to each player's HumanoidRootPart, with RaycastParams that leave out the player's own character: a clear line sets leaderstats Seen to 1 and plays the Alarm Sound, the Wall in between sets it to 0. When a client fires the Guide RemoteEvent, the server computes a path around the wall with PathfindingService, walks the character along its waypoints with Humanoid:MoveTo, then plays a cheer through the Humanoid's Animator (Init/Server.shot; the client script only prints):

@server enabled

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local PathfindingService = game:GetService("PathfindingService")

-- a floor: a character with no part under it falls
local baseplate = Instance.new("Part")
baseplate.Name = "Baseplate"
baseplate.Anchored = true
baseplate.Size = Vector3.new(512, 1, 512)
baseplate.Position = Vector3.new(0, -0.5, 0)
baseplate.Parent = workspace

local wall = Instance.new("Part")
wall.Name = "Wall"
wall.Anchored = true
wall.Size = Vector3.new(2, 8, 30)
wall.Position = Vector3.new(10, 4, 0)
wall.Parent = workspace

local goal = Instance.new("Part")
goal.Name = "Goal"
goal.Anchored = true
goal.Size = Vector3.new(4, 1, 4)
goal.Position = Vector3.new(20, 0.5, 0)
goal.Parent = workspace

local alarm = Instance.new("Sound")
alarm.Name = "Alarm"
alarm.SoundId = "rbxassetid://9118823100"
alarm.Parent = workspace

local guide = Instance.new("RemoteEvent")
guide.Name = "Guide"
guide.Parent = ReplicatedStorage

local LOOKOUT = Vector3.new(0, 4, 0)

local seenValues : {[Player] : IntValue} = {}

Players.PlayerAdded:Connect(function(player : Player)
    local stats = Instance.new("Folder")
    stats.Name = "leaderstats"
    local seen = Instance.new("IntValue")
    seen.Name = "Seen"
    seen.Value = 0
    seen.Parent = stats
    stats.Parent = player
    seenValues[player] = seen
end)

Players.PlayerRemoving:Connect(function(player : Player)
    seenValues[player] = nil
end)

task.spawn(function()
    while true do
        task.wait(0.5)
        for _, player in Players:GetPlayers() do
            local character = player.Character
            local seen = seenValues[player]
            local root = if character then character:FindFirstChild("HumanoidRootPart") else nil
            if character and seen and root and root:IsA("BasePart") then
                local params = RaycastParams.new()
                params.FilterType = Enum.RaycastFilterType.Exclude
                params.FilterDescendantsInstances = { character }
                local hit = workspace:Raycast(LOOKOUT, root.Position - LOOKOUT, params)
                if hit == nil then
                    seen.Value = 1
                    if not alarm.IsPlaying then
                        alarm:Play()
                    end
                else
                    seen.Value = 0
                end
            end
        end
    end
end)

guide.OnServerEvent:Connect(function(player : Player)
    local character = player.Character
    if character == nil then
        return
    end
    local humanoid = character:FindFirstChildOfClass("Humanoid")
    local root = character:FindFirstChild("HumanoidRootPart")
    if humanoid == nil or root == nil or not root:IsA("BasePart") then
        return
    end
    local path = PathfindingService:CreatePath()
    path:ComputeAsync(root.Position, goal.Position)
    if path.Status ~= Enum.PathStatus.Success then
        return
    end
    local waypoints = path:GetWaypoints()
    character:SetAttribute("PathWaypoints", #waypoints)
    for index, waypoint in waypoints do
        if index > 1 then
            humanoid:MoveTo(waypoint.Position)
            humanoid.MoveToFinished:Wait()
        end
    end
    character:SetAttribute("Arrived", true)

    local found = humanoid:FindFirstChildOfClass("Animator")
    local animator : Animator = found or Instance.new("Animator")
    animator.Parent = humanoid
    local animation = Instance.new("Animation")
    animation.AnimationId = "rbxassetid://507770239"
    local track = animator:LoadAnimation(animation)
    track:Play()
    if track.IsPlaying then
        character:SetAttribute("Cheered", true)
    end
end)

A player in plain sight is seen and sets off the alarm (Scenarios/Seen.luau). Hidden is the same scenario with Alice at (20, 3, 0), behind the wall, where Seen stays 0:

-- Seen: Alice stands in the lookout's line of sight, so Seen is 1 and Alarm plays.
return function(world)
    world:Boot()
    local alice = world:AddPlayer("Alice")
    world:SetPosition(alice, Vector3.new(5, 3, 0))
    world:Step(1.5)
    local seen = alice.leaderstats.Seen
    local alarm = world.Server.Game.Workspace.Alarm
    world.Logf("Seen=%s alarm playing=%s", world.Fmt(seen.Value), world.Fmt(alarm.IsPlaying))
    assert(seen.Value == 1, "Seen should be 1")
    assert(alarm.IsPlaying, "Alarm should be playing")
    assert(world:ErrorCount() == 0, "no game errors")
end

The guide walks Alice around the wall to the Goal; the path has more than 2 waypoints, since a straight line would go through it. world:StepUntil runs frames until she has arrived and cheered, for at most 30 s (Scenarios/Guide.luau):

-- Guide: Alice fires Guide and is walked around the wall to the Goal, then cheers.
return function(world)
    world:Boot()
    local alice, client = world:AddPlayer("Alice")
    world:SetPosition(alice, Vector3.new(5, 3, 0))
    world:Step(0.5)
    local guide = client.Game:GetService("ReplicatedStorage"):WaitForChild("Guide")
    guide:FireServer()
    local character = alice.Character
    local _, t = world:StepUntil(function()
        return character:GetAttribute("Arrived") == true and character:GetAttribute("Cheered") == true
    end, 30)
    local pos = character.HumanoidRootPart.Position
    local goal = world.Server.Game.Workspace.Goal.Position
    local count = character:GetAttribute("PathWaypoints")
    world.Logf("t=%s pos=%s waypoints=%s", world.Fmt(t), world.Fmt(pos), world.Fmt(count))
    assert(character:GetAttribute("Arrived") == true, "Arrived")
    assert(character:GetAttribute("Cheered") == true, "Cheered")
    assert(math.abs(pos.X - goal.X) <= 3 and math.abs(pos.Z - goal.Z) <= 3, "within 3 studs of Goal")
    assert(count ~= nil and count > 2, "more than 2 waypoints")
    assert(world:ErrorCount() == 0, "no game errors")
end

A motto board

A motto each player types, saves and loads back. The server makes a SaveMotto RemoteEvent and a LoadMotto RemoteFunction in code. Save keeps the first 20 characters in the Mottos DataStore under "u_" .. UserId, sets the player's Motto attribute and adds 1 to Saves; Load returns the saved motto, or "none". A player who joins gets their Motto from the DataStore (Init/Server.shot):

@server enabled

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local DataStoreService = game:GetService("DataStoreService")

local store = DataStoreService:GetDataStore("Mottos")

local saveRemote = Instance.new("RemoteEvent")
saveRemote.Name = "SaveMotto"
saveRemote.Parent = ReplicatedStorage

local loadRemote = Instance.new("RemoteFunction")
loadRemote.Name = "LoadMotto"
loadRemote.Parent = ReplicatedStorage

local function keyFor(player : Player) : string
    return "u_" .. tostring(player.UserId)
end

local function readMotto(player : Player) : string?
    local ok, value = pcall(function()
        return store:GetAsync(keyFor(player))
    end)
    if ok and type(value) == "string" then
        return value
    end
    return nil
end

local function onJoin(player : Player)
    local leaderstats = Instance.new("Folder")
    leaderstats.Name = "leaderstats"
    local saves = Instance.new("IntValue")
    saves.Name = "Saves"
    saves.Value = 0
    saves.Parent = leaderstats
    leaderstats.Parent = player

    local motto = readMotto(player)
    player:SetAttribute("Motto", motto or "")
end

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

saveRemote.OnServerEvent:Connect(function(player : Player, text : any)
    if type(text) ~= "string" then
        return
    end
    local motto = string.sub(text, 1, 20)
    local ok = pcall(function()
        store:SetAsync(keyFor(player), motto)
    end)
    if not ok then
        return
    end
    player:SetAttribute("Motto", motto)
    local leaderstats = player:FindFirstChild("leaderstats")
    local saves = leaderstats and leaderstats:FindFirstChild("Saves")
    if saves and saves:IsA("IntValue") then
        saves.Value += 1
    end
end)

loadRemote.OnServerInvoke = function(player : Player) : string
    return readMotto(player) or "none"
end

The client waits for both remotes, builds the Board (a TextBox, Save and Load buttons and a label) and wires the buttons. Each button has its own Position and Size: a click goes to what is under the mouse (Init/Client.shot):

@client enabled

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local saveRemote = ReplicatedStorage:WaitForChild("SaveMotto") :: RemoteEvent
local loadRemote = ReplicatedStorage:WaitForChild("LoadMotto") :: RemoteFunction

local gui = Instance.new("ScreenGui")
gui.Name = "Board"

local box = Instance.new("TextBox")
box.Name = "MottoBox"
box.PlaceholderText = "Your motto"
box.Text = ""
box.Position = UDim2.fromOffset(10, 10)
box.Size = UDim2.fromOffset(300, 40)
box.Parent = gui

local saveButton = Instance.new("TextButton")
saveButton.Name = "Save"
saveButton.Text = "Save"
saveButton.Position = UDim2.fromOffset(10, 60)
saveButton.Size = UDim2.fromOffset(100, 40)
saveButton.Parent = gui

local loadButton = Instance.new("TextButton")
loadButton.Name = "Load"
loadButton.Text = "Load"
loadButton.Position = UDim2.fromOffset(120, 60)
loadButton.Size = UDim2.fromOffset(100, 40)
loadButton.Parent = gui

local shown = Instance.new("TextLabel")
shown.Name = "Shown"
shown.Text = ""
shown.Position = UDim2.fromOffset(10, 110)
shown.Size = UDim2.fromOffset(300, 40)
shown.Parent = gui

saveButton.Activated:Connect(function()
    saveRemote:FireServer(box.Text)
end)

loadButton.Activated:Connect(function()
    local result = loadRemote:InvokeServer()
    shown.Text = "Motto: " .. tostring(result)
end)

gui.Parent = Players.LocalPlayer:WaitForChild("PlayerGui")

Bob loads before saving and sees none, saves Brave, leaves, and joins again with the same UserId: the DataStore keeps his motto, and his new Board starts empty (Scenarios/Rejoin.luau). world:Type types into the TextBox and world:Click clicks a button, each with his own client's input:

-- Rejoin: Bob sees none, saves Brave, leaves, rejoins and keeps his motto.
return function(world)
    world:Boot()
    local bob, client = world:AddPlayer("Bob")
    world:Step(0.1)
    local board = client.LocalPlayer.PlayerGui.Board
    world:Click(bob, board.Load)
    world:Step(0.5)
    assert(board.Shown.Text == "Motto: none", "first load should say none, got " .. board.Shown.Text)
    world:Type(bob, board.MottoBox, "Brave")
    world:Click(bob, board.Save)
    world:Step(0.5)
    local id = bob.UserId
    world:RemovePlayer(bob)
    world:Step(1)
    local bob2, client2 = world:AddPlayer("Bob", { UserId = id })
    world:Step(1)
    world.Logf("Motto=%s", world.Fmt(bob2:GetAttribute("Motto")))
    assert(bob2:GetAttribute("Motto") == "Brave", "Motto attribute should be Brave")
    assert(client2.LocalPlayer.PlayerGui.Board.Shown.Text == "", "new Shown should be empty")
end

A scenario reads the DataStore itself with world:ReadDataStore, as Trim does to check the 20-character cut:

local stored = world:ReadDataStore("Mottos", "u_" .. alice.UserId)
assert(stored == "abcdefghijklmnopqrst", "store should hold the first 20 characters")