Skip to content

Quick start

This page builds the usual setup: one store, a profile loaded when a player joins, released when they leave. It assumes KeepBlox is a ModuleScript in ServerScriptService (Installation).

-- ServerScriptService/PlayerData.server.luau
local Players = game:GetService("Players")
local ServerScriptService = game:GetService("ServerScriptService")
local KeepBlox = require(ServerScriptService.KeepBlox)
local store = KeepBlox.store("Profiles", {
template = { coins = 0, inventory = {} },
})
store.onError:connect(function(key, message)
warn(`[KeepBlox] {key}: {message}`)
end)
local function keyFor(player)
return `u_{player.UserId}`
end
local function onPlayerAdded(player)
local result
-- A timeout loses nothing: the data waits in the store, and nobody else can write it meanwhile. So
-- load again while the player is still here, and kick only after a few tries.
for _ = 1, 3 do
result = store:load(keyFor(player), {
-- Polled while the load waits: stop if the player left.
cancel = function()
return player.Parent == nil
end,
})
if result.ok or result.reason ~= "timeout" then
break
end
end
if not result.ok then
if result.reason ~= "cancelled" then
player:Kick(`Your data could not be loaded ({result.reason}). Please rejoin.`)
end
return
end
local profile = result.profile
profile:addUserId(player.UserId) -- for data erasure requests
profile.onEnded:connect(function(reason)
-- "released": we released it. "shutdown": the server is closing.
-- "handedOver" or "lost": another server has the profile now.
if reason == "handedOver" or reason == "lost" then
player:Kick("Your data was opened on another server.")
end
end)
if player.Parent == nil then
-- The player left while the load finished.
profile:release()
return
end
-- The game uses profile.Data from here on.
profile.Data.coins += 10
end
Players.PlayerAdded:Connect(onPlayerAdded)
for _, player in Players:GetPlayers() do
task.spawn(onPlayerAdded, player)
end
Players.PlayerRemoving:Connect(function(player)
local profile = store:profiles()[keyFor(player)]
if profile then
profile:release()
end
end)

There is no BindToClose in this script: every store releases all of its open profiles in parallel when the server shuts down, within shutdownDeadline (25 seconds by default).

KeepBlox.store(name, options) opens one DataStore called name. It needs a template (the data a new profile starts with) or a schema (Schema and migrations), not both. The template is filled into loaded data deeply on every load: keys the stored data lacks get a copy of the template’s value, and existing values are never replaced.

The store renews, saves and releases its profiles in the background. You do not call save on a timer. Store reference lists every option.

store:load(key, options) yields until the profile is yours or the load fails. It never throws for a data store problem. It returns a result:

  • { ok = true, profile = profile }, or
  • { ok = false, reason = reason }.

The reasons you are most likely to see:

Reason Meaning
"cancelled" cancel() returned true: the player left. Nothing was kept.
"timeout" The load did not finish within loadTimeout (120 seconds by default). Nothing is lost: load again while the player is still here.
"closing" The server began shutting down.
"open" This server already has a live session for that key.
"foreign" The key holds something that is not a profile. It was copied to quarantine and left untouched.
"outbid" A third server asked for the key after this one, and gets it.

Every reason, including the schema and import ones, is on the load failures page. A failed load never writes data: a key taken and then given up (the player left, or shutdown began) is released without changing it.

cancel is a function polled while the load waits. When it returns true, the load stops with "cancelled", and a key that was already claimed is given back. Without it, a player who leaves during a long wait still has their key claimed for a profile nobody uses.

The load asks the other server to hand the profile over. A live KeepBlox server saves and releases within a second of the message, or at its next MemoryStore beat if messaging is down. A server that crashed is known dead within about 10 seconds. On Roblox’s real services a hand-over took 4-5 s in all, and taking over from a crashed server 12-13 s. How it works covers each case.

profile.Data is a plain table. Change it in place:

profile.Data.coins += 100
table.insert(profile.Data.inventory, { id = "sword", level = 1 })

KeepBlox notices the change and saves it. Before every write it checks the data. A value a data store cannot hold (a string that is not valid UTF-8, a cycle, a function, an Instance), and NaN or infinity (which Roblox stores, but which break every sum they touch), is repaired in what is stored, so it never costs the rest of the save, and store.onError names its path, for example Data.inventory[12].name. What cannot be repaired is refused, and the last good save stays: a mixed table, an array with holes, a dictionary with number keys, or more than 4 MB.

profile:save() saves at once and yields until the save is stored. It returns true, or false if it could not be stored. You rarely need it; purchases and trades have their own helpers.

profile.onEnded fires once, with the reason the session ended:

Reason What happened
"released" The game called profile:release().
"shutdown" The server closed, and the store released every profile.
"handedOver" Another server asked for the key, and this one saved and gave it up.
"lost" A write found the key owned by another server. Nothing more was written.

By the time onEnded fires, profile.Data is frozen with table.freeze, all the way down. Code that still writes to it errors instead of changing a copy nobody will save. That is what stops a trade or a purchase on a stale copy after the session moved elsewhere.

profile:isActive() is true while the session is live and not releasing.

profile:release() ends the session: it makes a final save, then lets go of the lock. It yields until done. The data freezes when the release begins, so the final save holds every change the game made. Just before that, profile.onSaving fires with final = true: the last chance to write into Data.

A player who rejoins the same server while their release is still running does not get "open": the new load waits for the release, then claims the key afresh.

store:profiles() returns the profiles open on this server, by key. It is a new table on every call, so looking one up is cheap and holding it is safe.

store.onError fires with (key, message) for every problem worth logging: a failed call, refused data, a quarantined value, a failed import read. The key is "" for a problem not tied to one key. Log it; it is how bad data and outages become visible.