Claude Skill

roblox-luau-patterns

Use for Roblox module boundaries, object lifecycles, signals, task scheduling, fallible calls, and cleanup in Luau.

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download tabooharmony-roblox-brain-skills_core_roblox-luau-patterns-6051c35.zip · 8 KB
Part of tabooharmony/roblox-brain — 29 skills

Install

skills CLI npx skills add https://github.com/TabooHarmony/roblox-brain/tree/main/skills/core/roblox-luau-patterns
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install tabooharmony-roblox-brain@llmmart
Git 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.

No comments yet.

Reviews (0)

No reviews yet.

Related