Cross-Key Transactions
A transaction applies related changes together. If one change fails, it undoes them all. Use one when a shop must take payment and grant an item together.
A transaction covers one player’s data. Start with the forge example below. The later sections explain gifts and trades between players.
The examples run in a server script with the Emberfall template. Only call the player functions after WaitForData succeeds.
Quick reference
Section titled “Quick reference”| Your task | Use |
|---|---|
| Take payment and grant something to one player | Transaction, then Flush to confirm the save; or Data.Purchase |
| Combine several updates into fewer network messages | Batch |
| Give something to another player without receiving anything back | A saved transfer record and a message handler that ignores duplicates; see the outbox example below |
| Trade items between two players on this server | Exchange |
| Trade items between players on different servers | A separate trade protocol; Scribe has no built-in API for this |
One player, one transaction
Section titled “One player, one transaction”Put related writes inside Data.Transaction. Call Data.Flush afterward to confirm that they were saved.
The transaction callback must not yield. Do not call Flush, wait for an event, or make a network request inside it.
Emberfall’s forge takes 250 Coins and hands back an Ember Blade plus a little Xp:
local ReplicatedStorage = game:GetService("ReplicatedStorage")local Data = require(ReplicatedStorage.Shared.EmberfallData).Server
local function forge(player: Player): ("Saved" | "Pending" | "Refused", string?) local data = Data[player]
local ok, err = Data.Transaction(player, function() if data.Coins.Get() < 250 then error("not enough coins") end data.Coins.Decrement(250) data.Xp.Increment(200) data.Inventory["Emberblade"].Update(function(entry) local quantity = if entry then entry.Qty else 0 if quantity >= 999 then error("Emberblade stack is full") end return { Qty = quantity + 1, Rarity = "Rare" } end) end)
if not ok then return "Refused", err -- nothing changed, the coins are untouched end if not Data.Flush(player) then return "Pending", "The forge applied, but saving is not confirmed yet" end return "Saved"endIf any write fails, the coins, XP, and inventory all return to their previous values. This includes a full stack or a new inventory entry that exceeds MaxKeys.
The 999 check matches the template’s stack limit. Update both if that limit changes. Without the check, the quantity could stay at its maximum while the player still pays.
Result from forge | What your game should do |
|---|---|
Saved | Show success. The save was confirmed. |
Pending | Show a pending state and retry Flush. The purchase already applied. |
Refused | Show the reason. The transaction changed nothing. |
Do not call forge again to retry a pending save. That starts another purchase.
Why one player never needs a protocol
A session lock gives one server permission to write the profile. ProfileStore checks that permission inside the save operation.
Each save also writes the whole data tree in one UpdateAsync. The coin debit, inventory entry, and XP change therefore share one saved version.
Commit is not durability
Section titled “Commit is not durability”Transaction returning true means the changes applied in memory. They still need a save. A server crash before that save can lose them.
Flush waits for save confirmation. A true return confirms the save; a false return needs careful handling.
Flush is free when there is nothing to save
Flush returns true without a DataStore request when the last save succeeded, nothing has changed since, and no save is in flight. A new grant changes the data, so it still needs a save.
Flush(player, { Timeout = 30 }) raises the confirmation wait from its 15-second default.
Batch, when you only want fewer frames
Section titled “Batch, when you only want fewer frames”Use Data.Batch to send several changes in one replication flush and notify container listeners once. If a write fails, earlier writes stay applied.
-- Emberfall's respawn: two stats move, the client redraws once.Data.Batch(player, function() local data = Data[player] data.Stats.Deaths.Increment(1) data.Stats.Playtime.Increment(60)end)Use Transaction when all changes must succeed together. It already batches its writes, so wrapping it in Batch is unnecessary.
Batch | Transaction | |
|---|---|---|
| Coalesces replication | yes | yes |
| Rolls back on error | no | yes |
| Return value | none | (boolean, string?) |
| May yield inside | yes | no |
What one Changed pass actually means
Changing two fields in Stats fires Stats.Changed and the root Changed once each, with the final state. This applies on the server and client. Each individual field still fires its own Changed.
Container Changed passes (new, old). To receive every child write, use Value.OnChildChanged, which passes (key, new, old) and is never combined.
When the rule mentions two players
Section titled “When the rule mentions two players”For a gift, the sender gives and the recipient only receives. Save a record of what is owed before sending the message. This saved list of pending transfers is a durable outbox.
The sequence is:
- Remove the coins from the sender and record the transfer in one transaction.
- Confirm that save with
Flush. - Send the transfer ID and amount to the recipient.
- Credit the recipient and remember the ID in one transaction. Ignore the same ID on later deliveries.
Step 4 makes delivery idempotent: retrying the message does not grant twice.
Use this pattern when order does not matter, such as adding currency, setting a permanent flag, or keeping the highest score. The recipient must be able to accept the full grant. Check any balance or inventory limits before applying it; silently clamping a grant loses value.
Emberfall needs two extra root fields for it, both Scribe.ServerOnly because the client does not need this bookkeeping:
-- Added to the Emberfall template.Outbox = Scribe.ServerOnly(Scribe.DictOf({ ToUserId = Scribe.Int(0, { Min = 0 }), Coins = Scribe.Int(0, { Min = 0 }), SentAt = Scribe.Int(0, { Min = 0 }),}, { MaxKeys = 16, MaxKeyLength = 40 })),
Credited = Scribe.ServerOnly(Scribe.DictOf( Scribe.Int(0, { Min = 0 }), { MaxKeys = 256, MaxKeyLength = 40 })),Outbox records transfers removed from Ava’s balance whose message send is not yet confirmed. A message may already be queued even if its send returned false.
Credited records the transfer IDs Ben has applied, along with their application time.
Ava’s side: debit and promise in one write
Section titled “Ava’s side: debit and promise in one write”local HttpService = game:GetService("HttpService")
local function deliverPending(ava: Player, transferId: string): boolean local record = Data[ava].Outbox[transferId].Get() if record == nil then return true -- already handed off and removed from the outbox end if not Data.Flush(ava) then return false -- keep the same record and ID; its save may still complete end if not Data.SendMessage(record.ToUserId, { Kind = "CoinGift", Id = transferId, Coins = record.Coins, }) then return false -- a retry sends the same ID, never another debit end Data[ava].Outbox.Remove(transferId) return trueend
local function sendCoins(ava: Player, benUserId: number, amount: number): ("Sent" | "Pending" | "Refused", string?) if benUserId <= 0 or benUserId == math.huge or benUserId % 1 ~= 0 then return "Refused", "recipient must be a positive finite user ID" end if amount <= 0 or amount == math.huge or amount % 1 ~= 0 then return "Refused", "amount must be a positive finite integer" end local transferId = HttpService:GenerateGUID(false)
-- The debit and transfer record will be saved together by deliverPending. local ok, reason = Data.Transaction(ava, function() local data = Data[ava] if data.Coins.Get() < amount then error("not enough coins") end data.Coins.Decrement(amount) data.Outbox[transferId].Set({ ToUserId = benUserId, Coins = amount, SentAt = os.time(), }) end)
if not ok then return "Refused", reason -- the debit and record both rolled back end if not deliverPending(ava, transferId) then return "Pending", transferId end return "Sent", transferIdendAfter the confirmed flush, Ava’s saved data records the coins as owed. She can no longer spend them.
Result from sendCoins | Meaning |
|---|---|
Sent | The message was saved on the recipient’s key. The recipient may not have applied it yet. |
Pending | The outbox record remains. The second return value is its transfer ID. |
Refused | No new transfer was created. The second return value explains why. |
Retry a pending transfer with deliverPending(ava, transferId). Do not call sendCoins again for the same transfer; it creates a new ID and takes another payment.
Your delivery worker must resume saved outbox records after a load and retry with the same IDs. Use one worker per sender and block overlapping UI submissions for the same request. If the sender leaves, their pending transfers wait until your code can resume them.
Use a bounded retry policy so unresolved transfers become visible to operators. Reaching the limit does not prove delivery failed: keep the record for resolution rather than refunding or deleting it.
Ben’s side: credit and marker in one write
Section titled “Ben’s side: credit and marker in one write”Data.OnMessage:Connect(function(ben, message) if message.Kind ~= "CoinGift" then return end local ok, reason = Data.Transaction(ben, function() local data = Data[ben] if data.Credited[message.Id].Get() ~= nil then return -- already applied, so a redelivery does nothing end data.Coins.Increment(message.Coins) data.Credited[message.Id].Set(os.time()) end) if not ok then error(reason or "Coin gift could not be applied") -- retain the message for retry endend)Data.SendMessage reaches Ben’s active server, or queues the message until he next loads. The same transfer can arrive more than once, so the handler saves the credit and its ID together.
Keep these rules:
- Confirm Ava’s save before sending. Otherwise Ben could keep a credit while Ava’s debit is lost in a crash.
- Save Ben’s credit and marker together. If the marker were saved separately, a crash between the two saves could allow a second credit.
- Retry an uncertain send with the same ID.
SendMessagereturningfalsedoes not always mean nothing was queued. Never use that result alone to refund Ava. - Handle a full inbox. Each recipient can hold 1,000 undelivered messages. Further sends return
falseand logMESSAGE_QUEUE_FULL. Keep the transfer pending and retry later.
When both sides give
Section titled “When both sides give”A trade has a stronger rule than a gift: “I give my Ember Blade only if I receive your Frost Shield.” Two independent gifts cannot enforce that rule.
Exchange handles trades between two players loaded on the same server. It sets aside both offers and uses one recorded outcome to decide where each goes. Start with that guide for a normal trade window.
Scribe has no cross-server trade API. Building one requires more than sending a pair of messages:
- Each server can check only the data it owns. Appending a message cannot also require that the recipient still owns an offered item. Both sides need saved commitments and a shared decision.
- A timeout cannot authorize a refund. The other side may have received the item despite an error. Reading its data once is also insufficient: it could apply the message just after that read.
- Intermediate changes remain visible. Clients can see changes to
Sharedfields. Leaderboards use a separate store, so rolling back a profile does not roll back an already-written score. - Delivery can be delayed. It depends on the message queue and save round trips. Design a pending state instead of assuming both sides finish within a trade-window countdown.
- Game rules still need protection. Validate requests on the server, prevent overlapping spends, and consider what restoring an old profile would do to items already traded away.
What you can do today
Section titled “What you can do today”- Choose the smallest operation. A shop affects one player’s profile. A gift has one giver. Use a trade protocol only when both sides must give together.
- Keep ownership in one place where practical. Some games can record who owns a unique item in one authoritative record. This is a different data design, not something two messages provide automatically.
- Show pending transfers. Tell the player which value is waiting, such as “500 coins to Ben: pending”. Expose unresolved records to operators too.
- Resolve the original transfer before returning value. A return needs a saved decision that prevents the original delivery from applying later, then its own duplicate-safe delivery. Neither a timeout nor a failed send provides that decision. The example above does not implement returns.
- Limit outstanding transfers. Cap their count and total value before taking payment. Also limit how many records one sender can aim at a recipient.
Where to next
Section titled “Where to next”- Exchange: trades between two players on one server.
- Session Lifecycle: loading, saving, shutdown, and
Flush. - Monetization: purchases within one player’s data.
- Gifting: Scribe’s built-in developer-product gifting flow.
- Replication & Visibility: what clients see during changes.
- Log Code Reference: messaging errors and warnings.
- Diagnostics: save latency and failed flushes.