Skip to content

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

serveryields
Server.WaitForData(player: Player, timeout: number?)  (Value?, LifecycleReason?)

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

server
Server.GetState(player: Player)  "Loading" | "Ready" | "SessionEnded"

The player's session state, without yielding.

.Get

server
Server.Get(player: Player)  Value

The player's accessor tree. Errors if the profile is not Ready. Prefer Server.WaitForData on join. Data[player] is shorthand for this.

.Batch

server
Server.Batch(player: Player, fn: () -> ())

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

server
Server.Transaction(player: Player, fn: () -> ())  (boolean, string?)

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

server
Server.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

serveryields
Server.Flush(player: Player, opts: { Force: boolean?, Timeout: number? }?)  boolean

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

server
Server.GetSaveInfo(player: Player)  SaveInfo

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

serveryields
Server.GetOffline(userId: number)  { [string]: any }?

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

serveryields
Server.UpdateOffline(userId: number, fn: (data: { [string]: any }) -> ())  (boolean, string?)

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

serveryields
Server.ListVersions(userId: number, limit: number?)  { { VersionId: string, CreatedAt: number, Size: number? } }

Lists a profile's DataStore version history, for support/rollback tools.

.GetVersion

serveryields
Server.GetVersion(userId: number, versionId: string)  { [string]: any }?

Reads the raw data of a specific historical version.

.RestoreVersion

serveryields
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

serveryields
Server.Erase(userId: number)  (boolean, string?)

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

serveryields
Server.Export(userId: number)  string?

GDPR data export: the profile as a JSON string (buffers base64-encoded), or nil if it doesn't exist.

Commands

.Command

server
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

server
Server.GetLeaderboard(name: string, limit: number?)  { LeaderboardEntry }

Types: LeaderboardEntry

The cached top-N of an all-time board ({ Rank, UserId, Name, Score }). Never triggers a live OrderedDataStore read.

.GetMyRank

server
Server.GetMyRank(player: Player, name: string)  number?

The player's current rank on a board, or nil if unranked.

Monetization

.PromptGift

server
Server.PromptGift(buyer: Player, productName: string, recipientUserId: number)  (boolean, string?)

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

server
Server.GetGiftCredits(player: Player)  { [string]: number }

The player's unassigned gift credits by product name.

.HandleReceipt

server
Server.HandleReceipt(receiptInfo: table)  Enum.ProductPurchaseDecision

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

server
Server.TryHandleReceipt(receiptInfo: table)  Enum.ProductPurchaseDecision?

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

server
Server.Owns(player: Player, key: string)  boolean

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

serveryields
Server.OwnsAsync(player: Player, key: string, timeout: number?)  boolean

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

server
Server.ObserveOwned(player: Player, key: string, callback: (owned: boolean) -> ())  () -> ()

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

server
Server.GrantPerk(player: Player, key: string)

Grants a perk directly (admin/reward flows).

.RevokePerk

server
Server.RevokePerk(player: Player, key: string)

Revokes a granted perk.

.Purchase

server
Server.Purchase(player: Player, spec: PurchaseSpec)  (boolean, string?)

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

server
Server.RecordPurchase(player: Player, entry: table)

Appends an entry to the player's in-game purchase log (your own records).

.GetPurchases

server
Server.GetPurchases(player: Player, filter: PurchaseFilter?)  { table }

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

signalserver
Server.OnOwnershipChanged: Signal

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
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

server
Server.PeekCooldown(player: Player, key: string)  (onCooldown: boolean, remaining: number)

Read-only cooldown check. Never arms it. Safe for UI.

.ClearCooldown

server
Server.ClearCooldown(player: Player, key: string)

Clears a cooldown (support/testing resets).

Signals

.OnSave

signalserver
Server.OnSave: Signal

Fires after every save attempt with { Player, Ok, Duration, At }.

The value is a Signal, where Connect, Once and Wait are documented.

.SessionEnded

signalserver
Server.SessionEnded: Signal

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

signalserver
Server.OnAnomaly: Signal

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

signalserver
Server.OnGiftReceived: Signal

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

signalserver
Server.OnCooldownEnded: Signal

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

signalserver
Server.OnLeaderboard: Signal

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

signalserver
Server.OnGiftCredit: Signal

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

signalserver
Server.OnMessage: Signal

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

serveryields
Server.SendMessage(userId: number, message: any)  boolean

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

propertyserver
Server.ProfileStore: 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

propertyserver
Server.Raw: Server

The same Data object without the typed accessor tree: the untyped escape hatch if the type solver ever trips on your template.