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¶
The library version (e.g. "1.0.0").
.new¶
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¶
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¶
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¶
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¶
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¶
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.
See Narrowing a float for the byte table.
.Big¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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.
See Full-precision CFrames for the byte table and for the axis snapping the 13-byte path does.
.Datatypes¶
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¶
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¶
Sets configuration that belongs to the process rather than to one bundle. Call it once, before constructing any bundle:
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¶
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¶
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¶
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¶
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¶
The current data-service health, derived from ProfileStore's error signals
with hysteresis. Also broadcast to clients via Client:GetServiceStatus.
.OnStatusChanged¶
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¶
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¶
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¶
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¶
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¶
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.