Hey guys, I just finished working on a modular DataManagement system for my Roblox game, and was wondering if you guys would be down to review my code? This is my first time writing a system like this and I really enjoyed it but please remember that while reviewing my code! I will post the features down below, and then ill post the main module, and then each module after that is a child of the main one!
Features
-
Session Lock Management:
- Prevents concurrent modifications of player data by using GUIDS.
- Verifies the validity of session locks upon every save to ensure data integrity.
- Automatically handles expired or invalid session locks.
- Customizable expiry time for session locks, ensuring old sessions are automatically cleared.
-
Save Slot Management:
- Supports multiple save slots for each player.
- Allows developers to specify which save slot to load or save data to.
- Handles loading and saving data asynchronously with error handling.
-
Optimized Auto Save system:
- Fully customizable.
- Saves every players data every x seconds only if data has been updated.
- Settings allows you to specify certain keys to skip for efficiency e.g an inventory with hundreds of items.
- Force save every x amount of cycles.
-
Automatic Default Data Fallback:
- If no data is found for a player’s save slot, a fully customizable default data table is returned.
-
Dedicated Getter, Setter, and Checker functions:
- By using these dedicated functions, security is maximized and efficiency is prioritized.
-
Dedicated functions for player inventory management:
-
Functions such as item lookups, adding and removing, clearing etc.
-
Data validation and error handling:
- All incoming data is properly checked.
- Errors are handled gracefully
- Many functions will return specific values
-
Debug Mode:
- Option to enable debug messages for developers to monitor the system’s operations.
Main module:
local PlayerData = {}
PlayerData.__index = PlayerData
--//Modules
local DataStoreHandle = require(script.DataStoreHandle)
local Settings = require(script.PlayerDataSettings)
--//Variables
local DebugMode = Settings.DebugMode
local AutoSaveInterval = Settings.AutoSaveInterval
local PeriodicSaveInterval = Settings.PeriodicSaveInterval
local SaveCycleCount = 0
--//Tables
PlayerData.DataTable = {}
local PreviousTable = {}
local skipKeys = Settings.skipKeys
function PlayerData.new(PlayerInfo, ChosenSlot)
local self = setmetatable(PlayerInfo or {}, PlayerData)
--//Core Values, removing these will break everything if they are not defined in the default table!
self.UserId = self.UserId
self.SlotNum = ChosenSlot
self.Inventory = self.Inventory or {}
self.GiftBank = self.GiftBank or {}
return self
end
local function CompareTables(CurrentData, PreviousData)
for key, Current in pairs(CurrentData) do
local Previous = PreviousData[key]
if typeof(Current) == "table" and typeof(Previous) == "table" then
if CompareTables(Current, Previous) then
return true
end
continue
end
if Current ~= Previous then
return true
end
end
for key, _ in pairs(PreviousData) do
if CurrentData[key] == nil then
return true
end
end
for key, _ in pairs(CurrentData) do
if PreviousData[key] == nil then
return true
end
end
return false
end
local function DeepCopy(original)
if type(original) ~= "table" then
if DebugMode then warn("DeepCopy called with invalid input: expected table, got", type(original)) end
return nil
end
local copy = {}
for key, value in next, original do
if skipKeys and skipKeys[key] then
if DebugMode then print("Skipping key: " .. tostring(key)) end
else
if type(value) == "table" then
copy[key] = DeepCopy(value)
else
copy[key] = value
end
end
end
return copy
end
--//Autosaving
local function AutoSaveData()
while task.wait(AutoSaveInterval) do
SaveCycleCount = SaveCycleCount + 1
local Players = game:GetService("Players"):GetPlayers()
for _, Player in pairs(Players) do
local FindCurrent = PlayerData.DataTable[Player.UserId]
local Current = DeepCopy(FindCurrent)
local Previous = PreviousTable[Player.UserId]
if SaveCycleCount == PeriodicSaveInterval then
local TableCopy = DeepCopy(Current)
PreviousTable[Player.UserId] = TableCopy
if DebugMode then print("Force Saving data for:".." "..Player.Name) end
FindCurrent:SavePlayerSlot(Player)
SaveCycleCount = 0
continue
end
local HasChanged = CompareTables(Current, Previous)
if HasChanged == false then
if DebugMode then
print("No data change has been detected for:".." "..Player.Name.."Skipping AutoSave")
print(Current)
print(Previous)
print("CycleCount".." "..SaveCycleCount)
end
else
local TableCopy = DeepCopy(Current)
PreviousTable[Player.UserId] = TableCopy
if DebugMode then
print("Data change detetected for:".." "..Player.Name.."Saving data")
print("CycleCount".." "..SaveCycleCount)
end
FindCurrent:SavePlayerSlot(Player)
end
end
end
end
--//Data handling
function PlayerData.RetrieveData(Player)
if not Player then return end
return PlayerData.DataTable[Player.UserId]
end
function PlayerData:SavePlayerSlot(Player)
local succcess = DataStoreHandle.SaveDataToSlot(Player, self, self.SlotNum)
if not succcess then
if DebugMode then warn("Failed to Save Slot for:"..Player.Name) end
end
end
function PlayerData.LoadChosenSlot(Player, ChosenSlot)
if not Player or not ChosenSlot then
if DebugMode then
print("Error: Missing required arguments - Player or ChosenSlot.")
end
return
end
if ChosenSlot then
if Settings.SingleSaveSlot == true then
ChosenSlot = "Slot1"
end
local Data = DataStoreHandle.LoadSaveSlot(Player, ChosenSlot)
local DataObject = PlayerData.new(Data, ChosenSlot)
DataObject:AddPlayerData(Player)
return Data
end
end
function PlayerData.StartSession(Player)
local success = DataStoreHandle.EngageSessionLock(Player)
if not success then
-- Optionally, handle fallback logic (e.g., retry or notify player)
if DebugMode then Player:Kick("Unfortunately an error occured when attempting to initialize your session, please rejoin or try again later") end
end
end
function PlayerData.EndSession(Player)
local RetreivedData = PlayerData.RetrieveData(Player)
RetreivedData:RemovePlayerData(Player)
RetreivedData:SavePlayerSlot(Player)
local attempts = 3 -- Max attempts
local retryDelay = 5 -- Delay, totaling 15 seconds
for attempt = 1, attempts do
local success, errorMessage = pcall(function()
return DataStoreHandle.RemoveSessionLock(Player)
end)
if success then
return -- All clear
else
if DebugMode then warn("Failed to remove session lock for player: " .. Player.Name .. " - Attempt " .. attempt .. " - " .. errorMessage) end
if attempt < attempts then
task.wait(retryDelay) -- Wait before retrying
else
-- If all attempts fail, notify the player and handle the failure
if DebugMode then warn("All 3 retry attempts failed to remove session lock for: " .. Player.Name) end
end
end
end
end
--//Adding and Removing methods for player data
function PlayerData:AddPlayerData(Player)
self.UserId = Player.UserId
if not PlayerData.DataTable[self.UserId] then
PlayerData.DataTable[self.UserId] = self
local DeepCopy = DeepCopy(self)
PreviousTable[self.UserId] = DeepCopy
if DebugMode then print("Player data added for: " .. Player.Name) end
else
if DebugMode then print("Player data already exists for: " .. Player.Name) end
end
end
function PlayerData:RemovePlayerData(Player)
if PlayerData.DataTable[self.UserId] then
PlayerData.DataTable[self.UserId] = nil
PreviousTable[self.UserId] = nil
if DebugMode then print("Player data removed for:"..Player.Name) end
else
if DebugMode then print("No player data found for: " .. Player.Name) end
end
end
--//Setters and Getters for non inventory data
function PlayerData:GetData(Attribute, Key)
if self[Attribute] then
if typeof(self[Attribute]) == "table" then
return self[Attribute][Key] -- Get table data
else
return self[Attribute] -- Get simple data
end
else
if DebugMode then print("Error: Invalid attribute") end
return nil
end
end
function PlayerData:SetData(Attribute, Key, Value)
if self[Attribute] then
if typeof(self[Attribute]) == "table" then
self[Attribute][Key] = Value -- Set table data
else
self[Attribute] = Key -- Set simple data
end
else
if DebugMode then print("Error: Invalid attribute") end
end
end
function PlayerData:IncrementData(Attribute, Key, Value)
local FetchedAttribute = self:GetData(Attribute, Key)
if typeof(FetchedAttribute) ~= "number" then
if DebugMode then warn("Error: Attempting to increment a non-numeric value") end
return
end
if FetchedAttribute then
if typeof(self[Attribute]) == "table" then
local NewValue = FetchedAttribute + Value
self[Attribute][Key] = NewValue -- Set table data
else
local NewValue = FetchedAttribute + Key
self[Attribute] = NewValue -- Set simple data
end
else
if DebugMode then print("Error: Invalid attribute") end
end
end
function PlayerData:DecrementData(Attribute, Key, Value)
local FetchedAttribute = self:GetData(Attribute, Key) or 0
if FetchedAttribute then
if typeof(FetchedAttribute) ~= "number" then
if DebugMode then warn("Error: Attempting to decrement a non-numeric value") end
return
end
local NewValue = FetchedAttribute - Value
if NewValue < 0 and not Settings.AllowNegativeValues then
if DebugMode then warn("Update aborted: AllowNegativeValues has been set to false") end
return
end
if NewValue >= 0 then
if typeof(self[Attribute]) == "table" then
self[Attribute][Key] = NewValue -- Set table data
else
self[Attribute] = NewValue -- Set simple data
end
else
if DebugMode then print("Value is negative, update aborted") end
end
else
if DebugMode then print("Error: Invalid attribute") end
end
end
--//Inventory methods
function PlayerData:AddItemToInventory(Item, Ammount)
if typeof(Item) == "table" then
local found = false
for _, Object in pairs(self.Inventory) do
if typeof(Object) == "table" and typeof(Ammount) == "number" then
if Object.Name == Item.Name then
Object.Quantity = Object.Quantity + Ammount
found = true
if DebugMode then print(Item.Name.." ".."already exists, adding".." "..Ammount.." ".."to Quantity") end
break
end
else
if DebugMode then print("Invalid arguments, check your code!") end
end
end
if found == false then
table.insert(self.Inventory, Item)
if DebugMode then print(Item.Name.." ".."not found, adding to table") end
end
elseif typeof(Item) == "string" then
local found = false
for _, Object in pairs(self.Inventory) do
if Object.Name == Item then
Object.Quantity = Object.Quantity + Ammount
if DebugMode then print(Item.." ".."has been found, adding".." "..Ammount.." ".."to table") end
found = true
break
end
end
if found == false then
if DebugMode then print(Item.." ".."not found".." ".."Returning false") end
return false
end
end
end
function PlayerData:RemoveItemFromInventory(Item, Ammount)
if typeof(Item) == "table" then
local found = false
for index, Object in pairs(self.Inventory) do
if typeof(Object) == "table" then
if Object.Name == Item.Name then
Object.Quantity = Object.Quantity - Ammount
if DebugMode then print("Removed:".." "..Ammount.."From".." "..Item.Name) end
if Object.Quantity <= 0 then
if DebugMode then print(Item.Name.." ".."Quantity is or has fallen below 0".." ".."Removing item from table") end
table.remove(self.Inventory, index)
end
found = true
break
end
end
end
if not found then
if DebugMode then print(Item.Name.." ".."not found, returning false") end
return false
end
elseif typeof(Item) == "string" then
local found = false
for index, Object in pairs(self.Inventory) do
if Object.Name == Item then
Object.Quantity = Object.Quantity - Ammount
if DebugMode then print("Removed:".." "..Ammount.."From".." "..Item) end
if Object.Quantity <= 0 then
table.remove(self.Inventory, index)
end
found = true
break
end
end
if not found then
if DebugMode then print(Item.." ".."not found".." ".."Returning false") end
return false
end
end
end
function PlayerData:EditInventoryItem(Item, Key, Value)
if typeof(Item) ~= "string" then return end
for index, Object in pairs(self.Inventory) do
if typeof(Object) ~= "table" then return end
if Object.Name == Item then
Object[Key] = Value
return Object[Key]
end
end
return false
end
function PlayerData:LookUpInventoryItem(Item,Key)
if typeof(Item) == "string" then
for index, Object in pairs(self.Inventory) do
if typeof(Object) ~= "table" then return end
if Object.Name == Item then
if Key then
return Object[Key]
else
return Object
end
end
end
if DebugMode then print("Could not find:".." "..Item) end
return false
else
if DebugMode then print("Invalid argument detected, must be a string value") end
return false
end
end
function PlayerData:ClearInventory(Player)
if #self.Inventory <= 0 then
if DebugMode then print("Inventory is already empty") end
else
table.clear(self.Inventory)
if DebugMode then print("Inventory cleared for player:", Player.Name or "Unknown") end
end
end
spawn(function()
AutoSaveData()
end)
return PlayerData
DataStoreHandle
local DataStoreHandle = {}
--//Services
local DataStoreService = game:GetService("DataStoreService")
local ServerStorage = game:GetService("ServerStorage")
local HttpService = game:GetService("HttpService")
--//Modules
local Settings = require(script.Parent.PlayerDataSettings)
--//Variables
local ExpiryInterval = Settings.ExpiryTime
local DebugMode = Settings.DebugMode
--//Tables
local ActiveSessionLocks = {}
local DefaultTable = Settings.DefaultData
local function GenerateSessionGUID()
local GUID = HttpService:GenerateGUID(false)
local ExpiryTime = os.time() + ExpiryInterval
table.insert(ActiveSessionLocks, GUID)
return GUID, ExpiryTime
end
local function VerifySessionLock(Player)
local SessionLockkey = "SessionLock_"..Player.UserId
local PlayerDataStore = DataStoreService:GetDataStore("PlayerData")
local ExistingLock = PlayerDataStore:GetAsync(SessionLockkey)
if not ExistingLock then
return "Nil"
end
local PlayerId, sessionGUID, expiryTime = ExistingLock:match("^(%d+)_(%S+)_(%d+)$")
expiryTime = tonumber(expiryTime)
if os.time() > expiryTime then
DataStoreHandle.RemoveSessionLock(Player)
return "Expired" -- Lock expired
end
if PlayerId == tostring(Player.UserId) then
local CurrentLock = table.find(ActiveSessionLocks, sessionGUID)
if CurrentLock then
if DebugMode then print("Session lock valid for player: ".." ".. Player.Name) end
return "Valid" -- Valid session lock
else
return "Invalid" -- Invalid session lock
end
else
if DebugMode then warn("PlayerId not found for:".." ".. Player.Name) end
return "Invalid" -- Invalid session lock
end
end
local function RetrieveSaveSlot(Player, SaveSlot)
local ChosenSlot = "PlayerData_"..Player.UserId.."_"..SaveSlot
local PlayerDataStore = DataStoreService:GetDataStore("PlayerData")
if PlayerDataStore then
if DebugMode then print("Successfully retrieved the data store for " .. ChosenSlot) end
else
if DebugMode then warn("Failed to retrieve the data store for"..Player.Name..SaveSlot) end
return false
end
return PlayerDataStore, ChosenSlot
end
function DataStoreHandle.RemoveSessionLock(Player)
local SessionLockkey = "SessionLock_"..Player.UserId
local PlayerDataStore = DataStoreService:GetDataStore("PlayerData")
local ExistingLock = PlayerDataStore:GetAsync(SessionLockkey)
if ExistingLock then
local PlayerId, sessionGUID, expiryTime = ExistingLock:match("^(%d+)_(%S+)_(%d+)$")
-- Remove the session GUID from the ActiveSessionLocks table
for i, lock in ipairs(ActiveSessionLocks) do
if lock == sessionGUID then
table.remove(ActiveSessionLocks, i)
break
end
end
-- Remove the session lock from the DataStore
local success, errorMessage = pcall(function()
PlayerDataStore:RemoveAsync(SessionLockkey)
end)
if success then
if DebugMode then print("Session lock successfully removed for player: " .. Player.Name) end
return true
else
if DebugMode then warn("Failed to remove session lock for player: " .. Player.Name .. " - " .. errorMessage) end
return false
end
else
if DebugMode then warn("No session lock found for player: " .. Player.Name) end
return false
end
end
function DataStoreHandle.EngageSessionLock(Player)
local SessionLockkey = "SessionLock_"..Player.UserId
local PlayerDataStore = DataStoreService:GetDataStore("PlayerData")
local ExistingLock = PlayerDataStore:GetAsync(SessionLockkey)
if ExistingLock then
if DebugMode then print("Session lock already exists for player: " .. Player.Name) end
return -- Session lock already exists
end
local SessionGUID, ExpiryTime = GenerateSessionGUID()
local SessionLockData = Player.UserId.."_"..SessionGUID.."_"..ExpiryTime
local success, errorMessage = pcall(function()
PlayerDataStore:SetAsync(SessionLockkey, SessionLockData)
end)
if success then
if DebugMode then print("Session lock created and saved for player: " .. Player.Name) end
return true
else
if DebugMode then warn("Failed to save session lock for player: " .. Player.Name .. " - " .. errorMessage) end
return false
end
end
function DataStoreHandle.InvalidSession(Player, SessionLockStatus)
if SessionLockStatus == "Invalid" then
--Lock is corrupted, we will take immediate action
if DebugMode then warn("Corrupted Session Detected for:"..Player.Name) end
Player:Kick("Corrupted Session detected, please rejoin")
DataStoreHandle.RemoveSessionLock(Player)
elseif SessionLockStatus == "Nil" then
--[[Lock not found, could be a number of issues such as script error,
timing error, or exploiter. In this case, we will deny the data request--]]
if DebugMode then warn("SessionLock not found for:"..Player.Name.."Data Request Denied") end
return false
elseif SessionLockStatus == "Expired" then
--[[Lock has expired due to a number of reasons such as prolonged session,
server crash etc. In thise case, we will simply create a new lock--]]
if DebugMode then warn("SessionLock has expired for:"..Player.Name.."Engaging new Lock") end
DataStoreHandle.EngageSessionLock()
end
end
function DataStoreHandle.SaveDataToSlot(Player, Data, SaveSlot)
local PlayerDataStore, ChosenSlot = RetrieveSaveSlot(Player, SaveSlot)
local SessionLockStatus = VerifySessionLock(Player)
if SessionLockStatus == "Valid" then
local success, errorMessage = pcall(function()
PlayerDataStore:SetAsync(ChosenSlot, Data)
end)
if success then
if DebugMode then print("Successfully saved"..SaveSlot) end
return true
else
if DebugMode then warn("Could not save"..SaveSlot) end
return false
end
else
DataStoreHandle.InvalidSession(Player, SessionLockStatus)
end
end
function DataStoreHandle.LoadSaveSlot(Player, SaveSlot)
local PlayerDataStore, ChosenSlot = RetrieveSaveSlot(Player, SaveSlot)
local SessionLockStatus = VerifySessionLock(Player)
if SessionLockStatus == "Valid" then
local success, data
--//Load attempt
success, data = pcall(function()
return PlayerDataStore:GetAsync(ChosenSlot)
end)
if success then
if data then
if DebugMode then print("Successfully loaded"..SaveSlot.."for"..Player.Name) end
return data
else
if DebugMode then print("Could not load"..SaveSlot.."for"..Player.Name.."Setting Default Data") end
return DefaultTable
end
else
if DebugMode then warn("Error when loading"..SaveSlot.."for"..Player.Name) end
return false
end
else
DataStoreHandle.InvalidSession(Player, SessionLockStatus)
end
end
return DataStoreHandle
Settings module
local PlayerDataSettings = {}
PlayerDataSettings.AutoSaveInterval = 30 --in seconds, how often the autosave system should save data? Remember, the lower the interval the more performane heavy
PlayerDataSettings.ExpiryTime = 1800 --In seconds e.g 1800 = 30 minutes
PlayerDataSettings.PeriodicSaveInterval = 4 -- in cycles of AutoSaveInterval e.g 10 == every 5 minutes
PlayerDataSettings.SingleSaveSlot = false -- If set to true, players will only be able to have one slot
PlayerDataSettings.AllowNegativeValues = true -- If false and a value is negative, it will not be updated
PlayerDataSettings.DebugMode = true --Prints and warns will be output to the console if enabled
--[[Keys added to this table will be skipped during each autosave cycle, meaning that they won't be checked
for upated values. The data still saves though. It is recommended that Inventory be skipped if your game allows
players to oobtain a large amount of items, or if items are complex and contain a lot of data - More info in documentation]]
PlayerDataSettings.skipKeys = {
Inventory = false
}
--[[Default table, add your values here!]]
PlayerDataSettings.DefaultData = {
Currency = 500,
Level = 1,
CurrentXP = 0,
XPtoNextLevel = 1000,
Inventory = {
{Name = "Starter Sword", ItemType = "Object", Quantity = 1},
{Name = "Bread", ItemType = "Object",Quantity = 5}
},
EquippedItems = {
["Weapon"] = nil,
["Head"] = nil,
["Torso"] = nil,
["Legs"] = nil,
["Boots"] = nil,
["Neck"] = nil,
["Ring1"] = nil,
["Ring2"] = nil,
},
Reputation = {
["Nordraxia"] = 1
}
}
return PlayerDataSettings