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.
Get and Set
Section titled “Get and Set”Two methods cover most of what you will ever write.
local data = Data.WaitForData(player)if not data then return end
data.Coins.Get() --> 0data.Coins.Set(100) --> 100, the stored valuedata.Coins.Increment(50) --> 150data.Coins.Decrement(25) --> 125Every 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.
Reacting to a change
Section titled “Reacting to a change”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.
-- clientlocal stop = Data.Coins.Observe(function(coins) coinsLabel.Text = tostring(coins)end)
Data.Level.Changed(function(new, old) if new > old then playLevelUpEffect(new) endend)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.
Read, modify, write
Section titled “Read, modify, write”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 backtable.clear(snapshot)
data.Settings.Set(data.Settings.Default()) -- reset settings to the template defaultDefault 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.
The whole-tree read is frozen
Section titled “The whole-tree read is frozen”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 tableData.Get(player).Coins.Set(999) -- the spelling that validates and replicatesScribe’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.
Numbers
Section titled “Numbers”| Method | What 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.
Booleans
Section titled “Booleans”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") --> truedata.Settings.Toggle("Sfx") --> true, the new statedata.Settings.Disable("Music")data.Settings.Get() --> { "Sfx" }data.Settings.Clear() -- every flag offEnable 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") --> truedata.Unlocked.Add("AshfallRidge") --> false, already a memberdata.Unlocked.Has("AshfallRidge") --> truedata.Unlocked.Remove("AshfallRidge") --> truedata.Unlocked.Count() --> 0data.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)endThere 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.
Big numbers
Section titled “Big numbers”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 bonusdata.Essence.Divide(2) -- 8.625e99, halved on a prestige resetMultiply 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.
Containers
Section titled “Containers”Three listeners answer three different questions about a container.
| Listener | Question it answers | Callback |
|---|---|---|
Changed | Something under here changed. What does it look like now? | (new, old) |
OnChildChanged | Which direct child moved, and from what to what? | (key, new, old) |
OnKeyAdded and OnKeyRemoved | Did a key appear or disappear? | (key, value) |
data.Inventory.Changed(function(inventory) redrawTotalWeight(inventory) -- once per batch, however many slots movedend)
data.Inventory.OnChildChanged(function(itemId, new) refreshSlot(itemId, new) -- once per writeend)
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.
The whole surface at a glance
Section titled “The whole surface at a glance”| Method | Applies to |
|---|---|
Get, Set, Update, Clone, Default, Observe, Changed | every field |
Increment, Decrement, Min, Max | numbers, including Scribe.Big |
Multiply, Divide | Scribe.Big only |
Toggle | booleans, and Scribe.Flags with a member name |
Enable, Disable, Has, Clear | Scribe.Flags |
Add, Remove, Has, Count, Clear | Scribe.SetOf |
Insert, Remove, RemoveValue, Find, Has, Count, Clear | arrays and dictionaries |
OnInsert, OnRemove, OnKeyAdded, OnKeyRemoved, OnChildChanged | containers |
SetTimed, ExtendTimed, Active | Scribe.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.
Where to next
Section titled “Where to next”- Declaring Your Template is the other half: how a field comes to exist in the first place.
- Containers covers arrays, dictionaries, maps and sets as declarators.
- Big Numbers covers the big value object and its arithmetic, and
BigValueis its reference page. - Replication and Visibility explains who receives a write and when.
- Economy Analytics covers the tag table on
IncrementandDecrement.