Data & Persistence
Complete, copyable recipes for everything that must survive the server: player profiles, backups, settings, cross-server state, and update deploys. Kernel is the booted server kernel unless stated otherwise. Persistence recipes depend on services Studio can’t fully exercise, so most carry a Status note stating exactly what is proven and what needs a published place.
Save player data
Section titled “Save player data”local PlayerData = DataDriver.new({ Name = "PlayerData", Defaults = { Coins = 0, Level = 1, Inventory = {}, Settings = { Music = true } }, SchemaVersion = 1, Validate = function(data) return type(data.Coins) == "number" and data.Coins >= 0 end,})PlayerData:attach(Kernel)attach wires the full lifecycle: load on join into session.Profile, save on leave, autosave every 60s, flush on shutdown. From any service:
Kernel:onSession(function(session) -- session.Profile.Data is persistent; session.Data is per-visit scratch session.Profile.Data.Coins += 10end)Built-in protections: session locks prevent rejoin-dupes, a failed load kicks instead of substituting defaults (defaults overwriting a real record is how data wipes happen), writes are atomic with retries, and unserializable or Validate-failing data refuses to save. For schema changes, add Migrations = { [1] = function(data) ... return data end } and bump SchemaVersion.
Backends: "DataStore" (default), "Memory" (tests/Studio), "Http" for your own database — note the Http backend has never been pointed at a real API and its read-modify-write isn’t atomic like UpdateAsync (the session lock makes collisions rare; an API wanting hard guarantees should add revision checks — see the module header).
For smaller records, add Codec = Serde.schema({...}) and profile data stores as packed buffers — or Codec = "Auto", which infers a typed schema from Defaults (safe wide widths) with extras enabled, so fields added later persist adaptively before they are typed.
Go deeper: DataDriver · Backends · Serde & Base64 · Sessions
Back up player data
Section titled “Back up player data”Config.Backup enables rotated backups with automatic disaster recovery:
local PlayerData = DataDriver.new({ Name = "PlayerData", Defaults = { Coins = 0, Inventory = {} }, Backup = { IntervalSeconds = 86400, -- one backup per key per day (the default) Keep = 3, -- rotate the last 3 dailies per key Mirror = true, -- ALSO copy every save to a "live" slot -- Backend = "Http", BackendConfig = { BaseUrl = ... } -- external DB as the backup target },})- Exactly once, game-wide, no coordination. Backups are written only by the server that holds the profile’s session lock, and the schedule (
BackupAt) is stored inside the record itself — so a hundred servers can’t produce a hundred dailies, and a player hopping servers doesn’t reset the clock. A key that isn’t loaded anywhere doesn’t get re-backed-up because its data isn’t changing — the last backup is still true. - Rotation, not growth.
Keep = 3means each key cycles through 3 slots; storage per player is bounded forever. Mirroradditionally copies every save to aliveslot, so the fallback is never older than one autosave — combine both for “fresh copy + daily history”.- Automatic restore. If a record exists but its data won’t decode or migrate,
load()restores the newest readable backup, flagsprofile.RestoredFromBackup, and publishesKernel.ProfileRestoredon the bus (tell the player, ping your webhook, grant make-goods). Restore never runs when the profile is locked by another server (corrupt ≠ unlocked) and never when the backend is down — loading without a lock is a dupe vector, so those still fail closed. - Ops tooling:
driver:backupNow(profile)forces a copy,driver:listBackups(key)shows slots + ages,driver:peekBackup(key, slot?)reads a backup without locking anything — safe while the player is online elsewhere.
The backup target is any backend: a second DataStore (default, named {Name}_Backups), or "Http" to keep copies entirely off Roblox. Roblox’s own point-in-time versioning (ListVersionsAsync) still works underneath as a third layer, since the driver only ever writes through UpdateAsync.
Go deeper: DataDriver · Backends
Save player settings and keybinds
Section titled “Save player settings and keybinds”-- Server: the schema is the allowlist — clients cannot write anything elselocal Prefs = Settings.attach(Kernel, { Schema = { MusicVolume = { Kind = "number", Min = 0, Max = 1, Default = 0.5 }, ShowHints = { Kind = "boolean", Default = true }, DashKeys = { Kind = "strings", MaxItems = 2, MaxLength = 20, Default = { "Q" } }, },})Prefs:get(session, "MusicVolume") -- server-side read with defaults-- Client: fetch once, set optimistically, react to confirmed changeslocal Net = NetClient.new()local MyPrefs = SettingsClient.new(Net)MyPrefs:fetch()MyPrefs:set("MusicVolume", 0.8)MyPrefs:onChanged(function(key, value) if key == "DashKeys" then Input:rebind("Dash", "Keyboard", toKeyCodes(value)) endend)Writes ride a rate-limited intent through the fail-closed chain: unknown keys reject, numbers clamp into Min/Max, strings cap at MaxLength, and string arrays are rebuilt clean so hidden hash keys never reach storage. Values persist via the DataDriver profile (pre-profile writes merge in, player’s choice wins) and Settings_Sync pushes the authoritative value back so an out-of-range optimistic set self-corrects. Bus: Settings.Changed(session, key, value) — wire it to InputDriver rebind and remaps survive rejoins.
Go deeper: Player settings · InputDriver
Share state across servers
Section titled “Share state across servers”MemoryStore with automatic buffer packing — for round state, global events, live leaderboards:
local GlobalBoss = MemoryDriver.new({ Name = "GlobalBoss", Codec = Serde.schema({ Health = "NumberU32", Phase = "NumberU8" }), DefaultTtlSeconds = 300,})
GlobalBoss:update("current", function(state) state = state or { Health = 1000000, Phase = 1 } state.Health -= damage return state -- return nil to abort the writeend)For leaderboards use Kind = "SortedMap" and getRange().
update is the safe primitive: it read-modify-writes with retries, and returning nil from the transform aborts cleanly — no partial state ever lands. The codec packs values into buffers (base64 fallback where buffers aren’t supported), so cross-server state pays the same wire discipline as everything else.
Go deeper: MemoryDriver · Serde & Base64
Survive game updates gracefully
Section titled “Survive game updates gracefully”SoftShutdown.attach(Kernel)When Roblox shuts the server down for an update, players ride a reserved transit server back into a fresh public server instead of being dumped to the app. Combined with the DataDriver’s shutdown flush: data saves, players come back, the update deploys.
Go deeper: SoftShutdown · DataDriver