One player-data library that types your data end to end, replicates it to clients out of the box, and layers production persistence, monetization, and diagnostics on top of ProfileStore.
Every Roblox game re-solves the same data problems. You save with session locking, migrate old profiles, stream values to clients, gate purchases, and try to keep it all typed so that a single typo does not quietly corrupt a save. I kept rewriting that whole layer for every project, so I built Scribe: one shared module that declares your data shape once and handles the rest.
You write a template, and Scribe gives you a fully typed accessor tree, automatic replication to clients over a pluggable transport, migrations, a wipe guard, leaderboards, a first-class monetization and gifting layer, and production diagnostics. It sits directly on ProfileStore, so a game already using ProfileStore can adopt Scribe in place with no data migration.
One module per game. Server and client come from the same source.
Why Scribe
- Typed end to end. A type-function-generated accessor tree types every read and write. Calls like
data.Coins.Increment(50), nested containers, arrays, and Roblox datatype fields all check at compile time. - Replication out of the box. Schema-compressed batched diffs stream to clients over a pluggable transport. You read player data on the client with the same accessor API, with no RemoteEvents to wire up.
- Serialization built in. Most data systems store a
Vector3orCFrameas a bloated JSON table, or cannot save them at all. Scribe packs the full set of Roblox datatypes, plus rawbufferfields, into compact binary: aVector3is 12 bytes, an axis-alignedCFrame13, an exact-RGBColor34. You keep working with the real datatype, while storage and the wire only ever see the packed form. - Production persistence. Migrations, a wipe guard, version history, and GDPR export and erase all sit on top of ProfileStore session locking. Everything is fail closed, so a half-migrated or half-granted profile can never persist.
- Monetization done right. Products, gamepasses, gifting, and perks live in the config table. Receipts are idempotent and fail closed, so Robux are never eaten.
- Observable. Structured logs with stable codes, a health status machine, per-player save state, and a companion Studio plugin that renders all of it live.
Games using Scribe
Scribe already runs in production, powering live experiences with over 100 million visits combined:
Eurotunnel, Border Roleplay - 40M+ Visits
Project Flight - 60M+ Visits
Features
Typed accessor tree from one template
Click to expand
Declare the shape once. Scribe generates a typed accessor tree, so every path is checked and autocompleted, end to end, on both the server and the client.
local template = {
Coins = 0,
Inventory = {} :: { [string]: { Health: number, Dmg: number } },
Settings = { Music = true, Sfx = true },
}
-- server, fully typed
data.Coins.Increment(50)
data.Settings.Music.Set(false)
print(data.Inventory.Sword_001.Dmg.Get())
The typed API uses the new Luau type solver. Scribe still runs correctly without it, and
Data.Rawis the explicit untyped escape hatch.
Replication with no RemoteEvents
Click to expand
Reads on the client use the same accessor tree as the server. Scribe streams schema-compressed batched diffs over the transport, so you never wire up a RemoteEvent for data.
-- client
local Data = require(ReplicatedStorage.Shared.Data).Client
Data.Coins.Observe(function(coins)
coinsLabel.Text = tostring(coins)
end)
Client writes are local only for optimistic UI. Server operations always win. To change data authoritatively, the client calls a named command (see below).
Commands and Requests (validated client to server)
Click to expand
Client-initiated mutation goes through named, validated, rate-limited commands. The handler only runs once the profile is Ready.
-- server
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)
-- client (yields until the reply or a timeout)
local ok, reason = Data.Request("EquipItem", "Sword_001")
Declarators: one static type, runtime enforcement, compact wire format
Click to expand
A declarator keeps the plain Luau type while adding always-on runtime rules and a smaller wire format.
Money = Scribe.Int(0, { Min = 0 }), -- integer, non-negative
Health = Scribe.Number(100, { Min = 0, Max = 100 }), -- float, clamped
Team = Scribe.Enum("Civilian", { "Civilian", "Police", "MP" }), -- one of a fixed set
Name = Scribe.String("", { MaxLength = 24 }),
XPBooster = Scribe.Timed(false), -- gains SetTimed / ExtendTimed / Active
Unlocked = Scribe.SetOf(Scribe.String("")), -- Add / Has / Remove, by value
Tutorial = Scribe.Flags({ "Move", "Combat", "Trade" }), -- up to 32 named booleans
Friends = Scribe.MapOf("integer", Scribe.String("")), -- integer keys, no tostring
Coins = Scribe.Big(0, { Min = 0 }), -- past 2^53, for an idle currency
Level = Scribe.Derived(Scribe.Int(1), { "Xp" }, levelForXp), -- computed, never written
data.Unlocked.Add("Desert") -- unique membership, stored sorted
data.Tutorial.Enable("Combat") -- one bitmask on the wire
data.Friends[123456].Set("Ada") -- survives the DataStore round trip as an integer
data.Coins.Increment("1e40")
print(data.Coins.Get():Short()) --> "10.00DDc"
print(data.Level.Get()) -- recomputed whenever Xp changes
Writes are clamped or rejected per BoundsPolicy, enum writes must be a member, and MaxLength is enforced. Scribe.Int packs as a varint, Scribe.Enum as a single byte, Scribe.Flags as one bitmask however many members are set, and a Scribe.Big stores a normalized mantissa and exponent, so it holds 15 significant figures at any magnitude.
Roblox datatype fields (opt-in packed buffers)
Click to expand
Declared datatype fields accept and return the real datatype, typed end to end, while persistence and replication only ever see a compact packed buffer.
SpawnPoint = Scribe.Vector3(Vector3.zero),
HouseDoor = Scribe.CFrame(CFrame.identity),
Tint = Scribe.Color3(Color3.fromRGB(255, 120, 0)),
A Vector3 costs 12 bytes, an axis-aligned CFrame 13 bytes (roughly 200 as a JSON table), and an exact-RGB Color3 4 bytes. Supported types include Vector3, Vector2, CFrame, Color3, BrickColor, UDim, UDim2, Rect, NumberRange, NumberSequence, ColorSequence, DateTime, EnumItem, Font, and PhysicalProperties.
Root visibility (ServerOnly, Shared, Session)
Click to expand
Wrap a root to control who sees it and whether it saves.
Template = {
Coins = 0, -- replicated to the owner, saved
Secret = Scribe.ServerOnly({ Flagged = false }), -- saved, never enters any client packet
Public = Scribe.Shared({ DisplayTitle = "" }), -- saved, replicated to everyone
Runtime = Scribe.Session({ InCombat = false }), -- replicated to the owner, never saved
}
Other playersâ Shared roots are readable on the client via Data.GetShared(playerOrUserId) and Data.OnSharedChanged.
Server-owned state (ServerStore)
Click to expand
Some state belongs to the server, not to any player: the wave number, the boss health bar, the intermission clock. ServerStore declares it beside your template.
return Scribe({
Template = template,
ProfileStoreIndex = "PlayerData",
ProfileKeyPrefix = "PLAYER_",
ServerStore = {
Wave = Scribe.Int(1, { Min = 1 }),
BossHp = Scribe.Int(0, { Min = 0 }),
Seed = Scribe.ServerOnly(0), -- server-side only
},
})
-- Server writes it. No player argument, anywhere, on either realm.
Data.ServerStore.Wave.Increment(1)
-- Client reads it like any other field.
Data.ServerStore.Wave.Observe(function(wave)
waveLabel.Text = `Wave {wave}`
end)
One table for the whole server, not one per player. It exists before anyone joins, so the server can write it with the game empty, and a player joining during wave 7 receives 7 in the same snapshot that carries their own profile. Nothing in it is saved: there is no key, no session lock and no migration, and it is gone when the server closes.
It compiles through the same pass as your template, so store fields share one id space and one schema hash with the rest of the bundle. A bundle that declares no ServerStore builds no store tree and does not pay so much as a function call per frame for the feature.
Migrations and adopting existing ProfileStore data
Click to expand
Because Scribe sits directly on ProfileStore, a game already using ProfileStore adopts Scribe in place. Point ProfileStoreIndex and ProfileKeyPrefix at your existing store and keys, and existing profiles load unchanged.
Migrations = {
[2] = function(data) data.Gems = data.Gems or 0 end,
[3] = function(data) data.Inventory = convertLegacyInventory(data.Inventory) end,
},
Migrations are fail closed. If any step throws, nothing is stamped, nothing is saved, and the session ends. A half-migrated profile can never persist. A staged-deploy guard (VersionAheadPolicy) also refuses newer-shaped data on an old server rather than corrupting it.
Migrating from several stores at once
Click to expand
A real migration rarely reads one key. A main store, a sharded copy and three OrderedDataStores is five requests per joining player, and twenty joining at once is a hundred. Roblox throttles that, and the reads start failing.
ImportLegacyData = function(player, userId, migration)
local ok, done = migration.AwaitBudget("GetSortedAsync", 3)
local boards = if ok then readThreeOrderedStores(userId) else {}
done()
local mainOk, mainDone = migration.AwaitBudget("GetAsync")
local main = if mainOk then readMainStore(userId) else nil
mainDone()
return merge(main, boards)
end
AwaitBudget yields until that many DataStore requests are spare. Waiting players are served first come, first served, per request type, so nobody is overtaken by a later joiner and a hook wanting five requests is never passed by one wanting one. It hands back a release to call once the reads have gone out, which passes the allowance straight to the next waiting player; skipping it only means a timer gets there a little later.
Nothing kicks a player for a slow import. The hook runs after the session is already held, so it may take as long as it needs, and Data.GetState returns a second value while they wait, "Importing" or "ImportThrottled", for your loading screen to show. Already on Scribe with stores left to move? OnPlayerInit receives the same context.
Monetization, perks, and gifting
Click to expand
Declare products and passes in the config table. Scribe binds ProcessReceipt for you, and receipts are idempotent and fail closed.
Products = {
Coins1000 = { Id = 111, Category = "Currency", Grant = function(data) data.Coins.Increment(1000) end },
GiftVIP = { Id = 222, Category = "Gamepass", Grants = "VIP" },
},
Passes = { VIP = { Id = 333 } },
Prices are per player, so a hard-coded number on a shop button is wrong for a large share of them: a Roblox Plus subscriber pays 10 to 20% less, and regional pricing can put another player at 30% of your listed price. Ask on the client, by the name you declared.
-- LocalScript
Data.ObserveProductInfo("VIP", function(info)
label.Text = if info then `{info.PriceInRobux} R$` else "..."
end)
PriceInRobux is what this player is charged; UserBasePriceInRobux and PriceDiscountDetails are there for a âwas 100, now 80â button. Client-only by necessity: a live server has no player in context and answers the base catalog price, which is the one number a discounted player never pays.
Roblox has no native âgift a gamepassâ flow, so gifting sells a developer product and grants the recipient a saved perk. Data.PromptGift(buyer, "GiftVIP", recipientUserId) records a durable intent before money moves, and delivery survives cross-server hops and offline recipients. Data.Owns(player, "VIP") is the unified check across perks, real passes, and Roblox Plus, so store UIs never let a gifted player re-buy the pass.
Leaderboards
Click to expand
All-time leaderboards on a paced, deduplicated OrderedDataStore write queue.
Leaderboards = {
TopCoins = { Stat = "Coins", Limit = 100 }, -- server-only by default
TopWins = { Stat = "Wins", Limit = 50, Replicate = true }, -- streamed to clients
TopGems = { Stat = "Gems", SigFigs = 14 }, -- a Scribe.Big stat
},
Read boards on the server with Data.GetLeaderboard and Data.GetMyRank. Set Replicate = true to stream a board to clients, which receive the cached top N at handshake and then only when it changes. A client can never trigger an OrderedDataStore request.
A Scribe.Big stat ranks exactly, packed exponent-major into the key with no logarithm, and its entry.Score comes back as a big. SigFigs (1 to 15, default 12) decides how many significant figures that key carries, trading exponent range for displayed resolution. A big board is server-only, since a big does not fit the board frameâs f64.
Diagnostics and the wipe guard
Click to expand
Structured logs with stable machine-readable codes (PROFILE_LOAD_FAIL, MIGRATION_FAIL, WIPE_GUARD_TRIPPED, and more), a 512-entry ring buffer, and pluggable sinks.
Scribe.GetRecentLogs() -- the ring buffer
Scribe.AddLogSink(function(entry) end) -- your own sink
Scribe.OnIssue:Connect(function(entry) end) -- fires on every Error or Fatal
print(Scribe.GetStatus()) -- "Healthy" | "Degraded" | "Outage"
A wipe guard compares every save against the last good one. If top-level keys vanish or the payload collapses, it trips WIPE_GUARD_TRIPPED and fires OnAnomaly. Under WipeGuardPolicy = "Block", the guard holds the save and persists the last good snapshot instead.
TTL, cooldowns, and analytics
Click to expand
Timed values survive rejoins, cooldowns are wall-clock, and economy events emit automatically.
data.XPBooster.SetTimed(true, 3600) -- value plus seconds, survives rejoins
data.XPBooster.ExtendTimed(1800) -- stack time onto a running timer
local active, remaining = data.XPBooster.Active()
if not Data.OnCooldown(player, "DailyReward", 86400) then grantDailyReward(player) end
local onCooldown, remaining = Data.PeekCooldown(player, "DailyReward") -- read-only, never arms
data.Coins.Increment(100, { Source = "QuestReward" }) -- auto-emits an economy event
Edit mode and storybooks (UI Labs, Hoarcekat)
Click to expand
When RunService:IsRunning() is false, the client module skips the transport and handshake and initializes instantly to template defaults with no yields. Observe and Changed work normally, so Scribe-backed components render and live-update in storybooks.
Data.Mock({ Coins = 5000 }, { Perks = { "VIP" } })
Data.MockCommand("EquipItem", function(itemId) return true end)
Custom transports
Click to expand
The transport just moves opaque buffers. Scribe owns serialization, batching, and RPC correlation, so a custom adapter is a few lines.
local DataPacket = Packet("ScribeData", Packet.Buffer)
return {
Name = "Packet",
SendToClient = function(_, player, bytes) DataPacket:FireClient(player, bytes) end,
ListenServer = function(_, cb) DataPacket.OnServerEvent:Connect(cb) end,
SendToServer = function(_, bytes) DataPacket:Fire(bytes) end,
ListenClient = function(_, cb) DataPacket.OnClientEvent:Connect(cb) end,
} :: Scribe.ScribeTransport
The default transport uses two RemoteEvents under a folder in ReplicatedStorage, so you need none of this to get started.
Scribe Studio (companion plugin)
Click to expand
Scribe Studio renders Scribeâs diagnostics layer as a live, interactive dock. It turns âwhat is my data actually doingâ into something you can watch and drive: inspect every playerâs session, replay the change feed with time travel, simulate outages, profile bandwidth, invoke commands, lint your template, and, with an explicit opt-in, edit live production profiles.
It is read only by default, Studio only by construction, and every plugin write goes through Scribeâs normal server API with full validation and logging. The debug hook ships in the library, so nothing extra is needed on the game side.
Get it here: Scribe Studio on the Creator Store
Quick start
One shared module declares the template and options and returns { Server, Client }:
-- ReplicatedStorage/Shared/Data.luau
local Scribe = require(game:GetService("ReplicatedStorage").Packages.Scribe)
return Scribe({
Template = { Coins = 0, Settings = { Music = true } },
ProfileStoreIndex = "PlayerData", -- required: your DataStore name
ProfileKeyPrefix = "PLAYER_", -- required: per-player key prefix
})
-- Server: wait for the profile to load, then use the typed accessor
local Data = require(game:GetService("ReplicatedStorage").Shared.Data).Server
game:GetService("Players").PlayerAdded:Connect(function(player)
local data = Data.WaitForData(player) -- yields until Ready
if data then
data.Coins.Increment(50)
end
end)
-- Client: read the same data reactively
local Data = require(game:GetService("ReplicatedStorage").Shared.Data).Client
Data.Coins.Observe(function(coins)
coinsLabel.Text = tostring(coins)
end)
Install with Wally:
[dependencies]
Scribe = "ericplane/scribe@2.5.0"
Upgrading from 1.x? ProfileStore now ships inside Scribe, so delete the separate ProfileStore entry from your wally.toml.
Install with npm (roblox-ts):
npm install @rbxts/scribe
import Scribe from "@rbxts/scribe";
The same Luau runtime, with typed templates and APIs for TypeScript. See the roblox-ts guide for setup and examples.
Prefer a Studio model? A drag-and-drop model is attached to each GitHub release and also available on the Roblox Creator Store. The full install guide is in the documentation.
Options worth knowing
return Scribe({
Template = template,
-- required: name your own DataStore and per-player key prefix
ProfileStoreIndex = "PlayerData",
ProfileKeyPrefix = "PLAYER_",
-- optional
Transport = "Default", -- "Default" (two RemoteEvents) or a custom adapter
SaveInterval = 300, -- seconds between autosaves per profile (floored at 15)
Migrations = { [2] = function(data) data.Gems = data.Gems or 0 end },
LoadFailurePolicy = "Kick", -- "Kick" or "Wait" (retry through outages)
VersionAheadPolicy = "Kick", -- fail closed on staged-deploy data
WipeGuardPolicy = "Warn", -- "Warn" or "Block"
BoundsPolicy = "Clamp", -- "Clamp" or "Reject" out-of-range writes
LogLevel = "Info", -- "Debug" through "Fatal"
Products = products, -- monetization, gifting, perks
Passes = passes,
Leaderboards = boards,
})
ProfileStoreIndex and ProfileKeyPrefix are the only required fields. Naming your own store is deliberate, so a test build never shares live storage. The full options reference is in the documentation.
Updates
-
v2.5.0 Current release. roblox-ts support, client purchase prompts and countdowns, a leaderstats add-on, and leaderboard health alerts. Fewer leaderboard requests and fixes for purchases, gifts, exchanges, and
ServerStore. release. -
v2.4.0 Daily and weekly leaderboards:
Period = "Daily"or"Weekly"ranks what a player earned this period, andScope = "Server"ranks the players on this server with no store behind it.Data.Erasemarks the profile first, refuses joins on every server while it runs, and resumes after a failure or shutdown instead of leaving a half-removed profile.ScribeTelemetry, an optional add-on, posts health changes, errors and summaries to Discord; releases now attach it and the UI adapters as separate models. The docs have a new site with an in-browser playground, and two community reports drove a naming pass over the strings the API returns. Upgrade notes: the reserved root gained aBoardschild, which changes the schema hash, so deploy server and client together, and the Studio plugin hooks are off unless you setStudioHook = true. The full changelog is on the release. -
v2.3.0 Paid random items:
PaidRandom = trueon a product is refused on every purchase path for a player whose policy restricts it, and while that policy is unknown.Data.GetProductStateon both realms answers what the prompt would say, withObserveProductStateon the client for a button that repaints. Large templates typecheck much further before Luau reports âCode is too complexâ: the unions that grew with the template are capped, and the templates guide has an escape hatch for anything past that. Upgrade note:PromptPurchasenow refuses with the fixedScribe.ProductStatestrings. The full changelog is on the release.
Older releases (v2.2.0 and earlier)
-
v2.2.0
ServerStore: a second tree owned by the server, never saved, replicated to everyone. Per-player prices on the client, after Roblox Plus and regional discounts.ImportLegacyDatapaces itself on the DataStore request budget. Copy-in UI adapters for Vide, React and Fusion. AScribe.Biginside a container no longer pushes the template past the type solverâs budget. Upgrade note: assigning to an accessor (data.Coins = 5) is now a type error; it only ever shadowed the real accessor. The full changelog is on the release. -
v2.1.1 A types patch.
Data.PromptPurchase,Data.ExchangeandData.Stopwere missing from the typeScribe.Serverhands back, so calling them from a strict-mode script was a type error even though they worked; all three are now declared. Templates can also be roughly three times larger before Luau stops type-checking the file, and the leaderboards guide now covers Robloxâs built-in leaderboard UI. No behaviour, wire or data changes. The full changelog is on the release. -
v2.1.0
Data.Exchange.Attemptmoves value between two players loaded on the same server, with one verdict deciding both sides so nothing is duplicated or destroyed; you opt each path in through the newExchangeableallowlist, and a leg moves a whole container entry, an amount off a balance, or part of a stack.Data.PromptPurchaseprompts a declared product or pass by name and refuses what the player already owns, and a finished gamepass purchase is now confirmed with an ownership check before anything is credited. NewLoadDurationandFlushDurationdistributions, aScribe.FlushMicroProfiler label and aScribememory category say what Scribe costs in a live server. The largest fix:for key, entry in data.Inventory doused to silently empty the container it was reading, and now raises. Upgrade note: the reserved root gained two exchange subkeys, which changes the schema hash both realms compare, so deploy your server and client together even if you declare nothing exchangeable. The full changelog is on the release. -
v2.0.0 The first release with breaking changes.
Scribe.Deriveddeclares a field Scribe computes from other declared fields instead of accepting writes, recomputed on whichever realm can see its inputs and rankable on a leaderboard.Client.RequestOnceand a purchaseIdempotencyKeymake a command or a purchase apply at most once per key, however many times it is retried.Scribe.NumbergainedPrecision, narrowing a replicated field to four, two or one bytes, andScribe.CFramegainedPrecision = "exact"for a bit-for-bit 49 byte pack. The newSchemaPolicychecks stored data against your template as a profile loads, and a frame larger than the outbound budget is now split into fragments and reassembled rather than failing to send.ImportLegacyDataadopts data from another library once, before Scribe has ever saved for that player. Thirty two fixes land in the money, persistence and replication paths: a gift receipt retried more than an hour after purchase was granted to the buyer instead of the recipient, several paths could grant a paid gift credit twice or swallow it entirely, two copies of one receipt arriving together could each run the grant,Data.Flushcould return true for a save the wipe guard had swapped out, a run ofAwaitSavecalls could hang the calling thread for the life of the server, and a replication frame the transport refused was dropped and left that client permanently stale. Upgrade notes: the replication protocol moved from 1 to 6, so deploy your server and client together because a mixed deploy leaves players unable to load; ProfileStore now ships inside Scribe, so remove it from yourwally.toml; game code can no longer write inside the reserved_Scriberoot; a transaction can no longer reach a second playerâs data; a cross-server message is no longer acknowledged when nothing is connected toData.OnMessageor a handler raises, so a handler must tolerate seeing one twice; and a template with a non-finiteMinorMax, or two pass names sharing one gamepassId, now fails at startup. The full changelog is on the release. -
v1.3.2 A hardening pass on the client boundary: command arguments decode only after the rate limit and every other gate, a pre-Ready session answers
not-readyfor every name so commands canât be enumerated, and newMaxInboundFrameRatecaps raw inbound frames per player. Receipt de-duplication evicts by age (PurchaseIdTTL,MaxProcessedPurchaseIds) rather than count, warningPURCHASE_ID_EVICTEDwhen a still-retryable id is dropped. CommandArgsaccept Scribe declarators and nested shapes. New Security guide. -
v1.3.1
Scribe.ServerOnly(Scribe.Session(v))resolved toSessionand replicated that field to its owner, leaking one wrapped as a secret. Saving and replication are now independent: pairScribe.SessionwithScribe.ServerOnlyorScribe.Sharedin either order, while combining those two is a startup error.Mode = "Mock"also never reached leaderboards, so Studio play-tests read and wrote real OrderedDataStores. Upgrade note: a leaderboardStatthat can never rank (a non-numeric field, a whole container, or aScribe.Sessionfield) is now a boot error rather than a board that stays silently empty, so check your board configs. Plus a clearerPROFILE_UNPERSISTABLEfor a raw datatype written around the accessor, a new Commands & Requests guide, and 77 documentation corrections. -
v1.3.0 Five new declarators:
Scribe.SetOf(unique membership),Scribe.MapOf(declared key type, so integer keys survive the DataStore round trip),Scribe.Flags(up to 32 named booleans in one field),Scribe.Big(idle-game numbers past 2^53, rankable on a leaderboard, with a per-boardSigFigstrading exponent range for displayed resolution), andEvictonArrayOffor a self-trimming history.OnPlayerLeavingruns before the final save, so a playtime tally written there persists, andOnCooldowntakes{ IncludeOfflineTime = false }for a cooldown that only ticks while the player is online.Batchnow delivers the âone Changed passâ it always documented: a container fires once per batch rather than once per write, on both realms,Insert/Remove/Clearincluded. Upgrade notes: a containerChangedtakes(new, old)and errors at connect on a thirdkeyparameter, so move that logic to the newOnChildChanged(key, new, old). Leaf listeners are unchanged. AScribe.Bigboard is server-only, and itsentry.Scoreis a big rather than a number. -
v1.2.1 Root whole-data reads (
Get,Clone,Observe,Changed) return exactly your declared roots: no internal_Scribe, andScribe.Sessionroots are finally included.Get()is frozen, so a strayGet().Coins = 1raises instead of writing silently.Scribe.ServerOnlyfields left the client type, so reading one there is a build error, not anil. -
v1.2.0 Leaderboards gain a per-board
RefreshInterval(default 60s, floored at 60) so a board can read less often, and a server-sideOnLeaderboardsignal that fires with(boardName, entries)when a board actually changes, including server-only boards, so you no longer need a polling loop. -
v1.1.0 New
OnCooldownEndedsignal and aScribe.PlayerData<T>type for annotating one playerâs accessor tree. Sixteen fixes, the notable ones: a whole-containerSet/Clearnow firesChanged/Observeon the fields beneath it, a receiptGrantthat throws no longer leaves partial writes that compound on retry,Mode = "NoSave"dry runs actually run your migrations, and returning players whose data is still all defaults are no longer mistaken for new ones and skipped.Value.Remove(i)with an out-of-range index is now a no-op instead of deleting a real element. A productGrantthat yields still works but loses rollback and logs an error; move async work outside it. New opt-inMigrationShadowre-runs your migrations against the raw stored data in Studio and warns when a nil-guarded step silently no-ops because Reconcile already filled the field. Offline writes andRestoreVersionno longer report success for a save the DataStore silently dropped, which could bank a Robux grant that never persisted. -
v1.0.12
WaitForDataandFlushnow take a timeout (60s and 15s by default), thanks to @ryancundiff in #9.ProfileKeyPrefixnow accepts""for games adopting a database whose keys were bare user ids. FixesRestoreVersionreporting success when the profile had been erased, andScribe.Configurewrongly refusing to run after a failedScribe()call. -
v1.0.11 New
Modeoption (Live,Mock,NoSave) that replaces the four separate persistence flags,Scribe.Configurefor the process-wide autosave interval, andTryHandleReceiptso an externalProcessReceiptrouter can fall through on products Scribe does not own. Lifecycle failure reasons are now one typed set (Scribe.LifecycleReason), which renamessession-endtosession-endedandlefttoplayer-left. Fixes read-only profile views, which never finished loading. -
v1.0.10 New
Scribe.ArrayOf,Scribe.DictOf, andScribe.Optional: arrays and dictionaries whose elements have a real schema, so Roblox datatypes finally work inside containers with no manualPack/Unpack, and elements get typing, bounds, andMaxItems/MaxKeyscaps. Upgrade notes: element records are closed, so an undeclared field is a write error;Set(nil)on a middle array index is refused (useRemove); a table can no longer mix array indices and string keys. -
v1.0.9 New
OnOwnershipChanged(server and client) and a server-sideObserveOwned, so you can react the moment a player gains a pass or perk instead of polling. Added the built-inRobloxPlusownership key to the documentation. -
v1.0.8 Test suite increased from 203 to 231. Upgrade notes: a table can no longer hold both a numeric key and its string form, a
Data.Transactioncallback that yields is refused and rolled back, an invalid leaderboardScalenow errors at startup, and a mismatched schema hash fails the handshake closed. Also: leaderboard write throttling no longer drives global health to Outage (which could block Robux receipts),OwnsAsyncverifies ownership live on the server,OnPlayerInitreceivesisNewProfile, andGetSaveInforeportsSize. -
v1.0.7 Invalid UTF-8 and unserializable values such as userdata,
Instances, and functions are now rejected at the write boundary with the exact field path. This covers data written through migrations,OnPlayerInit, andUpdateOffline, so one bad byte can no longer fail a whole profileâs save opaquely with DataStore error 104. NewOwnsAsync(server and client) yields until game pass ownership has actually synced, so an ownership gate is correct the moment a player joins. In Studio,OwnsandOwnsAsyncalso warn on an unregistered key. -
v1.0.6 A deep hardening pass over the whole module (40+ fixes, test suite grown from 124 to 180). Highlights: purchases, gifts, and offline grants now acknowledge only after the data is genuinely saved, so paid value survives crashes, blocked saves, and concurrent grants; transactions roll back completely (timers included) with no phantom containers and no client desync; client mirrors converge in every op-coalescing edge case; every read path returns real datatypes instead of packed buffers; nested
Scribe.Timedfields work;EraseUserscrubs in-memory leaderboard caches immediately; clearer template-compile errors; and corrected testing docs (useViewedUserId, notDontSave, to dry-run against real data). -
v1.0.5 Fixed nested
Scribe.Vector3,Scribe.Dynamic, etc from failing the template type checker -
v1.0.4 New
Scribe.Dynamic(factory)seeds per-profile defaults like creation timestamps: computed the first time a profile has the field, and never overwriting an existing value. Read a fieldâs declared metadata anywhere withValue.Min,Value.Max, andValue.Default(client and server, even before data loads). Fixed a replication bug where anObserveorChanged(and edit-modeMock) registered before a clientâs data first arrived missed the initial value on nested fields. Removed the Signal dependency by using GoodSignal, so Scribe now ships with zero shared-realm dependencies. -
v1.0.2 Configurable economy analytics: declare each currencyâs custom fields once (mapped to Robloxâs three
CustomFieldslots), with a shared or per-currency field resolver, automatic value prefixing, currency-label overrides, and enum transaction types, all fail-closed. Plus the complete Value listener docs (OnInsert,OnRemove,OnKeyAdded,OnKeyRemoved), a new Economy Analytics guide, and constructor and type-checker fixes. -
v1.0.1 Documentation site, a drag-and-drop rbxm bundle, and package metadata polish.
-
v1.0.0 Initial public release: typed accessor tree, schema-compressed replication over a pluggable transport, declarators, Roblox datatype packing, root visibility, fail-closed migrations and wipe guard, monetization with gifting and perks, leaderboards, and diagnostics.
Links
Docs: ericplane.github.io/Scribe
Wally: ericplane/scribe
roblox-ts: @rbxts/scribe on npm
Roblox model (no Wally): latest release
Studio plugin: Scribe Studio
Source and issues: Repository
License: MIT
You can find my other project here: Luix, smart UI authoring for VS Code



