Learn to code Roblox scripts with Luau
LuauJit is a free, hands-on course for Luau, the language that powers every Roblox game. Go from your first print() to LocalScripts, GUIs, full game systems, and anti-tamper. The built-in AI has two brains: a tutor that explains, and a builder that writes complete scripts and GUIs for you on request.
Write real scripts
Every lesson is real Luau you can paste straight into Roblox Studio and run.
Learn the hard parts
Client vs server, RemoteEvents, and sanity checks, the things most tutorials skip.
Protect your game
A full lesson on anti-tamper: server authority, validation, rate limits, and secrets.
Let AI build it
Flip the chat to Build mode and get complete LocalScripts, GUIs, and game systems on demand.
1. Getting Started
Luau is Roblox's own version of Lua 5.1: faster, safer, and built for games. Everything in a Roblox world, from a coin to the camera, is controlled by scripts written in Luau.
Set up in 2 minutes
- Install Roblox Studio and open it.
- Create a new place from the Baseplate template.
- In the Explorer panel, hover over ServerScriptService, click the +, and choose Script.
A script opens with print("Hello world!") already in it. Press the big Play button and open the Output window (View > Output). You'll see it printed.
Your first script
-- a script in ServerScriptService
print("Hello from LuauJit!")
-- print can take many values at once
print("Score:", 100, true)2. Variables & Types
A variable is a name that holds a value. In Luau you create one with local, which keeps it inside the current script scope.
local playerName = "Kers0ne" -- string local coins = 250 -- number local isVIP = true -- boolean (true/false) local target = nil -- nothing yet local speed = 16 * 2 -- math works here
Strings
local greeting = "Hello"
local name = "world"
-- two ways to join text
print(greeting .. ", " .. name) -- old style, two dots
print(`{greeting}, {name}!`) -- Luau string interpolationThe backtick style is Luau-only and much cleaner. Anything inside { } is dropped in as a value.
Numbers and math
local base = 10 base += 5 -- 15, Luau has += -= *= /= too print(base ^ 2) -- 225 (exponent) print(7 % 3) -- 1 (remainder)
local. A variable without it becomes global, which is slower and easy to overwrite by accident.3. Tables & Loops
Tables are the one data structure Luau has, and you will use them for everything: lists, inventories, settings, databases.
Arrays (ordered lists)
local fruits = {"apple", "banana", "cherry"}
print(fruits[1]) -- "apple" (tables start at 1, not 0!)
print(#fruits) -- 3 (the # operator is the length)
fruits[2] = "blueberry" -- replace one
table.insert(fruits, "date") -- add to the end
table.remove(fruits, 1) -- remove index 1Dictionaries (name > value)
local stats = {
coins = 250,
level = 3,
vip = true,
}
print(stats.coins) -- 250
stats.coins += 50 -- 300Loops
local fruits = {"apple", "banana", "cherry"}
-- loop a fixed number of times
for i = 1, 5 do
print("lap " .. i)
end
-- walk an array
for index, fruit in ipairs(fruits) do
print(index, fruit)
end
-- walk a dictionary
for key, value in pairs(stats) do
print(key, value)
end
-- repeat while a condition holds
local hp = 100
while hp > 0 do
hp -= 25
endipairs for arrays, pairs for dictionaries. Roblox scripts run once top to bottom, so anything that must keep running (a countdown, a spawner) lives inside a loop.4. Functions & Modules
Functions are reusable blocks of logic. Modules are scripts that share code between your other scripts, so you write a thing once and use it everywhere.
Functions
-- define once
local function giveCoins(player, amount)
print(player.Name .. " got " .. amount .. " coins")
return amount * 2 -- functions can return values
end
-- call many times
local doubled = giveCoins("Alice", 50) -- prints, returns 100Luau functions can return more than one value, which is great for "result + reason" patterns:
local function tryPurchase(player, price)
if player.coins < price then
return false, "not enough coins"
end
player.coins -= price
return true, "ok"
end
local ok, reason = tryPurchase(somePlayer, 100)ModuleScripts
Create a ModuleScript in ReplicatedStorage. It must return exactly one table:
-- ModuleScript named "Rewards" in ReplicatedStorage
local Rewards = {}
function Rewards.dailyBonus(player)
return 100
end
return Rewards-- any Script that needs it local Rewards = require(game.ReplicatedStorage.Rewards) local bonus = Rewards.dailyBonus(player)
5. Instances & Workspace
Everything you see in a Roblox world is an Instance: Parts, Scripts, Players, GUIs. Scripts create, move, and delete them in real time.
Create a part from code
local part = Instance.new("Part")
part.Name = "TreasureChest"
part.Size = Vector3.new(4, 2, 4)
part.Position = Vector3.new(0, 5, -10)
part.Anchored = true
part.BrickColor = BrickColor.new("Gold")
part.Parent = workspaceFind and change things
-- two ways to reach an instance
local spawn1 = workspace:FindFirstChild("SpawnLocation")
local spawn2 = workspace.SpawnLocation -- errors if missing
-- walk every child
for _, child in ipairs(workspace:GetChildren()) do
if child:IsA("BasePart") then
child.Transparency = 0.5
end
end
-- copy values between instances
part2.CFrame = part1.CFramepart.Anchored = true unless you want physics to move it.6. Events
Scripts don't just run once. Events are the heartbeat of Roblox: your code waits, and when something happens (a touch, a player joining, a click), your function fires.
Connect an event
local part = workspace.TreasureChest
part.Touched:Connect(function(hit)
local character = hit.Parent
local humanoid = character:FindFirstChildOfClass("Humanoid")
if humanoid then
print(humanoid.Parent.Name .. " touched the chest")
end
end)Player lifecycle
local Players = game:GetService("Players")
Players.PlayerAdded:Connect(function(player)
print(player.Name .. " joined!")
player.CharacterAdded:Connect(function(character)
print(player.Name .. " spawned")
end)
end)
Players.PlayerRemoving:Connect(function(player)
print(player.Name .. " left")
end):Connect() fires every time, :Once() fires one time and disconnects itself. Use Once for things like a one-time tutorial trigger.7. Client vs Server
This is the lesson that separates script kiddies from real developers. Roblox has two worlds running at once:
- Server (Scripts in ServerScriptService): the truth. Runs once for everyone, sees everything, and is much harder to cheat against.
- Client (LocalScripts, usually in StarterPlayerScripts or StarterGui): one player's view and input. A player can replace, read, or stop any client script, so client code must never be trusted.
RemoteEvents connect the two
A RemoteEvent object (create it in ReplicatedStorage) is a bridge: the client asks, the server decides.
-- CLIENT (LocalScript): fire the request
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local remote = ReplicatedStorage:WaitForChild("BuyItem")
remote:FireServer("sword")-- SERVER (Script): validate, then act
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local remote = ReplicatedStorage:WaitForChild("BuyItem")
local PRICES = { sword = 500, shield = 350 }
remote.OnServerEvent:Connect(function(player, itemName)
-- 1. validate the argument type
if type(itemName) ~= "string" then return end
-- 2. validate the value
local price = PRICES[itemName]
if not price then return end
-- 3. only NOW act
-- (check the player really has the coins here)
print(player.Name .. " bought " .. itemName)
end)8. Build Something Real
Let's put it together: a coin leaderboard and a working shop purchase. These two systems appear in almost every Roblox game.
Leaderstats (the leaderboard)
-- Script in ServerScriptService
local Players = game:GetService("Players")
Players.PlayerAdded:Connect(function(player)
local leaderstats = Instance.new("Folder")
leaderstats.Name = "leaderstats"
leaderstats.Parent = player
local coins = Instance.new("IntValue")
coins.Name = "Coins"
coins.Value = 0
coins.Parent = leaderstats
end)That's it. Roblox reads any leaderstats folder on the player and shows it on the player list automatically.
A working shop
-- Script in ServerScriptService, plus a RemoteEvent named "BuyItem"
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local remote = ReplicatedStorage:WaitForChild("BuyItem")
local ITEMS = {
SpeedBoost = { price = 100, speed = 24 },
JumpBoost = { price = 150, jump = 75 },
}
remote.OnServerEvent:Connect(function(player, itemName)
local item = ITEMS[itemName] -- nil for unknown names
if not item then return end
local coins = player.leaderstats.Coins
if coins.Value < item.price then return end
coins.Value -= item.price -- charge on the server
local character = player.Character
local humanoid = character and character:FindFirstChildOfClass("Humanoid")
if humanoid and item.speed then
humanoid.WalkSpeed = item.speed
end
if humanoid and item.jump then
humanoid.UseJumpPower = true
humanoid.JumpPower = item.jump
end
end)9. Anti-Tamper & Security
Cheaters attack the client, because that's the only thing they control. Your defense is not clever client tricks; it is a server that assumes every client is hostile.
Rule 1: The server is the truth
Never let a client tell the server something important. Wrong:
-- BAD: client says how much damage it did
remote.OnServerEvent:Connect(function(player, damage)
enemy.Humanoid:TakeDamage(damage) -- cheater sends 999999
end)-- GOOD: server computes damage from its own data
remote.OnServerEvent:Connect(function(player, targetId)
local weapon = getEquippedWeapon(player) -- server's own record
if not weapon then return end
local target = findTarget(targetId)
if not target then return end
target.Humanoid:TakeDamage(weapon.damage) -- server's number
end)Rule 2: Validate every remote
remote.OnServerEvent:Connect(function(player, itemName)
-- type check: exploiters can send tables, NaN, anything
if type(itemName) ~= "string" then return end
if #itemName > 30 then return end -- sane length
if not ITEMS[itemName] then return end -- known item only
-- ... then business logic
end)Rule 3: Rate limit (cooldowns)
local lastFire = {}
remote.OnServerEvent:Connect(function(player, itemName)
local now = os.clock()
if lastFire[player] and now - lastFire[player] < 1 then
return -- too fast, drop it
end
lastFire[player] = now
Players.PlayerRemoving:Connect(function(leaving)
lastFire[leaving] = nil -- clean up
end)
-- ... business logic
end)Rule 4: Keep secrets on the server
Anything in a Script, ServerStorage, or ServerScriptService is invisible to players. Anything in ReplicatedStorage, StarterGui, or a LocalScript can be read and copied. API keys, admin lists, and formulas live server-side only.
-- server-side admin check, never a client-side one
local ADMIN_IDS = {
[12345678] = true,
}
script.Parent.AdminAction.OnServerEvent:Connect(function(player, action)
if not ADMIN_IDS[player.UserId] then return end
-- only real admins reach here
end)Rule 5: Sanity-check the physics
-- server watches for impossible movement
local MAX_SPEED = 50
spawn(function()
while task.wait(5) do
for _, player in ipairs(Players:GetPlayers()) do
local char = player.Character
local root = char and char:FindFirstChild("HumanoidRootPart")
if root then
-- server compares position jumps over time
-- if a player teleports far more than max speed allows,
-- flag or teleport them back
end
end
end
end)10. Level Up Luau
Luau has real type checking, modern async tools, and OOP patterns. These make your code faster to write and harder to break.
Type annotations
local function addCoins(player: Player, amount: number): number
local coins = player.leaderstats.Coins
coins.Value += amount
return coins.Value
end
type Item = {
name: string,
price: number,
}
local sword: Item = { name = "sword", price = 500 }Studio checks these as you type and flags mistakes before you ever press Play. Turn on strict mode in Script Properties for the full benefit.
The task library
-- never use wait() or spawn() anymore
task.wait(2) -- exact-ish timing
task.spawn(function() -- run alongside current code
while true do
task.wait(10)
-- periodic work
end
end)
task.delay(5, function() -- run after 5 seconds
print("5 seconds later")
end)OOP with metatables
local Enemy = {}
Enemy.__index = Enemy
function Enemy.new(name: string, hp: number)
return setmetatable({ name = name, hp = hp }, Enemy)
end
function Enemy:takeDamage(amount: number)
self.hp -= amount
if self.hp <= 0 then
print(self.name .. " is defeated")
end
end
local spider = Enemy.new("Spider", 40)
spider:takeDamage(30)Where to go next
- DataStores: save player data between sessions.
- Raycasting: lasers, line of sight, guns.
- CollectionService tags: give one script power over many objects.
- ProfileService / replica libraries: community standards for safe data.
- The Roblox Creator docs and the Luau language site are the two references worth bookmarking.
11. LocalScripts Deep Dive
LocalScripts are the client's brain. They run on one player's device and handle everything that should feel instant: input, camera, UI, effects. They live in StarterPlayerScripts, StarterPlayer.StarterCharacterScripts, StarterGui, or inside a Tool.
Input: UserInputService
-- LocalScript in StarterPlayerScripts
local UserInputService = game:GetService("UserInputService")
UserInputService.InputBegan:Connect(function(input, gameProcessed)
if gameProcessed then return end -- player was typing in chat, ignore
if input.KeyCode == Enum.KeyCode.E then
print("E pressed")
end
end)The character loop: RunService
local RunService = game:GetService("RunService")
-- runs every frame: perfect for smooth movement or camera work
RunService.RenderStepped:Connect(function(dt)
-- dt is the time since last frame; multiply speeds by dt
-- so movement looks the same at any framerate
end)
-- runs on a fixed rhythm: good for game logic
RunService.Heartbeat:Connect(function(dt)
-- server-style periodic work
end)Camera and effects
local player = game.Players.LocalPlayer
local camera = workspace.CurrentCamera
-- a simple third-person offset camera
RunService.RenderStepped:Connect(function()
local char = player.Character
local root = char and char:FindFirstChild("HumanoidRootPart")
if root then
camera.CFrame = camera.CFrame:Lerp(
CFrame.new(root.Position + Vector3.new(0, 8, 12),
root.Position),
0.1
)
end
end):Lerp(a, 0.1) moves 10% of the way each frame, which makes any motion silky instead of jerky. It is the single most useful trick for client-side polish.12. Build GUIs
GUIs are just Instances: a ScreenGui holds Frames, Buttons, and Labels. You can build them in Studio, but writing them from a script means your whole UI is version-controlled and copy-pasteable. Here is a complete, working menu:
A full menu GUI in one LocalScript
-- LocalScript in StarterPlayerScripts
local Players = game:GetService("Players")
local TweenService = game:GetService("TweenService")
local player = Players.LocalPlayer
local playerGui = player:WaitForChild("PlayerGui")
-- screen root
local screenGui = Instance.new("ScreenGui")
screenGui.Name = "LuauJitMenu"
screenGui.ResetOnSpawn = false
screenGui.Parent = playerGui
-- the window
local frame = Instance.new("Frame")
frame.Size = UDim2.fromOffset(280, 180)
frame.Position = UDim2.new(0.5, -140, 0.5, -90)
frame.BackgroundColor3 = Color3.fromRGB(18, 24, 33)
frame.Active = true
frame.Draggable = true
frame.Parent = screenGui
local corner = Instance.new("UICorner")
corner.CornerRadius = UDim.new(0, 10)
corner.Parent = frame
-- title
local title = Instance.new("TextLabel")
title.Size = UDim2.new(1, 0, 0, 40)
title.BackgroundTransparency = 1
title.Text = "LuauJit Menu"
title.TextColor3 = Color3.fromRGB(34, 211, 238)
title.Font = Enum.Font.GothamBold
title.TextSize = 18
title.Parent = frame
-- a working button with hover animation
local button = Instance.new("TextButton")
button.Size = UDim2.new(1, -24, 0, 44)
button.Position = UDim2.new(0, 12, 0, 60)
button.BackgroundColor3 = Color3.fromRGB(28, 37, 51)
button.Text = "Give +100 Coins (demo)"
button.TextColor3 = Color3.new(1, 1, 1)
button.Font = Enum.Font.Gotham
button.TextSize = 15
button.Parent = frame
local btnCorner = Instance.new("UICorner")
btnCorner.CornerRadius = UDim.new(0, 8)
btnCorner.Parent = button
button.MouseEnter:Connect(function()
TweenService:Create(button,
TweenInfo.new(0.15),
{ BackgroundColor3 = Color3.fromRGB(34, 211, 238) }
):Play()
end)
button.MouseLeave:Connect(function()
TweenService:Create(button,
TweenInfo.new(0.15),
{ BackgroundColor3 = Color3.fromRGB(28, 37, 51) }
):Play()
end)
button.MouseButton1Click:Connect(function()
-- real games send a RemoteEvent here instead (see lesson 7)
print("button clicked")
end)The ideas that matter
- UDim2 positions things:
UDim2.new(xScale, xOffset, yScale, yOffset). Scales are fractions of the screen; offsets are pixels. Use both: scale for layout, offset for padding. - UICorner, UIStroke, UIListLayout: drop these into any frame for rounded corners, outlines, and automatic vertical lists. No manual positioning.
- TweenService animates any property. Hover effects, sliding menus, fades, all one call.
- ResetOnSpawn = false: keeps the GUI alive when the player respawns. Forget this and your menu vanishes every death.
13. Build a Full Game
Let's build a complete obby: checkpoints, kill bricks, and saved progress. Three scripts, and you have a playable game.
Checkpoints
-- Script in ServerScriptService
-- Put numbered parts named "Checkpoint1", "Checkpoint2"... in a folder
-- in Workspace called "Checkpoints"
local Players = game:GetService("Players")
local checkpoints = workspace:WaitForChild("Checkpoints")
local data = {} -- [player] = highest stage reached
Players.PlayerAdded:Connect(function(player)
player.CharacterAdded:Connect(function(character)
local stage = data[player] or 1
local pad = checkpoints:FindFirstChild("Checkpoint" .. stage)
if pad then
character:PivotTo(pad.CFrame + Vector3.new(0, 4, 0))
end
end)
end)
-- touching a pad advances your stage
for _, pad in ipairs(checkpoints:GetChildren()) do
local stage = tonumber(pad.Name:match("%d+"))
if stage then
pad.Touched:Connect(function(hit)
local player = Players:GetPlayerFromCharacter(hit.Parent)
if player and stage > (data[player] or 1) then
data[player] = stage
print(player.Name .. " reached stage " .. stage)
end
end)
end
endKill bricks
-- Script in ServerScriptService
-- tag any part "KillBrick" (use CollectionService in Studio, or name them this)
local Players = game:GetService("Players")
workspace.DescendantAdded:Connect(function(part)
if part.Name == "KillBrick" and part:IsA("BasePart") then
part.Touched:Connect(function(hit)
local humanoid = hit.Parent and hit.Parent:FindFirstChildOfClass("Humanoid")
if humanoid then
humanoid.Health = 0 -- respawn sends them to their checkpoint
end
end)
end
end)Saving progress with DataStores
-- add to the checkpoint script
local DataStoreService = game:GetService("DataStoreService")
local store = DataStoreService:GetDataStore("ObbyProgress")
-- load when the player joins
Players.PlayerAdded:Connect(function(player)
local ok, saved = pcall(function()
return store:GetAsync("stage_" .. player.UserId)
end)
data[player] = (ok and saved) or 1
end)
-- save when they leave
Players.PlayerRemoving:Connect(function(player)
pcall(function()
store:SetAsync("stage_" .. player.UserId, data[player])
end)
data[player] = nil
end)
-- autosave every 2 minutes too
task.spawn(function()
while task.wait(120) do
for player, stage in pairs(data) do
pcall(function()
store:SetAsync("stage_" .. player.UserId, stage)
end)
end
end
end)pcall catches the failure so one bad save never crashes your whole script. This pattern (load, autosave, save on leave) is the backbone of every persistent Roblox game.