Roblox DataStores are persistent key-value databases hosted by Roblox. Every Roblox experience gets free, always-available storage — no external DB needed. The service follows a simple dictionary model: you store and retrieve values by string keys scoped to a named DataStore.
Key-value model: Each key maps to a single value (which can be a table). Think of it like a Redis or DynamoDB table: fast random access, no joins, no queries beyond exact key lookup.
Best practice: Use the player's UserId (a number, never changes) as the key, not Name (players can rename). Prefix keys to namespace: "Player_" .. userId.
local DataStoreService = game:GetService("DataStoreService") local PlayerStore = DataStoreService:GetDataStore("PlayerData") local DEFAULT_DATA = { Gold = 100, Level = 1, XP = 0, Inventory = {}, LastSave = os.time(), } local KEY_PREFIX = "Player_" local SavePlayerGold = function(player: Player, newGold: number) local key = KEY_PREFIX .. player.UserId -- UpdateAsync guarantees atomic read-modify-write: no lost updates local success, result = pcall(function() return PlayerStore:UpdateAsync(key, function(oldData) oldData = oldData or table.clone(DEFAULT_DATA) oldData.Gold = newGold oldData.LastSave = os.time() return oldData end) end) if not success then warn("Failed to save gold for", player, result) end return success end local LoadPlayerGold = function(player: Player): number local key = KEY_PREFIX .. player.UserId local success, data = pcall(function() return PlayerStore:GetAsync(key) end) if success and data then return data.Gold end return DEFAULT_DATA.Gold end -- Increment gold atomically (safe against double-spend) local AddGold = function(player: Player, amount: number): boolean local key = KEY_PREFIX .. player.UserId local success = pcall(function() PlayerStore:UpdateAsync(key, function(oldData) oldData = oldData or table.clone(DEFAULT_DATA) oldData.Gold = math.max(0, oldData.Gold + amount) oldData.LastSave = os.time() return oldData end) end) if not success then warn("AddGold failed for", player) end return success end
Roblox enforces hard limits on DataStore usage. Exceeding them throws errors, drops writes, and can get your experience throttled. Know the numbers before you ship.
-- Enumerate all player keys (admin panel) local ListAllPlayerKeys = function(store): {string} local keys = {} local cursor = nil local prefix = KEY_PREFIX repeat local success, result = pcall(function() return store:ListKeysAsync(prefix, cursor) end) if success then for _, keyInfo in result.Keys do table.insert(keys, keyInfo.KeyName) end cursor = result.Cursor else warn("ListKeysAsync failed:", result) break end until cursor == nil return keys end -- OrderedDataStore leaderboard: top 10 players local LeaderStore = DataStoreService:GetOrderedDataStore("Leaderboard") local SetScore = function(userId: number, score: number) local success, err = pcall(function() LeaderStore:SetAsync(tostring(userId), score) end) if not success then warn("Failed to set score:", err) end end local GetTopScores = function(count: number): {{userId: number, score: number}} local success, pages = pcall(function() return LeaderStore:GetSortedAsync(false, count) end) if success then return pages:GetCurrentPage() end return {} end -- Throttle-aware save: batch writes and enforce minimum intervals local ThrottledSave = function(playerStore, key, data) local throttleKey = "LastWrite_" .. key local now = tick() if (now - (_G[throttleKey] or 0)) < 1.0 then return false, "Throttled" end _G[throttleKey] = now return pcall(function() playerStore:SetAsync(key, data) end) end
DataStores store Lua tables natively, but relying on Roblox's internal serialization is risky — schema changes, corrupted values, or version incompatibilities can silently wreck data. Best practice: manually serialize to JSON strings with a version field for schema migration.
local HttpService = game:GetService("HttpService") local CURRENT_VERSION = 2 local DEFAULT_PLAYER_DATA = { Gold = 100, Level = 1, XP = 0, Inventory = {}, EquippedItems = {}, Settings = { MusicVolume = 0.5, SFXVolume = 0.8, ParticleQuality = "High", }, Stats = { GamesPlayed = 0, EnemiesDefeated = 0, TotalPlayTime = 0, }, Achievements = {}, DataVersion = CURRENT_VERSION, LastUpdated = "", } local SerializePlayerData = function(data: table): (boolean, string?) data.DataVersion = CURRENT_VERSION data.LastUpdated = DateTime.now():FormatUniversalTime("YYYY-MM-DDTHH:MM:SSZ") local success, json = pcall(function() return HttpService:JSONEncode(data) end) if not success then warn("JSON serialization failed:", json) return false, nil end if #json > 3_800_000 then warn("Data too large (3.8MB+), truncation risk!") end return true, json end local DeserializePlayerData = function(json: string): (boolean, table?) if type(json) ~= "string" or #json < 2 then return false, nil end local success, decoded = pcall(function() return HttpService:JSONDecode(json) end) if not success or type(decoded) ~= "table" then return false, nil end decoded = MigrateToCurrent(decoded) decoded = MergeDefaults(decoded) return true, decoded end local MigrateToCurrent = function(data: table): table local version = data.DataVersion or 0 if version < 1 then -- v0 → v1: add Settings sub-table data.Settings = data.Settings or table.clone(DEFAULT_PLAYER_DATA.Settings) version = 1 end if version < 2 then -- v1 → v2: add Stats sub-table, rename Score→Gold data.Gold = data.Score or data.Gold or 100 data.Score = nil data.Stats = data.Stats or table.clone(DEFAULT_PLAYER_DATA.Stats) version = 2 end data.DataVersion = CURRENT_VERSION return data end local MergeDefaults = function(data: table): table for k, v in DEFAULT_PLAYER_DATA do if data[k] == nil then data[k] = type(v) == "table" and table.clone(v) or v end end return data end
The standard lifecycle pattern is the most critical pattern in Roblox data persistence. Every player that joins must get their data loaded, and every player that leaves must have their data saved — no exceptions. Missing a save means lost progress. Missing a load means exploits.
-- DataLifecycleManager.luau — Complete lifecycle module local DataStoreService = game:GetService("DataStoreService") local Players = game:GetService("Players") local RunService = game:GetService("RunService") local DataLifecycle = {} DataLifecycle.__index = DataLifecycle local PlayerStore = DataStoreService:GetDataStore("PlayerData") local PlayerDataMap = {} -- [Player] = { Data, Dirty, Loaded, LoadTime } local SAVE_INTERVAL = 90 -- seconds between periodic saves local GetPlayerKey = function(userId: number): string return "Player_" .. userId end local LoadDataAsync = function(store, userId: number): (boolean, table?) local key = GetPlayerKey(userId) local success, json = pcall(function() return store:GetAsync(key) end) if not success then warn("Data load failed for", userId, json) return false, nil end if json == nil then return true, table.clone(DEFAULT_PLAYER_DATA) end local ok, decoded = DeserializePlayerData(json) if not ok then warn("Data deserialization failed for", userId) if RunService:IsStudio() then -- In studio, re-throw to see the error error("Corrupt data for " .. userId) end return false, nil end return true, decoded end local SaveDataAsync = function(store, userId: number, data: table): boolean local key = GetPlayerKey(userId) local ok, json = SerializePlayerData(data) if not ok then return false end local success, err = pcall(function() store:SetAsync(key, json) end) if not success then warn("Data save failed for", userId, err) end return success end local OnPlayerAdded = function(player: Player) local entry = { Data = nil, Loaded = false, Dirty = false, LoadTime = 0, SaveThread = nil, } PlayerDataMap[player] = entry local startTime = tick() local success, data = LoadDataAsync(PlayerStore, player.UserId) if not success then player:Kick("Failed to load data. Please rejoin.") PlayerDataMap[player] = nil return end entry.Data = data entry.Loaded = true entry.LoadTime = tick() - startTime ApplyDataToPlayer(player, data) entry.SaveThread = StartAutoSaveLoop(player, entry) end local ApplyDataToPlayer = function(player: Player, data: table) local leaderstats = Instance.new("Folder") leaderstats.Name = "leaderstats" leaderstats.Parent = player local gold = Instance.new("NumberValue") gold.Name = "Gold" gold.Value = data.Gold gold.Parent = leaderstats local level = Instance.new("NumberValue") level.Name = "Level" level.Value = data.Level level.Parent = leaderstats local xp = Instance.new("NumberValue") xp.Name = "XP" xp.Value = data.XP xp.Parent = leaderstats local goldChanged = gold:GetPropertyChangedSignal("Value"):Connect(function() data.Gold = gold.Value entry.Dirty = true end) local levelChanged = level:GetPropertyChangedSignal("Value"):Connect(function() data.Level = level.Value entry.Dirty = true end) end local OnPlayerRemoving = function(player: Player) local entry = PlayerDataMap[player] if not entry or not entry.Loaded then return end if entry.SaveThread then coroutine.close(entry.SaveThread) entry.SaveThread = nil end local success = SaveDataAsync(PlayerStore, player.UserId, entry.Data) if not success then -- Attempt backup save to a recovery key local backupKey = "Backup_" .. player.UserId .. "_" .. os.time() pcall(function() PlayerStore:SetAsync(backupKey, entry.Data) end) end PlayerDataMap[player] = nil end local OnShutdown = function() print("Server shutting down, saving all players...") for _, player in Players:GetPlayers() do OnPlayerRemoving(player) end end local StartAutoSaveLoop = function(player: Player, entry) return coroutine.wrap(function() while PlayerDataMap[player] do task.wait(SAVE_INTERVAL) if entry.Dirty and PlayerDataMap[player] then local ok = SaveDataAsync(PlayerStore, player.UserId, entry.Data) if ok then entry.Dirty = false end end end end)() end function DataLifecycle:Init() Players.PlayerAdded:Connect(OnPlayerAdded) Players.PlayerRemoving:Connect(OnPlayerRemoving) game:BindToClose(OnShutdown) end function DataLifecycle:GetPlayerData(player: Player): table? local entry = PlayerDataMap[player] return entry and entry.Data or nil end function DataLifecycle:MarkDirty(player: Player) local entry = PlayerDataMap[player] if entry then entry.Dirty = true end end function DataLifecycle:ForceSave(player: Player): boolean local entry = PlayerDataMap[player] if not entry or not entry.Data then return false end local ok = SaveDataAsync(PlayerStore, player.UserId, entry.Data) if ok then entry.Dirty = false end return ok end return DataLifecycle
Relying only on PlayerRemoving is dangerous — servers crash, network fails, players lose connection. AutoSave with a dirty flag pattern ensures at most 90 seconds of data loss. Backup keys give you a recovery path when primary saves fail.
local AutoSaveManager = {} AutoSaveManager.__index = AutoSaveManager local BACKUP_RETENTION = 5 local MAX_BACKUP_AGE = 7 * 24 * 60 * 60 -- 7 days in seconds function AutoSaveManager:new(dataStore, playerDataMap) local self = setmetatable({}, AutoSaveManager) self.Store = dataStore self.PlayerMap = playerDataMap self.ActiveLoops = {} return self end local SerializeBackup = function(data: table): string local success, json = pcall( HttpService.JSONEncode, HttpService, data ) return success and json or "" end function AutoSaveManager:SaveWithBackup(userId: number, data: table): boolean local primaryOk = false for attempt = 1, 3 do local ok, err = pcall(function() self.Store:SetAsync("Player_" .. userId, data) end) if ok then primaryOk = true break end warn("Primary save attempt", attempt, "failed for", userId, err) task.wait(0.5 * attempt) end -- Always write a backup on successful save too (rolling history) local backupKey = "Recovery_" .. userId .. "_" .. os.time() local json = SerializeBackup(data) if #json > 0 then pcall(function() self.Store:SetAsync(backupKey, json) end) end if primaryOk then -- Prune old backups asynchronously task.spawn(function() self:PruneOldBackups(userId) end) end return primaryOk end function AutoSaveManager:PruneOldBackups(userId: number) local prefix = "Recovery_" .. userId .. "_" local cursor = nil local backupKeys = {} repeat local success, result = pcall(function() return self.Store:ListKeysAsync(prefix, cursor) end) if not success then break end for _, keyInfo in result.Keys do table.insert(backupKeys, keyInfo.KeyName) end cursor = result.Cursor until cursor == nil -- Sort by timestamp descending (timestamp is suffix after userId_) table.sort(backupKeys, function(a, b) local tsA = tonumber(a:match("_([0-9]+)$")) or 0 local tsB = tonumber(b:match("_([0-9]+)$")) or 0 return tsA > tsB end) -- Remove excess backups and expired ones local now = os.time() for idx, key in backupKeys do local ts = tonumber(key:match("_([0-9]+)$")) or 0 if idx > BACKUP_RETENTION or (now - ts) > MAX_BACKUP_AGE then pcall(function() self.Store:RemoveAsync(key) end) end end end function AutoSaveManager:RecoverFromBackup(userId: number): table? local prefix = "Recovery_" .. userId .. "_" local newestKey = nil local newestTime = 0 local cursor = nil repeat local success, result = pcall(function() return self.Store:ListKeysAsync(prefix, cursor) end) if not success then break end for _, keyInfo in result.Keys do local ts = tonumber(keyInfo.KeyName:match("_([0-9]+)$")) or 0 if ts > newestTime then newestTime = ts newestKey = keyInfo.KeyName end end cursor = result.Cursor until cursor == nil if newestKey then local success, json = pcall(function() return self.Store:GetAsync(newestKey) end) if success then local ok, decoded = DeserializePlayerData(json) if ok then return decoded end end end return nil end function AutoSaveManager:StartLoop(userId: number, getData, interval: number) if self.ActiveLoops[userId] then return end local thread = task.spawn(function() while self.ActiveLoops[userId] do task.wait(interval) local data = getData() if data then local ok = self:SaveWithBackup(userId, data) if ok then data._dirty = false end end end end) self.ActiveLoops[userId] = thread end function AutoSaveManager:StopLoop(userId: number) self.ActiveLoops[userId] = nil end return AutoSaveManager
ProfileService is the industry-standard data persistence library for Roblox. It handles sessions, locks, auto-save, conflict resolution, and more out of the box. Every major Roblox game (Adopt Me, Tower of Hell, etc.) uses a variant of this pattern. Never roll your own data lifecycle from scratch.
-- wally.toml [dependencies] ProfileService = "1lipse/profile-service@1.4.0" -- Usage in a Script local ProfileService = require(game:GetService("ReplicatedStorage").Packages.ProfileService) local PROFILE_TEMPLATE = { Gold = 100, Level = 1, XP = 0, Inventory = {}, DataVersion = 2, } local ProfileStore = ProfileService.GetProfileStore( "PlayerData", PROFILE_TEMPLATE ) local Players = game:GetService("Players") local Profiles = {} local OnPlayerAdded = function(player: Player) local profile = ProfileStore:LoadProfileAsync( "Player_" .. player.UserId, "ForceLoad" ) if not profile then -- Profile is locked by another server player:Kick("Data still loading from previous server. Please rejoin.") return end -- Handle session release (player leaves, server shuts down) profile:ListenToRelease(function() Profiles[player] = nil if player.Parent then player:Kick("Data session released.") end end) Profiles[player] = profile -- Apply data to leaderstats local data = profile.Data local leaderstats = Instance.new("Folder") leaderstats.Name = "leaderstats" leaderstats.Parent = player local gold = Instance.new("NumberValue") gold.Name = "Gold" gold.Value = data.Gold gold.Parent = leaderstats gold:GetPropertyChangedSignal("Value"):Connect(function() data.Gold = gold.Value end) print("Profile loaded for", player, "- Gold:", data.Gold) end local OnPlayerRemoving = function(player: Player) local profile = Profiles[player] if profile then profile:Release() Profiles[player] = nil end end Players.PlayerAdded:Connect(OnPlayerAdded) Players.PlayerRemoving:Connect(OnPlayerRemoving) game:BindToClose(function() for _, profile in Profiles do profile:Release() end end)
Data corruption is silent and permanent. A single malformed value can crash the entire load sequence, making a player's data unrecoverable. Defensive programming at every stage is mandatory. Validate every value after deserialization — assume the saved data is malicious or broken.
local VALIDATE_SCHEMA = { Gold = { type = "number", min = 0, max = 1e9, required = true }, Level = { type = "number", min = 1, max = 999, required = true }, XP = { type = "number", min = 0, max = 1e12, required = true }, Inventory = { type = "table", required = true }, EquippedItems = { type = "table", required = false }, Achievements = { type = "table", required = false }, DataVersion = { type = "number", min = 1, max = 999, required = true }, } local IsValidArray = function(t: any): boolean if type(t) ~= "table" then return false end local count = #t for i = 1, count do if t[i] == nil then return false end end return true end local SanitizePlayerData = function(raw: any): (boolean, table?, string?) if type(raw) ~= "table" then return false, nil, "Top-level data is not a table." end for fieldName, rules in VALIDATE_SCHEMA do local value = raw[fieldName] -- Check required fields if rules.required and value == nil then return false, nil, "Missing required field: " .. fieldName end -- Type check if value ~= nil and type(value) ~= rules.type then return false, nil, "Field '" .. fieldName .. "': expected " .. rules.type .. ", got " .. type(value) end -- Bounds check for numbers if rules.type == "number" and value ~= nil then if value ~= value then -- NaN check return false, nil, "Field '" .. fieldName .. "' is NaN" end if value == 1/0 or value == -1/0 then return false, nil, "Field '" .. fieldName .. "' is infinite" end if value ~= math.floor(value) then -- Non-integer: clamp to integer but accept (floating point from JSON) raw[fieldName] = math.floor(value) end if value < rules.min or value > rules.max then warn("Field '" .. fieldName .. "' out of bounds (" .. value .. "), clamping to [" .. rules.min .. "," .. rules.max .. "]") raw[fieldName] = math.clamp(value, rules.min, rules.max) end end -- Array validation for Inventory and Achievements if fieldName == "Inventory" or fieldName == "Achievements" then if value ~= nil and not IsValidArray(value) then warn("Field '" .. fieldName .. "' is a malformed table, resetting") raw[fieldName] = {} end end end -- Sub-table validation for Settings if type(raw.Settings) == "table" then raw.Settings.MusicVolume = type(raw.Settings.MusicVolume) == "number" and math.clamp(raw.Settings.MusicVolume, 0, 1) or 0.5 raw.Settings.SFXVolume = type(raw.Settings.SFXVolume) == "number" and math.clamp(raw.Settings.SFXVolume, 0, 1) or 0.8 else raw.Settings = nil end return true, raw, nil end -- Safe load pipeline: fetch → deserialize → migrate → validate → clamp → use local SafeLoadPlayerData = function(store, userId: number): (boolean, table?) local ok, value = pcall(function() return store:GetAsync("Player_" .. userId) end) if not ok then return false, nil end if value == nil then return true, table.clone(DEFAULT_PLAYER_DATA) end -- Deserialize local ok2, decoded = DeserializePlayerData(value) if not ok2 then return false, nil end -- Validate and sanitize local ok3, sanitized, errMsg = SanitizePlayerData(decoded) if not ok3 then warn("Sanitization failed for", userId, errMsg) -- Fall back to backup, then defaults local backup = RecoverFromBackup(userId) if backup then local ok4, recovered = SanitizePlayerData(backup) if ok4 then return true, recovered end end return true, table.clone(DEFAULT_PLAYER_DATA) end return true, sanitized end
MemoryStoreService is Roblox's in-memory, temporary, key-value storage that persists across servers but not across restarts. Unlike DataStores, it has near-zero latency and no hard capacity limit per key (but limited to a total of 64 MB per store). Data automatically expires after a TTL (time-to-live).
local MemoryStoreService = game:GetService("MemoryStoreService") -- Live leaderboard: top players by score (updates every 30s from DataStore) local Scoreboard = MemoryStoreService:GetSortedMap("LiveLeaderboard") local SetLiveScore = function(userId: number, score: number) local key = tostring(userId) local success, err = pcall(function() Scoreboard:SetAsync(key, score, 120) -- 120 second TTL end) if not success then warn("MemoryStore SetAsync failed:", err) end end local GetLiveTopScores = function(count: number): {{key: string, value: number}} local success, result = pcall(function() return Scoreboard:GetRangeAsync( Enum.SortDirection.Descending, count ) end) if success then return result end return {} end -- Matchmaking queue: MemoryStoreQueue local MatchQueue = MemoryStoreService:GetQueue("Matchmaking") local JoinMatchQueue = function(player: Player, elo: number) local entry = { UserId = player.UserId, Name = player.Name, Elo = elo, JoinTime = os.time(), } local success, err = pcall(function() MatchQueue:AddAsync(entry, 300) -- 5 minute TTL end) return success end local TryMatchPlayers = function(): {Player}? local pool = {} local success, entries = pcall(function() local all, _ = MatchQueue:ReadAsync(50) return all end) if not success or #entries < 2 then return nil end -- Simple Elo-based pairing: sort by Elo, pair adjacent table.sort(entries, function(a, b) return a.Value.Elo < b.Value.Elo end) local matched = { entries[1].Value, entries[2].Value } for _, entry in {entries[1], entries[2]} do pcall(function() MatchQueue:RemoveAsync(entry.Id) end) end return matched end -- Cross-server coordination HashMap: which server is hosting a game local ServerRegistry = MemoryStoreService:GetHashMap("ServerRegistry") local RegisterServer = function(gameId: string, playerCount: number) local info = { JobId = game.JobId, PlaceId = game.PlaceId, PlayerCount = playerCount, MaxPlayers = 20, StartedAt = os.time(), } local json = HttpService:JSONEncode(info) pcall(function() ServerRegistry:SetAsync(gameId, json, 60) -- heartbeat every 60s end) end local FindGameWithSpace = function(): string? local success, items = pcall(function() return ServerRegistry:GetAsync("*", 100) -- wildcard scan end) if not success then return nil end for gameId, json in items do local ok, info = pcall(HttpService.JSONDecode, HttpService, json) if ok and info.PlayerCount < info.MaxPlayers then return gameId end end return nil end
MessagingService allows servers within the same experience to communicate in real time. When you publish a message, all servers subscribed to that topic receive it. This is how cross-server chat, global announcements, and server-wide events work.
local MessagingService = game:GetService("MessagingService") local Messaging = {} Messaging.__index = Messaging local TOPIC_ANNOUNCEMENTS = "GlobalAnnouncements" local TOPIC_TRADING = "CrossServerTrading" local TOPIC_SERVER_EVENTS = "ServerEvents" local SUBSCRIPTION_QUOTA = 0 local MAX_SUBSCRIPTIONS = 10 function Messaging:new() local self = setmetatable({}, Messaging) self.Subscriptions = {} self.Handlers = {} return self end function Messaging:PublishAnnouncement(message: string): boolean if #message > 1000 then warn("Announcement truncated to 1024 bytes") message = message:sub(1, 1000) end local payload = HttpService:JSONEncode({ Type = "announcement", Text = message, Timestamp = os.time(), ServerId = game.JobId, }) local success, err = pcall(function() MessagingService:PublishAsync(TOPIC_ANNOUNCEMENTS, payload) end) if not success then warn("MessagingService publish failed:", err) end return success end -- Cross-server trade notification function Messaging:NotifyTradeAccepted(fromUserId: number, toUserId: number, offerId: string) local payload = HttpService:JSONEncode({ Type = "trade_accepted", FromUserId = fromUserId, ToUserId = toUserId, OfferId = offerId, Timestamp = os.time(), }) pcall(function() MessagingService:PublishAsync(TOPIC_TRADING, payload) end) end function Messaging:SubscribeToTopic(topic: string, handler: (string) -> ()) if #self.Subscriptions >= MAX_SUBSCRIPTIONS then warn("Max subscriptions reached, consider consolidating topics") return end local success, connection = pcall(function() return MessagingService:SubscribeAsync(topic, function(message) local ok, decoded = pcall(function() return HttpService:JSONDecode(message) end) if ok then local ok2, err2 = pcall(handler, decoded) if not ok2 then warn("Messaging handler error:", err2) end end end) end) if success then table.insert(self.Subscriptions, { Topic = topic, Connection = connection, }) self.Handlers[topic] = handler else warn("Failed to subscribe to topic '" .. topic .. "':", connection) end end function Messaging:StartListening() self:SubscribeToTopic(TOPIC_ANNOUNCEMENTS, function(data) -- Show announcement UI to all players local ReplicatedStorage = game:GetService("ReplicatedStorage") local remoteEvent = ReplicatedStorage:FindFirstChild("ShowAnnouncement") if remoteEvent then remoteEvent:FireAllClients(data.Text) end end) self:SubscribeToTopic(TOPIC_TRADING, function(data) if data.Type == "trade_accepted" then -- Find the receiving player on this server and drop items local Players = game:GetService("Players") local target = Players:GetPlayerByUserId(data.ToUserId) if target then -- Grant items to target player GrantTradeItems(target, data.OfferId) end end end) self:SubscribeToTopic(TOPIC_SERVER_EVENTS, function(data) if data.Type == "global_event" then StartGlobalEvent(data.EventName, data.Duration) end end) end function Messaging:Cleanup() for _, sub in self.Subscriptions do local success, err = pcall(function() sub.Connection:Disconnect() end) if not success then warn("Failed to disconnect subscription:", err) end end self.Subscriptions = {} self.Handlers = {} end return Messaging
AnalyticsService lets you log custom events from your game to the Roblox developer dashboard. Use it to track player behavior, measure retention, identify funnel drop-offs, and make data-driven decisions. Events appear in the Creator Dashboard under Analytics → Custom Events.
local AnalyticsService = game:GetService("AnalyticsService") local Analytics = {} Analytics.__index = Analytics local EVENT_VERSION = "1.0" local BuildMetadata = function(player: Player, extra: table): table local meta = { UserId = player.UserId, DisplayName = player.DisplayName, AccountAge = player.AccountAge, EventVersion = EVENT_VERSION, Timestamp = os.time(), ServerId = game.JobId, PlaceId = game.PlaceId, } for k, v in extra do meta[k] = v end return meta end function Analytics:logPurchase(player: Player, productId: string, price: number, currency: string) local meta = BuildMetadata(player, { ProductId = productId, Price = price, Currency = currency, EventName = "purchase", }) local success, err = pcall(function() AnalyticsService:LogCustomEvent("Purchase", meta) end) if not success then warn("Analytics logPurchase failed:", err) end end function Analytics:logLevelUp(player: Player, newLevel: number, xpGained: number) local meta = BuildMetadata(player, { NewLevel = newLevel, XPGained = xpGained, TotalPlayTime = GetPlayerPlayTime(player), EventName = "level_up", }) local success, err = pcall(function() AnalyticsService:LogCustomEvent("LevelUp", meta) end) if not success then warn("Analytics logLevelUp failed:", err) end end function Analytics:logQuestComplete(player: Player, questId: string, questName: string, completionTime: number, rewards: table) local meta = BuildMetadata(player, { QuestId = questId, QuestName = questName, CompletionTimeSeconds = completionTime, RewardGold = rewards.Gold or 0, RewardXP = rewards.XP or 0, RewardItems = #(rewards.Items or {}), EventName = "quest_complete", }) local success, err = pcall(function() AnalyticsService:LogCustomEvent("QuestComplete", meta) end) if not success then warn("Analytics logQuestComplete failed:", err) end end -- Funnel tracking: tutorial completion funnel local TUTORIAL_STEPS = { "TutorialStarted", "TutorialMovement", "TutorialCombat", "TutorialCrafting", "TutorialCompleted", } function Analytics:logTutorialStep(player: Player, stepIndex: number, stepName: string, timeSinceStart: number) local meta = BuildMetadata(player, { StepIndex = stepIndex, StepName = stepName, TotalSteps = #TUTORIAL_STEPS, TimeSinceStart = timeSinceStart, IsLastStep = stepIndex == #TUTORIAL_STEPS, EventName = "tutorial_step", }) pcall(function() AnalyticsService:LogCustomEvent(stepName, meta) end) end -- Periodic session heartbeat: log every 5 minutes that player is still active function Analytics:startSessionHeartbeat(player: Player) local startTime = tick() task.spawn(function() while player.Parent do task.wait(300) -- every 5 minutes if not player.Parent then break end local meta = BuildMetadata(player, { SessionDuration = tick() - startTime, EventName = "heartbeat", }) pcall(function() AnalyticsService:LogCustomEvent("SessionHeartbeat", meta) end) end end) end -- Aggregate stats: write daily summary to DataStore for dashboard function Analytics:logDailySummary(serverId: string, stats: table) local meta = table.clone(stats) meta.ServerId = serverId meta.EventVersion = EVENT_VERSION meta.Timestamp = os.time() meta.Date = DateTime.now():FormatLocalTime("YYYY-MM-DD") pcall(function() AnalyticsService:LogCustomEvent("DailySummary", meta) end) end -- Example usage in a game system local OnPlayerLevelUp = function(player: Player, newLevel: number, xpGained: number) Analytics:logLevelUp(player, newLevel, xpGained) if newLevel == 10 then Analytics:logQuestComplete(player, "milestone_10", "Reach Level 10", 0, { Gold = 500, XP = 1000, Items = { "LegendarySword" } }) end end return Analytics