Store and save player data
Set up the store
Section titled “Set up the store”Configure once, during Init, then wire the gate so no service sees a player
before their data exists.
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)Read and write
Section titled “Read and write”Data.Get returns the live table. Mutate it and it saves on its own.
function ShopService.OnPlayerReady(player, data) data.Coins += 100endlocal data = Twill.Data.Get(player)if not data then returnend
data.Stats.Wins += 1There is no commit step, and no save call belongs in BindToClose. Twill
flushes everything on shutdown already.
Add a field later
Section titled “Add a field later”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}Change a field later
Section titled “Change a field later”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,Never renumber a migration
Section titled “Never renumber a migration”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.
Refuse data that should never be saved
Section titled “Refuse data that should never be saved”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.
Store a Roblox value
Section titled “Store a Roblox value”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.
Show data on the client
Section titled “Show data on the client”Name the fields in Replicate and they arrive on the owning client, following
direct mutation.
Twill.Data.Configure({ -- ... Replicate = { "Coins", "Stats" },})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.
Data for a player who is not here
Section titled “Data for a player who is not here”local data = Twill.Data.GetOffline(userId)Read-only, and it does not take the session from whichever server holds it.
Write to a player who is not here
Section titled “Write to a player who is not here”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.
Verify a write landed
Section titled “Verify a write landed”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 >= expectedend, 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.