agentleFS
EL4CTEOEL4CTEO/roblox-skills/skills/roblox-luau/SKILL.md

roblox-luau

Skill92 starsChanged 47 days agoMITView on GitHub

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.

1 other project uses a version of this.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

12 rules. About 0 are common, 3 are distinctive, and 9 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈3

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 9

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Use the task library: task.wait, task.spawn, task.defer, task.delay, task.cancel.

    line 16
  2. Very specific:

    Never wait, spawn, delay (deprecated, throttled, imprecise).

    line 17
  3. Very specific:

    Prefer the *Async variants of APIs.

    line 19
  4. Very specific:

    Avoid any except at trust boundaries (remote payloads, decoded JSON) — validate there, then narrow.

    line 97
  5. Distinctive:

    Never create circular requires.

    line 121
  6. Very specific:

    Don't yield at module top level (no WaitForChild chains on things that may never exist, no DataStore calls): every requirer blocks until …

    line 122
  7. Distinctive:

    Prefer plain modules of functions + data tables over deep inheritance.

    line 146
  8. Very specific:

    Don't wrap everything in pcall — only calls that can fail for reasons outside your code.

    line 178
  9. Very specific:

    Write handlers that work under deferred signal behavior (Workspace.SignalBehavior): handlers run later in the frame, not inline.

    line 194
  10. Very specific:

    Never yield inside metamethods or Changed-style callbacks that must be synchronous.

    line 197
  11. Very specific:

    Use :Once() for one-shot connections.

    line 225
  12. Distinctive:

    Use it on math-heavy modules, not everywhere (compile time + memory limits).

    line 270

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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

  1. --!strict at the top of new ModuleScripts (and Scripts when practical). Type public functions.
  2. Get services with game:GetService("Name") at the top of the file, never game.Name.
  3. Use the task library: task.wait, task.spawn, task.defer, task.delay, task.cancel. Never wait, spawn, delay (deprecated, throttled, imprecise).
  4. Yielding engine calls that can fail (DataStore, HTTP, Marketplace, Teleport, *Async) go inside pcall.
  5. Prefer the *Async variants of APIs; Roblox renamed most yielding methods (LoadCharacterAsync, GetProductInfoAsync, UserHasBadgeAsync, ...). See the table below.
  6. Disconnect connections / destroy instances you create (see "Cleanup" below).
  7. Naming: PascalCase for services, modules, classes, types, and instance names; camelCase for locals and functions; SCREAMING_SNAKE_CASE for constants.
  8. 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/ipairs still work; generalized iteration is preferred and iterates arrays in order.
  • #t is only defined for arrays without holes. Setting t[i] = nil in the middle creates holes.
  • Numbers are doubles: integers are exact to 2^53. Use math.floor/// for integer math.
  • tick() is discouraged: use os.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 IsA refinement or FindFirstChildOfClass/FindFirstChildWhichIsA.
  • Avoid any except 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 with WaitForChild when 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 WaitForChild chains 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

CallBehavior
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't while true do task.wait() end for per-frame logic; connect to an event and use its deltaTime.
  • Write handlers that work under deferred signal behavior (Workspace.SignalBehavior): handlers run later in the frame, not inline. New template places use Deferred, Default will 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 locks Parent; it does not clear your Lua references — nil out tables that hold players/instances on PlayerRemoving.
  • 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 writeWrite instead
wait(), spawn(), delay()task.wait(), task.spawn(), task.delay()
game.Workspace, game.Playersworkspace, 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, BodyGyroLinearVelocity, AlignPosition, AlignOrientation
player:LoadCharacter()player:LoadCharacterAsync()
MarketplaceService:GetProductInfo():GetProductInfoAsync()
BadgeService:AwardBadge():AwardBadgeAsync()
player:GetRankInGroup()GroupService:GetRolesInGroupAsync() / player:IsInGroupAsync()
TeleportService:Teleport*() variantsTeleportService:TeleportAsync()
Chat service / ChatService modulesTextChatService
GuiObject.DraggableUIDragDetector
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).
  • --!native at the top of a script, or @native on a function, compiles hot numeric code natively. Use it on math-heavy modules, not everywhere (compile time + memory limits). Type annotations help it.
  • Cache Instance lookups and service references outside loops; avoid FindFirstChild chains per frame.
  • Build large strings with table.concat or buffer, not repeated .. in loops.
  • Reuse RaycastParams/OverlapParams objects 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).

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.