Scribe v1.3.2 - Persistent, typed, auto-replicated player data built on ProfileStore

:card_file_box: 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.

Wally Roblox Model Studio Plugin Documentation

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.


:sparkles: 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 Vector3 or CFrame as a bloated JSON table, or cannot save them at all. Scribe packs the full set of Roblox datatypes, plus raw buffer fields, into compact binary: a Vector3 is 12 bytes, an axis-aligned CFrame 13, an exact-RGB Color3 4. 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.

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


:bullseye: 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.Raw is 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


:high_voltage: 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.


:hammer_and_wrench: 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.


:scroll: 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-ready for every name so commands can’t be enumerated, and new MaxInboundFrameRate caps raw inbound frames per player. Receipt de-duplication evicts by age (PurchaseIdTTL, MaxProcessedPurchaseIds) rather than count, warning PURCHASE_ID_EVICTED when a still-retryable id is dropped. Command Args accept Scribe declarators and nested shapes. New Security guide.

  • v1.3.1 Scribe.ServerOnly(Scribe.Session(v)) resolved to Session and replicated that field to its owner, leaking one wrapped as a secret. Saving and replication are now independent: pair Scribe.Session with Scribe.ServerOnly or Scribe.Shared in 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 leaderboard Stat that can never rank (a non-numeric field, a whole container, or a Scribe.Session field) is now a boot error rather than a board that stays silently empty, so check your board configs. Plus a clearer PROFILE_UNPERSISTABLE for 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-board SigFigs trading exponent range for displayed resolution), and Evict on ArrayOf for a self-trimming history. OnPlayerLeaving runs before the final save, so a playtime tally written there persists, and OnCooldown takes { IncludeOfflineTime = false } for a cooldown that only ticks while the player is online. Batch now delivers the “one Changed pass” it always documented: a container fires once per batch rather than once per write, on both realms, Insert/Remove/Clear included. Upgrade notes: a container Changed takes (new, old) and errors at connect on a third key parameter, so move that logic to the new OnChildChanged(key, new, old). Leaf listeners are unchanged. A Scribe.Big board is server-only, and its entry.Score is 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, and Scribe.Session roots are finally included. Get() is frozen, so a stray Get().Coins = 1 raises instead of writing silently. Scribe.ServerOnly fields left the client type, so reading one there is a build error, not a nil.

  • v1.2.0 Leaderboards gain a per-board RefreshInterval (default 60s, floored at 60) so a board can read less often, and a server-side OnLeaderboard signal 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 OnCooldownEnded signal and a Scribe.PlayerData<T> type for annotating one player’s accessor tree. Sixteen fixes, the notable ones: a whole-container Set/Clear now fires Changed/Observe on the fields beneath it, a receipt Grant that 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 product Grant that yields still works but loses rollback and logs an error; move async work outside it. New opt-in MigrationShadow re-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 and RestoreVersion no longer report success for a save the DataStore silently dropped, which could bank a Robux grant that never persisted.

  • v1.0.12 WaitForData and Flush now take a timeout (60s and 15s by default), thanks to @ryancundiff in #9. ProfileKeyPrefix now accepts "" for games adopting a database whose keys were bare user ids. Fixes RestoreVersion reporting success when the profile had been erased, and Scribe.Configure wrongly refusing to run after a failed Scribe() call.

  • v1.0.11 New Mode option (Live, Mock, NoSave) that replaces the four separate persistence flags, Scribe.Configure for the process-wide autosave interval, and TryHandleReceipt so an external ProcessReceipt router can fall through on products Scribe does not own. Lifecycle failure reasons are now one typed set (Scribe.LifecycleReason), which renames session-end to session-ended and left to player-left. Fixes read-only profile views, which never finished loading.

  • v1.0.10 New Scribe.ArrayOf, Scribe.DictOf, and Scribe.Optional: arrays and dictionaries whose elements have a real schema, so Roblox datatypes finally work inside containers with no manual Pack/Unpack, and elements get typing, bounds, and MaxItems/MaxKeys caps. Upgrade notes: element records are closed, so an undeclared field is a write error; Set(nil) on a middle array index is refused (use Remove); a table can no longer mix array indices and string keys.

  • v1.0.9 New OnOwnershipChanged (server and client) and a server-side ObserveOwned, so you can react the moment a player gains a pass or perk instead of polling. Added the built-in RobloxPlus ownership 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.Transaction callback that yields is refused and rolled back, an invalid leaderboard Scale now 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), OwnsAsync verifies ownership live on the server, OnPlayerInit receives isNewProfile, and GetSaveInfo reports Size.

  • 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, and UpdateOffline, so one bad byte can no longer fail a whole profile’s save opaquely with DataStore error 104. New OwnsAsync (server and client) yields until game pass ownership has actually synced, so an ownership gate is correct the moment a player joins. In Studio, Owns and OwnsAsync also 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.Timed fields work; EraseUser scrubs in-memory leaderboard caches immediately; clearer template-compile errors; and corrected testing docs (use ViewedUserId, not DontSave, 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 with Value.Min, Value.Max, and Value.Default (client and server, even before data loads). Fixed a replication bug where an Observe or Changed (and edit-mode Mock) 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 CustomField slots), 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.


:link: Links

You can find my other project here: Luix, smart UI authoring for VS Code

33 Likes

replicated for free

There are services that want you to pay for replication?!

3 Likes

No, I guess the phrasing could have been improved but by those phrases I meant that its “built-in”. I’ve improved the wording :slight_smile:

Does this have support for listening to insertion/removal from arrays and dicts?

Yes, it does! I have noticed the Documentation is missing the functions for this but I will fix that right now. You have access to .OnKeyAdded, .OnKeyRemoved, .OnInsert, .OnRemove.

1 Like

I remember, there is also a resource here pretty much alike from what “Scribe” is offered. It uses also ProfileStore and Replica combined, it seems yours uses your own replication system. Just saying, you might want to check it and see how it differs on yours.

I believe what you’re talking about is PlayerState. I did use it for inspiration at the beginning but Scribe has evolved to be so much more. It uses the new type solver, has serialization, allows you to just use gifting, economy analytics, etc out of the box and so much more.

Scribe v1.0.2 - Economy Analytics

  • Added configurable economy analytics (.Increment, .Decrement, .Purchase now support CustomField slots for the economy analytics).
  • Added documentation for Economy Analytics
  • Added missing Value listener docs (.OnInsert, .OnRemove, .OnKeyAdded, .OnKeyRemoved)
  • Constructor & type-checker fixes
1 Like

It’s been added on the documentation site :slight_smile:

Really impressive stuff! We’re using this on Eurotunnel, Border Roleplay, and I’m personally a big fan of being able to read data on the client without setting up my own replication system alongside with blocking certain parts of data from being replicated, making replication easy and secure. Also the built in leaderboard and gifting is amazing, hope to feature this soon in my game! Would highly recommend using.

1 Like

Scribe v1.0.4 - Dynamic Upgrade

  • Added Scribe.Dynamic(factory) for per-profile default values like creation timestamps: computed the first time a profile has the field, and never overwriting an existing value. See Dynamic defaults.
  • Added .Min(), .Max(), and .Default() to read a field’s declared bounds and default value, on the client and server and even before data loads.
  • Fixed a replication bug: .Observe / .Changed (and edit-mode .Mock) registered before a player’s data first arrived now fire for nested fields, not just top-level ones.
  • Removed the Signal dependency by using GoodSignal, so Scribe now ships with zero shared-realm dependencies.

The Studio Plugin has also updated to accomodate the new Scribe.Dynamic(factory) in the linter. So I advice you update from Manage Plugins in Studio!

Scribe v1.0.5 - Nested Custom Datatype Hotfix

  • Fixed nested Scribe.Vector3, Scribe.Dynamic, etc from failing the template type checker

Roblox Studio received some new settings! Update it now via Manage Plugins in Studio!

So how do you correctly set this up for replication?
Im getting a warning saying the Client handshake dropped or something

That error is likely because you never initialized the module anywhere on the client. Make sure you have at least one server script/modulescript which runs:

local Data = require(game.ReplicatedStorage.Shared.Data).Server

and one client localscript/modulescript which runs:

local Data = require(game.ReplicatedStorage.Shared.Data).Client

Hope that helps :slight_smile:

I do require it from both client and server

This is what it prints:

Scribe CLIENT_HANDSHAKE_TIMEOUT {
                    [Player] = {
                       [Name] = "rrotss",
                       [UserId] = 266723646
                    },
                    [category] = "Replication",
                    [code] = "CLIENT_HANDSHAKE_TIMEOUT",
                    [message] = "client never sent Hello — check custom transport adapters"
                 }


Is the Shared.Scribe module the actual module or where you define your Scrbie({}) constructor?

This is the Shared Scribe module

local replicatedStorage = game:GetService('ReplicatedStorage')
local runService = game:GetService('RunService')

local packages = replicatedStorage.Packages
local scribe = require(packages.Scribe)

local products: scribe.ProductConfig? = {
	Taps1000 = {
		Id = 00000,
		Category = 'Currency',
		Grant = function(data: data)
			data.Currency.Taps.Increment(1_000)
		end,
	},
	
	GiftVIP = {
		Id = 222,
		Category = 'Gamepass',
		Grants = 'VIP',
	},
}

local passes: scribe.PassConfig? = {
	VIP = {
		Id = 00000,
	}
}

local boards: scribe.LeaderboardConfig? = {
	TopTaps = {
		Stat = 'Currency.Taps',
		Limit = 100,
		Replicate = true,
	},
	TopRebirths = {
		Stat = 'Currency.Rebirths',
		Limit = 100,
		Replicate = true,
	}
}

local template = {
	Currency = {
		Taps = scribe.Int(0, {Min = 0}),
		Rebirths = scribe.Int(0, {Min = 0}),
	},
}

local Scribe = scribe({
	Template = template,

	-- required: name your own DataStore and per-player key prefix
	ProfileStoreIndex = "PlayerData",
	ProfileKeyPrefix  = "PLAYER_",
	
	-- optional: enable mock data for local testing
	UseMock = runService:IsStudio(),
	
	-- optional
	SaveInterval = 300,           -- seconds between autosaves per profile (floored at 15)
	Migrations = { },
	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,
	Perks = { },
	
	Banner = false
})

type data = typeof(Scribe.Server.WaitForData(...))

return Scribe

And both your Server and Client version of DataService are being require() by a normal script/localscript correct (I’m asking since they are modulescripts)?

Yes, the server one is required thru a server script with runcontext of Server, and client one is required thru a script with runcontext of client in replicatedstorage

Where is your script with runcontext client located? (Should be StarterPlayerScripts or smth similar)