Skip to content

Reading & Writing Values

Use Get to read a field and Set to change it. For example, data.Coins gives you the methods for the Coins field in your template. This object is called an accessor.

Start with Getting Started if you do not have a shared data module yet. The first example below works with its Coins field. Later inventory and level examples use the larger Emberfall template.

Two methods cover most of what you will ever write.

local data = Data.WaitForData(player)
if not data then return end
data.Coins.Get() --> 0
data.Coins.Set(100) --> 100, the stored value
data.Coins.Increment(50) --> 150
data.Coins.Decrement(25) --> 125

Every write is validated against the declarator before it lands, clamped if it is out of bounds, replicated to the owning client, and announced to any listener. Set returns the value that was actually stored, which is not always the value you passed: data.Coins.Set(-10) on a { Min = 0 } field returns 0.

That is the whole common case. Everything below is a method for a particular kind of field.

Keeping one write off the wire

Most mutators take a trailing replicate boolean. Pass false and the write happens on the server, persists as normal, and is simply not sent to the client:

data.Stats.Playtime.Increment(60, false)

Reach for it on a field that changes very often and that no UI reads. If the client should never see a field, declare it Scribe.ServerOnly instead, which is a statement about the field rather than about one write.

On Increment and Decrement the second argument is normally an economy tag table, so false in that slot is the way to say “server-only” there.

Observe calls you immediately with the current value and again on every change. Changed skips that first call. Both return a function that disconnects the listener.

In this UI example, coinsLabel is a TextLabel you have created and playLevelUpEffect is your own effect function. Use print(coins) in place of the label assignment when trying it without a UI.

-- client
local stop = Data.Coins.Observe(function(coins)
coinsLabel.Text = tostring(coins)
end)
Data.Level.Changed(function(new, old)
if new > old then
playLevelUpEffect(new)
end
end)

Watch a change fire in the playground, including what happens after the listener disconnects.

Observe is what you want for UI, because it removes the “what do I show before the first value arrives” question. Changed is what you want when only the transition matters.

Update reads the current value, hands it to your function, and stores whatever you return. It saves you the read and keeps the two halves next to each other.

-- Round Emberfall's coin balance down to the nearest ten.
data.Coins.Update(function(current)
return current - (current % 10)
end)

Clone gives you a deep copy you may freely mutate, and Default gives you the field’s declared default without touching the stored value at all.

local snapshot = data.Inventory.Clone() -- yours to edit; nothing is written back
table.clear(snapshot)
data.Settings.Set(data.Settings.Default()) -- reset settings to the template default

Default is read-only schema metadata, so it works on the client, on the server, and before any data has loaded. That makes it the right way to build a “reset to defaults” button, and the right way to tell whether a player has ever changed something.

Why the rule is blanket rather than conditional

A subtree that contains packed datatypes is rebuilt on the way out, so mutating that one is silently discarded rather than silently applied. Two different silent failures depending on the shape of your template is worse than one rule, so treat everything Get returns as read-only.

One container is not yours and does not hand back the stored table. Scribe’s own _Scribe root, holding the receipt-dedupe ring, perks, gift credits, pending gifts, purchase logs, timers and the schema version, refuses every write from game code, and a read of it returns a detached deep copy so that refusal cannot be walked around. Get is not a write, so data._Scribe.ProcessedPurchaseIds.Get() followed by table.clear would otherwise empty the dedupe ring and make the next receipt Roblox retries grant a second time on one payment. Read that state with Data.Owns, Data.GetGiftCredits and Data.GetPurchases. Your own containers are unaffected.

The refusal is by caller, not by object. Scribe writes those same paths through its own privileged handle, so the library is never blocked by its own guard, and there is no object you can hold that lets game code past it. Change that state through the APIs that own it: Data.GrantPerk, Data.RevokePerk, Data.Purchase and Data.PromptGift.

Data.Get(player).Get(), with no field in front of the final call, is the one read that does not hand back the store. It returns a frozen table of exactly the roots your template declares, so the mistake above fails loudly there:

local whole = Data.Get(player).Get()
whole.Coins = 999 --> error: attempt to modify a readonly table
Data.Get(player).Coins.Set(999) -- the spelling that validates and replicates

Scribe’s internal _Scribe root is not in that table, and neither is _ScribeSession on the client, which is what the accessor type has always promised. Scribe.Session roots are present on the server, where the type promised them and the runtime used to leave them out.

The freeze is shallow. Nested containers reached through it are still the live tables described above. Clone returns the same shape unfrozen, and Data.Export is the full profile dump including Scribe’s internals.

MethodWhat it does
Increment(amount, meta?)Adds and returns the new value.
Decrement(amount, meta?)Subtracts and returns the new value.
Min()The declared minimum, or nil if unbounded.
Max()The declared maximum, or nil if unbounded.

Min and Max are read-only schema metadata, so they work on the client and before data loads. Drive a slider or a progress bar off them and the bound lives in exactly one place:

levelBar.Size = UDim2.fromScale(Data.Level.Get() / Data.Level.Max(), 1)

The optional second argument to Increment and Decrement is an economy tag table. Pass one and Scribe emits a Roblox economy event for the write, with this field’s name as the currency:

data.Coins.Increment(500, {
TransactionType = Enum.AnalyticsEconomyTransactionType.IAP,
ItemSku = "CoinPack500",
})

Economy Analytics covers the tag table in full.

A plain boolean field has Toggle(), which flips it and returns the new value.

Emberfall keeps its three settings in a Scribe.Flags field instead, because they belong together. A flags field is addressed by member name, and the whole set is one write and one Changed:

Settings = Scribe.Flags({ "Music", "Sfx", "TutorialDone" }),
data.Settings.Enable("Music")
data.Settings.Has("Music") --> true
data.Settings.Toggle("Sfx") --> true, the new state
data.Settings.Disable("Music")
data.Settings.Get() --> { "Sfx" }
data.Settings.Clear() -- every flag off

Enable and Disable return nothing. Enabling a name that is not a declared member is an error rather than a silent no-op, and setting a flag to the state it already holds writes nothing at all: no replication, no Changed.

Flags start off, and there is no way to declare otherwise

A Scribe.Flags field’s default is the empty set, so every member of Emberfall’s Settings starts disabled, including Music and Sfx. There is no per-member default.

If your game should start with some of them on, enable them in OnPlayerInit, which runs before the player can read anything. That hook receives the raw profile table rather than the accessor tree, and a flags field is stored as the list of enabled member names, so the assignment is rawData.Settings = { "Music", "Sfx" }.

Flags against three sibling booleans

Three separate boolean fields would be three writes, three Changed fires, and three chances to write a field name the template never declared. A flags field is one write, one Changed, and a compile-time list of the legal names, and it packs to a bitmask on the wire.

The value is stored as the enabled member names, in declaration order, so reordering or removing members in a later release is safe. Only the wire packing is positional, and both ends of a replication frame always come from the same build. The cap is 32 members, refused at compile time rather than by silently dropping the 33rd.

A Scribe.SetOf field holds unique scalars and answers “is this in here”. Say Emberfall tracks which regions a player has unlocked:

Unlocked = Scribe.SetOf(Scribe.String("", { MaxLength = 32 }), { MaxItems = 64 }),
data.Unlocked.Add("AshfallRidge") --> true
data.Unlocked.Add("AshfallRidge") --> false, already a member
data.Unlocked.Has("AshfallRidge") --> true
data.Unlocked.Remove("AshfallRidge") --> true
data.Unlocked.Count() --> 0
data.Unlocked.Clear()

Add on a value already present, and Remove on one that is absent, both return false and do nothing at all: no write, no replication, no Changed. That return value is the cheap way to answer “was this the first time”, which is exactly what a discovery reward needs:

if data.Unlocked.Add(regionId) then
data.Xp.Increment(250)
end

There is no Insert and no index-based Remove, because a set has no positions. Containers covers the declarator side, including why the element has to be a scalar.

A Scribe.Big field carries a value far past the point a Luau number stops being exact. Suppose Emberfall’s prestige mode adds one:

Essence = Scribe.Big(0, { Min = 0 }),

On top of Set, Increment and Decrement, a big field gets two methods no other field has. Both take a plain number, a numeric string, or another big value, and both return the new stored value:

data.Essence.Set("1.5e100")
data.Essence.Multiply(1.15) -- 1.725e100, a prestige bonus
data.Essence.Divide(2) -- 8.625e99, halved on a prestige reset

Multiply and Divide go through the ordinary write path, so bounds, replication and economy events all apply exactly as they do to Increment. Dividing by zero is an error naming the field rather than a value nothing can interpret.

Reading a big field back gives you an object rather than a number. BigValue is the reference page for it, covering Short, ToNumber, Pow and Log10, and why Get() < 2000 throws.

Why these two exist at all

A big value is an object, not a number, so data.Essence.Set(data.Essence.Get() * 1.15) is a read, an arithmetic operation, and a write, and the arithmetic is the only part you actually meant. Multiply collapses the three, and more importantly it keeps the operation on the same write path as everything else, so a Min or Max clamp reports one anomaly rather than none.

Big Numbers covers the value object itself: Short, ToNumber, Pow, Log10, and why a comparison needs a big on both sides.

Three listeners answer three different questions about a container.

ListenerQuestion it answersCallback
ChangedSomething under here changed. What does it look like now?(new, old)
OnChildChangedWhich direct child moved, and from what to what?(key, new, old)
OnKeyAdded and OnKeyRemovedDid a key appear or disappear?(key, value)
data.Inventory.Changed(function(inventory)
redrawTotalWeight(inventory) -- once per batch, however many slots moved
end)
data.Inventory.OnChildChanged(function(itemId, new)
refreshSlot(itemId, new) -- once per write
end)
data.Inventory.OnKeyRemoved(function(itemId, lastValue)
destroySlot(itemId)
print(`{itemId} left the bag holding {lastValue.Qty}`)
end)

Changed on a container is coalesced: a Data.Batch writing four fields fires it once, and on the client one replication frame fires it once however many children it carried. OnChildChanged is never coalesced, so three writes fire it three times. All of a container’s OnChildChanged fires arrive before its Changed, so you can accumulate keys and act once.

OnKeyAdded and OnKeyRemoved report a key appearing or disappearing, never one changing. To learn that an existing key’s value moved, use OnChildChanged.

Why old is sometimes the same table as new

On a container, Changed reports state rather than a transition, and old is the same reference as new, because the fire happens after the write and Scribe does not snapshot a container’s contents. The same caveat applies to OnChildChanged when the child is itself a container. When the child is a leaf, old is a real prior value.

A write that replaces the container’s own table is different: a whole-container Set or Update, and the set and flags mutators, which rewrite the whole value. Those carry a real prior table as old and are not coalesced, because collapsing them would throw that prior value away.

MethodApplies to
Get, Set, Update, Clone, Default, Observe, Changedevery field
Increment, Decrement, Min, Maxnumbers, including Scribe.Big
Multiply, DivideScribe.Big only
Togglebooleans, and Scribe.Flags with a member name
Enable, Disable, Has, ClearScribe.Flags
Add, Remove, Has, Count, ClearScribe.SetOf
Insert, Remove, RemoveValue, Find, Has, Count, Cleararrays and dictionaries
OnInsert, OnRemove, OnKeyAdded, OnKeyRemoved, OnChildChangedcontainers
SetTimed, ExtendTimed, ActiveScribe.Timed

Calling one on the wrong kind of field is an error naming the field and the method, not a silent no-op. A derived field has no mutators at all: they are absent from its type and they throw if you reach them anyway.