roblox-luau-patterns
Use for Roblox module boundaries, object lifecycles, signals, task scheduling, fallible calls, and cleanup in Luau.
Install
npx skills add https://github.com/TabooHarmony/roblox-brain/tree/main/skills/core/roblox-luau-patterns
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install tabooharmony-roblox-brain@llmmart
git clone https://github.com/TabooHarmony/roblox-brain.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole tabooharmony/roblox-brain collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Luau Patterns
When to Load
Load when choosing a module shape, owning Roblox instances or event connections, scheduling work, or preserving failure semantics. Use roblox-luau-core for language behavior, roblox-luau-types for types, and roblox-architecture for project-wide ownership and startup.
Quick Reference
Choose the smallest shape
- Plain functions: default for stateless transformation or validation.
- Module with private state: one explicit subsystem owner. Do not create a manager class merely to namespace functions.
- Object with metatable: multiple independent values need shared behavior and lifecycle.
- Existing library abstraction: follow it when the project already uses it consistently. Do not add Promise, signal, cleanup, or framework dependencies for one call site.
Constructors use ., instance methods use :, and mutable fields belong on the instance, not the class table.
Make ownership visible
The code that connects a signal, creates an instance, or starts a task owns cleanup. Store connections and cancel or disconnect them when it ends. Type custom signal payloads once (Signal<T...>) and export the alias; see full.md.
Configure an instance before parenting when observers should not see partial state. Parent earlier only when the API or lifecycle requires ancestry, and document that reason. This is visibility control, not a magic replication-race fix.
Preserve failure semantics
local ok, value = pcall(dataStore.GetAsync, dataStore, key)
if not ok then
return nil, `read failed: {value}`
end
return value, nil -- value may legitimately be nil
Do not collapse "call succeeded and returned nil" into "call failed." Retry only when the domain operation is safe to repeat. Persistence, HTTP, purchases, and remotes belong to their domain skills.
Schedule deliberately
Use task.defer, task.spawn, task.delay, and task.cancel deliberately. Avoid legacy wait() and unjustified polling. Prefer a real state-change signal; otherwise choose and measure an explicit cadence.
Review
Clear owner, narrow public API, no circular require, no accidental concurrent startup, success and missing-data states remain distinct, connections/tasks cleaned up, client input routed to roblox-networking.
Detailed decision rules and lifecycle examples: references/full.md
Files (roblox-brain)
-
references
-
full.md 15.9 KB
# Luau Patterns: Full Reference > Examples are illustrative. Match the project's existing conventions and verify behavior in Studio. ## 1. Pick a shape from ownership, not fashion Start with plain functions. Add state only when something must own it. Add an object only when several independent values need the same behavior and lifecycle. | Need | Smallest useful shape | | --- | --- | | Pure transformation or validation | functions in a module | | One subsystem owns state | module with private state and a narrow API | | Many independent values share behavior | table plus metatable | | Framework or lifecycle already established | existing project abstraction | A module table is already a namespace. A class named `Manager`, `Service`, or `Controller` does not create a boundary by itself. Name the owned state and public contract. Split only when lifecycle, authority, or reasons to change are genuinely different. Use `roblox-architecture` for cross-project dependency direction and startup order. ## 2. Metatable objects ```luau local Countdown = {} Countdown.__index = Countdown export type Countdown = typeof(setmetatable( {} :: { remaining: number, finished: boolean, }, Countdown )) function Countdown.new(seconds: number): Countdown return setmetatable({ remaining = seconds, finished = seconds <= 0, }, Countdown) end function Countdown:step(deltaTime: number): boolean if self.finished then return true end self.remaining = math.max(0, self.remaining - deltaTime) self.finished = self.remaining == 0 return self.finished end return Countdown ``` Rules: - Set `__index` on the class table. - Call constructors with `.`, methods with `:`. - Store mutable state on `self`. Fields on the class table are shared. - Prefer composition to metatable inheritance. Inheritance obscures fields, construction, and cleanup, and often produces weak type inference. - Give owned resources an explicit `destroy` or equivalent lifecycle only when the object actually owns resources. Do not wrap a single table in an object merely to imitate another language. ## 3. Module state and startup Top-level module code runs during `require` and can yield. Keep it cheap. Expose explicit initialization only when wiring cannot happen lazily. ```luau local Registry = {} local byPlayer: {[Player]: string} = {} function Registry.get(player: Player): string? return byPlayer[player] end function Registry.set(player: Player, value: string) byPlayer[player] = value end function Registry.remove(player: Player) byPlayer[player] = nil end return Registry ``` The bootstrap, not each module, should own global startup order. Avoid every module inventing `Init` and `Start` by default. If phases exist, define what each phase guarantees and fail visibly when a dependency is unavailable. Never spawn all initializers concurrently when order matters. Player removal is not a universal save or cleanup hook. It is one lifecycle signal. The canonical persistence layer owns save/release behavior, and each subsystem owns only its own player-keyed memory and connections. ## 4. Instance visibility and ancestry Configure before parenting when descendants, listeners, plugins, or replication should observe only a complete object. ```luau local marker = Instance.new("Part") marker.Name = "SpawnMarker" marker.Anchored = true marker.CanCollide = false marker.Size = Vector3.new(1, 1, 1) marker.CFrame = spawnCFrame marker.Parent = workspace ``` This reduces exposure to partial state and unnecessary change observation. It does not solve an abstract "replication race." Some operations require ancestry or a specific parent. Parent earlier when the API contract requires it, then make partial visibility explicit. For groups of instances, build under an unparented model and parent the completed model once. Avoid repeated full-tree searches in hot paths. Cache stable ownership references, use tags for dynamic collections, and handle streamed or destroyed instances becoming unavailable. ## 5. Signals and cleanup The code that creates a connection owns it. ```luau local connection: RBXScriptConnection? = nil local function stop() if connection then connection:Disconnect() connection = nil end end connection = source.Changed:Connect(function(value) consume(value) end) ``` Use `:Once()` for a genuinely one-shot event. Use `:Wait()` only when yielding the current thread is acceptable and cancellation or timeout is not required. For long-lived owners, collect connections, tasks, and instances behind one cleanup boundary. A cleanup library can be worthwhile when the project already uses one; one connection does not justify a dependency. ### Typed signal payloads Engine-provided signals such as `RemoteEvent.OnServerEvent` are typed as `...any` in the API dump, so their payloads are unchecked. Custom signal classes can do better: make them generic (`Signal<T...>`) and type the payload once at the module boundary so every `.Connect` call site is checked. (Tooling-defined type surfaces such as `luau-lsp`'s exported `RBXScriptSignal<T...>` do carry payloads; the engine dump itself does not.) ```luau type Purchased = Signal<{ userId: number, productId: number, }> local purchased: Purchased = signal.new() purchased:Connect(function(payload) -- payload.userId and payload.productId are checked here grantProduct(payload.userId, payload.productId) end) ``` The useful habit is exporting the typed alias from the module that owns the signal and requiring that type at call sites, instead of re-declaring the payload shape inline each time. Without it, a typo like `productID` compiles and fails at runtime. Disconnecting before destroying is useful when callbacks could run during teardown or captured references outlive the instance. Do not claim every destroyed instance leaks every attached connection. Verify the actual owner and references. ### Instance references For an ordinary replicated reference, use an `ObjectValue` under a stable owner. With streaming enabled, its `Value` is `nil` until the target streams in, so callers must handle temporary absence. Attributes remain useful for lightweight configuration metadata. <!-- temporal: 2026-08 --> Studio beta adds instance-typed attributes (`Instance:SetAttribute("Target", otherInstance)`). Reading one back returns an `InstanceHandle`, not the instance: call `handle:Get()` (nil when the target is absent) or `handle:Wait(timeout)` (yields until it streams in). The handle exists so "attribute missing" (GetAttribute returns nil) stays distinguishable from "target not streamed in yet" (handle present, `Get()` nil). Treat this as beta until it reaches the stable engine reference, and verify current behavior before relying on it. ## 6. Task scheduling and cancellation - `task.defer`: queue work after the current resumption cycle. - `task.spawn`: start independent work promptly. - `task.delay`: schedule after a duration. - `task.cancel`: cancel a thread that is still owned and cancellable. - `task.wait`: yield for an approximate duration, not a precise clock. Do not use legacy `wait()`. Prefer a state-change signal when one exists; use `task.wait()` only for justified polling or cadence and give long-lived loops an explicit stop condition. A spawned task has no automatic owner and no automatic error contract. Keep its handle when cancellation matters. Do not use spawning to hide required startup ordering or to turn an error into an unobserved background failure. Event-driven work is preferable only when a real event represents the state change. Replacing a measured 10 Hz scan with `Heartbeat` creates a 60 Hz scan. If polling is necessary, choose cadence from responsiveness and cost, then measure it. ## 7. Fallible calls without semantic collapse `pcall` returns a transport/execution success flag followed by the function's results. A successful function may legitimately return `nil`. ```luau local ok, value = pcall(dataStore.GetAsync, dataStore, key) if not ok then return nil, `read failed: {value}` end return value, nil ``` Do not write a retry helper that returns only `T?`; it collapses "successful missing value" and "all attempts failed." Preserve a result shape or separate error value. ```luau export type Attempt<T> = { ok: true, value: T } | { ok: false, error: string } ``` Retry policy is domain-specific: - A read may be safe to repeat but still needs a bound and observable failure. - A write, purchase grant, webhook side effect, or remote action may duplicate work. - DataStore budgets, HTTP status, receipt retries, and session ownership have separate contracts. Route persistence to `roblox-data`, purchases to `roblox-monetization`, web calls to `roblox-cloud`, and client requests to `roblox-networking`. Use `xpcall` when a custom error handler or traceback is part of the diagnostic boundary. Do not add it mechanically around every function. ## 8. Optional libraries Promise, signal, cleanup, component, and remote libraries can encode useful contracts. They also add API surface, versioning, and team learning cost. Use one when: - the project already depends on it; - multiple call sites need the same cancellation, cleanup, or composition semantics; - its behavior is covered by tests and current documentation. Do not recommend a package from name recognition alone. Verify maintenance, license, current API, and whether native Luau plus a small explicit pattern is enough. Install through the package manager already used by the project. ## Static Scan Interpretation Static searches for `:Destroy(`, `:Disconnect(`, `Janitor`, `Maid`, or wait calls are review prompts, not proof of correct teardown or scheduling. Trace whether cleanup is reachable, whether every owned resource is covered, and whether a wait-coupled loop has a justified cadence or should respond to a real state-change event. ## 9. Review checklist - The chosen shape is the smallest one that represents ownership. - Public API and mutable private state are obvious. - No circular `require` or hidden top-level yield. - Startup order is explicit where order matters. - Every connection, task, and created instance has an owner and endpoint. - Successful `nil`, thrown errors, cancellation, timeout, and retry exhaustion stay distinct. - Retried operations are safe to repeat or idempotent. - Partial instance visibility is intentional. - No new manager class, framework, or dependency exists only to namespace one feature. - Security, persistence, purchases, networking, and performance are handed to their canonical skills. ## 10. Hot-path micro-optimizations Moved from `roblox-performance`, which keeps profiling, budgets, and engine tuning. These are code-level patterns: measure first, then apply only on paths the profiler confirms are hot. ### Object Pooling Pre-clone and reuse instances to avoid allocation spikes and garbage-collection pressure on spawn-heavy paths. ```luau local Pool = {} Pool.__index = Pool function Pool.new(template: Instance, initialSize: number) local self = setmetatable({ _template = template, _available = {}, _leases = {}, -- Instance -> token held by its current borrower }, Pool) for _ = 1, initialSize do local obj = template:Clone() obj.Parent = nil table.insert(self._available, obj) end return self end function Pool:get(): (Instance, any) local obj = table.remove(self._available) if not obj then obj = self._template:Clone() end local lease = {} -- a fresh token per acquisition self._leases[obj] = lease return obj, lease end function Pool:release(obj: Instance, lease: any): boolean -- Only the live lease may return obj. A nil lease must NEVER validate: -- for an unowned object _leases[obj] is nil, so a nil lease would -- compare equal and admit a duplicate pool entry. if lease == nil then return false end if self._leases[obj] ~= lease then return false end self._leases[obj] = nil obj.Parent = nil -- Reset state here table.insert(self._available, obj) return true end ``` Callers must hold the token from `get` and pass it back to `release`. Membership tracking alone is not enough: a delayed callback holding only the object reference can release it after it was re-acquired, double-booking it. The per-acquisition token rejects that stale release instead. ### Throttled Updates Instead of updating every frame, batch expensive work at fixed intervals: ```luau -- Instead of updating every frame, batch at fixed intervals local TICK_RATE = 1/10 -- 10 updates per second local accumulated = 0 RunService.Heartbeat:Connect(function(dt) accumulated += dt if accumulated < TICK_RATE then return end accumulated -= TICK_RATE -- Do expensive work here (runs 10x/sec, not 60x) updateAllNPCs() end) ``` ### Distance-Based Relevance Filtering Skip expensive updates for distant entities; the discovery scan itself remains O(n): ```luau -- This reduces expensive updates after discovery; the scan itself remains O(n). local ACTIVATION_RANGE = 100 local function getActiveEntities(playerPosition: Vector3): {Instance} local active = {} for _, entity in allEntities do if (entity.Position - playerPosition).Magnitude < ACTIVATION_RANGE then table.insert(active, entity) end end return active end ``` For large populations or frequent queries, use a real spatial index such as a grid or spatial hash. Choose its cell size from the query radius and movement pattern; this linear filter is not spatial partitioning. ### Lazy Loading ```luau -- Don't load everything at once -- Stream content as player approaches local loaded = {} local function ensureLoaded(zoneName: string) if loaded[zoneName] then return end loaded[zoneName] = true local zone = ServerStorage.Zones:FindFirstChild(zoneName) if zone then zone:Clone().Parent = workspace.ActiveZones end end ``` ## 11. ValueBase objects `StringValue`, `NumberValue`, `IntValue`, `BoolValue`, `ObjectValue`, and `CFrameValue` are typed single-value containers parented in the DataModel. The value lives in the `.Value` property; assigning it fires the object's `Changed` signal with the new value (not the property name). Prefer instance attributes (`SetAttribute`/`GetAttribute`) for small static data on an instance. Reach for a ValueBase object for legacy compatibility or when a shared, replicated container with its own `Changed` signal is the point. - `StringValue`: shared strings (game state, current minigame name). `.Value` accepts at most 200,000 characters; longer raises `String too long`. - `CFrameValue`: rarely used; stores one CFrame. Attributes already store CFrames, so use it only when `Changed` signaling on a replicated container is the requirement. ```luau -- Server: currentGame is a StringValue in workspace currentGame.Value = "Lobby" -- Client: statusLabel is a TextLabel workspace.CurrentGame.Changed:Connect(function(newName: string) statusLabel.Text = newName end) ``` ## Community ecosystem (leads, not sources) Top-sorted DevForum canon for language patterns. These are the posts experienced scripters still cite. - [All about OOP](https://devforum.roblox.com/t/all-about-object-oriented-programming/8585) (2.7k likes, 2014, still the reference); [metatables and metamethods](https://devforum.roblox.com/t/all-you-need-to-know-about-metatables-and-metamethods/503259). - [Avoiding wait() and why](https://devforum.roblox.com/t/avoiding-wait-and-why/244015): origin of the `task.*` canon. - [Pcalls: when and how](https://devforum.roblox.com/t/pcalls-when-and-how-to-use-them/393687) (658 likes); [Promises and why](https://devforum.roblox.com/t/promises-and-why-you-should-use-them/350825) for the Promise library. - Framework design: [Writing an FPS framework](https://devforum.roblox.com/t/writing-an-fps-framework-2020/503318) (+ part 2), the most-cited architecture walkthrough; [Server Authority: How to Begin?](https://devforum.roblox.com/t/server-authority-how-to-begin/4139185) (2025).
-
-
SKILL.md 2.9 KB
--- name: roblox-luau-patterns description: "Use for Roblox module boundaries, object lifecycles, signals, task scheduling, fallible calls, and cleanup in Luau." last_reviewed: 2026-09-19 sources: - https://luau-lang.org/ - https://create.roblox.com/docs/reference/engine/classes/ObjectValue - https://create.roblox.com/docs/reference/engine/classes/StringValue - https://create.roblox.com/docs/reference/engine/classes/CFrameValue - https://devforum.roblox.com/t/studio-beta-reference-instances-directly-with-attributes/4753441 - original --- # Luau Patterns ## When to Load Load when choosing a module shape, owning Roblox instances or event connections, scheduling work, or preserving failure semantics. Use `roblox-luau-core` for language behavior, `roblox-luau-types` for types, and `roblox-architecture` for project-wide ownership and startup. ## Quick Reference ### Choose the smallest shape - **Plain functions:** default for stateless transformation or validation. - **Module with private state:** one explicit subsystem owner. Do not create a manager class merely to namespace functions. - **Object with metatable:** multiple independent values need shared behavior and lifecycle. - **Existing library abstraction:** follow it when the project already uses it consistently. Do not add Promise, signal, cleanup, or framework dependencies for one call site. Constructors use `.`, instance methods use `:`, and mutable fields belong on the instance, not the class table. ### Make ownership visible The code that connects a signal, creates an instance, or starts a task owns cleanup. Store connections and cancel or disconnect them when it ends. Type custom signal payloads once (`Signal<T...>`) and export the alias; see full.md. Configure an instance before parenting when observers should not see partial state. Parent earlier only when the API or lifecycle requires ancestry, and document that reason. This is visibility control, not a magic replication-race fix. ### Preserve failure semantics ```luau local ok, value = pcall(dataStore.GetAsync, dataStore, key) if not ok then return nil, `read failed: {value}` end return value, nil -- value may legitimately be nil ``` Do not collapse "call succeeded and returned nil" into "call failed." Retry only when the domain operation is safe to repeat. Persistence, HTTP, purchases, and remotes belong to their domain skills. ### Schedule deliberately Use `task.defer`, `task.spawn`, `task.delay`, and `task.cancel` deliberately. Avoid legacy `wait()` and unjustified polling. Prefer a real state-change signal; otherwise choose and measure an explicit cadence. ### Review Clear owner, narrow public API, no circular require, no accidental concurrent startup, success and missing-data states remain distinct, connections/tasks cleaned up, client input routed to `roblox-networking`. > Detailed decision rules and lifecycle examples: [references/full.md](references/full.md)
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.