Skip to content

Data

Server only. Requiring this from a client fails at the require, naming the module.

local Data = require("@game/ServerScriptService/Twill").Data
Data.Configure({
Store = "PlayerData",
Version = 1,
Template = { Coins = 0, Stats = { Wins = 0 } },
})
Lifecycle.SetPlayerGate(Data.Gate)

Data puts a template, a version number, migrations, and a joining gate over Store. Store holds the key; Data decides what the key contains and who waits for it.

Configure runs once, during Init. Calling it twice throws.

Data.Gate holds a joining player until their data exists. Wire it into Lifecycle and no service sees a player before their data is there.

A load that fails after its retries kicks the player rather than handing back the template. Data that could not be read is not data that should be overwritten.

Your saved data could not be loaded.
You have not lost anything. Please rejoin.

A session claimed by another server kicks as well, with a different message. Nothing is lost either way: the other server holds the data.

In Studio, a place that cannot reach storage runs on stand-in stores instead, and says so once. Data then lives in memory and is forgotten when the session stops, rather than every player waiting for a store that will never answer.

A player’s sessions end when their Scope.Player bag closes. Under Lifecycle that is after the last OnPlayerRemoving, so a service can still read and change Data.Get(player) there, and the change is part of the last save.

Version is the shape the running server expects. Migrations are keyed by the version each one produces.

Data.Configure({
Store = "PlayerData",
Version = 3,
Template = { Coins = 0, Level = 1 },
Migrations = {
[2] = function(data) data.Coins = data.Money or 0 end,
[3] = function(data) data.Level = 1 end,
},
})

Steps run in ascending order, from the version the data carries to the version the server wants. A missing step is skipped. The steps run on a copy, which replaces the live data only once every step has succeeded. A step that raises stops the load and the player is kicked, with their stored data left exactly as it was.

A player with no saved data starts from the template at the current version, and no step runs. A step only ever sees data that was saved at an older version.

Data already at a version past the server’s own is left exactly as it is, and reported once. This is what stops a rollback running every migration a second time on data that has already had them.

After migration, anything the template gained and the data lacks is filled in. Existing values are left alone.

A branch is a separate store under the same user, with its own template, its own version, and its own migrations. Use one for data that is large, rarely read, or both.

Data.Configure({
Store = "PlayerData",
Template = { Coins = 0 },
Branches = {
pets = { Version = 1, Template = { Owned = {}, Slots = 3 } },
},
})
local pets = Data.LoadBranch(player, "pets")

Branches are not loaded with the player. LoadBranch opens one, and two callers asking at once both wait on the same load rather than starting two.

UnloadBranch closes one early, for data only needed during part of a session. Anything still open closes when the player leaves.

A branch cannot be named main.

Validate is a check the data must pass before any save. A save it refuses is skipped, the last good data stays stored, and the refusal is logged.

Data.Configure({
Store = "PlayerData",
Template = { Coins = 0 },
Validate = function(data)
return data.Coins >= 0, "coins cannot go below zero"
end,
})

The check must return exactly true. A second return value is the reason that is logged. A check that raises counts as a refusal. Each branch takes its own Validate.

When a player leaves while their data fails the check, their session is still released so the next server does not wait, and the data they last saved is what that server loads.

Transact runs a function that changes a player’s live data, and keeps either every change or none. The changes are undone when the function raises, when it yields, or when the result fails the store’s Validate check.

local bought, reason = Data.Transact(player, function(data)
assert(data.Coins >= 250, "not enough coins")
data.Coins -= 250
data.Owned.Sword = true
end)

Raise to undo: assert(condition, reason) hands the reason back as the second return value. A call that returns false and a reason, as every kit call does, fits inside assert directly.

Undoing puts the data back in place. A table held before the transaction still points at live data afterwards.

A transaction opened inside another joins it. The outer one decides whether everything stays, so a failure inside undoes the outer changes too.

Defer holds an effect until the open transaction is kept. Use it for anything that must not report a change that did not stay, such as a signal or an analytics event.

Edit and Reset work on any user, wherever they are. When this server holds the data, the change lands here. When it does not, the change is written into the key itself and applied by whichever server next holds it.

That difference is what Outcome reports. When the scope has a Validate check, a change made here is tried on a copy first, and a change the check refuses is never applied.

OutcomeMeaning
appliedWritten here and saved.
queuedLeft on the key. It lands when a server holds them.
blockedSomething along that path is not a table.
refusedThe scope’s Validate check rejected the result. Nothing changed.
unknownNo scope by that name.
unsupportedThe value would not survive being saved.
failedThe change could not be sent.

queued is not applied. For a user who never returns, it never lands.

[Server]

Prepares the store, the template, and the upgrade path.

function Data.Configure(config: Config)

Config

FieldTypeDescription
StorestringThe DataStore name.
Template{ [string]: any }What a player starts with.
Versionnumber?The shape this server expects. 1 when left out.
Migrations{ [number]: (data) -> () }?Keyed by the version each step produces.
Branches{ [string]: Branch }?Separate stores under the same user.
Replicate{ string }?Field names the player’s own client should see.
Validate((data) -> (boolean, string?))?A check every save must pass. See Refusing data that should never be saved.

A Branch takes Template, Version, Migrations and Validate, with the same meanings.

Throws on a second call, on a missing or empty Store, on a Template that is not a table, and on a branch named main.

[Server]

Reports whether a store has been configured.

function Data.IsConfigured(): boolean

[Server]

Runs a store’s Validate check on data without saving it.

function Data.Check(data: { [string]: any }, branch: string?): (boolean, string?)

Returns

boolean - true when the store would accept the data. true as well when the store has no check.

string? - Why it would not.

Try a change on a copy first, and make it for real only when the copy passes. A change made across two players at once, such as a trade, is the usual reason.

Throws before Configure, on data that is not a table, and on a branch nobody configured.

[Server]

Holds a joining player until their data exists, then releases them with it.

function Data.Gate<T>(player: Player, ready: (data: T) -> ())

Kicks the player when the session could not be opened. Yields. Throws when used before Configure.

[Server]

Returns a player’s live data.

function Data.Get<T>(player: Player): T?

Write to it directly. There is no Set and no commit step.

Answers nil when no session is open. Throws when given anything but a Player.

[Server]

Reports whether a player’s data is loaded and their session still holds.

function Data.IsReady(player: Player): boolean

[Server]

Reads any user’s data, whether or not they are on this server.

function Data.GetOffline(userId: number, branch: string?): { [string]: any }?

Answers the live table for a user this server holds, and a read-only copy for anyone else. Migrations and template filling are applied to the copy, so it has the shape this server expects.

Yields. Throws when used before Configure and when no branch answers to that name.

[Server]

Requests an early save for one player.

function Data.Save(player: Player)

Returns before the write lands.

[Server]

Saves a player and waits until the write is confirmed.

function Data.SaveNow(player: Player, verify: ((saved: { [string]: any }) -> boolean)?, timeout: number?): boolean

Parameters

NameTypeDescription
playerPlayerWhose data to write.
verify((saved) -> boolean)?Given the data that landed. Answer true when it holds the change.
timeoutnumber?Seconds to wait. 30 when left out.

Returns

boolean - true when the write landed and verify accepted it. Yields for at most timeout seconds.

Only a save that read the data after the call counts, so a save that was already under way cannot confirm a change it never saw. A save refused by Validate answers false.

This is what a Developer Product grant waits on. Granting in memory and answering PurchaseGranted before the write lands loses the purchase if the server stops in between.

[Server]

Makes several changes to a player’s data as one: all of them stay, or none does.

function Data.Transact<T>(player: Player, change: (data: T) -> ()): (boolean, string?)

Parameters

NameTypeDescription
playerPlayerWhose data to change.
change(data: T) -> ()Makes the changes on the live data. Raise to undo them all.

Returns

boolean - true when every change was kept. string? - why nothing was kept: what was raised, a transaction cannot yield, the refusal from Validate, or data is not loaded.

Throws when player is not a Player or change is not a function. A transaction copies the player’s data once, so keep it to changes made at the pace of a player’s actions.

[Server]

Runs an effect once the player’s open transaction is kept.

function Data.Defer(player: Player, effect: () -> ())

Parameters

NameTypeDescription
playerPlayerWhose transaction decides.
effect() -> ()What to do once the change is certain to stay.

With no transaction open, the effect runs at once. An effect queued by a transaction that is undone never runs.

[Server]

Requests an early save for every open session, branches included.

function Data.SaveAll()

Returns before the writes land. Everything open is flushed on shutdown anyway, so no save call belongs in BindToClose. Where the platform gives warning of a scheduled restart, Configure asks for an early save on it.

[Server]

Opens a branch for a player, or hands back the one already open.

function Data.LoadBranch<T>(player: Player, name: string): T?

Two callers asking at once wait on the same load. Yields. Throws when no branch answers to that name, and when given anything but a Player.

[Server]

Returns an already open branch without loading anything.

function Data.GetBranch<T>(player: Player, name: string): T?

[Server]

Reports whether a branch is open and its session still holds.

function Data.IsBranchLoaded(player: Player, name: string): boolean

[Server]

Closes a branch early.

function Data.UnloadBranch(player: Player, name: string)

[Server]

Lists the branch names this store was configured with.

function Data.ListBranches(): { string }

A fresh list in a settled order, safe to keep or reorder.

[Server]

Writes one field of a user’s data, wherever that user is.

function Data.Edit(userId: number, scope: string, path: string, value: any): Outcome

Parameters

NameTypeDescription
userIdnumberWhose data to edit.
scopestring"main", or a branch name.
pathstringDot separated, such as Stats.Coins.
valueanyWhat to write. nil removes the field.

A value storage would refuse is refused here instead, and answers unsupported. Run it through Serialize.Encode first. A result the scope’s Validate check rejects answers refused and changes nothing.

Yields. Throws when used before Configure, and on a missing user id or path.

[Server]

Puts a field, or a whole scope, back to its template.

function Data.Reset(userId: number, scope: string, path: string?): Outcome

Leave path out to restore the whole scope.

There is no confirmation step and no undo. Restoring a scope rewrites the live table in place, so anything holding it keeps working and sees the template. Fields whose names begin with __twill survive a whole-scope reset, so a purchase already paid for is still recognised as paid. Data.Versions reads earlier versions if the old data is needed back.

[Server]

Reports who holds a user’s scope and what the key records about it.

function Data.Inspect(userId: number, scope: string): { [string]: any }?

Returns

{ [string]: any }? - Holder, Created, Updated, Loads and Users, or nil when the key holds nothing or no scope answers to that name. Yields.

Holder is nil when no server holds the key. Created and Updated are os.time readings, and Updated is 0 on a key that was never written.

[Server]

Walks back through what a user’s scope held before.

function Data.Versions(userId: number, scope: string): any

Returns

any - A walk, or nil when no scope answers to that name. NextAsync on it answers one version at a time, newest first, then nil.

local walk = Data.Versions(userId, "main")
while true do
local older = walk:NextAsync()
if not older then
break
end
print(older.Updated, older.Data.Coins)
end

Reading a version changes nothing. Write a value back with Data.Edit.

[Server]

Raises stored data to a version by applying each upgrade in turn.

function Data.Migrate(stored: { [string]: any }, version: number, steps: { [number]: (data) -> () })

Changes stored in place. Data already current is left untouched, and data past that version is left as it is rather than marked back down to it.

Throws when a step raises, naming the version it was reaching.

export type Outcome = "applied" | "queued" | "blocked" | "refused" | "unknown" | "unsupported" | "failed"

Replicate names the fields a player’s own client should see. Those fields are published to that player alone, under the key Data, and brought back in line on an interval.

Data.Configure({
Store = "PlayerData",
Template = { Coins = 0, Secret = "" },
Replicate = { "Coins" },
})
-- Client
Replication.Subscribe("Data.Coins", function(coins)
label.Text = tostring(coins)
end, bag)

A field left out of Replicate never leaves the server.

LimitValue
Time between replication refreshes0.1 seconds
SaveNow default timeout30 seconds
Key size4 MB
Autosave, set by Store180 seconds