Skip to content

Data and persistence

Recipes for player profiles, setup after the profile loads, version history and backups, fast rejoins, validated settings, cross-server state and update shutdowns.

Everything that must outlive the server: player profiles, their recovery, settings, cross-server state and update deploys. Server examples are services in src/Server/Game/Services/. Most of these recipes depend on DataStores, MemoryStores, MessagingService or teleports, which Studio cannot fully exercise, so they carry a Status note saying what the specs and regression runners prove and what needs a published place.

Run python tools/scaffold.py data once per game. It creates DataService (a DataDriver plus Receipts), Data/Profile.luau and Data/Products.luau, and puts DataService first in the registry. The profile module is the whole configuration:

src/Server/Game/Data/Profile.luau
--!strict
-- Player profile for DataDriver. Change Defaults, SchemaVersion, Migrations and Validate together.
local RunService = game:GetService("RunService")
return {
Name = "PlayerData",
Defaults = {
Coins = 0,
Level = 1,
LastDaily = 0,
PlaytimeSeconds = 0,
},
SchemaVersion = 2,
Migrations = {
-- Keyed by the version migrated from. Runs inside the DataStore transform: never yield
[1] = function(data)
data.Coins = data.Gold or data.Coins
data.Gold = nil
return data
end,
},
-- Types and shape only. A game rule here (Coins >= 0) would stop every save after one bug
Validate = function(data: any): boolean
return type(data.Coins) == "number" and type(data.Level) == "number"
end,
-- Studio reads and writes the published game's DataStores; delete this line to test real saves
Backend = if RunService:IsStudio() then "Memory" else "DataStore",
}

DataService passes this table to DataDriver.new and calls attach(kernel), which loads each player’s profile into session.Profile on join, autosaves every 60 seconds (each profile on its own thread, spread over a quarter of the interval), saves on leave after every other cleanup bound to the session, and drains on server close within 27 seconds. Other services read and write session.Profile.Data synchronously and never keep it across a yield. session.ProfilePending is true while the profile loads; the kits that persist rewards wait for it instead of paying into session.Data, which lasts only the visit.

The driver refuses what it cannot do safely:

  • A failed load kicks the player instead of substituting defaults, because defaults overwriting a real record is how data wipes happen. When another server still holds the lock, attach first waits up to LockWaitSeconds (15) and asks that server to release (see Rejoin without a cooldown). A record written by a newer SchemaVersion, or one the current Validate or a migration rejects, fails the load instead of restoring an older copy over it.
  • A save the driver could not read back is refused and publishes Data.SaveRefused(key, reason): a value a codec would change, a record over the 4 MB DataStore limit, or data Validate rejects. The session lock keeps refreshing, so no other server loads the older stored copy meanwhile. A warning fires once a profile passes 75% of the limit.
  • A save that finds its lock taken by another server ends the player’s session and publishes Data.LockLost(key).

New Defaults fields reach existing records, but an entry the game removed is never refilled from Defaults. DataStore throttling retries with wider, jittered spacing within the same total retry time and publishes Data.Throttled(key, errorText) once per episode. Codec = "Auto" stores profiles as packed buffers with a schema inferred from Defaults (every number a 64-bit float) and keeps fields added later. The "Http" backend is for your own database: its endpoints must enforce ETag/If-Match on updates and If-None-Match: * on creation, and a record without an ETag fails closed.

Go deeper: DataDriver, Backends, Serde, Scaffolding.

Per-player setup that reads the profile subscribes to the Kernel.ProfileLoaded milestone. Milestones.on also replays profiles that loaded before the service subscribed, so boot order cannot lose a player:

src/Server/Game/Services/DailyRewardService.luau
--!strict
-- DailyRewardService: the daily login reward and playtime tracking.
local ServerScriptService = game:GetService("ServerScriptService")
local Milestones = require(ServerScriptService.ChloeKernelServer.Milestones)
local DaySeconds = 86400
local DailyCoins = 100
local DailyRewardService = {
Name = "DailyRewardService",
Dependencies = { "DataService" },
}
function DailyRewardService.init(self: any, kernel: any)
self.Kernel = kernel
self.Scope = kernel:createScope()
end
function DailyRewardService.start(self: any)
self.Scope:add(Milestones.on(self.Kernel, "Kernel.ProfileLoaded", function(session, profile)
local Now = os.time()
if Now - profile.Data.LastDaily >= DaySeconds then
profile.Data.LastDaily = Now
profile.Data.Coins += DailyCoins
end
local JoinedAt = os.clock()
session:bind(function()
-- Bindings run before the leave save, so this write persists
local Current = session.Profile
if Current then
Current.Data.PlaytimeSeconds += os.clock() - JoinedAt
end
end)
end))
end
function DailyRewardService.stop(self: any)
self.Scope:destroy()
end
return DailyRewardService

kernel:onSession fires when the player joins, before the profile exists, so reading session.Profile.Data there errors. Kernel.ProfileLoaded(session, profile) fires once the profile is loaded and locked. Everything passed to session:bind runs when the player leaves, before the DataDriver’s final save, so leave-time writes reach storage. session:bind accepts connections, Instances, functions and tables with a disconnect or destroy method; wrap a Scheduler handle in a function that calls :cancel(). Other milestones work the same way: Commerce.PassesResolved and Policy.Loaded.

Go deeper: Milestones, Sessions.

Roblox keeps a free native version of each DataStore key: the first write in each UTC hour becomes a version, later writes in that hour overwrite it, and an overwritten version expires after 30 days. When a record exists but will not decode or migrate, load() walks that history newest first and restores the first version that passes, sets profile.RestoredFromBackup and publishes Kernel.ProfileRestored(session, profile). This is on by default for DataStore profiles:

src/Server/Game/Data/Profile.luau
return {
Name = "PlayerData",
Defaults = { Coins = 0, Level = 1 },
Versions = { MaxVersions = 10 }, -- versions tried per fallback (the default); Fallback = false turns it off
-- Optional retention in separate keys, for history past the version window or copies off Roblox
Backup = {
IntervalSeconds = 86400, -- one backup per key per day (the default)
Keep = 3, -- rotate the last 3 per key (the default)
Mirror = true, -- also copy every save to a "live" slot
},
}

Support tooling can roll a key back to a moment with driver:restoreAt(key, unixMs). It takes the session lock like a load and returns the profile with the old data staged in memory; nothing is overwritten until you call profile:save() or profile:release(), and profile:discard() frees the lock and keeps the live record. Autosaves only keep the staging’s lock alive, and a shutdown discards a staging nobody confirmed. Restores follow the copy’s default-paths ledger, so default entries the game removed stay removed (backups written before 0.9.1 have no ledger). Versions are hourly, so when Backup holds a newer copy (the Mirror slot is one autosave old) the fallback restores that instead.

Backups are written only by the server holding the profile’s lock, and the schedule (BackupAt) lives in the record, so a hundred servers cannot write a hundred daily copies and a player hopping servers does not reset the clock. Keep bounds storage per player. The backup target defaults to a second DataStore named {Name}_Backups (so Name stays within 42 characters); a "Memory" primary gets a Memory backup so Studio never writes production keys. Every copy carries the owner’s UserIds for erasure requests. driver:backupNow(profile), driver:listBackups(key) and driver:peekBackup(key, slot?) serve support tooling; peekBackup locks nothing.

Restore never runs while another server holds the lock, never while the backend is down, and never when Validate rejects the live record: loading without a lock is a duplication path, and a stricter Validate is a code change to fix, not data to roll back. Since April 9, 2026, DataStore storage above 500 MB + 1 MB x lifetime users is billed, and each backup slot and the Mirror slot is a separate key, so Keep = 3 plus Mirror can add up to four times the profile’s size. Native versions are free. Leave Backup off unless you need retention beyond the version history.

Go deeper: Version history, Backups.

Players come back within seconds after a crash, a teleport or a misclick. DataDriver hands the session lock over for each case, so a game needs no rejoin cooldown and no lock wait of its own:

src/Server/Game/Data/Profile.luau
return {
Name = "PlayerData",
Defaults = { Coins = 0, Level = 1 },
LockWaitSeconds = 15, -- the default: how long a join waits for another server's lock before kicking
-- Handoff is on for live servers on a shared backend; Handoff = false turns it off
}
  • On the same server, a leave save still in flight is waited for on an event, and one that failed is not waited out: the player carries on with the retained profile, which is the newest copy and still under this server’s lock.
  • On another server, the joining server asks the lock holder over MessagingService to release now. The holder ends a stale session it still has for that player (a dropped client that has not timed out) or retries a failed leave save, and replies the moment the lock is free. A graceful move takes about as long as one save. The request names the exact lock it saw, so a late one never ends a newer session.
  • After a crash, presence decides. Every live server beats a presence key in MemoryStore every 3 seconds (10-second expiry). A lock whose holder stopped beating is taken after two empty reads a beat apart, about 10 to 15 seconds after the crash instead of the 3 to 4 minutes a lock takes to go stale. A trade the crashed server left half-written is settled from its journal in the same load. A holder that still beats never loses its lock to age, so a server whose saves are stalling keeps its newer data.

Handoff turns on by itself when the server has a JobId, the backend is shared (not "Memory") and the server is not Studio. It costs one MessagingService subscription per server and one MemoryStore write every 3 seconds per server. Locks record whether their holder beats presence, so servers on an older build are taken over only by lock age and a mixed-version deploy is safe.

Go deeper: Handoff, DataDriver.

The schema is the allowlist: clients cannot write any key it does not declare.

src/Server/Game/Services/SettingsService.luau
--!strict
-- SettingsService: persisted player preferences.
local ServerScriptService = game:GetService("ServerScriptService")
local Settings = require(ServerScriptService.ChloeKernelServer.Settings)
local SettingsService = {
Name = "SettingsService",
Dependencies = { "DataService" },
}
function SettingsService.init(self: any, kernel: any)
self.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" } },
Nickname = { Kind = "string", MaxLength = 20, Default = "", Filter = "Broadcast" },
},
})
end
function SettingsService.stop(self: any)
self.Prefs:destroy()
end
return SettingsService
src/Client/Game/Controllers/SettingsController.luau
--!strict
-- SettingsController: the local copy of the player's settings.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local NetClient = require(ReplicatedStorage.ChloeKernel.Net.Client)
local SettingsClient = require(ReplicatedStorage.ChloeKernel.Net.SettingsClient)
local SettingsController = {
Name = "SettingsController",
}
function SettingsController.init(self: any, kernel: any)
self.Kernel = kernel
end
function SettingsController.start(self: any)
self.Prefs = SettingsClient.new(NetClient.new())
self.Prefs:onChanged(function(key, value)
if key == "DashKeys" then
-- The controller that owns the Dash binding rebinds it
self.Kernel.Bus:publish("Controls.Remap", "Dash", value)
end
end)
self.Prefs:onWriteFailed(function(key, reason)
self.Kernel.Bus:publish("Hud.Toast", `Could not save {key} ({reason})`)
end)
-- fetch() invokes a request and yields, so it runs off the boot thread
task.spawn(function()
self.Prefs:fetch()
end)
end
function SettingsController.stop(self: any)
if self.Prefs then
self.Prefs:destroy()
end
end
return SettingsController

A settings screen calls self.Prefs:set("MusicVolume", 0.8): the change applies locally at once (and fires onChanged), then goes to the server through a rate-limited request (10 per second by default) that runs the fail-closed Intent.Settings_Set hook chain. Unknown keys and wrong kinds reject, numbers clamp into Min and Max, strings over MaxLength reject, and string arrays are rebuilt so hidden hash keys never reach storage. The reply carries the stored value, so a clamped set corrects itself. A rejected write restores the stored value and fires onWriteFailed(key, "Rejected") once; transient failures retry and then report "Unavailable". fetch() returns the current cache while the server still waits for the profile, and the profile load fills it.

Values persist in the profile under Settings. On the server, Prefs:get(session, key) reads with defaults, and Prefs:set(session, key, value) runs the same rules and returns whether it stored the value; an invalid value is a caller bug and raises. A string rule with Filter = "Broadcast" (the Nickname) passes through TextFilter after the chain and stores the filtered text, so no path can show the raw input; a failed filter rejects the write, and set yields while it filters. Bus: Settings.Changed(session, key, value). The controller that owns the Dash binding turns the saved names back into keys with InputDriver.parseKeys and calls rebind (see Add keybinds players can remap), so remaps survive rejoins. Deploy the server and client together.

Go deeper: Player settings, InputDriver.

MemoryDriver wraps MemoryStore with buffer packing, for round state, global events and live boards:

src/Server/Game/Services/WorldBossService.luau
--!strict
-- WorldBossService: one boss health pool shared by every server.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local MemoryDriver = require(ServerScriptService.ChloeKernelServer.Data.MemoryDriver)
local Serde = require(ReplicatedStorage.ChloeKernel.Serde)
local WorldBossService = {
Name = "WorldBossService",
}
function WorldBossService.init(self: any, _kernel: any)
self.Boss = MemoryDriver.new({
Name = "WorldBoss",
Codec = Serde.schema({ Health = "NumberU32", Phase = "NumberU8" }),
DefaultTtlSeconds = 300,
})
end
-- Yields: call it from an intent handler or a spawned thread, never a Scheduler job
function WorldBossService.damage(self: any, amount: number): boolean
local Ok, _State, Err = self.Boss:update("current", function(state)
state = state or { Health = 1000000, Phase = 1 }
state.Health = math.max(state.Health - amount, 0)
return state -- return nil to abort the write
end)
return Ok and Err == nil
end
return WorldBossService

update(key, fn, ttl?) reads, transforms and writes with retries, and returns (ok, newValue, err); returning nil from the transform aborts with nothing written. It retries only errors that prove the call never ran (throttling, quota, a conflict). After a timeout or an internal error the write may already have committed, so it returns false and an “outcome unknown” error instead of applying the transform twice. For leaderboards use Kind = "SortedMap" and pass the score as the sort key (set(key, value, ttl, score), or return value, score from the transform), then read with getRange(direction?, count?, lower?, upper?); count is clamped to 200. Expirations must be 1 to 3,888,000 seconds (45 days). Buffer support is detected at runtime, with a base64 fallback.

Go deeper: MemoryDriver, Serde.

src/Server/Game/Services/ShutdownService.luau
--!strict
-- ShutdownService: moves players to a fresh server on update deploys and scheduled restarts.
local ServerScriptService = game:GetService("ServerScriptService")
local SoftShutdown = require(ServerScriptService.ChloeKernelServer.SoftShutdown)
local ShutdownService = {
Name = "ShutdownService",
}
function ShutdownService.init(self: any, kernel: any)
self.Kernel = kernel
self.Scope = kernel:createScope()
self.Shutdown = self.Scope:add(SoftShutdown.attach(kernel), "destroy")
self.RestartPending = false
end
function ShutdownService.start(self: any)
self.Scope:add(self.Kernel.Bus:subscribe("Kernel.RestartScheduled", function()
self.RestartPending = true
end))
-- Move everyone at a natural break instead of mid-round
self.Scope:add(self.Kernel.Bus:subscribe("Round.PhaseChanged", function(_topic, phaseName)
if self.RestartPending and phaseName == "Results" then
self.Shutdown.migrate() -- (ok, reason?): Studio, NoPlayers or ReserveFailed
end
end))
end
function ShutdownService.stop(self: any)
self.Scope:destroy()
end
return ShutdownService

When Roblox closes the server for an update, players go through a reserved transit server and back into a fresh public server instead of to the app, while the DataDriver’s close drain saves their data. SoftShutdown.attach(kernel, options?) returns { migrate, destroy }. A restart scheduled from the Creator Hub or Open Cloud publishes Kernel.RestartScheduled(restartTime, source, attributes) as soon as the engine announces it, so the game can show a countdown or call migrate() at a round boundary; MigrateLeadSeconds = 60 moves everyone that long before the restart without game code. Only arrivals whose SourcePlaceId is this place bounce, an arrival whose return teleport fails after every attempt is kicked with a rejoin message rather than left alone in the transit server, and a moderation shutdown skips the migration.

Go deeper: SoftShutdown, TeleportRetry, DataDriver.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.