roblox-opencode 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -122
- package/commands/setup-game.md +108 -108
- package/commands/sync-check.md +53 -53
- package/core/roblox-core.md +93 -93
- package/dist/server.js +189 -167
- package/package.json +35 -35
- package/skills/roblox-analytics/SKILL.md +277 -277
- package/skills/roblox-analytics/references/event-batcher.luau +75 -75
- package/skills/roblox-animation-vfx/SKILL.md +1325 -1325
- package/skills/roblox-architecture/SKILL.md +877 -863
- package/skills/roblox-architecture/references/combat-systems.md +1381 -1381
- package/skills/roblox-code-review/SKILL.md +686 -686
- package/skills/roblox-data/SKILL.md +889 -889
- package/skills/roblox-data/references/inventory-systems.md +1729 -1729
- package/skills/roblox-debug/SKILL.md +98 -98
- package/skills/roblox-gui/SKILL.md +1103 -1103
- package/skills/roblox-gui-fusion/SKILL.md +150 -150
- package/skills/roblox-gui-fusion/references/inventory.luau +427 -427
- package/skills/roblox-gui-fusion/references/settings-menu.luau +579 -579
- package/skills/roblox-gui-fusion/references/shop.luau +411 -411
- package/skills/roblox-luau-mastery/SKILL.md +1618 -1519
- package/skills/roblox-monetization/SKILL.md +1084 -1084
- package/skills/roblox-monetization/references/process-receipt.luau +131 -131
- package/skills/roblox-networking/SKILL.md +669 -669
- package/skills/roblox-networking/references/remote-validator.luau +193 -193
- package/skills/roblox-publish-checklist/SKILL.md +127 -127
- package/skills/roblox-runtime/SKILL.md +753 -753
- package/skills/roblox-sharp-edges/SKILL.md +294 -294
- package/skills/roblox-sync/SKILL.md +126 -126
- package/skills/roblox-testing/SKILL.md +943 -943
- package/skills/roblox-tooling/SKILL.md +149 -149
- package/vendor/LICENSES/ProfileStore-LICENSE +201 -201
- package/vendor/LICENSES/RbxUtil-LICENSE +7 -7
- package/vendor/LICENSES/promise-LICENSE +20 -20
- package/vendor/LICENSES/t-LICENSE +21 -21
- package/vendor/LICENSES/testez-LICENSE +200 -200
- package/vendor/README.md +83 -83
- package/vendor/fusion/Animation/ExternalTime.luau +83 -83
- package/vendor/fusion/Animation/Spring.luau +321 -321
- package/vendor/fusion/Animation/Stopwatch.luau +127 -127
- package/vendor/fusion/Animation/Tween.luau +187 -187
- package/vendor/fusion/Animation/getTweenDuration.luau +27 -27
- package/vendor/fusion/Animation/getTweenRatio.luau +47 -47
- package/vendor/fusion/Animation/lerpType.luau +163 -163
- package/vendor/fusion/Animation/packType.luau +99 -99
- package/vendor/fusion/Animation/springCoefficients.luau +80 -80
- package/vendor/fusion/Animation/unpackType.luau +102 -102
- package/vendor/fusion/Colour/Oklab.luau +70 -70
- package/vendor/fusion/Colour/sRGB.luau +54 -54
- package/vendor/fusion/External.luau +167 -167
- package/vendor/fusion/ExternalDebug.luau +69 -69
- package/vendor/fusion/Graph/Observer.luau +113 -113
- package/vendor/fusion/Graph/castToGraph.luau +28 -28
- package/vendor/fusion/Graph/change.luau +80 -80
- package/vendor/fusion/Graph/depend.luau +32 -32
- package/vendor/fusion/Graph/evaluate.luau +55 -55
- package/vendor/fusion/Instances/Attribute.luau +57 -57
- package/vendor/fusion/Instances/AttributeChange.luau +46 -46
- package/vendor/fusion/Instances/AttributeOut.luau +63 -63
- package/vendor/fusion/Instances/Child.luau +21 -21
- package/vendor/fusion/Instances/Children.luau +147 -147
- package/vendor/fusion/Instances/Hydrate.luau +32 -32
- package/vendor/fusion/Instances/New.luau +52 -52
- package/vendor/fusion/Instances/OnChange.luau +49 -49
- package/vendor/fusion/Instances/OnEvent.luau +53 -53
- package/vendor/fusion/Instances/Out.luau +69 -69
- package/vendor/fusion/Instances/applyInstanceProps.luau +148 -148
- package/vendor/fusion/Instances/defaultProps.luau +194 -194
- package/vendor/fusion/LICENSE +21 -21
- package/vendor/fusion/Logging/formatError.luau +48 -48
- package/vendor/fusion/Logging/messages.luau +51 -51
- package/vendor/fusion/Logging/parseError.luau +24 -24
- package/vendor/fusion/Memory/checkLifetime.luau +133 -133
- package/vendor/fusion/Memory/deriveScope.luau +23 -23
- package/vendor/fusion/Memory/deriveScopeImpl.luau +44 -44
- package/vendor/fusion/Memory/doCleanup.luau +78 -78
- package/vendor/fusion/Memory/innerScope.luau +33 -33
- package/vendor/fusion/Memory/legacyCleanup.luau +17 -17
- package/vendor/fusion/Memory/needsDestruction.luau +16 -16
- package/vendor/fusion/Memory/poisonScope.luau +33 -33
- package/vendor/fusion/Memory/scopePool.luau +54 -54
- package/vendor/fusion/Memory/scoped.luau +26 -26
- package/vendor/fusion/Memory/whichLivesLonger.luau +74 -74
- package/vendor/fusion/RobloxExternal.luau +97 -97
- package/vendor/fusion/State/Computed.luau +138 -138
- package/vendor/fusion/State/For/Disassembly.luau +210 -210
- package/vendor/fusion/State/For/ForTypes.luau +30 -30
- package/vendor/fusion/State/For/init.luau +109 -109
- package/vendor/fusion/State/ForKeys.luau +93 -93
- package/vendor/fusion/State/ForPairs.luau +96 -96
- package/vendor/fusion/State/ForValues.luau +93 -93
- package/vendor/fusion/State/Value.luau +87 -87
- package/vendor/fusion/State/castToState.luau +25 -25
- package/vendor/fusion/State/peek.luau +30 -30
- package/vendor/fusion/Types.luau +314 -314
- package/vendor/fusion/Utility/Contextual.luau +90 -90
- package/vendor/fusion/Utility/Safe.luau +22 -22
- package/vendor/fusion/Utility/isSimilar.luau +29 -29
- package/vendor/fusion/Utility/merge.luau +35 -35
- package/vendor/fusion/Utility/nameOf.luau +34 -34
- package/vendor/fusion/Utility/never.luau +13 -13
- package/vendor/fusion/Utility/nicknames.luau +10 -10
- package/vendor/fusion/Utility/xtypeof.luau +26 -26
- package/vendor/fusion/init.luau +82 -82
- package/vendor/profilestore/init.luau +2242 -2242
- package/vendor/promise/init.luau +1982 -1982
- package/vendor/rbxutil/buffer-util/Buffer.test.luau +25 -25
- package/vendor/rbxutil/buffer-util/BufferReader.luau +228 -228
- package/vendor/rbxutil/buffer-util/BufferWriter.luau +269 -269
- package/vendor/rbxutil/buffer-util/DataTypeBuffer.luau +223 -223
- package/vendor/rbxutil/buffer-util/Types.luau +60 -60
- package/vendor/rbxutil/buffer-util/index.d.ts +153 -153
- package/vendor/rbxutil/buffer-util/init.luau +41 -41
- package/vendor/rbxutil/buffer-util/package.json +16 -16
- package/vendor/rbxutil/buffer-util/wally.toml +9 -9
- package/vendor/rbxutil/comm/Client/ClientComm.luau +232 -232
- package/vendor/rbxutil/comm/Client/ClientRemoteProperty.luau +156 -156
- package/vendor/rbxutil/comm/Client/ClientRemoteSignal.luau +109 -109
- package/vendor/rbxutil/comm/Client/init.luau +135 -135
- package/vendor/rbxutil/comm/Server/RemoteProperty.luau +295 -295
- package/vendor/rbxutil/comm/Server/RemoteSignal.luau +211 -211
- package/vendor/rbxutil/comm/Server/ServerComm.luau +211 -211
- package/vendor/rbxutil/comm/Server/init.luau +140 -140
- package/vendor/rbxutil/comm/Types.luau +18 -18
- package/vendor/rbxutil/comm/Util.luau +27 -27
- package/vendor/rbxutil/comm/init.luau +35 -35
- package/vendor/rbxutil/comm/wally.toml +13 -13
- package/vendor/rbxutil/component/init.luau +759 -759
- package/vendor/rbxutil/component/init.test.luau +311 -311
- package/vendor/rbxutil/component/wally.toml +14 -14
- package/vendor/rbxutil/concur/init.luau +542 -542
- package/vendor/rbxutil/concur/init.test.luau +364 -364
- package/vendor/rbxutil/concur/wally.toml +8 -8
- package/vendor/rbxutil/enum-list/init.luau +101 -101
- package/vendor/rbxutil/enum-list/init.test.luau +91 -91
- package/vendor/rbxutil/enum-list/wally.toml +8 -8
- package/vendor/rbxutil/find/index.d.ts +20 -20
- package/vendor/rbxutil/find/init.luau +44 -44
- package/vendor/rbxutil/find/package.json +17 -17
- package/vendor/rbxutil/find/wally.toml +8 -8
- package/vendor/rbxutil/input/Gamepad.luau +559 -559
- package/vendor/rbxutil/input/Keyboard.luau +124 -124
- package/vendor/rbxutil/input/Mouse.luau +278 -278
- package/vendor/rbxutil/input/PreferredInput.luau +91 -91
- package/vendor/rbxutil/input/Touch.luau +120 -120
- package/vendor/rbxutil/input/init.luau +33 -33
- package/vendor/rbxutil/input/wally.toml +12 -12
- package/vendor/rbxutil/loader/index.d.ts +15 -15
- package/vendor/rbxutil/loader/init.luau +137 -137
- package/vendor/rbxutil/loader/wally.toml +8 -8
- package/vendor/rbxutil/log/index.d.ts +38 -38
- package/vendor/rbxutil/log/init.luau +746 -746
- package/vendor/rbxutil/log/wally.toml +8 -8
- package/vendor/rbxutil/net/init.luau +190 -190
- package/vendor/rbxutil/net/wally.toml +8 -8
- package/vendor/rbxutil/option/index.d.ts +44 -44
- package/vendor/rbxutil/option/init.luau +489 -489
- package/vendor/rbxutil/option/init.test.luau +342 -342
- package/vendor/rbxutil/option/wally.toml +8 -8
- package/vendor/rbxutil/pid/index.d.ts +53 -53
- package/vendor/rbxutil/pid/init.luau +195 -195
- package/vendor/rbxutil/pid/package.json +16 -16
- package/vendor/rbxutil/pid/wally.toml +9 -9
- package/vendor/rbxutil/quaternion/index.d.ts +117 -117
- package/vendor/rbxutil/quaternion/init.luau +570 -570
- package/vendor/rbxutil/quaternion/package.json +16 -16
- package/vendor/rbxutil/quaternion/wally.toml +9 -9
- package/vendor/rbxutil/query/index.d.ts +43 -43
- package/vendor/rbxutil/query/init.luau +117 -117
- package/vendor/rbxutil/query/package.json +18 -18
- package/vendor/rbxutil/query/wally.toml +9 -9
- package/vendor/rbxutil/sequent/index.d.ts +28 -28
- package/vendor/rbxutil/sequent/init.luau +340 -340
- package/vendor/rbxutil/sequent/package.json +16 -16
- package/vendor/rbxutil/sequent/wally.toml +9 -9
- package/vendor/rbxutil/ser/init.luau +175 -175
- package/vendor/rbxutil/ser/init.test.luau +50 -50
- package/vendor/rbxutil/ser/wally.toml +11 -11
- package/vendor/rbxutil/shake/index.d.ts +36 -36
- package/vendor/rbxutil/shake/init.luau +532 -532
- package/vendor/rbxutil/shake/init.test.luau +267 -267
- package/vendor/rbxutil/shake/package.json +16 -16
- package/vendor/rbxutil/shake/wally.toml +9 -9
- package/vendor/rbxutil/signal/index.d.ts +100 -100
- package/vendor/rbxutil/signal/init.luau +432 -432
- package/vendor/rbxutil/signal/init.test.luau +190 -190
- package/vendor/rbxutil/signal/package.json +17 -17
- package/vendor/rbxutil/signal/wally.toml +9 -9
- package/vendor/rbxutil/silo/TableWatcher.luau +65 -65
- package/vendor/rbxutil/silo/Util.luau +55 -55
- package/vendor/rbxutil/silo/init.luau +338 -338
- package/vendor/rbxutil/silo/init.test.luau +215 -215
- package/vendor/rbxutil/silo/wally.toml +8 -8
- package/vendor/rbxutil/spring/index.d.ts +40 -40
- package/vendor/rbxutil/spring/init.luau +97 -97
- package/vendor/rbxutil/spring/package.json +17 -17
- package/vendor/rbxutil/spring/wally.toml +8 -8
- package/vendor/rbxutil/stream/index.d.ts +88 -88
- package/vendor/rbxutil/stream/init.luau +597 -597
- package/vendor/rbxutil/stream/package.json +18 -18
- package/vendor/rbxutil/stream/wally.toml +9 -9
- package/vendor/rbxutil/streamable/Streamable.luau +202 -202
- package/vendor/rbxutil/streamable/StreamableUtil.luau +80 -80
- package/vendor/rbxutil/streamable/init.luau +8 -8
- package/vendor/rbxutil/streamable/wally.toml +12 -12
- package/vendor/rbxutil/symbol/init.luau +56 -56
- package/vendor/rbxutil/symbol/init.test.luau +37 -37
- package/vendor/rbxutil/symbol/wally.toml +8 -8
- package/vendor/rbxutil/table-util/init.luau +938 -938
- package/vendor/rbxutil/table-util/init.test.luau +439 -439
- package/vendor/rbxutil/task-queue/index.d.ts +27 -27
- package/vendor/rbxutil/task-queue/init.luau +97 -97
- package/vendor/rbxutil/task-queue/wally.toml +8 -8
- package/vendor/rbxutil/timer/index.d.ts +81 -81
- package/vendor/rbxutil/timer/init.luau +249 -249
- package/vendor/rbxutil/timer/init.test.luau +73 -73
- package/vendor/rbxutil/timer/wally.toml +11 -11
- package/vendor/rbxutil/tree/index.d.ts +15 -15
- package/vendor/rbxutil/tree/init.luau +137 -137
- package/vendor/rbxutil/tree/wally.toml +8 -8
- package/vendor/rbxutil/trove/index.d.ts +46 -46
- package/vendor/rbxutil/trove/init.luau +787 -787
- package/vendor/rbxutil/trove/init.test.luau +203 -203
- package/vendor/rbxutil/trove/wally.toml +8 -8
- package/vendor/rbxutil/typed-remote/init.luau +196 -196
- package/vendor/rbxutil/typed-remote/wally.toml +8 -8
- package/vendor/rbxutil/wait-for/index.d.ts +17 -17
- package/vendor/rbxutil/wait-for/init.luau +257 -257
- package/vendor/rbxutil/wait-for/init.test.luau +182 -182
- package/vendor/rbxutil/wait-for/wally.toml +11 -11
- package/vendor/t/t.lua +1350 -1350
- package/vendor/testez/Context.lua +26 -26
- package/vendor/testez/Expectation.lua +311 -311
- package/vendor/testez/ExpectationContext.lua +38 -38
- package/vendor/testez/LifecycleHooks.lua +89 -89
- package/vendor/testez/Reporters/TeamCityReporter.lua +101 -101
- package/vendor/testez/Reporters/TextReporter.lua +105 -105
- package/vendor/testez/Reporters/TextReporterQuiet.lua +96 -96
- package/vendor/testez/TestBootstrap.lua +146 -146
- package/vendor/testez/TestEnum.lua +27 -27
- package/vendor/testez/TestPlan.lua +304 -304
- package/vendor/testez/TestPlanner.lua +39 -39
- package/vendor/testez/TestResults.lua +111 -111
- package/vendor/testez/TestRunner.lua +188 -188
- package/vendor/testez/TestSession.lua +243 -243
- package/vendor/testez/init.lua +39 -39
|
@@ -1,669 +1,669 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: roblox-networking
|
|
3
|
-
description: >
|
|
4
|
-
Server-authoritative networking, RemoteEvent validation, rate limiting, exploit prevention,
|
|
5
|
-
security hardening.
|
|
6
|
-
last_reviewed: 2026-05-22
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
<!-- Source: brockmartin/roblox-game-skill (MIT) -->
|
|
10
|
-
|
|
11
|
-
# Roblox Networking & Security Reference
|
|
12
|
-
|
|
13
|
-
---
|
|
14
|
-
|
|
15
|
-
## Overview
|
|
16
|
-
|
|
17
|
-
**Load this reference when:**
|
|
18
|
-
|
|
19
|
-
- Validating RemoteEvent/RemoteFunction input on the server
|
|
20
|
-
- Implementing rate limiting or anti-exploit measures
|
|
21
|
-
- Designing server-authoritative systems (damage, currency, inventory)
|
|
22
|
-
- Hardening existing networking code against exploiters
|
|
23
|
-
|
|
24
|
-
This document covers server-side validation, rate limiting, suspicion scoring, and server-authoritative design patterns. For player lifecycle (PlayerAdded/Removing), see **roblox-architecture**.
|
|
25
|
-
|
|
26
|
-
---
|
|
27
|
-
|
|
28
|
-
## Quick Reference
|
|
29
|
-
|
|
30
|
-
**Load Full Reference below only when you need specific validation module code or rate limiting implementations.**
|
|
31
|
-
|
|
32
|
-
Key rules:
|
|
33
|
-
- NEVER trust the client. Every RemoteEvent arg is attacker-controlled.
|
|
34
|
-
- Validate: type, range, ownership, cooldown on EVERY server handler.
|
|
35
|
-
- Server-authoritative: server decides outcomes. Client is display-only.
|
|
36
|
-
- Rate limit all remotes. Per-player cooldown table minimum.
|
|
37
|
-
- Damage: server calculates from weapon stats + distance + cooldown. Never accept damage values from client.
|
|
38
|
-
- Currency: all math server-side. Client displays only.
|
|
39
|
-
- Movement: validate distance/speed against physics. Flag teleportation.
|
|
40
|
-
- Use `t` library for composable type checks on remote args.
|
|
41
|
-
- Suspicion scoring: accumulate violations, kick/ban at threshold. Don't instant-kick on first offense.
|
|
42
|
-
- Exploiters can: fire any remote, read all client code, modify any client state, speed/fly/teleport.
|
|
43
|
-
|
|
44
|
-
---
|
|
45
|
-
|
|
46
|
-
## Full Reference
|
|
47
|
-
|
|
48
|
-
## Security Hardening
|
|
49
|
-
|
|
50
|
-
### Never Trust the Client
|
|
51
|
-
|
|
52
|
-
Every RemoteEvent payload is attacker-controlled. Validate type, range, ownership, and cooldown on the server for every request.
|
|
53
|
-
|
|
54
|
-
- **Modify any LocalScript** -- injecting code, changing variables, hooking functions.
|
|
55
|
-
- **Fire any RemoteEvent with arbitrary arguments** -- types, values, and counts are all attacker-controlled.
|
|
56
|
-
- **Speed hack, fly, and teleport** -- the character's physics can be overridden entirely on the client.
|
|
57
|
-
- **See all client-accessible code** -- anything in `StarterPlayerScripts`, `StarterGui`, `ReplicatedStorage`, or `ReplicatedFirst` is fully readable.
|
|
58
|
-
- **Read and modify any client-side state** -- health displays, cooldown timers, UI flags.
|
|
59
|
-
- **Intercept and replay network traffic** -- RemoteSpy tools let exploiters see every remote call and replay or modify them.
|
|
60
|
-
|
|
61
|
-
**The client is a display layer, not a trusted authority.** It renders the world and collects input. The server decides what actually happens.
|
|
62
|
-
|
|
63
|
-
A useful mental model: treat every `RemoteEvent:FireServer()` call as if it were an HTTP request from an anonymous stranger on the internet. Validate everything. Assume nothing.
|
|
64
|
-
|
|
65
|
-
---
|
|
66
|
-
|
|
67
|
-
### RemoteEvent Validation Patterns
|
|
68
|
-
|
|
69
|
-
> **For runtime type checking, the `t` library is vendored** at `vendor/t/t.lua` (osyrisrblx/t v3.1.1, MIT). It provides composable type checks (`t.string`, `t.number`, `t.interface({...})`) that are cleaner than manual typeof() chains. The agent can place it when relevant.
|
|
70
|
-
|
|
71
|
-
### The Problem
|
|
72
|
-
|
|
73
|
-
A bare remote handler like this is exploitable:
|
|
74
|
-
|
|
75
|
-
```luau
|
|
76
|
-
-- BAD: No validation at all
|
|
77
|
-
DamageRemote.OnServerEvent:Connect(function(player, targetName, damage)
|
|
78
|
-
local target = Players:FindFirstChild(targetName)
|
|
79
|
-
target.Character.Humanoid:TakeDamage(damage)
|
|
80
|
-
end)
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
An exploiter can fire this with any target name and any damage value, instantly killing anyone.
|
|
84
|
-
|
|
85
|
-
### Production-Ready Validation Module
|
|
86
|
-
|
|
87
|
-
Place this in `ServerScriptService`:
|
|
88
|
-
|
|
89
|
-
```luau
|
|
90
|
-
-- ServerScriptService/Modules/RemoteValidator.luau
|
|
91
|
-
|
|
92
|
-
local RemoteValidator = {}
|
|
93
|
-
|
|
94
|
-
--[[ -----------------------------------------------------------------------
|
|
95
|
-
Type Checking
|
|
96
|
-
Validates that arguments match expected types.
|
|
97
|
-
----------------------------------------------------------------------- ]]
|
|
98
|
-
|
|
99
|
-
type TypeSpec = string | (value: any) -> boolean
|
|
100
|
-
|
|
101
|
-
function RemoteValidator.checkType(value: any, expected: TypeSpec): boolean
|
|
102
|
-
if typeof(expected) == "function" then
|
|
103
|
-
return expected(value)
|
|
104
|
-
end
|
|
105
|
-
return typeof(value) == expected
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
function RemoteValidator.validateArgs(
|
|
109
|
-
args: { any },
|
|
110
|
-
schema: { { name: string, type: TypeSpec, optional: boolean? } }
|
|
111
|
-
): (boolean, string?)
|
|
112
|
-
for i, spec in schema do
|
|
113
|
-
local value = args[i]
|
|
114
|
-
|
|
115
|
-
if value == nil then
|
|
116
|
-
if not spec.optional then
|
|
117
|
-
return false, `Missing required argument: {spec.name}`
|
|
118
|
-
end
|
|
119
|
-
continue
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
if not RemoteValidator.checkType(value, spec.type) then
|
|
123
|
-
return false, `Invalid type for {spec.name}: expected {tostring(spec.type)}, got {typeof(value)}`
|
|
124
|
-
end
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
-- Reject extra arguments that were not declared in the schema
|
|
128
|
-
if #args > #schema then
|
|
129
|
-
return false, `Too many arguments: expected {#schema}, got {#args}`
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
return true, nil
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
--[[ -----------------------------------------------------------------------
|
|
136
|
-
Range Checking
|
|
137
|
-
Validates that numeric values fall within acceptable bounds.
|
|
138
|
-
----------------------------------------------------------------------- ]]
|
|
139
|
-
|
|
140
|
-
function RemoteValidator.checkRange(value: number, min: number, max: number): boolean
|
|
141
|
-
return typeof(value) == "number"
|
|
142
|
-
and value == value -- NaN check
|
|
143
|
-
and value >= min
|
|
144
|
-
and value <= max
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
function RemoteValidator.checkIntegerRange(value: number, min: number, max: number): boolean
|
|
148
|
-
return RemoteValidator.checkRange(value, min, max)
|
|
149
|
-
and math.floor(value) == value
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
--[[ -----------------------------------------------------------------------
|
|
153
|
-
Cooldown Tracking
|
|
154
|
-
Per-player, per-action cooldown enforcement.
|
|
155
|
-
----------------------------------------------------------------------- ]]
|
|
156
|
-
|
|
157
|
-
local cooldowns: { [Player]: { [string]: number } } = {}
|
|
158
|
-
|
|
159
|
-
function RemoteValidator.checkCooldown(player: Player, action: string, cooldownSeconds: number): boolean
|
|
160
|
-
local now = os.clock()
|
|
161
|
-
local playerCooldowns = cooldowns[player]
|
|
162
|
-
|
|
163
|
-
if not playerCooldowns then
|
|
164
|
-
playerCooldowns = {}
|
|
165
|
-
cooldowns[player] = playerCooldowns
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
local lastUsed = playerCooldowns[action]
|
|
169
|
-
if lastUsed and (now - lastUsed) < cooldownSeconds then
|
|
170
|
-
return false
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
playerCooldowns[action] = now
|
|
174
|
-
return true
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
function RemoteValidator.clearPlayerCooldowns(player: Player)
|
|
178
|
-
cooldowns[player] = nil
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
--[[ -----------------------------------------------------------------------
|
|
182
|
-
Existence Checks
|
|
183
|
-
Validates that targets, objects, and instances actually exist.
|
|
184
|
-
----------------------------------------------------------------------- ]]
|
|
185
|
-
|
|
186
|
-
function RemoteValidator.playerExists(playerName: string): Player?
|
|
187
|
-
local Players = game:GetService("Players")
|
|
188
|
-
return Players:FindFirstChild(playerName) :: Player?
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
function RemoteValidator.characterAlive(player: Player): boolean
|
|
192
|
-
local character = player.Character
|
|
193
|
-
if not character then
|
|
194
|
-
return false
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
local humanoid = character:FindFirstChildOfClass("Humanoid")
|
|
198
|
-
if not humanoid then
|
|
199
|
-
return false
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
return humanoid.Health > 0
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
function RemoteValidator.instanceExists(parent: Instance, name: string, className: string?): Instance?
|
|
206
|
-
local child = parent:FindFirstChild(name)
|
|
207
|
-
if not child then
|
|
208
|
-
return nil
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
if className and not child:IsA(className) then
|
|
212
|
-
return nil
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
return child
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
--[[ -----------------------------------------------------------------------
|
|
219
|
-
Authorization
|
|
220
|
-
Checks if a player is allowed to perform an action.
|
|
221
|
-
----------------------------------------------------------------------- ]]
|
|
222
|
-
|
|
223
|
-
function RemoteValidator.playerOwnsItem(player: Player, itemId: string, inventoryFolder: Folder?): boolean
|
|
224
|
-
local folder = inventoryFolder or player:FindFirstChild("Inventory") :: Folder?
|
|
225
|
-
if not folder then
|
|
226
|
-
return false
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
return folder:FindFirstChild(itemId) ~= nil
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
function RemoteValidator.playerHasAttribute(player: Player, attribute: string, expectedValue: any?): boolean
|
|
233
|
-
local value = player:GetAttribute(attribute)
|
|
234
|
-
if expectedValue ~= nil then
|
|
235
|
-
return value == expectedValue
|
|
236
|
-
end
|
|
237
|
-
return value ~= nil
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
--[[ -----------------------------------------------------------------------
|
|
241
|
-
Distance Check
|
|
242
|
-
Validates that two positions are within an acceptable range.
|
|
243
|
-
----------------------------------------------------------------------- ]]
|
|
244
|
-
|
|
245
|
-
function RemoteValidator.withinRange(posA: Vector3, posB: Vector3, maxDistance: number): boolean
|
|
246
|
-
return (posA - posB).Magnitude <= maxDistance
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
function RemoteValidator.playerWithinRange(player: Player, targetPos: Vector3, maxDistance: number): boolean
|
|
250
|
-
local character = player.Character
|
|
251
|
-
if not character then
|
|
252
|
-
return false
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
local root = character:FindFirstChild("HumanoidRootPart")
|
|
256
|
-
if not root then
|
|
257
|
-
return false
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
return RemoteValidator.withinRange(root.Position, targetPos, maxDistance)
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
--[[ -----------------------------------------------------------------------
|
|
264
|
-
Cleanup
|
|
265
|
-
----------------------------------------------------------------------- ]]
|
|
266
|
-
|
|
267
|
-
game:GetService("Players").PlayerRemoving:Connect(function(player)
|
|
268
|
-
RemoteValidator.clearPlayerCooldowns(player)
|
|
269
|
-
end)
|
|
270
|
-
|
|
271
|
-
return RemoteValidator
|
|
272
|
-
```
|
|
273
|
-
|
|
274
|
-
### Using the Validation Module
|
|
275
|
-
|
|
276
|
-
```luau
|
|
277
|
-
-- ServerScriptService/RemoteHandlers/DamageHandler.server.luau
|
|
278
|
-
|
|
279
|
-
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
280
|
-
local ServerScriptService = game:GetService("ServerScriptService")
|
|
281
|
-
|
|
282
|
-
local Validator = require(ServerScriptService.Modules.RemoteValidator)
|
|
283
|
-
local DamageRemote = ReplicatedStorage.Remotes.DealDamage
|
|
284
|
-
|
|
285
|
-
local MAX_DAMAGE = 50
|
|
286
|
-
local DAMAGE_COOLDOWN = 0.5 -- seconds
|
|
287
|
-
local ATTACK_RANGE = 15 -- studs
|
|
288
|
-
|
|
289
|
-
local ARG_SCHEMA = {
|
|
290
|
-
{ name = "targetPlayer", type = "Instance" },
|
|
291
|
-
{ name = "damage", type = "number" },
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
DamageRemote.OnServerEvent:Connect(function(player: Player, ...: any)
|
|
295
|
-
local args = { ... }
|
|
296
|
-
|
|
297
|
-
-- 1. Validate argument types
|
|
298
|
-
local valid, err = Validator.validateArgs(args, ARG_SCHEMA)
|
|
299
|
-
if not valid then
|
|
300
|
-
warn(`[DamageHandler] {player.Name}: {err}`)
|
|
301
|
-
return
|
|
302
|
-
end
|
|
303
|
-
|
|
304
|
-
local targetPlayer: Player = args[1]
|
|
305
|
-
local damage: number = args[2]
|
|
306
|
-
|
|
307
|
-
-- 2. Validate the target is actually a Player
|
|
308
|
-
if not targetPlayer:IsA("Player") then
|
|
309
|
-
return
|
|
310
|
-
end
|
|
311
|
-
|
|
312
|
-
-- 3. Validate damage range
|
|
313
|
-
if not Validator.checkIntegerRange(damage, 1, MAX_DAMAGE) then
|
|
314
|
-
warn(`[DamageHandler] {player.Name}: damage out of range ({damage})`)
|
|
315
|
-
return
|
|
316
|
-
end
|
|
317
|
-
|
|
318
|
-
-- 4. Cooldown check
|
|
319
|
-
if not Validator.checkCooldown(player, "DealDamage", DAMAGE_COOLDOWN) then
|
|
320
|
-
return
|
|
321
|
-
end
|
|
322
|
-
|
|
323
|
-
-- 5. Verify attacker is alive
|
|
324
|
-
if not Validator.characterAlive(player) then
|
|
325
|
-
return
|
|
326
|
-
end
|
|
327
|
-
|
|
328
|
-
-- 6. Verify target is alive
|
|
329
|
-
if not Validator.characterAlive(targetPlayer) then
|
|
330
|
-
return
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
-- 7. Range check -- attacker must be near the target
|
|
334
|
-
local targetRoot = targetPlayer.Character and targetPlayer.Character:FindFirstChild("HumanoidRootPart")
|
|
335
|
-
if not targetRoot then
|
|
336
|
-
return
|
|
337
|
-
end
|
|
338
|
-
|
|
339
|
-
if not Validator.playerWithinRange(player, targetRoot.Position, ATTACK_RANGE) then
|
|
340
|
-
warn(`[DamageHandler] {player.Name}: target out of range`)
|
|
341
|
-
return
|
|
342
|
-
end
|
|
343
|
-
|
|
344
|
-
-- 8. Authorization -- verify the player has a weapon equipped
|
|
345
|
-
local character = player.Character
|
|
346
|
-
local weapon = character and character:FindFirstChildOfClass("Tool")
|
|
347
|
-
if not weapon or not weapon:GetAttribute("CanDealDamage") then
|
|
348
|
-
warn(`[DamageHandler] {player.Name}: no valid weapon equipped`)
|
|
349
|
-
return
|
|
350
|
-
end
|
|
351
|
-
|
|
352
|
-
-- 9. Server calculates actual damage (never trust client damage value directly)
|
|
353
|
-
local serverDamage = math.min(damage, weapon:GetAttribute("MaxDamage") or MAX_DAMAGE)
|
|
354
|
-
|
|
355
|
-
-- 10. Apply damage
|
|
356
|
-
local targetHumanoid = targetPlayer.Character:FindFirstChildOfClass("Humanoid")
|
|
357
|
-
if targetHumanoid then
|
|
358
|
-
targetHumanoid:TakeDamage(serverDamage)
|
|
359
|
-
end
|
|
360
|
-
end)
|
|
361
|
-
```
|
|
362
|
-
|
|
363
|
-
---
|
|
364
|
-
|
|
365
|
-
### Server-Authoritative Design
|
|
366
|
-
|
|
367
|
-
The server owns all game state. The client requests actions; the server decides outcomes.
|
|
368
|
-
|
|
369
|
-
### Movement Validation
|
|
370
|
-
|
|
371
|
-
```luau
|
|
372
|
-
-- ServerScriptService/Security/MovementValidator.server.luau
|
|
373
|
-
|
|
374
|
-
local Players = game:GetService("Players")
|
|
375
|
-
local RunService = game:GetService("RunService")
|
|
376
|
-
|
|
377
|
-
local MAX_SPEED = 50 -- studs per second (walk + sprint + tolerance)
|
|
378
|
-
local MAX_VERTICAL_SPEED = 100 -- studs per second (jumping/falling tolerance)
|
|
379
|
-
local VIOLATION_THRESHOLD = 5 -- strikes before action
|
|
380
|
-
local CHECK_INTERVAL = 0.5 -- seconds between checks
|
|
381
|
-
|
|
382
|
-
local playerData: { [Player]: {
|
|
383
|
-
lastPosition: Vector3,
|
|
384
|
-
lastCheck: number,
|
|
385
|
-
violations: number,
|
|
386
|
-
} } = {}
|
|
387
|
-
|
|
388
|
-
Players.PlayerAdded:Connect(function(player)
|
|
389
|
-
player.CharacterAdded:Connect(function(character)
|
|
390
|
-
local root = character:WaitForChild("HumanoidRootPart")
|
|
391
|
-
playerData[player] = {
|
|
392
|
-
lastPosition = root.Position,
|
|
393
|
-
lastCheck = os.clock(),
|
|
394
|
-
violations = 0,
|
|
395
|
-
}
|
|
396
|
-
end)
|
|
397
|
-
end)
|
|
398
|
-
|
|
399
|
-
Players.PlayerRemoving:Connect(function(player)
|
|
400
|
-
playerData[player] = nil
|
|
401
|
-
end)
|
|
402
|
-
|
|
403
|
-
RunService.Heartbeat:Connect(function()
|
|
404
|
-
local now = os.clock()
|
|
405
|
-
|
|
406
|
-
for player, data in playerData do
|
|
407
|
-
if (now - data.lastCheck) < CHECK_INTERVAL then
|
|
408
|
-
continue
|
|
409
|
-
end
|
|
410
|
-
|
|
411
|
-
local character = player.Character
|
|
412
|
-
if not character then
|
|
413
|
-
continue
|
|
414
|
-
end
|
|
415
|
-
|
|
416
|
-
local root = character:FindFirstChild("HumanoidRootPart")
|
|
417
|
-
if not root then
|
|
418
|
-
continue
|
|
419
|
-
end
|
|
420
|
-
|
|
421
|
-
local dt = now - data.lastCheck
|
|
422
|
-
local displacement = root.Position - data.lastPosition
|
|
423
|
-
local horizontalSpeed = Vector3.new(displacement.X, 0, displacement.Z).Magnitude / dt
|
|
424
|
-
local verticalSpeed = math.abs(displacement.Y) / dt
|
|
425
|
-
|
|
426
|
-
if horizontalSpeed > MAX_SPEED or verticalSpeed > MAX_VERTICAL_SPEED then
|
|
427
|
-
data.violations += 1
|
|
428
|
-
warn(`[MovementValidator] {player.Name}: speed violation #{data.violations} (h={math.floor(horizontalSpeed)}, v={math.floor(verticalSpeed)})`)
|
|
429
|
-
|
|
430
|
-
if data.violations >= VIOLATION_THRESHOLD then
|
|
431
|
-
-- Teleport player back to last valid position
|
|
432
|
-
root.CFrame = CFrame.new(data.lastPosition)
|
|
433
|
-
-- Or kick for persistent abuse:
|
|
434
|
-
-- player:Kick("Movement anomaly detected.")
|
|
435
|
-
end
|
|
436
|
-
else
|
|
437
|
-
-- Decay violations over time for legitimate edge cases
|
|
438
|
-
data.violations = math.max(0, data.violations - 1)
|
|
439
|
-
data.lastPosition = root.Position
|
|
440
|
-
end
|
|
441
|
-
|
|
442
|
-
data.lastCheck = now
|
|
443
|
-
end
|
|
444
|
-
end)
|
|
445
|
-
```
|
|
446
|
-
|
|
447
|
-
### Damage Validation
|
|
448
|
-
|
|
449
|
-
```luau
|
|
450
|
-
-- Server decides damage, not the client.
|
|
451
|
-
|
|
452
|
-
local function calculateDamage(attacker: Player, weapon: Tool, target: Player): number?
|
|
453
|
-
local weaponConfig = WeaponDatabase[weapon.Name]
|
|
454
|
-
if not weaponConfig then
|
|
455
|
-
return nil
|
|
456
|
-
end
|
|
457
|
-
|
|
458
|
-
-- Server checks weapon cooldown
|
|
459
|
-
local lastFire = weapon:GetAttribute("LastFired") or 0
|
|
460
|
-
if os.clock() - lastFire < weaponConfig.Cooldown then
|
|
461
|
-
return nil
|
|
462
|
-
end
|
|
463
|
-
|
|
464
|
-
-- Server checks range
|
|
465
|
-
local attackerRoot = attacker.Character and attacker.Character:FindFirstChild("HumanoidRootPart")
|
|
466
|
-
local targetRoot = target.Character and target.Character:FindFirstChild("HumanoidRootPart")
|
|
467
|
-
if not attackerRoot or not targetRoot then
|
|
468
|
-
return nil
|
|
469
|
-
end
|
|
470
|
-
|
|
471
|
-
local distance = (attackerRoot.Position - targetRoot.Position).Magnitude
|
|
472
|
-
if distance > weaponConfig.Range then
|
|
473
|
-
return nil
|
|
474
|
-
end
|
|
475
|
-
|
|
476
|
-
-- Server calculates damage
|
|
477
|
-
weapon:SetAttribute("LastFired", os.clock())
|
|
478
|
-
return weaponConfig.BaseDamage
|
|
479
|
-
end
|
|
480
|
-
```
|
|
481
|
-
|
|
482
|
-
### Currency Transactions
|
|
483
|
-
|
|
484
|
-
```luau
|
|
485
|
-
-- WRONG: Client tells server how much to add
|
|
486
|
-
CurrencyRemote.OnServerEvent:Connect(function(player, amount)
|
|
487
|
-
player.leaderstats.Gold.Value += amount -- exploiter sends 999999
|
|
488
|
-
end)
|
|
489
|
-
|
|
490
|
-
-- RIGHT: Server calculates the reward
|
|
491
|
-
QuestCompleteRemote.OnServerEvent:Connect(function(player, questId)
|
|
492
|
-
-- Validate quest ID type
|
|
493
|
-
if typeof(questId) ~= "string" then
|
|
494
|
-
return
|
|
495
|
-
end
|
|
496
|
-
|
|
497
|
-
-- Server checks quest state
|
|
498
|
-
local questData = PlayerQuestData[player]
|
|
499
|
-
if not questData or not questData[questId] then
|
|
500
|
-
return
|
|
501
|
-
end
|
|
502
|
-
|
|
503
|
-
if questData[questId].completed then
|
|
504
|
-
return -- already claimed
|
|
505
|
-
end
|
|
506
|
-
|
|
507
|
-
-- Server looks up the reward from its own data
|
|
508
|
-
local questConfig = QuestDatabase[questId]
|
|
509
|
-
if not questConfig then
|
|
510
|
-
return
|
|
511
|
-
end
|
|
512
|
-
|
|
513
|
-
-- Server awards the reward
|
|
514
|
-
questData[questId].completed = true
|
|
515
|
-
player.leaderstats.Gold.Value += questConfig.Reward
|
|
516
|
-
end)
|
|
517
|
-
```
|
|
518
|
-
|
|
519
|
-
### Inventory Operations
|
|
520
|
-
|
|
521
|
-
```luau
|
|
522
|
-
-- Server-side trade validation
|
|
523
|
-
local function executeTrade(playerA: Player, playerB: Player, itemIdA: string, itemIdB: string): boolean
|
|
524
|
-
-- Both players must be alive and in range
|
|
525
|
-
if not Validator.characterAlive(playerA) or not Validator.characterAlive(playerB) then
|
|
526
|
-
return false
|
|
527
|
-
end
|
|
528
|
-
|
|
529
|
-
-- Verify ownership on the server
|
|
530
|
-
local invA = playerA:FindFirstChild("Inventory")
|
|
531
|
-
local invB = playerB:FindFirstChild("Inventory")
|
|
532
|
-
if not invA or not invB then
|
|
533
|
-
return false
|
|
534
|
-
end
|
|
535
|
-
|
|
536
|
-
---
|
|
537
|
-
|
|
538
|
-
## Rate Limiting
|
|
539
|
-
|
|
540
|
-
Roblox's built-in throttle (~500 req/sec per client) is NOT a substitute for custom rate limiting. Players can still spam remotes at hundreds of requests per second. You need application-level throttling.
|
|
541
|
-
|
|
542
|
-
### Pattern 1: Per-Player Cooldown Table
|
|
543
|
-
|
|
544
|
-
Simple and effective for most games. Each remote has a minimum time between calls per player.
|
|
545
|
-
|
|
546
|
-
```luau
|
|
547
|
-
local cooldowns: {[Player]: {[string]: number}} = {}
|
|
548
|
-
local COOLDOWN = 0.2 -- seconds between calls
|
|
549
|
-
|
|
550
|
-
local function isThrottled(player: Player, remoteName: string): boolean
|
|
551
|
-
local now = os.clock()
|
|
552
|
-
if not cooldowns[player] then
|
|
553
|
-
cooldowns[player] = {}
|
|
554
|
-
end
|
|
555
|
-
|
|
556
|
-
local lastCall = cooldowns[player][remoteName]
|
|
557
|
-
if lastCall and (now - lastCall) < COOLDOWN then
|
|
558
|
-
return true -- throttled
|
|
559
|
-
end
|
|
560
|
-
|
|
561
|
-
cooldowns[player][remoteName] = now
|
|
562
|
-
return false
|
|
563
|
-
end
|
|
564
|
-
|
|
565
|
-
-- Clean up when player leaves
|
|
566
|
-
Players.PlayerRemoving:Connect(function(player)
|
|
567
|
-
cooldowns[player] = nil
|
|
568
|
-
end)
|
|
569
|
-
|
|
570
|
-
-- Usage
|
|
571
|
-
BuyItem.OnServerEvent:Connect(function(player, itemId)
|
|
572
|
-
if isThrottled(player, "BuyItem") then return end
|
|
573
|
-
-- process purchase
|
|
574
|
-
end)
|
|
575
|
-
```
|
|
576
|
-
|
|
577
|
-
### Pattern 2: Declarative Remote Definitions
|
|
578
|
-
|
|
579
|
-
Define all remotes in one place with rate limits, validation, and allowed states. Cleaner than scattered OnServerEvent handlers.
|
|
580
|
-
|
|
581
|
-
```luau
|
|
582
|
-
type RemoteDef = {
|
|
583
|
-
RateLimit: number?,
|
|
584
|
-
Validate: (Player, ...any) -> boolean,
|
|
585
|
-
Handler: (Player, ...any) -> (),
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
local Remotes: {[string]: RemoteDef} = {
|
|
589
|
-
BuyItem = {
|
|
590
|
-
RateLimit = 0.5,
|
|
591
|
-
Validate = function(player, itemId)
|
|
592
|
-
return typeof(itemId) == "string" and #itemId < 50
|
|
593
|
-
end,
|
|
594
|
-
Handler = function(player, itemId)
|
|
595
|
-
-- process purchase
|
|
596
|
-
end,
|
|
597
|
-
},
|
|
598
|
-
EquipTool = {
|
|
599
|
-
RateLimit = 0.3,
|
|
600
|
-
Validate = function(player, toolId)
|
|
601
|
-
return typeof(toolId) == "string"
|
|
602
|
-
end,
|
|
603
|
-
Handler = function(player, toolId)
|
|
604
|
-
-- equip tool
|
|
605
|
-
end,
|
|
606
|
-
},
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
-- Wire up automatically
|
|
610
|
-
for name, def in Remotes do
|
|
611
|
-
local remote = ReplicatedStorage:WaitForChild(name)
|
|
612
|
-
remote.OnServerEvent:Connect(function(player, ...)
|
|
613
|
-
if def.RateLimit and isThrottled(player, name) then return end
|
|
614
|
-
if not def.Validate(player, ...) then return end
|
|
615
|
-
def.Handler(player, ...)
|
|
616
|
-
end)
|
|
617
|
-
end
|
|
618
|
-
```
|
|
619
|
-
|
|
620
|
-
### Pattern 3: Suspicion Scoring
|
|
621
|
-
|
|
622
|
-
For high-stakes games. Track suspicious behavior over time instead of hard-blocking.
|
|
623
|
-
|
|
624
|
-
```luau
|
|
625
|
-
local suspicion: {[Player]: number} = {}
|
|
626
|
-
local SUSPICION_THRESHOLD = 10
|
|
627
|
-
local DECAY_RATE = 1 -- points lost per second
|
|
628
|
-
|
|
629
|
-
local function addSuspicion(player: Player, amount: number, reason: string)
|
|
630
|
-
suspicion[player] = (suspicion[player] or 0) + amount
|
|
631
|
-
if suspicion[player] >= SUSPICION_THRESHOLD then
|
|
632
|
-
warn(`High suspicion for {player.Name}: {reason}`)
|
|
633
|
-
end
|
|
634
|
-
end
|
|
635
|
-
|
|
636
|
-
-- In remote handler
|
|
637
|
-
BuyItem.OnServerEvent:Connect(function(player, itemId)
|
|
638
|
-
if isThrottled(player, "BuyItem") then
|
|
639
|
-
addSuspicion(player, 2, "rate limit exceeded")
|
|
640
|
-
return
|
|
641
|
-
end
|
|
642
|
-
-- normal processing
|
|
643
|
-
end)
|
|
644
|
-
|
|
645
|
-
-- Decay suspicion over time
|
|
646
|
-
task.spawn(function()
|
|
647
|
-
while true do
|
|
648
|
-
task.wait(1)
|
|
649
|
-
for player, score in suspicion do
|
|
650
|
-
suspicion[player] = math.max(0, score - DECAY_RATE)
|
|
651
|
-
end
|
|
652
|
-
end
|
|
653
|
-
end)
|
|
654
|
-
```
|
|
655
|
-
|
|
656
|
-
### What NOT to Do
|
|
657
|
-
|
|
658
|
-
```luau
|
|
659
|
-
-- BAD: no rate limiting at all
|
|
660
|
-
BuyItem.OnServerEvent:Connect(function(player, itemId)
|
|
661
|
-
-- exploiter can call this 1000 times/second
|
|
662
|
-
grantItem(player, itemId)
|
|
663
|
-
end)
|
|
664
|
-
|
|
665
|
-
-- BAD: client-side rate limiting (exploiter bypasses)
|
|
666
|
-
-- Rate limiting MUST be server-side
|
|
667
|
-
```
|
|
668
|
-
|
|
669
|
-
Source: Roblox Server-Side Detection Guide (Roblox/creator-docs, MIT), DevForum rate limiting patterns
|
|
1
|
+
---
|
|
2
|
+
name: roblox-networking
|
|
3
|
+
description: >
|
|
4
|
+
Server-authoritative networking, RemoteEvent validation, rate limiting, exploit prevention,
|
|
5
|
+
security hardening.
|
|
6
|
+
last_reviewed: 2026-05-22
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<!-- Source: brockmartin/roblox-game-skill (MIT) -->
|
|
10
|
+
|
|
11
|
+
# Roblox Networking & Security Reference
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Overview
|
|
16
|
+
|
|
17
|
+
**Load this reference when:**
|
|
18
|
+
|
|
19
|
+
- Validating RemoteEvent/RemoteFunction input on the server
|
|
20
|
+
- Implementing rate limiting or anti-exploit measures
|
|
21
|
+
- Designing server-authoritative systems (damage, currency, inventory)
|
|
22
|
+
- Hardening existing networking code against exploiters
|
|
23
|
+
|
|
24
|
+
This document covers server-side validation, rate limiting, suspicion scoring, and server-authoritative design patterns. For player lifecycle (PlayerAdded/Removing), see **roblox-architecture**.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Quick Reference
|
|
29
|
+
|
|
30
|
+
**Load Full Reference below only when you need specific validation module code or rate limiting implementations.**
|
|
31
|
+
|
|
32
|
+
Key rules:
|
|
33
|
+
- NEVER trust the client. Every RemoteEvent arg is attacker-controlled.
|
|
34
|
+
- Validate: type, range, ownership, cooldown on EVERY server handler.
|
|
35
|
+
- Server-authoritative: server decides outcomes. Client is display-only.
|
|
36
|
+
- Rate limit all remotes. Per-player cooldown table minimum.
|
|
37
|
+
- Damage: server calculates from weapon stats + distance + cooldown. Never accept damage values from client.
|
|
38
|
+
- Currency: all math server-side. Client displays only.
|
|
39
|
+
- Movement: validate distance/speed against physics. Flag teleportation.
|
|
40
|
+
- Use `t` library for composable type checks on remote args.
|
|
41
|
+
- Suspicion scoring: accumulate violations, kick/ban at threshold. Don't instant-kick on first offense.
|
|
42
|
+
- Exploiters can: fire any remote, read all client code, modify any client state, speed/fly/teleport.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Full Reference
|
|
47
|
+
|
|
48
|
+
## Security Hardening
|
|
49
|
+
|
|
50
|
+
### Never Trust the Client
|
|
51
|
+
|
|
52
|
+
Every RemoteEvent payload is attacker-controlled. Validate type, range, ownership, and cooldown on the server for every request.
|
|
53
|
+
|
|
54
|
+
- **Modify any LocalScript** -- injecting code, changing variables, hooking functions.
|
|
55
|
+
- **Fire any RemoteEvent with arbitrary arguments** -- types, values, and counts are all attacker-controlled.
|
|
56
|
+
- **Speed hack, fly, and teleport** -- the character's physics can be overridden entirely on the client.
|
|
57
|
+
- **See all client-accessible code** -- anything in `StarterPlayerScripts`, `StarterGui`, `ReplicatedStorage`, or `ReplicatedFirst` is fully readable.
|
|
58
|
+
- **Read and modify any client-side state** -- health displays, cooldown timers, UI flags.
|
|
59
|
+
- **Intercept and replay network traffic** -- RemoteSpy tools let exploiters see every remote call and replay or modify them.
|
|
60
|
+
|
|
61
|
+
**The client is a display layer, not a trusted authority.** It renders the world and collects input. The server decides what actually happens.
|
|
62
|
+
|
|
63
|
+
A useful mental model: treat every `RemoteEvent:FireServer()` call as if it were an HTTP request from an anonymous stranger on the internet. Validate everything. Assume nothing.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
### RemoteEvent Validation Patterns
|
|
68
|
+
|
|
69
|
+
> **For runtime type checking, the `t` library is vendored** at `vendor/t/t.lua` (osyrisrblx/t v3.1.1, MIT). It provides composable type checks (`t.string`, `t.number`, `t.interface({...})`) that are cleaner than manual typeof() chains. The agent can place it when relevant.
|
|
70
|
+
|
|
71
|
+
### The Problem
|
|
72
|
+
|
|
73
|
+
A bare remote handler like this is exploitable:
|
|
74
|
+
|
|
75
|
+
```luau
|
|
76
|
+
-- BAD: No validation at all
|
|
77
|
+
DamageRemote.OnServerEvent:Connect(function(player, targetName, damage)
|
|
78
|
+
local target = Players:FindFirstChild(targetName)
|
|
79
|
+
target.Character.Humanoid:TakeDamage(damage)
|
|
80
|
+
end)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
An exploiter can fire this with any target name and any damage value, instantly killing anyone.
|
|
84
|
+
|
|
85
|
+
### Production-Ready Validation Module
|
|
86
|
+
|
|
87
|
+
Place this in `ServerScriptService`:
|
|
88
|
+
|
|
89
|
+
```luau
|
|
90
|
+
-- ServerScriptService/Modules/RemoteValidator.luau
|
|
91
|
+
|
|
92
|
+
local RemoteValidator = {}
|
|
93
|
+
|
|
94
|
+
--[[ -----------------------------------------------------------------------
|
|
95
|
+
Type Checking
|
|
96
|
+
Validates that arguments match expected types.
|
|
97
|
+
----------------------------------------------------------------------- ]]
|
|
98
|
+
|
|
99
|
+
type TypeSpec = string | (value: any) -> boolean
|
|
100
|
+
|
|
101
|
+
function RemoteValidator.checkType(value: any, expected: TypeSpec): boolean
|
|
102
|
+
if typeof(expected) == "function" then
|
|
103
|
+
return expected(value)
|
|
104
|
+
end
|
|
105
|
+
return typeof(value) == expected
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
function RemoteValidator.validateArgs(
|
|
109
|
+
args: { any },
|
|
110
|
+
schema: { { name: string, type: TypeSpec, optional: boolean? } }
|
|
111
|
+
): (boolean, string?)
|
|
112
|
+
for i, spec in schema do
|
|
113
|
+
local value = args[i]
|
|
114
|
+
|
|
115
|
+
if value == nil then
|
|
116
|
+
if not spec.optional then
|
|
117
|
+
return false, `Missing required argument: {spec.name}`
|
|
118
|
+
end
|
|
119
|
+
continue
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
if not RemoteValidator.checkType(value, spec.type) then
|
|
123
|
+
return false, `Invalid type for {spec.name}: expected {tostring(spec.type)}, got {typeof(value)}`
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
-- Reject extra arguments that were not declared in the schema
|
|
128
|
+
if #args > #schema then
|
|
129
|
+
return false, `Too many arguments: expected {#schema}, got {#args}`
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
return true, nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
--[[ -----------------------------------------------------------------------
|
|
136
|
+
Range Checking
|
|
137
|
+
Validates that numeric values fall within acceptable bounds.
|
|
138
|
+
----------------------------------------------------------------------- ]]
|
|
139
|
+
|
|
140
|
+
function RemoteValidator.checkRange(value: number, min: number, max: number): boolean
|
|
141
|
+
return typeof(value) == "number"
|
|
142
|
+
and value == value -- NaN check
|
|
143
|
+
and value >= min
|
|
144
|
+
and value <= max
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
function RemoteValidator.checkIntegerRange(value: number, min: number, max: number): boolean
|
|
148
|
+
return RemoteValidator.checkRange(value, min, max)
|
|
149
|
+
and math.floor(value) == value
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
--[[ -----------------------------------------------------------------------
|
|
153
|
+
Cooldown Tracking
|
|
154
|
+
Per-player, per-action cooldown enforcement.
|
|
155
|
+
----------------------------------------------------------------------- ]]
|
|
156
|
+
|
|
157
|
+
local cooldowns: { [Player]: { [string]: number } } = {}
|
|
158
|
+
|
|
159
|
+
function RemoteValidator.checkCooldown(player: Player, action: string, cooldownSeconds: number): boolean
|
|
160
|
+
local now = os.clock()
|
|
161
|
+
local playerCooldowns = cooldowns[player]
|
|
162
|
+
|
|
163
|
+
if not playerCooldowns then
|
|
164
|
+
playerCooldowns = {}
|
|
165
|
+
cooldowns[player] = playerCooldowns
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
local lastUsed = playerCooldowns[action]
|
|
169
|
+
if lastUsed and (now - lastUsed) < cooldownSeconds then
|
|
170
|
+
return false
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
playerCooldowns[action] = now
|
|
174
|
+
return true
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
function RemoteValidator.clearPlayerCooldowns(player: Player)
|
|
178
|
+
cooldowns[player] = nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
--[[ -----------------------------------------------------------------------
|
|
182
|
+
Existence Checks
|
|
183
|
+
Validates that targets, objects, and instances actually exist.
|
|
184
|
+
----------------------------------------------------------------------- ]]
|
|
185
|
+
|
|
186
|
+
function RemoteValidator.playerExists(playerName: string): Player?
|
|
187
|
+
local Players = game:GetService("Players")
|
|
188
|
+
return Players:FindFirstChild(playerName) :: Player?
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
function RemoteValidator.characterAlive(player: Player): boolean
|
|
192
|
+
local character = player.Character
|
|
193
|
+
if not character then
|
|
194
|
+
return false
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
local humanoid = character:FindFirstChildOfClass("Humanoid")
|
|
198
|
+
if not humanoid then
|
|
199
|
+
return false
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
return humanoid.Health > 0
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
function RemoteValidator.instanceExists(parent: Instance, name: string, className: string?): Instance?
|
|
206
|
+
local child = parent:FindFirstChild(name)
|
|
207
|
+
if not child then
|
|
208
|
+
return nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
if className and not child:IsA(className) then
|
|
212
|
+
return nil
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
return child
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
--[[ -----------------------------------------------------------------------
|
|
219
|
+
Authorization
|
|
220
|
+
Checks if a player is allowed to perform an action.
|
|
221
|
+
----------------------------------------------------------------------- ]]
|
|
222
|
+
|
|
223
|
+
function RemoteValidator.playerOwnsItem(player: Player, itemId: string, inventoryFolder: Folder?): boolean
|
|
224
|
+
local folder = inventoryFolder or player:FindFirstChild("Inventory") :: Folder?
|
|
225
|
+
if not folder then
|
|
226
|
+
return false
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
return folder:FindFirstChild(itemId) ~= nil
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
function RemoteValidator.playerHasAttribute(player: Player, attribute: string, expectedValue: any?): boolean
|
|
233
|
+
local value = player:GetAttribute(attribute)
|
|
234
|
+
if expectedValue ~= nil then
|
|
235
|
+
return value == expectedValue
|
|
236
|
+
end
|
|
237
|
+
return value ~= nil
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
--[[ -----------------------------------------------------------------------
|
|
241
|
+
Distance Check
|
|
242
|
+
Validates that two positions are within an acceptable range.
|
|
243
|
+
----------------------------------------------------------------------- ]]
|
|
244
|
+
|
|
245
|
+
function RemoteValidator.withinRange(posA: Vector3, posB: Vector3, maxDistance: number): boolean
|
|
246
|
+
return (posA - posB).Magnitude <= maxDistance
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
function RemoteValidator.playerWithinRange(player: Player, targetPos: Vector3, maxDistance: number): boolean
|
|
250
|
+
local character = player.Character
|
|
251
|
+
if not character then
|
|
252
|
+
return false
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
local root = character:FindFirstChild("HumanoidRootPart")
|
|
256
|
+
if not root then
|
|
257
|
+
return false
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
return RemoteValidator.withinRange(root.Position, targetPos, maxDistance)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
--[[ -----------------------------------------------------------------------
|
|
264
|
+
Cleanup
|
|
265
|
+
----------------------------------------------------------------------- ]]
|
|
266
|
+
|
|
267
|
+
game:GetService("Players").PlayerRemoving:Connect(function(player)
|
|
268
|
+
RemoteValidator.clearPlayerCooldowns(player)
|
|
269
|
+
end)
|
|
270
|
+
|
|
271
|
+
return RemoteValidator
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Using the Validation Module
|
|
275
|
+
|
|
276
|
+
```luau
|
|
277
|
+
-- ServerScriptService/RemoteHandlers/DamageHandler.server.luau
|
|
278
|
+
|
|
279
|
+
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
280
|
+
local ServerScriptService = game:GetService("ServerScriptService")
|
|
281
|
+
|
|
282
|
+
local Validator = require(ServerScriptService.Modules.RemoteValidator)
|
|
283
|
+
local DamageRemote = ReplicatedStorage.Remotes.DealDamage
|
|
284
|
+
|
|
285
|
+
local MAX_DAMAGE = 50
|
|
286
|
+
local DAMAGE_COOLDOWN = 0.5 -- seconds
|
|
287
|
+
local ATTACK_RANGE = 15 -- studs
|
|
288
|
+
|
|
289
|
+
local ARG_SCHEMA = {
|
|
290
|
+
{ name = "targetPlayer", type = "Instance" },
|
|
291
|
+
{ name = "damage", type = "number" },
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
DamageRemote.OnServerEvent:Connect(function(player: Player, ...: any)
|
|
295
|
+
local args = { ... }
|
|
296
|
+
|
|
297
|
+
-- 1. Validate argument types
|
|
298
|
+
local valid, err = Validator.validateArgs(args, ARG_SCHEMA)
|
|
299
|
+
if not valid then
|
|
300
|
+
warn(`[DamageHandler] {player.Name}: {err}`)
|
|
301
|
+
return
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
local targetPlayer: Player = args[1]
|
|
305
|
+
local damage: number = args[2]
|
|
306
|
+
|
|
307
|
+
-- 2. Validate the target is actually a Player
|
|
308
|
+
if not targetPlayer:IsA("Player") then
|
|
309
|
+
return
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
-- 3. Validate damage range
|
|
313
|
+
if not Validator.checkIntegerRange(damage, 1, MAX_DAMAGE) then
|
|
314
|
+
warn(`[DamageHandler] {player.Name}: damage out of range ({damage})`)
|
|
315
|
+
return
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
-- 4. Cooldown check
|
|
319
|
+
if not Validator.checkCooldown(player, "DealDamage", DAMAGE_COOLDOWN) then
|
|
320
|
+
return
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
-- 5. Verify attacker is alive
|
|
324
|
+
if not Validator.characterAlive(player) then
|
|
325
|
+
return
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
-- 6. Verify target is alive
|
|
329
|
+
if not Validator.characterAlive(targetPlayer) then
|
|
330
|
+
return
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
-- 7. Range check -- attacker must be near the target
|
|
334
|
+
local targetRoot = targetPlayer.Character and targetPlayer.Character:FindFirstChild("HumanoidRootPart")
|
|
335
|
+
if not targetRoot then
|
|
336
|
+
return
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
if not Validator.playerWithinRange(player, targetRoot.Position, ATTACK_RANGE) then
|
|
340
|
+
warn(`[DamageHandler] {player.Name}: target out of range`)
|
|
341
|
+
return
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
-- 8. Authorization -- verify the player has a weapon equipped
|
|
345
|
+
local character = player.Character
|
|
346
|
+
local weapon = character and character:FindFirstChildOfClass("Tool")
|
|
347
|
+
if not weapon or not weapon:GetAttribute("CanDealDamage") then
|
|
348
|
+
warn(`[DamageHandler] {player.Name}: no valid weapon equipped`)
|
|
349
|
+
return
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
-- 9. Server calculates actual damage (never trust client damage value directly)
|
|
353
|
+
local serverDamage = math.min(damage, weapon:GetAttribute("MaxDamage") or MAX_DAMAGE)
|
|
354
|
+
|
|
355
|
+
-- 10. Apply damage
|
|
356
|
+
local targetHumanoid = targetPlayer.Character:FindFirstChildOfClass("Humanoid")
|
|
357
|
+
if targetHumanoid then
|
|
358
|
+
targetHumanoid:TakeDamage(serverDamage)
|
|
359
|
+
end
|
|
360
|
+
end)
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
### Server-Authoritative Design
|
|
366
|
+
|
|
367
|
+
The server owns all game state. The client requests actions; the server decides outcomes.
|
|
368
|
+
|
|
369
|
+
### Movement Validation
|
|
370
|
+
|
|
371
|
+
```luau
|
|
372
|
+
-- ServerScriptService/Security/MovementValidator.server.luau
|
|
373
|
+
|
|
374
|
+
local Players = game:GetService("Players")
|
|
375
|
+
local RunService = game:GetService("RunService")
|
|
376
|
+
|
|
377
|
+
local MAX_SPEED = 50 -- studs per second (walk + sprint + tolerance)
|
|
378
|
+
local MAX_VERTICAL_SPEED = 100 -- studs per second (jumping/falling tolerance)
|
|
379
|
+
local VIOLATION_THRESHOLD = 5 -- strikes before action
|
|
380
|
+
local CHECK_INTERVAL = 0.5 -- seconds between checks
|
|
381
|
+
|
|
382
|
+
local playerData: { [Player]: {
|
|
383
|
+
lastPosition: Vector3,
|
|
384
|
+
lastCheck: number,
|
|
385
|
+
violations: number,
|
|
386
|
+
} } = {}
|
|
387
|
+
|
|
388
|
+
Players.PlayerAdded:Connect(function(player)
|
|
389
|
+
player.CharacterAdded:Connect(function(character)
|
|
390
|
+
local root = character:WaitForChild("HumanoidRootPart")
|
|
391
|
+
playerData[player] = {
|
|
392
|
+
lastPosition = root.Position,
|
|
393
|
+
lastCheck = os.clock(),
|
|
394
|
+
violations = 0,
|
|
395
|
+
}
|
|
396
|
+
end)
|
|
397
|
+
end)
|
|
398
|
+
|
|
399
|
+
Players.PlayerRemoving:Connect(function(player)
|
|
400
|
+
playerData[player] = nil
|
|
401
|
+
end)
|
|
402
|
+
|
|
403
|
+
RunService.Heartbeat:Connect(function()
|
|
404
|
+
local now = os.clock()
|
|
405
|
+
|
|
406
|
+
for player, data in playerData do
|
|
407
|
+
if (now - data.lastCheck) < CHECK_INTERVAL then
|
|
408
|
+
continue
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
local character = player.Character
|
|
412
|
+
if not character then
|
|
413
|
+
continue
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
local root = character:FindFirstChild("HumanoidRootPart")
|
|
417
|
+
if not root then
|
|
418
|
+
continue
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
local dt = now - data.lastCheck
|
|
422
|
+
local displacement = root.Position - data.lastPosition
|
|
423
|
+
local horizontalSpeed = Vector3.new(displacement.X, 0, displacement.Z).Magnitude / dt
|
|
424
|
+
local verticalSpeed = math.abs(displacement.Y) / dt
|
|
425
|
+
|
|
426
|
+
if horizontalSpeed > MAX_SPEED or verticalSpeed > MAX_VERTICAL_SPEED then
|
|
427
|
+
data.violations += 1
|
|
428
|
+
warn(`[MovementValidator] {player.Name}: speed violation #{data.violations} (h={math.floor(horizontalSpeed)}, v={math.floor(verticalSpeed)})`)
|
|
429
|
+
|
|
430
|
+
if data.violations >= VIOLATION_THRESHOLD then
|
|
431
|
+
-- Teleport player back to last valid position
|
|
432
|
+
root.CFrame = CFrame.new(data.lastPosition)
|
|
433
|
+
-- Or kick for persistent abuse:
|
|
434
|
+
-- player:Kick("Movement anomaly detected.")
|
|
435
|
+
end
|
|
436
|
+
else
|
|
437
|
+
-- Decay violations over time for legitimate edge cases
|
|
438
|
+
data.violations = math.max(0, data.violations - 1)
|
|
439
|
+
data.lastPosition = root.Position
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
data.lastCheck = now
|
|
443
|
+
end
|
|
444
|
+
end)
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
### Damage Validation
|
|
448
|
+
|
|
449
|
+
```luau
|
|
450
|
+
-- Server decides damage, not the client.
|
|
451
|
+
|
|
452
|
+
local function calculateDamage(attacker: Player, weapon: Tool, target: Player): number?
|
|
453
|
+
local weaponConfig = WeaponDatabase[weapon.Name]
|
|
454
|
+
if not weaponConfig then
|
|
455
|
+
return nil
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
-- Server checks weapon cooldown
|
|
459
|
+
local lastFire = weapon:GetAttribute("LastFired") or 0
|
|
460
|
+
if os.clock() - lastFire < weaponConfig.Cooldown then
|
|
461
|
+
return nil
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
-- Server checks range
|
|
465
|
+
local attackerRoot = attacker.Character and attacker.Character:FindFirstChild("HumanoidRootPart")
|
|
466
|
+
local targetRoot = target.Character and target.Character:FindFirstChild("HumanoidRootPart")
|
|
467
|
+
if not attackerRoot or not targetRoot then
|
|
468
|
+
return nil
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
local distance = (attackerRoot.Position - targetRoot.Position).Magnitude
|
|
472
|
+
if distance > weaponConfig.Range then
|
|
473
|
+
return nil
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
-- Server calculates damage
|
|
477
|
+
weapon:SetAttribute("LastFired", os.clock())
|
|
478
|
+
return weaponConfig.BaseDamage
|
|
479
|
+
end
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
### Currency Transactions
|
|
483
|
+
|
|
484
|
+
```luau
|
|
485
|
+
-- WRONG: Client tells server how much to add
|
|
486
|
+
CurrencyRemote.OnServerEvent:Connect(function(player, amount)
|
|
487
|
+
player.leaderstats.Gold.Value += amount -- exploiter sends 999999
|
|
488
|
+
end)
|
|
489
|
+
|
|
490
|
+
-- RIGHT: Server calculates the reward
|
|
491
|
+
QuestCompleteRemote.OnServerEvent:Connect(function(player, questId)
|
|
492
|
+
-- Validate quest ID type
|
|
493
|
+
if typeof(questId) ~= "string" then
|
|
494
|
+
return
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
-- Server checks quest state
|
|
498
|
+
local questData = PlayerQuestData[player]
|
|
499
|
+
if not questData or not questData[questId] then
|
|
500
|
+
return
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
if questData[questId].completed then
|
|
504
|
+
return -- already claimed
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
-- Server looks up the reward from its own data
|
|
508
|
+
local questConfig = QuestDatabase[questId]
|
|
509
|
+
if not questConfig then
|
|
510
|
+
return
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
-- Server awards the reward
|
|
514
|
+
questData[questId].completed = true
|
|
515
|
+
player.leaderstats.Gold.Value += questConfig.Reward
|
|
516
|
+
end)
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
### Inventory Operations
|
|
520
|
+
|
|
521
|
+
```luau
|
|
522
|
+
-- Server-side trade validation
|
|
523
|
+
local function executeTrade(playerA: Player, playerB: Player, itemIdA: string, itemIdB: string): boolean
|
|
524
|
+
-- Both players must be alive and in range
|
|
525
|
+
if not Validator.characterAlive(playerA) or not Validator.characterAlive(playerB) then
|
|
526
|
+
return false
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
-- Verify ownership on the server
|
|
530
|
+
local invA = playerA:FindFirstChild("Inventory")
|
|
531
|
+
local invB = playerB:FindFirstChild("Inventory")
|
|
532
|
+
if not invA or not invB then
|
|
533
|
+
return false
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
---
|
|
537
|
+
|
|
538
|
+
## Rate Limiting
|
|
539
|
+
|
|
540
|
+
Roblox's built-in throttle (~500 req/sec per client) is NOT a substitute for custom rate limiting. Players can still spam remotes at hundreds of requests per second. You need application-level throttling.
|
|
541
|
+
|
|
542
|
+
### Pattern 1: Per-Player Cooldown Table
|
|
543
|
+
|
|
544
|
+
Simple and effective for most games. Each remote has a minimum time between calls per player.
|
|
545
|
+
|
|
546
|
+
```luau
|
|
547
|
+
local cooldowns: {[Player]: {[string]: number}} = {}
|
|
548
|
+
local COOLDOWN = 0.2 -- seconds between calls
|
|
549
|
+
|
|
550
|
+
local function isThrottled(player: Player, remoteName: string): boolean
|
|
551
|
+
local now = os.clock()
|
|
552
|
+
if not cooldowns[player] then
|
|
553
|
+
cooldowns[player] = {}
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
local lastCall = cooldowns[player][remoteName]
|
|
557
|
+
if lastCall and (now - lastCall) < COOLDOWN then
|
|
558
|
+
return true -- throttled
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
cooldowns[player][remoteName] = now
|
|
562
|
+
return false
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
-- Clean up when player leaves
|
|
566
|
+
Players.PlayerRemoving:Connect(function(player)
|
|
567
|
+
cooldowns[player] = nil
|
|
568
|
+
end)
|
|
569
|
+
|
|
570
|
+
-- Usage
|
|
571
|
+
BuyItem.OnServerEvent:Connect(function(player, itemId)
|
|
572
|
+
if isThrottled(player, "BuyItem") then return end
|
|
573
|
+
-- process purchase
|
|
574
|
+
end)
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
### Pattern 2: Declarative Remote Definitions
|
|
578
|
+
|
|
579
|
+
Define all remotes in one place with rate limits, validation, and allowed states. Cleaner than scattered OnServerEvent handlers.
|
|
580
|
+
|
|
581
|
+
```luau
|
|
582
|
+
type RemoteDef = {
|
|
583
|
+
RateLimit: number?,
|
|
584
|
+
Validate: (Player, ...any) -> boolean,
|
|
585
|
+
Handler: (Player, ...any) -> (),
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
local Remotes: {[string]: RemoteDef} = {
|
|
589
|
+
BuyItem = {
|
|
590
|
+
RateLimit = 0.5,
|
|
591
|
+
Validate = function(player, itemId)
|
|
592
|
+
return typeof(itemId) == "string" and #itemId < 50
|
|
593
|
+
end,
|
|
594
|
+
Handler = function(player, itemId)
|
|
595
|
+
-- process purchase
|
|
596
|
+
end,
|
|
597
|
+
},
|
|
598
|
+
EquipTool = {
|
|
599
|
+
RateLimit = 0.3,
|
|
600
|
+
Validate = function(player, toolId)
|
|
601
|
+
return typeof(toolId) == "string"
|
|
602
|
+
end,
|
|
603
|
+
Handler = function(player, toolId)
|
|
604
|
+
-- equip tool
|
|
605
|
+
end,
|
|
606
|
+
},
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
-- Wire up automatically
|
|
610
|
+
for name, def in Remotes do
|
|
611
|
+
local remote = ReplicatedStorage:WaitForChild(name)
|
|
612
|
+
remote.OnServerEvent:Connect(function(player, ...)
|
|
613
|
+
if def.RateLimit and isThrottled(player, name) then return end
|
|
614
|
+
if not def.Validate(player, ...) then return end
|
|
615
|
+
def.Handler(player, ...)
|
|
616
|
+
end)
|
|
617
|
+
end
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
### Pattern 3: Suspicion Scoring
|
|
621
|
+
|
|
622
|
+
For high-stakes games. Track suspicious behavior over time instead of hard-blocking.
|
|
623
|
+
|
|
624
|
+
```luau
|
|
625
|
+
local suspicion: {[Player]: number} = {}
|
|
626
|
+
local SUSPICION_THRESHOLD = 10
|
|
627
|
+
local DECAY_RATE = 1 -- points lost per second
|
|
628
|
+
|
|
629
|
+
local function addSuspicion(player: Player, amount: number, reason: string)
|
|
630
|
+
suspicion[player] = (suspicion[player] or 0) + amount
|
|
631
|
+
if suspicion[player] >= SUSPICION_THRESHOLD then
|
|
632
|
+
warn(`High suspicion for {player.Name}: {reason}`)
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
-- In remote handler
|
|
637
|
+
BuyItem.OnServerEvent:Connect(function(player, itemId)
|
|
638
|
+
if isThrottled(player, "BuyItem") then
|
|
639
|
+
addSuspicion(player, 2, "rate limit exceeded")
|
|
640
|
+
return
|
|
641
|
+
end
|
|
642
|
+
-- normal processing
|
|
643
|
+
end)
|
|
644
|
+
|
|
645
|
+
-- Decay suspicion over time
|
|
646
|
+
task.spawn(function()
|
|
647
|
+
while true do
|
|
648
|
+
task.wait(1)
|
|
649
|
+
for player, score in suspicion do
|
|
650
|
+
suspicion[player] = math.max(0, score - DECAY_RATE)
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
end)
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
### What NOT to Do
|
|
657
|
+
|
|
658
|
+
```luau
|
|
659
|
+
-- BAD: no rate limiting at all
|
|
660
|
+
BuyItem.OnServerEvent:Connect(function(player, itemId)
|
|
661
|
+
-- exploiter can call this 1000 times/second
|
|
662
|
+
grantItem(player, itemId)
|
|
663
|
+
end)
|
|
664
|
+
|
|
665
|
+
-- BAD: client-side rate limiting (exploiter bypasses)
|
|
666
|
+
-- Rate limiting MUST be server-side
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
Source: Roblox Server-Side Detection Guide (Roblox/creator-docs, MIT), DevForum rate limiting patterns
|