Skip to content

Store and save player data

Configure once, during Init, then wire the gate so no service sees a player before their data exists.

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

Data.Get returns the live table. Mutate it and it saves on its own.

function ShopService.OnPlayerReady(player, data)
data.Coins += 100
end
local data = Twill.Data.Get(player)
if not data then
return
end
data.Stats.Wins += 1

There is no commit step, and no save call belongs in BindToClose. Twill flushes everything on shutdown already.

Add it to the template. Existing profiles are filled in on load, so a new field needs no migration.

Template = {
Coins = 0,
Inventory = {},
Stats = { Wins = 0, Losses = 0 },
Pets = {}, -- new; existing profiles get {} on next load
}

Renaming, reshaping, or recomputing a field needs a migration. Bump the version and add a step keyed by the version it produces.

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

Steps run in ascending order, and only on profiles that already hold data. A new profile starts at the current version and skips all of them.

A step that throws prevents the session from opening, and the player is kicked rather than served a half-upgraded profile. That is the safe failure, but it is still a failure, so write migrations defensively:

[3] = function(data)
data.Stats = { Wins = tonumber(data.Wins) or 0 }
end,

Once a version has shipped, its number is fixed. Profiles in the wild record which version they are at. Renumbering makes a step run twice or not at all.

A bug that sets Coins to a negative number, or removes a required field, is saved like any other change. Give the store a check, and a failing state is never written: the last good data stays stored, and the refusal is logged.

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

The check must return exactly true to allow the save. A check that raises counts as a refusal. Each branch takes its own Validate.

Data.Edit and Data.Reset try a change on a copy first and return refused when the check fails, so an admin edit cannot put a player into a state that would never save again.

A DataStore holds JSON. A Vector3 written straight into player data fails the save, late and quietly.

data.Home = Twill.Serialize.Encode(spawnPoint.Position)
-- ...
local home = Twill.Serialize.Decode(data.Home)

See Serialize for the full list of what needs encoding, and the key shapes a DataStore silently mangles.

Name the fields in Replicate and they arrive on the owning client, following direct mutation.

Twill.Data.Configure({
-- ...
Replicate = { "Coins", "Stats" },
})
client
Twill.Replication.Subscribe("Data.Coins", function(coins)
label.Text = Twill.Format.Comma(coins or 0)
end)

Only the named fields travel, and only to their owner.

local data = Twill.Data.GetOffline(userId)

Read-only, and it does not take the session from whichever server holds it.

local outcome = Twill.Data.Edit(userId, "main", "Coins", 500)

The scope is "main" for the main profile, or the name of a branch. One of three routes is chosen for you: applied here if this server holds the session, sent to whichever server does, or left waiting in their saved data until a server next opens their session. Nothing ever writes over a session it does not own.

Check the outcome. "unsupported" means the value cannot survive a DataStore and needs Serialize.Encode first. "refused" means the scope’s Validate check rejected the result.

Move a large collection off the main profile

Section titled “Move a large collection off the main profile”

A profile that loads on every join should stay small. Put rarely-read data in a branch.

Twill.Data.Configure({
-- ...
Branches = {
Pets = {
Template = { Owned = {} },
Version = 1,
},
},
})
local pets = Twill.Data.GetBranch(player, "Pets")
or Twill.Data.LoadBranch(player, "Pets")

GetBranch does not yield. LoadBranch does.

Most writes need no confirmation. When one does, such as before telling Roblox a purchase succeeded:

local landed = Twill.Data.SaveNow(player, function(saved)
return saved.Coins >= expected
end, 10)

Only a save that read the data after the call counts, so a save already under way cannot confirm a change it never saw. Monetization already does this for receipts. You do not need to repeat it there.