Migrating to Scribe¶
Moving an existing game onto Scribe. How you do it depends on where your data lives today.
"Migration" means two different things
This page is about bringing an existing game's data into Scribe. It is not the same as the Migrations option, which evolves your own Scribe template across versions once you're already on Scribe. Don't reach for Migrations to import from another library.
From ProfileStore → adopt in place¶
Scribe sits directly on ProfileStore, so a game already using ProfileStore adopts Scribe in place with no data copy and no conversion. Point Scribe at the same store and your existing profiles load exactly as they are:
- Set
ProfileStoreIndexandProfileKeyPrefixto match your current store name and key prefix. - Existing profiles load unchanged.
Datais the template root, and the ProfileStore envelope (session metadata,UserIds,GlobalUpdates) is untouched. Reconcilefills any newly added template fields; useMigrationsto reshape anything whose structure changed.
return Scribe({
Template = template,
ProfileStoreIndex = "PlayerData", -- your existing store name
ProfileKeyPrefix = "Player_", -- your existing key prefix
})
Your template's field names must match the keys already in the stored data (or a migration bridges the difference). Validate against real data first with ViewedUserId (loads that user's real profile read-only, never writes) before you point a live game at it. Note that DontSave = true is NOT a dry-run against real data: it swaps in a full in-memory mock store, so every profile loads as blank template defaults and validates nothing about your stored shapes.
Coming from ProfileService¶
ProfileStore is ProfileService's successor (same author), and it reads existing ProfileService profiles from the same keys with no conversion, so a ProfileService game adopts Scribe in place exactly like a ProfileStore one. Treat the switch as one-directional, though: once Scribe starts writing global updates through ProfileStore, the old ProfileService module may no longer load those same profiles. Run a Studio test with API access enabled before you ship the change.
From DataStore2, a custom store, or anything else → read and seed¶
Other systems store data in a completely different layout, so there's no "point Scribe at it": you read each player's old data out once and write it into Scribe. There's no built-in importer (Scribe can't guess your old schema), but the hook is built for exactly this: OnPlayerInit runs on load, before the session goes Ready, receives the raw profile data, and is allowed to yield.
Add a one-time guard flag to your template, then import in OnPlayerInit:
local template = {
Coins = 0,
Inventory = {} :: { [string]: any },
LegacyImported = false, -- the guard
}
return Scribe({
Template = template,
ProfileStoreIndex = "PlayerData",
ProfileKeyPrefix = "PLAYER_",
OnPlayerInit = function(player, data)
if data.LegacyImported then
return -- already imported; never run twice
end
local old = MyOldStore:Get(player) -- read the legacy store (may yield, which is fine here)
if old then
data.Coins = old.coins
data.Inventory = convertOldInventory(old.inventory)
end
data.LegacyImported = true
end,
})
OnPlayerInit mutates the raw data before the player can touch it, so the first Ready state already reflects the imported values. The LegacyImported flag makes re-loads safe: the import only ever happens on a player's first Scribe load.
Preserve timestamps yourself instead of relying on the store's metadata
Values like a first-join date belong in a field of your template, not in a store-specific timestamp. ProfileStore's FirstSessionTime is stamped when the profile is first created. For a game moving over, that's the migration moment. It's also read-only, so it can't be backdated (and Scribe doesn't surface it anyway). Owning the field means you carry the real date across from your old store, and it survives any future move:
FirstJoined = 0, -- in your template
OnPlayerInit = function(player, data)
if data.LegacyImported then return end
local old = MyOldStore:Get(player)
-- real date from the old store, or now for genuinely new players
data.FirstJoined = (old and old.firstJoined) or os.time()
-- ...copy the rest...
data.LegacyImported = true
end
Backfilling offline players¶
Most migrations never need this. With the read-and-seed approach above, every player is imported automatically the next time they log in, so you can leave the old store in place and let it drain on its own.
The one time you'd reach for a batch job is retiring the old store for good: before you can stop reading it, you have to import the stragglers who haven't logged in since the cutover. Data.UpdateOffline edits a profile that has no active session and fails closed if the user happens to be online elsewhere, so it can never clobber a live game:
for _, userId in userIdsToImport do
local old = MyOldStore:Get(userId)
if old then
Data.UpdateOffline(userId, function(data)
if not data.LegacyImported then
data.Coins = old.coins
data.LegacyImported = true
end
end)
end
end
Tips¶
- Keep the legacy store readable until you're confident. Don't delete old data the moment you cut over. The guard flag means a re-run is harmless.
- Convert shapes explicitly: copy field by field into your Scribe template rather than assigning the whole old table, so the result matches your declarators.
- Dry-run first with
ViewedUserId(reads a real profile without writing), or against a throwawayProfileStoreIndexseeded with copies, before touching production. (DontSave = trueuses an in-memory mock and never reads real data, so it cannot validate a migration.)