Skip to main content

Coming from ProfileStore, ProfileService or Lapis

Geode is an independent project and shares no code with ProfileStore, ProfileService or Lapis. It covers a lot of the same ground, though: session-locked profiles, a template, filling in missing fields, safe server hops and developer products. This page maps what you know to Geode and shows how to move existing data.

The biggest difference is that data is no longer a table you edit in place. Writes go through ops and transactions, and reads return frozen values. That one write path is what lets guards, reactions, replication and history see every change.

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Geode = require(ReplicatedStorage.Packages.Geode)

local PlayerData = Geode.players({
name = "PlayerData",
template = { coins = 0, inventory = {} },
})

PlayerData:onLoaded(function(player, profile)
profile:transact(function(tx)
tx:increment("coins", 10)
tx:append("inventory", "starter sword")
end, { note = "first join reward" })
end)

From ProfileStore​

ProfileStoreGeodeNotes
ProfileStore.New(name, template)Geode.Profile.store({ name = name, template = template })Or Geode.players, which also loads on join and releases on leave
store:StartSessionAsync(key)store:load(key, { userId = userId })Returns a result: result.ok, then result.profile or result.error
Steal = truestealPolicy = "steal" with stealAfter = 0Set on the store, not per call. By default Geode already takes over after 15 s if the holder doesn't let go
Cancel callbackstealPolicy = "fail", loadTimeoutThere's no polled cancel function
profile.Data.coins += 10profile:increment("coins", 10)Read with profile:get("coins")
profile:Reconcile()Automatic on every loadLists aren't merged, and keys the template doesn't know are kept
profile:EndSession()profile:release()Saves once more, then gives up the lease
profile:IsActive()profile:isActive()
profile:Save()profile:save()
profile.OnSessionEndprofile.released and profile.sessionLostSplit in two: a normal release, and a session another server took
profile.OnAfterSaveprofile.savedFires after a save succeeds. To react to each change, use profile:observe or profile:onCommit
profile:AddUserId(userId)userId or userIds load optionGiven when the profile loads
store:MessageAsync(key, message)store:mail(key, op, ...)Queues an op that applies exactly once, the next time the profile loads. It doesn't reach a profile that's already open
profile:MessageHandler(fn)Not neededMail runs as a transaction on load
store:VersionQuery(key)store:history(key), then store:version(key, id)
store:GetAsync(key)No direct equivalentstore:version reads a stored version. store:get(key) only returns profiles open on this server
profile:SetAsync() on an old versionstore:rollback(key, versionId, note)Goes through the server holding the profile instead of ending its session
store:RemoveAsync(key)store:erase(key), or Geode.erase(userId) for every storeLeaves an erased marker. A server still holding the profile loses its session instead of writing it back
ProfileStore.MockStudio mode "mock", the default in Studio, or Geode.Testing in Lune
Developer productsThe products option of Geode.players, or Geode.receiptsPurchase ids are tracked for you. See Developer products

From ProfileService​

Most ProfileStore rows apply here too. These are the ones that differ:

ProfileServiceGeodeNotes
ProfileService.GetProfileStore(name, template)Geode.Profile.store({ name = name, template = template })
store:LoadProfileAsync(key, "ForceLoad")store:load(key)The default stealPolicy = "steal" asks the holder to release first and takes the lease after stealAfter seconds
"Steal"stealPolicy = "steal" with stealAfter = 0
A handler returning "Cancel"stealPolicy = "fail"The load fails with leaseHeld
profile:Release()profile:release()
profile:ListenToRelease(fn)profile.released and profile.sessionLostGeode.players already kicks a player whose session another server took
profile:ListenToHopReady(fn)No direct equivalentThe new server asks the old one to release, so a teleport doesn't need to wait for it
GlobalUpdatesstore:mail(key, op, ...)No active and locked stages. Each mail op applies once, on the next load
MetaTagsKeep the value in the dataPurchase ids are kept for you, outside your data
store:ViewProfileAsync(key)store:history(key) and store:version(key, id)See the GetAsync row above
store:WipeProfileAsync(key)store:erase(key)
Autosave every 30 sEvery 60 s by defaultSet with autosaveInterval on the store

From Lapis​

LapisGeodeNotes
Lapis.createCollection(name, options)Geode.Profile.store({ name = name, ... })A collection is a store, a document is a profile
defaultDatatemplateMissing fields are filled in on every load
validateschema, and guards for rules across fieldsChecked on every write, not only when saving
migrationsmigrations keyed by version, plus schemaVersionEach one runs as a transaction on the stored form, oldest first
backwardsCompatibleNo direct equivalentA server that finds data saved by a newer schema version fails the load with newerData
collection:load(key)store:load(key)Yields and returns a result instead of a promise
document:read()profile:get() or profile:snapshot()Frozen, like Lapis's default
document:write(data)Ops, or profile:set({}, data) to replace everything
document:close()profile:release()
document:save()profile:save()
beforeSave, beforeCloseReactions and derived fieldsThey run inside each transaction, not at save time. See Bundles, reactions and derived fields
defaultUserIds, addUserIduserId or userIds load option
collection:remove(key)store:erase(key)
A locked load retries, then errorsstealPolicy = "fail"Geode's default takes the lease over instead
setConfig({ dataStoreService = ... }) to mockStudio mode "mock", or Geode.Testing

Moving existing data​

Each of these libraries keeps data in its own record format, so Geode can't read those keys directly. Keep the old library for reading and copy each player's data across the first time they join, behind a flag so it happens once.

  1. Give the Geode store a new DataStore name, so old and new keys never collide
  2. On a player's first Geode load, read their old data with the old library, the way your game does today
  3. Write the old data into the new profile in one transaction with a note, and keep a flag such as migrated = true in the template so it never runs twice
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Geode = require(ReplicatedStorage.Packages.Geode)

-- Stands in for reading the player's old data with the library you used before
local function readOldData(userId: number): { coins: number }?
return { coins = 250 }
end

local PlayerData = Geode.players({
name = "PlayerDataV2",
template = { coins = 0, migrated = false },
})

PlayerData:onLoaded(function(player, profile)
if profile:get("migrated") then
return
end
local old = readOldData(player.UserId)
profile:transact(function(tx)
if old then
tx:set("coins", old.coins)
end
tx:set("migrated", true)
end, { note = "moved from the old store" })
end)

Once every active player has been moved, the old store can stay readable for support cases and be dropped later.