roblox-luau
Modern, type-safe Luau for Roblox, covering --!strict and the new type solver, generics, string interpolation, if-expressions, task library, require-by-string, module/OOP patterns, error handling with retries, buffer/vector, and legacy APIs to avoid. Use when writing, refactoring, or explaining any Roblox script, fixing type errors, or modernizing old Lua code.
How do I install this agent skill?
npx skills add https://github.com/el4cteo/roblox-skills --skill roblox-luauIs this agent skill safe to install?
- Gen Agent Trust Hubpass
The skill provides comprehensive documentation and best practices for modern Luau development on the Roblox platform. It focuses on type safety, performance, and proper resource management without any detected security risks.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
Modern Luau for Roblox
Luau is Roblox's gradually-typed Lua 5.1 derivative. Engine and language move fast; many patterns from older tutorials (and older training data) are deprecated. Apply these defaults to every script you write unless the project clearly uses another convention.
Defaults for all generated code
--!strictat the top of new ModuleScripts (and Scripts when practical). Type public functions.- Get services with
game:GetService("Name")at the top of the file, nevergame.Name. - Use the
tasklibrary:task.wait,task.spawn,task.defer,task.delay,task.cancel. Neverwait,spawn,delay(deprecated, throttled, imprecise). - Yielding engine calls that can fail (DataStore, HTTP, Marketplace, Teleport,
*Async) go insidepcall. - Prefer the
*Asyncvariants of APIs; Roblox renamed most yielding methods (LoadCharacterAsync,GetProductInfoAsync,UserHasBadgeAsync, ...). See the table below. - Disconnect connections / destroy instances you create (see "Cleanup" below).
- Naming:
PascalCasefor services, modules, classes, types, and instance names;camelCasefor locals and functions;SCREAMING_SNAKE_CASEfor constants. - Tabs for indentation (StyLua default in the Roblox ecosystem), double quotes.
Modern syntax cheat sheet
local Players = game:GetService("Players")
local count = 0
count += 1 -- compound assignment: += -= *= /= //= %= ^= ..=
local label = if count > 1 then "many" else "one" -- if-expression (no `and/or` pitfalls)
print(`Player count: {#Players:GetPlayers()} ({label})`) -- string interpolation
for index, player in Players:GetPlayers() do -- generalized iteration: no pairs/ipairs needed
if player.AccountAge < 1 then
continue -- continue is a keyword
end
print(index, player.Name)
end
local half = 7 // 2 -- floor division → 3
local frozen = table.freeze({ Speed = 16 }) -- read-only table; writes throw
local list = table.create(100, 0) -- preallocated array
local copy = table.clone(frozen) -- shallow copy (unfrozen)
local mid = math.lerp(0, 10, 0.5) -- also: math.map, math.clamp, math.round, math.sign
print(half, list[1], copy.Speed, mid)
pairs/ipairsstill work; generalized iteration is preferred and iterates arrays in order.#tis only defined for arrays without holes. Settingt[i] = nilin the middle creates holes.- Numbers are doubles: integers are exact to 2^53. Use
math.floor///for integer math. tick()is discouraged: useos.clock()for benchmarks,workspace:GetServerTimeNow()for synced time across clients/server,os.time()/DateTime.now()for wall-clock UTC.
Types (new type solver)
Roblox's new type solver is generally available (Nov 2025) and Workspace.UseNewLuauTypeSolver
controls it; Workspace.LuauTypeCheckMode sets the default mode. Write code that checks cleanly
under --!strict.
--!strict
export type Item = {
id: string,
name: string,
stack: number,
tags: { string }?, -- optional field
}
type Rarity = "Common" | "Rare" | "Legendary" -- singleton (literal) union
local function totalStack(items: { Item }, filter: ((Item) -> boolean)?): number
local total = 0
for _, item in items do
if filter == nil or filter(item) then
total += item.stack
end
end
return total
end
local function first<T>(list: { T }): T? -- generics
return list[1]
end
local rarity: Rarity = "Rare"
local part = workspace:FindFirstChild("Door") :: BasePart? -- cast with ::
print(totalStack({}, nil), first({ 1, 2 }), rarity, part)
- Refine before use:
if part then ... end,if typeof(x) == "Instance" and x:IsA("BasePart") then. typeof(v)knows Roblox types ("Vector3","Instance", ...);type(v)only knows Lua types.- Instance types come from
IsArefinement orFindFirstChildOfClass/FindFirstChildWhichIsA. - Avoid
anyexcept at trust boundaries (remote payloads, decoded JSON) — validate there, then narrow.
Details: generics, type packs, read/write properties, user-defined type functions, typing
OOP classes, and migrating legacy code to strict: references/type-system.md.
Modules and require
ModuleScripts run once per environment (server or each client) and return one value, cached for every
later require. A module in ReplicatedStorage required by both sides runs twice, once per side, with
separate state.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Signal = require(ReplicatedStorage.Packages.Signal) -- instance path (always works)
local Config = require("./Config") -- require-by-string: sibling of this script (script.Parent)
local Utils = require("@self/Utils") -- child of this script
local Shared = require("@game/ReplicatedStorage/Shared") -- absolute from game
print(Signal, Config, Utils, Shared)
- String paths:
./=script.Parent,../=script.Parent.Parent,@self/=script,@game/=game. They do not wait for instances to exist. Use instance paths withWaitForChildwhen the module may not have replicated yet. - Never create circular requires; they deadlock/error. Break cycles with dependency injection or events.
- Don't yield at module top level (no
WaitForChildchains on things that may never exist, no DataStore calls): every requirer blocks until the module returns.
Module / class pattern
--!strict
local Counter = {}
Counter.__index = Counter
export type Counter = typeof(setmetatable({} :: { value: number, step: number }, Counter))
function Counter.new(step: number?): Counter
return setmetatable({ value = 0, step = step or 1 }, Counter)
end
function Counter.increment(self: Counter): number
self.value += self.step
return self.value
end
return Counter
Prefer plain modules of functions + data tables over deep inheritance. Use composition.
Errors and retries
local DataStoreService = game:GetService("DataStoreService")
local store = DataStoreService:GetDataStore("PlayerData")
local function retry<T...>(attempts: number, fn: () -> T...): (boolean, T...)
local delaySeconds = 1
for attempt = 1, attempts do
local result = table.pack(pcall(fn))
if result[1] then
return table.unpack(result, 1, result.n)
end
if attempt < attempts then
warn(`attempt {attempt} failed: {result[2]}`)
task.wait(delaySeconds)
delaySeconds *= 2 -- exponential backoff
end
end
return false
end
local ok, data = retry(3, function()
return store:GetAsync("Player_1")
end)
print(ok, data)
error("message", 2)blames the caller;error({ code = "X" })throws a table for structured errors.xpcall(fn, debug.traceback)keeps the stack trace.- Don't wrap everything in
pcall— only calls that can fail for reasons outside your code.
Concurrency: the task library
| Call | Behavior |
|---|---|
task.spawn(fn, ...) | Run now (until first yield) on a new thread. |
task.defer(fn, ...) | Run at the end of the current resumption cycle. Good for "after this finishes". |
task.delay(t, fn, ...) | Run after t seconds. Returns a thread you can task.cancel. |
task.wait(t?) | Yield ≥ t seconds (one frame if omitted); returns elapsed time. |
task.cancel(thread) | Stop a spawned/delayed thread. |
task.desynchronize() / task.synchronize() | Parallel Luau (inside Actors) — see roblox-performance. |
- Per-frame work:
RunService.Heartbeat(after physics),RunService.PreSimulation/PostSimulation,RunService:BindToRenderStep(client, before render, ordered). Don'twhile true do task.wait() endfor per-frame logic; connect to an event and use itsdeltaTime. - Write handlers that work under deferred signal behavior (
Workspace.SignalBehavior): handlers run later in the frame, not inline. New template places useDeferred,Defaultwill switch to it, and server authority requires it. Don't rely on handler side effects being visible right after:Fire(). - Never yield inside metamethods or
Changed-style callbacks that must be synchronous.
Cleanup (memory leaks are the #1 Roblox bug)
local Players = game:GetService("Players")
local connections: { RBXScriptConnection } = {}
table.insert(
connections,
Players.PlayerRemoving:Connect(function(player)
print(`{player.Name} left`)
end)
)
local function cleanup()
for _, connection in connections do
connection:Disconnect()
end
table.clear(connections)
end
cleanup()
Instance:Destroy()disconnects that instance's connections and locksParent; it does not clear your Lua references — nil out tables that hold players/instances onPlayerRemoving.- Use
:Once()for one-shot connections. Use a Trove/Janitor/Maid-style helper for groups. - Keys that are Player/Instance objects in long-lived tables leak unless removed on leave/destroy.
Buffers and vectors (performance-sensitive data)
local packet = buffer.create(10)
buffer.writeu16(packet, 0, 513) -- item id
buffer.writef32(packet, 2, 12.5) -- amount
buffer.writei32(packet, 6, -4)
print(buffer.readu16(packet, 0), buffer.readf32(packet, 2), buffer.len(packet))
local v = vector.create(1, 2, 3) -- native 3-float vector (same type as Vector3 at runtime)
print(vector.magnitude(v), vector.normalize(v), vector.dot(v, vector.one))
buffer is the right tool for compact network payloads, large grids, and binary serialization.
Standard-library quick reference: references/stdlib-cheatsheet.md.
Legacy → current (most common mistakes)
| Don't write | Write instead |
|---|---|
wait(), spawn(), delay() | task.wait(), task.spawn(), task.delay() |
game.Workspace, game.Players | workspace, game:GetService("Players") |
Instance.new("Part", parent) | create, set properties, then set .Parent last |
Humanoid:LoadAnimation() | Animator:LoadAnimation() |
Model:SetPrimaryPartCFrame() | Model:PivotTo() / Model:GetPivot() |
workspace:FindPartOnRay(Ray.new(...)) | workspace:Raycast(origin, direction, params) |
BodyVelocity, BodyPosition, BodyGyro | LinearVelocity, AlignPosition, AlignOrientation |
player:LoadCharacter() | player:LoadCharacterAsync() |
MarketplaceService:GetProductInfo() | :GetProductInfoAsync() |
BadgeService:AwardBadge() | :AwardBadgeAsync() |
player:GetRankInGroup() | GroupService:GetRolesInGroupAsync() / player:IsInGroupAsync() |
TeleportService:Teleport*() variants | TeleportService:TeleportAsync() |
Chat service / ChatService modules | TextChatService |
GuiObject.Draggable | UIDragDetector |
table.getn, table.foreach, getfenv | #t, for ... in, nothing |
The full deprecation map lives in the roblox-code-review skill.
Performance idioms (only where it matters)
- Measure first (
debug.profilebegin/profileend, MicroProfiler, Script Profiler). --!nativeat the top of a script, or@nativeon a function, compiles hot numeric code natively. Use it on math-heavy modules, not everywhere (compile time + memory limits). Type annotations help it.- Cache
Instancelookups and service references outside loops; avoidFindFirstChildchains per frame. - Build large strings with
table.concatorbuffer, not repeated..in loops. - Reuse
RaycastParams/OverlapParamsobjects instead of creating them per call.
Related skills
roblox-architecture (project structure, client/server), roblox-networking (remotes),
roblox-performance (profiling, Parallel Luau), roblox-code-review (review checklist, full deprecation map).
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/el4cteo/roblox-skills/roblox-luau">View roblox-luau on skillZs</a>