Luau Best Practices
Production-quality patterns for Roblox game development.
Core Principles
- Server Authority - Server owns game state; client is for presentation
- Fail Fast - Validate early, error loudly in development
- Explicit > Implicit - Clear intent beats clever code
- Minimal Surface Area - Expose only what's needed
Code Style
Naming Conventions
-- PascalCase: Types, Classes, Services, Modules
type PlayerData = { ... }
local ShopService = {}
local PlayerController = require(...)
-- camelCase: Variables, functions, methods
local playerCount = 0
local function getPlayerData() end
function ShopService:purchaseItem() end
-- SCREAMING_SNAKE_CASE: Constants
local MAX_PLAYERS = 50
local DEFAULT_HEALTH = 100
-- Private with underscore prefix
local function _validateInput() end
local _cache = {}
File Organization
Order within a file: --!strict → services/imports → constants → types → module table → private state → private functions → public API → return. Full template in references/code-style.md.
Module Patterns
Service Pattern (Server)
--!strict
local MyService = {}
local _started = false
function MyService:Start()
assert(not _started, "MyService already started")
_started = true
-- Initialize connections, load data
end
function MyService:Stop()
-- Cleanup for hot-reloading
end
return MyService
Controller Pattern (Client)
--!strict
local MyController = {}
local _player = game:GetService("Players").LocalPlayer
function MyController:Init()
-- Setup without yielding
end
function MyController:Start()
-- Connect events, start loops
end
return MyController
More patterns (signals, state management, lazy init) in references/patterns.md.
Error Handling
Use pcall for External Calls
-- DataStore, HTTP, any Roblox API that can fail
local success, result = pcall(function()
return dataStore:GetAsync(key)
end)
if not success then
warn("DataStore failed:", result)
return nil
end
return result
Use assert for programming errors (things that should never happen); use pcall + warn for external calls that legitimately fail. Result types and retry patterns in references/error-handling.md.
Memory Management
Always Disconnect
local connection: RBXScriptConnection
connection = event:Connect(function()
-- handler
end)
-- Later, cleanup:
connection:Disconnect()
Use Maids/Janitors
local Maid = require(Packages.Maid)
local maid = Maid.new()
maid:GiveTask(event:Connect(handler))
maid:GiveTask(instance)
maid:GiveTask(function()
-- Custom cleanup
end)
-- Cleanup everything at once
maid:Destroy()
Weak tables for caches and other leak prevention patterns in references/memory.md.
Security Best Practices
Server Authority
-- BAD: Client tells server what happened
RemoteEvent.OnServerEvent:Connect(function(player, damage)
target.Health -= damage -- Client controls damage!
end)
-- GOOD: Server calculates everything
RemoteEvent.OnServerEvent:Connect(function(player, targetId)
local target = getValidTarget(player, targetId)
if not target then return end
local damage = calculateDamage(player) -- Server calculates
target.Health -= damage
end)
Validate All Input
RemoteFunction.OnServerInvoke = function(player, itemId, quantity)
-- Type validation
if typeof(itemId) ~= "string" then return end
if typeof(quantity) ~= "number" then return end
-- Range validation
if quantity < 1 or quantity > 99 then return end
if quantity ~= math.floor(quantity) then return end
-- Business logic validation
if not Items[itemId] then return end
if not canAfford(player, itemId, quantity) then return end
-- Now safe to process
return purchaseItem(player, itemId, quantity)
end
Rate limiting and anti-exploit patterns in references/security.md.
Common Anti-Patterns
Avoid
-- Using wait() - use task.wait()
wait(1) -- BAD
task.wait(1) -- GOOD
-- spawn() - use task.spawn()
spawn(fn) -- BAD
task.spawn(fn) -- GOOD
-- delay() - use task.delay()
delay(1, fn) -- BAD
task.delay(1, fn) -- GOOD
-- Polling when events exist
while true do
if something then break end
task.wait()
end
-- GOOD: Use events/signals instead
-- String concatenation in loops
local s = ""
for i = 1, 1000 do
s = s .. tostring(i) -- O(n²)
end
-- GOOD: Use table.concat
-- Dot-index chains on instances
workspace.Folder.SubFolder.Part -- Errors if missing
-- GOOD: Safe navigation
local folder = workspace:FindFirstChild("Folder")
local subFolder = folder and folder:FindFirstChild("SubFolder")
local part = subFolder and subFolder:FindFirstChild("Part")
Prefer
-- Generalized iteration
for _, v in ipairs(array) do end -- OLD
for _, v in array do end -- MODERN (Luau)
-- If expressions
local x = if condition then a else b -- Clean ternary
-- Continue in loops
for _, item in items do
if not item.valid then continue end
process(item)
end
-- Optional chaining with and
local name = player and player.Character and player.Character.Name
Project Structure
src/
├── Server/
│ ├── init.server.luau # Bootstrap
│ ├── Services/ # Game services
│ │ ├── DataService.luau
│ │ └── CombatService.luau
│ └── Components/ # Server components
├── Client/
│ ├── init.client.luau # Bootstrap
│ ├── Controllers/ # Client controllers
│ └── UI/ # UI components
├── Shared/
│ ├── Types.luau # Shared type definitions
│ ├── Constants.luau # Shared constants
│ └── Util/ # Shared utilities
└── Packages/ # Wally packages
Quick Reference
| Do | Don't |
|---|---|
task.wait() |
wait() |
task.spawn() |
spawn() |
task.delay() |
delay() |
for _, v in t |
for _, v in pairs(t) |
| Validate on server | Trust client data |
| Use types | Use any everywhere |
| Disconnect events | Leave connections dangling |
| Use constants | Magic numbers/strings |
| Early return | Deep nesting |
| Small functions | 200+ line functions |
References
- Code Style Guide - Naming, formatting, organization
- Common Patterns - Services, signals, state management
- Error Handling - pcall, Result types, retries
- Memory Management - Cleanup, leaks, weak tables
- Security - Server authority, validation, anti-exploit