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")
endA 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")
endHealth 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()
endThe 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: 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")
endA 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")
endA 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")
endA 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")
endA 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")
endA 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
endEach 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)
endA 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")
endThe 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")
endA 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"
endThe 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")
endA 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")