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.
- package/README.md +122 -0
- package/commands/setup-game.md +108 -0
- package/commands/sync-check.md +53 -0
- package/core/roblox-core.md +93 -0
- package/dist/server.js +167 -0
- package/package.json +35 -0
- package/skills/roblox-analytics/SKILL.md +277 -0
- package/skills/roblox-analytics/references/event-batcher.luau +75 -0
- package/skills/roblox-animation-vfx/SKILL.md +1325 -0
- package/skills/roblox-architecture/SKILL.md +863 -0
- package/skills/roblox-architecture/references/combat-systems.md +1381 -0
- package/skills/roblox-code-review/SKILL.md +687 -0
- package/skills/roblox-data/SKILL.md +889 -0
- package/skills/roblox-data/references/inventory-systems.md +1729 -0
- package/skills/roblox-debug/SKILL.md +99 -0
- package/skills/roblox-gui/SKILL.md +1103 -0
- package/skills/roblox-gui-fusion/SKILL.md +150 -0
- package/skills/roblox-gui-fusion/references/inventory.luau +427 -0
- package/skills/roblox-gui-fusion/references/settings-menu.luau +579 -0
- package/skills/roblox-gui-fusion/references/shop.luau +411 -0
- package/skills/roblox-luau-mastery/SKILL.md +1519 -0
- package/skills/roblox-monetization/SKILL.md +1084 -0
- package/skills/roblox-monetization/references/process-receipt.luau +131 -0
- package/skills/roblox-networking/SKILL.md +669 -0
- package/skills/roblox-networking/references/remote-validator.luau +193 -0
- package/skills/roblox-publish-checklist/SKILL.md +128 -0
- package/skills/roblox-runtime/SKILL.md +753 -0
- package/skills/roblox-sharp-edges/SKILL.md +295 -0
- package/skills/roblox-sync/SKILL.md +126 -0
- package/skills/roblox-testing/SKILL.md +943 -0
- package/skills/roblox-tooling/SKILL.md +150 -0
- package/vendor/LICENSES/ProfileStore-LICENSE +201 -0
- package/vendor/LICENSES/RbxUtil-LICENSE +7 -0
- package/vendor/LICENSES/promise-LICENSE +21 -0
- package/vendor/LICENSES/t-LICENSE +21 -0
- package/vendor/LICENSES/testez-LICENSE +201 -0
- package/vendor/README.md +84 -0
- package/vendor/fusion/Animation/ExternalTime.luau +84 -0
- package/vendor/fusion/Animation/Spring.luau +322 -0
- package/vendor/fusion/Animation/Stopwatch.luau +128 -0
- package/vendor/fusion/Animation/Tween.luau +187 -0
- package/vendor/fusion/Animation/getTweenDuration.luau +27 -0
- package/vendor/fusion/Animation/getTweenRatio.luau +47 -0
- package/vendor/fusion/Animation/lerpType.luau +164 -0
- package/vendor/fusion/Animation/packType.luau +100 -0
- package/vendor/fusion/Animation/springCoefficients.luau +80 -0
- package/vendor/fusion/Animation/unpackType.luau +103 -0
- package/vendor/fusion/Colour/Oklab.luau +70 -0
- package/vendor/fusion/Colour/sRGB.luau +55 -0
- package/vendor/fusion/External.luau +168 -0
- package/vendor/fusion/ExternalDebug.luau +70 -0
- package/vendor/fusion/Graph/Observer.luau +114 -0
- package/vendor/fusion/Graph/castToGraph.luau +29 -0
- package/vendor/fusion/Graph/change.luau +81 -0
- package/vendor/fusion/Graph/depend.luau +33 -0
- package/vendor/fusion/Graph/evaluate.luau +56 -0
- package/vendor/fusion/Instances/Attribute.luau +58 -0
- package/vendor/fusion/Instances/AttributeChange.luau +47 -0
- package/vendor/fusion/Instances/AttributeOut.luau +63 -0
- package/vendor/fusion/Instances/Child.luau +21 -0
- package/vendor/fusion/Instances/Children.luau +148 -0
- package/vendor/fusion/Instances/Hydrate.luau +33 -0
- package/vendor/fusion/Instances/New.luau +53 -0
- package/vendor/fusion/Instances/OnChange.luau +50 -0
- package/vendor/fusion/Instances/OnEvent.luau +54 -0
- package/vendor/fusion/Instances/Out.luau +69 -0
- package/vendor/fusion/Instances/applyInstanceProps.luau +149 -0
- package/vendor/fusion/Instances/defaultProps.luau +194 -0
- package/vendor/fusion/LICENSE +21 -0
- package/vendor/fusion/Logging/formatError.luau +49 -0
- package/vendor/fusion/Logging/messages.luau +52 -0
- package/vendor/fusion/Logging/parseError.luau +25 -0
- package/vendor/fusion/Memory/checkLifetime.luau +134 -0
- package/vendor/fusion/Memory/deriveScope.luau +24 -0
- package/vendor/fusion/Memory/deriveScopeImpl.luau +45 -0
- package/vendor/fusion/Memory/doCleanup.luau +79 -0
- package/vendor/fusion/Memory/innerScope.luau +34 -0
- package/vendor/fusion/Memory/legacyCleanup.luau +18 -0
- package/vendor/fusion/Memory/needsDestruction.luau +17 -0
- package/vendor/fusion/Memory/poisonScope.luau +34 -0
- package/vendor/fusion/Memory/scopePool.luau +55 -0
- package/vendor/fusion/Memory/scoped.luau +27 -0
- package/vendor/fusion/Memory/whichLivesLonger.luau +75 -0
- package/vendor/fusion/RobloxExternal.luau +98 -0
- package/vendor/fusion/State/Computed.luau +139 -0
- package/vendor/fusion/State/For/Disassembly.luau +211 -0
- package/vendor/fusion/State/For/ForTypes.luau +30 -0
- package/vendor/fusion/State/For/init.luau +110 -0
- package/vendor/fusion/State/ForKeys.luau +94 -0
- package/vendor/fusion/State/ForPairs.luau +97 -0
- package/vendor/fusion/State/ForValues.luau +94 -0
- package/vendor/fusion/State/Value.luau +88 -0
- package/vendor/fusion/State/castToState.luau +26 -0
- package/vendor/fusion/State/peek.luau +31 -0
- package/vendor/fusion/State/updateAll.luau +1 -0
- package/vendor/fusion/Types.luau +314 -0
- package/vendor/fusion/Utility/Contextual.luau +91 -0
- package/vendor/fusion/Utility/Safe.luau +23 -0
- package/vendor/fusion/Utility/isSimilar.luau +29 -0
- package/vendor/fusion/Utility/merge.luau +35 -0
- package/vendor/fusion/Utility/nameOf.luau +35 -0
- package/vendor/fusion/Utility/never.luau +14 -0
- package/vendor/fusion/Utility/nicknames.luau +11 -0
- package/vendor/fusion/Utility/xtypeof.luau +27 -0
- package/vendor/fusion/init.luau +82 -0
- package/vendor/profilestore/init.luau +2243 -0
- package/vendor/promise/init.luau +1982 -0
- package/vendor/rbxutil/buffer-util/Buffer.test.luau +25 -0
- package/vendor/rbxutil/buffer-util/BufferReader.luau +228 -0
- package/vendor/rbxutil/buffer-util/BufferWriter.luau +269 -0
- package/vendor/rbxutil/buffer-util/DataTypeBuffer.luau +223 -0
- package/vendor/rbxutil/buffer-util/Types.luau +60 -0
- package/vendor/rbxutil/buffer-util/index.d.ts +153 -0
- package/vendor/rbxutil/buffer-util/init.luau +41 -0
- package/vendor/rbxutil/buffer-util/package.json +16 -0
- package/vendor/rbxutil/buffer-util/wally.toml +9 -0
- package/vendor/rbxutil/comm/Client/ClientComm.luau +232 -0
- package/vendor/rbxutil/comm/Client/ClientRemoteProperty.luau +156 -0
- package/vendor/rbxutil/comm/Client/ClientRemoteSignal.luau +109 -0
- package/vendor/rbxutil/comm/Client/init.luau +135 -0
- package/vendor/rbxutil/comm/Server/RemoteProperty.luau +295 -0
- package/vendor/rbxutil/comm/Server/RemoteSignal.luau +211 -0
- package/vendor/rbxutil/comm/Server/ServerComm.luau +211 -0
- package/vendor/rbxutil/comm/Server/init.luau +140 -0
- package/vendor/rbxutil/comm/Types.luau +18 -0
- package/vendor/rbxutil/comm/Util.luau +27 -0
- package/vendor/rbxutil/comm/init.luau +35 -0
- package/vendor/rbxutil/comm/wally.toml +13 -0
- package/vendor/rbxutil/component/init.luau +759 -0
- package/vendor/rbxutil/component/init.test.luau +311 -0
- package/vendor/rbxutil/component/wally.toml +14 -0
- package/vendor/rbxutil/concur/init.luau +542 -0
- package/vendor/rbxutil/concur/init.test.luau +364 -0
- package/vendor/rbxutil/concur/wally.toml +8 -0
- package/vendor/rbxutil/enum-list/init.luau +101 -0
- package/vendor/rbxutil/enum-list/init.test.luau +91 -0
- package/vendor/rbxutil/enum-list/wally.toml +8 -0
- package/vendor/rbxutil/find/index.d.ts +20 -0
- package/vendor/rbxutil/find/init.luau +44 -0
- package/vendor/rbxutil/find/package.json +17 -0
- package/vendor/rbxutil/find/wally.toml +8 -0
- package/vendor/rbxutil/input/Gamepad.luau +559 -0
- package/vendor/rbxutil/input/Keyboard.luau +124 -0
- package/vendor/rbxutil/input/Mouse.luau +278 -0
- package/vendor/rbxutil/input/PreferredInput.luau +91 -0
- package/vendor/rbxutil/input/Touch.luau +120 -0
- package/vendor/rbxutil/input/init.luau +33 -0
- package/vendor/rbxutil/input/wally.toml +12 -0
- package/vendor/rbxutil/loader/index.d.ts +15 -0
- package/vendor/rbxutil/loader/init.luau +137 -0
- package/vendor/rbxutil/loader/wally.toml +8 -0
- package/vendor/rbxutil/log/index.d.ts +38 -0
- package/vendor/rbxutil/log/init.luau +746 -0
- package/vendor/rbxutil/log/wally.toml +8 -0
- package/vendor/rbxutil/net/init.luau +190 -0
- package/vendor/rbxutil/net/wally.toml +8 -0
- package/vendor/rbxutil/option/index.d.ts +44 -0
- package/vendor/rbxutil/option/init.luau +489 -0
- package/vendor/rbxutil/option/init.test.luau +342 -0
- package/vendor/rbxutil/option/wally.toml +8 -0
- package/vendor/rbxutil/pid/index.d.ts +53 -0
- package/vendor/rbxutil/pid/init.luau +195 -0
- package/vendor/rbxutil/pid/package.json +16 -0
- package/vendor/rbxutil/pid/wally.toml +9 -0
- package/vendor/rbxutil/quaternion/index.d.ts +117 -0
- package/vendor/rbxutil/quaternion/init.luau +570 -0
- package/vendor/rbxutil/quaternion/package.json +16 -0
- package/vendor/rbxutil/quaternion/wally.toml +9 -0
- package/vendor/rbxutil/query/index.d.ts +43 -0
- package/vendor/rbxutil/query/init.luau +117 -0
- package/vendor/rbxutil/query/package.json +18 -0
- package/vendor/rbxutil/query/wally.toml +9 -0
- package/vendor/rbxutil/sequent/index.d.ts +28 -0
- package/vendor/rbxutil/sequent/init.luau +340 -0
- package/vendor/rbxutil/sequent/package.json +16 -0
- package/vendor/rbxutil/sequent/wally.toml +9 -0
- package/vendor/rbxutil/ser/init.luau +175 -0
- package/vendor/rbxutil/ser/init.test.luau +50 -0
- package/vendor/rbxutil/ser/wally.toml +11 -0
- package/vendor/rbxutil/shake/index.d.ts +36 -0
- package/vendor/rbxutil/shake/init.luau +532 -0
- package/vendor/rbxutil/shake/init.test.luau +267 -0
- package/vendor/rbxutil/shake/package.json +16 -0
- package/vendor/rbxutil/shake/wally.toml +9 -0
- package/vendor/rbxutil/signal/index.d.ts +100 -0
- package/vendor/rbxutil/signal/init.luau +432 -0
- package/vendor/rbxutil/signal/init.test.luau +190 -0
- package/vendor/rbxutil/signal/package.json +17 -0
- package/vendor/rbxutil/signal/wally.toml +9 -0
- package/vendor/rbxutil/silo/TableWatcher.luau +65 -0
- package/vendor/rbxutil/silo/Util.luau +55 -0
- package/vendor/rbxutil/silo/init.luau +338 -0
- package/vendor/rbxutil/silo/init.test.luau +215 -0
- package/vendor/rbxutil/silo/wally.toml +8 -0
- package/vendor/rbxutil/spring/index.d.ts +40 -0
- package/vendor/rbxutil/spring/init.luau +97 -0
- package/vendor/rbxutil/spring/package.json +17 -0
- package/vendor/rbxutil/spring/wally.toml +8 -0
- package/vendor/rbxutil/stream/index.d.ts +88 -0
- package/vendor/rbxutil/stream/init.luau +597 -0
- package/vendor/rbxutil/stream/package.json +18 -0
- package/vendor/rbxutil/stream/wally.toml +9 -0
- package/vendor/rbxutil/streamable/Streamable.luau +202 -0
- package/vendor/rbxutil/streamable/StreamableUtil.luau +80 -0
- package/vendor/rbxutil/streamable/init.luau +8 -0
- package/vendor/rbxutil/streamable/wally.toml +12 -0
- package/vendor/rbxutil/symbol/init.luau +56 -0
- package/vendor/rbxutil/symbol/init.test.luau +37 -0
- package/vendor/rbxutil/symbol/wally.toml +8 -0
- package/vendor/rbxutil/table-util/init.luau +938 -0
- package/vendor/rbxutil/table-util/init.test.luau +439 -0
- package/vendor/rbxutil/table-util/wally.toml +8 -0
- package/vendor/rbxutil/task-queue/index.d.ts +27 -0
- package/vendor/rbxutil/task-queue/init.luau +97 -0
- package/vendor/rbxutil/task-queue/wally.toml +8 -0
- package/vendor/rbxutil/timer/index.d.ts +81 -0
- package/vendor/rbxutil/timer/init.luau +249 -0
- package/vendor/rbxutil/timer/init.test.luau +73 -0
- package/vendor/rbxutil/timer/wally.toml +11 -0
- package/vendor/rbxutil/tree/index.d.ts +15 -0
- package/vendor/rbxutil/tree/init.luau +137 -0
- package/vendor/rbxutil/tree/wally.toml +8 -0
- package/vendor/rbxutil/trove/index.d.ts +46 -0
- package/vendor/rbxutil/trove/init.luau +787 -0
- package/vendor/rbxutil/trove/init.test.luau +203 -0
- package/vendor/rbxutil/trove/wally.toml +8 -0
- package/vendor/rbxutil/typed-remote/init.luau +196 -0
- package/vendor/rbxutil/typed-remote/wally.toml +8 -0
- package/vendor/rbxutil/wait-for/index.d.ts +17 -0
- package/vendor/rbxutil/wait-for/init.luau +257 -0
- package/vendor/rbxutil/wait-for/init.test.luau +182 -0
- package/vendor/rbxutil/wait-for/wally.toml +11 -0
- package/vendor/t/t.lua +1350 -0
- package/vendor/testez/Context.lua +26 -0
- package/vendor/testez/Expectation.lua +311 -0
- package/vendor/testez/ExpectationContext.lua +38 -0
- package/vendor/testez/LifecycleHooks.lua +89 -0
- package/vendor/testez/Reporters/TeamCityReporter.lua +102 -0
- package/vendor/testez/Reporters/TextReporter.lua +106 -0
- package/vendor/testez/Reporters/TextReporterQuiet.lua +97 -0
- package/vendor/testez/TestBootstrap.lua +147 -0
- package/vendor/testez/TestEnum.lua +28 -0
- package/vendor/testez/TestPlan.lua +304 -0
- package/vendor/testez/TestPlanner.lua +40 -0
- package/vendor/testez/TestResults.lua +112 -0
- package/vendor/testez/TestRunner.lua +188 -0
- package/vendor/testez/TestSession.lua +243 -0
- 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.
|