A datastore write is a claim, not a fact
Player data patterns from a billion plays: session locks, idempotent grants, and treating every write as unproven until something observes it.
Somewhere right now a player is buying an upgrade in the last three seconds before a server shuts down. Whether that purchase survives decides whether they trust the game tomorrow. Across the Pogi Games titles we have passed a billion lifetime plays, which means one-in-a-million timing is not an edge case. It is a Tuesday.
Every game we ship runs on the same framework, and the framework has a written rules file. The data section is the least negotiable part of it, because data is the one place where a bug does not just look bad. It takes something a player earned.
One owner, one witness
DataService is the single source of truth for player data. The client gets a read-only mirror and never writes to it. Every mutation goes through the service API: Set for whole values, Update for read-modify-write, Increment, and the array helpers. This is not ceremony. Observe and OnChange only fire for changes made through that API, so a write that sneaks around the side is a write nothing can react to. The UI will not update. The quest tracker will not tick. The change happened and no one saw it, which in a live game is the same as it not happening correctly.
That is the deeper rule hiding under the API: "the write succeeded" is a claim made by the code that wanted it to succeed. The witness is the replica change. Client UI listens to the data mirror, not to its own request. If the mirror moved, the data moved. If you find yourself updating a coin label directly from the purchase handler, you have built a UI that reports intentions instead of state.
Loading is a state, not a moment
Profiles load asynchronously, so every handler that touches a player's data checks DataService:IsReady(player) first. Skipping the check works in Studio every single time, because you are one player on a warm server. In production, a grant that fires during the loading window lands on data that is about to be replaced by the real profile. The player did the thing and owns nothing. Support tickets from that class of bug are unanswerable, because your own logs say the grant happened.
Session locking handles the other half: one live server owns a profile at a time, ProfileStore style, and a second server waits its turn instead of loading a stale copy. The alternative is merging concurrent writes after the fact, and I think merge resolution is how you lose data politely. A queue is ruder and safer. Players tolerate three seconds of "loading your data" far better than a rollback.
Grants that can replay
Purchases and rewards are processed with an id, and the profile records which ids it has seen. Processing the same receipt twice is a no-op. Exactly-once delivery does not exist in a system where servers die mid-frame; at-least-once plus idempotence is the version of it you can actually build. The receipt id costs a few bytes per purchase and converts every duplicate, retry, and crash-replay from a dupe exploit into silence.
Timers get the same suspicion. Anything saved uses workspace:GetServerTimeNow(), the synced epoch clock, never os.clock() or tick(). Process clocks measure a server's lifetime, and the server's lifetime is the one thing guaranteed to be shorter than the player's.
The last rule is cultural: let it error. No silent pcall around state that should always exist, no nil-guards over a path that a typo broke. A wrong datastore path should fail loudly at the source, in development, where it costs nothing. The quiet version fails in production as a no-op, and a no-op on the data path is a loss you find out about from an angry player days later.
None of these rules make a game more fun. They make it forgettable in the one place it should be: nobody has ever praised a game for keeping their inventory, and nobody forgives one for losing it.