Contents

1. DataStoreService Fundamentals INTERMEDIATE

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
Exercise: Write a function SpendGold(player, cost) that deducts gold and returns true only if the player had enough. Use UpdateAsync to prevent race conditions.

2. Data Limits & Rate Limits ADVANCED

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
Exercise: Use GetSortedAsync with ascending order to display the bottom 5 players. Add a pagination cursor so an admin can scroll through all entries.

3. JSON Serialization INTERMEDIATE

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
Exercise: Add a v3 migration that changes Inventory from a flat string array to an array of {ItemId, Quantity, Equipped} objects. Write the migration function and the reverse migration.

4. Player Data Lifecycle ADVANCED

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
Exercise: Add telemetry logging to the lifecycle: track load times, save success/failure rates, and average data size per player. Store these in a separate DataStore key for monitoring.

5. AutoSave & Periodic Backups ADVANCED

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
Exercise: Implement a dead-man's switch: write a "heartbeat" key every 30 seconds with the server ID and timestamp. On server start, check if another server's heartbeat is still alive; if so, scan for unsaved player data and recover it.

6. ProfileService ADVANCED

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)
Exercise: Set up ProfileService with a custom ProfileStore that uses MetaData for version tracking. Write a migration that runs when DataVersion is less than the current version, modifying the profile data before any gameplay code touches it.

7. Data Corruption & Safety ADVANCED

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
Exercise: Write a data repair tool that scans all player keys via ListKeysAsync, loads, validates, repairs with defaults, and re-saves. Add a dry-run mode that only logs issues without fixing.

8. MemoryStoreService ADVANCED

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
Exercise: Build a cross-server trading system: player A lists an item on server 1 (writes to a MemoryStore HashMap with a 60-second lock). Player B on server 2 reads the listing and accepts. Use MemoryStore atomic operations to prevent double-accept.

9. MessagingService INTERMEDIATE

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
Exercise: Implement a cross-server "raid boss" system: one server publishes (via MessagingService) when a boss spawns. All servers display a notification. When the boss is defeated, the killing server publishes the victory, and all servers grant participation rewards.

10. AnalyticsService INTERMEDIATE

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
Exercise: Build a retention analysis pipeline: log SessionStart and SessionEnd events with timestamps. Then write a script (in Python or Luau) that reads the custom events from the dashboard API and computes D1, D7, and D30 retention rates.