Skip to content
Back to skills

Roblox Luau

ASecurity

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.

  • 5 stars
  • 0 votes
  • 3 copies
  • 30 views
  • Added September 29, 2026
ai-agentsrustgoexpressrefactoringcode-reviewapiperformance

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 3 files and shows the line behind each finding

Scanned September 29, 2026

npx -y skills add EL4CTEO/roblox-skills --skill roblox-luau --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Roblox Luau?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Roblox Luau
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/el4cteo-roblox-luau/badge)](https://www.skillsdirectory.com/skills/el4cteo-roblox-luau)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: roblox-luau
description: 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.
---

# 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

```luau
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`.

```luau
--!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](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.

```luau
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

```luau
--!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

```luau
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'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)

```luau
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)

```luau
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](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).
- `--!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).

Files in this skill

  • SKILL.md11.8 KB
  • references/stdlib-cheatsheet.md4.3 KB
  • references/type-system.md6.9 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.