Luau is derived from Lua 5.1 — Roblox forked Lua 5.1 and evolved it independently. If you've used Lua in PICO-8, Love2D, or WoW, you'll recognize most of the syntax but many key differences exist.
Type annotations are Luau's headline feature. Lua is dynamically typed; Luau adds : number, : string, and full gradual type inference. The compiler catches type mismatches before runtime — write local x: number = "hello" and the Script Analysis panel highlights the error.
No globals by default — Lua lets you write x = 5 to create a global. In Luau, every variable must be declared with local. Writing x = 5 in a Script yields a warning unless you explicitly mark it _G.x = 5.
String interpolation — Lua concatenates with ..; Luau adds backtick template strings: `Hello {name}, you are {age}`. This is faster to write and more readable.
Continue keyword — Lua has no continue; you emulate it with nested if. Luau supports continue in all loops natively.
bit32 library — Built-in bitwise operations (bit32.band, bit32.bor, bit32.lshift, etc.) for flags, permissions, and compression.
Roblox-specific types — Instance, CFrame, Vector3, Color3, UDim2, Ray, BrickColor, Enum, and Axes are first-class types with rich operator overloads.
-- Lua 5.1 (would work) x = 10 -- global, allowed in Lua table.getn(t) -- deprecated in Luau -- Luau local x: number = 10 -- typed local (Luau only) local name = "Alice" local msg = `Hello {name}, x is {x}` -- interpolation for i = 1, 10 do if i == 5 then continue end -- continue keyword print(i) end local flags = bit32.bor(1, 4, 8) -- bitwise operations
Open Roblox Studio, create a Script in ServerScriptService, and write code that uses string interpolation, continue in a loop, and a bit32.band check. Print each result to the Output window.
Luau has eight primitive types: number (IEEE 754 double-precision float64), string, boolean, table, function, nil, userdata (used by Roblox objects internally), and thread. There's no integer type — Luau uses float64 for everything, which means 100 and 100.0 are identical.
Type annotations follow a colon after the variable name. If you initialize a variable, type inference often makes the annotation optional. Annotations are checked by the Script Analysis engine and will produce warnings on mismatches.
The typeof() function returns a string representation of the type, including Roblox-specific types like "Instance" or "CFrame". The standard Lua type() function cannot distinguish Roblox types — it returns "userdata" for all of them. Always prefer typeof() in Roblox.
local health: number = 100 local name: string = "Hero" local isAlive: boolean = true local inventory: table = {} local onDamage: ((damage: number) -> ())? -- optional function local part = Instance.new("Part") print(typeof(part)) -- "Instance" print(type(part)) -- "userdata" (less useful) -- type() vs typeof() comparison local cf = CFrame.new(0, 5, 0) print(type(cf)) -- "userdata" (unhelpful) print(typeof(cf)) -- "CFrame" (exact) -- any type opts out of type checking local flexible: any = "could be anything" flexible = 42 -- no warning
Declare variables of every Luau primitive type with annotations. Use typeof() to print each variable's type. Create a Vector3 and Color3 and confirm typeof() returns the correct type names.
Tables are Luau's sole data-structuring mechanism. They function as arrays, dictionaries, objects, sets, modules, and more — all depending on how you index them. Understanding tables deeply is essential to writing good Roblox code.
Arrays in Luau are 1-indexed, not 0-indexed. This is a common source of bugs for programmers coming from C, Python, or JavaScript. The first element is at index 1, not 0. Array-like tables use consecutive integer keys starting at 1.
The length operator # returns the last consecutive integer index starting from 1. If a table has holes (nil gaps), # gives undefined behavior — it may return any value. Never rely on # for tables with intentional nil gaps.
Dictionaries use arbitrary keys (strings, instances, or any value). Mixing array-style and dictionary-style keys in one table is common and works fine, but # only counts the contiguous integer-keyed portion.
-- Array (1-indexed!) local fruits: {string} = {"Apple", "Banana", "Cherry"} print(fruits[1]) -- "Apple" print(#fruits) -- 3 -- Dictionary local scores: {[string]: number} = { Alice = 95, Bob = 82, Charlie = 78, } print(scores.Alice) -- 95 (syntactic sugar for scores["Alice"]) -- Mixed table local player = { "Alice", -- [1] health = 100, level = 5, } print(player[1]) -- "Alice" print(player.health) -- 100 print(#player) -- 1 (only counts integer keys) -- table.insert, table.remove, table.sort local items = {"sword", "shield", "potion"} table.insert(items, "ring") -- appends table.insert(items, 2, "helmet") -- inserts at position 2 table.remove(items, 3) -- removes index 3 table.sort(items) -- alphabetical sort local idx = table.find(items, "sword") -- returns 1 or nil -- Metatables intro local t = {} local mt = { __index = function(self, key) return `default_{key}` end, } setmetatable(t, mt) print(t.unknownKey) -- "default_unknownKey"
Create an array table of 10 numbers, sort them descending, then insert a number at position 3. Create a dictionary mapping player names to levels and use table.find to look up a value. Then create a metatable that returns 0 for any missing numeric key.
Luau's control flow is familiar but has subtle behaviors every experienced dev should know.
Truthiness — Only false and nil are falsy. Everything else is truthy: 0, empty string "", empty table {}, and 0.0 are all true. This differs from Python, JavaScript, and C.
Numeric for loops evaluate the start, end, and step expressions once, before the loop begins. Step is optional (defaults to 1). The loop variable is local to the loop body.
Generic for loops — ipairs() iterates over consecutive integer keys from 1 to the first nil gap. pairs() iterates over all keys in arbitrary order. Neither guarantees order for dictionary keys.
-- if / elseif / else local hp = 25 if hp <= 0 then print("Dead") elseif hp < 30 then print("Critical") elseif hp < 70 then print("Injured") else print("Healthy") end -- while local fuel = 100 while fuel > 0 do fuel -= 10 task.wait(0.5) end print("Out of fuel!") -- repeat / until (always executes at least once) local guesses = 0 local target = 7 local guess repeat guess = math.random(1, 10) guesses += 1 until guess == target print(`Found {target} in {guesses} tries`) -- numeric for with step for i = 10, 1, -2 do print(i) -- 10, 8, 6, 4, 2 end -- generic for: ipairs (arrays) vs pairs (dictionaries) local items = {"a", "b", "c"} for i, v in ipairs(items) do print(i, v) -- 1 "a", 2 "b", 3 "c" end local stats = {str = 10, dex = 14, int = 18} for k, v in pairs(stats) do print(k, v) -- order is NOT guaranteed end -- truthiness trap: 0 is truthy if 0 then print("0 is truthy in Luau") -- this runs! end if "" then print("empty string is also truthy") -- this runs! end
Write a loop that prints "Fizz" for multiples of 3, "Buzz" for multiples of 5, "FizzBuzz" for both, and the number otherwise, from 1 to 100. Use the continue keyword in your solution. Write a second version using repeat/until that finds the first FizzBuzz.
Functions are first-class values in Luau: assign them to variables, pass them as arguments, return them from other functions. This is the backbone of callbacks, event handlers, and higher-order programming in Roblox.
Arrow syntax provides a concise way to write anonymous functions. Arrow functions inherit self from the enclosing scope rather than binding their own, which makes them ideal for callbacks.
Lexical scoping — inner functions capture variables from the outer scope. Each invocation creates a new closure with its own captured state. This is critical for BindToClose, TweenService callbacks, and event connections that need to retain per-instance data.
Variadic args use the ... token. Pack them with {...} into a table or access individual args. Luau also supports default arguments by checking for nil — a pattern so common it has a shorthand.
Tail call optimization — if a function's last statement is a call to another function, Luau reuses the current stack frame. This allows recursive functions to run indefinitely without stack overflow, but only if the return is exactly return func(...) with no extra operations.
-- First-class functions local function apply(a: number, b: number, fn: (number, number) -> number): number return fn(a, b) end print(apply(5, 3, function(x, y) return x * y end)) -- 15 -- Arrow syntax local add = @(a: number, b: number): number => a + b local greet = @(name: string) => print(`Hi {name}`) print(add(10, 20)) -- 30 -- Lexical scoping / closures function makeCounter(start: number): () -> number local count = start return function() count += 1 return count end end local counter = makeCounter(0) print(counter()) -- 1 print(counter()) -- 2 print(counter()) -- 3 -- Variadic arguments function sum(...: number): number local total = 0 for _, v in ipairs({...}) do total += v end return total end print(sum(1, 2, 3, 4, 5)) -- 15 -- Default arguments function createPlayer(name: string, level: number?) level = level or 1 return {name = name, level = level} end -- Tail call optimization (no stack growth) function factorial(n: number, acc: number?): number acc = acc or 1 if n <= 1 then return acc end return factorial(n - 1, n * acc) -- tail call end print(factorial(100)) -- no stack overflow -- NOT a tail call (extra operation after return) -- return factorial(n - 1) + 0 -- would grow stack
Write a makeTimer function that returns two closures: start() and elapsed(). start() records the current time, elapsed() returns seconds since start. Test by calling start, waiting 1 second, and printing elapsed. Also write a variadic max function that returns the largest number passed to it.
Luau has no class keyword. Object-oriented programming is built on metatables — ordinary tables that intercept operations on other tables. The __index metamethod is the most important: it defines what happens when you access a key that doesn't exist on the table.
The class pattern uses a metatable as the "class" and individual tables as "instances." The metatable's __index field points to itself (the class), so instance method lookups fall through to the class table. The colon syntax obj:method() is equivalent to obj.method(obj) — it passes the object as the first argument self.
Inheritance uses a metatable chain: the subclass metatable's __index points to the parent class. Method lookups walk the chain until they find the key or reach a table without a metatable.
local Weapon = {} Weapon.__index = Weapon -- Constructor function Weapon.new(name: string, damage: number, cooldown: number): table local self = setmetatable({}, Weapon) self.Name = name self.Damage = damage self.Cooldown = cooldown self._lastUsed = 0 return self end -- Methods (colon syntax = implicit self) function Weapon:CanUse(): boolean return (os.clock() - self._lastUsed) >= self.Cooldown end function Weapon:Use(target) if not self:CanUse() then print(`{self.Name} is on cooldown!`) return end self._lastUsed = os.clock() target.Health -= self.Damage print(`Struck {target.Name} for {self.Damage} damage`) end function Weapon:GetInfo(): string return `{self.Name} | DMG: {self.Damage} | CD: {self.Cooldown}s` end -- Inheritance local RangedWeapon = setmetatable({}, {__index = Weapon}) RangedWeapon.__index = RangedWeapon function RangedWeapon.new(name, damage, cooldown, range, ammo) local self = setmetatable(Weapon.new(name, damage, cooldown), RangedWeapon) self.Range = range self.Ammo = ammo self._maxAmmo = ammo return self end function RangedWeapon:Reload() self.Ammo = self._maxAmmo print(`{self.Name} reloaded`) end function RangedWeapon:Use(target) if self.Ammo <= 0 then print("Out of ammo!") return end self.Ammo -= 1 return Weapon.Use(self, target) -- call parent method end -- Usage local sword = Weapon.new("Iron Sword", 25, 1.0) local bow = RangedWeapon.new("Longbow", 18, 2.5, 50, 10) print(sword:GetInfo()) -- "Iron Sword | DMG: 25 | CD: 1.0s" print(bow:GetInfo()) -- "Longbow | DMG: 18 | CD: 2.5s" print(bow.Range) -- 50 bow:Reload() -- "Longbow reloaded"
Create a PlayerClass base class with Name, Health, MaxHealth, and methods TakeDamage(amount) and Heal(amount). Then create Mage and Warrior subclasses. Mage has mana and a CastSpell method. Warrior has rage and a Shout method that buffs damage.
Roblox runs on a single main thread with a frame-based execution model. Blocking the thread freezes the game for all players. Coroutines and the task library allow you to write asynchronous code without blocking.
Coroutines (coroutine.create/resume/yield) are Lua's native cooperative multitasking. coroutine.yield() suspends execution; coroutine.resume() continues it. However, yielding a coroutine does not guarantee the thread returns to Roblox's scheduler — it may starve rendering and network updates if misused.
The task library (task.spawn, task.wait, task.delay, task.cancel) was introduced to replace spawn() and wait(). Unlike raw coroutines, task.* functions integrate with Roblox's engine scheduler, yielding across frame boundaries properly and respecting engine heartbeats.
task.wait(n) yields for at least n seconds (default 0 = one frame). It is the preferred sleep function. The legacy wait() is deprecated and has inconsistent timing. Always use task.wait() in new code.
-- Coroutine basics local co = coroutine.create(function() for i = 1, 5 do print(`Coroutine step {i}`) coroutine.yield() end return "done" end) while coroutine.status(co) ~= "dead" do local ok, result = coroutine.resume(co) print(ok, result) end -- task.spawn (preferred over coroutine.resume) task.spawn(function() for i = 1, 3 do print(`Spawned task iteration {i}`) task.wait(1) end end) -- task.wait (preferred over wait()) print("Before delay") task.wait(0.5) -- waits at least half a second print("After 0.5s") -- task.delay (fire callback once after n seconds) local connection = task.delay(5, function() print("5 seconds elapsed!") end) -- task.cancel (cancel a delayed task) task.cancel(connection) -- Practical: repeating timer pattern local running = true task.spawn(function() while running do print("Heartbeat tick") task.wait(1) end end) -- Differences summary -- coroutine.resume(co) -- raw Lua, no scheduler integration -- task.spawn(fn) -- uses scheduler, better for Roblox -- wait(1) -- deprecated, minimum 1 frame + rounding -- task.wait(1) -- precise, engine-aware yielding
Write a script that spawns 5 concurrent tasks, each printing a countdown from 5 to 1 with a 1-second delay between numbers. Use task.spawn and task.wait. Then write a version using raw coroutines and explain why the task version is better for Roblox.
Luau's type system is gradual — you can add types incrementally. Untyped variables default to any, which disables checking. The more types you annotate, the more errors the Script Analysis panel catches before runtime.
Type aliases let you define reusable types with export type. Use them to keep function signatures readable and consistent across modules.
Union types (string | number) allow a value to be one of several types. Optional types use ? (equivalent to T | nil). Luau infers optional types from nil defaults.
Table shapes define the expected structure of a table: {name: string, level: number}. Luau checks access against the shape.
-- Type aliases export type Vector2 = {x: number, y: number} export type PlayerData = { userId: number, displayName: string, level: number, inventory: {[string]: number}, } -- Union types local function parseId(input: string | number): number if typeof(input) == "string" then return tonumber(input) or 0 end return input end -- Optional types local function findPlayer(name: string): Player? -- returns Player or nil for _, p in ipairs(Players:GetPlayers()) do if p.Name == name then return p end end return nil end -- Array sugar local names: {string} = {"Alice", "Bob"} -- array of strings local matrix: {{number}} = {{1, 2}, {3, 4}} -- 2D array -- Function type signatures type DamageHandler = (target: Instance, amount: number) -> (boolean, string?) local applyDamage: DamageHandler = function(target, amount) if not target:FindFirstChild("Humanoid") then return false, "No humanoid" end target.Humanoid.Health -= amount return true, nil end -- Table shapes type Config = { name: string, maxPlayers: number, respawnTime: number, spawnPoints: {Vector3}, enabled: boolean?, } local gameConfig: Config = { name = "Arena", maxPlayers = 16, respawnTime = 3.0, spawnPoints = {Vector3.new(0, 5, 0), Vector3.new(10, 5, 10)}, }
Define a WeaponConfig type alias with fields for name (string), damage (number), type ("melee" | "ranged" union), effects (optional array of strings), and onHit (optional callback). Then create a valid table of that type and pass it to a function typed to accept it.
Even experienced developers hit these Luau-specific traps regularly. Knowing them saves hours of debugging.
1-indexing — Arrays start at 1, not 0. A loop like for i = 0, #tbl-1 skips element 1 and accesses tbl[0] which is nil. Wrap your head around this early.
nil removes table keys — Assigning tbl[key] = nil removes the key entirely; it does not store nil. If you iterate with pairs(), that key won't appear. To preserve a key with a "null" value, use a sentinel like "__null__" instead.
No native class system — There's no class keyword, no extends, no super, no instanceof. OOP is done entirely via metatables (see Section 6). Libraries like Class (Roblox built-in) provide syntactic sugar but are not part of the language.
No destructors — Luau has no __gc metamethod on tables (only on full userdata, which Roblox controls). You cannot automatically clean up when a table goes out of scope. Always use explicit Destroy or Disconnect for Roblox instances and connections.
== compares by reference — Tables, instances, and functions are compared by identity, not value. {1, 2} == {1, 2} returns false. Use table.equals or manual field comparison for deep equality.
Strings are immutable — Every concatenation with .. creates a new string. In a loop with thousands of iterations, this creates massive garbage. Prefer table.concat for building large strings.
-- Pitfall 1: 1-indexing local arr = {"a", "b", "c"} -- WRONG (Python/JS habit): -- for i = 0, #arr-1 do print(arr[i]) end -- prints nil, "a", "b" -- CORRECT: for i = 1, #arr do print(arr[i]) end -- "a", "b", "c" -- Pitfall 2: nil removes keys local t = {a = 1, b = 2, c = 3} t.b = nil for k, v in pairs(t) do print(k, v) end -- only "a" and "c" appear -- Pitfall 3: reference equality local a = {x = 1, y = 2} local b = {x = 1, y = 2} print(a == b) -- false (different tables) local c = a print(a == c) -- true (same table) -- Pitfall 4: string concatenation in loops local parts = {} for i = 1, 1000 do parts[i] = tostring(i) end local bigString = table.concat(parts, ",") -- fast, no garbage -- Bad equivalent (creates 999 intermediate strings): -- local s = "" -- for i = 1, 1000 do s = s .. tostring(i) .. "," end -- Pitfall 5: forgetting colon vs dot local obj = { value = 5, getValue = function(self) return self.value end, } print(obj.getValue()) -- nil (self is the first arg, not passed) print(obj:getValue()) -- 5 (colon passes obj as self automatically)
Write a function that exposes at least 3 of the above pitfalls. For example: function demonstratePitfalls() that creates a table, assigns a key to nil to show removal, compares two structurally identical tables with == to show false, and builds a string with .. vs table.concat inside a loop. Print the results.
Roblox exposes game engine capabilities through services, accessible via game:GetService("ServiceName"). Modern Roblox also supports game:GetService(ServiceName) with the service type directly. Services should not be created with Instance.new — they are singletons.
Workspace — The 3D world. Contains all parts, models, terrain, and physics objects. The workspace global is shorthand for game:GetService("Workspace"). Use workspace:WaitForChild("PartName") to wait for objects to load.
Players — Manages player connections. Events: PlayerAdded, PlayerRemoving, Player.CharacterAdded. The Players global is shorthand.
ReplicatedStorage — Shared container visible to server and all clients. Store ModuleScripts, remote events/functions, and shared assets here. Do not put things here that should only exist server-side (use ServerScriptService) or client-side (use StarterGui).
RunService — Frame-rate dependent events. Heartbeat (every frame, on all clients), Stepped (before physics), RenderStepped (client-only, before rendering). Essential for smooth animations and physics-based gameplay.
DataStoreService — Persistent key-value storage for player data, game state, and leaderboards. Data persists across sessions and server restarts. Must be enabled in Studio settings.
-- Service retrieval patterns local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local ServerScriptService = game:GetService("ServerScriptService") local RunService = game:GetService("RunService") local TweenService = game:GetService("TweenService") local MarketPlaceService = game:GetService("MarketplaceService") local DataStoreService = game:GetService("DataStoreService") local StarterGui = game:GetService("StarterGui") -- Players event pattern Players.PlayerAdded:Connect(function(player) print(`{player.Name} joined the game`) -- Leaderstats setup local leaderstats = Instance.new("Folder") leaderstats.Name = "leaderstats" local gold = Instance.new("IntValue") gold.Name = "Gold" gold.Value = 0 gold.Parent = leaderstats leaderstats.Parent = player -- Character spawned player.CharacterAdded:Connect(function(character) print(`{player.Name}'s character spawned`) end) end) Players.PlayerRemoving:Connect(function(player) print(`Saving data for {player.Name}...`) end) -- TweenService example local part = Instance.new("Part") part.Size = Vector3.new(4, 1, 4) part.Position = Vector3.new(0, 10, 0) part.Parent = workspace local tweenInfo = TweenInfo.new( 2, -- duration (seconds) Enum.EasingStyle.Bounce, -- easing style Enum.EasingDirection.Out, -- direction 0, -- repeat count (0 = none, -1 = infinite) false -- reverses ) local goal = {Position = Vector3.new(0, 20, 0)} local tween = TweenService:Create(part, tweenInfo, goal) tween:Play() -- RunService: Heartbeat loop RunService.Heartbeat:Connect(function(deltaTime) -- deltaTime = time since last frame in seconds -- Use for frame-rate independent movement end)
In a Script, get the Players and Workspace services. On PlayerAdded, create a part (named after the player) in the workspace. Use TweenService to animate the part moving up and down. When the player leaves, destroy the part. Use task.spawn to keep the tween repeating.
ModuleScript is Roblox's mechanism for code modularity. A ModuleScript returns a single value (table, function, or any value) that is cached by require() — subsequent require() calls return the same object, not a copy. This makes modules ideal for shared state, configuration, and utility libraries.
require() caching — The first require() executes the module and caches its return value. All subsequent require() calls from anywhere in the game return the cached value. To reset state, you must explicitly clear the cache (or avoid storing mutable state at the module level).
Modules return either a table of functions/constants (config module) or a constructor function (class/factory module). The idiomatic pattern for classes stores the class table in _G or uses require() in every script.
-- config.lua (ModuleScript) local Config = { GameName = "My Arena", MaxPlayers = 16, RespawnTime = 3.0, DefaultGravity = 196.2, StartingGold = 100, Weapons = { {Name = "Sword", Damage = 25, Cooldown = 1.0}, {Name = "Bow", Damage = 18, Cooldown = 2.5, Range = 50}, {Name = "Staff", Damage = 22, Cooldown = 1.5, ManaCost = 10}, }, } function Config:GetWeapon(name: string): table? for _, weapon in ipairs(self.Weapons) do if weapon.Name == name then return weapon end end return nil end return Config -- utility.lua (ModuleScript) local Utility = {} function Utility.Clamp(value: number, min: number, max: number): number return math.max(min, math.min(max, value)) end function Utility.RandomString(length: number): string local chars = "abcdefghijklmnopqrstuvwxyz0123456789" local result = {} for i = 1, length do result[i] = string.sub(chars, math.random(1, #chars), math.random(1, #chars)) end return table.concat(result) end function Utility.ShuffleTable(t: {any}): {any} for i = #t, 2, -1 do local j = math.random(i) t[i], t[j] = t[j], t[i] end return t end function Utility.FormatNumber(n: number): string local formatted = tostring(math.floor(n)) while true do local k formatted, k = string.gsub(formatted, "^(-?%d+)(%d%d%d)", "%1,%2") if k == 0 then break end end return formatted end return Utility -- Using modules (in a Script or LocalScript) local Config = require(ReplicatedStorage.Config) local Utility = require(ReplicatedStorage.Utility) print(Config.GameName) -- "My Arena" local sword = Config:GetWeapon("Sword") print(sword.Damage) -- 25 local shuffled = Utility.ShuffleTable({1, 2, 3, 4, 5}) print(Utility.FormatNumber(1234567)) -- "1,234,567"
Create a ModuleScript called ColorManager that exports a table with functions: GetRandomColor(), HexToColor3(hex: string), LerpColor(a: Color3, b: Color3, t: number), and a Palette table with 5 predefined Color3 values. Create a second module PartBuilder that uses require to call ColorManager functions. Test from a Script.
Scripts in Roblox run at specific times depending on their type and parent container. Understanding execution order is critical for setup dependencies and avoiding race conditions.
Script vs LocalScript — Script runs on the server (Roblox cloud). LocalScript runs on each client's machine. LocalScripts only work when parented to a Player's PlayerGui, PlayerScripts, Backpack, or the StarterGui/StarterPack service trees. A LocalScript in ServerScriptService will not run.
Alphabetical execution — Scripts within the same container run in alphabetical order by their name. If Script A relies on a ModuleScript that B creates, name your files carefully (e.g., 00_Init, 01_Systems, 02_GameLoop).
game.Loaded fires when the game environment finishes loading. game.OnClose fires when the server is shutting down — use it to save player data, disconnect from DataStores, and clean up instances. game:BindToClose(fn) registers a callback for graceful shutdown.
task.wait vs wait — wait() schedules the next resume at a minimum of n seconds but rounds up to the nearest frame boundary and has a minimum of ~1/30s. task.wait() is precise and integrates with the engine scheduler. Always prefer task.wait().
-- Execution order example (Script in ServerScriptService) print("1. Script runs immediately when loaded") -- game.Loaded fires once when environment is ready game.Loaded:Wait() print("2. Game loaded") Players.PlayerAdded:Connect(function(player) print("3. Player joined (may happen after Loaded, before, or during)") end) game:BindToClose(function() print("4. Server shutting down — save all data here") -- Save player data, close DataStores, etc. local timeout = os.clock() + 30 for _, player in ipairs(Players:GetPlayers()) do -- SavePlayerData(player) end while os.clock() < timeout do task.wait(0.1) end end) -- LocalScript lifecycle (in StarterGui or PlayerScripts) local player = Players.LocalPlayer print(`LocalScript running for {player.Name}`) player.CharacterAdded:Connect(function(character) print("Character spawned on client") task.wait(0.1) -- let character fully load local humanoid = character:WaitForChild("Humanoid") humanoid.Died:Connect(function() print("Character died on client") end) end) -- Alphabetical execution example -- ServerScriptService/ -- 00_Init.lua (runs first — load modules, config) -- 01_Services.lua (runs second — set up services) -- 02_GameLoop.lua (runs third — start game logic) -- Z_Cleanup.lua (runs last — cleanup scripts) -- task.wait vs wait() -- wait(0.1) -- minimum ~0.033s, imprecise, deprecated -- task.wait(0.1) -- precise 0.1s, engine-aware
Create two Scripts in ServerScriptService: "A_First.lua" and "B_Second.lua". A_First prints "A" and creates a ModuleScript. B_Second prints "B" and requires the ModuleScript. Verify the execution order. Then add game.OnClose handling in A_First that logs the shutdown time.
Roblox Studio provides several tools for debugging Luau code. The Output window (View → Output) displays all print/warn/error output. The Script Analysis panel (View → Script Analysis) shows live type errors, unused variables, and other diagnostics.
print(...) — Outputs to the Output window. Accepts multiple arguments, separated by spaces. Use print("debug:", var) liberally during development.
warn(...) — Like print but outputs in yellow with a warning icon. Indicates potential issues, not errors.
error(message, level) — Throws a runtime error with a red message in Output. level controls stack trace depth (1 = this function, 2 = caller, etc.). Uncaught errors halt execution.
assert(condition, message) — Throws an error if condition is falsy. Use for invariant checking: assert(health > 0, "Health must be positive").
pcall / xpcall — Protected calls to catch errors without crashing. pcall(fn, ...) returns success, .... xpcall(fn, errHandler) passes the error to a handler. Essential for DataStore operations and any network or fallible code.
Breakpoints — Click the left margin in the script editor to set a red breakpoint dot. When execution hits that line, the script pauses. Hover over variables to inspect their values. Step through with F10 (step over), F11 (step into), Shift+F11 (step out).
-- print / warn / error local health = 50 print("Player health:", health) -- "Player health: 50" if health < 20 then warn("Player critically low health:", health) end if health < 0 then error("Health cannot be negative") -- halts execution end -- assert local function setHealth(amount: number) assert(typeof(amount) == "number", "amount must be a number") assert(amount >= 0, "amount must be non-negative") health = amount end -- pcall (protected call) local function riskyOperation(id: string): (boolean, any) local success, result = pcall(function() -- DataStore:GetAsync(id) could fail if id == "" then error("Empty ID") end return `Data for {id}` end) if not success then warn("Risky operation failed:", result) return false, nil end return true, result end print(riskyOperation("player_123")) -- true, "Data for player_123" print(riskyOperation("")) -- false, nil -- xpcall with custom error handler local function logError(err) warn("Error caught:", err) warn(debug.traceback()) end local ok, result = xpcall(function() error("Something went wrong") end, logError) -- Debugging with type checking local function inspect(value: any): string local t = typeof(value) if t == "table" then return `table({#value} entries)` elseif t == "Instance" then return `Instance({value.ClassName}: {value.Name})` end return tostring(value) end print(inspect(game)) -- "Instance(DataModel: Game)"
Write a function that divides two numbers but uses pcall to handle division by zero gracefully. Then write a version that uses xpcall with a custom error handler that prints the full stack trace. Use assert to verify argument types before any arithmetic. Set breakpoints and step through with F10/F11.
String manipulation is a daily task in Roblox development — formatting chat messages, parsing network responses, constructing display text, and validating user input. Luau inherits Lua's powerful string library with its own interpolation syntax on top.
String interpolation with backticks is the most readable way to build strings with variable substitutions: `Player {name} scored {score} points`. Internally, Roblox rewrites this as efficient concatenation.
string.sub(s, i, j) extracts a substring from position i to j (inclusive). Negative indices count from the end. string.sub("hello", -3) returns "llo".
string.find(s, pattern, init, plain) — Finds the first occurrence of a pattern (or plain string with plain = true). Returns start and end indices, or nil.
string.gmatch iterates over all matches. string.gsub replaces all occurrences and returns the modified string plus a count.
string.split(s, separator) — Splits a string into a table of substrings. Much easier than using gmatch for simple splits.
-- String interpolation local name = "Alice" local level = 42 local msg = `Welcome {name}! You are level {level}.` print(msg) -- "Welcome Alice! You are level 42." -- Expressions inside interpolation local scores = {10, 25, 8} print(`Total: {scores[1] + scores[2] + scores[3]}`) -- "Total: 43" -- string.sub local text = "Roblox Development" print(string.sub(text, 1, 6)) -- "Roblox" print(string.sub(text, -11)) -- "Development" print(string.sub(text, 8, -1)) -- "Development" -- string.find (plain = true for literal search) local url = "https://www.roblox.com/games/12345" local startPos, endPos = string.find(url, "roblox", 1, true) print(startPos, endPos) -- 12 17 -- string.gmatch (iterate over pattern matches) local csv = "apple,banana,cherry,date" for fruit in string.gmatch(csv, "([^,]+)") do print(fruit) -- "apple", "banana", "cherry", "date" end -- string.gsub (replace) local phrase = "The quick brown fox" local replaced, count = string.gsub(phrase, "o", "0") print(replaced, count) -- "The quick br0wn f0x" 2 -- string.split (easiest splitting) local data = "level:42:health:100:mana:50" local parts = string.split(data, ":") -- parts = {"level", "42", "health", "100", "mana", "50"} -- string.format (C-style formatting) print(string.format("%s has %d HP (%.1f%%)", "Hero", 75, 75.0)) -- "Hero has 75 HP (75.0%)" -- Case conversion print(string.lower("HELLO")) -- "hello" print(string.upper("world")) -- "WORLD" -- Practical: sanitize player input local function sanitizeName(input: string): string -- Strip non-alphanumeric characters except space and hyphen local clean, _ = string.gsub(input, "[^%w%s%-]", "") return string.sub(clean, 1, 20) -- limit to 20 chars end print(sanitizeName("Player@#1!")) -- "Player1" -- Building large strings efficiently local lines = {} for i = 1, 100 do lines[i] = `Line number {i}` end local hugeString = table.concat(lines, "\n")
Write a function parseCommand(input: string) that takes a string like "/give sword 5" and returns a table {command = "give", args = {"sword", "5"}}. Use string.split and validate with string.find. Then write a function formatScoreboard(players: {string: number}) that returns a formatted string table with aligned columns.
Luau's math library provides standard mathematical functions. Combined with Roblox's Vector3, CFrame, and Random classes, you have everything needed for game physics, procedural generation, and stat calculations.
math.random — Returns a pseudo-random number. With no args, returns [0, 1). With one arg n, returns [1, n] integer. With two args, returns [m, n] integer. math.randomseed seeds the generator — call once at startup, ideally with os.time() or tick(). Roblox also provides Random.new() which creates independent random number streams (preferred for multiplayer to avoid desync).
math.clamp(x, min, max) — Clamps a value to a range. Equivalent to math.max(min, math.min(max, x)). Note: math.clamp was added relatively recently; older code may define its own.
Vector3 math — Vector3 supports +, -, * (scalar), / (scalar), dot, cross, Magnitude, Unit, Lerp. These are heavily optimized and run in C++ — prefer them over manual per-component math.
-- Basic random numbers math.randomseed(tick()) print(math.random()) -- float between 0 and 1 print(math.random(10)) -- integer 1 to 10 print(math.random(5, 15)) -- integer 5 to 15 -- Random.new (independent streams, preferred) local rng = Random.new() print(rng:NextNumber()) -- float 0 to 1 print(rng:NextInteger(1, 6)) -- integer 1 to 6 (dice roll) -- math.clamp local health = 150 health = math.clamp(health, 0, 100) print(health) -- 100 -- math.floor / math.ceil print(math.floor(3.7)) -- 3 print(math.ceil(3.1)) -- 4 print(math.floor(-2.3)) -- -3 (rounds toward -inf) -- min / max print(math.min(10, 5, 8)) -- 5 print(math.max(10, 5, 8)) -- 10 -- Vector3 operations local a = Vector3.new(3, 0, 0) local b = Vector3.new(0, 4, 0) print(a + b) -- (3, 4, 0) print(a - b) -- (3, -4, 0) print(a * 10) -- (30, 0, 0) print(a / 2) -- (1.5, 0, 0) print(a:Dot(b)) -- 0.0 (perpendicular) print(a:Cross(b)) -- (0, 0, 12) print(a.Magnitude) -- 3.0 print(a.Unit) -- (1, 0, 0) -- Distance between two points local pos1 = Vector3.new(0, 0, 0) local pos2 = Vector3.new(3, 4, 0) local distance = (pos2 - pos1).Magnitude print(distance) -- 5.0 (3-4-5 triangle) -- Vector3.Lerp (smooth interpolation) local start = Vector3.new(0, 0, 0) local goal = Vector3.new(10, 0, 0) print(start:Lerp(goal, 0.5)) -- (5, 0, 0) — halfway -- Practical: random point in sphere local function randomPointInSphere(radius: number, rng: Random): Vector3 -- Rejection sampling while true do local x = rng:NextNumber(-radius, radius) local y = rng:NextNumber(-radius, radius) local z = rng:NextNumber(-radius, radius) if Vector3.new(x, y, z).Magnitude <= radius then return Vector3.new(x, y, z) end end end -- Practical: weighted random choice local function chooseWeighted(choices: {string: number}): string local total = 0 for _, weight in pairs(choices) do total += weight end local roll = math.random() * total local cumulative = 0 for name, weight in pairs(choices) do cumulative += weight if roll <= cumulative then return name end end return next(choices) -- fallback end print(chooseWeighted({common = 60, rare = 30, legendary = 10}))
Write a function spawnInCircle(center: Vector3, radius: number, count: number) that spawns count parts evenly spaced around a circle centered at center. Use math.cos / math.sin to calculate positions. Then write a function rollLootTable(tier: string) that returns a random item from weighted loot tables per tier.
Consistent code style reduces bugs, improves readability, and makes collaboration easier. Roblox does not enforce a style guide, but the community has converged on conventions that work well at scale.
Naming conventions — camelCase for variables and functions (playerName, getHealth). PascalCase for classes/modules (Weapon, PlayerManager). UPPER_CASE for constants (MAX_PLAYERS). _prefix for private members (_internalState). Events use past tense (PlayerJoined, GameStarted).
Module structure — Each ModuleScript should have a single responsibility. Return a table with clearly named functions. Group related functions. Export types at the top. Use local functions inside the module for private helpers — they don't pollute the return table.
Error handling — Always wrap DataStore calls in pcall. Use assert for invariant checks in development. Don't pcall everything — let unexpected errors surface during development so you fix them. Use xpcall with a logging handler in production.
Performance — Avoid repeated # on large tables (cache the length in a local). Use local references for frequently accessed globals: local Players = game:GetService("Players") at the top of the script. Use table.concat instead of .. in loops. Prefer ipairs over pairs for array-like tables (faster iteration).
-- Good module structure local PlayerDataManager = {} -- Private helpers (not exported) local function validateUserId(id: number): boolean return id > 0 and id < 999999999 end -- Public API function PlayerDataManager.LoadPlayer(userId: number): table? assert(validateUserId(userId), "Invalid userId") local success, data = pcall(function() -- DataStoreService logic here return {gold = 100, level = 1} end) if not success then warn("Failed to load data for", userId) return nil end return data end function PlayerDataManager.SavePlayer(userId: number, data: table): boolean local success, err = pcall(function() -- DataStoreService logic here end) return success end return PlayerDataManager -- Performance patterns -- BAD: repeated # access inside loop for i = 1, #items do for j = 1, #items do -- #items evaluated every iteration! -- ... end end -- GOOD: cache length in local local count = #items for i = 1, count do for j = 1, count do -- ... end end -- BAD: repeated service lookup -- game:GetService("Players").PlayerAdded:Connect(...) -- game:GetService("Players").PlayerRemoving:Connect(...) -- GOOD: local reference local Players = game:GetService("Players") Players.PlayerAdded:Connect(...) Players.PlayerRemoving:Connect(...) -- Avoid string concat in hot loops -- BAD: local s = "" for i = 1, 1000 do s = s .. i end -- GOOD: local parts = {} for i = 1, 1000 do parts[i] = tostring(i) end local s = table.concat(parts) -- Clean event handling: always disconnect local connection: RBXScriptConnection? function startListening() connection = RunService.Heartbeat:Connect(function(dt) -- update logic end) end function stopListening() if connection then connection:Disconnect() connection = nil end end
Refactor a poorly written script into a well-structured ModuleScript. Take the following mess: a single Script that declares globals, uses wait(), has no locals, and repeats service lookups. Rewrite it as two ModuleScripts (one for config, one for logic) and a clean main Script that requires them. Add error handling with pcall and assert.