DOCUMENTATION & EXTRA SCRIPTS:
CurrencyModule
The CurrencyModule is meant to be a helper you can require from anywhere (other server scripts, NPC drops, shops, quest rewards, etc.). It abstracts the “read/write leaderstats” boilerplate into clean calls.
Here’s how you’d use it in practice (e.g., inside a quest reward script):
-- Example use: rewarding gold when a quest is completed
local Currency = require(game.ReplicatedStorage:WaitForChild("CurrencyModule"))
game.Players.PlayerAdded:Connect(function(plr)
local leaderstats = plr:WaitForChild("leaderstats")
-- Reward 50 gold for testing
task.wait(5)
Currency.add(leaderstats, 50)
print("Player now has", Currency.get(leaderstats), "gold")
end)
Extra Script: Daily Login Rewards (uses CurrencyModule
):
-- ServerScriptService/DailyRewardsServer
-- Dependencies:
-- ReplicatedStorage/CurrencyModule (required)
-- ServerStorage/DataStoreManager (recommended; falls back to memory if missing)
local Players = game:GetService("Players")
local RS = game:GetService("ReplicatedStorage")
local SS = game:GetService("ServerStorage")
local Currency = require(RS:WaitForChild("CurrencyModule"))
-- RemoteEvent players use to claim:
local RE = RS:FindFirstChild("RPG_RequestDailyClaim") or Instance.new("RemoteEvent", RS)
RE.Name = "RPG_RequestDailyClaim"
-- Optional dialogue bridge (if you added DialogueClient’s RemoteEvent)
local ShowDialogue = RS:FindFirstChild("RPG_ShowDialogue") -- RemoteEvent or nil
-- Datastore wrapper (optional but recommended)
local DM
pcall(function() DM = require(SS:WaitForChild("DataStoreManager")) end)
-- Fallback memory store if DM is unavailable in Studio
local mem = {} -- { [player] = { lastDay = n, streak = n } }
local function key(p) return "daily_" .. p.UserId end
local function todayDay() return math.floor((DateTime.now().UnixTimestamp) / 86400) end -- UTC day number
-- Reward formula: tweak as you like
local BASE = 100 -- day 1
local STEP = 25 -- + per streak step
local CAP = 500 -- max per day
local function computeReward(streak)
return math.clamp(BASE + STEP * (streak - 1), BASE, CAP)
end
-- Load/save helpers
local function loadState(p)
if DM then
return DM.loadAsync(key(p)) or { lastDay = 0, streak = 0 }
end
mem[p] = mem[p] or { lastDay = 0, streak = 0 }
return mem[p]
end
local function saveState(p, state)
if DM then
DM.saveAsync(key(p), state)
else
mem[p] = state
end
end
-- Ensure leaderstats exist (Level/XP/Gold or at least Gold)
local function ensureLeaderstats(p)
local ls = p:FindFirstChild("leaderstats") or Instance.new("Folder", p)
ls.Name = "leaderstats"
local gold = ls:FindFirstChild("Gold")
if not gold then
gold = Instance.new("IntValue"); gold.Name = "Gold"; gold.Parent = ls
end
return ls
end
-- Main claim handler
RE.OnServerEvent:Connect(function(p)
local ls = ensureLeaderstats(p)
local state = loadState(p)
local today = todayDay()
if state.lastDay == today then
if ShowDialogue then ShowDialogue:FireClient(p, "Daily already claimed today.") end
return
end
-- Streak logic (yesterday = +1, otherwise reset to 1)
if state.lastDay == today - 1 then
state.streak = (state.streak or 0) + 1
else
state.streak = 1
end
local amount = computeReward(state.streak)
Currency.add(ls, amount) -- awards Gold via CurrencyModule
state.lastDay = today
saveState(p, state)
if ShowDialogue then
ShowDialogue:FireClient(p, ("Daily: +%d Gold (Streak x%d)"):format(amount, state.streak))
end
print(("[DailyRewards] %s claimed +%d Gold (streak %d)"):format(p.Name, amount, state.streak))
end)
-- Optional: preload state on join (not required to claim)
Players.PlayerAdded:Connect(function(p)
loadState(p)
end)
Players.PlayerRemoving:Connect(function(p)
if mem[p] then mem[p] = nil end
end)
Daily login reward with streak. Drop this in ServerScriptService. It awards Gold once per UTC day, increases a streak if you claim on consecutive days, and persists via DataStoreManager
. Client presses a Gui button that fires the RemoteEvent.
Client one-liner (hook to a Gui button):
-- LocalScript (e.g., in a button.Activated handler)
game.ReplicatedStorage.RPG_RequestDailyClaim:FireServer()
StatsModule
The StatsModule is intended as a central “stats math” library.
You don’t put it directly in a player, you require it from other scripts to either:
- Spawn default stats (HP, ATK, DEF, etc.)
- Check XP thresholds for level-ups
Here’s a practical usage snippet (e.g., inside your leveling system or combat script):
-- Example use: checking if a player can level up
local Stats = require(game.ReplicatedStorage:WaitForChild("StatsModule"))
game.Players.PlayerAdded:Connect(function(plr)
local ls = Instance.new("Folder"); ls.Name = "leaderstats"; ls.Parent = plr
local Level = Instance.new("IntValue", ls); Level.Name="Level"; Level.Value=1
local XP = Instance.new("IntValue", ls); XP.Name="XP"; XP.Value=0
-- Add 100 XP for testing
XP.Value += 100
-- Use StatsModule to check if it exceeds threshold
if XP.Value >= Stats.xpToNext(Level.Value) then
XP.Value -= Stats.xpToNext(Level.Value)
Level.Value += 1
print(plr.Name, "leveled up to", Level.Value)
end
end)
Extra Script: RebirthServer — Requires StatsModule
. Lets players rebirth at a target level; resets Level/XP, adds +Gems, increments Rebirths, and gives a permanent gold multiplier used by shops/loot. Call _G.RPG_CanRebirth(player)
/ fire RPG_RequestRebirth
. Get multiplier with _G.RPG_GetGoldMult(player)
:
-- RebirthServer
-- Requires: ReplicatedStorage/StatsModule
-- Optional: ServerStorage/DataStoreManager (to persist Rebirths & Gems)
-- Purpose: Rebirth at target level -> reset Level/XP via StatsModule, grant Gems,
-- increment Rebirths, and set a permanent gold multiplier.
local Players = game:GetService("Players")
local RS = game:GetService("ReplicatedStorage")
local SS = game:GetService("ServerStorage")
local Stats = require(RS:WaitForChild("StatsModule"))
-- === CONFIG ===
local REQ_LEVEL = 30 -- minimum level to prestige
local GEMS_PER_REBIRTH = 100 -- premium currency grant per rebirth
local MULT_PER_REBIRTH = 0.25 -- +25% gold per rebirth (1.0 base -> 1.25, 1.5, ...)
-- Optional dialogue toast bridge (works with DialogueClient)
local ShowDialogue = RS:FindFirstChild("RPG_ShowDialogue") -- RemoteEvent or nil
-- Optional persistence
local DM do
local ok, mod = pcall(function() return require(SS:WaitForChild("DataStoreManager")) end)
DM = ok and mod or nil
end
local function key(p) return "rebirth_"..p.UserId end
-- === UTIL ===
local function ensureLeaderstats(p)
local ls = p:FindFirstChild("leaderstats") or Instance.new("Folder", p)
ls.Name = "leaderstats"
local function ensureInt(name, default)
local v = ls:FindFirstChild(name) or Instance.new("IntValue", ls)
v.Name = name
if v.Value == 0 and default then v.Value = default end
return v
end
return ls, ensureInt
end
local function loadRebirthState(p)
if DM then
return DM.loadAsync(key(p)) or { Rebirths = 0, Gems = 0 }
end
return { Rebirths = 0, Gems = 0 }
end
local function saveRebirthState(p, state)
if DM then DM.saveAsync(key(p), state) end
end
local function recomputeGoldMult(p, rebirths)
local mult = 1.0 + (rebirths * MULT_PER_REBIRTH)
p:SetAttribute("RebirthGoldMult", mult) -- read via _G.RPG_GetGoldMult
return mult
end
-- === PUBLIC API ===
_G.RPG_GetGoldMult = function(p)
return p:GetAttribute("RebirthGoldMult") or 1.0
end
_G.RPG_CanRebirth = function(p)
local ls = p:FindFirstChild("leaderstats"); if not ls then return false, "No leaderstats" end
local lvl = ls:FindFirstChild("Level"); if not lvl then return false, "No Level" end
return (lvl.Value >= REQ_LEVEL), ("Reach Level %d to Rebirth"):format(REQ_LEVEL)
end
local function doRebirth(p)
local ok, reason = _G.RPG_CanRebirth(p)
if not ok then return false, reason end
local ls, ensureInt = ensureLeaderstats(p)
-- Ensure required values
local Level = ls:FindFirstChild("Level") or Instance.new("IntValue", ls); Level.Name = "Level"
local XP = ls:FindFirstChild("XP") or Instance.new("IntValue", ls); XP.Name = "XP"
local Rebs = ls:FindFirstChild("Rebirths") or Instance.new("IntValue", ls); Rebs.Name = "Rebirths"
local Gems = ls:FindFirstChild("Gems") or Instance.new("IntValue", ls); Gems.Name = "Gems"
-- Reset core stats using StatsModule defaults
local d = Stats.defaultStats()
Level.Value = 1
XP.Value = 0
-- (d.HP/ATK/DEF can be reapplied elsewhere if you mirror them to leaderstats)
-- Rewards
Rebs.Value += 1
Gems.Value += GEMS_PER_REBIRTH
-- Permanent gold multiplier
local mult = recomputeGoldMult(p, Rebs.Value)
-- Persist
saveRebirthState(p, { Rebirths = Rebs.Value, Gems = Gems.Value })
if ShowDialogue then
ShowDialogue:FireClient(p, ("Rebirth complete! +%d Gems, Gold x%.2f"):format(GEMS_PER_REBIRTH, mult))
end
return true
end
-- RemoteEvent to request rebirth (from a UI button)
local RE = RS:FindFirstChild("RPG_RequestRebirth") or Instance.new("RemoteEvent", RS)
RE.Name = "RPG_RequestRebirth"
RE.OnServerEvent:Connect(function(p)
local ok, err = doRebirth(p)
if not ok and ShowDialogue then ShowDialogue:FireClient(p, err or "Can't rebirth") end
end)
-- Load on join and set derived gold multiplier
Players.PlayerAdded:Connect(function(p)
local state = loadRebirthState(p)
local ls = ensureLeaderstats(p)
local Rebs = ls:FindFirstChild("Rebirths") or Instance.new("IntValue", ls); Rebs.Name = "Rebirths"; Rebs.Value = state.Rebirths or 0
local Gems = ls:FindFirstChild("Gems") or Instance.new("IntValue", ls); Gems.Name = "Gems"; Gems.Value = state.Gems or 0
recomputeGoldMult(p, Rebs.Value)
end)
How to apply the gold multiplier when paying players (server)
Use this when you grant Gold (e.g., after kills/quests). With your CurrencyModule
:
local RS = game:GetService("ReplicatedStorage")
local Currency = require(RS:WaitForChild("CurrencyModule"))
local function grantGoldWithRebirth(p, base)
local ls = p:FindFirstChild("leaderstats"); if not ls then return end
local mult = _G.RPG_GetGoldMult and _G.RPG_GetGoldMult(p) or 1.0
local amt = math.floor((base * mult) + 0.5)
Currency.add(ls, amt)
end
-- Example: quest complete
-- grantGoldWithRebirth(player, 50) -- 50 base, scaled by rebirth multiplier
Notes:
- Requires your game to already create
leaderstats
with Level
/XP
(e.g., via XPLevelServer).
Rebirths
and Gems
are added to leaderstats
so they’re visible.
- Gold multiplier is stored as a Player Attribute (
RebirthGoldMult
) and derived from Rebirths
(no separate save needed).
- Optional persistence via
DataStoreManager
keeps Rebirths
and Gems
across sessions.
XPLevelServer
This is the “glue” that actually uses StatsModule and leaderstats to manage XP and levels.
The important part: developers don’t manually edit XP/Level values directly — they should call the global function it exposes:
_G.RPG_AddXP(player, amount)
That’s how you reward XP when a quest is completed, an enemy is defeated, etc.
Practical usage snippet:
-- Example: give XP when a monster is defeated
local function onMonsterDefeated(plr)
if _G.RPG_AddXP then
_G.RPG_AddXP(plr, 50) -- awards 50 XP
print(plr.Name .. " gained 50 XP!")
end
end
Here’s a bonus script: XPBoostServer — wrapper around _G.RPG_AddXP
that applies global, gamepass, and timed personal XP multipliers. Call _G.RPG_AddXP_Boosted(player, baseXP)
. Optional save of timed boosts via DataStoreManager
.
-- XPBoostServer
-- Requires XPLevelServer (provides _G.RPG_AddXP). Optional: ServerStorage/DataStoreManager for persistence.
local Players = game:GetService("Players")
local MarketplaceService = game:GetService("MarketplaceService")
local RS = game:GetService("ReplicatedStorage")
local SS = game:GetService("ServerStorage")
-- ========== CONFIG ==========
local GLOBAL_XP_MULTIPLIER = 1.0 -- set to 2.0 for "Double XP weekend"
local GAMEPASS_ID = 0 -- put your VIP/2x XP Gamepass ID here, or 0 to disable
local TIMED_MULTIPLIER = 3.0 -- personal boost multiplier (e.g., 3×)
local DEFAULT_BOOST_MIN = 15 -- default minutes for a personal boost
local SAVE_TIMED_BOOSTS = true -- persist timed boosts across sessions (needs DataStoreManager)
-- (Optional) server event to grant a timed boost from admin/UI/shop
local RE = RS:FindFirstChild("RPG_ActivateXPBoost") or Instance.new("RemoteEvent", RS)
RE.Name = "RPG_ActivateXPBoost"
-- Optional dialogue bridge (if you use DialogueClient’s RemoteEvent)
local ShowDialogue = RS:FindFirstChild("RPG_ShowDialogue") -- RemoteEvent or nil
-- Optional datastore wrapper
local DM do
local ok, mod = pcall(function() return require(SS:WaitForChild("DataStoreManager")) end)
DM = ok and mod or nil
end
-- ========== STATE ==========
local ownsCache = {} -- [userId] = boolean (gamepass)
local timedBoosts = {} -- [Player] = { endTime = Unix, mult = number }
local function nowUnix()
return (typeof(DateTime)=="table" and DateTime.now and DateTime.now().UnixTimestamp) or os.time()
end
local function keyTimed(p) return "xpboost_"..p.UserId end
-- Gamepass check with cache
local function hasGamepass(p)
if GAMEPASS_ID == 0 then return false end
local uid = p.UserId
if ownsCache[uid] ~= nil then return ownsCache[uid] end
local ok, owned = pcall(MarketplaceService.UserOwnsGamePassAsync, MarketplaceService, uid, GAMEPASS_ID)
ownsCache[uid] = ok and owned or false
return ownsCache[uid]
end
-- Compute total multiplier
local function totalMultiplier(p)
local m = GLOBAL_XP_MULTIPLIER
if hasGamepass(p) then m *= 2.0 end -- VIP 2×
local tb = timedBoosts[p]
if tb and tb.endTime > nowUnix() then
m *= (tb.mult or TIMED_MULTIPLIER)
end
return m
end
-- Public API: call this instead of _G.RPG_AddXP
_G.RPG_AddXP_Boosted = function(p, baseAmount)
if type(_G.RPG_AddXP) ~= "function" then
warn("[XPBoostServer] _G.RPG_AddXP not found (is XPLevelServer running?)")
return
end
local amt = tonumber(baseAmount) or 0
if amt <= 0 then return end
local m = totalMultiplier(p)
local final = math.max(1, math.floor(amt * m + 0.5))
_G.RPG_AddXP(p, final)
end
-- Grant a timed personal boost
local function grantTimedBoost(p, minutes, mult)
local dur = math.max(1, tonumber(minutes) or DEFAULT_BOOST_MIN) * 60
timedBoosts[p] = { endTime = nowUnix() + dur, mult = tonumber(mult) or TIMED_MULTIPLIER }
if SAVE_TIMED_BOOSTS and DM then
DM.saveAsync(keyTimed(p), timedBoosts[p])
end
if ShowDialogue then
local mins = math.floor(dur/60 + 0.5)
ShowDialogue:FireClient(p, ("XP Boost active: x%.1f for %d min"):format(timedBoosts[p].mult, mins))
end
end
-- Optional RemoteEvent handler (hook this to a dev product/gamepass flow or admin UI)
RE.OnServerEvent:Connect(function(p, minutes, mult)
-- Minimal anti-abuse: only allow if you wire this from a trusted server source.
-- For production, validate via receipt processing or server-side admin check.
grantTimedBoost(p, minutes, mult)
end)
-- Load/save timed boosts
local function loadTimed(p)
if SAVE_TIMED_BOOSTS and DM then
local data = DM.loadAsync(keyTimed(p))
if data and data.endTime and data.endTime > nowUnix() then
timedBoosts[p] = { endTime = data.endTime, mult = data.mult or TIMED_MULTIPLIER }
end
end
end
local function saveTimed(p)
if SAVE_TIMED_BOOSTS and DM and timedBoosts[p] then
DM.saveAsync(keyTimed(p), timedBoosts[p])
end
end
Players.PlayerAdded:Connect(function(p)
loadTimed(p)
-- Warm the gamepass cache once (non-blocking if it fails)
task.spawn(function() hasGamepass(p) end)
end)
Players.PlayerRemoving:Connect(function(p)
saveTimed(p)
timedBoosts[p] = nil
end)
game:BindToClose(function()
for _, p in ipairs(Players:GetPlayers()) do
saveTimed(p)
end
end)
-- Optional: helpers to flip global boosts from the command bar
_G.RPG_SetGlobalXPMult = function(mult)
GLOBAL_XP_MULTIPLIER = math.max(0, tonumber(mult) or 1)
print("[XPBoostServer] GLOBAL_XP_MULTIPLIER =", GLOBAL_XP_MULTIPLIER)
end
_G.RPG_StartDoubleXP = function(hours)
GLOBAL_XP_MULTIPLIER = 2.0
print("[XPBoostServer] Double XP ON for", hours or "session")
if type(hours) == "number" and hours > 0 then
task.delay(hours*3600, function()
GLOBAL_XP_MULTIPLIER = 1.0
print("[XPBoostServer] Double XP OFF")
end)
end
end
Award XP anywhere on the server (kills, quests, etc.):
-- Instead of _G.RPG_AddXP(player, 100):
_G.RPG_AddXP_Boosted(player, 100) -- applies global/gamepass/timed multipliers
Start a timed personal boost (e.g., after buying a dev product):
-- From server logic:
-- grant 30 minutes at 3× (fires a dialogue toast if you wired DialogueClient)
game.ReplicatedStorage.RPG_ActivateXPBoost:FireClient(player, 30, 3.0) -- or call grant on server directly
Toggle Double XP weekend from the server command bar:
_G.RPG_StartDoubleXP(48) -- 48 hours of global 2×
-- or
_G.RPG_SetGlobalXPMult(1) -- back to normal
Notes (short):
- Requires XPLevelServer active (defines
_G.RPG_AddXP
).
- Set
GAMEPASS_ID
to your VIP/2× gamepass ID to auto-apply for owners.
- Timed boosts persist if
DataStoreManager
is present.
- For real purchases, wire Developer Product receipts to call
grantTimedBoost
.
InventoryModule
This module stores a table of items per player while the server is running. It’s good for demos, quests, loot, shops, etc. (if you want persistence, you’d later tie it into your DataStore system).
Practical usages, For example, awarding a potion to the player when they open a chest:
-- Example: giving and checking inventory
local Inventory = require(game.ReplicatedStorage:WaitForChild("InventoryModule"))
game.Players.PlayerAdded:Connect(function(plr)
-- Give the player 1 Potion
Inventory.add(plr, "Potion", 1)
-- Check if they have at least 1 Potion
if Inventory.has(plr, "Potion", 1) then
print(plr.Name .. " now owns a Potion!")
end
end)
Bonus Script: GroundLootServer — requires InventoryModule
. Spawns world drops with a ProximityPrompt; on pickup the server checks slot cap, stack cap, auto-stacks, pays the player by adding to inventory, and updates/cleans the drop. Use _G.RPG_SpawnItemDrop(position, itemId, qty)
:
-- GroundLootServer
-- Requires: ReplicatedStorage/InventoryModule
-- Purpose: Spawn world item drops; players pick them via ProximityPrompt.
-- Server validates capacity, auto-stacks, and updates/cleans the drop.
local RS = game:GetService("ReplicatedStorage")
local Debris = game:GetService("Debris")
local Inventory = require(RS:WaitForChild("InventoryModule")) -- REQUIRED
local ShowDialogue = RS:FindFirstChild("RPG_ShowDialogue") -- optional (DialogueClient RemoteEvent)
-- ===== CONFIG =====
local MAX_UNIQUE_SLOTS = 20 -- max different item IDs a player can hold
local DEFAULT_MAX_STACK = 99 -- default stack cap per item
local STACK_CAP = { -- per-item overrides
Sword_Iron = 1,
Armor_Leather = 1,
Potion = 99,
Herb = 99,
Bottle = 99,
-- add more as needed
}
local DESPAWN_TIME = 120 -- seconds before a drop auto-cleans
-- ===== HELPERS =====
local function stackCapFor(id)
return STACK_CAP[id] or DEFAULT_MAX_STACK
end
local function usedSlots(bag)
local n = 0
for _, qty in pairs(bag) do
if (qty or 0) > 0 then n += 1 end
end
return n
end
local function toast(p, msg)
if ShowDialogue then ShowDialogue:FireClient(p, msg) end
end
-- Build a simple, readable drop model with a prompt
local function createDropModel(itemId, qty, pos)
local m = Instance.new("Model")
m.Name = "Drop_" .. tostring(itemId)
local part = Instance.new("Part")
part.Name = "Body"
part.Size = Vector3.new(1.2, 0.6, 1.2)
part.Color = Color3.fromRGB(255, 221, 85)
part.Material = Enum.Material.Neon
part.Anchored = true
part.CanCollide = false
part.CFrame = CFrame.new(pos + Vector3.new(0, 1.5, 0))
part.Parent = m
local ui = Instance.new("BillboardGui")
ui.Name = "Label"
ui.Size = UDim2.fromOffset(120, 18)
ui.ExtentsOffsetWorldSpace = Vector3.new(0, 1.2, 0)
ui.AlwaysOnTop = true
ui.Parent = part
local label = Instance.new("TextLabel")
label.BackgroundTransparency = 1
label.Size = UDim2.fromScale(1, 1)
label.TextScaled = true
label.Font = Enum.Font.GothamBold
label.TextColor3 = Color3.new(1,1,1)
label.TextStrokeTransparency = 0.5
label.Text = ("%s x%d"):format(itemId, qty)
label.Parent = ui
local prompt = Instance.new("ProximityPrompt")
prompt.Name = "Pickup"
prompt.ActionText = "Pick Up"
prompt.ObjectText = ("%s x%d"):format(itemId, qty)
prompt.HoldDuration = 0
prompt.MaxActivationDistance = 12
prompt.Parent = part
-- Attributes to track the payload
m:SetAttribute("ItemId", itemId)
m:SetAttribute("Qty", qty)
return m, prompt, label
end
-- Try to give as many as possible to the player; return actually given
local function tryGive(player, itemId, qty)
if qty <= 0 then return 0 end
local bag = Inventory.get(player)
local have = bag[itemId] or 0
local cap = stackCapFor(itemId)
-- If this would be a new item line, check slot capacity
if have == 0 and usedSlots(bag) >= MAX_UNIQUE_SLOTS then
toast(player, "Inventory full")
return 0
end
local addCap = math.max(0, cap - have)
local give = math.min(qty, addCap)
if give <= 0 then
toast(player, "Stack is full")
return 0
end
Inventory.add(bag, itemId, give) -- InventoryModule increments the count
return give
end
-- ===== PUBLIC API =====
-- Spawn a drop in the world. Anyone can pick it up.
_G.RPG_SpawnItemDrop = function(position, itemId, qty)
qty = math.max(1, tonumber(qty) or 1)
local model, prompt, label = createDropModel(itemId, qty, position)
model.Parent = workspace
Debris:AddItem(model, DESPAWN_TIME)
local function updateText(rem)
label.Text = ("%s x%d"):format(itemId, rem)
prompt.ObjectText = label.Text
end
prompt.Triggered:Connect(function(player)
if not model.Parent then return end
local remaining = model:GetAttribute("Qty") or 0
if remaining <= 0 then return end
local given = tryGive(player, itemId, remaining)
if given <= 0 then return end
remaining -= given
model:SetAttribute("Qty", remaining)
if remaining > 0 then
updateText(remaining)
toast(player, ("+%s x%d (left %d)"):format(itemId, given, remaining))
else
toast(player, ("+%s x%d"):format(itemId, given))
model:Destroy()
end
end)
return model
end
--[[ Practical examples (server):
-- 1) Drop 3 Potions where an NPC dies:
-- hum.Died:Connect(function()
-- _G.RPG_SpawnItemDrop(npc:GetPivot().Position, "Potion", 3)
-- end)
-- 2) Chest reward:
-- _G.RPG_SpawnItemDrop(chest.PrimaryPart.Position, "Sword_Iron", 1)
-- 3) Command bar quick test:
-- _G.RPG_SpawnItemDrop(workspace.CurrentCamera.CFrame.Position, "Herb", 5)
]]
QuestService
Quest tracker — keeps a per-player table with state (active/completed) and numeric progress while the server is running. Use it for fetch/kill/visit objectives and gating content. Call start
, progress
, and complete
via _G.RPG_QuestService
; for persistence, save/restore the quest table with your DataStore system.
Here is an example fetch quest:
-- Example: simple fetch quest – collect 5 Herbs, then reward XP/Gold/Item
-- Place this in a server Script that handles pickups or NPC dialogue.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Currency = require(ReplicatedStorage:WaitForChild("CurrencyModule"))
local Inventory = require(ReplicatedStorage:WaitForChild("InventoryModule"))
local QUEST_ID = "FETCH_HERBS_01"
local GOAL = 5
-- Start the quest (e.g., when the player talks to an NPC)
local function startFetchQuest(player)
if _G.RPG_QuestService then
_G.RPG_QuestService.start(player, QUEST_ID)
end
end
-- When the player picks up an Herb:
local function onHerbCollected(player)
if not _G.RPG_QuestService then return end
_G.RPG_QuestService.progress(player, QUEST_ID, 1)
-- Read current state
local q = _G.RPG_QuestService.get(player)[QUEST_ID]
if q and q.state == "active" and (q.progress or 0) >= GOAL then
_G.RPG_QuestService.complete(player, QUEST_ID)
-- Rewards: XP + Gold + Potion
if _G.RPG_AddXP then _G.RPG_AddXP(player, 100) end
local ls = player:FindFirstChild("leaderstats")
if ls then Currency.add(ls, 50) end
Inventory.add(player, "Potion", 1)
print(player.Name .. " completed " .. QUEST_ID .. " and received rewards.")
end
end
Bonus Script: QuestTracker — requires QuestService. Server API to start/track/progress quests and push updates; client shows a mini tracker (“QuestId 3/5
”) and a world waypoint on the nearest target tagged QuestTarget_<QuestId>
. Use _G.RPG_StartTrackedQuest
, _G.RPG_TrackQuest
, _G.RPG_QuestProgress
:
-- Server
-- Requires: QuestService that exposes _G.RPG_QuestService.{get,start,progress,complete}
-- Optional: DialogueClient's RemoteEvent (RPG_ShowDialogue) for toasts.
local RS = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
-- Remote: server -> client updates
local RE = RS:FindFirstChild("RPG_QuestTrackerUpdate") or Instance.new("RemoteEvent", RS)
RE.Name = "RPG_QuestTrackerUpdate"
local ShowDialogue = RS:FindFirstChild("RPG_ShowDialogue") -- optional
-- Per-player tracked quest {id=string, goal=number}
local tracked = {} -- [Player] = { id="FETCH_HERBS_01", goal=5 }
local function sendUpdate(p, questId, goal)
if not (_G.RPG_QuestService and typeof(_G.RPG_QuestService.get)=="function") then return end
local qtbl = _G.RPG_QuestService.get(p)
local q = qtbl and qtbl[questId]
local state = q and q.state or "none"
local prog = q and (q.progress or 0) or 0
goal = goal or (tracked[p] and tracked[p].goal) or 0
RE:FireClient(p, questId, state, prog, goal)
end
-- PUBLIC API: Start + track a quest (server)
_G.RPG_StartTrackedQuest = function(p, questId, goal)
_G.RPG_QuestService.start(p, questId)
tracked[p] = { id = questId, goal = tonumber(goal) or 1 }
sendUpdate(p, questId, tracked[p].goal)
if ShowDialogue then
ShowDialogue:FireClient(p, ("Quest started: %s (0/%d)"):format(questId, tracked[p].goal))
end
end
-- PUBLIC API: Track an existing quest (no start)
_G.RPG_TrackQuest = function(p, questId, goal)
tracked[p] = { id = questId, goal = tonumber(goal) or (tracked[p] and tracked[p].goal) or 1 }
sendUpdate(p, questId, tracked[p].goal)
end
-- PUBLIC API: Progress the quest, auto-complete at goal, push update
_G.RPG_QuestProgress = function(p, questId, amount, goal)
amount = tonumber(amount) or 1
_G.RPG_QuestService.progress(p, questId, amount)
local q = _G.RPG_QuestService.get(p)[questId]
local g = tonumber(goal) or (tracked[p] and tracked[p].goal) or 1
if q and q.state == "active" and (q.progress or 0) >= g then
_G.RPG_QuestService.complete(p, questId)
if ShowDialogue then
ShowDialogue:FireClient(p, ("Quest complete: %s"):format(questId))
end
end
sendUpdate(p, questId, g)
end
-- Cleanup
Players.PlayerRemoving:Connect(function(p) tracked[p] = nil end)
Practical use:
-- Start + track a 5-Herb quest, then increment on each pickup:
_G.RPG_StartTrackedQuest(player, "FETCH_HERBS_01", 5)
-- later, when a Herb is collected:
_G.RPG_QuestProgress(player, "FETCH_HERBS_01", 1, 5)
-- Client
-- Shows "QuestId x/y" + a world waypoint on nearest target tagged: QuestTarget_<QuestId>
local Players = game:GetService("Players")
local RS = game:GetService("ReplicatedStorage")
local CS = game:GetService("CollectionService")
local player = Players.LocalPlayer
-- = UI =
local gui = Instance.new("ScreenGui")
gui.Name = "RPG_QuestTracker"
gui.ResetOnSpawn = false
gui.Parent = player:WaitForChild("PlayerGui")
local frame = Instance.new("Frame")
frame.Size = UDim2.fromOffset(220, 48)
frame.Position = UDim2.new(0, 20, 0, 80) -- top-left
frame.BackgroundColor3 = Color3.fromRGB(20, 20, 28)
frame.BackgroundTransparency = 0.15
frame.Parent = gui
local corner = Instance.new("UICorner", frame); corner.CornerRadius = UDim.new(0, 6)
local title = Instance.new("TextLabel")
title.BackgroundTransparency = 1
title.Font = Enum.Font.GothamBold
title.TextSize = 14
title.TextColor3 = Color3.fromRGB(235,235,240)
title.Size = UDim2.new(1, -12, 0, 18)
title.Position = UDim2.new(0, 6, 0, 4)
title.Text = "Quest: -"
title.Parent = frame
local prog = Instance.new("TextLabel")
prog.BackgroundTransparency = 1
prog.Font = Enum.Font.Gotham
prog.TextSize = 14
prog.TextColor3 = Color3.fromRGB(200,200,210)
prog.Size = UDim2.new(1, -12, 0, 18)
prog.Position = UDim2.new(0, 6, 0, 24)
prog.Text = "Progress: 0/0"
prog.Parent = frame
-- = Waypoint (Billboard over nearest quest target) =
local waypointGui -- created on demand
local function ensureWaypoint()
if waypointGui and waypointGui.Parent then return waypointGui end
waypointGui = Instance.new("BillboardGui")
waypointGui.Size = UDim2.fromOffset(24, 24)
waypointGui.AlwaysOnTop = true
local img = Instance.new("ImageLabel")
img.Name = "Arrow"
img.BackgroundTransparency = 1
img.Size = UDim2.fromScale(1,1)
img.Image = "rbxassetid://7072718365" -- simple marker; swap to your icon
img.Parent = waypointGui
return waypointGui
end
local function nearestTarget(tagName)
local targets = CS:GetTagged(tagName)
local char = player.Character
local root = char and char:FindFirstChild("HumanoidRootPart")
if not root then return nil end
local best, bestD = nil, math.huge
for _, inst in ipairs(targets) do
if inst and inst.Parent and inst:IsA("BasePart") then
local d = (inst.Position - root.Position).Magnitude
if d < bestD then best, bestD = inst, d end
end
end
return best
end
-- Handle updates from server
local currentQuestId = nil
local function onUpdate(questId, state, progress, goal)
currentQuestId = questId
title.Text = ("Quest: %s [%s]"):format(questId, state)
prog.Text = ("Progress: %d/%d"):format(progress or 0, goal or 0)
-- Find nearest target with tag "QuestTarget_<QuestId>" and attach a marker
local tag = "QuestTarget_" .. tostring(questId)
local target = nearestTarget(tag)
-- Clear old marker
if waypointGui and waypointGui.Parent and waypointGui.Parent ~= target then
waypointGui.Parent = nil
end
if target then
local bb = ensureWaypoint()
bb.Parent = target
bb.ExtentsOffsetWorldSpace = Vector3.new(0, 3, 0)
end
end
local RE = RS:WaitForChild("RPG_QuestTrackerUpdate")
RE.OnClientEvent:Connect(onUpdate)
How to tag world targets (Studio or Server):
-- Tag the Herb pickup parts so the tracker can find them:
game:GetService("CollectionService"):AddTag(workspace.Herbs.Herb1, "QuestTarget_FETCH_HERBS_01")
-- repeat for Herb2, Herb3, ...
DialogueClient
Dialogue bar for your RPG. Creates a simple ScreenGui at the bottom of the screen and exposes _G.RPG_ShowDialogue(text)
to set the message. Also listens to ReplicatedStorage.RPG_ShowDialogue
(RemoteEvent) so server scripts can :FireClient(player, "text")
to display lines. Use for NPC talk, quest updates, and system notices. Gui-only, non-persistent; reposition/style as needed.
Here’s 2 practical ways to use DialogueClient:
A) Client-only (fastest): call the global from any LocalScript.
-- Example: show dialogue when a ProximityPrompt is triggered (client)
local prompt = workspace.NPC.TalkPrompt -- your ProximityPrompt
prompt.Triggered:Connect(function()
if _G.RPG_ShowDialogue then
_G.RPG_ShowDialogue("Welcome, traveler. Press E to trade.")
end
end)
B) Server → Client (recommended): fire a RemoteEvent from the server; the client listens and shows text.
-- ServerScriptService/DialogueServerBridge (server)
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ShowDialogue = ReplicatedStorage:FindFirstChild("RPG_ShowDialogue") or Instance.new("RemoteEvent")
ShowDialogue.Name = "RPG_ShowDialogue"; ShowDialogue.Parent = ReplicatedStorage
-- Example: after a quest completes
local function onQuestComplete(player)
ShowDialogue:FireClient(player, "Quest complete! +100 XP, +50 Gold")
end
Note: _G
is not shared between server and client. Use a RemoteEvent to drive client dialogue from server code.
Bonus Script: DialogueChoices — depends on DialogueClient. Server sends a prompt + options; client shows clickable choices; selection returns to server to trigger actions (start quest, open shop, next line). API: _G.RPG_DialogueStart(player, prompt, { {id="ACCEPT", text="…"}, … })
:
-- DialogueChoicesClient
-- Requires DialogueClient (_G.RPG_ShowDialogue). Renders clickable choices and
-- returns the player's selection to the server.
local Players = game:GetService("Players")
local RS = game:GetService("ReplicatedStorage")
local player = Players.LocalPlayer
-- RemoteEvents
local RE_Show = RS:FindFirstChild("RPG_DialogueChoices") or Instance.new("RemoteEvent", RS)
RE_Show.Name = "RPG_DialogueChoices"
local RE_Chosen = RS:FindFirstChild("RPG_DialogueChoiceSelected") or Instance.new("RemoteEvent", RS)
RE_Chosen.Name = "RPG_DialogueChoiceSelected"
-- === UI ===
local gui = Instance.new("ScreenGui")
gui.Name = "RPG_DialogueChoicesGui"
gui.ResetOnSpawn = false
gui.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
gui.Parent = player:WaitForChild("PlayerGui")
local panel = Instance.new("Frame")
panel.Name = "Choices"
panel.Size = UDim2.new(0.6, 0, 0, 56)
panel.Position = UDim2.new(0.2, 0, 0.75, 0)
panel.BackgroundColor3 = Color3.fromRGB(20, 20, 24)
panel.BackgroundTransparency = 0.2
panel.Visible = false
panel.Parent = gui
local corner = Instance.new("UICorner", panel); corner.CornerRadius = UDim.new(0, 8)
local list = Instance.new("UIListLayout", panel)
list.FillDirection = Enum.FillDirection.Horizontal
list.HorizontalAlignment = Enum.HorizontalAlignment.Center
list.VerticalAlignment = Enum.VerticalAlignment.Center
list.Padding = UDim.new(0, 8)
local function clearChoices()
for _, ch in ipairs(panel:GetChildren()) do
if ch:IsA("TextButton") then ch:Destroy() end
end
end
local function makeChoiceButton(label, id)
local b = Instance.new("TextButton")
b.Size = UDim2.new(0, 140, 1, -16)
b.Position = UDim2.fromOffset(0, 8)
b.BackgroundColor3 = Color3.fromRGB(35, 35, 42)
b.TextColor3 = Color3.fromRGB(235,235,240)
b.Text = tostring(label)
b.AutoButtonColor = true
local c = Instance.new("UICorner", b); c.CornerRadius = UDim.new(0, 6)
b.Parent = panel
b.MouseButton1Click:Connect(function()
-- send selection to server, hide UI
panel.Visible = false
clearChoices()
RE_Chosen:FireServer(id)
end)
return b
end
-- Public helper: show choices locally (optional)
_G.RPG_ShowChoices = function(promptText, options)
if _G.RPG_ShowDialogue then _G.RPG_ShowDialogue(tostring(promptText)) end
clearChoices()
for _, opt in ipairs(options or {}) do
makeChoiceButton(opt.text or opt.id, opt.id)
end
panel.Visible = true
end
-- Server -> client entry point
RE_Show.OnClientEvent:Connect(function(promptText, options)
_G.RPG_ShowChoices(promptText, options)
end)
-- DialogueChoicesServer
-- Depends on DialogueClient on the client side (to display text).
-- Provides a simple branching dialogue entry point for NPCs.
local RS = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
-- Events
local RE_Show = RS:FindFirstChild("RPG_DialogueChoices") or Instance.new("RemoteEvent", RS)
RE_Show.Name = "RPG_DialogueChoices"
local RE_Chosen= RS:FindFirstChild("RPG_DialogueChoiceSelected") or Instance.new("RemoteEvent", RS)
RE_Chosen.Name = "RPG_DialogueChoiceSelected"
-- Optional helpers (wire if present)
local ShowDialogue = RS:FindFirstChild("RPG_ShowDialogue") -- RemoteEvent (from DialogueClient bridge)
local QuestOK = function() return _G.RPG_QuestService ~= nil end
-- API: start a dialogue with choices for a player
_G.RPG_DialogueStart = function(player: Player, promptText: string, options: {{id: string, text: string}})
-- Send to client; client displays buttons and returns selection via RE_Chosen
RE_Show:FireClient(player, promptText, options)
end
-- Example NPC flows per player (simple state remembered for last starter)
local awaitingChoice = {} -- [Player] = handler table { map of choice id -> function(player) end }
-- Helper: define a simple menu for a player
local function startBlacksmithDialogue(p: Player)
-- 1) send choices
_G.RPG_DialogueStart(p, "Blacksmith: Need something?", {
{id="BUY", text="Open shop"},
{id="QUEST", text="Got any work?"},
{id="GOODBYE", text="Goodbye"}
})
-- 2) define what each option does
awaitingChoice[p] = {
BUY = function(plr)
if ShowDialogue then ShowDialogue:FireClient(plr, "Opening shop...") end
-- your shop RemoteEvent here, e.g.: RS.RPG_OpenShop:FireClient(plr)
end,
QUEST = function(plr)
if QuestOK() then
_G.RPG_QuestService.start(plr, "FETCH_HERBS_01")
if ShowDialogue then ShowDialogue:FireClient(plr, "Bring me 5 herbs from the woods.") end
else
if ShowDialogue then ShowDialogue:FireClient(plr, "Come back later.") end
end
end,
GOODBYE = function(plr)
if ShowDialogue then ShowDialogue:FireClient(plr, "Stay sharp.") end
end,
}
end
-- Receive the player's choice and invoke the mapped handler
RE_Chosen.OnServerEvent:Connect(function(player, choiceId: string)
local map = awaitingChoice[player]
if not map then return end
local fn = map[choiceId]
awaitingChoice[player] = nil -- one-shot; rebuild for multi-step branches
if type(fn) == "function" then
fn(player)
end
end)
--[[ Practical usage:
-- Hook to a ProximityPrompt on an NPC (server):
-- workspace.NPC.Blacksmith.TalkPrompt.Triggered:Connect(startBlacksmithDialogue)
-- Or start from server logic:
-- startBlacksmithDialogue(player)
-- Multi-step branching:
-- Inside a handler, call startBlacksmithDialogue(player) again with a new prompt/options,
-- and set awaitingChoice[player] = { ... } for the next step.
-- Security: All actions (quests, shop, rewards) happen on the server *after*
-- you receive RE_Chosen from the specific player who clicked.
]]
^ How to use:
- Put DialogueChoicesClient in StarterPlayerScripts and DialogueChoicesServer in ServerScriptService.
- From any server script (e.g., NPC ProximityPrompt), call:
_G.RPG_DialogueStart(player, "Need anything crafted?", {
{id="BUY", text="Open shop"},
{id="QUEST", text="Got any work?"},
{id="GOODBYE", text="Goodbye"}
})
- Handle selections in
DialogueChoicesServer
(e.g., start a quest, open shop, say goodbye).
- All Gui shows client-side; decisions are validated and executed server-side.
DataStoreManager
Wrapper around GetAsync
/SetAsync
with pcall
. Use it to save/load a small table per player (e.g., Level/XP/Gold). Returns nil
on load failure; you provide defaults.
Minimal save/load using DataStoreManager:
local Players = game:GetService("Players")
local DM = require(game.ServerStorage.DataStoreManager) -- pull in the wrapper module
-- Build a unique datastore key for each player (e.g., "plr_12345678").
local function key(p) return "plr_"..p.UserId end
-- On player join: load their data (or use defaults) and create leaderstats.
Players.PlayerAdded:Connect(function(p)
-- Try to load saved data; if none, fall back to defaults.
local d = DM.loadAsync(key(p)) or {Level=1, XP=0, Gold=0}
-- Create the leaderstats folder so stats show on the Roblox leaderboard.
local ls = Instance.new("Folder")
ls.Name = "leaderstats"
ls.Parent = p
-- Create an IntValue under leaderstats for each saved stat.
for name, val in pairs(d) do
local v = Instance.new("IntValue", ls)
v.Name = name
v.Value = val
end
end)
-- On player leave: save current leaderstats back to the datastore.
Players.PlayerRemoving:Connect(function(p)
local ls = p:FindFirstChild("leaderstats") -- guard: may not exist
if not ls then return end
-- Write a compact table. Assumes Level/XP/Gold IntValues exist under leaderstats.
DM.saveAsync(key(p), {
Level = ls.Level.Value,
XP = ls.XP.Value,
Gold = ls.Gold.Value
})
end)
Notes:
- Server-only; enable API Access in Game Settings for Studio tests.
- DataStores throttle; save small tables, not giant inventories.
- For merge/update behavior, consider switching to
UpdateAsync
later.
Bonus Script: SafeAutosave — requires DataStoreManager
. Loads primary → falls back to rolling backups, autosaves every N seconds & on leave, and migrates old saves to the current schema. Public API: _G.RPG_SaveNow(player)
and _G.RPG_RestoreBackup(player)
:
-- Place in ServerScriptService
-- SafeAutosave
-- Requires: ServerStorage/DataStoreManager (the wrapper module from this pack)
-- What it does:
-- • Loads per-player data; if missing, tries backups.
-- • Autosaves on a timer and on PlayerRemoving/BindToClose.
-- • Rotates backups every few saves (bak1/bak2).
-- • Migrates old schema to CURRENT_VERSION.
-- Data kept small: {Level, XP, Gold, v}
local Players = game:GetService("Players")
local ServerStorage= game:GetService("ServerStorage")
local DM = require(ServerStorage:WaitForChild("DataStoreManager"))
-- ===== CONFIG =====
local AUTOSAVE_SEC = 60 -- autosave period, change as you desire
local BACKUP_EVERY = 5 -- write a backup every N successful saves
local CURRENT_VERSION = 2 -- bump when your schema changes
-- ===== KEYS =====
local function key(p) return "plr_"..p.UserId end
local function keyBak(p, i) return ("plr_%d_bak%d"):format(p.UserId, i) end -- i=1 or 2
-- ===== DEFAULTS & MIGRATION =====
local function defaultData()
return { v = CURRENT_VERSION, Level = 1, XP = 0, Gold = 0 }
end
local function migrate(data)
data = data or {}
-- v1 -> v2 (example): ensure fields exist
if not data.v then data.v = 1 end
if data.v <= 1 then
data.Level = data.Level or 1
data.XP = data.XP or 0
data.Gold = data.Gold or 0
data.v = 2
end
-- future: if data.v < CURRENT_VERSION then step through more migrations here
return data
end
-- ===== PER-PLAYER STATE =====
local state = {} -- [Player] = { dirty=bool, saves=0, lastSave=osTime, conns={} }
local function markDirty(p)
local st = state[p]; if st then st.dirty = true end
end
local function readFromLeaderstats(p)
local ls = p:FindFirstChild("leaderstats")
if not ls then return defaultData() end
return {
v = CURRENT_VERSION,
Level = (ls:FindFirstChild("Level") and ls.Level.Value) or 1,
XP = (ls:FindFirstChild("XP") and ls.XP.Value) or 0,
Gold = (ls:FindFirstChild("Gold") and ls.Gold.Value) or 0,
}
end
local function writeToLeaderstats(p, data)
local ls = p:FindFirstChild("leaderstats") or Instance.new("Folder", p)
ls.Name = "leaderstats"
local function ensureInt(name, val)
local v = ls:FindFirstChild(name) or Instance.new("IntValue", ls)
v.Name = name; v.Value = val
end
ensureInt("Level", data.Level or 1)
ensureInt("XP", data.XP or 0)
ensureInt("Gold", data.Gold or 0)
end
local function loadWithBackups(p)
-- Try primary, then bak1, then bak2
local data = DM.loadAsync(key(p))
if not data then data = DM.loadAsync(keyBak(p, 1)) end
if not data then data = DM.loadAsync(keyBak(p, 2)) end
return migrate(data) or defaultData()
end
local function saveNow(p, forceBackup)
local st = state[p]; if not st then return false end
local data = readFromLeaderstats(p)
local ok = DM.saveAsync(key(p), data)
if ok then
st.saves += 1
st.lastSave = os.time()
st.dirty = false
if forceBackup or (st.saves % BACKUP_EVERY == 0) then
-- Alternate bak1/bak2
local idx = (st.saves / BACKUP_EVERY) % 2 < 1 and 1 or 2
DM.saveAsync(keyBak(p, idx), data)
end
else
warn("[SafeAutosave] save failed for", p.UserId)
end
return ok
end
_G.RPG_SaveNow = function(p) return saveNow(p, true) end
_G.RPG_RestoreBackup = function(p)
-- Load the newest available backup (try bak1, then bak2), write to leaderstats, and mark dirty
local data = DM.loadAsync(keyBak(p, 1)) or DM.loadAsync(keyBak(p, 2))
if not data then return false, "No backup" end
data = migrate(data)
writeToLeaderstats(p, data)
markDirty(p)
return true
end
-- ===== LIFECYCLE =====
local function hookChangeSignals(p)
local ls = p:FindFirstChild("leaderstats"); if not ls then return end
state[p].conns = state[p].conns or {}
for _, name in ipairs({"Level","XP","Gold"}) do
local v = ls:FindFirstChild(name)
if v then
table.insert(state[p].conns, v:GetPropertyChangedSignal("Value"):Connect(function()
markDirty(p)
end))
end
end
end
local function clearSignals(p)
local st = state[p]
if st and st.conns then
for _, c in ipairs(st.conns) do pcall(function() c:Disconnect() end) end
st.conns = {}
end
end
Players.PlayerAdded:Connect(function(p)
state[p] = { dirty=false, saves=0, lastSave=0, conns={} }
-- Load data (with backup fallback) and apply to leaderstats
local data = loadWithBackups(p)
writeToLeaderstats(p, data)
hookChangeSignals(p)
-- Autosave loop
task.spawn(function()
while state[p] do
task.wait(AUTOSAVE_SEC)
if state[p] and state[p].dirty then saveNow(p) end
end
end)
end)
local function onLeave(p)
if not state[p] then return end
saveNow(p, true) -- force a backup on exit
clearSignals(p)
state[p] = nil
end
Players.PlayerRemoving:Connect(onLeave)
game:BindToClose(function()
for _, p in ipairs(Players:GetPlayers()) do
onLeave(p)
end
end)
- Drop SafeAutosave.server.lua into ServerScriptService.
- Keep using/adjusting
leaderstats.Level/XP/Gold
as normal; this script handles saves.
- If you need to force a save or recover:
_G.RPG_SaveNow(player) -- force immediate save + backup
_G.RPG_RestoreBackup(player) -- load latest backup into leaderstats
SaveLoadServer
Server script that auto-loads and auto-saves leaderstats
(Level, XP, Gold) using DataStoreManager
. It creates leaderstats
if missing and persists on leave/shutdown. You don’t call it directly—just change leaderstats
in your game; this script handles persistence.
Practical uses:
-- Anywhere on the server: award progress; SaveLoadServer will persist it.
local Players = game:GetService("Players")
local Currency = require(game.ReplicatedStorage.CurrencyModule)
local function onQuestComplete(plr)
local ls = plr:FindFirstChild("leaderstats"); if not ls then return end
if _G.RPG_AddXP then _G.RPG_AddXP(plr, 100) end -- XP/Level updates
Currency.add(ls, 50) -- +50 Gold
-- No save call needed. SaveLoadServer writes on leave/shutdown.
end
-- Example: save a small settings table with DM (separate from SLS)
local DM = require(game.ServerStorage.DataStoreManager)
local function settingsKey(p) return "settings_"..p.UserId end
-- load
local settings = DM.loadAsync(settingsKey(player)) or {Music=true, CameraShake=true}
-- save later
DM.saveAsync(settingsKey(player), settings)
Note: If you add more stats later, just ensure leaderstats
has matching IntValues; this script will save them when you update the save table.