Server¶
The server-side data API: bundle.Server. Index it with a Player to
get that player's typed accessor tree (Data[player].Coins.Increment(50)),
and call its methods to drive persistence, monetization, leaderboards, and
administration.
Data[player] and Data.Get error while a profile is still loading.
Use Server.WaitForData first (or read inside a command handler, which
only runs once the player is Ready).
Indexing
Data[player] returns the same accessor tree as Data.Get(player). The
methods on individual fields (.Get, .Set, .Increment, …) are
documented on the Value class.
Lifecycle¶
.WaitForData¶
Types: LifecycleReason
Yields until the player's profile is Ready, then returns their accessor tree,
waiting up to timeout seconds (default 60). On failure returns (nil, reason),
so always handle the nil branch. This is the safe way to read in PlayerAdded.
reason is one of seven values, exported as the Scribe.LifecycleReason type
and the Scribe.Reason table so you can branch without guessing at spellings:
| Reason | Meaning | Retryable |
|---|---|---|
player-left |
The player left. By far the most common. | no |
still-loading |
The wait elapsed while the load was still in flight. Nothing has failed; call again, or read Server.GetState to watch it. |
yes |
timeout |
The wait elapsed and Scribe has no session for this player at all, usually because the bundle was constructed after they joined. | no |
load-failed |
The profile could not be loaded. | no |
migration-failed |
A migration errored, so the profile was released unmigrated. | no |
session-ended |
The session ended while the player was still in game. | no |
shutdown |
The server is closing. | no |
still-loading and timeout were one value until 2.0, so handle both if you
branch on timeout today. Session Lifecycle says why.
.GetState¶
The player's session state, without yielding.
.Get¶
The player's accessor tree. Errors if the profile is not Ready. Prefer
Server.WaitForData on join. Data[player] is shorthand for this.
.Batch¶
Runs fn as a batch: every write inside coalesces into a single replication
flush and one Changed pass. Use it for bulk updates.
"One Changed pass" applies to containers. Writing four fields of one
table fires that table's (and the root's) Changed once rather than four
times, on the server and on the client. Each individual field still fires its
own leaf Changed, because each is a distinct transition with its own old and
new value. Use Value.OnChildChanged, which is never coalesced, to learn
which children moved.
Data.Batch(player, function()
state.Index.Set(i + 1)
state.StartedAt.Set(0)
state.Current.Set(next)
end)
-- state.Changed fires ONCE with the end state
-- state.OnChildChanged fires three times: Index, StartedAt, Current
-- state.Index.Changed fires once, with its own old and new
.Transaction¶
Runs fn atomically: if it throws, every write inside is rolled back and
(false, error) is returned. On success returns (true, nil).
fn must not yield. A yield is refused with (false, error) and rolled
back, because a concurrent write landing during the yield would be pulled into
the transaction, so do the async work before or after. Tagged
Value.Increment and Value.Decrement calls inside fn defer their economy
event to commit and drop it on rollback.
Atomic in memory, not on the DataStore. A true return means the writes
landed on this player's tree together, not that they are saved: they queue for
the next save like any other write. Pair it with Server.Flush when the
operation must be durable before you acknowledge it.
One player, and only one. While the transaction is open, any write to a different player's tree is refused, and that refusal fails this transaction. There is no cross-player or cross-key transaction. For one-sided value, use the durable outbox in Cross-Key Transactions instead.
.Stop¶
Releases everything this bundle holds on the process: the Timed sweep, the
leaderboard write pacer and refresh cycle, the per-frame replication flush,
the Players and MarketplaceService listeners, the ProfileStore signal
handlers, and MarketplaceService.ProcessReceipt if this bundle owns it.
A Scribe bundle is normally built once and lives for the server's lifetime, so a game never needs this. It exists because a PROCESS that builds many bundles (a test suite, or a simulation standing up a fleet of servers) otherwise accumulates immortal loops and listeners, and ends up measuring that accumulation rather than Scribe.
Idempotent, and does NOT save: call Data.Flush first if the data matters.
Loaded sessions are left alone; drop the bundle and they go with it.
Persistence¶
.Flush¶
Forces a save now instead of waiting for the next autosave. Call it right
after a grant or purchase. Force = true also pushes through a blocked
wipe-guard save. Timeout bounds the wait for save confirmation in
seconds (default 15); on timeout Flush returns false, though the save
may still complete afterwards.
When the profile is already on disk (nothing written since the last save,
no save still in flight, and that save succeeded), this returns true
immediately and spends no DataStore request, because the answer is already
known. Force = true always goes to the store. The saving is real for a
game that flushes on a timer or a checkpoint; it never applies right after a
grant, since a grant leaves the profile dirty by definition.
.GetSaveInfo¶
Types: SaveInfo
The player's save state ({ LastSaveAt, LastResult, Dirty, Size }) for
"Saving… / Saved ✓ / Unsaved changes" UI. Also mirrored to the owner.
Size is the approximate byte size of the last successful save (nil before
the first), useful for spotting a profile growing toward the ~4 MB ceiling.
.GetOffline¶
Reads a profile that is not on this server (offline, or another server),
or nil if it doesn't exist. Read-only. Use Server.UpdateOffline to
write.
.UpdateOffline¶
Mutates an offline profile's raw data. Your callback runs against a copy and may yield for as long as it likes, and the commit is a compare-and-set: the write lands only if, at that instant, the profile is still free of a live session and still byte-for-byte the one your callback was handed. A refusal writes nothing at all, not even a key version.
Three reasons come back: "profile does not exist", since it cannot create
one; "profile has an active session on another server", because it fails
closed rather than stealing a live lock; and "profile changed while the
update was being prepared", meaning something else wrote the key first.
Nothing was written in any of them, so retrying the last against a fresh read
is always safe, and is the right response.
A session whose last write is older than the store's dead-session threshold counts as dead and the write proceeds, so a crashed server cannot lock a profile out forever. Offline Profiles covers the threshold and the custom-store caveat.
.ListVersions¶
Server.ListVersions(userId: number, limit: number?) → { { VersionId: string, CreatedAt: number, Size: number? } }
Lists a profile's DataStore version history, for support/rollback tools.
.GetVersion¶
Reads the raw data of a specific historical version.
.RestoreVersion¶
Server.RestoreVersion(userId: number, versionId: string, opts: { RollBackReserved: boolean? }?) → (boolean, string?)
Restores a profile to a historical version. Fails closed if the user has a
live session anywhere, and while service health reports an
outage. It needs a live key to write over, so restoring an erased profile fails
with no live profile exists for this user to restore over.
The reserved _Scribe root is not rolled back. Receipts granted, gifts
paid for, perks bought, the purchase log and running cooldowns do not un-happen
because a backup was restored, so that root is carried across from the live
profile and stamped with RestoredFrom. A preserved root that differed from
the snapshot's logs RESTORE_RESERVED_PRESERVED. The schema Version is the
one exception: it describes the shape of the game data, so it travels with it.
RollBackReserved = true rolls that root back too. It is a repair tool for a
_Scribe that is itself corrupt, and nothing else: see the danger note in
Offline Profiles.
.Erase¶
GDPR right-to-erasure: removes the profile and the user's leaderboard
entries. Returns (false, reason) if any part failed so you can retry.
.Export¶
GDPR data export: the profile as a JSON string (buffers base64-encoded),
or nil if it doesn't exist.
Commands¶
.Command¶
Server.Command(name: string, specOrHandler: ({ Args: { string }?, Idempotent: boolean? } | (player: Player, ...any) -> ...any), handler: ((player: Player, ...any) -> ...any)?)
Registers a named command clients can call via Client.Request. Sender identity
is always the real player; args are shape-validated and rate-limited, and the
handler only runs once the caller is Ready.
Data.Command("EquipItem", { Args = { "string" } }, function(player, itemId)
if not Data[player].Inventory[itemId].Get() then return false, "not owned" end
Data[player].Equipped.Set(itemId)
return true
end)
Idempotent = true makes the command require a caller-supplied key, sent with
Client.RequestOnce. The handler then runs at most once per key, and every
repeat, including one arriving while the first is still yielding, is answered
with the original reply. The requirement is symmetric: a key sent to a command
without Idempotent, or a plain Client.Request to one with it, is refused
with "bad-idempotency-key".
Commands & Requests covers Args and every refusal reason.
Leaderboards¶
.GetLeaderboard¶
Types: LeaderboardEntry
The cached top-N of an all-time board ({ Rank, UserId, Name, Score }).
Never triggers a live OrderedDataStore read.
.GetMyRank¶
The player's current rank on a board, or nil if unranked.
Monetization¶
.PromptGift¶
Prompts buyer to gift a product's perk to recipientUserId. Records a
durable intent before money moves; delivery survives cross-server hops
and offline recipients, and a held gift credit is consumed with no charge.
Returns (false, reason) if the gift can't proceed.
.GetGiftCredits¶
The player's unassigned gift credits by product name.
.HandleReceipt¶
Processes a developer-product receipt. Scribe binds ProcessReceipt
automatically unless OwnReceipts = false, in which case call this from
your own handler. Idempotent by PurchaseId and fail-closed. Robux are
never eaten.
.TryHandleReceipt¶
Like Server.HandleReceipt, but returns nil for a product Scribe does
not know, so your own ProcessReceipt can fall through to its own
handling. Use this one when you route every receipt through Scribe:
MarketplaceService.ProcessReceipt = function(receiptInfo)
local decision = Data.TryHandleReceipt(receiptInfo)
if decision then
return decision -- a Scribe product; already granted and saved
end
return myOwnHandler(receiptInfo)
end
HandleReceipt answers NotProcessedYet for an unknown product instead, which
is correct when Scribe owns ProcessReceipt but would stall your product in
a permanent retry loop when routed through an external handler. This variant
also logs nothing for an unknown product, since there it is normal.
.Owns¶
The unified ownership check: a granted perk or an owned game pass (cached) or RobloxPlus. Hide "buy" buttons whenever this is true.
Non-yielding, so it reads the gamepass cache that fills asynchronously right
after a player joins. For a gate that must be correct at that instant, use
Server.OwnsAsync.
.OwnsAsync¶
Like Server.Owns but authoritative and yielding: if the pass is not
already owned in the cache, it verifies live with the server-side
UserOwnsGamePassAsync on every call, so a pass bought moments ago (in
experience or on the Roblox website) is reflected immediately. Once owned,
the cached value is returned without a re-check. This is the ownership check
to gate grants on. The client's Owns/OwnsAsync are only mirror reads and
must never gate a grant. (timeout applies only to the client version, which
waits on a replicated flag.)
.ObserveOwned¶
Calls callback with the player's current ownership of key, then again
whenever it changes. Returns a disconnect function. The server twin of
Client.ObserveOwned. To react to any key without subscribing per key, use
Server.OnOwnershipChanged.
.GrantPerk¶
Grants a perk directly (admin/reward flows).
.RevokePerk¶
Revokes a granted perk.
.Purchase¶
Types: PurchaseSpec
Atomic soft-currency purchase: debits Cost, runs Grant, and logs it. All or
nothing: insufficient funds or a throwing Grant rolls everything back, no debit.
Data.Purchase(player, {
Cost = { Path = "Coins", Amount = 45000 },
Category = "Vehicle", ItemId = "Police01",
Grant = function(data) data.Vehicles.Insert("Police01") end,
})
Cost.Path may point into a typed container, where the element's Min floor
and int rule apply to the debit. An unresolvable path returns
(false, "invalid cost path"), but a Scribe.DictOf accepts any key, so a typo
there spends the element default as a balance nobody granted.
An optional IdempotencyKey (up to 64 bytes, valid UTF-8) makes the purchase a
once-guard: a repeat under that key answers what the first call answered and
spends nothing. Monetization covers claims and reasons.
.RecordPurchase¶
Appends an entry to the player's in-game purchase log (your own records).
.GetPurchases¶
Types: PurchaseFilter
The player's purchase history (Robux receipts and in-game purchases), newest first, optionally filtered.
PurchaseFilter is { Kind: ("Robux" | "InGame")?, Category: string?,
ItemId: string?, Since: number?, Limit: number? }, exported as
Scribe.PurchaseFilter.
.OnOwnershipChanged¶
Fires (player, key, owned) whenever a player's effective ownership of a
pass, perk, or RobloxPlus actually changes, covering purchases, grants, gift
deliveries, and revokes. Pre-existing ownership at join is the baseline and
does not fire. Use this to react to any grant without subscribing per key;
use Server.ObserveOwned for one specific key.
Ownership only ever gains within a session, so a game pass refund is not
detected until the player rejoins (a cached true is never re-verified).
The value is a Signal, where Connect, Once and Wait are documented.
Cooldowns¶
.OnCooldown¶
Server.OnCooldown(player: Player, key: string, seconds: number, opts: { IncludeOfflineTime: boolean? }?) → (onCooldown: boolean, remaining: number)
Checks and arms a keyed cooldown: returns false and starts a new
seconds cooldown if it was off. Call it only at the grant or claim moment,
and use Server.PeekCooldown for display checks.
Pass { IncludeOfflineTime = false } for a cooldown that only ticks down
while the player is online. The default counts wall-clock time, so a
one-hour cooldown armed before logging off is over on a rejoin the next day;
with the option off, that same rejoin still has the full hour left.
Data.OnCooldown(player, "DailyChest", 86400) -- ticks while offline
Data.OnCooldown(player, "Boost", 3600, { IncludeOfflineTime = false }) -- only while playing
A key holds one cooldown either way: re-arming it in the other mode replaces
the first rather than leaving two running, and Server.ClearCooldown and
Server.PeekCooldown cover both without needing to know which was used.
.PeekCooldown¶
Read-only cooldown check. Never arms it. Safe for UI.
.ClearCooldown¶
Clears a cooldown (support/testing resets).
Signals¶
.OnSave¶
Fires after every save attempt with { Player, Ok, Duration, At }.
The value is a Signal, where Connect, Once and Wait are documented.
.SessionEnded¶
Fires when a session ends with (player, reason). With
KickOnSessionEnd = true (the default) the player is also kicked.
The value is a Signal, where Connect, Once and Wait are documented.
.OnAnomaly¶
Fires for integrity anomalies (out-of-bounds writes, a tripped wipe guard,
an unserializable snapshot) with { Player, Path, Value, Reason }.
The value is a Signal, where Connect, Once and Wait are documented.
.OnGiftReceived¶
Fires when a player receives a gift with (player, { FromUserId, Product, GiftId }).
The value is a Signal, where Connect, Once and Wait are documented.
.OnCooldownEnded¶
Fires with (player, key) when a cooldown lapses, within about a second of
its expiry. A cooldown holds no value, so unlike a Scribe.Timed field it
fires no Changed; this is its only expiry notification.
One signal covers every cooldown, because cooldown keys are arbitrary strings rather than declared fields, so there is no set to subscribe to. Filter inside the handler for a specific one:
Data.OnCooldownEnded:Connect(function(player, key)
if key ~= "DailyReward" then
return
end
-- ...
end)
Cooldowns that lapsed while the player was offline do not fire on join.
Read the current state with Server.PeekCooldown instead.
The value is a Signal, where Connect, Once and Wait are documented.
.OnLeaderboard¶
Fires with (boardName, entries) when a board's contents actually change, once
per refresh cycle. entries is a fresh copy, safe to keep or mutate, and is
rank-ordered: entries[1] is rank 1, the same order and shape
Server.GetLeaderboard returns. An unchanged board does not re-fire, so this is
quiet on an idle board.
This is the server-side counterpart to the client signal of the same name, and
unlike that one it fires for every board, not just Replicate ones. That
matters because a server-only board is the default, and polling cannot align
with the per-board refresh schedule.
See Leaderboards for the connect example.
The value is a Signal, where Connect, Once and Wait are documented.
.OnGiftCredit¶
Fires when a player is issued a gift credit with (player, productName).
The value is a Signal, where Connect, Once and Wait are documented.
Messaging¶
.OnMessage¶
Fires with (player, message) when a cross-server message sent through
Server.SendMessage arrives for an active session.
Delivery is at-least-once, so handlers must be idempotent. A message is
retired from the recipient's key only once a handler has returned without
raising, and only in the same write that persists the data that handler
produced. So a message survives, and is handed to you again on the player's
next load, whenever nothing was connected here (logged as
MESSAGE_NO_LISTENER), a handler raised (MESSAGE_HANDLER_ERROR), or the save
carrying its effect never landed. Payloads carry no id of their own, so put one
in yours (a trade id, a payout id) and ignore a repeat. Seeing a message twice
is recoverable; losing one is not, which is the trade this makes.
Redelivery is bounded rather than continuous: an unacknowledged message is offered once per session, not once per save.
The value is a Signal, where Connect, Once and Wait are documented.
.SendMessage¶
Sends a durable cross-server message to userId, delivered to
Server.OnMessage on whatever server they are (or next become) active on,
including offline recipients (queued until their next load). message must
be DataStore-serializable. Returns whether it was committed. Rides
ProfileStore's global-update channel, so keep messages small and infrequent.
The queue is bounded at 1,000 undelivered messages per recipient, and a
send past the cap returns false and logs
MESSAGE_QUEUE_FULL. It used to return true after silently
dropping the OLDEST queued message. Always check the return value and keep the
message retryable: a recipient's queue fills when messages arrive faster than
they log in to drain them, or when a handler never acknowledges one.
true means committed, not delivered and not handled. Delivery to
Server.OnMessage is at-least-once: a message the recipient's handler never
returned from, raised on, or that had no handler connected at all is kept and
re-offered on their next load. Give the payload an id of your own and make the
handler idempotent.
false does not always mean nothing was written. The store retries
internally, so a write can commit and then lose its answer, after which the
refusal you get back is reported for a message that is already in the
recipient's inbox. The log line and its Context.ProvablyClean say which
happened; the return value cannot. Retrying is still the right response, and
that is what the idempotent payload above is for.
Escape hatch¶
.ProfileStore¶
The underlying ProfileStore instance, an advanced escape hatch for
store-level operations Scribe doesn't wrap (version reads, raw MessageAsync,
and so on). It bypasses Scribe's schema, replication, and session guarantees,
so treat it as read-mostly and never mutate an active-session profile through
it. Most games never need this; prefer the typed API and Server.SendMessage.
.Raw¶
The same Data object without the typed accessor tree: the untyped escape
hatch if the type solver ever trips on your template.