Use this file to discover all available pages before exploring further.
Luau is Roblox’s fork of Lua 5.1 — but it is not standard Lua 5.1. Luau ships with a gradual type system, generics, the continue keyword, compound assignment operators (+=, -=, *=, /=), string interpolation with backtick syntax, floor division (//), and a suite of new built-in functions (math.clamp, table.freeze, table.clone, string.split, and more). Code that targets Luau can safely use all of these extensions; code that targets stock Lua 5.1 cannot. This reference covers everything you need to write idiomatic, type-safe, production-quality Luau for Roblox games.
-- if / elseif / elselocal health = 50if health <= 0 then print("Dead")elseif health < 30 then print("Critical")else print("Alive")end-- Numeric for (start, end, step)for i = 1, 10 do print(i)end-- Generalized iteration (Luau extension — preferred over ipairs/pairs)local items = { "sword", "shield", "potion" }for index, item in items do print(index, item)end-- Dictionary iterationlocal stats = { health = 100, mana = 50, stamina = 75 }for key, value in stats do print(key, value)end-- repeat-until loop (condition checked AFTER body — always runs at least once)local attempts = 0repeat attempts += 1 local success = attempts >= 3until success-- continue (Luau extension — skips to next iteration)for i = 1, 10 do if i % 2 == 0 then continue end print(i) -- prints odd numbers onlyend
Luau uses gradual typing: types are optional and can be added incrementally. The type checker runs at analysis time and does not affect runtime behavior — you can annotate as much or as little as you like.
-- Variable annotationslocal name: string = "Alice"local health: number = 100local isAlive: boolean = truelocal data: any = nil -- opt out of type checking for this variable-- Function parameter and return typeslocal function add(a: number, b: number): number return a + bend-- Optional parameters (T | nil shorthand)local function greet(name: string, title: string?): string if title then return `{title} {name}` end return nameend
-- Union typelocal id: string | number = "abc123"id = 42 -- also valid-- Optional is shorthand for T | nillocal nickname: string? = nil-- typeof() is Roblox-aware and preferred over type()local function process(value: string | number) if typeof(value) == "string" then print(string.upper(value)) -- narrowed to string else print(value * 2) -- narrowed to number endend-- :IsA() for instance type narrowinglocal function handlePart(instance: Instance) if instance:IsA("BasePart") then instance.Anchored = true instance.BrickColor = BrickColor.new("Bright red") endend
-- A common Roblox pattern: modules that act as singletons/services-- File: ServerScriptService/Services/CombatService.lualocal ReplicatedStorage = game:GetService("ReplicatedStorage")local Players = game:GetService("Players")local CombatService = {}local activeBuffs: { [Player]: { string } } = {}function CombatService.init() Players.PlayerRemoving:Connect(function(player: Player) activeBuffs[player] = nil -- cleanup on leave end)endfunction CombatService.calculateDamage(attacker: Player, baseDamage: number): number local multiplier = 1.0 local buffs = activeBuffs[attacker] if buffs then for _, buff in buffs do if buff == "strength" then multiplier += 0.5 end end end return math.floor(baseDamage * multiplier)endfunction CombatService.addBuff(player: Player, buffName: string) if not activeBuffs[player] then activeBuffs[player] = {} end table.insert(activeBuffs[player], buffName)endfunction CombatService.removeBuff(player: Player, buffName: string) local buffs = activeBuffs[player] if not buffs then return end local index = table.find(buffs, buffName) if index then table.remove(buffs, index) endendreturn CombatService
-- pcall wraps a call and catches errorslocal success, result = pcall(function() return game:GetService("DataStoreService"):GetDataStore("PlayerData")end)if success then print("Got data store:", result)else warn("Failed to get data store:", result)end-- pcall with arguments (passed after the function)local success, data = pcall(dataStore.GetAsync, dataStore, "player_123")-- xpcall provides a custom error handler with stack tracelocal success, result = xpcall(function() error("Something went wrong")end, function(err) warn("Error:", err) warn("Stack:", debug.traceback()) return errend)-- Retry patternlocal function retryAsync<T>(maxAttempts: number, delayBetween: number, fn: () -> T): T? for attempt = 1, maxAttempts do local success, result = pcall(fn) if success then return result end if attempt < maxAttempts then warn(`Attempt {attempt} failed: {result}. Retrying in {delayBetween}s...`) task.wait(delayBetween) else warn(`All {maxAttempts} attempts failed. Last error: {result}`) end end return nilend-- Usage: retry DataStore callslocal data = retryAsync(3, 1, function() return dataStore:GetAsync("player_123")end)
The task library is the modern replacement for deprecated globals wait(), spawn(), and delay().
Never use wait(), spawn(), or delay(). They are deprecated, have throttling issues, and swallow errors. Use task.* equivalents exclusively.
-- task.wait: yields the current thread for a durationlocal elapsed = task.wait(2)print(`Actually waited {elapsed} seconds`)-- task.spawn: runs a function immediately in a new threadtask.spawn(function() print("This runs immediately in a new coroutine") task.wait(5) print("This runs 5 seconds later")end)print("This also runs immediately, after the spawned function yields")-- task.delay: runs a function after a delaytask.delay(3, function() print("This runs after 3 seconds")end)-- task.defer: runs at end of the current resumption cycletask.defer(function() print("This runs after the current thread and any task.spawn calls finish")end)-- task.cancel: cancels a thread created by task.spawn or task.delaylocal thread = task.delay(10, function() print("This will never run")end)task.cancel(thread)
-- Coroutines allow cooperative multitaskinglocal function producer(): () for i = 1, 5 do coroutine.yield(i) endendlocal co = coroutine.create(producer)for i = 1, 5 do local success, value = coroutine.resume(co) print(value) --> 1, 2, 3, 4, 5end-- coroutine.wrap creates a function that resumes automaticallylocal nextValue = coroutine.wrap(producer)print(nextValue()) --> 1print(nextValue()) --> 2-- Practical example: staggered parallel initializationlocal function initSystems(systems: { { name: string, init: () -> () } }) for _, system in systems do task.spawn(function() local success, err = pcall(system.init) if not success then warn(`Failed to initialize {system.name}: {err}`) else print(`{system.name} initialized`) end end) endend
The community-standard roblox-lua-promise library provides Promise-based async control flow.
local Promise = require(ReplicatedStorage.Packages.Promise)-- Creating a Promiselocal function loadPlayerData(player: Player) return Promise.new(function(resolve, reject, onCancel) local key = `player_{player.UserId}` local cancelled = false onCancel(function() cancelled = true end) local success, data = pcall(dataStore.GetAsync, dataStore, key) if cancelled then return end if success then resolve(data or {}) else reject(`Failed to load data: {data}`) end end)end-- ChainingloadPlayerData(player) :andThen(function(data) return processData(data) end) :andThen(function(processed) applyData(player, processed) end) :catch(function(err) warn("Error:", err) end) :finally(function() print("Load attempt complete") end)-- Promise.all: wait for multiple promisesPromise.all({ loadPlayerData(player), loadInventory(player), loadSettings(player),}):andThen(function(results) local data, inventory, settings = results[1], results[2], results[3]end):catch(function(err) warn("One or more loads failed:", err)end)-- Promise.retryPromise.retry(function() return loadPlayerData(player)end, 3):andThen(function(data) print("Loaded after retry")end)
-- GetService is the canonical way to access Roblox serviceslocal Players = game:GetService("Players")local ReplicatedStorage = game:GetService("ReplicatedStorage")local ServerStorage = game:GetService("ServerStorage")local RunService = game:GetService("RunService")local UserInputService = game:GetService("UserInputService")local TweenService = game:GetService("TweenService")local HttpService = game:GetService("HttpService")local CollectionService = game:GetService("CollectionService")local DataStoreService = game:GetService("DataStoreService")-- Declare at the top of each script; store in locals for performance
-- Connect returns an RBXScriptConnection — store it to disconnect laterlocal connection: RBXScriptConnectionconnection = Players.PlayerAdded:Connect(function(player: Player) print(`{player.Name} joined the game`)end)connection:Disconnect() -- prevent memory leaks-- One-shot with :Once()Players.PlayerAdded:Once(function(player: Player) print(`First player to join: {player.Name}`) -- Auto-disconnects after firing onceend)-- RunService hookslocal RunService = game:GetService("RunService")RunService.Heartbeat:Connect(function(deltaTime: number) -- fires every frame after physics; use for most game logicend)RunService.Stepped:Connect(function(elapsedTime: number, deltaTime: number) -- fires every frame before physics; use for pre-physics / input logicend)-- Property change signalpart:GetPropertyChangedSignal("Position"):Connect(function() print(`Part moved to {part.Position}`)end)
-- SERVER SIDElocal damageEvent = Instance.new("RemoteEvent")damageEvent.Name = "DamageEvent"damageEvent.Parent = ReplicatedStoragedamageEvent.OnServerEvent:Connect(function(player: Player, targetName: string, amount: number) -- ALWAYS validate client input on the server if typeof(targetName) ~= "string" then return end if typeof(amount) ~= "number" or amount < 0 or amount > 100 then return end local target = Players:FindFirstChild(targetName) if not target then return end applyDamage(target, amount)end)-- Fire to a specific clientdamageEvent:FireClient(somePlayer, "You took damage!", 25)-- Fire to all clientsdamageEvent:FireAllClients("Boss spawned!", bossPosition)-- CLIENT SIDElocal damageEvent = ReplicatedStorage:WaitForChild("DamageEvent")damageEvent.OnClientEvent:Connect(function(message: string, data: any) print(message)end)-- Send request to serverdamageEvent:FireServer("EnemyA", 50)
-- FindFirstChild: returns first direct child with name (or nil)local head = character:FindFirstChild("Head")-- FindFirstChild with recursive flaglocal sword = workspace:FindFirstChild("Sword", true)-- FindFirstChildWhichIsA: by class hierarchylocal basePart = model:FindFirstChildWhichIsA("BasePart")-- WaitForChild: yields until child exists (always pass a timeout)local tool = player.Backpack:WaitForChild("Sword", 5) -- 5 second timeout-- GetDescendants: all descendants recursivelocal allParts: { BasePart } = {}for _, descendant in workspace:GetDescendants() do if descendant:IsA("BasePart") then table.insert(allParts, descendant) endend-- CollectionService: tag-based filteringlocal CollectionService = game:GetService("CollectionService")local enemies = CollectionService:GetTagged("Enemy")CollectionService:GetInstanceAddedSignal("Enemy"):Connect(function(instance) setupEnemy(instance)end)
Luau uses Lua patterns, not regular expressions. The escape character is %, not \. There is no alternation (|), no lookahead, and the lazy quantifier is - instead of *?.
-- Character classes: %a letters, %d digits, %l lowercase, %u uppercase,-- %w alphanumeric, %s whitespace, %p punctuation, . any char-- string.match: extract captureslocal year, month, day = string.match("2026-03-04", "(%d+)-(%d+)-(%d+)")print(year, month, day) --> "2026" "03" "04"-- string.gmatch: iterate over all matcheslocal text = "score=100, level=42, health=75"for key, value in string.gmatch(text, "(%w+)=(%d+)") do print(key, value)end-- string.gsub: replace matcheslocal cleaned = string.gsub("Hello World", "%s+", " ")print(cleaned) --> "Hello World"-- Anchors: ^ start, $ endlocal isEmail = string.match("user@example.com", "^%w+@%w+%.%w+$") ~= nil
-- BAD: wastes CPU cycles, imprecise timingwhile true do local target = findNearestEnemy() if target then attack(target) end task.wait(0.1)end-- GOOD: event or Heartbeatlocal RunService = game:GetService("RunService")RunService.Heartbeat:Connect(function(dt: number) local target = findNearestEnemy() if target then attack(target) endend)
-- BAD: creates a new string every iteration — O(n²) memorylocal result = ""for i = 1, 1000 do result = result .. tostring(i) .. ","end-- GOOD: collect into table, join once — O(n)local parts = {}for i = 1, 1000 do table.insert(parts, tostring(i))endlocal result = table.concat(parts, ",")
-- BAD: client controls damage valueremoteEvent.OnServerEvent:Connect(function(player, damage, targetName) local target = Players:FindFirstChild(targetName) target.Character.Humanoid:TakeDamage(damage) -- exploiter sends 9999!end)-- GOOD: server calculates damage; client sends intent onlyremoteEvent.OnServerEvent:Connect(function(player: Player, targetName: unknown) if typeof(targetName) ~= "string" then return end local target = Players:FindFirstChild(targetName) if not target or not target.Character then return end local targetHumanoid = target.Character:FindFirstChildOfClass("Humanoid") if not targetHumanoid then return end local weapon = getEquippedWeapon(player) if not weapon then return end local attackerPos = player.Character and player.Character:GetPivot().Position local targetPos = target.Character:GetPivot().Position if not attackerPos or (attackerPos - targetPos).Magnitude > weapon.range then return end if not canAttack(player) then return end targetHumanoid:TakeDamage(weapon.damage)end)
Luau arrays are 1-indexed. The first element is array[1], not array[0]. Accessing array[0] returns nil without an error, which is a silent off-by-one bug.
local items = { "first", "second", "third" }print(items[1]) --> "first"print(items[0]) --> nil (silent bug!)-- Correct loopfor i = 1, #items do print(items[i])end
The # Operator and Nil Gaps
The # length operator is only reliable for contiguous arrays with no nil gaps. Setting an element to nil creates a gap and makes # return an unpredictable result.
local a = { 1, 2, 3, 4, 5 }print(#a) --> 5 (reliable)local b = { 1, 2, nil, 4, 5 }print(#b) --> could be 2 or 5 — undefined behavior!-- Use table.remove to maintain a contiguous arraytable.remove(b, 3) -- shifts elements down, keeps table contiguous
Table Reference Semantics
Tables are assigned and passed by reference, not by value. Assigning a table to a new variable gives you an alias, not a copy.
local original = { 1, 2, 3 }local alias = originalalias[1] = 99print(original[1]) --> 99 (same table!)-- Use table.clone for a shallow copylocal copy = table.clone(original)copy[1] = 0print(original[1]) --> 99 (unaffected)-- Nested tables are still shared in a shallow clone — use deepCopy for nested structures
Metatables: Missing __index
Forgetting to set ClassName.__index = ClassName means method lookups fail silently — the method is nil and calling it errors.
local MyClass = {}-- Missing: MyClass.__index = MyClass <-- THIS IS THE BUGfunction MyClass.new() return setmetatable({}, MyClass)endfunction MyClass.doSomething(self) endlocal obj = MyClass.new()obj:doSomething() --> ERROR: attempt to call a nil value
While Loop Closure Capture
In for loops, Luau creates a new variable per iteration so closures capture correctly. In while loops the variable is shared — all closures see the final value.
-- for loop: each iteration gets its own 'i'local fns = {}for i = 1, 5 do fns[i] = function() return i endendprint(fns[1]()) --> 1 (correct)-- while loop: all closures share the same 'j'local fns2 = {}local j = 1while j <= 5 do fns2[j] = function() return j end -- captures the variable, not the value j += 1endprint(fns2[1]()) --> 6 (wrong! j is now 6)-- Fix: capture with a locallocal fns3 = {}local k = 1while k <= 5 do local captured = k fns3[k] = function() return captured end k += 1endprint(fns3[1]()) --> 1 (correct)
0, Empty String, and Empty Table are Truthy
Unlike many languages, Luau only considers nil and false falsy. Zero, empty strings, and empty tables all evaluate as truthy.
if 0 then print("0 is truthy") end --> printsif "" then print("empty string is truthy") end --> printsif {} then print("empty table is truthy") end --> prints-- Be explicit when checking for empty/zero:if value ~= nil and value ~= "" then endif value ~= nil and value ~= 0 then end