Skip to content

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.

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 += 10
end)

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

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 = 3 means each key cycles through 3 slots; storage per player is bounded forever.
  • Mirror additionally copies every save to a live slot, 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, flags profile.RestoredFromBackup, and publishes Kernel.ProfileRestored on 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

-- Server: the schema is the allowlist — clients cannot write anything else
local 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 changes
local 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)) end
end)

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

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 write
end)

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

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