data
data is persistent, typed, automatically replicated player data for Roblox Luau.
The API is intentionally small:
Data[player].currency() -- get
Data[player].currency(50) -- set
Data[player].currency(function(currency) -- update
return currency + 10
end)
On the client, it is the same idea without the player:
Data.currency()
Data.currency(50)
Data.currency.changed(function(currency)
print(currency)
end)
Installation
Add data to your wally.toml:
[dependencies]
data = "twistedsignal/[email protected]"
Then run:
wally install
Create Your Data
Create one shared data module. Most games call it Data.luau.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Packages = ReplicatedStorage.Packages
local data = require(Packages.Data)
type ItemData = {
health: number,
dmg: number,
}
local template = {
currency = 0,
inventory = {
apples = 5,
oranges = 10,
},
settings = {
music = true,
sfx = true,
},
items = {} :: { [string]: ItemData },
equippedItemId = nil :: string?,
questProgress = {} :: { number },
}
return data({
template = template,
})
Keep saved data datastore-safe: numbers, strings, booleans, arrays, dictionaries, and nil for optional values. Do not store Instances, CFrames, Vector3s, functions, threads, or userdata.
Require It
Require .server on the server:
local Data = require(path.to.Data).server
Require .client on the client:
local Data = require(path.to.Data).client
This keeps the local variable named Data everywhere.
Get
Call a field with no arguments.
local currency = Data[player].currency()
local apples = Data[player].inventory.apples()
local musicEnabled = Data[player].settings.music()
On the client:
local currency = Data.currency()
local apples = Data.inventory.apples()
Set
Call a field with a new value.
Data[player].currency(50)
Data[player].inventory.apples(12)
Data[player].settings.music(false)
Data[player].equippedItemId("wooden_sword")
Clear optional values with nil.
Data[player].equippedItemId(nil)
Server changes save and replicate to that player's client automatically.
Client changes are local-only:
Data.settings.music(false)
That updates the client mirror and fires client callbacks, but it does not save and does not replicate to the server.
Update
Call a field with a function to update from the current value.
Data[player].currency(function(currency)
return currency + 10
end)
The function receives the old value and returns the new value.
Data[player].inventory.apples(function(apples)
return math.max(0, apples - 1)
end)
Listen
Use .changed on any field.
local disconnect = Data[player].currency.changed(function(currency, previousCurrency)
print("Currency:", previousCurrency, "->", currency)
end)
disconnect()
Client code looks the same:
Data.currency.changed(function(currency)
print("Currency changed to", currency)
end)
You can listen to nested fields:
Data[player].inventory.apples.changed(function(apples)
print("Apples:", apples)
end)
You can also listen to parent tables:
Data[player].inventory.changed(function(inventory, previousChildValue, childKey)
print(childKey, "changed")
end)
Optional Replication
Server writes replicate by default. Pass false as the second argument to skip replication for that write.
Data[player].currency(100, false)
Data[player].currency(function(currency)
return currency + 25
end, false)
This still changes the server data and still saves. It only skips the server-to-client update for that mutation.
Arrays
Typed arrays get .insertArray, .removeArray, .onArrayInserted, and .onArrayRemoved.
Data[player].questProgress.insertArray(10)
Data[player].questProgress.insertArray(20)
Data[player].questProgress.insertArray(15, 2)
print(Data[player].questProgress()[1]) -- 10
print(Data[player].questProgress()[2]) -- 15
print(Data[player].questProgress()[3]) -- 20
Remove by position:
local removed = Data[player].questProgress.removeArray(2)
Omit the position to remove the last item:
local last = Data[player].questProgress.removeArray()
Listen to array operations:
Data[player].questProgress.onArrayInserted(function(item, position)
print("Inserted", item, "at", position)
end)
Data[player].questProgress.onArrayRemoved(function(item, position)
print("Removed", item, "from", position)
end)
Skip replication for an array operation with false:
Data[player].questProgress.insertArray(3, nil, false)
Data[player].questProgress.removeArray(1, false)
Dictionaries
String-keyed dictionaries work like normal nested data.
Data[player].items.sword_001({
health = 0,
dmg = 12,
})
print(Data[player].items.sword_001.dmg())
Remove dictionary entries by setting them to nil.
Data[player].items.sword_001(nil)
Listen for dictionary keys being added or removed with .onKeyAdded and .onKeyRemoved.
Data[player].items.onKeyAdded(function(key, item)
print("Added item", key, item.dmg)
end)
Data[player].items.onKeyRemoved(function(key, item)
print("Removed item", key, item.dmg)
end)
Both functions return a disconnect callback, just like .changed.
These fire when a key changes from nil to a value, or from a value to nil.
Data[player].items.potion_001({
health = 25,
dmg = 0,
}) -- OnKeyAdded
Data[player].items.potion_001(nil) -- OnKeyRemoved
Changing an existing key fires .changed, not .onKeyAdded.
Data[player].items.potion_001.dmg(5)
Options
You can pass options when creating the data module:
return data({
template = template,
profileStoreIndex = "Default",
profileStoreDataPrefix = "PLAYER_",
useMock = false,
viewedUserId = nil,
overridenUserId = nil,
dontSave = false,
resetData = false,
})
| Option | Description |
|---|---|
template | Required. The default data shape for every player. |
profileStoreIndex | ProfileStore name. Defaults to "Default". |
profileStoreDataPrefix | Prefix before the user id in the profile key. Defaults to "PLAYER_". |
useMock | Uses ProfileStore mock storage for testing. |
viewedUserId | Loads another user's data for viewing without starting a write session. |
overridenUserId | Uses another user id as the active profile id. |
dontSave | Loads with GetAsync instead of a write session. Useful for read-only testing. |
resetData | Removes the profile before loading. Useful while testing data templates. |
overridenUserId is spelled this way in the current API.
Next Steps
- Read about service functions for profile access, reset tools, loading hooks, and global messages.
- Browse the complete API reference