roblox-sharp-edges

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese
<!-- Source: brockmartin/roblox-game-skill (MIT) -->
<!-- 来源:brockmartin/roblox-game-skill(MIT协议) -->

Roblox Sharp Edges (Gotchas) Reference

Roblox 常见陷阱参考手册

Every entry here represents a real production footgun that has caused data loss, exploits, crashes, or hours of debugging in Roblox games.
Severity Levels:
  • Critical - Data loss, security breach, or revenue loss. Fix before shipping.
  • High - Server instability, degraded experience, or exploit surface. Fix in current sprint.
  • Medium - Correctness bugs, performance issues, or dev confusion. Fix before scale.
  • Low - Code quality, maintainability, or minor timing issues. Fix when convenient.

这里的每一条记录都代表Roblox游戏中真实发生过的生产级陷阱问题,曾导致数据丢失、漏洞利用、崩溃或数小时的调试工作。
严重程度等级:
  • 严重 - 数据丢失、安全 breach 或收入损失。上线前必须修复。
  • - 服务器不稳定、体验下降或存在漏洞风险。当前迭代必须修复。
  • - 正确性bug、性能问题或开发者困惑。规模化前必须修复。
  • - 代码质量、可维护性或轻微时序问题。方便时修复。

SE-1 | Critical | DataStore Data Loss from Session Handling

SE-1 | 严重 | 会话处理不当导致DataStore数据丢失

See roblox-data → Session Locking and ProfileStore for full details.
When a player server-hops, the old server may still be saving while the new server loads stale data. ProfileStore handles session locking automatically - only one server owns a player's data at a time. Never use raw DataStoreService for player data without session locking.

详情请查看 roblox-data → Session Locking and ProfileStore。
当玩家跨服务器跳转时,旧服务器可能仍在保存数据,而新服务器加载的是过期数据。ProfileStore会自动处理会话锁定——同一时间只有一个服务器拥有玩家数据的控制权。绝不使用未加会话锁定的原生DataStoreService来存储玩家数据。

SE-2 | Critical | Client-Side Currency Manipulation

SE-2 | 严重 | 客户端货币篡改

See roblox-networking → Security Hardening for full details.
Currency and all authoritative game state must live exclusively on the server. Never accept currency amounts from the client. The server computes all transactions internally and pushes display-only updates to the client. This is the single most common exploit in Roblox games.

详情请查看 roblox-networking → Security Hardening。
货币及所有权威游戏状态必须完全存储在服务器端。绝不要接受客户端传来的货币数值。服务器应在内部计算所有交易,仅向客户端推送用于展示的更新。这是Roblox游戏中最常见的漏洞类型。

SE-3 | Critical | ProcessReceipt Mishandling

SE-3 | 严重 | ProcessReceipt处理不当

See roblox-monetization → ProcessReceipt for full details.
MarketplaceService.ProcessReceipt
must return
PurchaseGranted
ONLY after the item is granted AND saved. If you return
PurchaseGranted
before granting, the player loses Robux. If you don't return it, Roblox retries on every join - potentially granting duplicates. Grant first, save second, return third.

详情请查看 roblox-monetization → ProcessReceipt。
MarketplaceService.ProcessReceipt
必须在道具发放完成且保存成功后,才能返回
PurchaseGranted
。如果在发放前返回该值,玩家会损失Robux;如果不返回,Roblox会在玩家每次登录时重试——可能导致重复发放道具。正确顺序是:先发放道具,再保存数据,最后返回结果。

SE-4 | High | Memory Leaks from Undisconnected Events

SE-4 | 高 | 未断开事件导致内存泄漏

Problem

问题

Every
:Connect()
returns an
RBXScriptConnection
. If you never
:Disconnect()
it, the connection persists for the script's lifetime - even after the object is destroyed. In per-player systems, memory grows linearly with every player who has ever joined.
每个
:Connect()
都会返回一个
RBXScriptConnection
对象。如果从不调用
:Disconnect()
,该连接会在脚本生命周期内持续存在——即使关联对象已被销毁。在按玩家实例化的系统中,内存会随着每一位加入过的玩家线性增长。

Symptoms

症状

  • Server memory climbs steadily over time.
  • Server FPS degrades after hours.
  • Callbacks fire for players who left.
  • 服务器内存持续攀升
  • 服务器FPS在数小时后下降
  • 已离开玩家的回调仍会触发

Solution

解决方案

Use the Trove module (Sleitnick/RbxUtil, install via Wally) to group connections per-player and clean them all on
PlayerRemoving
:
luau
local Players = game:GetService("Players")
local Trove = require(game.ReplicatedStorage.Packages.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")
        trove:Connect(humanoid.Died, function()
            task.wait(3)
            player:LoadCharacter()
        end)
    end)
end

local function onPlayerRemoving(player: Player)
    local trove = playerTroves[player]
    if trove then
        trove:Clean()
        playerTroves[player] = nil
    end
end

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

使用Trove模块(Sleitnick/RbxUtil,可通过Wally安装)按玩家分组管理连接,并在
PlayerRemoving
时统一清理:
luau
local Players = game:GetService("Players")
local Trove = require(game.ReplicatedStorage.Packages.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")
        trove:Connect(humanoid.Died, function()
            task.wait(3)
            player:LoadCharacter()
        end)
    end)
end

local function onPlayerRemoving(player: Player)
    local trove = playerTroves[player]
    if trove then
        trove:Clean()
        playerTroves[player] = nil
    end
end

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

SE-5 | High | RemoteEvent Flooding

SE-5 | 高 | RemoteEvent 洪水攻击

Problem

问题

RemoteEvents have no built-in rate limiting. Exploiters can fire thousands of times per second, flooding the server with DataStore calls, instance creation, or raycasts.
RemoteEvent没有内置的速率限制。攻击者每秒可触发数千次请求,导致服务器被DataStore调用、实例创建或射线检测请求淹没。

Solution

解决方案

Implement per-player, per-remote rate limiting on the server. See roblox-networking → Rate Limiting for production patterns.
Minimal inline example:
luau
local lastFire: { [Player]: number } = {}
local COOLDOWN = 0.1

AttackRemote.OnServerEvent:Connect(function(player: Player, targetId: number)
    local now = os.clock()
    if lastFire[player] and now - lastFire[player] < COOLDOWN then return end
    lastFire[player] = now
    -- process attack
end)

在服务器端为每个玩家、每个RemoteEvent实现速率限制。查看roblox-networking → Rate Limiting获取生产环境可用的实现模式。
极简内联示例:
luau
local lastFire: { [Player]: number } = {}
local COOLDOWN = 0.1

AttackRemote.OnServerEvent:Connect(function(player: Player, targetId: number)
    local now = os.clock()
    if lastFire[player] and now - lastFire[player] < COOLDOWN then return end
    lastFire[player] = now
    -- 处理攻击逻辑
end)

SE-6 | High | BindToClose Timeout

SE-6 | 高 | BindToClose 超时

See roblox-data → Best Practices (BindToClose Handler) for full details.
game:BindToClose()
gives at most 30 seconds. If using ProfileStore, this is automatic. If using raw DataStore, save all players in parallel with
task.spawn
- sequential saves with 50 players will timeout.

详情请查看 roblox-data → Best Practices (BindToClose Handler)。
game:BindToClose()
最多提供30秒时间。如果使用ProfileStore,该过程会自动处理。如果使用原生DataStore,需用
task.spawn
并行保存所有玩家数据——若按顺序保存50位玩家的数据,必然会超时。

SE-7 | Medium | Part Count on Mobile

SE-7 | 中 | 移动端零件数量问题

Mobile devices struggle above ~10,000 visible parts. Enable StreamingEnabled and configure
StreamingMinRadius
/
StreamingTargetRadius
. Use
ModelStreamingMode
to mark distant models as Opportunistic and gameplay-critical models as Persistent.
See roblox-runtime → StreamingEnabled for configuration details.

移动设备在可见零件数量超过约10000个时会出现性能问题。启用StreamingEnabled并配置
StreamingMinRadius
/
StreamingTargetRadius
。使用
ModelStreamingMode
将远处模型标记为Opportunistic(按需加载),将游戏关键模型标记为Persistent(持续加载)。
查看roblox-runtime → StreamingEnabled获取配置详情。

SE-8 | Medium | Yielding in Module Require

SE-8 | 中 | 模块Require时阻塞

Problem

问题

require()
executes the module body synchronously. If it yields (
WaitForChild
,
task.wait
, HTTP), every script requiring that module blocks. Two modules requiring each other with yields = deadlock.
require()
会同步执行模块代码。如果模块代码中包含阻塞操作(如
WaitForChild
task.wait
、HTTP请求),所有引用该模块的脚本都会被阻塞。若两个模块相互引用且都包含阻塞操作,会导致死锁。

Solution

解决方案

Never yield in a module body. Use Init/Start lifecycle:
luau
local CombatSystem = {}

function CombatSystem:Init()
    -- WaitForChild is safe here (called by bootstrap, not during require)
    self._remotes = game.ReplicatedStorage:WaitForChild("Remotes", 10)
end

function CombatSystem:Start()
    -- Connect events after all modules are Init'd
end

return CombatSystem
Bootstrap script calls
:Init()
on all modules, then
:Start()
on all modules.

绝不在模块主体中执行阻塞操作。使用Init/Start生命周期模式:
luau
local CombatSystem = {}

function CombatSystem:Init()
    -- WaitForChild在此处是安全的(由启动脚本调用,而非require阶段)
    self._remotes = game.ReplicatedStorage:WaitForChild("Remotes", 10)
end

function CombatSystem:Start()
    -- 所有模块初始化完成后再连接事件
end

return CombatSystem
启动脚本先调用所有模块的
:Init()
方法,再调用所有模块的
:Start()
方法。

SE-9 | Medium | Table Length with Nil Gaps

SE-9 | 中 | 含Nil间隙的表格长度问题

Problem

问题

#
is only reliable for sequence tables (consecutive integer keys, no nil gaps). Setting
tbl[3] = nil
creates a hole;
#tbl
may return any valid boundary.
#
运算符仅对序列表格(连续整数键,无Nil间隙)有效。设置
tbl[3] = nil
会造成间隙;
#tbl
可能返回任意有效边界值。

Solution

解决方案

  • Never set array elements to
    nil
    . Use
    table.remove()
    to shift elements.
  • Use generalized iteration (
    for _, v in tbl do
    ) instead of
    for i = 1, #tbl
    .
  • For sparse data, use dictionary keys instead of integer indices.

  • 绝不将数组元素设为Nil。使用
    table.remove()
    来移动元素。
  • 使用通用迭代方式(
    for _, v in tbl do
    )替代
    for i = 1, #tbl
  • 对于稀疏数据,使用字典键而非整数索引。

SE-10 | Low | Deprecated wait()/spawn()/delay()

SE-10 | 低 | 已废弃的wait()/spawn()/delay()

See roblox-luau-mastery → Task Library for full details.
Replace
wait()
task.wait()
,
spawn()
task.spawn()
,
delay()
task.delay()
. Legacy functions have minimum yield issues, unpredictable timing, and swallow errors.

详情请查看 roblox-luau-mastery → Task Library。
wait()
替换为
task.wait()
spawn()
替换为
task.spawn()
delay()
替换为
task.delay()
。旧版函数存在最小阻塞时间问题、时序不可预测且会吞掉错误。

SE-11 | Medium | Infinite Yield Warning

SE-11 | 中 | 无限阻塞警告

Problem

问题

WaitForChild(name)
without a timeout yields forever if the child never appears. Common with renamed instances, StreamingEnabled, or race conditions.
不带超时参数的
WaitForChild(name)
会在子对象永远不存在时无限阻塞。常见于实例重命名、启用StreamingEnabled或存在竞态条件的场景。

Solution

解决方案

Always pass a timeout. Handle
nil
return:
luau
local folder = ReplicatedStorage:WaitForChild("Weapons", 10)
if not folder then
    warn("[Init] Weapons folder not found after 10s")
    return
end

始终传入超时参数,并处理返回值为Nil的情况:
luau
local folder = ReplicatedStorage:WaitForChild("Weapons", 10)
if not folder then
    warn("[初始化] 10秒后仍未找到Weapons文件夹")
    return
end

SE-12 | Low | String Patterns vs Regex

SE-12 | 低 | 字符串模式与正则表达式的区别

Problem

问题

Luau uses Lua string patterns, not regex.
\d
doesn't work - use
%d
. Escape with
%
not
\
. No alternation (
|
), no non-greedy
*?
(use
-
instead), no lookahead.
Luau使用Lua字符串模式,而非正则表达式。
\d
无法生效——需使用
%d
。用
%
而非
\
转义特殊字符。不支持分支(
|
)、非贪婪匹配
*?
(需用
-
替代)、前瞻断言。

Key Differences

核心差异

  • Digits:
    %d
    not
    \d
  • Word chars:
    %w
    not
    \w
  • Whitespace:
    %s
    not
    \s
  • Escape special chars:
    %.
    not
    \.
  • Non-greedy:
    .-
    not
    .*?
  • Literal
    %
    :
    %%

  • 数字:
    %d
    而非
    \d
  • 单词字符:
    %w
    而非
    \w
  • 空白字符:
    %s
    而非
    \s
  • 转义特殊字符:
    %.
    而非
    \.
  • 非贪婪匹配:
    .-
    而非
    .*?
  • 字面量
    %
    %%

SE-13 | Medium | Local Function Declaration Order

SE-13 | 中 | 局部函数声明顺序

Problem

问题

Luau has no hoisting. A
local function
is invisible to code above its declaration. AI assistants frequently place helper functions below the functions that call them, causing nil-value runtime errors.
Luau不支持变量提升。
local function
在其声明位置之前的代码中不可见。AI助手常将辅助函数放在调用它的函数之后,导致运行时出现Nil值错误。

Rule

规则

Callees above callers. Always. If
functionA()
calls
helperB()
, then
helperB
must be declared first.
luau
-- BAD: helperB is nil when functionA runs
local function functionA()
    helperB() -- ERROR: attempt to call a nil value
end

local function helperB()
    print("I'm a helper")
end

-- GOOD: helper declared first
local function helperB()
    print("I'm a helper")
end

local function functionA()
    helperB() -- works
end
被调用函数必须在调用函数之前声明。 如果
functionA()
调用
helperB()
,则
helperB
必须先声明。
luau
-- 错误:functionA运行时helperB为Nil
local function functionA()
    helperB() -- 错误:尝试调用Nil值
end

local function helperB()
    print("我是辅助函数")
end

-- 正确:辅助函数先声明
local function helperB()
    print("我是辅助函数")
end

local function functionA()
    helperB() -- 正常运行
end

When you need mutual recursion

处理递归调用

Use forward declaration:
luau
local functionB -- forward declare
local function functionA()
    functionB()
end
function functionB() -- note: no 'local' (already declared above)
    functionA()
end

使用前置声明:
luau
local functionB -- 前置声明
local function functionA()
    functionB()
end
function functionB() -- 注意:此处无需加'local'(已在上方声明)
    functionA()
end

Quick Reference

快速参考

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

HIGH (fix in current sprint):
  SE-4  Undisconnected events            → Trove pattern (RbxUtil)
  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 or explicit length
  SE-11 Infinite yield WaitForChild      → Always pass timeout parameter
  SE-13 Local function order             → Callees above callers (no hoisting)

LOW (fix when convenient):
  SE-10 Deprecated wait/spawn/delay      → task.wait/spawn/delay
  SE-12 String patterns vs regex         → %d not \d, % not \
严重(上线前必须修复):
  SE-1  DataStore会话锁定        → 使用ProfileStore
  SE-2  客户端货币处理          → 仅由服务器权威控制
  SE-3  ProcessReceipt执行顺序   → 先发放道具再返回PurchaseGranted

高(当前迭代必须修复):
  SE-4  未断开事件连接          → 使用Trove模式(RbxUtil)
  SE-5  RemoteEvent洪水攻击      → 实现按玩家速率限制
  SE-6  BindToClose 30秒超时     → 用task.spawn并行保存数据

中(规模化前必须修复):
  SE-7  移动端零件数量          → 启用StreamingEnabled + 控制在10000个以内
  SE-8  模块Require时阻塞       → 使用Init/Start生命周期模式
  SE-9  含Nil间隙的表格#运算     → 使用table.remove或显式长度计算
  SE-11 WaitForChild无限阻塞    → 始终传入超时参数
  SE-13 局部函数声明顺序        → 被调用函数在前(无变量提升)

低(方便时修复):
  SE-10 已废弃的wait/spawn/delay → 替换为task.wait/spawn/delay
  SE-12 字符串模式与正则的区别   → 使用%d而非\d,%而非\