Skip to content

Scribe

The module you require and call. Scribe(options) compiles your template and returns a { Server, Client } bundle. Use .Server on the server and .Client on the client from the same shared module. Every option the table accepts is documented in Configuration.

In edit mode (RunService:IsRunning() false, as in a UI Labs / Hoarcekat storybook or the command bar) there is no server to build, so the bundle holds the edit-mode client half and .Server errors if touched.

Scribe also exposes the field declarators (Scribe.Int, Scribe.Vector3, …) you use inside a template, the visibility wrappers (Scribe.ServerOnly, Scribe.Shared, Scribe.Session), and library-wide diagnostics.

local Scribe = require(ReplicatedStorage.Packages.Scribe)
return Scribe({
    Template = { Coins = Scribe.Int(0, { Min = 0 }) },
    ProfileStoreIndex = "PlayerData",
    ProfileKeyPrefix = "PLAYER_",
})

Setup

.Version

property
Scribe.Version: string

The library version (e.g. "1.0.0").

.new

Scribe.new(options: ScribeOptions)  Bundle

Types: ScribeOptions, Bundle

Compiles the template and builds the { Server, Client } bundle. Calling the module directly (Scribe(options)) is sugar for this. Require the same shared module on both the server and the client and use the matching half.

options is a ScribeOptions table. Only three fields are required; the rest cover persistence, monetization, gifting, behaviour limits and diagnostics. Configuration documents every one of them, and The three you must set is where to start on a first bundle.

Visibility

.ServerOnly

Scribe.ServerOnly(value: T)  ServerOnly<T>

Types: ServerOnly

Marks a root field server-only: it persists and is readable on the server, but is never replicated to any client. May also wrap a nested field to keep just that subtree server-side.

The field is absent from the client's accessor type, so reading it there is a type error rather than a nil at runtime. On the server it reads like any other field.

Combines with Scribe.Session for runtime state only the server sees: Scribe.ServerOnly(Scribe.Session(v)). Combining it with Scribe.Shared is a startup error, since a field has one replication target, not two.

.Shared

Scribe.Shared(value: T)  T

Marks a root field shared: it replicates to every client (not just the owner), for public info like a display name or team.

Combines with Scribe.Session for runtime state everyone sees and nothing saves: Scribe.Shared(Scribe.Session(v)). Combining it with Scribe.ServerOnly is a startup error.

.Session

Scribe.Session(value: T)  T

Marks a root field session-only: never persisted, and replicated to the owner unless another wrapper says otherwise. Ideal for runtime state like InCombat that should reset on rejoin.

Saving and replication are independent, so this composes with exactly one of Scribe.ServerOnly (nobody sees it) or Scribe.Shared (everyone does), in either order. Doubling it up is a startup error.

Field Types

.Int

Scribe.Int(default: number, meta: { Min: number?, Max: number? }?)  number

Declares an integer field. Non-integer writes round (or reject under BoundsPolicy = "Reject"); out-of-range writes clamp. Bounded ints also pack to a smaller wire width.

.Number

Scribe.Number(default: number, meta: FloatMeta?)  number

Types: FloatMeta

Declares a floating-point field with optional bounds and an optional narrowed wire form.

Min and Max must be finite, since math.huge and NaN would each declare a bound that silently checks nothing. For a bound past the double range use Scribe.Big, whose bounds may be written as numeric strings.

Precision is opt-in and narrows only the replicated copy; the server keeps and persists the full double either way. A fixed-point step requires both Min and Max, and bounds too wide for the width you ask for refuse to compile.

Health = Scribe.Number(100, { Min = 0, Max = 100, Precision = 0.5 }), -- 1 byte

See Narrowing a float for the byte table.

.Big

Scribe.Big(default: (number | string)?, meta: BigMeta?)  BigValue

Types: BigMeta, BigValue

A number with unlimited range, for an idle or simulator currency that runs past 2^53. It carries about 15 significant digits at any magnitude, so 1e20 + 1 == 1e20; use Scribe.Int when every digit has to be exact.

Essence = Scribe.Big(0, { Min = 0, Max = "1e600" }), -- a bound past 1.8e308 must be a string
data.Essence.Get():Short() --> "1.95Dc"

Set and every arithmetic method take a plain number, a numeric string, or another big value, and Value.Multiply and Value.Divide exist only here. Get() returns a BigValue, not a plain number, and it will not compare against one: Get() < 2000 throws and Get() == 5 is silently false.

A big field may be a leaderboard stat, which requires non-negative values and refuses Scale. See Big Numbers and Leaderboards.

.String

Scribe.String(default: string, meta: { MaxLength: number? }?)  string

Declares a string field, optionally length-capped.

MaxLength is a byte budget and must be a non-negative integer; a template declaring otherwise fails to compile. Over-long values are truncated on a character boundary (never mid-character, which would produce invalid UTF-8 and fail the profile's next save), or refused outright under BoundsPolicy = "Reject".

.Enum

Scribe.Enum(default: string, members: { string })  string

Declares a string field restricted to a fixed set of members. Writes outside the set are rejected, and the value packs to a single byte.

.Flags

Scribe.Flags(members: { string })  Flags

Types: Flags

A fixed set of named booleans stored as one field, up to 32 members, addressed by name:

Settings = Scribe.Flags({ "Music", "Sfx", "TutorialDone" }),
data.Settings.Enable("Music")
data.Settings.Has("Music") --> true

The accessors are Value.Enable, Value.Disable, Value.Toggle, Value.Has, Value.Count and Value.Clear. Members are not accessor children, so it is Settings.Enable("Music") and not Settings.Music.Set(true). Enabling an undeclared name is an error, and setting a flag to what it holds writes nothing.

The stored value is the enabled member names, so reordering or removing members later is safe, and more than 32 is refused when the template compiles. See Named booleans.

.Timed

Scribe.Timed(default: T)  T

Declares a field with an expiry. Set it with field.SetTimed(value, seconds); it auto-clears back to default when the timer lapses (firing Changed). Great for boosters and temporary buffs.

.Dynamic

Scribe.Dynamic(factory: () -> T)  T

Declares a field whose default is produced by factory per profile rather than frozen once at server start. The field types as the factory's return type, and a datatype result is packed for you.

CreatedUnix = Scribe.Dynamic(os.time),
JoinedAt    = Scribe.Dynamic(function() return DateTime.now() end),

The factory runs only to seed a brand-new profile, or an existing profile that gains the field after you add it, so a returning player keeps a stored value.

It must be pure, because Scribe also calls it once while your template module loads to sample its return type, so anything that yields, errors or reserves an id fires that effect with no profile attached. A factory takes no arguments, so player-specific defaults belong in OnPlayerInit, and Scribe.Dynamic inside a Scribe.Session root is a startup error.

.Derived

Scribe.Derived(output: T, inputs: { string }, compute: (...any) -> T)  T

Declares a field Scribe computes from other declared fields instead of accepting writes. It is never persisted, never migrated, recomputed whenever an input changes, and replicated only when the receiver cannot compute it itself.

output is an ordinary declarator supplying the type and bounds. inputs are dotted paths to statically declared scalar fields, passed to compute in the order written; one derived field may read another, and the compiler orders the graph and rejects cycles at startup. compute must be pure, because Scribe calls it once per realm at startup to seed the default.

Reads are ordinary (Get, Changed, Observe) and every mutator throws. The field must be a root field or live inside a Scribe.Session root, and wrapping it in Scribe.Session, Scribe.Timed, Scribe.Dynamic, Scribe.Optional or a container is refused when the template compiles. See Derived Fields.

.Optional

Scribe.Optional(inner: T)  T?

Declares a field that may legitimately be absent, wrapping another declarator for its type and bounds:

Pets = Scribe.DictOf({
    Species  = Scribe.String("", { MaxLength = 32 }),
    Nickname = Scribe.Optional(Scribe.String("", { MaxLength = 20 })),
}),

The inner declarator's default is dropped: an optional field has no default, so it is not seeded into a new profile and not filled in when an element write omits it. Get() returns nil until something writes it, and Set(nil) takes it back to absent.

It wraps leaves only. Scribe.Timed, Scribe.Dynamic and the container declarators are all refused inside it, since each of the three exists to supply a value an optional field is defined not to have.

.ArrayOf

Scribe.ArrayOf(shape: T, opts: ArrayOpts?)  { T }

Types: ArrayOpts

Declares an array whose elements have a schema, so type checking, bounds and datatype packing all apply per element. shape may be a record or a single leaf declarator, and it nests freely.

PlacedFurniture = Scribe.ArrayOf({
    Cf = Scribe.CFrame(CFrame.new()), ItemId = Scribe.String("", { MaxLength = 64 }),
}, { MaxItems = 200, Evict = "Front" }),

opts takes MaxItems and Evict and nothing else. MaxItems refuses growth past the cap but leaves an already-oversized container writable, so a rolling deploy is safe. Evict requires MaxItems and names the end an Insert at the cap drops from, "Front" or "Back".

Element records are closed at every depth, and Value.Find, Value.Has and Value.RemoveValue compare by value. See Containers.

.SetOf

Scribe.SetOf(element: T, opts: SetOpts?)  SetOf<T>

Types: SetOpts, SetOf

A collection of unique entries: membership, not order. Use it where you would otherwise keep a dictionary of true values, such as owned items.

Unlocked = Scribe.SetOf(Scribe.String("", { MaxLength = 32 })),
data.Unlocked.Add("AshfallRidge")    --> true
data.Unlocked.Remove("AshfallRidge") --> true

The accessors are Value.Add, Value.Remove, Value.Has, Value.Find, Value.Count and Value.Clear. Remove takes the member itself and returns whether it was one. An Add of a value already present, or a Remove of one that is absent, writes nothing and fires no Changed.

Entries are stored deduplicated and sorted, so element must be a scalar and a record shape is refused by the declarator. MaxItems caps membership and is the only option. See Sets of unique values.

.MapOf

Scribe.MapOf(keyType: MapKeyType, value: V, opts: DictOpts?)  MapOf<K, V>

Types: MapKeyType, DictOpts, MapOf

A dictionary whose keys have a declared type, for keys that are user ids or anything else you would otherwise tostring at every write and tonumber at every read. Scribe.DictOf is the string-keyed shorthand.

Friends = Scribe.MapOf("integer", { Name = Scribe.String("") }),
data.Friends[ava.UserId].Name.Set("Ava") -- no tostring

keyType is "integer" or "string", and anything else errors at declaration. Integer keys come back as numbers even though a DataStore serializes every object key to a string, and fractional, infinite and NaN keys are refused at the write.

opts takes MaxKeys and MaxKeyLength and nothing else, and MaxKeyLength with keyType = "integer" is an error rather than a cap that could never fire. See Maps with typed keys.

.DictOf

Scribe.DictOf(shape: V, opts: { MaxKeys: number?, MaxKeyLength: number? }?)  { [string]: V }

Declares a string-keyed dictionary whose values have a schema, the dictionary counterpart of Scribe.ArrayOf:

Inventory = Scribe.DictOf({ Count = Scribe.Int(1, { Min = 1, Max = 999 }) }, { MaxKeys = 200 }),
data.Inventory.Emberblade.Count.Get()        -- nil: nothing has written this key
data.Inventory.Emberblade.Count.Increment(5) -- 6: starts from the declared default

A key exists only once something writes it; until then Get() is nil and Count() does not include it. Any string is a valid key, so a typo is a new key rather than an error. Use plain declared fields when the key set is fixed.

MaxKeys caps the key count and MaxKeyLength caps each key's byte length. Both are write errors, and MaxKeyLength rejects rather than truncates. The element rules match Scribe.ArrayOf; see Containers.

.Vector3

Scribe.Vector3(default: Vector3)  Vector3

Declares a Roblox datatype field. The value is stored and replicated as a compact packed buffer; Get/Set convert at the boundary, so your code only ever sees the real Vector3.

.Vector2

Scribe.Vector2(default: Vector2)  Vector2

Declares a Roblox datatype field. The value is stored and replicated as a compact packed buffer; Get/Set convert at the boundary, so your code only ever sees the real Vector2.

.Vector3int16

Scribe.Vector3int16(default: Vector3int16)  Vector3int16

Declares a Roblox datatype field. The value is stored and replicated as a compact packed buffer; Get/Set convert at the boundary, so your code only ever sees the real Vector3int16.

.Vector2int16

Scribe.Vector2int16(default: Vector2int16)  Vector2int16

Declares a Roblox datatype field. The value is stored and replicated as a compact packed buffer; Get/Set convert at the boundary, so your code only ever sees the real Vector2int16.

.Color3

Scribe.Color3(default: Color3)  Color3

Declares a Roblox datatype field. The value is stored and replicated as a compact packed buffer; Get/Set convert at the boundary, so your code only ever sees the real Color3.

.BrickColor

Scribe.BrickColor(default: BrickColor)  BrickColor

Declares a Roblox datatype field. The value is stored and replicated as a compact packed buffer; Get/Set convert at the boundary, so your code only ever sees the real BrickColor.

.UDim

Scribe.UDim(default: UDim)  UDim

Declares a Roblox datatype field. The value is stored and replicated as a compact packed buffer; Get/Set convert at the boundary, so your code only ever sees the real UDim.

.UDim2

Scribe.UDim2(default: UDim2)  UDim2

Declares a Roblox datatype field. The value is stored and replicated as a compact packed buffer; Get/Set convert at the boundary, so your code only ever sees the real UDim2.

.Rect

Scribe.Rect(default: Rect)  Rect

Declares a Roblox datatype field. The value is stored and replicated as a compact packed buffer; Get/Set convert at the boundary, so your code only ever sees the real Rect.

.NumberRange

Scribe.NumberRange(default: NumberRange)  NumberRange

Declares a Roblox datatype field. The value is stored and replicated as a compact packed buffer; Get/Set convert at the boundary, so your code only ever sees the real NumberRange.

.NumberSequence

Scribe.NumberSequence(default: NumberSequence)  NumberSequence

Declares a Roblox datatype field. The value is stored and replicated as a compact packed buffer; Get/Set convert at the boundary, so your code only ever sees the real NumberSequence.

.ColorSequence

Scribe.ColorSequence(default: ColorSequence)  ColorSequence

Declares a Roblox datatype field. The value is stored and replicated as a compact packed buffer; Get/Set convert at the boundary, so your code only ever sees the real ColorSequence.

.DateTime

Scribe.DateTime(default: DateTime)  DateTime

Declares a Roblox datatype field. The value is stored and replicated as a compact packed buffer; Get/Set convert at the boundary, so your code only ever sees the real DateTime.

.EnumItem

Scribe.EnumItem(default: EnumItem)  EnumItem

Declares a Roblox datatype field. The value is stored and replicated as a compact packed buffer; Get/Set convert at the boundary, so your code only ever sees the real EnumItem.

.Font

Scribe.Font(default: Font)  Font

Declares a Roblox datatype field. The value is stored and replicated as a compact packed buffer; Get/Set convert at the boundary, so your code only ever sees the real Font.

.PhysicalProperties

Scribe.PhysicalProperties(default: PhysicalProperties)  PhysicalProperties

Declares a Roblox datatype field. The value is stored and replicated as a compact packed buffer; Get/Set convert at the boundary, so your code only ever sees the real PhysicalProperties.

.CFrame

Scribe.CFrame(default: CFrame, meta: CFrameMeta?)  CFrame

Types: CFrameMeta

Declares a CFrame field. An axis-aligned rotation, the common case for placed structures, packs to 13 bytes; any other rotation packs as a quaternion in 29 and reads back turned by roughly 1e-7.

Precision = "exact" is the only option and it is off by default. It stores all twelve components as f32 at a flat 49 bytes, so the value round-trips bit for bit. Reach for it when the orientation is data you compare, accumulate or replay. "exact" is its only accepted value, and Precision on any other datatype declarator is refused when the template compiles.

Camera = Scribe.CFrame(CFrame.identity, { Precision = "exact" }), -- 49 bytes, lossless

See Full-precision CFrames for the byte table and for the axis snapping the 13-byte path does.

.Datatypes

property
Scribe.Datatypes: table

Low-level helpers for the 17 Roblox datatypes, exposed so a migration can convert a legacy representation into the packed form.

Call Returns
Pack(name, value, precision?) the packed buffer
Unpack(name, b) the datatype
IsSupported(name) whether Scribe has a codec for name
PrecisionValues(name) the Precision values name accepts, sorted, or nil

Pack and Unpack both throw Scribe: unsupported datatype "<name>" on a name with no codec, so ask IsSupported first when the name is not a literal.

precision is optional and additive: omit it and every datatype packs exactly the bytes it always has. PrecisionValues reports what a datatype accepts, and today only Scribe.CFrame accepts anything ("exact"); any other value throws naming the ones it does. Unpack takes none, because every layout is self-describing from its first byte, which is what lets old data keep decoding.

Lifecycle

.Reason

property
Scribe.Reason: { [string]: LifecycleReason }

Types: LifecycleReason

Every value Server.WaitForData and the SessionEnded signal can report, as a frozen table, so you can branch on a constant instead of a hand-typed string:

local data, reason = Data.WaitForData(player)
if not data and reason ~= Scribe.Reason.PlayerLeft then
    warn("data unavailable:", reason)   -- PlayerLeft is routine, not an error
end

The seven members are LoadFailed, MigrationFailed, SessionEnded, PlayerLeft, Shutdown, StillLoading and Timeout. Each one's string, what it means and whether it is worth retrying are in the Session Lifecycle guide.

The matching Scribe.LifecycleReason type is the same set as a string union, so a comparison against a typo fails to type-check.

Configuration

.Configure

Scribe.Configure(config: { AutoSaveInterval: number? })

Sets configuration that belongs to the process rather than to one bundle. Call it once, before constructing any bundle:

Scribe.Configure({ AutoSaveInterval = 60 })

AutoSaveInterval is the autosave cadence, and it lives here because ProfileStore's AUTO_SAVE_PERIOD is a module-wide constant that also covers any direct ProfileStore use in the same game. The per-bundle SaveInterval option still wins where it is set, and two bundles asking for different cadences logs SAVE_INTERVAL_CONFLICT. Values below 15 seconds clamp up to it.

Calling this twice with different values, or after a bundle exists, is an error, because the value may already have been applied.

Commands

.RequestReason

property
Scribe.RequestReason: { [string]: RequestReason }

Types: RequestReason

The reasons Scribe answers a Client.Request with, as named constants.

The eleven members are RateLimited, NotReady, UnknownCommand, BadArgs, BadIdempotencyKey, Error, ReplyEncodeFailed, Timeout, SendFailed, EditMode and MockError. Each one's string and what provokes it are in the Commands and Requests guide.

A command handler may return any (false, string) of its own; those are deliberately not members, so a reason that is not in this table came from your handler rather than from Scribe.

.RequestFailed

property
Scribe.RequestFailed: table

The marker Client.Request returns as a third value whenever the refusal is Scribe's rather than your handler's.

RequestReason tells you what a reason string means, but not who said it: a handler may return any (false, string) it likes, including false, "timeout", and those values reach the caller verbatim. Compare against this frozen table instead; handler values cross the wire, which carries no reference identity, so nothing a handler returns can ever equal it.

local ok, reason, failed = Data.Request("EquipItem", itemId)
if failed == Scribe.RequestFailed then
    retryLater(reason) -- Scribe refused; `reason` is a RequestReason
elseif not ok then
    showToast(reason)  -- your handler's own message, safe to show
end

It is present on every framework refusal, including the four the client raises with no reply frame at all, and absent on success and on handler values.

Monetization

.PurchaseReason

property
Scribe.PurchaseReason: { [string]: PurchaseReason }

Types: PurchaseReason

The fixed refusals of Server.Purchase. A Grant that throws passes its own error text through instead, so a non-member reason is a failed grant.

The six members are DataNotLoaded, InvalidCostSpec, InvalidCostAmount, InvalidCostPath, CostPathNotSpendable and InsufficientFunds. Each one's string is in the Monetization guide.

.GiftReason

property
Scribe.GiftReason: { [string]: GiftReason }

Types: GiftReason

The fixed refusals of Server.PromptGift. Two further refusals are interpolated with the offending value and so cannot be constants: an unknown product name, and a failed purchase prompt.

The twelve members are BuyerDataNotLoaded, InvalidRecipient, CannotGiftYourself, GiftCooldown, TooManyPending, DataServicesDown, RecipientAlreadyOwns, CreditReserveFailed, DeliveryFailed, DeliveryUnconfirmed, AlreadyPending and IntentRecordFailed. Each one's string is in the Gifting guide.

Diagnostics

.GetStatus

Scribe.GetStatus()  "Healthy" | "Degraded" | "Outage"

The current data-service health, derived from ProfileStore's error signals with hysteresis. Also broadcast to clients via Client:GetServiceStatus.

.OnStatusChanged

signal
Scribe.OnStatusChanged: Signal

Fires with the new status ("Healthy" | "Degraded" | "Outage") whenever service health changes.

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

.OnIssue

signal
Scribe.OnIssue: Signal

Fires for every Error/Fatal log entry: the single hook to wire up developer alerting (Discord webhook, PagerDuty, …) from your own game code.

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

.AddLogSink

Scribe.AddLogSink(fn: (entry: LogEntry) -> ())  () -> ()

Types: LogEntry

Registers an extra log sink. Scribe never sends logs anywhere itself. Add a sink to forward them to your own pipeline. Keep secrets in game code.

Returns a function that removes the sink again. Calling it twice is a no-op rather than removing whoever took that slot since. Keep it if your sink has a lifetime (a hot-reloaded module, a bundle you Stop()), because the sink list is a module singleton that nothing else clears, and every entry spawns a thread per registered sink, on the busiest failure path there is.

.GetRecentLogs

Scribe.GetRecentLogs(filter: { Level: LogLevel?, Category: LogCategory?, Code: LogCode?, Limit: number? }?)  { LogEntry }

Types: LogLevel, LogCategory, LogCode, LogEntry

Returns entries from the ring buffer in chronological order, so the newest is the LAST element. The ring holds 512 entries unless the LogRingSize option raises it. Limit keeps the most recent N. Optionally filtered by level/category/code. Code, Level, and Category are typed string unions, so editors autocomplete valid values (see the Log Code Reference for the full list of codes).

.GetMetrics

Scribe.GetMetrics()  { [string]: number | { Count: number, Average: number, Max: number } }

A snapshot of internal counters (saves, loads, receipt outcomes, queue depths, …) for admin panels and load tests. Plain counters are numbers; timing and size distributions are { Count, Average, Max } records.

.GetPercentiles

Scribe.GetPercentiles()  { [string]: { P50: number, P90: number, P99: number } }

Nearest-rank percentiles for every distribution GetMetrics reports as a { Count, Average, Max } record: save durations, profile sizes, per-frame flush costs, bytes per send.

This is the half GetMetrics cannot give you. An average hides the tail, and the tail is the interesting part of every one of these: a P99 save duration is what a player actually waits for on a bad key, and a mean over a few thousand fast saves will not show it moving.

Keyed by metric name. A name that has never been observed is absent rather than present with zeroes, so next() on the result tells you whether anything has been measured at all.

Computed over a rolling window of the most recent 256 samples per name, not over all time. The window is bounded on purpose, because an unbounded sample list on a metric observed once per save is a slow leak. So these describe recent behaviour; GetMetrics().<name>.Count is the all-time count and does not agree with the window, which is intended.

.GetBudgetSnapshot

Scribe.GetBudgetSnapshot()  BudgetSnapshot

Types: BudgetSnapshot

The DataStore request allowance the engine currently reports, by request type.

local budget = Scribe.GetBudgetSnapshot()
if budget.Available and budget.Budgets.UpdateAsync < 10 then
    warn("running low on save allowance")
end

Available is false when the engine could not be asked at all, such as on a headless runner or in Studio with API access off. Treat that as no opinion: it is not the same as an allowance of zero, and code that confuses the two stalls forever in exactly the environments where there is nothing to conserve.

Purely a read. To have Scribe act on it, set BudgetPolicy, which paces the two leaderboard background loops and deliberately touches no save path.