From roblox-agent-skills
Modern, strictly-typed Luau. Use whenever writing, reviewing, or fixing Luau code (ModuleScripts, ServerScripts, LocalScripts) — language layer only, not Roblox APIs. Covers strict mode, type system, idioms, asserts, modules, and anti-patterns.
How this skill is triggered — by the user, by Claude, or both
Slash command
/roblox-agent-skills:luau-expertThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Write modern, strictly-typed Luau. Source: [luau.org](https://luau.org).
Write modern, strictly-typed Luau. Source: luau.org.
For deep dives see:
references/type-system.md — primitives, casts, aliases, tables, unions, generics, refinements, type functionsreferences/typed-class-recipe.md — full setmetatable+__index class patternFurther reading: references/type-functions-api.md (analysis-time types library), references/type-patterns.md (branded/recursive types, overloads, read-only tables).
This skill describes preferred patterns; it does not authorize rewriting existing code. Apply rules to new code and code the user explicitly asks to refactor. Match the codebase's existing style (casing, indentation, quote style, module shape) before applying skill defaults. Don't weaken stricter files; don't introduce tooling the project doesn't already use; surface conflicts once and default to the existing pattern.
Full rules: ../../shared/integration-policy.md.
--!strict. Migrate legacy code; never weaken a file that was strict.assert(typeof(x) == "number").local n = item.attack(p) — inference handles it.--!strict -- default for new files; flags anything that *might* fail at runtime
--!nonstrict -- legacy migration; infers any when type unknown
--!nocheck -- generated/third-party only
Project-wide: .luaurc with { "languageMode": "strict" }.
-- String interpolation (not string.format / not concat)
print(`Player {name} scored {score} points`)
-- if-then-else expression (not `a and b or c`)
local label = if score >= 100 then "Winner" else "Try again"
-- Generalized iteration (not pairs/ipairs)
for k, v in t do ... end
-- Compound assignments
count += 1
path ..= "/suffix"
-- Continue
for _, item in items do
if not item.active then continue end
process(item)
end
-- Floor division
local q = 7 // 2
-- Const bindings (binding immutable; value still mutable)
const MAX_PLAYERS = 16
-- Number separators
local big = 1_000_000
-- Function attributes
@native
local function hotPath(x: number): number
return x * x + x
end
ipairs only when you specifically need stop-at-first-nil semantics on a sparse array.
Guard clauses over pyramids — when the function logically can't keep going, return early.
local function dealDamage(humanoid: Humanoid, damage: number)
if damage <= 0 then return end
humanoid.Health -= damage
end
nil ChecksMost "truthy" checks really mean "not nil". Write what you mean:
if x.Parent ~= nil then ... -- ✅
if x.Parent then ... -- ❌ ambiguous
Two exceptions where truthy is fine: if-expressions (if x then a else b reads better than the inverted form) and or defaults.
or for Defaults; Never x and y or zsoundClone.Volume = volume or 0.5
-- Never simulate ternary with and/or — silently wrong when middle value is falsy
local goldAmount = if gamemode == "arena" then nil else 100 -- ✅
-- local goldAmount = gamemode == "arena" and nil or 100 -- ❌ returns 100 even on arena
local insert = table.insert -- ❌
table.insert(items, sword) -- ✅
call("string") -- ✅
call({ ... }) -- ✅
call "string" -- ❌
call { ... } -- ❌ (StyLua reformats)
Async Suffix on Yielding Functionslocal function fetchPlayerDataAsync(userId: number): PlayerData
-- yields here
end
React (and similar render libraries) does not expect yields — a re-render that hits a yielding call silently corrupts component state. Naming yielding functions makes it visible at the call site.
pcall Method-Shorthandpcall(part.Destroy, part) -- ❌ reads as nonsense
pcall(function() part:Destroy() end) -- ✅
pcall(saveMoney, player, 100) -- ✅ function-shorthand reads naturally
require(Modules.X) keeps types; require(child) in a loop erases them.sort_requires (covered in roblox-tooling).-- Optional
type Maybe<T> = T? -- T | nil
-- Tagged union (preferred for results)
type Result<T> = { ok: true, value: T } | { ok: false, err: string }
-- Indexed maps where misses are expected
local playerPoints: { [Player]: number? } = {} -- ✅ checker forces nil-handling
-- { [Player]: number } -- ❌ hides bugs on missing keys
-- Optional argument typing — ? at outer position
local function f(default: (boolean | () -> boolean)?) end -- ✅
-- local function f(default: boolean? | () -> boolean) end -- ❌
unknown for untrusted input; narrow before use. any for nothing — always reach for unknown first.
Full tour in references/type-system.md.
nil ≠ Nothinglocal function returnsNothing() end -- zero values
local function returnsNil() return nil end -- one value: nil
print(returnsNothing()) -- (blank)
print(returnsNil()) -- nil
Keep return shape consistent across branches. If a function returns T?, the no-value branch must return nil, not bare return.
exhaustiveMatchThe only enum form to use:
type Color = "red" | "green" | "blue"
local function exhaustiveMatch(value: never): never
error(`Unknown value in exhaustive match: {value}`)
end
local function setColor(color: Color)
if color == "red" then ...
elseif color == "green" then ...
elseif color == "blue" then ...
else exhaustiveMatch(color) -- type error if a branch is missed
end
end
No makeEnum({...}) libraries, no Color = { red = "red" :: Color } wrappers. They give nothing string unions don't.
See examples/exhaustive-match.luau.
assert(cond) produces useless assertion failed!.assert evaluates its message every call. For formatting use if not cond then error(\...`) end`.assert(typeof(x) == ...) to patch types. If your code is strictly typed, that line never trips except via untyped callers — fix the upstream type instead.unknown and narrow:HurtMe.OnServerEvent:Connect(function(player, damage: unknown)
if typeof(damage) ~= "number" or damage < 0 then return end
-- damage: number from here
dealDamage(player, damage)
end)
Typing the parameter as number makes the runtime check look statically valid even when an exploiter sends a string. unknown forces the check.
See examples/remote-validation.luau.
--!strict
local OtherModule = require("./OtherModule")
export type Config = { timeout: number, retries: number }
const DEFAULT_CONFIG: Config = { timeout = 30, retries = 3 }
local MyModule = {}
function MyModule.connect(cfg: Config?): ()
local c = cfg or DEFAULT_CONFIG
-- ...
end
return table.freeze(MyModule)
return a named local. No return function() ... end — breaks Ctrl-Shift-F across web/rg.TableUtil mega-modules; flatten.luau, reverse.luau separately. Luau LSP auto-requires bare names. Project-structure rationale lives in roblox-systems.table.freeze the returned module table to catch accidental mutations.any to break the cycle — and document why.For immutable updates, clone only the path you're touching:
items = table.clone(items)
items[1] = table.clone(items[1])
items[1].durability -= 10
Deep copy is always wasted work under immutability — unrelated subtrees keep their identity, which is exactly what == checks across renders want.
See examples/module-pattern.luau.
Plain data + free functions over classes. Metatables fight the type checker, hurt grep, and surprise readers.
type Slide = { length: number }
type Video = { slides: { Slide } }
-- videoLength.luau
local function videoLength(video: Video): number
local total = 0
for _, slide in video.slides do
total += slide.length
end
return total
end
return videoLength
Aside from __index and __tostring (and __mode for the rare weak table), avoid every metamethod. No __call, no __add, no operator overloads — they don't autocomplete and they fight inference.
When a class is genuinely warranted, see references/typed-class-recipe.md.
setfenv / getfenv — removed.unpack (global) — moved to table.unpack.loadstring — removed; use loadstring-equivalent only via loadstring if explicitly enabled, otherwise unavailable.bit32 — present, but consider native bitwise operators where possible.goto / labels — not in Luau.const, @native, type functions, default generics) exists or is stable, check luau.org before using it. State the uncertainty in your reply rather than guessing.RemoteEvent, DataStore, Instance, services, etc., defer to the roblox-dev skill. This skill covers the language only.| ❌ Don't | ✅ Do |
|---|---|
pairs(t) / ipairs(t) | for k, v in t do |
string.format(...) | `interpolated {value}` |
a and b or c | if a then b else c |
if x.Parent then | if x.Parent ~= nil then |
local insert = table.insert | Inline table.insert |
call "x" / call { ... } | call("x") / call({ ... }) |
| Reassignable constants | const MAX = 100 |
| Globals | local / const |
any | unknown + narrowing |
| Trivial local annotations | Let inference work |
| Untyped public function | Annotate params + return |
assert(typeof(x) == "T") to patch types | Fix the upstream type |
assert(cond) no message / formatted message | Constant message; if … then error(…) for formatted |
pcall(part.Destroy, part) | pcall(function() part:Destroy() end) |
{ [K]: V } when keys may be missing | { [K]: V? } |
Bare return from a T? function | Explicit return nil |
for _, m in c:GetChildren() do require(m) | Static require(c.X) |
| Sectioned require blocks | One alphabetical block |
Yielding fn missing Async suffix | Suffix Async |
makeEnum({"red","green"}) | type Color = "red" | "green" + exhaustiveMatch |
__call / __add / operator metamethods | Plain functions |
| Anonymous module return | local foo = ...; return foo |
TableUtil mega-modules | One function per file |
| Deep copy for immutable update | Clone only the touched path |
math.floor(a / b) | a // b |
| Mutating module table | table.freeze the return |
| Pyramid nesting | Early returns + continue |
examples/good.luau — idiomatic Luau ModuleScript using every recommended pattern.examples/bad.luau — same logic with every anti-pattern. For contrast only.examples/exhaustive-match.luau — string enums + exhaustiveMatch helper.examples/remote-validation.luau — unknown + narrowing at a RemoteEvent boundary.examples/module-pattern.luau — clean module skeleton.Provides behavioral guidelines to reduce common LLM coding mistakes, focusing on simplicity, surgical changes, assumption surfacing, and verifiable success criteria.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub afrxo/roblox-agent-skills --plugin roblox-agent-skills