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.
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 datalocal 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]endfunction 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) endendreturn PlayerDataService
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 clientlocal CurrencyChanged = ReplicatedStorage.Remotes.CurrencyChanged :: RemoteEventlocal 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 trueend-- 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 trueendfunction CurrencyService.getCoins(player: Player): number local profile = PlayerDataService.getProfile(player) return if profile then profile.Data.Coins else 0endreturn 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 :: RemoteEventlocal localPlayer = Players.LocalPlayerlocal 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) endend)
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 PurchaseGrantedbefore 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:
Check idempotency (was this PurchaseId already granted?)
Grant the item/currency
Save the PurchaseId to prevent future double-grants
Only then return PurchaseGranted
If anything fails before step 4, return NotProcessedYet
-- ServerScriptService/GamepassAndProductHandler.luaulocal 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.PurchaseGrantedendMarketplaceService.ProcessReceipt = processReceipt
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 = Trovefunction Trove.new() return setmetatable({ _objects = {} }, Trove)endfunction Trove:Add(object: any): any table.insert(self._objects, object) return objectendfunction Trove:Connect(signal: RBXScriptSignal, callback: (...any) -> ()): RBXScriptConnection local connection = signal:Connect(callback) table.insert(self._objects, connection) return connectionendfunction 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)endreturn Trove
-- ServerScriptService/PlayerSetup.luaulocal 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")endlocal function onPlayerRemoving(player: Player) local trove = playerTroves[player] if trove then trove:Clean() -- Disconnects ALL connections, destroys ALL instances playerTroves[player] = nil endendPlayers.PlayerAdded:Connect(onPlayerAdded)Players.PlayerRemoving:Connect(onPlayerRemoving)
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.luaulocal RateLimiter = {}RateLimiter.__index = RateLimiterexport 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)endfunction 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 allowedendfunction RateLimiter:removePlayer(player: Player) self._playerData[player] = nilendreturn RateLimiter
-- ServerScriptService/CombatRemotes.luaulocal 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 violationslocal 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)
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.luaulocal 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)) endend)
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 configurationlocal Workspace = game:GetService("Workspace")-- StreamingEnabled must be enabled in Studio's Workspace propertiesWorkspace.StreamingMinRadius = 128 -- Always load within 128 studsWorkspace.StreamingTargetRadius = 256 -- Try to load within 256 studsWorkspace.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.Opportunisticend-- Mark critical gameplay areas as Persistent (never unloads)local spawnArea = Workspace:FindFirstChild("SpawnArea")if spawnArea and spawnArea:IsA("Model") then spawnArea.ModelStreamingMode = Enum.ModelStreamingMode.Persistentend-- Set Automatic LOD on all MeshPartsfor _, meshPart in Workspace:GetDescendants() do if meshPart:IsA("MeshPart") then meshPart.RenderFidelity = Enum.RenderFidelity.Automatic endend
SE-8 (Medium) — Yielding in Module Require
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.luaulocal CombatSystem = {}local weaponConfig: Folder? = nillocal remotes: Folder? = nil-- Init: called by bootstrapper, safe to yield herefunction 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!") endend-- Start: called after ALL modules have Init'dfunction 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 endendfunction CombatSystem:_handleAttack(player: Player, targetId: number) -- Combat logic hereendreturn CombatSystem
-- ServerScriptService/Bootstrap.server.luaulocal 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() endend-- Phase 2: Start all modules (gameplay logic, event connections)for _, mod in modules do if mod.Start then mod:Start() endendprint("[Bootstrap] All systems initialized and started.")
SE-9 (Medium) — Table Length with Nil Gaps
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 gapslocal inventory = { "Sword", "Shield", nil, "Potion", "Bow" }print(#inventory) -- Could print 2 or 5 — UNRELIABLE-- SAFE PATTERN 1: Use table.remove instead of setting nillocal items = { "Sword", "Shield", "Helmet", "Potion" }table.remove(items, 3) -- shifts "Potion" down to index 3print(#items) -- Reliably prints 3-- SAFE PATTERN 2: ipairs stops at the first nillocal data = { 10, 20, nil, 40 }local sum = 0for _, value in ipairs(data) do sum += value -- Only adds 10 + 20; stops before nilend-- SAFE PATTERN 3: Dictionary for sparse datalocal 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.
Legacy
Replacement
Behavior
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 equivalentstask.wait(1)task.spawn(function() doExpensiveWork() -- errors properly propagate to outputend)task.delay(5, function() performCleanup()end)-- Cancel a delayed threadlocal delayedThread = task.delay(10, function() print("This will never print")end)task.cancel(delayedThread)-- Practical countdown timer with precise 1-second intervalslocal 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
SE-11 (Medium) — Infinite Yield on WaitForChild
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 handlinglocal ReplicatedStorage = game:GetService("ReplicatedStorage")local WAIT_TIMEOUT = 5 -- secondslocal 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 childend-- Usagelocal weaponFolder = safeWaitForChild(ReplicatedStorage, "Weapons", 10)if not weaponFolder then error("[Init] Cannot start without Weapons folder!")end-- For StreamingEnabled: use a generous timeoutlocal 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
SE-12 (Low) — String Patterns vs Regex
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 \.
Regex
Luau Pattern
Meaning
\d
%d
Digit (0–9)
\D
%D
Non-digit
\w
%w
Alphanumeric
\W
%W
Non-alphanumeric
\s
%s
Whitespace
\.
%.
Literal dot
[a-z]
%l
Lowercase letter
[A-Z]
%u
Uppercase letter
[a-zA-Z]
%a
Any 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 numberlocal firstNumber = string.match(testString, "%d+")print(firstNumber) -- "123"-- Extract all numbersfor 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 lazylocal 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"