pesde twistedsignal / data

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,
})
OptionDescription
templateRequired. The default data shape for every player.
profileStoreIndexProfileStore name. Defaults to "Default".
profileStoreDataPrefixPrefix before the user id in the profile key. Defaults to "PLAYER_".
useMockUses ProfileStore mock storage for testing.
viewedUserIdLoads another user's data for viewing without starting a write session.
overridenUserIdUses another user id as the active profile id.
dontSaveLoads with GetAsync instead of a write session. Useful for read-only testing.
resetDataRemoves the profile before loading. Useful while testing data templates.

overridenUserId is spelled this way in the current API.

Next Steps