-- Placement: ServerScriptService > PracticeBest (Script) -- ONLY in a separate, disposable test experience (a different universe). -- Educational monotonic best-count example, not a production player-data system. local Players = game:GetService("Players") local DataStoreService = game:GetService("DataStoreService") local store = DataStoreService:GetDataStore("TutorialPracticeBest_v1") local sessions = {} local closing = false local MAX_COUNT = 1000000 local COUNT_INTERVAL = 5 local SAVE_INTERVAL = 60 local function validCount(value) return typeof(value) == "number" and value == value and value >= 0 and value <= MAX_COUNT and value % 1 == 0 end local function decode(value) if value == nil then return true, 0 -- A successfully read missing key is a new player. end if typeof(value) == "table" and value.version == 1 and validCount(value.best) then return true, value.best end return false, nil -- Unexpected data must never be replaced with defaults. end local function request(operation, needsWrite) local lastError = "No request budget available" for attempt = 1, 3 do local reads = DataStoreService:GetRequestBudgetForRequestType( Enum.DataStoreRequestType.StandardRead ) local writes = DataStoreService:GetRequestBudgetForRequestType( Enum.DataStoreRequestType.StandardWrite ) if reads >= 1 and (not needsWrite or writes >= 1) then local ok, result = pcall(operation) if ok then return true, result end lastError = tostring(result) -- Setup errors are not transient. Fix setup before another test. if string.find(lastError, "403", 1, true) or string.find(lastError, "StudioAccessToApisNotAllowed", 1, true) then break end end if attempt < 3 then task.wait(2 ^ (attempt - 1) + math.random() * 0.5) end end return false, lastError end local function save(player, state) -- Never write if loading failed; never overlap this session's writes. if not state.loaded or state.saving or state.count <= state.savedBest then return end state.saving = true local candidate = state.count local rejected = false local ok, result = request(function() return store:UpdateAsync("user_" .. player.UserId, function(old) local valid, oldBest = decode(old) if not valid then rejected = true return nil end -- Do not yield or award gameplay rewards here. Roblox may rerun this callback. return { version = 1, best = math.max(oldBest, candidate) } end) end, true) if ok and not rejected and typeof(result) == "table" and validCount(result.best) then state.savedBest = math.max(state.savedBest, result.best) player:SetAttribute("PracticeBest", state.savedBest) player:SetAttribute("PracticeDataStatus", "Saved") print("[PracticeBest] saved", player.UserId, state.savedBest) else player:SetAttribute("PracticeDataStatus", "Save unconfirmed; will retry later") warn("[PracticeBest] save unconfirmed", player.UserId, rejected and "Unexpected stored schema" or tostring(result)) end state.saving = false end local function join(player) if sessions[player] or closing then return end local state = { loaded = false, saving = false, count = 0, savedBest = 0 } sessions[player] = state player:SetAttribute("PracticeCount", 0) player:SetAttribute("PracticeDataStatus", "Loading") local ok, value = request(function() return store:GetAsync("user_" .. player.UserId) end, false) if sessions[player] ~= state or player.Parent ~= Players or closing then return end local valid, best = false, nil if ok then valid, best = decode(value) end if not ok or not valid then player:SetAttribute("PracticeDataStatus", "Load failed; saving disabled") warn("[PracticeBest] load failed; no writes for this session", player.UserId, tostring(value)) return end state.loaded = true state.savedBest = best player:SetAttribute("PracticeBest", best) player:SetAttribute("PracticeDataStatus", "Loaded") task.spawn(function() while sessions[player] == state and not state.leaving and not closing do task.wait(COUNT_INTERVAL) if sessions[player] ~= state or state.leaving or closing then break end state.count = math.min(state.count + 1, MAX_COUNT) player:SetAttribute("PracticeCount", state.count) end end) task.spawn(function() task.wait(math.random(0, 10)) while sessions[player] == state and not state.leaving and not closing do task.wait(SAVE_INTERVAL) if sessions[player] ~= state or state.leaving or closing then break end save(player, state) end end) end local function leave(player) local state = sessions[player] if not state then return end -- Stop counting first. A pending save may have an older candidate. state.leaving = true while state.saving do task.wait(0.1) end save(player, state) sessions[player] = nil end Players.PlayerAdded:Connect(join) Players.PlayerRemoving:Connect(leave) for _, player in Players:GetPlayers() do task.spawn(join, player) end game:BindToClose(function() closing = true local pending = 0 for player, state in pairs(sessions) do pending += 1 task.spawn(function() while state.saving do task.wait(0.1) end save(player, state) pending -= 1 end) end local deadline = os.clock() + 25 while pending > 0 and os.clock() < deadline do task.wait(0.1) end -- Shutdown saving is best effort, never a guarantee after a crash. end)