Forest Logo
search
package_2

roblox-data

By @hurreman

Roblox

Mirrored from Wally

roblox-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, DataManager and schema modules;
  • Magic Neighborhood's SyncService and client Store;
  • 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:

  1. StartSessionAsync, cancelled if the player leaves while it waits.
  2. AddUserId.
  3. The legacy import, if configured.
  4. migrate.
  5. Reconcile against the template.
  6. onLoaded, guarded so one broken hook can't leave Await hanging.

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 convert and becomes profile.Data (Reconcile fills in the template afterwards, so migrate sees only what was saved), and the flag is set to os.time().
  • A player with no old record is simply flagged.
  • If the old record's _lock.time is younger than staleAfter, 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.DataStoreState isn'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:
    • DataLoader becomes Profiles.new with:
      • name = { studio = "Test_v3", live = "Live_v3" } and the vendored Libraries/ProfileStore (or the Wally one);
      • the join work as onLoaded;
      • the leave work (quest serialization, playtime flush) as onRelease.
    • DataManager.Profiles[player] becomes profiles:Get(player), and AwaitProfile becomes profiles:Await.
    • MergeTemplateIntoProfileData becomes Data.Tables.WithTemplate, and OnProfileReset becomes profiles:OnReset.
    • The currency functions become a Currency wallet. Its changed hook fires CurrencyUpdated and the progress event.
    • KitchenBoothSchema and FridgeStorageSchema can move onto Data.Schema as they're touched.
  • Magic Neighborhood and Steal an Egg:
    • DataService becomes Profiles.new plus legacy as above. Keep receipts in the converted data, and roblox-commerce's legacyIdsField = "receipts" keeps old receipts deduped.
    • SyncService and Store become Data.Sync and Data.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

check_circle

Safe for commercial use

Automated license review — not legal advice.