Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/brockmartin/roblox-game-skill/llms.txt

Use this file to discover all available pages before exploring further.

Roblox’s architecture — a replication model that automatically syncs the data tree between server and clients, DataStores that must be wrapped with session locking, a task scheduler with deprecated globals, and a scripting environment that exploiters can observe in real time — creates a set of failure modes that aren’t obvious from the documentation. Every entry in this reference represents a real production footgun that has caused data loss, security breaches, server crashes, or hours of debugging in shipped Roblox games. Knowing these gotchas before you ship is the difference between a stable game and an exploitable one.

Summary Table

IDSeverityIssueFix
SE-1🔴 CriticalDataStore data loss from session handlingUse ProfileService / ProfileStore with session locking
SE-2🔴 CriticalClient-side currency manipulationAll currency lives server-only; never accept balance from client
SE-3🔴 CriticalProcessReceipt mishandlingGrant item → record receipt → return PurchaseGranted
SE-4🟠 HighMemory leaks from undisconnected eventsTrove/Maid pattern; clean on PlayerRemoving
SE-5🟠 HighRemoteEvent floodingPer-player, per-remote rate limiter on the server
SE-6🟠 HighBindToClose timeoutSave all players in parallel with task.spawn
SE-7🟡 MediumPart count on mobileStreamingEnabled + keep visible parts under ~10,000
SE-8🟡 MediumYielding in module requireNo yields in module body; use Init/Start lifecycle
SE-9🟡 MediumTable # length with nil gapsUse table.remove; never set array elements to nil
SE-10🔵 LowDeprecated wait()/spawn()/delay()Replace with task.wait/task.spawn/task.delay
SE-11🟡 MediumInfinite yield on WaitForChildAlways pass a timeout; handle nil return
SE-12🔵 LowString patterns vs regexLuau uses %d not \d; % not \ as escape

Critical Issues

SE-1 — DataStore Data Loss from Session Handling

This is the most common cause of permanent player data loss in Roblox games. It affects any game with teleportation or that runs multiple server instances.
When a player server-hops or rapidly reconnects, the old server may still be in the middle of saving while the new server reads from the DataStore. This race condition means the new server reads stale data, the old server overwrites with its version, or both write conflicting snapshots. The result is permanently lost items, currency, and progress — and it is nearly impossible to reproduce locally because Studio only has one server instance. The fix: Use ProfileService (or its successor ProfileStore), which implements session locking. A session lock ensures only one server can own a player’s data at a time. The new server waits for the old server to release the lock before loading. If the old server crashes, the lock expires after a timeout and the new server claims it.
-- ServerScriptService/PlayerDataService.luau
-- Using ProfileService for session-locked player data

local Players = game:GetService("Players")
local ServerScriptService = game:GetService("ServerScriptService")
local ProfileService = require(ServerScriptService.Libs.ProfileService)

local PROFILE_TEMPLATE = {
    Coins = 0,
    Gems = 0,
    Inventory = {},
    Level = 1,
    Experience = 0,
}

local DATASTORE_NAME = "PlayerData_v1"

local ProfileStore = ProfileService.GetProfileStore(DATASTORE_NAME, PROFILE_TEMPLATE)
local Profiles: { [Player]: typeof(ProfileStore:LoadProfileAsync("")) } = {}

local PlayerDataService = {}

function PlayerDataService.getProfile(player: Player)
    return Profiles[player]
end

function PlayerDataService.start()
    local function onPlayerAdded(player: Player)
        -- Session locking happens automatically inside LoadProfileAsync.
        -- If another server holds the lock, this yields until it releases.
        local profile = ProfileStore:LoadProfileAsync(
            "Player_" .. player.UserId,
            "ForceLoad" -- Steal the lock if the other server is unresponsive
        )

        if profile == nil then
            -- Profile could not be loaded (DataStore outage, etc.)
            player:Kick("Unable to load your data. Please rejoin.")
            return
        end

        -- Guard: player may have left while we were loading
        if not player:IsDescendantOf(Players) then
            profile:Release()
            return
        end

        -- If the session is stolen by another server, kick this player
        profile:ListenToRelease(function()
            Profiles[player] = nil
            player:Kick("Your data was loaded on another server. Please rejoin.")
        end)

        Profiles[player] = profile
    end

    local function onPlayerRemoving(player: Player)
        local profile = Profiles[player]
        if profile then
            profile:Release() -- Releases session lock AND saves
            Profiles[player] = nil
        end
    end

    Players.PlayerAdded:Connect(onPlayerAdded)
    Players.PlayerRemoving:Connect(onPlayerRemoving)

    -- Handle players already in game (Studio edge case)
    for _, player in Players:GetPlayers() do
        task.spawn(onPlayerAdded, player)
    end
end

return PlayerDataService

SE-2 — Client-Side Currency Manipulation

This is the single most common exploit in Roblox games. Any value stored in or transmitted to the client can be read and modified by exploiters.
If your game stores currency in a NumberValue inside the player’s character, a leaderstats IntValue, or sends the balance via RemoteEvent for the client to “hold,” an exploiter can set that value to anything they want — and the server trusts it. The result is economy collapse: 999,999,999 coins after two minutes of play, leaderboards dominated by cheaters, and legitimate players leaving. The fix: Currency and all authoritative game state must live exclusively on the server. The client receives read-only display values. Never accept a currency amount from the client. Never let the client say “set my coins to X.” The server computes all transactions internally and pushes the result to the client for display only.
-- ServerScriptService/CurrencyService.luau
-- All currency logic is server-authoritative. The client NEVER sends a balance.

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local PlayerDataService = require(script.Parent.PlayerDataService)

-- Remote used ONLY to push display updates to the client
local CurrencyChanged = ReplicatedStorage.Remotes.CurrencyChanged :: RemoteEvent

local CurrencyService = {}

-- Server-only: Add currency. Called by server systems (quests, shops, etc.)
function CurrencyService.addCoins(player: Player, amount: number): boolean
    assert(typeof(amount) == "number", "Amount must be a number")
    assert(amount > 0, "Amount must be positive")
    assert(amount == math.floor(amount), "Amount must be an integer")
    assert(amount <= 1_000_000, "Amount exceeds single-transaction limit")

    local profile = PlayerDataService.getProfile(player)
    if not profile then return false end

    profile.Data.Coins += amount

    -- Push display update to client (client cannot modify this)
    CurrencyChanged:FireClient(player, "Coins", profile.Data.Coins)
    return true
end

-- Server-only: Spend currency. Returns true if successful.
function CurrencyService.spendCoins(player: Player, amount: number): boolean
    assert(typeof(amount) == "number", "Amount must be a number")
    assert(amount > 0, "Amount must be positive")
    assert(amount == math.floor(amount), "Amount must be an integer")

    local profile = PlayerDataService.getProfile(player)
    if not profile then return false end

    if profile.Data.Coins < amount then
        return false -- Insufficient funds
    end

    profile.Data.Coins -= amount
    CurrencyChanged:FireClient(player, "Coins", profile.Data.Coins)
    return true
end

function CurrencyService.getCoins(player: Player): number
    local profile = PlayerDataService.getProfile(player)
    return if profile then profile.Data.Coins else 0
end

return CurrencyService
-- StarterPlayerScripts/CurrencyDisplay.client.luau
-- Client ONLY listens for display updates. It never sends currency data.

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")

local CurrencyChanged = ReplicatedStorage.Remotes.CurrencyChanged :: RemoteEvent

local localPlayer = Players.LocalPlayer
local playerGui = localPlayer:WaitForChild("PlayerGui")

CurrencyChanged.OnClientEvent:Connect(function(currencyName: string, newValue: number)
    -- Update UI display only
    local label = playerGui:FindFirstChild(currencyName .. "Label", true)
    if label and label:IsA("TextLabel") then
        label.Text = tostring(newValue)
    end
end)

SE-3 — ProcessReceipt Mishandling

Real-money revenue is at stake. Getting the order wrong means players are double-charged or never receive their purchase.
MarketplaceService.ProcessReceipt is the callback Roblox calls when a player completes a developer product purchase. If you do not return Enum.ProductPurchaseDecision.PurchaseGranted, Roblox retries the callback on every server the player joins — potentially granting items multiple times. If you return PurchaseGranted before actually granting the item and the grant fails, the player loses their Robux with nothing to show for it. The fix — in this exact order:
  1. Check idempotency (was this PurchaseId already granted?)
  2. Grant the item/currency
  3. Save the PurchaseId to prevent future double-grants
  4. Only then return PurchaseGranted
  5. If anything fails before step 4, return NotProcessedYet
-- ServerScriptService/GamepassAndProductHandler.luau

local MarketplaceService = game:GetService("MarketplaceService")
local Players = game:GetService("Players")

local PlayerDataService = require(script.Parent.PlayerDataService)
local CurrencyService   = require(script.Parent.CurrencyService)

local PRODUCTS = {
    [123456789] = {
        name  = "100 Coins",
        grant = function(player: Player): boolean
            return CurrencyService.addCoins(player, 100)
        end,
    },
    [987654321] = {
        name  = "500 Coins",
        grant = function(player: Player): boolean
            return CurrencyService.addCoins(player, 500)
        end,
    },
}

local function processReceipt(receiptInfo: {
    PlayerId: number,
    ProductId: number,
    PurchaseId: string,
    CurrencySpent: number,
    PlaceIdWherePurchased: number,
}): Enum.ProductPurchaseDecision

    -- 1. Find the player in this server
    local player = Players:GetPlayerByUserId(receiptInfo.PlayerId)
    if not player then
        -- Player left; Roblox will retry when they rejoin
        return Enum.ProductPurchaseDecision.NotProcessedYet
    end

    -- 2. Wait for their profile to load
    local profile = PlayerDataService.getProfile(player)
    if not profile then
        return Enum.ProductPurchaseDecision.NotProcessedYet
    end

    -- 3. Idempotency check — was this PurchaseId already granted?
    if table.find(profile.Data.ProcessedReceipts, receiptInfo.PurchaseId) then
        -- Already granted; stop retrying
        return Enum.ProductPurchaseDecision.PurchaseGranted
    end

    -- 4. Look up and execute the grant
    local productConfig = PRODUCTS[receiptInfo.ProductId]
    if not productConfig then
        warn("[ProcessReceipt] Unknown ProductId:", receiptInfo.ProductId)
        return Enum.ProductPurchaseDecision.NotProcessedYet
    end

    local grantSuccess = productConfig.grant(player)
    if not grantSuccess then
        return Enum.ProductPurchaseDecision.NotProcessedYet
    end

    -- 5. Record the PurchaseId so we never double-grant
    table.insert(profile.Data.ProcessedReceipts, receiptInfo.PurchaseId)

    -- Keep the receipts list bounded (last 100)
    if #profile.Data.ProcessedReceipts > 100 then
        table.remove(profile.Data.ProcessedReceipts, 1)
    end

    -- 6. ONLY NOW tell Roblox the purchase is complete
    return Enum.ProductPurchaseDecision.PurchaseGranted
end

MarketplaceService.ProcessReceipt = processReceipt

High Severity Issues

SE-4 — Memory Leaks from Undisconnected Events

Every :Connect() call returns an RBXScriptConnection that persists until explicitly disconnected — even if the object the event belongs to is destroyed. In per-player systems, if you connect events when a player joins but never disconnect on leave, memory grows linearly with every player who has ever touched your server. Symptoms: Server memory climbs steadily over hours, server FPS degrades, callbacks fire for players who already left, eventual out-of-memory crash. The fix: Store every connection and disconnect during cleanup. Use a Trove (or Maid/Janitor) pattern to group connections per player and clean them all at once on PlayerRemoving.
-- Shared/Trove.luau (simplified cleanup utility)

local Trove = {}
Trove.__index = Trove

function Trove.new()
    return setmetatable({ _objects = {} }, Trove)
end

function Trove:Add(object: any): any
    table.insert(self._objects, object)
    return object
end

function Trove:Connect(signal: RBXScriptSignal, callback: (...any) -> ()): RBXScriptConnection
    local connection = signal:Connect(callback)
    table.insert(self._objects, connection)
    return connection
end

function Trove:Clean()
    for _, object in self._objects do
        if typeof(object) == "RBXScriptConnection" then
            object:Disconnect()
        elseif typeof(object) == "Instance" then
            object:Destroy()
        elseif typeof(object) == "function" then
            object()
        end
    end
    table.clear(self._objects)
end

return Trove
-- ServerScriptService/PlayerSetup.luau

local Players = game:GetService("Players")
local Trove = require(game.ReplicatedStorage.Shared.Trove)

local playerTroves: { [Player]: typeof(Trove.new()) } = {}

local function onPlayerAdded(player: Player)
    local trove = Trove.new()
    playerTroves[player] = trove

    trove:Connect(player.CharacterAdded, function(character)
        local humanoid = character:WaitForChild("Humanoid")

        -- This connection is also tracked — cleaned when the trove cleans
        trove:Connect(humanoid.Died, function()
            print(player.Name, "died — respawning in 3 seconds")
            task.wait(3)
            player:LoadCharacter()
        end)
    end)

    -- Track created instances too
    local billboard = trove:Add(Instance.new("BillboardGui"))
    billboard.Name = "PlayerLabel"
    billboard.Parent = player.Character and player.Character:FindFirstChild("Head")
end

local function onPlayerRemoving(player: Player)
    local trove = playerTroves[player]
    if trove then
        trove:Clean() -- Disconnects ALL connections, destroys ALL instances
        playerTroves[player] = nil
    end
end

Players.PlayerAdded:Connect(onPlayerAdded)
Players.PlayerRemoving:Connect(onPlayerRemoving)

SE-5 — RemoteEvent Flooding

RemoteEvents have no built-in rate limiting. An exploiter can fire a remote thousands of times per second. If your server handler does nontrivial work (DataStore calls, instance creation, raycasting), this floods the server and causes lag for every player — in extreme cases crashing the server entirely. The fix: Implement a per-player, per-remote rate limiter on the server. Drop requests that exceed the limit.
-- ServerScriptService/RateLimiter.luau

local RateLimiter = {}
RateLimiter.__index = RateLimiter

export type Config = {
    maxRequests: number,   -- Max requests allowed in the window
    windowSeconds: number, -- Time window in seconds
    kickAfter: number?,    -- Kick player after this many violations (nil = never)
}

function RateLimiter.new(config: Config)
    return setmetatable({
        _config = config,
        _playerData = {} :: { [Player]: { count: number, windowStart: number, violations: number } },
    }, RateLimiter)
end

function RateLimiter:check(player: Player): boolean
    local now  = os.clock()
    local data = self._playerData[player]

    if not data then
        data = { count = 0, windowStart = now, violations = 0 }
        self._playerData[player] = data
    end

    -- Reset window if expired
    if now - data.windowStart >= self._config.windowSeconds then
        data.count = 0
        data.windowStart = now
    end

    data.count += 1

    if data.count > self._config.maxRequests then
        data.violations += 1

        if self._config.kickAfter and data.violations >= self._config.kickAfter then
            player:Kick("Rate limit exceeded.")
        end

        return false -- Request denied
    end

    return true -- Request allowed
end

function RateLimiter:removePlayer(player: Player)
    self._playerData[player] = nil
end

return RateLimiter
-- ServerScriptService/CombatRemotes.luau

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players           = game:GetService("Players")
local RateLimiter       = require(script.Parent.RateLimiter)

local AttackRemote = ReplicatedStorage.Remotes.Attack :: RemoteEvent

-- Allow 10 attack requests per second; kick after 5 violations
local attackLimiter = RateLimiter.new({
    maxRequests   = 10,
    windowSeconds = 1,
    kickAfter     = 5,
})

AttackRemote.OnServerEvent:Connect(function(player: Player, targetId: number)
    if not attackLimiter:check(player) then
        return -- Silently drop the request
    end

    -- Validate and process the attack (server-authoritative)
    -- ...
end)

Players.PlayerRemoving:Connect(function(player)
    attackLimiter:removePlayer(player)
end)

SE-6 — BindToClose Timeout

game:BindToClose() gives you at most 30 seconds before Roblox forcefully shuts down the server. If you save player data sequentially (one player at a time) at 1-2 seconds each, you can only save 15-30 players before the timeout. In a 50-player server, the rest lose data on every update or maintenance window. The fix: Save all players in parallel using task.spawn. Collect all save threads and wait for completion or the 30-second deadline.
-- ServerScriptService/ShutdownHandler.luau

local Players    = game:GetService("Players")
local RunService = game:GetService("RunService")

local PlayerDataService = require(script.Parent.PlayerDataService)

local IS_STUDIO = RunService:IsStudio()

game:BindToClose(function()
    if IS_STUDIO then
        task.wait(1)
        return
    end

    local savingPlayers = Players:GetPlayers()
    if #savingPlayers == 0 then return end

    -- Save ALL players in parallel
    local completed = 0
    local total     = #savingPlayers

    for _, player in savingPlayers do
        task.spawn(function()
            -- ProfileService:Release() handles save + session unlock
            local profile = PlayerDataService.getProfile(player)
            if profile then
                profile:Release()
            end
            completed += 1
        end)
    end

    -- Wait until all saves complete OR we approach the 30s deadline
    local deadline = os.clock() + 27 -- Leave 3s buffer
    while completed < total and os.clock() < deadline do
        task.wait(0.1)
    end

    if completed < total then
        warn(string.format(
            "[Shutdown] Saved %d/%d players before timeout!",
            completed, total
        ))
    else
        print(string.format("[Shutdown] All %d players saved successfully.", total))
    end
end)

Medium and Low Severity Issues

Mobile devices (especially low-end Android phones and older iPads) have significantly less GPU and CPU headroom than desktop. A map with 50,000 parts may run at 60 FPS on desktop but 10 FPS on mobile, causing overheating and crashes.The fix: Keep visible part count under 10,000 for mobile targets. Enable StreamingEnabled to only load geometry near the player. Use MeshPart and unions to reduce draw calls. Set RenderFidelity = Automatic on MeshParts.
-- Workspace streaming configuration
local Workspace = game:GetService("Workspace")

-- StreamingEnabled must be enabled in Studio's Workspace properties
Workspace.StreamingMinRadius    = 128  -- Always load within 128 studs
Workspace.StreamingTargetRadius = 256  -- Try to load within 256 studs
Workspace.StreamingPauseMode    = Enum.StreamingPauseMode.ClientPhysicsPause

-- Mark distant decorative models as Opportunistic (unloads aggressively)
local distantMountain = Workspace:FindFirstChild("DistantMountain")
if distantMountain and distantMountain:IsA("Model") then
    distantMountain.ModelStreamingMode = Enum.ModelStreamingMode.Opportunistic
end

-- Mark critical gameplay areas as Persistent (never unloads)
local spawnArea = Workspace:FindFirstChild("SpawnArea")
if spawnArea and spawnArea:IsA("Model") then
    spawnArea.ModelStreamingMode = Enum.ModelStreamingMode.Persistent
end

-- Set Automatic LOD on all MeshParts
for _, meshPart in Workspace:GetDescendants() do
    if meshPart:IsA("MeshPart") then
        meshPart.RenderFidelity = Enum.RenderFidelity.Automatic
    end
end
When you require() a ModuleScript, Luau executes the module body synchronously on the requiring thread. If the body contains a yield (WaitForChild, task.wait, HTTP), every script that requires this module blocks until the yield resolves. If two modules require each other and both yield, you get a deadlock.The fix: Never yield in a module body. The body should only define functions and return a table. Use an explicit Init()/Start() lifecycle pattern called by a bootstrapper.
-- WRONG: Yields in module body block all requirers
-- local someInstance = workspace:WaitForChild("ImportantThing") -- BLOCKS!

-- CORRECT: No yields in body; use Init/Start lifecycle
-- ReplicatedStorage/Shared/CombatSystem.luau

local CombatSystem = {}

local weaponConfig: Folder? = nil
local remotes: Folder? = nil

-- Init: called by bootstrapper, safe to yield here
function CombatSystem:Init()
    local ReplicatedStorage = game:GetService("ReplicatedStorage")
    weaponConfig = ReplicatedStorage:WaitForChild("WeaponConfig", 10)
    remotes      = ReplicatedStorage:WaitForChild("Remotes", 10)

    if not weaponConfig then
        error("[CombatSystem] WeaponConfig folder not found!")
    end
end

-- Start: called after ALL modules have Init'd
function CombatSystem:Start()
    if remotes then
        local attackRemote = remotes:FindFirstChild("Attack")
        if attackRemote and attackRemote:IsA("RemoteEvent") then
            attackRemote.OnServerEvent:Connect(function(player, ...)
                CombatSystem:_handleAttack(player, ...)
            end)
        end
    end
end

function CombatSystem:_handleAttack(player: Player, targetId: number)
    -- Combat logic here
end

return CombatSystem
-- ServerScriptService/Bootstrap.server.luau

local modules = {
    require(script.Parent.Services.PlayerDataService),
    require(script.Parent.Services.CurrencyService),
    require(script.Parent.Services.CombatSystem),
}

-- Phase 1: Init all modules (order-independent setup)
for _, mod in modules do
    if mod.Init then mod:Init() end
end

-- Phase 2: Start all modules (gameplay logic, event connections)
for _, mod in modules do
    if mod.Start then mod:Start() end
end

print("[Bootstrap] All systems initialized and started.")
The # (length) operator is only reliable for sequence tables — arrays with consecutive integer keys starting at 1 and no nil gaps. Setting an element to nil creates a hole and makes # return any valid boundary unpredictably. This is defined behavior, not a bug.The fix: Never set array elements to nil. Use table.remove() to shift elements down. Use ipairs() for ordered iteration (it stops at the first nil). For sparse data, use a dictionary.
-- DANGEROUS: # on a table with nil gaps
local inventory = { "Sword", "Shield", nil, "Potion", "Bow" }
print(#inventory) -- Could print 2 or 5 — UNRELIABLE

-- SAFE PATTERN 1: Use table.remove instead of setting nil
local items = { "Sword", "Shield", "Helmet", "Potion" }
table.remove(items, 3) -- shifts "Potion" down to index 3
print(#items) -- Reliably prints 3

-- SAFE PATTERN 2: ipairs stops at the first nil
local data = { 10, 20, nil, 40 }
local sum = 0
for _, value in ipairs(data) do
    sum += value -- Only adds 10 + 20; stops before nil
end

-- SAFE PATTERN 3: Dictionary for sparse data
local equippedSlots: { [string]: string } = {
    Head = "Crown",
    -- Chest intentionally absent (no nil value stored)
    Legs = "Iron Greaves",
}
for slot, item in equippedSlots do
    print(slot, item)
end
The legacy globals wait(), spawn(), and delay() are deprecated and have significant problems: wait() has a minimum yield of ~0.03 seconds regardless of the argument; spawn() defers execution unpredictably and swallows errors silently; delay() compounds these timing issues.
LegacyReplacementBehavior
wait(n)task.wait(n)Resumes after n seconds (min 1 frame)
spawn(fn)task.spawn(fn)Runs immediately on a new thread
delay(n, fn)task.delay(n, fn)Runs fn after n seconds
task.defer(fn)Runs next resumption cycle
task.cancel(th)Cancels a thread from task.delay/defer
-- DEPRECATED: do not use
-- wait(1)
-- spawn(function() doWork() end)
-- delay(5, function() cleanup() end)

-- CORRECT: task library equivalents
task.wait(1)

task.spawn(function()
    doExpensiveWork() -- errors properly propagate to output
end)

task.delay(5, function()
    performCleanup()
end)

-- Cancel a delayed thread
local delayedThread = task.delay(10, function()
    print("This will never print")
end)
task.cancel(delayedThread)

-- Practical countdown timer with precise 1-second intervals
local function startCountdown(seconds: number, onTick: (remaining: number) -> ())
    return task.spawn(function()
        for i = seconds, 1, -1 do
            onTick(i)
            task.wait(1)
        end
        onTick(0)
    end)
end
Instance:WaitForChild(name) without a timeout parameter yields the current thread forever if the child never appears. Roblox prints an “Infinite yield possible” warning after 5 seconds, but the thread stays stuck. This is especially common when a developer renames an instance but forgets to update the reference, or when StreamingEnabled is on and the instance hasn’t streamed in yet.The fix: Always pass a timeout to WaitForChild. Handle the nil return when the timeout expires. Fail fast with a meaningful error rather than hanging silently.
-- DANGEROUS: No timeout — hangs forever if "WeaponSystem" doesn't exist
-- local weaponSystem = ReplicatedStorage:WaitForChild("WeaponSystem")

-- SAFE: Timeout with error handling
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local WAIT_TIMEOUT = 5 -- seconds

local function safeWaitForChild(
    parent: Instance,
    childName: string,
    timeout: number?
): Instance?
    local child = parent:WaitForChild(childName, timeout or WAIT_TIMEOUT)

    if not child then
        warn(string.format(
            "[safeWaitForChild] '%s' not found in '%s' after %d seconds. "
                .. "Check that it exists and is spelled correctly.",
            childName,
            parent:GetFullName(),
            timeout or WAIT_TIMEOUT
        ))
    end

    return child
end

-- Usage
local weaponFolder = safeWaitForChild(ReplicatedStorage, "Weapons", 10)
if not weaponFolder then
    error("[Init] Cannot start without Weapons folder!")
end

-- For StreamingEnabled: use a generous timeout
local function waitForStreamedModel(name: string): Model?
    local model = safeWaitForChild(workspace, name, 30)
    if not model then
        warn(name, "did not stream in within 30 seconds")
    end
    return model :: Model?
end
Luau uses Lua string patterns, not regular expressions. Developers coming from JavaScript, Python, or other languages instinctively write \d, \w, \s — these return nil in Luau. The escape character is %, not \.
RegexLuau PatternMeaning
\d%dDigit (0–9)
\D%DNon-digit
\w%wAlphanumeric
\W%WNon-alphanumeric
\s%sWhitespace
\.%.Literal dot
[a-z]%lLowercase letter
[A-Z]%uUppercase letter
[a-zA-Z]%aAny letter
*?-Lazy / non-greedy
\(%(Literal parenthesis
local testString = "Player_123 scored 456 points on 2026-03-04!"

-- WRONG (regex habits — returns nil):
-- string.match(testString, "\\d+")
-- string.match(testString, "\\w+")

-- CORRECT (Luau patterns):

-- Extract first number
local firstNumber = string.match(testString, "%d+")
print(firstNumber) -- "123"

-- Extract all numbers
for num in string.gmatch(testString, "%d+") do
    print("Found number:", num) -- "123", "456", "2026", "03", "04"
end

-- Extract a date (YYYY-MM-DD)
local year, month, day = string.match(testString, "(%d+)-(%d+)-(%d+)")
print(year, month, day) -- "2026", "03", "04"

-- Greedy vs lazy
local htmlish = "<tag>content</tag>"
local greedy = string.match(htmlish, "<(.+)>")  -- "tag>content</tag"
local lazy   = string.match(htmlish, "<(.-)>")  -- "tag"

-- Escaping special characters: use % not \
local version = "v2.5.1"
local major, minor, patch = string.match(version, "v(%d+)%.(%d+)%.(%d+)")
print(major, minor, patch) -- "2", "5", "1"

-- Literal percent sign requires %%
local discount = "Save 20% today!"
local pct = string.match(discount, "(%d+)%%")
print(pct) -- "20"

Quick Reference

CRITICAL — fix before shipping:
  SE-1  DataStore session locking     → Use ProfileService
  SE-2  Client-side currency          → Server-authoritative only
  SE-3  ProcessReceipt order          → Grant THEN return PurchaseGranted

HIGH — fix in current sprint:
  SE-4  Undisconnected events         → Trove/Maid pattern
  SE-5  RemoteEvent flooding          → Per-player rate limiter
  SE-6  BindToClose 30s timeout       → Parallel saves with task.spawn

MEDIUM — fix before scale:
  SE-7  Mobile part count             → StreamingEnabled + <10K parts
  SE-8  Yielding in module require    → Init/Start lifecycle pattern
  SE-9  Table # with nil gaps         → table.remove; never set nil in arrays
  SE-11 Infinite yield WaitForChild   → Always pass timeout parameter

LOW — fix when convenient:
  SE-10 Deprecated wait/spawn/delay   → task.wait / task.spawn / task.delay
  SE-12 String patterns vs regex      → %d not \d; % not \ as escape

Build docs developers (and LLMs) love