Your data
What goes in a store, how ops and the reducer work, and the rules your reducer has to follow.
On the last page the player only had gold. Let's give them an inventory too, and a way to buy things. Here's the Stores module again with both:
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Ledger = require(ReplicatedStorage.Ledger)
export type PlayerData = {
Gold: number,
Inventory: { [string]: number },
}
type PlayerOps = {
AddGold: { Amount: number },
SpendGold: { Amount: number },
BuyItem: { Item: string, Price: number },
}
local PlayerStore = Ledger.New<<PlayerData, PlayerOps>>({
Name = "PlayerData",
Keys = "Player",
Default = { Gold = 0, Inventory = {} },
MustExist = false,
Erasable = true,
Reducer = function(Data, Op)
if Op.Kind == "AddGold" then
local New = table.clone(Data)
New.Gold += Op.Amount
return New
end
if Op.Kind == "SpendGold" then
if Data.Gold < Op.Amount then
return nil -- not enough gold
end
local New = table.clone(Data)
New.Gold -= Op.Amount
return New
end
if Op.Kind == "BuyItem" then
if Data.Gold < Op.Price then
return nil -- not enough gold
end
local Inventory = table.clone(Data.Inventory)
Inventory[Op.Item] = (Inventory[Op.Item] or 0) + 1
local New = table.clone(Data)
New.Gold -= Op.Price
New.Inventory = Inventory
return New
end
return nil
end,
})
return {
PlayerStore = PlayerStore,
}What you can save
PlayerData is the shape of one player's data, and Default is what a new player starts with.
You can save numbers, strings, booleans, and tables of those, up to about 4 MB per player. The same rules as DataStores apply: no Instances, Vector3s or functions, no holes in arrays, and a table can't mix array and string keys.
Every field your data will ever have needs to be in Default. If a field has no value at the start, give it an empty one, like 0, "" or {}. A reducer that sets a field Default doesn't have is refused, and Ledger warns you in the output.
Ops
An op is one change to a player's data. PlayerOps lists every kind of op, and what each one carries:
type PlayerOps = {
AddGold: { Amount: number },
SpendGold: { Amount: number },
BuyItem: { Item: string, Price: number },
}When you send one, you give its Kind and its fields:
Session:Apply({ Kind = "BuyItem", Item = "Sword", Price = 50 })Because the ops are typed, a typo in a kind or a missing field shows up in Studio's script analysis before you ever run the game.
The reducer
The reducer is the only place your data changes. It gets the player's current data and an op, and returns one of two things:
- The new data, if the op is allowed.
nil, if it isn't. The op does nothing, and the call that sent it gets backfalse.
Notice that each branch starts with table.clone(Data) and changes the copy. That's on purpose, because of the rules below. If your reducer grows past a handful of ops, Bigger reducers shows how to keep it short.
Reducer rules
Ledger may run your reducer more than once for the same op, so it has to give the same answer every time:
- Never change
DataorOpdirectly. Copy the table you're changing withtable.clone, likeInventoryabove, and return the copy. - Return every field. Cloning
Datafirst takes care of that. - No randomness and no clock. Don't use
math.random,os.timeortick()in a reducer. Work it out in your script and put the result in the op. For a loot drop, roll the item in your script and send{ Kind = "AddItem", Item = Rolled }. - No waiting, no Ledger calls, nothing outside the data. Don't fire remotes, print, or change parts from a reducer. Do that in your script after the call answers, or in
Observe. - Return
nilfor kinds you don't know. That's thereturn nilat the bottom.
If your reducer errors, Ledger treats it as nil and the op is refused. It won't print the error, so wrap risky code in pcall while you debug.
Studio helps you catch broken rules. There, your reducer runs three times per op and Ledger warns you if the answers differ. Data and Op are also read-only in Studio, so a reducer that changes them directly gets every op refused.
Using it
Here's a shop that sells through the new op. The price comes from the server, never from the client:
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerStorage = game:GetService("ServerStorage")
local Stores = require(ServerStorage.Stores)
local PlayerStore = Stores.PlayerStore
local BuyItemEvent = ReplicatedStorage:WaitForChild("BuyItem") :: RemoteEvent
local PRICES: { [string]: number } = {
Sword = 50,
Shield = 80,
}
local function OnBuyItem(Player: Player, Item: unknown)
if type(Item) ~= "string" then
return
end
local Price = PRICES[Item]
if not Price then
return -- not something we sell
end
local Session = PlayerStore:Get(Player)
if not Session then
return -- still loading
end
local Ok = Session:Apply({ Kind = "BuyItem", Item = Item, Price = Price })
if not Ok then
print(Player.Name, "can't afford a", Item)
end
end
BuyItemEvent.OnServerEvent:Connect(OnBuyItem)If the player can't afford it, the reducer returns nil and Apply gives back false. Their gold and inventory stay as they were.
Balances: let Ledger handle gold
Most games have a few numbers that only ever go up or down by an amount: gold, gems, coins. Instead of writing reducer code for those, you can tell Ledger which ops add and which take away, and it handles them for you:
local PlayerStore = Ledger.New<<PlayerData, PlayerOps>>({
Name = "PlayerData",
Keys = "Player",
Default = { Gold = 0, Inventory = {} },
MustExist = false,
Erasable = true,
Balances = {
Gold = { Credit = "AddGold", Debit = "SpendGold" },
},
Reducer = function(Data, Op)
-- AddGold and SpendGold never get here: Ledger applies them.
-- ... your other ops, like BuyItem
return nil
end,
})Creditops add theirAmountto the field.Debitops take it away.- A debit that would take the field below 0 is refused, like the "not enough gold" check you'd write yourself.
Amountmust be a whole number above 0. Anything else throws when you send the op.- Keep the ops in
PlayerOpsas usual, with{ Amount: number }, so they stay type checked.
Plain ops can still change Gold too, like BuyItem taking the price. Ledger's limits don't apply to those, so the reducer checks them itself, as before.
Limits
Give the field a Min or a Max to change the limits. Here, gold can never go above a million:
Balances = {
Gold = { Credit = "AddGold", Debit = "SpendGold", Max = 1000000 },
},An AddGold that would pass the Max is refused.
You can list several ops, and give one op limits of its own by writing it as a table. Here a Refund always goes through, even above the cap:
local NO_LIMIT = 2 ^ 53 - 1
Balances = {
Gold = {
Credit = { "AddGold", { Kind = "Refund", Max = NO_LIMIT } },
Debit = "SpendGold",
Max = 1000000,
},
},Min and Max are limits on the field, not on one op: Max = 1000000 means the gold can't go above a million, not that one op can't add more than a million.
Why bother
For one player's gold, a reducer works just as well. Balances start to matter in trades: while a trade is in progress, it holds the player's data until it finishes, and their other changes wait. A gold op in a trade doesn't hold anything. It only sets the amount aside, so the player can keep earning and spending meanwhile. Shops, auctions and guild banks that many servers trade with at once rely on that, as in Auctions and markets.
Checking the shape with Schema
A reducer can still return data in the wrong shape, like a negative count or a field set to a string. Schema is a last check on every state your reducer returns:
Schema = function(Data): (boolean, string?)
for Item, Count in Data.Inventory do
if Count < 1 then
return false, `{Item} has a count of {Count}`
end
end
return true, nil
end,Return true, nil to keep the state, or false and a message to refuse it. A refused state isn't saved, the call answers Refused as if the reducer had returned nil, and Ledger warns in the output with the store, the op kind and your message.
You can use any validator that answers the same way. The t package does:
local IsPlayerData = t.strictInterface({
Gold = t.integer,
Inventory = t.map(t.string, t.integer),
})
Schema = function(Data): (boolean, string?)
return IsPlayerData(Data)
end,A few things to know:
- It only sees what the reducer returns. Balance ops never reach the reducer, so
Schemadoesn't check them. Limit a balance withMinandMaxinstead. - It doesn't check old saves. What a migration returns, and data saved by an older version of your game, aren't checked.
- Same rules as the reducer. No waiting, no Ledger calls, no randomness, and don't change the data it's given.
- It runs on every op. Keep it quick: check what can go wrong, not every field on every change.
MustExist and Erasable
Every store has to say these two:
| Option | true | false |
|---|---|---|
MustExist | Changing a key with no data fails with Missing. In a player store, the player's own session is the exception: Load starts a new player from Default, so only the player creates their own data. | Data that doesn't exist yet starts from Default, and is saved the first time it changes. |
Erasable | You can delete a key's data for good with Erase, for example for a GDPR request. | Erase isn't allowed. |
For player data, use MustExist = false and Erasable = true. Use MustExist = true on a player store if nothing else should create a player's data, so a gift or a trade sent to someone who has never played answers Missing instead. Pick Erasable before your game goes live: turning it on later takes extra care, covered in Deleting data.
Next: Players