I don’t like using ValueBase
instances, but they’re not too bad here since you don’t mind players seeing everyone else’s inventory. It’d make sense to keep the inventories inside a folder for them contained in game.ReplicatedStorage
.
Make sure you’re never firing a RemoteFunction
from server to client under any circumstances whatsoever, it is insecue. From client to server is fine, though.
Here’s major mistakes to be careful of when dealing with player data management:
- Don’t save or load guest data. Guests still exist!
-
DataStore
calls can error. Call them in protected mode.
- Make sure to check the
DataStore
request budget before making a DataStore
call unless you’re sure that throttling won’t affect your game logic.
- Players may join a server with a newer data schema, play, then join a server with an older schema version afterwards. Make sure that you do not erase the data from the newer schema.
- Do not save data if the loading data call failed!
- Be careful of the evil and improperly implemented
DataStore
cache. See buildthomas’ guide.
- Make sure to attempt to save everyone’s data when the game is going to shutdown if possible, using
game:BindToClose()
.
- Don’t save data in your data loading logic when the player has no data saved.
- Autosave everyone’s data every couple of minutes. This is very important.
I use a lock system now. It’s not perfect, but it seems to work well and I haven’t encountered any issues with it. In short, when loading a player’s data, if their data is “locked”, I’d wait some amount of time before ignoring the lock and proceeding anyway. This would help in cases where the player is rejoining the game, to give time for the last server they were in to save their data. I think my solution is pretty robust, and haven’t encountered any complete data loss yet.
I also use DataStore:UpdateAsync()
calls to load data because I can avoid the cache that way, even if it takes up more of my write budget. Per TheGamer101, DataStore:UpdateAsync()
will only cache-bust if you modify the data it returns, which is why I store last access information.
local player_data_preparer = {}
local services = {
server_script = game:GetService("ServerScriptService"),
}
local server_import_root = services.server_script.server_code
local imports = {
server = {
player_informant = require(server_import_root.player_informant),
transient_player_data = require(server_import_root.transient_player_data),
},
}
local function attempt_load_persistence(player)
local success = false
local lock_success = imports.server.transient_player_data.lock_unlocked_persistence(player)
if lock_success == false then
return success
end
local persistence_fetch_success = imports.server.transient_player_data.load_persistence(player)
if persistence_fetch_success == false then
return success
end
if imports.server.player_informant.is_in_game(player) == false then
return success
end
success = true
return success
end
function player_data_preparer.prepare(player)
local success = false
imports.server.transient_player_data.create(player)
if imports.server.player_informant.is_guest(player) == false then
success = attempt_load_persistence(player)
end
imports.server.transient_player_data.export(player)
return success
end
return player_data_preparer
local transient_player_data = {}
local services = {
data_store = game:GetService("DataStoreService"),
replicated_storage = game:GetService("ReplicatedStorage"),
server_script = game:GetService("ServerScriptService"),
}
local core_import_root = services.replicated_storage.core_code
local server_import_root = services.server_script.server_code
local imports = {
core = {
broadcaster = require(core_import_root.broadcaster),
table_utility = require(core_import_root.table_utility),
},
server = {
broadcaster_channels = require(server_import_root.broadcaster_channels),
player_informant = require(server_import_root.player_informant),
player_readiness = require(server_import_root.player_readiness),
},
}
local configuration = {}
configuration.schema_version = 3
configuration.data_schema = {
metadata = {
last_access = {
global_timestamp = 0,
local_timestamp = 0,
game_job_identifier = "",
},
schema_version = configuration.schema_version,
},
data = {
inventory = {
backpack = {
normal_sword = 50,
},
equipment = {},
},
},
}
configuration.persistence_lock_bypass_delay = 6
configuration.data_store_same_key_write_delay = 6
local player_data = {}
local player_persistence_load_successes = {}
local data_stores = {
persistence = services.data_store:GetDataStore("player_persistence"),
persistence_lock = services.data_store:GetDataStore("player_persistence_lock"),
}
local broadcast_channels = imports.server.broadcaster_channels.get_enumerated_names()
local function make_new_data()
return imports.core.table_utility.get_deep_copy(configuration.data_schema)
end
local function make_last_access_data()
return {
global_timestamp = os.time(),
local_timestamp = tick(),
game_job_identifier = game.JobId,
}
end
local function persistence_load_updater(persistence)
if persistence == nil then
persistence = make_new_data()
end
persistence.metadata.last_access_data = make_last_access_data()
return persistence
end
local function get_persistence(player)
return data_stores.persistence:UpdateAsync(player.UserId, persistence_load_updater)
end
local function merge_in_persistence(player, persistence)
local is_persistence_schema_newer = persistence.metadata.schema_version > configuration.schema_version
local merge_options = {
source_overwriting = is_persistence_schema_newer,
}
local transient_data_of_player = player_data[player.UserId]
local merged_data = imports.core.table_utility.get_deep_merge(transient_data_of_player.data, persistence.data, merge_options)
transient_data_of_player.data = merged_data
end
local function set_persistence(player, data)
local success = false
local function persistence_updater()
return data
end
local function update_persistence()
data_stores.persistence:UpdateAsync(player.UserId, persistence_updater)
end
local update_success = pcall(update_persistence)
success = update_success
return success
end
local function activate_unactivated_lock(player)
local success = false
local function lock_container_activator(lock_container)
local lock_container = {
enabled = true,
}
lock_container.last_access_data = make_last_access_data()
return lock_container
end
local function activate_lock()
data_stores.persistence_lock:UpdateAsync(player.UserId, lock_container_activator)
end
local lock_activator_success = pcall(activate_lock)
if lock_activator_success == false then
return success
end
if imports.server.player_informant.is_in_game(player) == false then
return success
end
success = true
return success
end
local function deactivate_lock(player)
local success = false
local function lock_container_deactivator(lock_container)
lock_container.enabled = false
lock_container.last_access_data = make_last_access_data()
return lock_container
end
local function lock_deactivator()
data_stores.persistence_lock:UpdateAsync(player.UserId, lock_container_deactivator)
end
success = pcall(lock_deactivator)
return success
end
function transient_player_data.is_created(player)
return player_data[player.UserId] ~= nil
end
function transient_player_data.create(player)
player_data[player.UserId] = make_new_data()
end
function transient_player_data.lock_unlocked_persistence(player)
local success = false
if services.data_store:GetRequestBudgetForRequestType(Enum.DataStoreRequestType.UpdateAsync) == 0 then
return success
end
local last_lock = nil
local function lock_container_updater(lock_container)
if lock_container == nil then
last_lock = false
lock_container = {
enabled = true,
}
else
last_lock = lock_container.enabled
if lock_container.enabled == false then
lock_container.enabled = true
end
end
lock_container.last_access_data = make_last_access_data()
return lock_container
end
local function update_lock_container()
data_stores.persistence_lock:UpdateAsync(player.UserId, lock_container_updater)
end
local lock_update_success = pcall(update_lock_container)
if lock_update_success == false then
return success
end
if imports.server.player_informant.is_in_game(player) == false then
return success
end
if last_lock == false then
success = true
return success
end
local lock_activation_delay = math.max(configuration.data_store_same_key_write_delay, configuration.persistence_lock_bypass_delay)
wait(lock_activation_delay)
if imports.server.player_informant.is_in_game(player) == false then
return success
end
if services.data_store:GetRequestBudgetForRequestType(Enum.DataStoreRequestType.UpdateAsync) == 0 then
return success
end
local lock_activate_success = activate_unactivated_lock(player)
success = lock_activate_success
return success
end
function transient_player_data.load_persistence(player)
local success, persistence = pcall(get_persistence, player)
if success == false then
return success
end
merge_in_persistence(player, persistence)
player_persistence_load_successes[player.UserId] = success
return success
end
function transient_player_data.export(player)
imports.server.player_readiness.set_readiness(player, true)
local transient_data_of_player = player_data[player.UserId]
imports.core.broadcaster.send(broadcast_channels.player_data_loaded, player, transient_data_of_player)
end
function transient_player_data.is_persistable(player)
local persistability = false
if player_persistence_load_successes[player.UserId] ~= true then
return persistability
end
local readiness = imports.server.player_readiness.get_readiness(player)
persistability = readiness
return persistability
end
function transient_player_data.save_to_persistence(player)
local success = false
if services.data_store:GetRequestBudgetForRequestType(Enum.DataStoreRequestType.UpdateAsync) == 0 then
return success
end
local transient_data_of_player = player_data[player.UserId]
local set_persistence_succeess = set_persistence(player, transient_data_of_player)
success = set_persistence_succeess
return success
end
function transient_player_data.unlock_persistence(player)
local success = false
if services.data_store:GetRequestBudgetForRequestType(Enum.DataStoreRequestType.UpdateAsync) == 0 then
return success
end
success = deactivate_lock(player)
return success
end
function transient_player_data.destroy(player)
imports.server.player_readiness.set_readiness(player, nil)
imports.core.broadcaster.send(broadcast_channels.player_data_destroying, player)
player_data[player.UserId] = nil
player_persistence_load_successes[player.UserId] = nil
end
return transient_player_data
local player_readiness = {}
local ready_players = {}
function player_readiness.get_readiness(player)
return ready_players[player] == true
end
function player_readiness.set_readiness(player, readiness)
ready_players[player] = readiness
end
return player_readiness
local player_informant = {}
local services = {
run = game:GetService("RunService"),
}
function player_informant.is_guest(player)
local guest_state = false
if services.run:IsStudio() == true then
return guest_state
end
if player.UserId < 1 then
guest_state = true
end
return guest_state
end
function player_informant.is_in_game(player)
return player.Parent ~= nil
end
return player_informant