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
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"
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.
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.
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 } },
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@1.3.2"
Not using Wally? 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
-
v1.3.2 Current release. 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.
Older releases (v1.2.1 and earlier)
-
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 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



