Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/brockmartin/roblox-game-skill/llms.txt

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.

Core Concepts

Variables

All variables must be declared local. Global variables pollute the shared environment and cause hard-to-trace bugs across scripts.
-- Always use local
local playerName = "Alice"
local score = 0
local isAlive = true

-- Constants use UPPER_CASE by convention
local MAX_HEALTH = 100
local RESPAWN_TIME = 5

-- Multiple assignment
local x, y, z = 1, 2, 3

-- Compound assignment (Luau extension — not available in Lua 5.1)
score += 10
score -= 5
score *= 2
score /= 2

Functions

-- Standard declaration with type annotations
local function calculateDamage(baseDamage: number, multiplier: number): number
    return baseDamage * multiplier
end

-- Anonymous function / closure
local onHit = function(target: Part)
    target:Destroy()
end

-- Variadic functions
local function logMessage(prefix: string, ...: any)
    local args = { ... }
    local message = prefix .. ": " .. table.concat(args, ", ")
    print(message)
end

-- Functions are first-class values
local function applyToAll(items: { Instance }, action: (Instance) -> ())
    for _, item in items do
        action(item)
    end
end

-- Multiple return values
local function getPosition(): (number, number, number)
    return 10, 20, 30
end

local x, y, z = getPosition()

Tables

Tables are the only compound data structure in Luau. They serve as arrays, dictionaries, objects, and namespaces.
-- Array (sequential integer keys, 1-based)
local fruits = { "apple", "banana", "cherry" }
print(fruits[1]) --> "apple"
print(#fruits)   --> 3

-- Dictionary (string keys)
local player = {
    name = "Alice",
    health = 100,
    inventory = {},
}
print(player.name)       --> "Alice"
print(player["health"])  --> 100

-- Nested tables
local config = {
    graphics = {
        quality = "high",
        shadows = true,
    },
    audio = {
        volume = 0.8,
        music = true,
    },
}
print(config.graphics.quality) --> "high"

-- Table as a namespace / module
local MathUtils = {}

function MathUtils.lerp(a: number, b: number, t: number): number
    return a + (b - a) * t
end

function MathUtils.clampedLerp(a: number, b: number, t: number): number
    return MathUtils.lerp(a, b, math.clamp(t, 0, 1))
end

Control Flow

-- if / elseif / else
local health = 50
if 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 iteration
local 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 = 0
repeat
    attempts += 1
    local success = attempts >= 3
until 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 only
end

String Manipulation

-- String interpolation (Luau extension — backtick strings)
local name = "Alice"
local level = 42
local message = `{name} reached level {level}!`
print(message) --> "Alice reached level 42!"

-- Expressions in interpolation
local price = 19.99
local tax = 0.08
print(`Total: ${price * (1 + tax)}`) --> "Total: $21.5892"

-- Common string functions
print(string.len("hello"))                      --> 5
print(string.upper("hello"))                    --> "HELLO"
print(string.sub("hello", 2, 4))               --> "ell"
print(string.format("%.2f", 3.14159))          --> "3.14"
print(string.find("hello world", "world"))     --> 7 11

-- string.split (Luau extension)
local parts = string.split("a,b,c", ",")
-- parts = {"a", "b", "c"}

-- String patterns (NOT regex — see patterns section below)
local matched = string.match("score: 42", "%d+")
print(matched) --> "42"

Math Operations

-- Arithmetic
local sum      = 10 + 5       --> 15
local diff     = 10 - 5       --> 5
local product  = 10 * 5       --> 50
local quotient = 10 / 3       --> 3.3333...
local intDiv   = 10 // 3      --> 3 (floor division, Luau extension)
local remainder = 10 % 3      --> 1
local power    = 2 ^ 10       --> 1024

-- Math library
print(math.abs(-5))              --> 5
print(math.ceil(3.2))            --> 4
print(math.floor(3.8))           --> 3
print(math.max(1, 5, 3))         --> 5
print(math.min(1, 5, 3))         --> 1
print(math.sqrt(16))             --> 4
print(math.pi)                   --> 3.14159...
print(math.huge)                 --> inf
print(math.clamp(15, 0, 10))     --> 10 (Luau extension)
print(math.sign(-7))             --> -1 (Luau extension)
print(math.round(3.5))           --> 4  (Luau extension)

-- Random numbers
print(math.random())             --> [0, 1) float
print(math.random(10))           --> [1, 10] integer
print(math.random(5, 15))        --> [5, 15] integer

-- For better randomness, use Random.new()
local rng = Random.new()
print(rng:NextNumber())          --> [0, 1) float
print(rng:NextInteger(1, 100))   --> [1, 100] integer

-- Trigonometry (radians)
print(math.sin(math.pi / 2))    --> 1
print(math.cos(0))               --> 1
print(math.rad(180))             --> pi
print(math.deg(math.pi))        --> 180

Type System

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.

Basic Annotations

-- Variable annotations
local name: string = "Alice"
local health: number = 100
local isAlive: boolean = true
local data: any = nil -- opt out of type checking for this variable

-- Function parameter and return types
local function add(a: number, b: number): number
    return a + b
end

-- Optional parameters (T | nil shorthand)
local function greet(name: string, title: string?): string
    if title then
        return `{title} {name}`
    end
    return name
end

Table Types

-- Array type
local scores: { number } = { 100, 95, 87 }

-- Dictionary type
local config: { [string]: boolean } = {
    shadows = true,
    particles = false,
}

-- Typed record
type PlayerData = {
    name: string,
    level: number,
    inventory: { string },
    stats: {
        health: number,
        mana: number,
    },
}

local player: PlayerData = {
    name = "Alice",
    level = 10,
    inventory = { "sword", "shield" },
    stats = {
        health = 100,
        mana = 50,
    },
}

Union Types and Type Narrowing

-- Union type
local id: string | number = "abc123"
id = 42 -- also valid

-- Optional is shorthand for T | nil
local 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
    end
end

-- :IsA() for instance type narrowing
local function handlePart(instance: Instance)
    if instance:IsA("BasePart") then
        instance.Anchored = true
        instance.BrickColor = BrickColor.new("Bright red")
    end
end

Generics

-- Generic function
local function first<T>(list: { T }): T?
    return list[1]
end

local name = first({ "Alice", "Bob" }) -- inferred as string?
local num  = first({ 1, 2, 3 })        -- inferred as number?

-- Generic type alias
type Result<T> = {
    success: boolean,
    value: T?,
    error: string?,
}

-- Generic class-like pattern
type Stack<T> = {
    items: { T },
    push: (self: Stack<T>, value: T) -> (),
    pop: (self: Stack<T>) -> T?,
    peek: (self: Stack<T>) -> T?,
}

Type Exports

-- In a ModuleScript, export types for other modules to consume
-- File: ReplicatedStorage/Types.lua

export type WeaponData = {
    name: string,
    damage: number,
    rarity: "Common" | "Rare" | "Epic" | "Legendary",
    durability: number,
}

export type InventorySlot = {
    item: WeaponData?,
    quantity: number,
}

-- Consumers import with require
-- File: ServerScriptService/WeaponService.lua
local Types = require(game.ReplicatedStorage.Types)

local function createWeapon(name: string, damage: number): Types.WeaponData
    return {
        name = name,
        damage = damage,
        rarity = "Common",
        durability = 100,
    }
end

Common Roblox Types

-- Instance hierarchy types
local part: Part      = Instance.new("Part")
local model: Model    = Instance.new("Model")
local player: Player  = game.Players.LocalPlayer
local character: Model = player.Character or player.CharacterAdded:Wait()
local humanoid: Humanoid = character:FindFirstChildWhichIsA("Humanoid") :: Humanoid

-- Value types (structs — NOT Instances)
local position: Vector3 = Vector3.new(10, 5, 0)
local rotation: CFrame  = CFrame.new(0, 10, 0) * CFrame.Angles(0, math.rad(90), 0)
local color: Color3     = Color3.fromRGB(255, 0, 0)
local size: Vector2     = Vector2.new(100, 50)
local region: Region3   = Region3.new(Vector3.new(-10, 0, -10), Vector3.new(10, 20, 10))
local ray: Ray          = Ray.new(Vector3.new(0, 10, 0), Vector3.new(0, -1, 0))
local udim2: UDim2      = UDim2.new(0.5, 0, 0.5, 0)

-- Enum types
local material: Enum.Material = Enum.Material.Grass
local partType: Enum.PartType = Enum.PartType.Ball

OOP Patterns

Metatable-Based Classes

The standard Roblox OOP pattern uses metatables with __index to implement method lookup.
local Weapon = {}
Weapon.__index = Weapon

export type Weapon = typeof(setmetatable(
    {} :: {
        name: string,
        damage: number,
        durability: number,
        maxDurability: number,
    },
    Weapon
))

function Weapon.new(name: string, damage: number, durability: number): Weapon
    local self = setmetatable({}, Weapon)
    self.name = name
    self.damage = damage
    self.durability = durability
    self.maxDurability = durability
    return self
end

function Weapon.attack(self: Weapon, target: Humanoid): boolean
    if self.durability <= 0 then
        warn(`{self.name} is broken!`)
        return false
    end
    target:TakeDamage(self.damage)
    self.durability -= 1
    return true
end

function Weapon.repair(self: Weapon)
    self.durability = self.maxDurability
end

function Weapon.toString(self: Weapon): string
    return `{self.name} (DMG: {self.damage}, DUR: {self.durability}/{self.maxDurability})`
end

-- Usage
local sword = Weapon.new("Iron Sword", 25, 100)
sword:attack(targetHumanoid)
print(sword:toString())

Inheritance via Metatable Chaining

-- Base class
local Entity = {}
Entity.__index = Entity

function Entity.new(name: string, health: number, position: Vector3)
    local self = setmetatable({}, Entity)
    self.name = name
    self.health = health
    self.maxHealth = health
    self.position = position
    return self
end

function Entity.takeDamage(self, amount: number)
    self.health = math.max(0, self.health - amount)
end

function Entity.isAlive(self): boolean
    return self.health > 0
end

-- Derived class
local Enemy = {}
Enemy.__index = Enemy
setmetatable(Enemy, { __index = Entity }) -- inherit from Entity

function Enemy.new(name: string, health: number, position: Vector3, attackDamage: number)
    local self = setmetatable({}, Enemy) :: any
    self.name = name
    self.health = health
    self.maxHealth = health
    self.position = position
    self.attackDamage = attackDamage
    self.aggroRange = 50
    return self
end

function Enemy.attackTarget(self, target)
    local distance = (target.position - self.position).Magnitude
    if distance <= self.aggroRange then
        target:takeDamage(self.attackDamage)
    end
end

-- Usage
local goblin = Enemy.new("Goblin", 50, Vector3.new(0, 0, 0), 10)
goblin:takeDamage(20)       -- inherited from Entity
goblin:attackTarget(player) -- defined on Enemy
print(goblin:isAlive())     -- inherited from Entity

Module-Based Service Pattern

-- A common Roblox pattern: modules that act as singletons/services
-- File: ServerScriptService/Services/CombatService.lua

local 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)
end

function 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)
end

function CombatService.addBuff(player: Player, buffName: string)
    if not activeBuffs[player] then
        activeBuffs[player] = {}
    end
    table.insert(activeBuffs[player], buffName)
end

function 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)
    end
end

return CombatService

Async Patterns

Error Handling with pcall / xpcall

-- pcall wraps a call and catches errors
local 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 trace
local success, result = xpcall(function()
    error("Something went wrong")
end, function(err)
    warn("Error:", err)
    warn("Stack:", debug.traceback())
    return err
end)

-- Retry pattern
local 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 nil
end

-- Usage: retry DataStore calls
local data = retryAsync(3, 1, function()
    return dataStore:GetAsync("player_123")
end)

Task Library

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 duration
local elapsed = task.wait(2)
print(`Actually waited {elapsed} seconds`)

-- task.spawn: runs a function immediately in a new thread
task.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 delay
task.delay(3, function()
    print("This runs after 3 seconds")
end)

-- task.defer: runs at end of the current resumption cycle
task.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.delay
local thread = task.delay(10, function()
    print("This will never run")
end)
task.cancel(thread)

Coroutines

-- Coroutines allow cooperative multitasking
local function producer(): ()
    for i = 1, 5 do
        coroutine.yield(i)
    end
end

local co = coroutine.create(producer)
for i = 1, 5 do
    local success, value = coroutine.resume(co)
    print(value) --> 1, 2, 3, 4, 5
end

-- coroutine.wrap creates a function that resumes automatically
local nextValue = coroutine.wrap(producer)
print(nextValue()) --> 1
print(nextValue()) --> 2

-- Practical example: staggered parallel initialization
local 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)
    end
end

Promise Pattern

The community-standard roblox-lua-promise library provides Promise-based async control flow.
local Promise = require(ReplicatedStorage.Packages.Promise)

-- Creating a Promise
local 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

-- Chaining
loadPlayerData(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 promises
Promise.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.retry
Promise.retry(function()
    return loadPlayerData(player)
end, 3):andThen(function(data)
    print("Loaded after retry")
end)

Roblox-Specific API Patterns

Instance Creation and Manipulation

-- Creating instances
local part = Instance.new("Part")
part.Name = "Floor"
part.Size = Vector3.new(50, 1, 50)
part.Position = Vector3.new(0, 0, 0)
part.Anchored = true
part.Material = Enum.Material.Grass
part.BrickColor = BrickColor.new("Bright green")
part.Parent = workspace -- ALWAYS set Parent last for performance

-- Cloning
local clone = part:Clone()
clone.Position = Vector3.new(0, 0, 60)
clone.Parent = workspace

-- Destroying (removes from hierarchy and disconnects all events)
part:Destroy()

Service Access

-- GetService is the canonical way to access Roblox services
local 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

Event Connections

-- Connect returns an RBXScriptConnection — store it to disconnect later
local connection: RBXScriptConnection
connection = 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 once
end)

-- RunService hooks
local RunService = game:GetService("RunService")

RunService.Heartbeat:Connect(function(deltaTime: number)
    -- fires every frame after physics; use for most game logic
end)

RunService.Stepped:Connect(function(elapsedTime: number, deltaTime: number)
    -- fires every frame before physics; use for pre-physics / input logic
end)

-- Property change signal
part:GetPropertyChangedSignal("Position"):Connect(function()
    print(`Part moved to {part.Position}`)
end)

RemoteEvents and RemoteFunctions

-- SERVER SIDE
local damageEvent = Instance.new("RemoteEvent")
damageEvent.Name = "DamageEvent"
damageEvent.Parent = ReplicatedStorage

damageEvent.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 client
damageEvent:FireClient(somePlayer, "You took damage!", 25)

-- Fire to all clients
damageEvent:FireAllClients("Boss spawned!", bossPosition)

-- CLIENT SIDE
local damageEvent = ReplicatedStorage:WaitForChild("DamageEvent")

damageEvent.OnClientEvent:Connect(function(message: string, data: any)
    print(message)
end)

-- Send request to server
damageEvent:FireServer("EnemyA", 50)

Instance Tree Traversal

-- FindFirstChild: returns first direct child with name (or nil)
local head = character:FindFirstChild("Head")

-- FindFirstChild with recursive flag
local sword = workspace:FindFirstChild("Sword", true)

-- FindFirstChildWhichIsA: by class hierarchy
local 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 recursive
local allParts: { BasePart } = {}
for _, descendant in workspace:GetDescendants() do
    if descendant:IsA("BasePart") then
        table.insert(allParts, descendant)
    end
end

-- CollectionService: tag-based filtering
local CollectionService = game:GetService("CollectionService")
local enemies = CollectionService:GetTagged("Enemy")

CollectionService:GetInstanceAddedSignal("Enemy"):Connect(function(instance)
    setupEnemy(instance)
end)

Common Idioms

Table Utilities

-- table.insert / table.remove
local queue = {}
table.insert(queue, "task1")
table.insert(queue, 1, "urgent")   -- insert at index 1
local removed = table.remove(queue, 1)
local last    = table.remove(queue) -- removes last element

-- table.find
local fruits = { "apple", "banana", "cherry" }
local index = table.find(fruits, "banana") --> 2

-- table.sort with comparator
local players = {
    { name = "Alice", score = 150 },
    { name = "Bob", score = 200 },
}
table.sort(players, function(a, b)
    return a.score > b.score -- descending
end)

-- table.freeze (Luau extension — immutable config tables)
local CONFIG = table.freeze({
    MAX_PLAYERS = 50,
    ROUND_TIME  = 300,
    MAP_SIZE    = 500,
})

-- table.clone (Luau extension — shallow copy)
local original = { 1, 2, 3, sub = { 4, 5 } }
local copy = table.clone(original)

-- Deep copy utility (not built-in)
local function deepCopy<T>(original: T): T
    if typeof(original) ~= "table" then
        return original
    end
    local copy = table.clone(original :: any)
    for key, value in copy do
        if typeof(value) == "table" then
            copy[key] = deepCopy(value)
        end
    end
    return copy :: T
end

String Patterns

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 captures
local year, month, day = string.match("2026-03-04", "(%d+)-(%d+)-(%d+)")
print(year, month, day) --> "2026" "03" "04"

-- string.gmatch: iterate over all matches
local text = "score=100, level=42, health=75"
for key, value in string.gmatch(text, "(%w+)=(%d+)") do
    print(key, value)
end

-- string.gsub: replace matches
local cleaned = string.gsub("Hello   World", "%s+", " ")
print(cleaned) --> "Hello World"

-- Anchors: ^ start, $ end
local isEmail = string.match("user@example.com", "^%w+@%w+%.%w+$") ~= nil

Math Helpers

-- Clamping
local health = math.clamp(currentHealth, 0, MAX_HEALTH)

-- Linear interpolation
local function lerp(a: number, b: number, t: number): number
    return a + (b - a) * t
end

-- Value remapping
local function map(value, inMin, inMax, outMin, outMax)
    return outMin + (outMax - outMin) * ((value - inMin) / (inMax - inMin))
end

-- Distance between two Vector3s
local distance = (posA - posB).Magnitude

-- Normalized direction vector
local direction = (target - origin).Unit

-- Rounding to decimal places
local function roundTo(value: number, places: number): number
    local factor = 10 ^ places
    return math.round(value * factor) / factor
end
print(roundTo(3.14159, 2)) --> 3.14

Best Practices

Naming Conventions

PascalCase

Classes, modules, services, types, enums — CombatService, PlayerData, WeaponManager

camelCase

Variables, function names, method names, parameters — playerHealth, calculateDamage

UPPER_CASE

Constants — MAX_HEALTH, RESPAWN_DELAY, DEFAULT_SPEED

_underscore prefix

Private members by convention (not enforced) — _cachedValue, _internalMethod

Module Structure

-- Standard module template
-- File: ReplicatedStorage/Modules/InventoryManager.lua

-- 1. Services at the top
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")

-- 2. Dependencies
local Types  = require(ReplicatedStorage.Shared.Types)
local Signal = require(ReplicatedStorage.Packages.Signal)

-- 3. Constants
local MAX_SLOTS   = 20
local STACK_LIMIT = 99

-- 4. Module table
local InventoryManager = {}

-- 5. Private state
local inventories: { [Player]: Types.Inventory } = {}

-- 6. Public API with type annotations
function InventoryManager.getInventory(player: Player): Types.Inventory?
    return inventories[player]
end

function InventoryManager.addItem(player: Player, itemId: string, quantity: number): boolean
    local inventory = inventories[player]
    if not inventory then return false end
    -- ... implementation
    return true
end

-- 7. Initialization
function InventoryManager.init()
    Players.PlayerAdded:Connect(function(player: Player)
        inventories[player] = { slots = {}, gold = 0 }
    end)
    Players.PlayerRemoving:Connect(function(player: Player)
        inventories[player] = nil
    end)
end

return InventoryManager

General Guidelines

1

Always declare local

Use local for every variable and function. Global variables pollute the environment and are invisible to the type checker.
2

Annotate public APIs

Add type annotations to all public module function signatures. Private helpers can be lighter on annotations.
3

Use the task library

Replace all wait() / spawn() / delay() calls with task.wait() / task.spawn() / task.delay().
4

Use typeof() not type()

typeof() is Roblox-aware and correctly classifies Instances, Vectors, CFrames, and other engine types.
5

Set Parent last

Configure all properties before parenting a new Instance. Setting Parent triggers replication and change events — do it once, at the end.
6

Clean up connections

Store every RBXScriptConnection and call :Disconnect() in cleanup. Use a Trove/Maid pattern for per-player systems.
7

Validate client input

Every OnServerEvent and OnServerInvoke handler must validate type, range, ownership, and rate. Never trust the client.
8

Wrap fallible calls in pcall

DataStores, HTTP requests, and any API that can fail must be wrapped in pcall or xpcall.

Anti-Patterns

Deprecated Global Functions

-- BAD: deprecated, unpredictable timing, errors are swallowed silently
wait(2)
spawn(function() end)
delay(2, function() end)

-- GOOD: task library equivalents
task.wait(2)
task.spawn(function() end)
task.delay(2, function() end)

Polling Instead of Events

-- BAD: wastes CPU cycles, imprecise timing
while true do
    local target = findNearestEnemy()
    if target then attack(target) end
    task.wait(0.1)
end

-- GOOD: event or Heartbeat
local RunService = game:GetService("RunService")
RunService.Heartbeat:Connect(function(dt: number)
    local target = findNearestEnemy()
    if target then attack(target) end
end)

String Concatenation in Loops

-- BAD: creates a new string every iteration — O(n²) memory
local 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))
end
local result = table.concat(parts, ",")

Trusting Client Input

-- BAD: client controls damage value
remoteEvent.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 only
remoteEvent.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)

Common Pitfalls

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 loop
for i = 1, #items do
    print(items[i])
end
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 array
table.remove(b, 3) -- shifts elements down, keeps table contiguous
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 = original
alias[1] = 99
print(original[1]) --> 99 (same table!)

-- Use table.clone for a shallow copy
local copy = table.clone(original)
copy[1] = 0
print(original[1]) --> 99 (unaffected)

-- Nested tables are still shared in a shallow clone — use deepCopy for nested structures
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 BUG

function MyClass.new()
    return setmetatable({}, MyClass)
end

function MyClass.doSomething(self) end

local obj = MyClass.new()
obj:doSomething() --> ERROR: attempt to call a nil value
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 end
end
print(fns[1]()) --> 1  (correct)

-- while loop: all closures share the same 'j'
local fns2 = {}
local j = 1
while j <= 5 do
    fns2[j] = function() return j end -- captures the variable, not the value
    j += 1
end
print(fns2[1]()) --> 6  (wrong! j is now 6)

-- Fix: capture with a local
local fns3 = {}
local k = 1
while k <= 5 do
    local captured = k
    fns3[k] = function() return captured end
    k += 1
end
print(fns3[1]()) --> 1  (correct)
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           --> prints
if "" then print("empty string is truthy") end --> prints
if {} then print("empty table is truthy") end  --> prints

-- Be explicit when checking for empty/zero:
if value ~= nil and value ~= "" then end
if value ~= nil and value ~= 0 then end

Build docs developers (and LLMs) love