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,889 @@
1
+ ---
2
+ name: roblox-data
3
+ description: DataStores, ProfileStore, session locking, data persistence patterns.
4
+ last_reviewed: 2026-05-21
5
+ ---
6
+
7
+ <!-- Source: brockmartin/roblox-game-skill (MIT) -->
8
+
9
+ # Roblox Data Persistence Reference
10
+
11
+ ## 1. Overview
12
+
13
+ Data persistence in Roblox means saving player progress so it survives across sessions. Every time a player joins, the server loads their data from the cloud; every time they leave (or periodically), it saves back.
14
+
15
+ **When data flows:**
16
+
17
+ ```
18
+ Player Joins --> Server loads from DataStore --> Populate in-game objects
19
+ Player Plays --> Data lives in server memory --> Auto-save on interval
20
+ Player Leaves --> Server saves to DataStore --> Data persists for next session
21
+ ```
22
+
23
+ **Data architecture decisions:**
24
+
25
+ | Approach | Best For | Complexity |
26
+ |----------|----------|------------|
27
+ | Raw DataStoreService | Simple games, prototypes | Low |
28
+ | **ProfileStore** | **Production games (USE THIS)** | Medium |
29
+ | Custom wrapper | Specific advanced requirements | High |
30
+
31
+ > **Use ProfileStore for any game that will ship.** Raw DataStore examples in sections 2-3 exist to explain the underlying system. Do NOT implement manual auto-save, session locking, BindToClose handlers, or retry logic - ProfileStore handles all of this automatically. Section 4 is the production pattern.
32
+
33
+ **Prerequisite:** Enable API Services in Roblox Studio under **Game Settings > Security > Enable Studio Access to API Services**. Without this, DataStore calls will fail in Studio testing.
34
+
35
+ ---
36
+
37
+ ## Quick Reference
38
+
39
+ **Load Full Reference below only when you need specific implementation examples or migration patterns.**
40
+
41
+ Key rules:
42
+ - ALWAYS use ProfileStore for player data. Never raw DataStoreService for mutable player state.
43
+ - Session locking prevents data corruption from multi-server joins. ProfileStore handles this.
44
+ - BindToClose is MANDATORY. Flush all pending saves on server shutdown.
45
+ - Schema: use a default template table. New fields get default values automatically.
46
+ - Access pattern: `profile.Data.fieldName`. Mutate directly, ProfileStore auto-saves.
47
+ - Release profile on PlayerRemoving: `profile:Release()`
48
+ - OrderedDataStore for leaderboards only (separate from player data).
49
+ - Data migration: version field in schema, migrate on load if version < current.
50
+ - Never store Instances or functions in DataStores. Serialize to primitives.
51
+ - Cross-server: MessagingService for real-time, GlobalDataStore for shared state.
52
+
53
+ ---
54
+
55
+ ## Full Reference
56
+
57
+ ## 2. Raw DataStore API (Reference Only)
58
+
59
+ > **For production games, skip to section 4 (ProfileStore).** This section exists so you understand what's underneath. Do NOT implement manual auto-save, session locking, BindToClose handlers, or retry logic - ProfileStore handles all of this.
60
+
61
+ ### Core Methods
62
+
63
+ | Method | Purpose | Notes |
64
+ |--------|---------|-------|
65
+ | `GetDataStore(name)` | Get/create a named DataStore | Returns DataStore object |
66
+ | `GetAsync(key)` | Read a value | Returns nil if key doesn't exist |
67
+ | `SetAsync(key, value)` | Write a value (overwrites) | No conflict protection |
68
+ | `UpdateAsync(key, callback)` | Atomic read-modify-write | **Preferred for saves** |
69
+ | `RemoveAsync(key)` | Delete a key | Returns the old value |
70
+
71
+ `UpdateAsync` is preferred over `SetAsync` because it is atomic - reads current value, transforms it, writes back in one operation.
72
+
73
+ ### Leaderstats (Display Pattern)
74
+
75
+ Leaderstats are IntValue/StringValue children of a Folder named "leaderstats" parented to the Player. Roblox automatically displays these on the in-game leaderboard.
76
+
77
+ ```luau
78
+ local leaderstats = Instance.new("Folder")
79
+ leaderstats.Name = "leaderstats"
80
+ leaderstats.Parent = player
81
+
82
+ local cash = Instance.new("IntValue")
83
+ cash.Name = "Cash"
84
+ cash.Value = profile.Data.Cash -- populated from ProfileStore
85
+ cash.Parent = leaderstats
86
+ ```
87
+
88
+ Leaderstats are display-only. The authoritative data lives in ProfileStore's `profile.Data`. Sync leaderstats back to profile on save.
89
+
90
+ ---
91
+
92
+ ## 4. ProfileStore
93
+
94
+ ProfileStore is the community-standard library for production-grade data persistence. It solves critical problems that raw DataStore usage does not handle.
95
+
96
+ ### Why Use It
97
+
98
+ | Feature | Raw DataStore | ProfileStore |
99
+ |---------|--------------|----------------|
100
+ | Session locking | Manual (hard) | Automatic |
101
+ | Auto-save | Manual | Built-in |
102
+ | Schema migration | Manual | Supported |
103
+ | Data corruption protection | None | Built-in |
104
+ | Retry logic | Manual | Built-in |
105
+ | BindToClose handling | Manual | Automatic |
106
+
107
+ ### Installation
108
+
109
+ **With Wally (recommended):**
110
+
111
+ ```toml
112
+ # wally.toml
113
+ [dependencies]
114
+ ProfileStore = "madstudioroblox/profileservice@1.4.0"
115
+ ```
116
+
117
+ Run `wally install`, then require from the Packages folder.
118
+
119
+ **Manual:** Download from GitHub and place the ProfileStore ModuleScript into ServerScriptService or ReplicatedStorage.
120
+
121
+ ### Complete ProfileStore Setup
122
+
123
+ ```luau
124
+ -- ServerScript in ServerScriptService
125
+ local Players = game:GetService("Players")
126
+ local ServerScriptService = game:GetService("ServerScriptService")
127
+
128
+ local ProfileStore = require(ServerScriptService.Packages.ProfileStore)
129
+ -- Adjust the require path based on where you installed it
130
+
131
+ -- Define the profile template (default data for new players)
132
+ local PROFILE_TEMPLATE = {
133
+ DataVersion = 1,
134
+ Cash = 0,
135
+ Level = 1,
136
+ Experience = 0,
137
+ Inventory = {},
138
+ Settings = {
139
+ MusicVolume = 0.5,
140
+ SFXVolume = 0.8,
141
+ },
142
+ Statistics = {
143
+ TotalPlayTime = 0,
144
+ GamesPlayed = 0,
145
+ },
146
+ }
147
+
148
+ -- Create the store (wraps a DataStore with session locking)
149
+ local PlayerStore = ProfileStore.New("PlayerProfiles_v1", PROFILE_TEMPLATE)
150
+
151
+ -- Active profiles cache
152
+ local Profiles: { [Player]: typeof(PlayerStore:LoadProfileAsync("")) } = {}
153
+
154
+ local function onProfileLoaded(player: Player, profile)
155
+ -- Session lock: if the profile was stolen by another server, release and kick
156
+ profile:AddUserId(player.UserId) -- GDPR compliance
157
+ profile:Reconcile() -- Fills in missing fields from PROFILE_TEMPLATE
158
+
159
+ profile:ListenToRelease(function()
160
+ Profiles[player] = nil
161
+ player:Kick("Your data was loaded on another server. Please rejoin.")
162
+ end)
163
+
164
+ -- Check if player is still in game (they may have left during async load)
165
+ if not player:IsDescendantOf(Players) then
166
+ profile:Release()
167
+ return
168
+ end
169
+
170
+ -- Store and set up the player
171
+ Profiles[player] = profile
172
+
173
+ -- Example: set up leaderstats from profile data
174
+ local leaderstats = Instance.new("Folder")
175
+ leaderstats.Name = "leaderstats"
176
+ leaderstats.Parent = player
177
+
178
+ local cash = Instance.new("IntValue")
179
+ cash.Name = "Cash"
180
+ cash.Value = profile.Data.Cash
181
+ cash.Parent = leaderstats
182
+
183
+ local level = Instance.new("IntValue")
184
+ level.Name = "Level"
185
+ level.Value = profile.Data.Level
186
+ level.Parent = leaderstats
187
+ end
188
+
189
+ Players.PlayerAdded:Connect(function(player: Player)
190
+ local profile = PlayerStore:LoadProfileAsync(
191
+ `Player_{player.UserId}`,
192
+ "ForceLoad" -- Wait until the session lock is acquired
193
+ )
194
+
195
+ if profile == nil then
196
+ player:Kick("Unable to load your data. Please rejoin.")
197
+ return
198
+ end
199
+
200
+ onProfileLoaded(player, profile)
201
+ end)
202
+
203
+ Players.PlayerRemoving:Connect(function(player: Player)
204
+ local profile = Profiles[player]
205
+ if profile then
206
+ -- Sync leaderstats back to profile before release
207
+ local leaderstats = player:FindFirstChild("leaderstats")
208
+ if leaderstats then
209
+ profile.Data.Cash = leaderstats.Cash.Value
210
+ profile.Data.Level = leaderstats.Level.Value
211
+ end
212
+
213
+ profile:Release()
214
+ end
215
+ end)
216
+
217
+ -- Helper to get a player's profile from other scripts
218
+ -- Export this via a ModuleScript in production
219
+ local function getProfile(player: Player)
220
+ return Profiles[player]
221
+ end
222
+ ```
223
+
224
+ ### Accessing Profile Data From Other Scripts
225
+
226
+ ```luau
227
+ -- In another ServerScript or ModuleScript
228
+ local function addCash(player: Player, amount: number)
229
+ local profile = getProfile(player)
230
+ if not profile then
231
+ return
232
+ end
233
+
234
+ profile.Data.Cash += amount
235
+
236
+ -- Also update leaderstats if visible
237
+ local leaderstats = player:FindFirstChild("leaderstats")
238
+ if leaderstats and leaderstats:FindFirstChild("Cash") then
239
+ leaderstats.Cash.Value = profile.Data.Cash
240
+ end
241
+ end
242
+ ```
243
+
244
+ ---
245
+
246
+ ## 5. Session Locking Explained
247
+
248
+ ### The Problem
249
+
250
+ Without session locking, data corruption can occur during server hops:
251
+
252
+ ```
253
+ Timeline:
254
+ t=0 Player is on Server A, data loaded
255
+ t=1 Player teleports to Server B
256
+ t=2 Server B starts loading player data from DataStore
257
+ t=3 Server A fires PlayerRemoving, starts saving data
258
+ t=4 Server B finishes loading (gets STALE data)
259
+ t=5 Server A finishes saving (writes LATEST data)
260
+ t=6 Server B eventually saves its stale copy, OVERWRITING the latest data
261
+
262
+ Result: Player loses progress from Server A session
263
+ ```
264
+
265
+ ### The Solution
266
+
267
+ Session locking ensures that only one server can own a player's data at a time:
268
+
269
+ ```
270
+ Timeline with Session Locking:
271
+ t=0 Server A loads profile, acquires session lock
272
+ t=1 Player teleports to Server B
273
+ t=2 Server B tries to load -- sees lock owned by Server A, WAITS
274
+ t=3 Server A fires PlayerRemoving, saves data, RELEASES lock
275
+ t=4 Server B detects lock released, acquires lock, loads LATEST data
276
+
277
+ Result: No data loss
278
+ ```
279
+
280
+ ### How ProfileStore Implements It
281
+
282
+ 1. When `LoadProfileAsync` is called, ProfileStore writes a session lock tag (server JobId) to the DataStore entry.
283
+ 2. If another server already holds the lock, ProfileStore either waits ("ForceLoad") or gives up ("Steal").
284
+ 3. The locking server periodically refreshes the lock via auto-save.
285
+ 4. On `profile:Release()`, the lock is cleared and the data is saved.
286
+ 5. If a server crashes without releasing, the lock expires after a timeout (~30 minutes by default), and another server can then claim it.
287
+
288
+ **You do NOT need to implement session locking manually.** ProfileStore handles all of this. This is the primary reason to use it over raw DataStoreService.
289
+
290
+ ---
291
+
292
+ ## 6. Data Schema Design
293
+
294
+ ### Flat vs. Nested Structure
295
+
296
+ **Flat (simple games):**
297
+
298
+ ```luau
299
+ local PROFILE_TEMPLATE = {
300
+ Cash = 0,
301
+ Level = 1,
302
+ Wins = 0,
303
+ Losses = 0,
304
+ }
305
+ ```
306
+
307
+ **Nested (complex games):**
308
+
309
+ ```luau
310
+ local PROFILE_TEMPLATE = {
311
+ DataVersion = 1,
312
+ Currency = {
313
+ Cash = 0,
314
+ Gems = 0,
315
+ Tickets = 0,
316
+ },
317
+ Progression = {
318
+ Level = 1,
319
+ Experience = 0,
320
+ Prestige = 0,
321
+ },
322
+ Inventory = {
323
+ Swords = {}, -- Array of item IDs or item tables
324
+ Armor = {},
325
+ Consumables = {},
326
+ },
327
+ Quests = {
328
+ Active = {},
329
+ Completed = {},
330
+ },
331
+ Settings = {
332
+ MusicVolume = 0.5,
333
+ SFXVolume = 0.8,
334
+ ShowTutorial = true,
335
+ },
336
+ }
337
+ ```
338
+
339
+ ### Versioning Your Schema
340
+
341
+ Always include a `DataVersion` field. This lets you detect and migrate old data formats.
342
+
343
+ ```luau
344
+ local PROFILE_TEMPLATE = {
345
+ DataVersion = 3, -- Increment when schema changes
346
+ -- ... fields ...
347
+ }
348
+ ```
349
+
350
+ ### Default Values for New Fields
351
+
352
+ When you add new fields, existing players won't have them. **ProfileStore's `Reconcile()` handles this automatically** - it fills in any missing fields from your PROFILE_TEMPLATE. Call it after loading:
353
+
354
+ ```luau
355
+ profile:Reconcile() -- Fills missing fields from template
356
+ ```
357
+
358
+ No manual merge code needed when using ProfileStore.
359
+
360
+ ### Type Safety Tips
361
+
362
+ - Use consistent types per field. If `Cash` is a number, never save it as a string.
363
+ - Arrays should contain uniform types (`{ number }`, not mixed).
364
+ - Avoid storing `nil` explicitly -- DataStore omits nil keys, which can cause confusion. Use sentinel values (e.g., `0`, `""`, `false`) instead.
365
+ - Remember: DataStore serializes to JSON internally. Only JSON-compatible types work: `number`, `string`, `boolean`, `table` (arrays and dictionaries). No Instances, Vector3s, CFrames, or other Roblox types directly.
366
+
367
+ ---
368
+
369
+ ## 7. Data Migration
370
+
371
+ When your data schema changes, you need to migrate existing player data to the new format.
372
+
373
+ ### Migration Strategy
374
+
375
+ 1. Check `DataVersion` when data is loaded.
376
+ 2. Apply migration functions sequentially (v1 -> v2, v2 -> v3, etc.).
377
+ 3. Update `DataVersion` to current.
378
+
379
+ ### Complete Migration Example
380
+
381
+ ```luau
382
+ -- DataMigrations module
383
+ local DataMigrations = {}
384
+
385
+ -- Each migration transforms data from version N to version N+1
386
+ local migrations: { [number]: (data: { [string]: any }) -> { [string]: any } } = {}
387
+
388
+ -- v1 -> v2: Split "Money" into "Cash" and "Gems"
389
+ migrations[1] = function(data)
390
+ if data.Money then
391
+ data.Cash = data.Money
392
+ data.Gems = 0
393
+ data.Money = nil
394
+ end
395
+ return data
396
+ end
397
+
398
+ -- v2 -> v3: Move settings out of flat structure into nested table
399
+ migrations[2] = function(data)
400
+ data.Settings = {
401
+ MusicVolume = data.MusicVolume or 0.5,
402
+ SFXVolume = data.SFXVolume or 0.8,
403
+ }
404
+ data.MusicVolume = nil
405
+ data.SFXVolume = nil
406
+ return data
407
+ end
408
+
409
+ -- v3 -> v4: Add Quests system and rename "Wins" to "Statistics.Wins"
410
+ migrations[3] = function(data)
411
+ data.Quests = {
412
+ Active = {},
413
+ Completed = {},
414
+ }
415
+ data.Statistics = data.Statistics or {}
416
+ data.Statistics.Wins = data.Wins or 0
417
+ data.Wins = nil
418
+ return data
419
+ end
420
+
421
+ local CURRENT_VERSION = 4
422
+
423
+ function DataMigrations.migrate(data: { [string]: any }): { [string]: any }
424
+ local version = data.DataVersion or 1
425
+
426
+ if version > CURRENT_VERSION then
427
+ warn(`[Migration] Data version {version} is newer than code version {CURRENT_VERSION}`)
428
+ return data
429
+ end
430
+
431
+ while version < CURRENT_VERSION do
432
+ local migrator = migrations[version]
433
+ if migrator then
434
+ data = migrator(data)
435
+ print(`[Migration] Migrated data from v{version} to v{version + 1}`)
436
+ end
437
+ version += 1
438
+ end
439
+
440
+ data.DataVersion = CURRENT_VERSION
441
+ return data
442
+ end
443
+
444
+ return DataMigrations
445
+ ```
446
+
447
+ ### Using Migrations With ProfileStore
448
+
449
+ ```luau
450
+ -- After loading the profile, before using the data:
451
+ local profile = PlayerStore:LoadProfileAsync(`Player_{player.UserId}`, "ForceLoad")
452
+ if profile then
453
+ profile.Data = DataMigrations.migrate(profile.Data)
454
+ profile:Reconcile() -- Fill in any remaining missing defaults
455
+ end
456
+ ```
457
+
458
+ ---
459
+
460
+ ## 8. OrderedDataStore
461
+
462
+ `OrderedDataStore` is a special DataStore type that stores integer values and supports sorted queries. It is the standard way to build global leaderboards.
463
+
464
+ ### Complete Leaderboard Implementation
465
+
466
+ ```luau
467
+ -- ServerScript in ServerScriptService
468
+ local Players = game:GetService("Players")
469
+ local DataStoreService = game:GetService("DataStoreService")
470
+
471
+ local cashLeaderboard = DataStoreService:GetOrderedDataStore("CashLeaderboard")
472
+
473
+ local LEADERBOARD_SIZE = 100
474
+ local UPDATE_INTERVAL = 120 -- seconds
475
+
476
+ -- Update a player's score in the leaderboard
477
+ local function updateLeaderboardScore(userId: number, score: number)
478
+ local success, err = pcall(function()
479
+ cashLeaderboard:SetAsync(tostring(userId), score)
480
+ end)
481
+
482
+ if not success then
483
+ warn(`[Leaderboard] Failed to update score for {userId}: {err}`)
484
+ end
485
+ end
486
+
487
+ -- Fetch the top N entries from the leaderboard
488
+ local function getTopPlayers(count: number): { { UserId: number, Score: number, Rank: number } }
489
+ local results = {}
490
+
491
+ local success, pages = pcall(function()
492
+ return cashLeaderboard:GetSortedAsync(
493
+ false, -- isAscending: false = highest first
494
+ count -- pageSize
495
+ )
496
+ end)
497
+
498
+ if not success then
499
+ warn(`[Leaderboard] Failed to fetch leaderboard: {pages}`)
500
+ return results
501
+ end
502
+
503
+ local currentPage = pages:GetCurrentPage()
504
+ local rank = 0
505
+
506
+ for _, entry in currentPage do
507
+ rank += 1
508
+ table.insert(results, {
509
+ UserId = tonumber(entry.key),
510
+ Score = entry.value,
511
+ Rank = rank,
512
+ })
513
+ end
514
+
515
+ return results
516
+ end
517
+
518
+ -- Populate a SurfaceGui or Billboard leaderboard (example with a Frame)
519
+ local function displayLeaderboard(surfaceGui: SurfaceGui, entries: { { UserId: number, Score: number, Rank: number } })
520
+ local container = surfaceGui:FindFirstChild("Container")
521
+ if not container then
522
+ return
523
+ end
524
+
525
+ -- Clear old entries
526
+ for _, child in container:GetChildren() do
527
+ if child:IsA("Frame") then
528
+ child:Destroy()
529
+ end
530
+ end
531
+
532
+ for _, entry in entries do
533
+ -- Get player name (works for offline players too)
534
+ local success, name = pcall(function()
535
+ return Players:GetNameFromUserIdAsync(entry.UserId)
536
+ end)
537
+
538
+ if success then
539
+ local row = Instance.new("Frame")
540
+ row.Name = `Rank_{entry.Rank}`
541
+ row.Size = UDim2.new(1, 0, 0, 30)
542
+ row.LayoutOrder = entry.Rank
543
+ row.Parent = container
544
+
545
+ local rankLabel = Instance.new("TextLabel")
546
+ rankLabel.Text = `#{entry.Rank}`
547
+ rankLabel.Size = UDim2.new(0.15, 0, 1, 0)
548
+ rankLabel.Parent = row
549
+
550
+ local nameLabel = Instance.new("TextLabel")
551
+ nameLabel.Text = name
552
+ nameLabel.Size = UDim2.new(0.55, 0, 1, 0)
553
+ nameLabel.Position = UDim2.new(0.15, 0, 0, 0)
554
+ nameLabel.Parent = row
555
+
556
+ local scoreLabel = Instance.new("TextLabel")
557
+ scoreLabel.Text = tostring(entry.Score)
558
+ scoreLabel.Size = UDim2.new(0.3, 0, 1, 0)
559
+ scoreLabel.Position = UDim2.new(0.7, 0, 0, 0)
560
+ scoreLabel.Parent = row
561
+ end
562
+ end
563
+ end
564
+
565
+ -- Periodic leaderboard update loop
566
+ task.spawn(function()
567
+ while true do
568
+ -- Update scores for all online players
569
+ for _, player in Players:GetPlayers() do
570
+ local leaderstats = player:FindFirstChild("leaderstats")
571
+ if leaderstats and leaderstats:FindFirstChild("Cash") then
572
+ task.spawn(updateLeaderboardScore, player.UserId, leaderstats.Cash.Value)
573
+ end
574
+ end
575
+
576
+ -- Fetch and display updated leaderboard
577
+ task.wait(5) -- Brief delay for scores to propagate
578
+ local topPlayers = getTopPlayers(LEADERBOARD_SIZE)
579
+
580
+ task.wait(UPDATE_INTERVAL)
581
+ end
582
+ end)
583
+ ```
584
+
585
+ **Important:** OrderedDataStore only supports **integer** values. If you need decimal scores, multiply by a factor (e.g., store `score * 100`).
586
+
587
+ ---
588
+
589
+ ## 9. Cross-Server Data
590
+
591
+ ### MessagingService (Real-Time Pub/Sub)
592
+
593
+ For real-time communication between servers (announcements, events, cross-server trading).
594
+
595
+ ```luau
596
+ local MessagingService = game:GetService("MessagingService")
597
+
598
+ -- Subscribe to a topic
599
+ local connection = MessagingService:SubscribeAsync("GlobalAnnouncement", function(message)
600
+ local data = message.Data -- The payload
601
+ local sent = message.Sent -- Timestamp when sent (Unix time)
602
+
603
+ -- Broadcast to all players on this server
604
+ for _, player in Players:GetPlayers() do
605
+ -- Show announcement UI, etc.
606
+ end
607
+ end)
608
+
609
+ -- Publish to a topic (reaches all servers)
610
+ local success, err = pcall(function()
611
+ MessagingService:PublishAsync("GlobalAnnouncement", {
612
+ Text = "Double XP weekend starts now!",
613
+ Duration = 3600,
614
+ })
615
+ end)
616
+ ```
617
+
618
+ **MessagingService limits:**
619
+ - Message size: 1 KB max
620
+ - Messages per server: 150 + 60 * playerCount per minute
621
+ - Subscriptions per server: 5 + 2 * playerCount
622
+ - Messages are NOT persisted -- only online servers receive them.
623
+
624
+ ### GlobalDataStore for Shared State
625
+
626
+ For persistent cross-server state (global counters, server-wide events):
627
+
628
+ ```luau
629
+ local globalStore = DataStoreService:GetDataStore("GlobalState")
630
+
631
+ -- Atomically increment a global counter
632
+ local function incrementGlobalCounter(key: string, amount: number): number?
633
+ local success, newValue = pcall(function()
634
+ return globalStore:UpdateAsync(key, function(old)
635
+ return (old or 0) + amount
636
+ end)
637
+ end)
638
+
639
+ if success then
640
+ return newValue
641
+ end
642
+ return nil
643
+ end
644
+
645
+ -- Example: Track total enemies defeated across all servers
646
+ local totalDefeated = incrementGlobalCounter("TotalEnemiesDefeated", 1)
647
+ ```
648
+
649
+ ---
650
+
651
+ ## 10. Best Practices
652
+
653
+ > **If using ProfileStore (recommended), sections 10.1 through 10.4 are handled automatically.** You only need to worry about these if you're building on raw DataStoreService. The patterns below are shown for understanding and for the rare case where raw DataStore is appropriate.
654
+
655
+ ### 10.1 Auto-Save Interval (ProfileStore: automatic)
656
+
657
+ ProfileStore handles auto-save internally. If using raw DataStore, save every 5 minutes:
658
+
659
+ ```luau
660
+ local AUTO_SAVE_INTERVAL = 300
661
+
662
+ task.spawn(function()
663
+ while true do
664
+ task.wait(AUTO_SAVE_INTERVAL)
665
+ for player, _data in playerDataCache do
666
+ task.spawn(savePlayerData, player)
667
+ end
668
+ end
669
+ end)
670
+ ```
671
+
672
+ ### 10.2 Save on PlayerRemoving (ProfileStore: automatic via Release)
673
+
674
+ ProfileStore saves and releases the session lock when `profile:Release()` is called. If using raw DataStore:
675
+
676
+ ```luau
677
+ Players.PlayerRemoving:Connect(function(player: Player)
678
+ savePlayerData(player)
679
+ playerDataCache[player] = nil
680
+ end)
681
+ ```
682
+
683
+ ### 10.3 BindToClose Handler (ProfileStore: automatic)
684
+
685
+ ProfileStore handles shutdown saves automatically. If using raw DataStore, `game:BindToClose` fires when the server shuts down. You have **30 seconds** to save all data before the server terminates. Use `task.spawn` for parallel saves.
686
+
687
+ ```luau
688
+ -- Only needed with raw DataStore
689
+ game:BindToClose(function()
690
+ if game:GetService("RunService"):IsStudio() then
691
+ task.wait(1)
692
+ return
693
+ end
694
+
695
+ local finished = Instance.new("BindableEvent")
696
+ local allPlayers = Players:GetPlayers()
697
+ local remaining = #allPlayers
698
+
699
+ if remaining == 0 then return end
700
+
701
+ for _, player in allPlayers do
702
+ task.spawn(function()
703
+ savePlayerData(player)
704
+ remaining -= 1
705
+ if remaining <= 0 then finished:Fire() end
706
+ end)
707
+ end
708
+
709
+ task.delay(25, function() finished:Fire() end)
710
+ finished.Event:Wait()
711
+ finished:Destroy()
712
+ end)
713
+ ```
714
+
715
+ ### 10.4 Retry Failed Saves (ProfileStore: built-in)
716
+
717
+ ProfileStore has built-in retry with exponential backoff. If using raw DataStore:
718
+
719
+ ```luau
720
+ local MAX_RETRIES = 3
721
+ local RETRY_DELAY = 2
722
+
723
+ local function saveWithRetry(player: Player): boolean
724
+ for attempt = 1, MAX_RETRIES do
725
+ local success = savePlayerData(player)
726
+ if success then return true end
727
+
728
+ if attempt < MAX_RETRIES then
729
+ warn(`[DataStore] Retry {attempt}/{MAX_RETRIES} for {player.Name}`)
730
+ task.wait(RETRY_DELAY * attempt)
731
+ end
732
+ end
733
+
734
+ warn(`[DataStore] All retries failed for {player.Name}`)
735
+ return false
736
+ end
737
+ ```
738
+
739
+ ### 10.5 Validate Data Before Saving (always relevant)
740
+
741
+ This applies regardless of whether you use ProfileStore or raw DataStore. Validate before writing:
742
+
743
+ ```luau
744
+ local function validateData(data: { [string]: any }): boolean
745
+ if typeof(data) ~= "table" then return false end
746
+ if typeof(data.Cash) ~= "number" or data.Cash < 0 then return false end
747
+ if typeof(data.Level) ~= "number" or data.Level < 1 then return false end
748
+ return true
749
+ end
750
+ ```
751
+
752
+ ---
753
+
754
+ ## 11. Anti-Patterns
755
+
756
+ ### Saving Too Frequently
757
+
758
+ **Wrong:**
759
+ ```luau
760
+ -- DO NOT DO THIS: saving on every coin pickup
761
+ coinTouched:Connect(function(player)
762
+ player.Data.Cash += 1
763
+ dataStore:SetAsync(`Player_{player.UserId}`, player.Data) -- Rate limit hit
764
+ end)
765
+ ```
766
+
767
+ **Right:** Modify in-memory data immediately, rely on periodic auto-save.
768
+
769
+ **DataStore rate limits:** `60 + numPlayers * 10` requests per minute per DataStore. With 50 players, that is 560 requests/min total -- or about 11 per player per minute. Saving once per 5 minutes uses only 0.2 per player per minute.
770
+
771
+ ### Not Handling DataStore Errors
772
+
773
+ **Wrong:**
774
+ ```luau
775
+ -- DO NOT DO THIS: unprotected call
776
+ local data = dataStore:GetAsync(key) -- Will error and break the script
777
+ ```
778
+
779
+ **Right:**
780
+ ```luau
781
+ local success, data = pcall(function()
782
+ return dataStore:GetAsync(key)
783
+ end)
784
+
785
+ if not success then
786
+ warn("DataStore error:", data)
787
+ -- Handle gracefully
788
+ end
789
+ ```
790
+
791
+ ### Storing Instance References
792
+
793
+ **Wrong:**
794
+ ```luau
795
+ -- DO NOT DO THIS: Instances are not serializable
796
+ data.Weapon = workspace.Sword -- Will fail or produce garbage
797
+ data.Character = player.Character -- Same problem
798
+ ```
799
+
800
+ **Right:** Store serializable identifiers.
801
+ ```luau
802
+ data.WeaponId = "IronSword"
803
+ data.EquippedSlots = { "Helmet_01", "Armor_03" }
804
+ ```
805
+
806
+ ### Exceeding Key Size Limits
807
+
808
+ | Limit | Value |
809
+ |-------|-------|
810
+ | Key name length | 50 characters |
811
+ | Value size per key | 4,194,304 bytes (4 MB) |
812
+ | DataStore name length | 50 characters |
813
+
814
+ If you're approaching 4 MB, split data across multiple keys:
815
+
816
+ ```luau
817
+ -- Split by category
818
+ local coreStore = DataStoreService:GetDataStore("PlayerCore")
819
+ local inventoryStore = DataStoreService:GetDataStore("PlayerInventory")
820
+ local questStore = DataStoreService:GetDataStore("PlayerQuests")
821
+ ```
822
+
823
+ ---
824
+
825
+ ## 12. Sharp Edges
826
+
827
+ ### Rate Limits
828
+
829
+ DataStore requests are throttled per-server, not per-player:
830
+
831
+ | Operation | Budget per Minute |
832
+ |-----------|-------------------|
833
+ | GetAsync | 60 + numPlayers * 10 |
834
+ | SetAsync / UpdateAsync | 60 + numPlayers * 10 |
835
+ | GetSortedAsync | 5 + numPlayers * 2 |
836
+ | SetAsync on OrderedDataStore | 5 + numPlayers * 2 |
837
+
838
+ Exceeding these results in requests being queued or erroring. Plan save intervals accordingly.
839
+
840
+ ### Eventual Consistency
841
+
842
+ DataStore reads are eventually consistent. After a `SetAsync`, a `GetAsync` from another server may briefly return stale data. `UpdateAsync` on the same key is atomic within a single call, but across keys or across servers there is no transaction guarantee.
843
+
844
+ ### BindToClose 30-Second Timeout
845
+
846
+ When a Roblox server shuts down, `BindToClose` callbacks are given at most **30 seconds** to finish. After that, the server process is killed regardless. If you have many players, you MUST save in parallel using `task.spawn`, not sequentially.
847
+
848
+ ```luau
849
+ -- BAD: Sequential saves with 50 players could take > 30 seconds
850
+ for _, player in Players:GetPlayers() do
851
+ savePlayerData(player) -- Each call might take 0.5-2 seconds
852
+ end
853
+
854
+ -- GOOD: Parallel saves complete in the time of the slowest single save
855
+ for _, player in Players:GetPlayers() do
856
+ task.spawn(savePlayerData, player)
857
+ end
858
+ task.wait(25) -- Wait with buffer
859
+ ```
860
+
861
+ ### Data Loss from Race Conditions Without Session Locking
862
+
863
+ Without session locking (i.e., using raw DataStore), the following scenario causes data loss:
864
+
865
+ 1. Player leaves Server A. `PlayerRemoving` fires, save begins.
866
+ 2. Player joins Server B before Server A's save completes.
867
+ 3. Server B loads stale data (Server A hasn't finished writing yet).
868
+ 4. Server A finishes saving. Server B later saves its stale copy, overwriting Server A's save.
869
+
870
+ **This is why you use ProfileStore.** It handles session locking automatically. If you must use raw DataStore, implement manual session locking with `UpdateAsync` by writing a lock field containing the server's `game.JobId` and checking it before loading.
871
+
872
+ ### Studio Testing Gotchas
873
+
874
+ - `PlayerRemoving` does NOT fire when you press Stop in Studio. Data will not save on exit during testing unless you also test via `BindToClose`.
875
+ - DataStore calls fail in Studio unless **Enable Studio Access to API Services** is checked in Game Settings > Security.
876
+ - Studio and live game share the same DataStore if using the same place. Use different DataStore names or a prefix for testing:
877
+
878
+ ```luau
879
+ local RunService = game:GetService("RunService")
880
+ local PREFIX = RunService:IsStudio() and "Dev_" or ""
881
+ local dataStore = DataStoreService:GetDataStore(`{PREFIX}PlayerData_v1`)
882
+ ```
883
+
884
+ ### Other Pitfalls
885
+
886
+ - **NaN values:** If a NaN (not a number) sneaks into your data (e.g., `0/0`), `SetAsync`/`UpdateAsync` will error silently or corrupt the entry. Validate numeric fields.
887
+ - **Empty tables:** An empty table `{}` can deserialize as either an array or a dictionary depending on context. Be consistent.
888
+ - **Key naming:** Keys are case-sensitive. `"Player_123"` and `"player_123"` are different keys. Standardize your key format.
889
+ - **UpdateAsync callback:** The callback passed to `UpdateAsync` must be pure (no yields, no side effects). It may be called multiple times if there is contention. Return `nil` to cancel the update.