Start typing to search packages!
roblox-data
By @hurreman
Roblox
Mirrored from Wallyroblox-data
Player saves for hurreman's Roblox games, on ProfileStore:
- Sessions: one per player from join to leave, with a one-time import from a game's old DataStore.
- Currencies: validated whole-number balances.
- Versioned sections: pieces of the save that change shape over time.
- Sync: throttled delta snapshots from server to client.
It is built from three sources:
- the generic half of Slime Nebula's
DataLoader,DataManagerand schema modules; - Magic Neighborhood's
SyncServiceand clientStore; - an importer for the hand-written session-lock saves of Magic Neighborhood and Steal an Egg.
# wally.toml
[dependencies]
Data = "hurreman/roblox-data@0.1.0"
Modules load on first access: Data.Profiles, Data.Currency, Data.Sync (server), Data.Store (client), and Data.Schema and Data.Tables (either side).
Profiles
local Data = require(ReplicatedStorage.Packages.Data)
local profiles = Data.Profiles.new({
ProfileStore = require(ServerPackages.ProfileStore), -- the Wally package or a vendored copy
name = { studio = "Test_v3", live = "Live_v3" },
template = require(script.Template),
-- key = function(player) return "Player_" .. player.UserId end (the default)
migrate = function(data, player, info) end, -- before Reconcile: sees exactly what was saved;
-- info.fresh: a brand-new player, info.imported: just imported
onLoaded = function(player, profile) end, -- the game's join work
onRelease = function(player, profile, reason) end, -- last writes, right before the final save:
-- Release, server shutdown or another server ("Manual" | "Shutdown" | "External")
-- mock = true -- ProfileStore's Mock store: no real DataStore
-- trace = false -- no [JoinFlow] prints / [JoinCritical] errors
})
profiles:Start()
profiles:Await(player, 5) -- yields: the profile, or nil on timeout or leave
profiles:Data(player) -- profile.Data, or nil while not loaded
profiles:Reset(player) -- the save back to the template; OnReset listeners run
Load runs in this order:
- StartSessionAsync, cancelled if the player leaves while it waits.
- AddUserId.
- The legacy import, if configured.
migrate.- Reconcile against the template.
onLoaded, guarded so one broken hook can't leaveAwaithanging.
onRelease runs from ProfileStore's OnLastSave, once per session: on Release, and also when the server closes (ProfileStore ends every session itself then) or another server takes the session.
A session that ends any way other than Release (another server took it, or ProfileStore gave up) kicks the player, so nobody keeps playing on data that won't save. Sessions ProfileStore ends itself while the server closes (ProfileStore.IsClosing) don't count.
Importing an old save (Magic Neighborhood, Steal an Egg)
legacy = {
store = { studio = "PlayerData_v1_Studio", live = "PlayerData_v1" },
key = function(player) return "u_" .. player.UserId end,
convert = function(record, player) return { cash = record.cash, receipts = record.receipts, ... } end,
-- lockField = "_lock", staleAfter = 300, flag = "LegacyImported", attempts = 3, retryDelay = 2
},
The import runs the first time a profile lacks the flag:
- The old record is read only, never written. It goes through
convertand becomesprofile.Data(Reconcile fills in the template afterwards, somigratesees only what was saved), and the flag is set toos.time(). - A player with no old record is simply flagged.
- If the old record's
_lock.timeis younger thanstaleAfter, an old server still holds that save. The player is asked to rejoin in a minute, and nothing is imported. - If the old store can't be read after its retries, the player is kicked and nothing is flagged. Playing on a fresh profile would let a later import overwrite that progress.
- Without DataStore access (
ProfileStore.DataStoreStateisn't"Access": Studio with API access off), ProfileStore plays a mock profile that won't save, so nothing is read or flagged; the import runs on a later join that has access.
To switch a live game over, use Roblox's "restart servers" when publishing. Old servers then release their locks as they close, and joiners import on the new servers.
Currency
local wallet = Data.Currency.new({
currencies = { SlimeBucks = { max = 1e9 }, Nebulite = {}, Gems = { field = "PremiumGems" } },
data = function(player) return profiles:Data(player) end,
changed = function(player, currency, balance, delta, reason) end, -- client update, telemetry
})
wallet:Add(player, "SlimeBucks", 50, "quest")
wallet:Spend(player, "SlimeBucks", 20, "shop") -- false if they can't afford it
Amounts are floored to whole numbers. NaN and ±inf are refused, so they never reach a save, and balances are clamped to [0, max].
Schema (versioned sections)
local BoothSchema = Data.Schema.new({
version = 3,
default = function() return { slots = {} } end,
steps = { [1] = function(raw) ... return raw end, [2] = function(raw) ... return raw end },
normalize = function(data, issues, catalog) ... return data end,
})
local booth, issues = BoothSchema:Load(profile.Data.Booth, catalog)
Load behaves like this:
- A missing or non-table save becomes the default.
- A save newer than
version(a rollback) is reset and reported. - Anything else is stepped up one version at a time, then normalized.
Sync and Store
-- server
local sync = Data.Sync.new({
snapshot = function(player) return { cash = ..., bag = ... } end,
send = function(player, changes, full, removed) net:SendTo(player, "DataSync", changes, full, removed) end,
})
sync:Start()
net:OnServerEvent("DataHello", function(player) sync:Hello(player) end)
-- after any change: sync:MarkDirty(player)
-- client
local store = Data.Store.new()
store:Connect(net:Event("DataSync"), net:Event("DataHello"))
store:Subscribe(function(state) ... end) -- a React hook is a useState over this
What gets sent:
- The first send, and the first after a hello, is the full snapshot.
- After that, only top-level keys whose value changed are sent; sub-tables are deep-compared against a copy and sent whole.
- Removed keys are listed in
removed.
A snapshot that errors leaves that player dirty and is retried every retryAfter seconds, without stopping the loop for anyone else.
Moving the games over
- Slime Nebula:
DataLoaderbecomesProfiles.newwith:name = { studio = "Test_v3", live = "Live_v3" }and the vendoredLibraries/ProfileStore(or the Wally one);- the join work as
onLoaded; - the leave work (quest serialization, playtime flush) as
onRelease.
DataManager.Profiles[player]becomesprofiles:Get(player), andAwaitProfilebecomesprofiles:Await.MergeTemplateIntoProfileDatabecomesData.Tables.WithTemplate, andOnProfileResetbecomesprofiles:OnReset.- The currency functions become a
Currencywallet. Itschangedhook firesCurrencyUpdatedand the progress event. KitchenBoothSchemaandFridgeStorageSchemacan move ontoData.Schemaas they're touched.
- Magic Neighborhood and Steal an Egg:
DataServicebecomesProfiles.newpluslegacyas above. Keepreceiptsin the converted data, and roblox-commerce'slegacyIdsField = "receipts"keeps old receipts deduped.SyncServiceandStorebecomeData.SyncandData.Store, with the game's snapshot. Toasts stay in the game: they go over roblox-net.- This is the riskiest migration. Dry-run it on the Studio store first, then follow the game's
docs/live-verification.md.
Developing
lune run tests/run # specs on hurreman/luau-spec (DevPackages, else ../luau-spec); fakes in tests/fixtures
The Lune specs use a fake ProfileStore and DataStoreService. They cover:
- sessions, kicks, Await, Reset and migrate;
- every legacy import outcome: imported, none, busy and failed;
- currency validation, schema steps and resets;
- sync deltas, hello, errors and the client store.
In Studio, Profiles was run against Slime Nebula's real vendored ProfileStore on its Mock store. These worked: load, Reconcile, AddUserId, onLoaded, Await, Release without a kick, a reload keeping the saved values and onRelease's write, and Reset.
The legacy import has not been run against a real DataStore.
Package Details
Install command (Click to copy)
Version
0.1.0
License
MIT
Safe for commercial use
Automated license review — not legal advice.
