So I’m almost a 1 year experienced scripter now, I’ve just finished scripting a great horror game called innocent on roblox and it mantains an active playerbase of 10-50 players.
When I was a bit more new to scripting, I was not too good with datastoreservice. I found it very complicated, until I was introduced to profileservice which handled all the data-saving for me. And provided a few extra benefits such as session-locking.
However, when I became more experienced. I went back and relearnt the regular datastoreservice (no modules) through roblox’s documentation tutorial on datastores.
I became an expert at data saving in roblox, and from then on I left profileservice and have just stuck to regular datastoreservice.
Since i’m making quite a few big games now some of them require data-saving systems, my question is now:
is using datastoreservice without modules fine? Because I don’t see many scripters do this. Usually they use modules for it.
To prevent data-loss, I do have systems in place already such as bindtoclose incase the game shuts down to save everyone’s data one last time just in case.
And pcall retry logic, meaning if data loading or data saving fails I ensure it repeats this function until the data is saved/loaded.
Is this enough usually for big games? Or will I have to get a module like profileservice?
In short: yes, as long as you make it the best it can be. Modules like ProfileService help simplify the whole data process for developers. It hides all the ins-and-outs of data stores so you don’t have to take care of them.
Making your own data module is good enough, as long as you include those things yourself. I haven’t ever used ProfileService, it’s an incredibly powerful module, but I prefer to make my own because it’s better tailored to my game and my needs. It also helped me to learn data stores. I have a few pointers if you will make your own:
Use one data store with compressed data
Retry logic (which you mentioned) and UpdateAsync
Data sanity checks within UpdateAsync (for example data versioning)
Data budgeting and autosave
Data saving after DevProduct purchases
Make your own data request queue so you don’t overload the server with requests
I’d actually recommend not to save on BindToClose but instead something like this:
game:BindToClose(function()
if game:GetService("RunService"):IsStudio() then
task.wait(5)
else
task.wait(30)
end
end)
I’d recommend this because PlayerRemoving will still fire for all the players if they get kicked, or if they just generally leave. You just need to keep the server up long enough to allow data requests to finish, and you need to yield because they are asynchronous network requests. Adding more data requests just increases the chance of data loss, and using retry logic should already cover failed attempts.
Remember ProfileService is a community-made module along with many other popular ones. If they can put in those measures, so can you! You can make your own module as powerful, if not more powerful, than ProfileService because you can tailor it more.
(All the pointers I mentioned might not be all you need to do to make it the most secure)
Not quite (I don’t think) but I have some good knowledge on them!
By sanity checks, I mean making sure all data is within plausible values and of the correct data type. This is important when receiving data from the client. You should generally not trust the client for anything if possible, this means validating any data the client sends to the server.
For budgeting data, the key is to preserve the amount of requests you have left before throttling. Throttling means the requests will take longer to go through and further requests will be dropped if the queue fills too much, basically the same as when you send too many requests at once. I generally use budgeting for an autosave system. You can get a budget for request using DataStoreService:GetRequestBudgetForRequestType() and passing the correct Enum.DataStoreRequestType.
The queue prevents the data store’s queue from filling. Since your own queue won’t lose any requests from it unless you remove them or the server closes, it’s arguably more reliable to use that since requests have less change of getting lost. So yeah, it’s just throttling your own requests and passing them slowly without losing any other requests. Just don’t forget to remove a request from the queue once it’s used.
I do most of these things, apart from “data sanity checks” because I usually dont send data from client to server. Neither do I do data-saving after devproduct purchases, I don’t see the use in that because they are meant to be a one time thing no? I have no idea what you mean by own data request queue though.
I mean throttling requests sent to a saving function. It helps to prevent overloading the server with requests.
--this example isn't modular
local saveInProgress = false
local queue = {}
local function save(saveKey: string, saveData: TypeForSaveData): (boolean, string?, number)
if saveInProgress then --save in progress so we need to wait until there isn't a save in progress
--compress the save data and add to queue
local compressed = {saveKey, saveData}
table.insert(queue, compressed)
repeat
task.wait(1)
until
--wait until there this request is at the front of the queue
not saveInProgress and table.find(queue, compressed) == 1
--remove the request from the queue
local index = table.find(queue, compressed)
table.remove(queue, index)
end
--now save the data
saveInProgress = true
local success, result
local attempt = 0
repeat
success, result = pcall(YourDataStore.UpdateAsync, YourDataStore, saveKey, function(old)
--checks and stuff
return saveData
end)
task.wait(2)
attempt += 1
until
success or attempt == 3
return success, result, attempt
end
--so now when we save:
local saveDataExample = {
["Money"] = 300
}
local key = "DataSaveKeyExample_"..Player.UserId
--this thread will yield until the data request has gone through. This is helpful to prevent overloading requests.
local success, result, attempt = save(key, saveDataExample)
--do as you will with these
print(success, result, attempt)
If you have DevProducts that give you in-game currency, you’re also going to want to make sure that data is saved after purchase, just in case.
But can’t you just save the data as normal (for devproducts)? Like when the player leaves you can just save the player.leaderstats.Coins.Value and it should work.
UpdateAsync reads before it writes, where as SetAsync just overwrites regardless. This is extremely useful for data comparisons to help prevent data loss, for example using versioning so an outdated data entry doesn’t overwrite a more up-to-date one, which can sometimes happen in specific circumstances (versioning saved outdated data from being written in my game today actually). It also has other uses, and generally has better safety features alone without this extra comparison code.
On the small chance your retry logic fails all attempts when the player leaves, it’s good to try and save when they buy it anyway, especially if it’s a large purchase. It’s up to you, but I’d recommend doing it.
Yeah I saw on the documentation, one downside is that its slower than setasync however. By the way what is versioning? I keep hearing this word thrown around in the community, just like I didn’t know what benchmarking was. Until I realised it was to check the performance of your code, scripters act too fancy I swear.
UpdateAsync is slower, but it’s generally better. Always use SetAsync for things like leaderboards, though- they only need to mimic data.
All versioning means in terms of data is adding a number to each data save. This number increases each save, and by comparing the save number in the new save data and the old save data you can determine whether the new save data that you are trying to save is outdated or not.
--when the player joins...
local function onJoin(player: Player)
local version = Instance.new("IntValue", player)
version.Name = "DataVersion"
version.Value = 0
--get data...
local success, data = pcall(dataStore.GetAsync, dataStore)
--unpack data
if success then
if not data then
--assign default data...
end
--add the version
version.Value = data.DataVersion + 1 --you will need to increment each autosave
--other data gets unpacked
else
warn(data)
player:Kick("Data loading failed, please rejoin!")
end
end
--and then when saving...
local function save(player: Player)
local saveKey = "DataKeyExamplePrefix_"..player.UserId
local dataToSave = {
["Money"] = player.leaderstats.Money.Value,
["DataVersion"] = player.DataVersion.Value
}
local success, result
local attempt = 0
repeat
success, result = pcall(dataStore.UpdateAsync, dataStore, saveKey, function(old)
if old and data.DataVersion <= old.DataVersion then
warn("Rejected attempt to save outdated data. Key = "..saveKey)
return nil --returning nil cancels the update
end
--do other checks
return data
end)
attempt += 1
task.wait(2)
until
success or attempt == 3
end
The colon operator just passes the item itself as a parameter, so it has the same effect as passing it manually. It just means you don’t have to create an anonymous function.
DataStore.GetAsync(DataStore, key, options)
--is the same as
DataStore:GetAsync(key, options)
Yeah, you can also use it for tables too, and since a data store includes a table of functions…
you just index it and pass the parameter manually instead of just indexing and passing the parameter automatically.
Cool, probably won’t do it though because it’d mess with my head. But what about session-locking? People talk about this all the time, even with all the things you have said. Would session-locking be a huge issue?
And why doesn’t roblox just make built-in session locking for datastoreservice, it’s kinda unfair that we have to.