roblox-opencode 1.0.0

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.
Files changed (248) hide show
  1. package/README.md +122 -0
  2. package/commands/setup-game.md +108 -0
  3. package/commands/sync-check.md +53 -0
  4. package/core/roblox-core.md +93 -0
  5. package/dist/server.js +167 -0
  6. package/package.json +35 -0
  7. package/skills/roblox-analytics/SKILL.md +277 -0
  8. package/skills/roblox-analytics/references/event-batcher.luau +75 -0
  9. package/skills/roblox-animation-vfx/SKILL.md +1325 -0
  10. package/skills/roblox-architecture/SKILL.md +863 -0
  11. package/skills/roblox-architecture/references/combat-systems.md +1381 -0
  12. package/skills/roblox-code-review/SKILL.md +687 -0
  13. package/skills/roblox-data/SKILL.md +889 -0
  14. package/skills/roblox-data/references/inventory-systems.md +1729 -0
  15. package/skills/roblox-debug/SKILL.md +99 -0
  16. package/skills/roblox-gui/SKILL.md +1103 -0
  17. package/skills/roblox-gui-fusion/SKILL.md +150 -0
  18. package/skills/roblox-gui-fusion/references/inventory.luau +427 -0
  19. package/skills/roblox-gui-fusion/references/settings-menu.luau +579 -0
  20. package/skills/roblox-gui-fusion/references/shop.luau +411 -0
  21. package/skills/roblox-luau-mastery/SKILL.md +1519 -0
  22. package/skills/roblox-monetization/SKILL.md +1084 -0
  23. package/skills/roblox-monetization/references/process-receipt.luau +131 -0
  24. package/skills/roblox-networking/SKILL.md +669 -0
  25. package/skills/roblox-networking/references/remote-validator.luau +193 -0
  26. package/skills/roblox-publish-checklist/SKILL.md +128 -0
  27. package/skills/roblox-runtime/SKILL.md +753 -0
  28. package/skills/roblox-sharp-edges/SKILL.md +295 -0
  29. package/skills/roblox-sync/SKILL.md +126 -0
  30. package/skills/roblox-testing/SKILL.md +943 -0
  31. package/skills/roblox-tooling/SKILL.md +150 -0
  32. package/vendor/LICENSES/ProfileStore-LICENSE +201 -0
  33. package/vendor/LICENSES/RbxUtil-LICENSE +7 -0
  34. package/vendor/LICENSES/promise-LICENSE +21 -0
  35. package/vendor/LICENSES/t-LICENSE +21 -0
  36. package/vendor/LICENSES/testez-LICENSE +201 -0
  37. package/vendor/README.md +84 -0
  38. package/vendor/fusion/Animation/ExternalTime.luau +84 -0
  39. package/vendor/fusion/Animation/Spring.luau +322 -0
  40. package/vendor/fusion/Animation/Stopwatch.luau +128 -0
  41. package/vendor/fusion/Animation/Tween.luau +187 -0
  42. package/vendor/fusion/Animation/getTweenDuration.luau +27 -0
  43. package/vendor/fusion/Animation/getTweenRatio.luau +47 -0
  44. package/vendor/fusion/Animation/lerpType.luau +164 -0
  45. package/vendor/fusion/Animation/packType.luau +100 -0
  46. package/vendor/fusion/Animation/springCoefficients.luau +80 -0
  47. package/vendor/fusion/Animation/unpackType.luau +103 -0
  48. package/vendor/fusion/Colour/Oklab.luau +70 -0
  49. package/vendor/fusion/Colour/sRGB.luau +55 -0
  50. package/vendor/fusion/External.luau +168 -0
  51. package/vendor/fusion/ExternalDebug.luau +70 -0
  52. package/vendor/fusion/Graph/Observer.luau +114 -0
  53. package/vendor/fusion/Graph/castToGraph.luau +29 -0
  54. package/vendor/fusion/Graph/change.luau +81 -0
  55. package/vendor/fusion/Graph/depend.luau +33 -0
  56. package/vendor/fusion/Graph/evaluate.luau +56 -0
  57. package/vendor/fusion/Instances/Attribute.luau +58 -0
  58. package/vendor/fusion/Instances/AttributeChange.luau +47 -0
  59. package/vendor/fusion/Instances/AttributeOut.luau +63 -0
  60. package/vendor/fusion/Instances/Child.luau +21 -0
  61. package/vendor/fusion/Instances/Children.luau +148 -0
  62. package/vendor/fusion/Instances/Hydrate.luau +33 -0
  63. package/vendor/fusion/Instances/New.luau +53 -0
  64. package/vendor/fusion/Instances/OnChange.luau +50 -0
  65. package/vendor/fusion/Instances/OnEvent.luau +54 -0
  66. package/vendor/fusion/Instances/Out.luau +69 -0
  67. package/vendor/fusion/Instances/applyInstanceProps.luau +149 -0
  68. package/vendor/fusion/Instances/defaultProps.luau +194 -0
  69. package/vendor/fusion/LICENSE +21 -0
  70. package/vendor/fusion/Logging/formatError.luau +49 -0
  71. package/vendor/fusion/Logging/messages.luau +52 -0
  72. package/vendor/fusion/Logging/parseError.luau +25 -0
  73. package/vendor/fusion/Memory/checkLifetime.luau +134 -0
  74. package/vendor/fusion/Memory/deriveScope.luau +24 -0
  75. package/vendor/fusion/Memory/deriveScopeImpl.luau +45 -0
  76. package/vendor/fusion/Memory/doCleanup.luau +79 -0
  77. package/vendor/fusion/Memory/innerScope.luau +34 -0
  78. package/vendor/fusion/Memory/legacyCleanup.luau +18 -0
  79. package/vendor/fusion/Memory/needsDestruction.luau +17 -0
  80. package/vendor/fusion/Memory/poisonScope.luau +34 -0
  81. package/vendor/fusion/Memory/scopePool.luau +55 -0
  82. package/vendor/fusion/Memory/scoped.luau +27 -0
  83. package/vendor/fusion/Memory/whichLivesLonger.luau +75 -0
  84. package/vendor/fusion/RobloxExternal.luau +98 -0
  85. package/vendor/fusion/State/Computed.luau +139 -0
  86. package/vendor/fusion/State/For/Disassembly.luau +211 -0
  87. package/vendor/fusion/State/For/ForTypes.luau +30 -0
  88. package/vendor/fusion/State/For/init.luau +110 -0
  89. package/vendor/fusion/State/ForKeys.luau +94 -0
  90. package/vendor/fusion/State/ForPairs.luau +97 -0
  91. package/vendor/fusion/State/ForValues.luau +94 -0
  92. package/vendor/fusion/State/Value.luau +88 -0
  93. package/vendor/fusion/State/castToState.luau +26 -0
  94. package/vendor/fusion/State/peek.luau +31 -0
  95. package/vendor/fusion/State/updateAll.luau +1 -0
  96. package/vendor/fusion/Types.luau +314 -0
  97. package/vendor/fusion/Utility/Contextual.luau +91 -0
  98. package/vendor/fusion/Utility/Safe.luau +23 -0
  99. package/vendor/fusion/Utility/isSimilar.luau +29 -0
  100. package/vendor/fusion/Utility/merge.luau +35 -0
  101. package/vendor/fusion/Utility/nameOf.luau +35 -0
  102. package/vendor/fusion/Utility/never.luau +14 -0
  103. package/vendor/fusion/Utility/nicknames.luau +11 -0
  104. package/vendor/fusion/Utility/xtypeof.luau +27 -0
  105. package/vendor/fusion/init.luau +82 -0
  106. package/vendor/profilestore/init.luau +2243 -0
  107. package/vendor/promise/init.luau +1982 -0
  108. package/vendor/rbxutil/buffer-util/Buffer.test.luau +25 -0
  109. package/vendor/rbxutil/buffer-util/BufferReader.luau +228 -0
  110. package/vendor/rbxutil/buffer-util/BufferWriter.luau +269 -0
  111. package/vendor/rbxutil/buffer-util/DataTypeBuffer.luau +223 -0
  112. package/vendor/rbxutil/buffer-util/Types.luau +60 -0
  113. package/vendor/rbxutil/buffer-util/index.d.ts +153 -0
  114. package/vendor/rbxutil/buffer-util/init.luau +41 -0
  115. package/vendor/rbxutil/buffer-util/package.json +16 -0
  116. package/vendor/rbxutil/buffer-util/wally.toml +9 -0
  117. package/vendor/rbxutil/comm/Client/ClientComm.luau +232 -0
  118. package/vendor/rbxutil/comm/Client/ClientRemoteProperty.luau +156 -0
  119. package/vendor/rbxutil/comm/Client/ClientRemoteSignal.luau +109 -0
  120. package/vendor/rbxutil/comm/Client/init.luau +135 -0
  121. package/vendor/rbxutil/comm/Server/RemoteProperty.luau +295 -0
  122. package/vendor/rbxutil/comm/Server/RemoteSignal.luau +211 -0
  123. package/vendor/rbxutil/comm/Server/ServerComm.luau +211 -0
  124. package/vendor/rbxutil/comm/Server/init.luau +140 -0
  125. package/vendor/rbxutil/comm/Types.luau +18 -0
  126. package/vendor/rbxutil/comm/Util.luau +27 -0
  127. package/vendor/rbxutil/comm/init.luau +35 -0
  128. package/vendor/rbxutil/comm/wally.toml +13 -0
  129. package/vendor/rbxutil/component/init.luau +759 -0
  130. package/vendor/rbxutil/component/init.test.luau +311 -0
  131. package/vendor/rbxutil/component/wally.toml +14 -0
  132. package/vendor/rbxutil/concur/init.luau +542 -0
  133. package/vendor/rbxutil/concur/init.test.luau +364 -0
  134. package/vendor/rbxutil/concur/wally.toml +8 -0
  135. package/vendor/rbxutil/enum-list/init.luau +101 -0
  136. package/vendor/rbxutil/enum-list/init.test.luau +91 -0
  137. package/vendor/rbxutil/enum-list/wally.toml +8 -0
  138. package/vendor/rbxutil/find/index.d.ts +20 -0
  139. package/vendor/rbxutil/find/init.luau +44 -0
  140. package/vendor/rbxutil/find/package.json +17 -0
  141. package/vendor/rbxutil/find/wally.toml +8 -0
  142. package/vendor/rbxutil/input/Gamepad.luau +559 -0
  143. package/vendor/rbxutil/input/Keyboard.luau +124 -0
  144. package/vendor/rbxutil/input/Mouse.luau +278 -0
  145. package/vendor/rbxutil/input/PreferredInput.luau +91 -0
  146. package/vendor/rbxutil/input/Touch.luau +120 -0
  147. package/vendor/rbxutil/input/init.luau +33 -0
  148. package/vendor/rbxutil/input/wally.toml +12 -0
  149. package/vendor/rbxutil/loader/index.d.ts +15 -0
  150. package/vendor/rbxutil/loader/init.luau +137 -0
  151. package/vendor/rbxutil/loader/wally.toml +8 -0
  152. package/vendor/rbxutil/log/index.d.ts +38 -0
  153. package/vendor/rbxutil/log/init.luau +746 -0
  154. package/vendor/rbxutil/log/wally.toml +8 -0
  155. package/vendor/rbxutil/net/init.luau +190 -0
  156. package/vendor/rbxutil/net/wally.toml +8 -0
  157. package/vendor/rbxutil/option/index.d.ts +44 -0
  158. package/vendor/rbxutil/option/init.luau +489 -0
  159. package/vendor/rbxutil/option/init.test.luau +342 -0
  160. package/vendor/rbxutil/option/wally.toml +8 -0
  161. package/vendor/rbxutil/pid/index.d.ts +53 -0
  162. package/vendor/rbxutil/pid/init.luau +195 -0
  163. package/vendor/rbxutil/pid/package.json +16 -0
  164. package/vendor/rbxutil/pid/wally.toml +9 -0
  165. package/vendor/rbxutil/quaternion/index.d.ts +117 -0
  166. package/vendor/rbxutil/quaternion/init.luau +570 -0
  167. package/vendor/rbxutil/quaternion/package.json +16 -0
  168. package/vendor/rbxutil/quaternion/wally.toml +9 -0
  169. package/vendor/rbxutil/query/index.d.ts +43 -0
  170. package/vendor/rbxutil/query/init.luau +117 -0
  171. package/vendor/rbxutil/query/package.json +18 -0
  172. package/vendor/rbxutil/query/wally.toml +9 -0
  173. package/vendor/rbxutil/sequent/index.d.ts +28 -0
  174. package/vendor/rbxutil/sequent/init.luau +340 -0
  175. package/vendor/rbxutil/sequent/package.json +16 -0
  176. package/vendor/rbxutil/sequent/wally.toml +9 -0
  177. package/vendor/rbxutil/ser/init.luau +175 -0
  178. package/vendor/rbxutil/ser/init.test.luau +50 -0
  179. package/vendor/rbxutil/ser/wally.toml +11 -0
  180. package/vendor/rbxutil/shake/index.d.ts +36 -0
  181. package/vendor/rbxutil/shake/init.luau +532 -0
  182. package/vendor/rbxutil/shake/init.test.luau +267 -0
  183. package/vendor/rbxutil/shake/package.json +16 -0
  184. package/vendor/rbxutil/shake/wally.toml +9 -0
  185. package/vendor/rbxutil/signal/index.d.ts +100 -0
  186. package/vendor/rbxutil/signal/init.luau +432 -0
  187. package/vendor/rbxutil/signal/init.test.luau +190 -0
  188. package/vendor/rbxutil/signal/package.json +17 -0
  189. package/vendor/rbxutil/signal/wally.toml +9 -0
  190. package/vendor/rbxutil/silo/TableWatcher.luau +65 -0
  191. package/vendor/rbxutil/silo/Util.luau +55 -0
  192. package/vendor/rbxutil/silo/init.luau +338 -0
  193. package/vendor/rbxutil/silo/init.test.luau +215 -0
  194. package/vendor/rbxutil/silo/wally.toml +8 -0
  195. package/vendor/rbxutil/spring/index.d.ts +40 -0
  196. package/vendor/rbxutil/spring/init.luau +97 -0
  197. package/vendor/rbxutil/spring/package.json +17 -0
  198. package/vendor/rbxutil/spring/wally.toml +8 -0
  199. package/vendor/rbxutil/stream/index.d.ts +88 -0
  200. package/vendor/rbxutil/stream/init.luau +597 -0
  201. package/vendor/rbxutil/stream/package.json +18 -0
  202. package/vendor/rbxutil/stream/wally.toml +9 -0
  203. package/vendor/rbxutil/streamable/Streamable.luau +202 -0
  204. package/vendor/rbxutil/streamable/StreamableUtil.luau +80 -0
  205. package/vendor/rbxutil/streamable/init.luau +8 -0
  206. package/vendor/rbxutil/streamable/wally.toml +12 -0
  207. package/vendor/rbxutil/symbol/init.luau +56 -0
  208. package/vendor/rbxutil/symbol/init.test.luau +37 -0
  209. package/vendor/rbxutil/symbol/wally.toml +8 -0
  210. package/vendor/rbxutil/table-util/init.luau +938 -0
  211. package/vendor/rbxutil/table-util/init.test.luau +439 -0
  212. package/vendor/rbxutil/table-util/wally.toml +8 -0
  213. package/vendor/rbxutil/task-queue/index.d.ts +27 -0
  214. package/vendor/rbxutil/task-queue/init.luau +97 -0
  215. package/vendor/rbxutil/task-queue/wally.toml +8 -0
  216. package/vendor/rbxutil/timer/index.d.ts +81 -0
  217. package/vendor/rbxutil/timer/init.luau +249 -0
  218. package/vendor/rbxutil/timer/init.test.luau +73 -0
  219. package/vendor/rbxutil/timer/wally.toml +11 -0
  220. package/vendor/rbxutil/tree/index.d.ts +15 -0
  221. package/vendor/rbxutil/tree/init.luau +137 -0
  222. package/vendor/rbxutil/tree/wally.toml +8 -0
  223. package/vendor/rbxutil/trove/index.d.ts +46 -0
  224. package/vendor/rbxutil/trove/init.luau +787 -0
  225. package/vendor/rbxutil/trove/init.test.luau +203 -0
  226. package/vendor/rbxutil/trove/wally.toml +8 -0
  227. package/vendor/rbxutil/typed-remote/init.luau +196 -0
  228. package/vendor/rbxutil/typed-remote/wally.toml +8 -0
  229. package/vendor/rbxutil/wait-for/index.d.ts +17 -0
  230. package/vendor/rbxutil/wait-for/init.luau +257 -0
  231. package/vendor/rbxutil/wait-for/init.test.luau +182 -0
  232. package/vendor/rbxutil/wait-for/wally.toml +11 -0
  233. package/vendor/t/t.lua +1350 -0
  234. package/vendor/testez/Context.lua +26 -0
  235. package/vendor/testez/Expectation.lua +311 -0
  236. package/vendor/testez/ExpectationContext.lua +38 -0
  237. package/vendor/testez/LifecycleHooks.lua +89 -0
  238. package/vendor/testez/Reporters/TeamCityReporter.lua +102 -0
  239. package/vendor/testez/Reporters/TextReporter.lua +106 -0
  240. package/vendor/testez/Reporters/TextReporterQuiet.lua +97 -0
  241. package/vendor/testez/TestBootstrap.lua +147 -0
  242. package/vendor/testez/TestEnum.lua +28 -0
  243. package/vendor/testez/TestPlan.lua +304 -0
  244. package/vendor/testez/TestPlanner.lua +40 -0
  245. package/vendor/testez/TestResults.lua +112 -0
  246. package/vendor/testez/TestRunner.lua +188 -0
  247. package/vendor/testez/TestSession.lua +243 -0
  248. package/vendor/testez/init.lua +40 -0
@@ -0,0 +1,1381 @@
1
+ # Combat Systems Reference
2
+
3
+ > **Load when:** Building combat, weapons, PvP, PvE, damage systems, hitboxes, melee/ranged attacks, combo systems, blocking/parrying, cooldowns, or skill-based combat.
4
+
5
+ ---
6
+
7
+ ## 1. Overview
8
+
9
+ Combat is one of the most exploited systems in Roblox. Every design decision must start from the principle: **the server is the sole authority on damage, health, and combat state**. The client's job is to send *intent* ("I pressed attack"), show responsive animations/VFX, and display server-confirmed results.
10
+
11
+ This reference covers:
12
+
13
+ - Server-authoritative architecture
14
+ - Hitbox detection (spatial queries and raycasts)
15
+ - Combat state machines
16
+ - Damage calculation formulas
17
+ - Melee and ranged combat patterns
18
+ - Cooldown enforcement
19
+ - Anti-exploit hardening
20
+
21
+ All code examples are production-grade Luau. Adapt to your game's scale.
22
+
23
+ ---
24
+
25
+ ## 2. Combat Architecture
26
+
27
+ ### The Golden Rule
28
+
29
+ > The client sends **intent**. The server **validates and applies**.
30
+
31
+ ```
32
+ Client Server
33
+ ------ ------
34
+ Player presses attack
35
+ |
36
+ +--> FireServer("Attack")
37
+ |
38
+ +--> Validate cooldown
39
+ +--> Validate state (not stunned, not dead)
40
+ +--> Run hitbox detection
41
+ +--> Calculate damage
42
+ +--> Apply damage to targets
43
+ +--> Update attacker state/cooldowns
44
+ |
45
+ <-- FireClient(results) ------+
46
+ |
47
+ +--> Play hit VFX/SFX
48
+ +--> Show damage numbers
49
+ ```
50
+
51
+ ### Why Not Client-Authoritative?
52
+
53
+ Exploiters can modify LocalScripts, fire RemoteEvents with fabricated data, and teleport their character. If the client decides who gets hit or how much damage to deal, a single exploiter ruins the entire server.
54
+
55
+ ### Latency Compensation
56
+
57
+ For fast-paced combat, pure server-side hitboxes can feel unresponsive. Two strategies:
58
+
59
+ 1. **Predictive VFX** -- Client plays the swing animation and VFX immediately on input. Server confirms the hit separately. If the server says "miss," the client shows no damage numbers. Players perceive responsiveness from the animation even if the server takes 50-100ms to confirm.
60
+
61
+ 2. **Client hint with server validation** -- Client sends its estimated hit targets along with the attack request. Server re-runs the hitbox check using the character's server-side position. If the server's check agrees, damage applies. If not, the client hint is discarded. This catches teleport exploits while tolerating minor positional lag.
62
+
63
+ ---
64
+
65
+ ## 3. Hitbox Detection
66
+
67
+ ### Area Detection (Melee / AoE)
68
+
69
+ Use `workspace:GetPartBoundsInBox()` for volume-based detection:
70
+
71
+ ```luau
72
+ local function getHitTargets(attackerRootPart: BasePart, range: number, width: number, height: number): {Model}
73
+ local cf = attackerRootPart.CFrame * CFrame.new(0, 0, -range / 2)
74
+ local size = Vector3.new(width, height, range)
75
+
76
+ local overlapParams = OverlapParams.new()
77
+ overlapParams.FilterType = Enum.RaycastFilterType.Exclude
78
+ overlapParams.FilterDescendantsInstances = { attackerRootPart.Parent }
79
+
80
+ local parts = workspace:GetPartBoundsInBox(cf, size, overlapParams)
81
+
82
+ local hitCharacters: {[Model]: true} = {}
83
+ local results: {Model} = {}
84
+
85
+ for _, part in parts do
86
+ local model = part:FindFirstAncestorWhichIsA("Model")
87
+ if model and model:FindFirstChildWhichIsA("Humanoid") and not hitCharacters[model] then
88
+ hitCharacters[model] = true
89
+ table.insert(results, model)
90
+ end
91
+ end
92
+
93
+ return results
94
+ end
95
+ ```
96
+
97
+ ### Alternative: `GetPartsInPart`
98
+
99
+ When you have a physical hitbox part (e.g., a sword blade):
100
+
101
+ ```luau
102
+ local overlapParams = OverlapParams.new()
103
+ overlapParams.FilterType = Enum.RaycastFilterType.Exclude
104
+ overlapParams.FilterDescendantsInstances = { swordModel }
105
+
106
+ local touchingParts = workspace:GetPartsInPart(hitboxPart, overlapParams)
107
+ ```
108
+
109
+ ### Raycast Detection (Ranged / Hitscan)
110
+
111
+ ```luau
112
+ local function hitscanRaycast(origin: Vector3, direction: Vector3, ignoreList: {Instance}): RaycastResult?
113
+ local raycastParams = RaycastParams.new()
114
+ raycastParams.FilterType = Enum.RaycastFilterType.Exclude
115
+ raycastParams.FilterDescendantsInstances = ignoreList
116
+
117
+ return workspace:Raycast(origin, direction, raycastParams)
118
+ end
119
+ ```
120
+
121
+ ### Hitbox Sizing Guidelines
122
+
123
+ | Weapon Type | Range (studs) | Width (studs) | Height (studs) |
124
+ |---|---|---|---|
125
+ | Dagger / Fist | 4-5 | 4 | 5 |
126
+ | Sword | 6-8 | 5 | 6 |
127
+ | Greatsword | 8-12 | 7 | 7 |
128
+ | Spear / Polearm | 10-14 | 3 | 5 |
129
+ | AoE Slam | 8-10 | 10 | 8 |
130
+
131
+ Position the hitbox CFrame in front of the character's HumanoidRootPart. Offset by half the range on the Z axis so the box starts at the character and extends forward.
132
+
133
+ ---
134
+
135
+ ## 4. State Machines
136
+
137
+ Combat states prevent invalid action combinations (e.g., attacking while stunned) and enforce recovery windows.
138
+
139
+ ### State Diagram
140
+
141
+ ```
142
+ +-----------+
143
+ +------>| Idle |<------+
144
+ | +-----+-----+ |
145
+ | | |
146
+ (recovery (attack (block
147
+ expires) input) input)
148
+ | | |
149
+ | +-----v-----+ |
150
+ | | Attacking | |
151
+ | +-----+-----+ |
152
+ | | |
153
+ | (attack +---+----+
154
+ | ends) | Blocking|
155
+ | | +---+----+
156
+ | +-----v-----+ |
157
+ +-------+ Recovery | |
158
+ | +-----------+ (release
159
+ | block)
160
+ | |
161
+ +----+-----+ +-----v-----+
162
+ | Stunned +----------->| Idle |
163
+ +----------+ (stun +-----------+
164
+ expires)
165
+
166
+ Dodge can interrupt Idle or Blocking:
167
+ Idle/Blocking --> Dodging --> Idle
168
+ ```
169
+
170
+ ### Complete State Machine Implementation
171
+
172
+ ```luau
173
+ --!strict
174
+ -- CombatStateMachine.luau (ModuleScript in ReplicatedStorage)
175
+
176
+ export type CombatState = "Idle" | "Attacking" | "Recovery" | "Blocking" | "Dodging" | "Stunned"
177
+
178
+ export type StateTransition = {
179
+ from: {CombatState},
180
+ to: CombatState,
181
+ condition: ((context: StateMachineContext) -> boolean)?,
182
+ }
183
+
184
+ export type StateMachineContext = {
185
+ player: Player,
186
+ currentState: CombatState,
187
+ stateStartTime: number,
188
+ lastAttackTime: number,
189
+ comboCount: number,
190
+ stunEndTime: number,
191
+ dodgeCooldownEnd: number,
192
+ }
193
+
194
+ local CombatStateMachine = {}
195
+ CombatStateMachine.__index = CombatStateMachine
196
+
197
+ local RECOVERY_DURATION = 0.4
198
+ local DODGE_DURATION = 0.5
199
+ local DODGE_COOLDOWN = 1.5
200
+ local STUN_DEFAULT_DURATION = 1.0
201
+ local ATTACK_DURATION = 0.35
202
+ local COMBO_WINDOW = 0.8
203
+ local MAX_COMBO = 4
204
+
205
+ local VALID_TRANSITIONS: {StateTransition} = {
206
+ { from = { "Idle" }, to = "Attacking" },
207
+ { from = { "Attacking" }, to = "Recovery" },
208
+ { from = { "Recovery" }, to = "Idle" },
209
+ { from = { "Recovery" }, to = "Attacking" }, -- combo: attack during recovery window
210
+ { from = { "Idle" }, to = "Blocking" },
211
+ { from = { "Blocking" }, to = "Idle" },
212
+ { from = { "Idle", "Blocking" }, to = "Dodging" },
213
+ { from = { "Dodging" }, to = "Idle" },
214
+ { from = { "Idle", "Attacking", "Recovery", "Blocking", "Dodging" }, to = "Stunned" },
215
+ { from = { "Stunned" }, to = "Idle" },
216
+ }
217
+
218
+ function CombatStateMachine.new(player: Player)
219
+ local self = setmetatable({}, CombatStateMachine)
220
+ self.context = {
221
+ player = player,
222
+ currentState = "Idle" :: CombatState,
223
+ stateStartTime = os.clock(),
224
+ lastAttackTime = 0,
225
+ comboCount = 0,
226
+ stunEndTime = 0,
227
+ dodgeCooldownEnd = 0,
228
+ } :: StateMachineContext
229
+ self._onStateChanged = {} :: {(CombatState, CombatState) -> ()}
230
+ return self
231
+ end
232
+
233
+ function CombatStateMachine:getState(): CombatState
234
+ return self.context.currentState
235
+ end
236
+
237
+ function CombatStateMachine:getComboCount(): number
238
+ return self.context.comboCount
239
+ end
240
+
241
+ function CombatStateMachine:onStateChanged(callback: (CombatState, CombatState) -> ())
242
+ table.insert(self._onStateChanged, callback)
243
+ end
244
+
245
+ function CombatStateMachine:canTransition(to: CombatState): boolean
246
+ local from = self.context.currentState
247
+
248
+ for _, transition in VALID_TRANSITIONS do
249
+ if transition.to == to and table.find(transition.from, from) then
250
+ if transition.condition and not transition.condition(self.context) then
251
+ continue
252
+ end
253
+ return true
254
+ end
255
+ end
256
+
257
+ return false
258
+ end
259
+
260
+ function CombatStateMachine:transition(to: CombatState): boolean
261
+ if not self:canTransition(to) then
262
+ return false
263
+ end
264
+
265
+ local from = self.context.currentState
266
+ self.context.currentState = to
267
+ self.context.stateStartTime = os.clock()
268
+
269
+ for _, callback in self._onStateChanged do
270
+ callback(from, to)
271
+ end
272
+
273
+ return true
274
+ end
275
+
276
+ function CombatStateMachine:tryAttack(): boolean
277
+ local now = os.clock()
278
+ local ctx = self.context
279
+
280
+ -- Combo: if in Recovery and within combo window, allow chaining
281
+ if ctx.currentState == "Recovery" then
282
+ local timeSinceAttack = now - ctx.lastAttackTime
283
+ if timeSinceAttack <= COMBO_WINDOW and ctx.comboCount < MAX_COMBO then
284
+ ctx.comboCount += 1
285
+ ctx.lastAttackTime = now
286
+ return self:transition("Attacking")
287
+ end
288
+ return false
289
+ end
290
+
291
+ if ctx.currentState ~= "Idle" then
292
+ return false
293
+ end
294
+
295
+ ctx.comboCount = 1
296
+ ctx.lastAttackTime = now
297
+ return self:transition("Attacking")
298
+ end
299
+
300
+ function CombatStateMachine:tryBlock(): boolean
301
+ return self:transition("Blocking")
302
+ end
303
+
304
+ function CombatStateMachine:releaseBlock(): boolean
305
+ if self.context.currentState ~= "Blocking" then
306
+ return false
307
+ end
308
+ return self:transition("Idle")
309
+ end
310
+
311
+ function CombatStateMachine:tryDodge(): boolean
312
+ local now = os.clock()
313
+ if now < self.context.dodgeCooldownEnd then
314
+ return false
315
+ end
316
+
317
+ if self:transition("Dodging") then
318
+ self.context.dodgeCooldownEnd = now + DODGE_COOLDOWN
319
+ return true
320
+ end
321
+ return false
322
+ end
323
+
324
+ function CombatStateMachine:applyStun(duration: number?)
325
+ local stunDuration = duration or STUN_DEFAULT_DURATION
326
+ self.context.stunEndTime = os.clock() + stunDuration
327
+ self:transition("Stunned")
328
+ end
329
+
330
+ function CombatStateMachine:update()
331
+ local now = os.clock()
332
+ local ctx = self.context
333
+ local elapsed = now - ctx.stateStartTime
334
+
335
+ if ctx.currentState == "Attacking" and elapsed >= ATTACK_DURATION then
336
+ self:transition("Recovery")
337
+ elseif ctx.currentState == "Recovery" and elapsed >= RECOVERY_DURATION then
338
+ ctx.comboCount = 0
339
+ self:transition("Idle")
340
+ elseif ctx.currentState == "Dodging" and elapsed >= DODGE_DURATION then
341
+ self:transition("Idle")
342
+ elseif ctx.currentState == "Stunned" and now >= ctx.stunEndTime then
343
+ self:transition("Idle")
344
+ end
345
+ end
346
+
347
+ function CombatStateMachine:destroy()
348
+ table.clear(self._onStateChanged)
349
+ end
350
+
351
+ return CombatStateMachine
352
+ ```
353
+
354
+ ### Tuning Constants
355
+
356
+ | Constant | Default | Effect |
357
+ |---|---|---|
358
+ | `ATTACK_DURATION` | 0.35s | How long the attack hitbox is active |
359
+ | `RECOVERY_DURATION` | 0.4s | Window after attack before returning to Idle |
360
+ | `COMBO_WINDOW` | 0.8s | Time after an attack during which the next attack counts as a combo |
361
+ | `MAX_COMBO` | 4 | Maximum consecutive hits in a combo chain |
362
+ | `DODGE_DURATION` | 0.5s | Invulnerability / movement duration |
363
+ | `DODGE_COOLDOWN` | 1.5s | Time between dodge uses |
364
+ | `STUN_DEFAULT_DURATION` | 1.0s | Default stun length |
365
+
366
+ ---
367
+
368
+ ## 5. Damage Calculation
369
+
370
+ ### Formula
371
+
372
+ ```
373
+ finalDamage = baseDamage
374
+ * weaponMultiplier
375
+ * (1 + totalBuffPercent)
376
+ * (1 - defensePercent)
377
+ * critMultiplier
378
+ * comboMultiplier
379
+ * typeEffectiveness
380
+ ```
381
+
382
+ ### Implementation
383
+
384
+ ```luau
385
+ --!strict
386
+ -- DamageCalculator.luau (ModuleScript in ServerScriptService)
387
+
388
+ export type DamageType = "Physical" | "Magical" | "Fire" | "Ice" | "Lightning"
389
+
390
+ export type WeaponStats = {
391
+ baseDamage: number,
392
+ weaponMultiplier: number,
393
+ damageType: DamageType,
394
+ critChance: number, -- 0.0 to 1.0
395
+ critMultiplier: number, -- e.g. 1.5 = 150% damage on crit
396
+ }
397
+
398
+ export type CombatStats = {
399
+ attackBuff: number, -- 0.0 to 1.0 (e.g. 0.25 = 25% buff)
400
+ defense: number, -- 0.0 to 1.0 (e.g. 0.3 = 30% damage reduction)
401
+ resistances: {[DamageType]: number}, -- 0.0 to 1.0 per type
402
+ }
403
+
404
+ export type DamageResult = {
405
+ rawDamage: number,
406
+ finalDamage: number,
407
+ isCritical: boolean,
408
+ damageType: DamageType,
409
+ blocked: boolean,
410
+ }
411
+
412
+ local DamageCalculator = {}
413
+
414
+ local COMBO_MULTIPLIERS = { 1.0, 1.1, 1.25, 1.5 }
415
+ local BLOCK_REDUCTION = 0.8 -- blocking reduces damage by 80%
416
+ local MIN_DAMAGE = 1
417
+
418
+ function DamageCalculator.calculate(
419
+ weapon: WeaponStats,
420
+ attackerStats: CombatStats,
421
+ defenderStats: CombatStats,
422
+ comboHit: number,
423
+ isBlocking: boolean
424
+ ): DamageResult
425
+ local baseDamage = weapon.baseDamage * weapon.weaponMultiplier
426
+
427
+ -- Buff multiplier
428
+ local buffMultiplier = 1 + attackerStats.attackBuff
429
+
430
+ -- Defense multiplier
431
+ local defenseMultiplier = 1 - math.clamp(defenderStats.defense, 0, 0.9) -- cap at 90%
432
+
433
+ -- Elemental resistance
434
+ local resistance = defenderStats.resistances[weapon.damageType] or 0
435
+ local typeMultiplier = 1 - math.clamp(resistance, 0, 0.9)
436
+
437
+ -- Critical hit
438
+ local isCritical = math.random() < weapon.critChance
439
+ local critMultiplier = if isCritical then weapon.critMultiplier else 1.0
440
+
441
+ -- Combo scaling
442
+ local comboIndex = math.clamp(comboHit, 1, #COMBO_MULTIPLIERS)
443
+ local comboMultiplier = COMBO_MULTIPLIERS[comboIndex]
444
+
445
+ -- Combine
446
+ local rawDamage = baseDamage * buffMultiplier * critMultiplier * comboMultiplier
447
+ local finalDamage = rawDamage * defenseMultiplier * typeMultiplier
448
+
449
+ -- Blocking
450
+ local blocked = false
451
+ if isBlocking then
452
+ finalDamage *= (1 - BLOCK_REDUCTION)
453
+ blocked = true
454
+ end
455
+
456
+ finalDamage = math.max(math.floor(finalDamage), MIN_DAMAGE)
457
+
458
+ return {
459
+ rawDamage = rawDamage,
460
+ finalDamage = finalDamage,
461
+ isCritical = isCritical,
462
+ damageType = weapon.damageType,
463
+ blocked = blocked,
464
+ }
465
+ end
466
+
467
+ return DamageCalculator
468
+ ```
469
+
470
+ ### Damage Numbers Display (Client)
471
+
472
+ ```luau
473
+ -- DamageDisplay.luau (ModuleScript in ReplicatedStorage, called from client)
474
+
475
+ local TweenService = game:GetService("TweenService")
476
+
477
+ local DamageDisplay = {}
478
+
479
+ local COLORS = {
480
+ Normal = Color3.fromRGB(255, 255, 255),
481
+ Critical = Color3.fromRGB(255, 50, 50),
482
+ Blocked = Color3.fromRGB(150, 150, 150),
483
+ Heal = Color3.fromRGB(50, 255, 50),
484
+ }
485
+
486
+ function DamageDisplay.show(target: Model, amount: number, isCritical: boolean, blocked: boolean)
487
+ local head = target:FindFirstChild("Head") :: BasePart?
488
+ if not head then
489
+ return
490
+ end
491
+
492
+ local billboard = Instance.new("BillboardGui")
493
+ billboard.Size = UDim2.fromOffset(100, 40)
494
+ billboard.StudsOffset = Vector3.new(math.random(-2, 2), 2, 0)
495
+ billboard.AlwaysOnTop = true
496
+ billboard.Parent = head
497
+
498
+ local label = Instance.new("TextLabel")
499
+ label.Size = UDim2.fromScale(1, 1)
500
+ label.BackgroundTransparency = 1
501
+ label.Text = tostring(amount)
502
+ label.Font = Enum.Font.GothamBold
503
+ label.TextScaled = true
504
+
505
+ if blocked then
506
+ label.TextColor3 = COLORS.Blocked
507
+ label.Text = amount .. " (Blocked)"
508
+ elseif isCritical then
509
+ label.TextColor3 = COLORS.Critical
510
+ label.Text = amount .. "!"
511
+ label.TextSize = 28
512
+ else
513
+ label.TextColor3 = COLORS.Normal
514
+ end
515
+
516
+ label.Parent = billboard
517
+
518
+ -- Float up and fade
519
+ local tweenInfo = TweenInfo.new(1.0, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
520
+ local tween = TweenService:Create(billboard, tweenInfo, {
521
+ StudsOffset = billboard.StudsOffset + Vector3.new(0, 3, 0),
522
+ })
523
+
524
+ local fadeTween = TweenService:Create(label, tweenInfo, {
525
+ TextTransparency = 1,
526
+ })
527
+
528
+ tween:Play()
529
+ fadeTween:Play()
530
+
531
+ fadeTween.Completed:Once(function()
532
+ billboard:Destroy()
533
+ end)
534
+ end
535
+
536
+ return DamageDisplay
537
+ ```
538
+
539
+ ---
540
+
541
+ ## 6. Melee Combat
542
+
543
+ ### Swing Detection
544
+
545
+ The server creates a hitbox in front of the attacker for the duration of the attack state. Only characters that intersect the box during the active window take damage.
546
+
547
+ ```luau
548
+ local function performMeleeAttack(attacker: Model, weapon: WeaponStats, comboHit: number): {DamageResult}
549
+ local rootPart = attacker:FindFirstChild("HumanoidRootPart") :: BasePart
550
+ if not rootPart then
551
+ return {}
552
+ end
553
+
554
+ local targets = getHitTargets(rootPart, weapon.range or 7, weapon.width or 5, weapon.height or 6)
555
+ local results: {DamageResult} = {}
556
+
557
+ for _, targetModel in targets do
558
+ local humanoid = targetModel:FindFirstChildWhichIsA("Humanoid")
559
+ if not humanoid or humanoid.Health <= 0 then
560
+ continue
561
+ end
562
+
563
+ local defenderStats = getStatsForCharacter(targetModel) -- your stats lookup
564
+ local isBlocking = getStateMachine(targetModel):getState() == "Blocking"
565
+
566
+ local result = DamageCalculator.calculate(weapon, getStatsForCharacter(attacker), defenderStats, comboHit, isBlocking)
567
+ humanoid:TakeDamage(result.finalDamage)
568
+ table.insert(results, result)
569
+ end
570
+
571
+ return results
572
+ end
573
+ ```
574
+
575
+ ### Combo System
576
+
577
+ Track consecutive hits within the combo window. Each successive hit escalates damage:
578
+
579
+ | Combo Hit | Multiplier | Typical Effect |
580
+ |---|---|---|
581
+ | 1 | 1.0x | Normal swing |
582
+ | 2 | 1.1x | Faster animation |
583
+ | 3 | 1.25x | Wider hitbox |
584
+ | 4 | 1.5x | Finisher with knockback |
585
+
586
+ The state machine tracks `comboCount`. When the combo window expires (player doesn't attack within `COMBO_WINDOW` seconds), the count resets to 0 on transition back to Idle.
587
+
588
+ ### Parry / Block
589
+
590
+ - **Block**: Hold a button to enter Blocking state. Incoming damage is reduced by `BLOCK_REDUCTION` (80%). Blocking drains a stamina resource (optional). If stamina hits 0, the block breaks and the player is Stunned.
591
+ - **Parry**: A precisely timed block (within ~0.15s of an incoming attack) negates all damage and stuns the attacker instead. Implementation: check if the defender entered Blocking state within a `PARRY_WINDOW` before the hit lands.
592
+
593
+ ```luau
594
+ local PARRY_WINDOW = 0.15
595
+
596
+ local function isParry(defenderStateMachine): boolean
597
+ if defenderStateMachine:getState() ~= "Blocking" then
598
+ return false
599
+ end
600
+ local blockDuration = os.clock() - defenderStateMachine.context.stateStartTime
601
+ return blockDuration <= PARRY_WINDOW
602
+ end
603
+ ```
604
+
605
+ ### Knockback
606
+
607
+ Apply knockback on combo finishers or heavy attacks using `LinearVelocity` (preferred over deprecated `BodyVelocity`):
608
+
609
+ ```luau
610
+ local function applyKnockback(targetRootPart: BasePart, direction: Vector3, force: number, duration: number)
611
+ local attachment = targetRootPart:FindFirstChild("RootAttachment") :: Attachment?
612
+ if not attachment then
613
+ return
614
+ end
615
+
616
+ local linearVelocity = Instance.new("LinearVelocity")
617
+ linearVelocity.Attachment0 = attachment
618
+ linearVelocity.VelocityConstraintMode = Enum.VelocityConstraintMode.Vector
619
+ linearVelocity.MaxForce = math.huge
620
+ linearVelocity.VectorVelocity = direction.Unit * force
621
+ linearVelocity.Parent = targetRootPart
622
+
623
+ task.delay(duration, function()
624
+ linearVelocity:Destroy()
625
+ end)
626
+ end
627
+ ```
628
+
629
+ ---
630
+
631
+ ## 7. Ranged Combat
632
+
633
+ ### Projectile System
634
+
635
+ Create a physical part that moves each frame. Good for visible, dodgeable projectiles.
636
+
637
+ ```luau
638
+ local RunService = game:GetService("RunService")
639
+
640
+ local function fireProjectile(origin: CFrame, speed: number, maxDistance: number, gravity: number, onHit: (RaycastResult) -> ())
641
+ local projectile = Instance.new("Part")
642
+ projectile.Size = Vector3.new(0.3, 0.3, 1)
643
+ projectile.CFrame = origin
644
+ projectile.Anchored = true
645
+ projectile.CanCollide = false
646
+ projectile.Parent = workspace
647
+
648
+ local velocity = origin.LookVector * speed
649
+ local distanceTraveled = 0
650
+
651
+ local raycastParams = RaycastParams.new()
652
+ raycastParams.FilterType = Enum.RaycastFilterType.Exclude
653
+ raycastParams.FilterDescendantsInstances = { projectile }
654
+
655
+ local connection: RBXScriptConnection
656
+ connection = RunService.Heartbeat:Connect(function(dt: number)
657
+ -- Apply gravity
658
+ velocity += Vector3.new(0, -gravity * dt, 0)
659
+
660
+ local displacement = velocity * dt
661
+ local rayResult = workspace:Raycast(projectile.Position, displacement, raycastParams)
662
+
663
+ if rayResult then
664
+ connection:Disconnect()
665
+ onHit(rayResult)
666
+ projectile:Destroy()
667
+ return
668
+ end
669
+
670
+ projectile.CFrame = CFrame.new(projectile.Position + displacement, projectile.Position + displacement + velocity)
671
+ distanceTraveled += displacement.Magnitude
672
+
673
+ if distanceTraveled >= maxDistance then
674
+ connection:Disconnect()
675
+ projectile:Destroy()
676
+ end
677
+ end)
678
+ end
679
+ ```
680
+
681
+ ### Hitscan (Instant Raycast)
682
+
683
+ For sniper rifles, laser beams, or any instant-hit weapon:
684
+
685
+ ```luau
686
+ local function hitscanAttack(attacker: Model, aimDirection: Vector3, maxRange: number): RaycastResult?
687
+ local rootPart = attacker:FindFirstChild("HumanoidRootPart") :: BasePart
688
+ if not rootPart then
689
+ return nil
690
+ end
691
+
692
+ local origin = rootPart.Position + Vector3.new(0, 1.5, 0) -- eye height
693
+ local raycastParams = RaycastParams.new()
694
+ raycastParams.FilterType = Enum.RaycastFilterType.Exclude
695
+ raycastParams.FilterDescendantsInstances = { attacker }
696
+
697
+ return workspace:Raycast(origin, aimDirection.Unit * maxRange, raycastParams)
698
+ end
699
+ ```
700
+
701
+ ### Bullet Drop
702
+
703
+ Simulate gravity over distance by bending the ray. For longer ranges, split into segments:
704
+
705
+ ```luau
706
+ local function raycastWithDrop(origin: Vector3, direction: Vector3, segments: number, dropPerSegment: number): RaycastResult?
707
+ local segmentLength = direction.Magnitude / segments
708
+ local currentPos = origin
709
+ local currentDir = direction.Unit
710
+
711
+ local raycastParams = RaycastParams.new()
712
+ raycastParams.FilterType = Enum.RaycastFilterType.Exclude
713
+
714
+ for i = 1, segments do
715
+ local drop = Vector3.new(0, -dropPerSegment * i, 0)
716
+ local segmentDir = (currentDir * segmentLength) + drop
717
+
718
+ local result = workspace:Raycast(currentPos, segmentDir, raycastParams)
719
+ if result then
720
+ return result
721
+ end
722
+
723
+ currentPos += segmentDir
724
+ end
725
+
726
+ return nil
727
+ end
728
+ ```
729
+
730
+ ### Spread / Bloom Patterns
731
+
732
+ Add random deviation within a cone for shotguns, automatic weapons, or hip-fire:
733
+
734
+ ```luau
735
+ local function applySpread(direction: Vector3, spreadAngleDegrees: number): Vector3
736
+ local spreadRadians = math.rad(spreadAngleDegrees)
737
+ local randomAngle = math.random() * math.pi * 2
738
+ local randomSpread = math.random() * spreadRadians
739
+
740
+ local right = direction:Cross(Vector3.yAxis).Unit
741
+ local up = right:Cross(direction).Unit
742
+
743
+ local offset = (right * math.cos(randomAngle) + up * math.sin(randomAngle)) * math.sin(randomSpread)
744
+
745
+ return (direction.Unit + offset).Unit
746
+ end
747
+
748
+ -- Usage: shotgun with 8 pellets, 12-degree spread
749
+ for i = 1, 8 do
750
+ local spreadDir = applySpread(aimDirection, 12)
751
+ local result = hitscanAttack(attacker, spreadDir, 50)
752
+ if result then
753
+ -- process hit
754
+ end
755
+ end
756
+ ```
757
+
758
+ ---
759
+
760
+ ## 8. WCS (Weapon Combat System) Framework
761
+
762
+ ### What It Is
763
+
764
+ WCS is a community-built framework for Roblox combat that provides:
765
+
766
+ - Skill/ability definition with built-in cooldowns
767
+ - Status effects (buffs, debuffs, DoTs)
768
+ - Moveset management (assign skills to characters)
769
+ - Client-server synchronization out of the box
770
+ - Holdable skills, channeling, and charge mechanics
771
+
772
+ ### When to Use WCS
773
+
774
+ | Scenario | Recommendation |
775
+ |---|---|
776
+ | Complex skill-based combat (RPG, fighting game) | **Use WCS** -- saves weeks of boilerplate |
777
+ | Many unique abilities with varied behavior | **Use WCS** -- skill definition system is well designed |
778
+ | Simple melee/ranged with few weapons | **Build custom** -- WCS adds unnecessary overhead |
779
+ | Learning combat fundamentals | **Build custom** -- understand the internals first |
780
+ | Very custom combo/input systems | **Build custom or extend WCS** -- may fight the framework |
781
+
782
+ ### Basic WCS Skill Definition
783
+
784
+ ```luau
785
+ -- Example: a fireball skill in WCS
786
+ local ReplicatedStorage = game:GetService("ReplicatedStorage")
787
+ local WCS = require(ReplicatedStorage.Packages.WCS)
788
+
789
+ local Fireball = WCS.RegisterSkill("Fireball")
790
+ Fireball.CooldownTime = 3
791
+ Fireball.MaxHoldTime = 2
792
+
793
+ function Fireball:OnStartServer()
794
+ -- Server-side fireball logic
795
+ local character = self.Character
796
+ -- spawn projectile, deal damage, etc.
797
+ end
798
+
799
+ function Fireball:OnStartClient()
800
+ -- Client-side VFX
801
+ -- play casting animation, spawn particle emitter
802
+ end
803
+ ```
804
+
805
+ Install WCS via Wally: `wcs = "digest/wcs@latest"` or grab the model from the toolbox.
806
+
807
+ ---
808
+
809
+ ## 9. Cooldown Systems
810
+
811
+ ### Server-Side Cooldown Tracking
812
+
813
+ All cooldowns must be tracked on the server. The client can display a timer, but the server rejects actions that violate cooldowns.
814
+
815
+ ```luau
816
+ --!strict
817
+ -- CooldownManager.luau (ModuleScript in ServerScriptService)
818
+
819
+ local CooldownManager = {}
820
+ CooldownManager.__index = CooldownManager
821
+
822
+ type CooldownMap = {[string]: number} -- abilityName -> expiry timestamp
823
+ type PlayerCooldowns = {[Player]: CooldownMap}
824
+
825
+ local cooldowns: PlayerCooldowns = {}
826
+
827
+ function CooldownManager.initialize(player: Player)
828
+ cooldowns[player] = {}
829
+ end
830
+
831
+ function CooldownManager.cleanup(player: Player)
832
+ cooldowns[player] = nil
833
+ end
834
+
835
+ function CooldownManager.isReady(player: Player, abilityName: string): boolean
836
+ local playerCooldowns = cooldowns[player]
837
+ if not playerCooldowns then
838
+ return false
839
+ end
840
+
841
+ local expiry = playerCooldowns[abilityName]
842
+ if not expiry then
843
+ return true
844
+ end
845
+
846
+ return os.clock() >= expiry
847
+ end
848
+
849
+ function CooldownManager.startCooldown(player: Player, abilityName: string, duration: number)
850
+ local playerCooldowns = cooldowns[player]
851
+ if not playerCooldowns then
852
+ return
853
+ end
854
+
855
+ playerCooldowns[abilityName] = os.clock() + duration
856
+ end
857
+
858
+ function CooldownManager.getRemainingTime(player: Player, abilityName: string): number
859
+ local playerCooldowns = cooldowns[player]
860
+ if not playerCooldowns then
861
+ return 0
862
+ end
863
+
864
+ local expiry = playerCooldowns[abilityName]
865
+ if not expiry then
866
+ return 0
867
+ end
868
+
869
+ return math.max(0, expiry - os.clock())
870
+ end
871
+
872
+ return CooldownManager
873
+ ```
874
+
875
+ ### Client-Side Cooldown Display
876
+
877
+ ```luau
878
+ -- CooldownUI.luau (LocalScript in StarterPlayerScripts)
879
+
880
+ local ReplicatedStorage = game:GetService("ReplicatedStorage")
881
+ local RunService = game:GetService("RunService")
882
+ local cooldownEvent = ReplicatedStorage:WaitForChild("CooldownStarted") :: RemoteEvent
883
+
884
+ local activeCooldowns: {[string]: {startTime: number, duration: number, frame: Frame}} = {}
885
+
886
+ cooldownEvent.OnClientEvent:Connect(function(abilityName: string, duration: number)
887
+ local frame = findAbilityFrame(abilityName) -- your UI lookup
888
+ if not frame then
889
+ return
890
+ end
891
+
892
+ local overlay = frame:FindFirstChild("CooldownOverlay") :: Frame
893
+ local label = frame:FindFirstChild("CooldownLabel") :: TextLabel
894
+
895
+ activeCooldowns[abilityName] = {
896
+ startTime = os.clock(),
897
+ duration = duration,
898
+ frame = frame,
899
+ }
900
+ end)
901
+
902
+ RunService.RenderStepped:Connect(function()
903
+ for abilityName, data in activeCooldowns do
904
+ local elapsed = os.clock() - data.startTime
905
+ local remaining = data.duration - elapsed
906
+
907
+ if remaining <= 0 then
908
+ -- Cooldown finished
909
+ local overlay = data.frame:FindFirstChild("CooldownOverlay") :: Frame?
910
+ if overlay then
911
+ overlay.Size = UDim2.fromScale(1, 0)
912
+ end
913
+ local label = data.frame:FindFirstChild("CooldownLabel") :: TextLabel?
914
+ if label then
915
+ label.Text = ""
916
+ end
917
+ activeCooldowns[abilityName] = nil
918
+ continue
919
+ end
920
+
921
+ -- Update sweep and text
922
+ local fraction = remaining / data.duration
923
+ local overlay = data.frame:FindFirstChild("CooldownOverlay") :: Frame?
924
+ if overlay then
925
+ overlay.Size = UDim2.fromScale(1, fraction)
926
+ end
927
+ local label = data.frame:FindFirstChild("CooldownLabel") :: TextLabel?
928
+ if label then
929
+ label.Text = string.format("%.1f", remaining)
930
+ end
931
+ end
932
+ end)
933
+ ```
934
+
935
+ ---
936
+
937
+ ## 10. Complete Melee Combat System
938
+
939
+ This ties together the state machine, hitbox detection, damage calculation, and cooldowns into a working server-side combat handler.
940
+
941
+ ```luau
942
+ --!strict
943
+ -- MeleeCombatServer.luau (Script in ServerScriptService)
944
+
945
+ local Players = game:GetService("Players")
946
+ local ReplicatedStorage = game:GetService("ReplicatedStorage")
947
+ local RunService = game:GetService("RunService")
948
+
949
+ local CombatStateMachine = require(ReplicatedStorage:WaitForChild("CombatStateMachine"))
950
+ local DamageCalculator = require(game.ServerScriptService:WaitForChild("DamageCalculator"))
951
+ local CooldownManager = require(game.ServerScriptService:WaitForChild("CooldownManager"))
952
+
953
+ -- RemoteEvents
954
+ local attackRemote = ReplicatedStorage:WaitForChild("AttackRemote") :: RemoteEvent
955
+ local blockRemote = ReplicatedStorage:WaitForChild("BlockRemote") :: RemoteEvent
956
+ local dodgeRemote = ReplicatedStorage:WaitForChild("DodgeRemote") :: RemoteEvent
957
+ local combatResultRemote = ReplicatedStorage:WaitForChild("CombatResultRemote") :: RemoteEvent
958
+ local cooldownRemote = ReplicatedStorage:WaitForChild("CooldownStarted") :: RemoteEvent
959
+
960
+ -- Per-player state
961
+ local playerStateMachines: {[Player]: typeof(CombatStateMachine.new(nil :: any))} = {}
962
+
963
+ -- Weapon config (in production, load from a data module)
964
+ local DEFAULT_WEAPON: DamageCalculator.WeaponStats = {
965
+ baseDamage = 20,
966
+ weaponMultiplier = 1.0,
967
+ damageType = "Physical",
968
+ critChance = 0.1,
969
+ critMultiplier = 1.5,
970
+ }
971
+
972
+ local DEFAULT_STATS: DamageCalculator.CombatStats = {
973
+ attackBuff = 0,
974
+ defense = 0,
975
+ resistances = {},
976
+ }
977
+
978
+ local ATTACK_COOLDOWN = 0.3
979
+ local DODGE_INVULN_TAG = "DodgeInvuln"
980
+
981
+ -- Hitbox detection
982
+ local function getHitTargets(attackerRootPart: BasePart, range: number, width: number, height: number): {Model}
983
+ local cf = attackerRootPart.CFrame * CFrame.new(0, 0, -range / 2)
984
+ local size = Vector3.new(width, height, range)
985
+
986
+ local overlapParams = OverlapParams.new()
987
+ overlapParams.FilterType = Enum.RaycastFilterType.Exclude
988
+ overlapParams.FilterDescendantsInstances = { attackerRootPart.Parent }
989
+
990
+ local parts = workspace:GetPartBoundsInBox(cf, size, overlapParams)
991
+
992
+ local hitCharacters: {[Model]: true} = {}
993
+ local results: {Model} = {}
994
+
995
+ for _, part in parts do
996
+ local model = part:FindFirstAncestorWhichIsA("Model")
997
+ if model and model:FindFirstChildWhichIsA("Humanoid") and not hitCharacters[model] then
998
+ hitCharacters[model] = true
999
+ table.insert(results, model)
1000
+ end
1001
+ end
1002
+
1003
+ return results
1004
+ end
1005
+
1006
+ -- Knockback helper
1007
+ local function applyKnockback(targetRootPart: BasePart, direction: Vector3, force: number)
1008
+ local attachment = targetRootPart:FindFirstChild("RootAttachment") :: Attachment?
1009
+ if not attachment then
1010
+ return
1011
+ end
1012
+
1013
+ local linearVelocity = Instance.new("LinearVelocity")
1014
+ linearVelocity.Attachment0 = attachment
1015
+ linearVelocity.VelocityConstraintMode = Enum.VelocityConstraintMode.Vector
1016
+ linearVelocity.MaxForce = math.huge
1017
+ linearVelocity.VectorVelocity = direction.Unit * force
1018
+ linearVelocity.Parent = targetRootPart
1019
+
1020
+ task.delay(0.3, function()
1021
+ linearVelocity:Destroy()
1022
+ end)
1023
+ end
1024
+
1025
+ -- Parry check
1026
+ local PARRY_WINDOW = 0.15
1027
+
1028
+ local function isParry(defenderSM: typeof(CombatStateMachine.new(nil :: any))): boolean
1029
+ if defenderSM:getState() ~= "Blocking" then
1030
+ return false
1031
+ end
1032
+ return (os.clock() - defenderSM.context.stateStartTime) <= PARRY_WINDOW
1033
+ end
1034
+
1035
+ -- Get state machine for a character model (NPC or player)
1036
+ local function getStateMachineForCharacter(character: Model): typeof(CombatStateMachine.new(nil :: any))?
1037
+ local player = Players:GetPlayerFromCharacter(character)
1038
+ if player then
1039
+ return playerStateMachines[player]
1040
+ end
1041
+ return nil
1042
+ end
1043
+
1044
+ -- Handle attack
1045
+ local function onAttack(player: Player)
1046
+ local sm = playerStateMachines[player]
1047
+ if not sm then
1048
+ return
1049
+ end
1050
+
1051
+ -- Cooldown check
1052
+ if not CooldownManager.isReady(player, "Attack") then
1053
+ return
1054
+ end
1055
+
1056
+ -- State machine check
1057
+ if not sm:tryAttack() then
1058
+ return
1059
+ end
1060
+
1061
+ CooldownManager.startCooldown(player, "Attack", ATTACK_COOLDOWN)
1062
+
1063
+ -- Get character
1064
+ local character = player.Character
1065
+ if not character then
1066
+ return
1067
+ end
1068
+ local rootPart = character:FindFirstChild("HumanoidRootPart") :: BasePart?
1069
+ if not rootPart then
1070
+ return
1071
+ end
1072
+
1073
+ -- Hitbox detection
1074
+ local comboHit = sm:getComboCount()
1075
+ local range = if comboHit >= 4 then 9 else 7
1076
+ local width = if comboHit >= 3 then 7 else 5
1077
+ local targets = getHitTargets(rootPart, range, width, 6)
1078
+
1079
+ for _, targetModel in targets do
1080
+ local targetHumanoid = targetModel:FindFirstChildWhichIsA("Humanoid")
1081
+ if not targetHumanoid or targetHumanoid.Health <= 0 then
1082
+ continue
1083
+ end
1084
+
1085
+ -- Check dodge invulnerability
1086
+ if targetModel:FindFirstChild(DODGE_INVULN_TAG) then
1087
+ continue
1088
+ end
1089
+
1090
+ -- Check parry
1091
+ local targetSM = getStateMachineForCharacter(targetModel)
1092
+ if targetSM and isParry(targetSM) then
1093
+ -- Parry: stun the attacker instead
1094
+ sm:applyStun(1.2)
1095
+ combatResultRemote:FireClient(player, "Parried", targetModel)
1096
+
1097
+ local targetPlayer = Players:GetPlayerFromCharacter(targetModel)
1098
+ if targetPlayer then
1099
+ combatResultRemote:FireClient(targetPlayer, "ParrySuccess", character)
1100
+ end
1101
+ continue
1102
+ end
1103
+
1104
+ -- Calculate damage
1105
+ local isBlocking = if targetSM then targetSM:getState() == "Blocking" else false
1106
+ local attackerStats = DEFAULT_STATS -- replace with real stats lookup
1107
+ local defenderStats = DEFAULT_STATS -- replace with real stats lookup
1108
+
1109
+ local result = DamageCalculator.calculate(DEFAULT_WEAPON, attackerStats, defenderStats, comboHit, isBlocking)
1110
+ targetHumanoid:TakeDamage(result.finalDamage)
1111
+
1112
+ -- Knockback on combo finisher
1113
+ local targetRootPart = targetModel:FindFirstChild("HumanoidRootPart") :: BasePart?
1114
+ if comboHit >= 4 and targetRootPart then
1115
+ local knockDir = (targetRootPart.Position - rootPart.Position)
1116
+ applyKnockback(targetRootPart, knockDir, 50)
1117
+ end
1118
+
1119
+ -- Notify all relevant clients
1120
+ combatResultRemote:FireAllClients("DamageDealt", {
1121
+ target = targetModel,
1122
+ amount = result.finalDamage,
1123
+ isCritical = result.isCritical,
1124
+ blocked = result.blocked,
1125
+ comboHit = comboHit,
1126
+ })
1127
+ end
1128
+ end
1129
+
1130
+ -- Handle block
1131
+ local function onBlock(player: Player, isBlocking: boolean)
1132
+ local sm = playerStateMachines[player]
1133
+ if not sm then
1134
+ return
1135
+ end
1136
+
1137
+ if isBlocking then
1138
+ sm:tryBlock()
1139
+ else
1140
+ sm:releaseBlock()
1141
+ end
1142
+ end
1143
+
1144
+ -- Handle dodge
1145
+ local function onDodge(player: Player)
1146
+ local sm = playerStateMachines[player]
1147
+ if not sm then
1148
+ return
1149
+ end
1150
+
1151
+ if not sm:tryDodge() then
1152
+ return
1153
+ end
1154
+
1155
+ local character = player.Character
1156
+ if not character then
1157
+ return
1158
+ end
1159
+ local rootPart = character:FindFirstChild("HumanoidRootPart") :: BasePart?
1160
+ if not rootPart then
1161
+ return
1162
+ end
1163
+
1164
+ -- Add invulnerability tag
1165
+ local tag = Instance.new("BoolValue")
1166
+ tag.Name = DODGE_INVULN_TAG
1167
+ tag.Parent = character
1168
+
1169
+ -- Apply dodge movement
1170
+ local moveDir = rootPart.CFrame.LookVector
1171
+ applyKnockback(rootPart, moveDir, 60)
1172
+
1173
+ -- Notify client for cooldown display
1174
+ cooldownRemote:FireClient(player, "Dodge", 1.5)
1175
+
1176
+ -- Remove tag after dodge duration
1177
+ task.delay(0.5, function()
1178
+ tag:Destroy()
1179
+ end)
1180
+ end
1181
+
1182
+ -- Player setup / teardown
1183
+ Players.PlayerAdded:Connect(function(player: Player)
1184
+ CooldownManager.initialize(player)
1185
+
1186
+ player.CharacterAdded:Connect(function()
1187
+ local sm = CombatStateMachine.new(player)
1188
+ playerStateMachines[player] = sm
1189
+ end)
1190
+
1191
+ player.CharacterRemoving:Connect(function()
1192
+ local sm = playerStateMachines[player]
1193
+ if sm then
1194
+ sm:destroy()
1195
+ playerStateMachines[player] = nil
1196
+ end
1197
+ end)
1198
+ end)
1199
+
1200
+ Players.PlayerRemoving:Connect(function(player: Player)
1201
+ CooldownManager.cleanup(player)
1202
+ local sm = playerStateMachines[player]
1203
+ if sm then
1204
+ sm:destroy()
1205
+ playerStateMachines[player] = nil
1206
+ end
1207
+ end)
1208
+
1209
+ -- Update state machines every frame
1210
+ RunService.Heartbeat:Connect(function()
1211
+ for _, sm in playerStateMachines do
1212
+ sm:update()
1213
+ end
1214
+ end)
1215
+
1216
+ -- Connect remotes
1217
+ attackRemote.OnServerEvent:Connect(onAttack)
1218
+ blockRemote.OnServerEvent:Connect(function(player: Player, isBlocking: boolean)
1219
+ if typeof(isBlocking) ~= "boolean" then
1220
+ return -- type validation
1221
+ end
1222
+ onBlock(player, isBlocking)
1223
+ end)
1224
+ dodgeRemote.OnServerEvent:Connect(onDodge)
1225
+ ```
1226
+
1227
+ ### Client-Side Input Handler
1228
+
1229
+ ```luau
1230
+ --!strict
1231
+ -- CombatInput.luau (LocalScript in StarterPlayerScripts)
1232
+
1233
+ local ReplicatedStorage = game:GetService("ReplicatedStorage")
1234
+ local UserInputService = game:GetService("UserInputService")
1235
+
1236
+ local attackRemote = ReplicatedStorage:WaitForChild("AttackRemote") :: RemoteEvent
1237
+ local blockRemote = ReplicatedStorage:WaitForChild("BlockRemote") :: RemoteEvent
1238
+ local dodgeRemote = ReplicatedStorage:WaitForChild("DodgeRemote") :: RemoteEvent
1239
+
1240
+ -- Attack: left mouse click
1241
+ UserInputService.InputBegan:Connect(function(input: InputObject, gameProcessed: boolean)
1242
+ if gameProcessed then
1243
+ return
1244
+ end
1245
+
1246
+ if input.UserInputType == Enum.UserInputType.MouseButton1 then
1247
+ attackRemote:FireServer()
1248
+ elseif input.KeyCode == Enum.KeyCode.F then
1249
+ blockRemote:FireServer(true)
1250
+ elseif input.KeyCode == Enum.KeyCode.Q then
1251
+ dodgeRemote:FireServer()
1252
+ end
1253
+ end)
1254
+
1255
+ -- Release block
1256
+ UserInputService.InputEnded:Connect(function(input: InputObject, gameProcessed: boolean)
1257
+ if input.KeyCode == Enum.KeyCode.F then
1258
+ blockRemote:FireServer(false)
1259
+ end
1260
+ end)
1261
+ ```
1262
+
1263
+ ---
1264
+
1265
+ ## 11. Best Practices
1266
+
1267
+ ### Server Authority
1268
+
1269
+ - **All damage is applied server-side.** The client never calls `Humanoid:TakeDamage()`.
1270
+ - **Validate every RemoteEvent argument.** Check `typeof()`, clamp numeric ranges, verify the player owns the weapon they claim to use.
1271
+ - **Run hitbox detection on the server** using the server's known character positions, not client-reported positions.
1272
+
1273
+ ### Hit Registration Integrity
1274
+
1275
+ - Use `OverlapParams.FilterDescendantsInstances` to exclude the attacker's own character from hitbox results.
1276
+ - Deduplicate hits: track which characters were already hit during a single attack swing to prevent multi-hit exploits.
1277
+ - Limit hit targets per swing (e.g., max 5) to prevent AoE exploits.
1278
+
1279
+ ### Fair PvP
1280
+
1281
+ - Normalize base stats in PvP arenas so gear differences don't create unfair advantages, or use matchmaking tiers.
1282
+ - Telegraph attacks: give enemies visual/audio cues (wind-up animation, sound effect) before damage lands so they can react.
1283
+ - Balance risk vs. reward: powerful attacks should have longer recovery, wider parry windows, or more telegraphing.
1284
+
1285
+ ### Performance
1286
+
1287
+ - Don't create new `OverlapParams` / `RaycastParams` every frame. Create once, reuse, update the filter list as needed.
1288
+ - Pool damage number BillboardGuis instead of creating/destroying them constantly.
1289
+ - For large-scale battles (20+ players), consider spatial partitioning to limit hitbox checks to nearby characters only.
1290
+
1291
+ ---
1292
+
1293
+ ## 12. Anti-Patterns
1294
+
1295
+ ### Client-Side Damage Calculation
1296
+
1297
+ ```luau
1298
+ -- BAD: Client decides damage and tells server
1299
+ attackRemote:FireServer(targetPlayer, 9999) -- exploiter sends any number
1300
+
1301
+ -- GOOD: Client sends intent, server calculates
1302
+ attackRemote:FireServer() -- server determines targets and damage
1303
+ ```
1304
+
1305
+ ### Trusting Client Hitbox Results
1306
+
1307
+ ```luau
1308
+ -- BAD: Client reports who it hit
1309
+ attackRemote:FireServer(hitTargets) -- exploiter sends all players on server
1310
+
1311
+ -- GOOD: Server runs its own hitbox detection
1312
+ -- Client sends nothing except "I attacked"
1313
+ ```
1314
+
1315
+ ### No Cooldown Enforcement
1316
+
1317
+ ```luau
1318
+ -- BAD: Only client checks cooldowns (exploiter removes the check)
1319
+ -- BAD: Server tracks cooldown but doesn't reject early attacks
1320
+
1321
+ -- GOOD: Server rejects and silently drops requests that violate cooldowns
1322
+ if not CooldownManager.isReady(player, "Attack") then
1323
+ return -- silently ignore, don't send error messages exploiters can use
1324
+ end
1325
+ ```
1326
+
1327
+ ### No State Validation
1328
+
1329
+ ```luau
1330
+ -- BAD: Allow attacking while stunned, dead, or in menus
1331
+ -- BAD: Allow blocking and attacking simultaneously
1332
+
1333
+ -- GOOD: State machine enforces valid transitions
1334
+ if not stateMachine:canTransition("Attacking") then
1335
+ return
1336
+ end
1337
+ ```
1338
+
1339
+ ### Instant Unavoidable Attacks
1340
+
1341
+ - Every attack should have a counter: dodging, blocking, parrying, or spacing.
1342
+ - If an attack is instant, it should deal low damage. If it deals high damage, it should be telegraphed and avoidable.
1343
+ - Avoid homing projectiles that are both fast and undodgeable.
1344
+
1345
+ ### Floating Point Stat Stacking
1346
+
1347
+ ```luau
1348
+ -- BAD: uncapped buff stacking
1349
+ totalBuff = totalBuff + newBuff -- can exceed 100%, goes to infinity
1350
+
1351
+ -- GOOD: clamp all multipliers
1352
+ totalBuff = math.clamp(totalBuff + newBuff, 0, 1.0) -- cap at 100% buff
1353
+ defense = math.clamp(defense, 0, 0.9) -- cap at 90% reduction, minimum damage always gets through
1354
+ ```
1355
+
1356
+ ---
1357
+
1358
+ ## Quick Reference: Required RemoteEvents
1359
+
1360
+ Create these in ReplicatedStorage for the complete system:
1361
+
1362
+ | RemoteEvent Name | Direction | Purpose |
1363
+ |---|---|---|
1364
+ | `AttackRemote` | Client -> Server | Player pressed attack |
1365
+ | `BlockRemote` | Client -> Server | Player started/stopped blocking |
1366
+ | `DodgeRemote` | Client -> Server | Player pressed dodge |
1367
+ | `CombatResultRemote` | Server -> Client | Damage dealt, parry, etc. for VFX |
1368
+ | `CooldownStarted` | Server -> Client | Ability cooldown began (for UI) |
1369
+
1370
+ ---
1371
+
1372
+ ## Quick Reference: Module Locations
1373
+
1374
+ | Module | Location | Purpose |
1375
+ |---|---|---|
1376
+ | `CombatStateMachine` | ReplicatedStorage | State machine (shared types) |
1377
+ | `DamageCalculator` | ServerScriptService | Damage formula (server only) |
1378
+ | `CooldownManager` | ServerScriptService | Cooldown tracking (server only) |
1379
+ | `DamageDisplay` | ReplicatedStorage | Floating damage numbers (client) |
1380
+ | `MeleeCombatServer` | ServerScriptService | Main combat handler (server) |
1381
+ | `CombatInput` | StarterPlayerScripts | Input -> RemoteEvent (client) |