Replication & Visibility¶
By default, a field persists and replicates to its owner, the player it belongs to. Wrap a root field to change that.
| Wrapper | Persists? | Replicates to | Use for |
|---|---|---|---|
| (default) | ✅ | the owner | most player data |
Scribe.ServerOnly(v) |
✅ | nobody | secrets, anti-cheat state |
Scribe.Shared(v) |
✅ | every client | public info (name, team) |
Scribe.Session(v) |
❌ | the owner | runtime-only state |
Scribe.Session cannot wrap a Scribe.Dynamic field (a startup error): Session data is rebuilt from its default every session, so a one-time seed has nowhere to live. Generate per-session values in OnPlayerInit instead.
local template = {
Coins = 0, -- persists, owner sees it
Secret = Scribe.ServerOnly({ Flagged = false }), -- never leaves the server
Public = Scribe.Shared({ DisplayName = "" }), -- everyone sees it
Runtime = Scribe.Session({ InCombat = false }), -- resets on rejoin
}
Shared and Session are root-only wrappers. ServerOnly may also wrap a nested field to keep just that subtree server-side while its siblings replicate.
Reading another player's shared data¶
Scribe.Shared roots stream to everyone. Read them on the client with GetShared, which accepts a Player or a userId:
local pub = Data.GetShared(otherPlayer)
if pub then nameLabel.Text = pub.DisplayName end
-- Fires (userId, shared) when anyone's shared data changes; `shared` is nil once they leave.
Data.OnSharedChanged:Connect(function(userId, shared)
if shared then
updateNameTag(userId, shared.DisplayName)
end
end)
OnSharedChanged hands you a userId rather than a Player on purpose: the owner may have already left (a departed player has no Player object, and that's exactly when a nil update fires). It's the stable key, and GetShared accepts it directly, so the two compose.
How replication works¶
Scribe derives an identical schema on the server and client from the shared template, then streams schema-compressed batched diffs over a pluggable transport. Writes coalesce per frame (a PostSimulation flush) and only send when something actually changes. There is no idle traffic. The client applies diffs to a local mirror, so reads are instant and Observe/Changed fire locally.
The client converges to the current value, not an event log
When a client finishes its handshake it receives a snapshot of the current data, then live diffs from that point on. It never replays writes that happened before it synced, so it is not a log of every value the server ever set.
- A value the server sets and then overwrites before the client syncs is not delivered on its own. Set a field to
5then10while the client is still handshaking, and the client's first value is10, never5.Observefires once with the synced value, then again on each later change. Data.WaitForData(player)on the server waits for that player's profile to load server-side, not for the client's handshake to finish. Writes made right after it returns can reach the client only as their final value. To act on a client being ready to receive, drive it from the client (a firstObservefire, or aData.Requestthe client sends once loaded).
The client always ends at the correct current value. To have it observe a sequence of values, produce those changes after the client is synced and space them across frames, since same-frame writes to one field coalesce to the latest. For UI, read the value passed to Observe rather than counting individual fires.
You never wire up RemoteEvents. The "Default" transport uses two RemoteEvents under a folder in ReplicatedStorage and is all most games ever need. If you already run your own networking layer, you can route Scribe's traffic through it. See Custom Transports.
Client writes are optimistic
data.Coins.Set(5) on the client updates the local mirror only. It's fine for snappy UI, but the server's value always wins on the next diff. Authoritative changes go through Data.Request → a server Command.