RPG Scripts Pack Plugin Useful Scripts/APIs for RPGs + Documentation

RPG Scripts Pack is a Roblox Studio plugin that provides 20 essential Script, LocalScript, and ModuleScript templates and APIs designed for RPGs, MMORPGs, or similar themed experiences! Check out the Documentation for more details and free extra scripts!

To use this Plugin, Multi-select scripts from the UI and insert them into your Roblox Studio. Each script is automatically placed in it’s correct location (ServerScriptService, ReplicatedStorage, StarterPlayerScripts, etc.) inside a folder named RPG Scripts Pack, for organization and your convenience.

Further use of this Plugin is enhanced by utilizing it’s API functionality and reviewing the Documentation on this DevForum Post for total customization and API utilization in your RPG experiences.

:package: Included Systems (20 total + free scripts & included APIs in Documentation)

  1. CurrencyModule – Currency helpers (gold/coins).
  2. StatsModule – Default stats + XP curve.
  3. XPLevelServer – Leaderstats + leveling logic.
  4. InventoryModule – Server-side per-player inventory.
  5. QuestService – Start/progress/complete quests.
  6. DialogueClient – On-screen dialogue box with _G.RPG_ShowDialogue().
  7. DataStoreManager – Simple DataStore wrapper.
  8. SaveLoadServer – Auto save/load Level, XP, Gold.
  9. DamageModule – Damage formula with DEF.
  10. CombatServer_G.RPG_DealDamage() API.
  11. NPCPatrol – Waypoint patrol scaffold.
  12. LootDropServer_G.RPG_DropLoot() spawns loot items.
  13. ShopNPCModule – Item pricing + canAfford checks.
  14. CraftingModule – Combine items → output recipe.
  15. EquipmentModule – Slots + stat bonuses.
  16. FootstepClient – Basic footstep sounds.
  17. CameraShakeClient_G.RPG_ShakeCamera() for screen shake.
  18. MinimapClient – Circular minimap UI.
  19. MusicManager – BGM play/stop controls.
  20. DayNightCycle – Smooth Lighting time cycle.

All scripts are commented, expandable, and ready to import.

:bullseye: Who This Is For

  • Developers making RPGs/MMOs, story driven games, or action adventure experiences
  • Solo devs or beginners who want to skip tedious setup and steps
  • Teams who need a consistent template pack for prototyping
  • Just anyone who wants a pack of useful scripts and APIs

:dollar_banknote: Price

$4.99 on the Roblox Creator Store.
:backhand_index_pointing_right: Purchase Plugin https://create.roblox.com/store/asset/120112968272920/RPG-Scripts-Pack

:camera_with_flash: Screenshots, GIFs, and Concept Images/Art



Anim03
Anim05


Anim01

:high_voltage: Plugin Capabilities

  • Saves hours of repetitive setup for anyone mass producing RPG or dungeon styled experiences especially
  • Provides 20 production-ready systems in one pack + free addons in Documentation, and message me if you require more support! :slight_smile: rob2002 is my Discord Tag and feel free to respond in this DevForum post
  • Clean templates make it easy to learn and expand for beginners and experienced developers alike
  • One-time purchase that will speed up future RPG projects, plus useful API utilities

If you do purchase this plugin, thank you for supporting not only me but the creation of more plugins and packs.

1 Like

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.

DamageModule

Tiny helper that returns final damage after DEF mitigation. Use Damage.compute(base, DEF) and apply it to a Humanoid with :TakeDamage().

Practical uses:

Option A — call the module directly

-- ServerScriptService/OnHit
local RS = game:GetService("ReplicatedStorage")
local Damage = require(RS:WaitForChild("DamageModule"))

local function onSwordHit(attacker: Player, victimHum: Humanoid)
    local BASE = 25                      -- your base attack
    local DEF  = 0                       -- TODO: pull victim's DEF from your stats system
    local amt  = Damage.compute(BASE, DEF)
    victimHum:TakeDamage(amt)
end

Option B — if you’re using CombatServer’s API

-- Anywhere on the server
-- CombatServer internally uses DamageModule for you.
local function onSwordHit(attacker: Player, victimHum: Humanoid)
    if _G.RPG_DealDamage then _G.RPG_DealDamage(attacker, victimHum, 25) end
end

Where to get DEF: from your stats/equipment (e.g., StatsModule + EquipmentModule). Example:

local Stats = require(RS.StatsModule)
local Equip = require(RS.EquipmentModule)
-- DEF = baseDEF + Equip.statsFrom(equipmentTable).DEF

Bonus Script: StatusEffectsServer — requires DamageModule. Apply DOT with _G.RPG_StatusApply(attacker, humanoid, effectId, power, duration). Effects: Burn, Poison, Bleed (stacking, timed). Uses Damage.compute(power, DEF) each tick; supports Humanoid attributes like Resist_Burn (0–1). Cleanse with _G.RPG_StatusCleanse(humanoid, effectId?):

-- Place in ServerScriptService
-- StatusEffectsServer
-- Requires: ReplicatedStorage/DamageModule
-- Adds damage-over-time effects (Burn/Poison/Bleed) with stacking + durations.
-- API:
--   _G.RPG_StatusApply(attacker, victimHumanoid, effectId, power?, duration?)
--   _G.RPG_StatusCleanse(victimHumanoid, effectId?) -- nil = remove all
--   _G.RPG_StatusHas(victimHumanoid, effectId) -> boolean
--
-- Notes:
--   • Each tick uses DamageModule.compute(base, DEF). Replace getDEF() to fit your stat system.
--   • Optional resistances: set victimHumanoid:GetAttribute("Resist_<EffectId>") in [0..1].
--   • Safe on NPCs and Players. Auto-cleans on death/removal.

local RS = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")

local Damage = require(RS:WaitForChild("DamageModule"))

-- === CONFIG ===
local TICK_HZ = 10                     -- scheduler frequency; each effect has its own interval
local DEFAULTS = {
	Burn   = { interval = 1.00, maxStacks = 3, refreshOnApply = true },
	Poison = { interval = 0.75, maxStacks = 5, refreshOnApply = true },
	Bleed  = { interval = 0.50, maxStacks = 3, refreshOnApply = true },
}
local DEFAULT_DURATION = 5.0           -- seconds, if not provided to Apply()
local DEFAULT_POWER    = 3             -- base damage per tick per stack (before DEF, resist)

-- How to read DEF for mitigation. Adjust to your game.
local function getDEF(hum)
	-- Example: read from leaderstats.DEF if this Humanoid belongs to a Player
	local char = hum.Parent
	if not char then return 0 end
	local plr = game.Players:GetPlayerFromCharacter(char)
	if plr then
		local ls = plr:FindFirstChild("leaderstats")
		local v = ls and ls:FindFirstChild("DEF")
		if v then return tonumber(v.Value) or 0 end
	end
	return 0
end

-- === INTERNAL STATE ===
-- effects[hum] = { [id] = { stacks=int, power=number, expires=t, nextTick=t } }
local effects = {}

local function now()
	return time()
end

local function alive(hum)
	return hum and hum.Parent and hum.Health > 0
end

local function resistFactor(hum, id)
	local r = hum:GetAttribute("Resist_"..id)
	r = (type(r)=="number") and math.clamp(r, 0, 1) or 0
	return 1 - r
end

local function ensureHum(hum)
	effects[hum] = effects[hum] or {}
	-- Clean on death automatically
	if not hum:GetAttribute("__RPG_StEffBound") then
		hum.Died:Connect(function()
			effects[hum] = nil
		end)
		hum.AncestryChanged:Connect(function(_, parent)
			if not parent then effects[hum] = nil end
		end)
		hum:SetAttribute("__RPG_StEffBound", true)
	end
	return effects[hum]
end

local function tickEffect(hum, id, data)
	if not alive(hum) then return false end
	local def = getDEF(hum)
	local base = math.max(1, (data.power or DEFAULT_POWER) * (data.stacks or 1))
	base = base * resistFactor(hum, id)
	local amt = Damage.compute(base, def)
	-- Guard: Humanoid may disappear mid-frame
	local ok = pcall(function() hum:TakeDamage(amt) end)
	if not ok then return false end
	return true
end

-- === PUBLIC API ===
_G.RPG_StatusApply = function(attacker, victimHum, effectId, power, duration)
	if not victimHum or not victimHum:IsA("Humanoid") then return end
	if not alive(victimHum) then return end

	effectId = tostring(effectId or "Burn")
	local cfg = DEFAULTS[effectId] or DEFAULTS.Burn
	local slot = ensureHum(victimHum)[effectId]

	local dur = tonumber(duration) or DEFAULT_DURATION
	local pwr = tonumber(power) or DEFAULT_POWER
	local nowt = now()

	if not slot then
		slot = { stacks = 1, power = pwr, expires = nowt + dur, nextTick = nowt + cfg.interval }
		ensureHum(victimHum)[effectId] = slot
	else
		-- stacking
		slot.stacks = math.clamp((slot.stacks or 1) + 1, 1, cfg.maxStacks or 1)
		slot.power  = math.max(slot.power or 0, pwr)
		if cfg.refreshOnApply then
			slot.expires = nowt + dur
		end
	end
end

_G.RPG_StatusCleanse = function(victimHum, effectId)
	if not victimHum or not effects[victimHum] then return end
	if effectId then
		effects[victimHum][tostring(effectId)] = nil
	else
		effects[victimHum] = nil
	end
end

_G.RPG_StatusHas = function(victimHum, effectId)
	local t = effects[victimHum]; if not t then return false end
	return t[tostring(effectId)] ~= nil
end

-- === SCHEDULER ===
task.spawn(function()
	local step = 1 / TICK_HZ
	while true do
		local tnow = now()
		for hum, map in pairs(effects) do
			if not alive(hum) then
				effects[hum] = nil
			else
				for id, data in pairs(map) do
					-- expire?
					if tnow >= (data.expires or 0) then
						map[id] = nil
					elseif tnow >= (data.nextTick or 0) then
						-- Bleed gets extra damage when moving
						local extra = 0
						if id == "Bleed" then
							local move = hum.MoveDirection
							if move.Magnitude > 0.1 then
								extra = (data.power or DEFAULT_POWER) * 0.5
							}
						end
						local oldPower = data.power
						if extra > 0 then data.power = (data.power or 0) + extra end
						if not tickEffect(hum, id, data) then
							map[id] = nil
						end
						data.power = oldPower
						-- schedule next tick
						local cfg = DEFAULTS[id] or DEFAULTS.Burn
						data.nextTick = tnow + (cfg.interval or 1)
					end
				end
				-- clear empty map
				local empty = true
				for _ in pairs(map) do empty = false break end
				if empty then effects[hum] = nil end
			end
		end
		task.wait(step)
	end
end)

Apply Burn for 5s at 4 dmg/tick (stacks up to 3):

-- on weapon hit / projectile validation:
_G.RPG_StatusApply(attackerPlayer, victimHumanoid, "Burn", 4, 5)

Apply Poison (defaults to 0.75s tick, max 5 stacks), 8s at 2 dmg/tick:

_G.RPG_StatusApply(attackerPlayer, victimHumanoid, "Poison", 2, 8)

Apply Bleed for 6s at 3 dmg/tick (moves = extra 50% that tick):

_G.RPG_StatusApply(attackerPlayer, victimHumanoid, "Bleed", 3, 6)

Cleanse one / all:

_G.RPG_StatusCleanse(victimHumanoid, "Poison") -- remove a single effect
_G.RPG_StatusCleanse(victimHumanoid)           -- remove all effects

Optional resistances (set once when spawning enemies):

-- 30% burn resistance, 50% poison resistance
victimHumanoid:SetAttribute("Resist_Burn", 0.3)
victimHumanoid:SetAttribute("Resist_Poison", 0.5)

Tip: If you already track DEF via EquipmentModule/leaderstats, this script’s getDEF() will pick it up automatically; otherwise, change getDEF() to your own logic.

CombatServer

Server script that exposes _G.RPG_DealDamage(attacker, victimHumanoid, baseDamage). Computes final damage (via DamageModule) and applies it with Humanoid:TakeDamage. Call it from server code after a confirmed hit. For client-detected hits, send a RemoteEvent to the server and call this API there.

Minimal uses:

A) Server confirms the hit (preferred):

-- On a confirmed melee/raycast hit (server-side):
local victimHum = targetModel:FindFirstChildOfClass("Humanoid")
if _G.RPG_DealDamage and victimHum then
    _G.RPG_DealDamage(attackerPlayer, victimHum, 25) -- 25 base damage
end

B) Client detects hit → ask server to apply:

-- ServerScriptService/CombatBridge.server.lua
local RS = game:GetService("ReplicatedStorage")
local RE = RS:FindFirstChild("RPG_RequestDealDamage") or Instance.new("RemoteEvent", RS)
RE.Name = "RPG_RequestDealDamage"

RE.OnServerEvent:Connect(function(player, victimHum)
	-- Validate instance/type & proximity (anti-exploit)
	if typeof(victimHum) ~= "Instance" or not victimHum:IsA("Humanoid") then return end
	local char = player.Character
	local root = char and char:FindFirstChild("HumanoidRootPart")
	local vroot = victimHum.Parent and victimHum.Parent:FindFirstChild("HumanoidRootPart")
	if not (root and vroot) then return end
	if (root.Position - vroot.Position).Magnitude > 10 then return end -- range check

	if _G.RPG_DealDamage then _G.RPG_DealDamage(player, victimHum, 25) end
end)

Client-side example:

-- LocalScript (after client thinks it hit something):
game.ReplicatedStorage.RPG_RequestDealDamage:FireServer(victimHumanoid)

Notes:

  • Avoid applying damage on the client. Call _G.RPG_DealDamage on the server.
  • Add your own validation (team/friendly-fire, cooldowns, line-of-sight) before calling.
  • To include DEF/ATK from gear, compute them before calling Damage.compute.

Bonus Script: HitConfirmServer — depends on CombatServer. Adds critical hits, headshot multiplier, per-target i-frames (anti multi-hit), optional knockback, and floating damage numbers. Call _G.RPG_ApplyHit(attacker, victimHumanoid, baseDamage, hitPart):

-- Place in ServerScriptService
-- HitConfirmServer
-- Requires: CombatServer (exposes _G.RPG_DealDamage(attacker, victimHumanoid, baseDamage))
-- Adds: crits, headshot multiplier, i-frames (per target), optional knockback, damage numbers.

local RS = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")

-- RemoteEvent for floating numbers (client renderer below)
local RE = RS:FindFirstChild("RPG_ShowDamageNumber") or Instance.new("RemoteEvent", RS)
RE.Name = "RPG_ShowDamageNumber"

-- ==== CONFIG ====
local CRIT_CHANCE     = 0.20   -- 20% crit
local CRIT_MULT       = 1.5    -- x1.5 on crit
local HEADSHOT_MULT   = 2.0    -- x2 on head hits
local IFRAMES_SEC     = 0.35   -- per-victim hit cooldown to prevent multi-hit spam
local MAX_RANGE       = 12     -- basic sanity check vs attacker
local DO_KNOCKBACK    = true
local KNOCKBACK_FORCE = 45     -- studs/sec impulse

-- ==== STATE ====
local lastHitAt = {} -- [Humanoid] = unixTime

local function now() return time() end

local function rootOf(model)
	if not model then return nil end
	return model:FindFirstChild("HumanoidRootPart") or model.PrimaryPart
end

local function inRange(attacker, victimHum)
	local aRoot = attacker and attacker.Character and rootOf(attacker.Character)
	local vRoot = victimHum and rootOf(victimHum.Parent)
	if not (aRoot and vRoot) then return true end -- skip in case of NPC attacks etc.
	return (aRoot.Position - vRoot.Position).Magnitude <= MAX_RANGE
end

local function knockback(attacker, victimHum)
	if not DO_KNOCKBACK then return end
	local vRoot = rootOf(victimHum.Parent); if not vRoot then return end
	local aRoot = attacker and attacker.Character and rootOf(attacker.Character)
	local dir = aRoot and (vRoot.Position - aRoot.Position).Unit or Vector3.new(0,0,0)
	if dir.Magnitude == 0 then dir = Vector3.new(0,0,0) end
	-- Small impulse using AssemblyLinearVelocity
	vRoot.AssemblyLinearVelocity += (dir + Vector3.new(0,0.15,0)) * KNOCKBACK_FORCE
end

-- PUBLIC API: call this instead of raw _G.RPG_DealDamage in your hit logic
_G.RPG_ApplyHit = function(attacker: Player?, victimHum: Humanoid, baseDamage: number, hitPart: Instance?)
	if type(_G.RPG_DealDamage) ~= "function" then
		warn("[HitConfirmServer] Missing _G.RPG_DealDamage (is CombatServer running?)")
		return false, "no_api"
	end
	if not victimHum or victimHum.Health <= 0 then return false, "dead" end
	-- I-frames
	local t = now()
	local last = lastHitAt[victimHum] or 0
	if t - last < IFRAMES_SEC then return false, "iframes" end

	-- Simple range sanity
	if attacker and not inRange(attacker, victimHum) then return false, "range" end

	-- Crit + headshot multipliers
	local mult = 1.0
	local isHead = (hitPart and hitPart.Name == "Head") or false
	if isHead then mult *= HEADSHOT_MULT end
	local isCrit = (math.random() < CRIT_CHANCE)
	if isCrit then mult *= CRIT_MULT end

	local adj = math.max(1, math.floor((tonumber(baseDamage) or 1) * mult + 0.5))

	-- Apply damage via CombatServer (handles DEF via DamageModule inside)
	_G.RPG_DealDamage(attacker, victimHum, adj)

	-- Mark i-frames and apply light knockback
	lastHitAt[victimHum] = t
	knockback(attacker, victimHum)

	-- Tell attacker to show a damage number (position best-effort)
	local pos =
		(hitPart and hitPart:IsA("BasePart") and hitPart.Position)
		or (rootOf(victimHum.Parent) and rootOf(victimHum.Parent).Position)
		or Vector3.new()
	if attacker then
		RE:FireClient(attacker, pos, adj, isCrit, isHead)
	end

	return true, adj, {crit=isCrit, head=isHead}
end

How to use (server, after you validate a hit):

-- Example: melee hit confirmed server-side
local hum = targetModel:FindFirstChildOfClass("Humanoid")
_G.RPG_ApplyHit(attackerPlayer, hum, 25, targetModel:FindFirstChild("Head"))
-- Place in StarterPlayerScripts
-- DamageNumbersClient
-- Renders floating damage numbers when server fires RPG_ShowDamageNumber.

local RS = game:GetService("ReplicatedStorage")
local Debris = game:GetService("Debris")
local RE = RS:WaitForChild("RPG_ShowDamageNumber")

local function makeBillboard(pos: Vector3, text: string, color: Color3)
	local part = Instance.new("Part")
	part.Anchored = true; part.CanCollide = false; part.Transparency = 1
	part.Size = Vector3.new(0.2,0.2,0.2)
	part.CFrame = CFrame.new(pos + Vector3.new(0, 2.3, 0))
	part.Parent = workspace

	local gui = Instance.new("BillboardGui")
	gui.Size = UDim2.fromOffset(0, 0) -- size driven by text
	gui.AlwaysOnTop = true
	gui.Parent = part

	local label = Instance.new("TextLabel")
	label.BackgroundTransparency = 1
	label.Font = Enum.Font.GothamBlack
	label.TextScaled = true
	label.TextStrokeTransparency = 0.5
	label.Text = text
	label.TextColor3 = color
	label.Size = UDim2.fromOffset(0, 0)
	label.AutomaticSize = Enum.AutomaticSize.XY
	label.Parent = gui

	-- Float up + fade
	task.spawn(function()
		local t0 = time()
		local dur = 0.8
		while time() - t0 < dur do
			local a = (time() - t0) / dur
			part.CFrame = part.CFrame + Vector3.new(0, 1.2*task.wait(0), 0)
			label.TextTransparency = a
		end
		part:Destroy()
	end)

	Debris:AddItem(part, 2)
end

RE.OnClientEvent:Connect(function(pos: Vector3, amount: number, isCrit: boolean, isHead: boolean)
	local c = isCrit and Color3.fromRGB(255,215,0) or (isHead and Color3.fromRGB(255,120,120)) or Color3.fromRGB(255,255,255)
	local prefix = isHead and "HS " or ""
	local suffix = isCrit and "!" or ""
	makeBillboard(pos, prefix .. tostring(amount) .. suffix, c)
end)

NPCPatrol

Server script that moves NPCs along waypoint parts in Workspace.NPC_Waypoints using Roblox pathfinding. Put NPC models in a folder Workspace.NPCs (must have a Humanoid). Optionally add a StringValue named PatrolRoute inside an NPC to pick a subfolder route. The script loops the route forever.

Setup:

  1. In Workspace, make a folder NPC_Waypoints. (if not NPCPatrol should create one for you.)
  • Add Parts named 1, 2, 3… (Anchored=true, CanCollide=false).
  • Optional: create subfolders (e.g., TownRoute, GateRoute) each with their own numbered parts.
  1. Put your NPC models in a folder in Workspace called NPCs.
  • Each NPC needs a Humanoid and HumanoidRootPart.
  • Optional: inside an NPC, add a StringValue named PatrolRoute set to a subfolder name (e.g., TownRoute).
  1. Insert NPCPatrol through the Script Pack Plugin!

Example code:

-- ServerScriptService/NPCPatrolMini

local NPCs = workspace:FindFirstChild("NPCs") or Instance.new("Folder", workspace)
NPCs.Name = "NPCs"

local WPRoot = workspace:FindFirstChild("NPC_Waypoints") or Instance.new("Folder", workspace)
WPRoot.Name = "NPC_Waypoints"

local function getWaypoints(route)
	local container = route and WPRoot:FindFirstChild(route) or WPRoot
	if not container then return {} end
	local pts = {}
	for _, inst in ipairs(container:GetChildren()) do
		if inst:IsA("BasePart") then table.insert(pts, inst) end
	end
	table.sort(pts, function(a,b)
		local na, nb = tonumber(a.Name), tonumber(b.Name)
		return na and nb and na < nb or a.Name < b.Name
	end)
	return pts
end

local function patrol(npc: Model)
	local hum = npc:FindFirstChildOfClass("Humanoid")
	local root = npc:FindFirstChild("HumanoidRootPart")
	if not (hum and root) then return end

	local routeVal = npc:FindFirstChild("PatrolRoute")
	local routeName = routeVal and routeVal:IsA("StringValue") and routeVal.Value or nil
	local pts = getWaypoints(routeName)
	if #pts == 0 then return end

	while npc.Parent and hum.Health > 0 do
		for _, p in ipairs(pts) do
			if not (npc.Parent and hum.Health > 0) then break end
			hum:MoveTo(p.Position)
			hum.MoveToFinished:Wait()
			task.wait(0.25) -- small pause at each point
		end
	end
end

for _, m in ipairs(NPCs:GetChildren()) do
	if m:IsA("Model") and m:FindFirstChildOfClass("Humanoid") then
		task.spawn(patrol, m)
	end
end

NPCs.ChildAdded:Connect(function(m)
	if not m:IsA("Model") then return end
	m:WaitForChild("Humanoid", 5)
	m:WaitForChild("HumanoidRootPart", 5)
	if m:FindFirstChildOfClass("Humanoid") then
		task.spawn(patrol, m)
	end
end)

BonusScript: GuardChaseAI — depends on NPCPatrol. NPCs on patrol will spot players (radius+FOV+line-of-sight), pause patrol, pathfind to chase, then search and resume the original patrol if they lose the target. Drop in ServerScriptService. Optional one-line patch lets patrol pause cleanly:

-- GuardChaseAI
-- Depends-on conventions from NPCPatrol:
--   • NPCs live under Workspace/NPCs (Model with Humanoid + HumanoidRootPart)
--   • (Optional) StringValue "PatrolRoute" inside NPC selects a waypoint subfolder
-- Integrates by pausing patrol via a model attribute: npc:SetAttribute("RPG_PatrolPaused", true/false)

local Players = game:GetService("Players")
local Workspace = game:GetService("Workspace")
local PathfindingService = game:GetService("PathfindingService")
local RunService = game:GetService("RunService")

-- === CONFIG ===
local NPCS_FOLDER_NAME    = "NPCs"
local TICK_SEC            = 0.25       -- main loop tick per NPC
local DETECT_RADIUS       = 80         -- studs
local FOV_DEG             = 110        -- vision cone
local LOSE_TIME_SEC       = 4          -- time without sight to give up
local SEARCH_TIME_SEC     = 3          -- linger at last seen point before giving up
local REPATH_SEC          = 0.75       -- recompute path while chasing
local HIT_DISTANCE        = 4          -- "touch" distance (optional hook point)
local PAUSE_PATROL        = true       -- set attribute so patrol can pause (see patch below)

-- === OPTIONAL PATCH to your NPCPatrol (recommended):
--   inside your MoveTo loop for each NPC, add:
--       if npc:GetAttribute("RPG_PatrolPaused") then
--           task.wait(0.1); continue
--       end
-- This prevents the patrol loop fighting with chase movement.

-- === HELPERS ===
local function getHumanoid(npc: Instance): Humanoid?
	if not npc or not npc:IsA("Model") then return nil end
	return npc:FindFirstChildOfClass("Humanoid")
end

local function getRoot(npcModel: Model): BasePart?
	return npcModel:FindFirstChild("HumanoidRootPart") or npcModel.PrimaryPart
end

local function dot(a: Vector3, b: Vector3) return a:Dot(b) end

local function inFOV(npcRoot: BasePart, targetPos: Vector3, fovDeg: number)
	local toTarget = (targetPos - npcRoot.Position)
	local flat = Vector3.new(toTarget.X, 0, toTarget.Z)
	if flat.Magnitude < 1e-3 then return true end
	local dir = flat.Unit
	local look = Vector3.new(npcRoot.CFrame.LookVector.X, 0, npcRoot.CFrame.LookVector.Z).Unit
	local c = dot(dir, look) -- cos(theta)
	local cosHalf = math.cos(math.rad(fovDeg * 0.5))
	return c >= cosHalf
end

local rayParams = RaycastParams.new()
rayParams.FilterType = Enum.RaycastFilterType.Exclude

local function hasLineOfSight(npcModel: Model, fromPos: Vector3, targetPart: BasePart)
	local ignore = {npcModel}
	local res = Workspace:Raycast(fromPos, (targetPart.Position - fromPos), rayParams and (function()
		local rp = RaycastParams.new()
		rp.FilterType = Enum.RaycastFilterType.Exclude
		rp.FilterDescendantsInstances = ignore
		return rp
	end)())
	if not res then return true end
	return res.Instance:IsDescendantOf(targetPart.Parent)
end

local function nearestVisiblePlayer(npcModel: Model)
	local hum = getHumanoid(npcModel); if not hum or hum.Health <= 0 then return end
	local root = getRoot(npcModel); if not root then return end

	local bestPlr, bestDist2 = nil, math.huge
	for _, plr in ipairs(Players:GetPlayers()) do
		local char = plr.Character
		local tRoot = char and char:FindFirstChild("HumanoidRootPart")
		local tHum  = char and char:FindFirstChildOfClass("Humanoid")
		if tRoot and tHum and tHum.Health > 0 then
			local d2 = (tRoot.Position - root.Position).Magnitude
			if d2 <= DETECT_RADIUS and inFOV(root, tRoot.Position, FOV_DEG) then
				if hasLineOfSight(npcModel, root.Position + Vector3.new(0, 1.5, 0), tRoot) then
					if d2 < bestDist2 then bestDist2, bestPlr = d2, plr end
				end
			end
		end
	end
	return bestPlr
end

local function setPatrolPaused(npc: Model, on: boolean)
	if PAUSE_PATROL then
		npc:SetAttribute("RPG_PatrolPaused", on and true or false)
	end
end

local function computePath(startPos: Vector3, goalPos: Vector3)
	local path = PathfindingService:CreatePath({
		AgentRadius = 2,
		AgentHeight = 5,
		AgentCanJump = true,
		WaypointSpacing = 4,
	})
	local ok = pcall(function() path:ComputeAsync(startPos, goalPos) end)
	if not ok or path.Status ~= Enum.PathStatus.Success then return nil end
	return path
end

local function followPath(hum: Humanoid, root: BasePart, path: Path)
	for _, wp in ipairs(path:GetWaypoints()) do
		if wp.Action == Enum.PathWaypointAction.Jump then hum.Jump = true end
		hum:MoveTo(wp.Position)
		local finished = hum.MoveToFinished:Wait()
		if not finished or hum.Health <= 0 then return false end
	end
	return true
end

-- === AI PER-NPC ===
local function guardLoop(npc: Model)
	local hum = getHumanoid(npc); if not hum then return end
	local root = getRoot(npc); if not root then return end

	local state = "patrol"           -- "patrol" | "chase" | "search"
	local target: Player? = nil
	local lastSeenPos: Vector3? = nil
	local lastSeenAt = 0

	while npc.Parent and hum.Health > 0 do
		if state == "patrol" then
			-- Look for a player
			local plr = nearestVisiblePlayer(npc)
			if plr then
				target = plr
				state = "chase"
				lastSeenAt = time()
				setPatrolPaused(npc, true)
			end
			task.wait(TICK_SEC)

		elseif state == "chase" then
			local char = target and target.Character
			local tRoot = char and char:FindFirstChild("HumanoidRootPart")
			local tHum  = char and char:FindFirstChildOfClass("Humanoid")
			if not tRoot or not tHum or tHum.Health <= 0 then
				state = "search"
				task.wait(0.1)
			else
				-- Update last seen if still visible
				if inFOV(root, tRoot.Position, FOV_DEG) and hasLineOfSight(npc, root.Position + Vector3.new(0,1.5,0), tRoot) then
					lastSeenPos = tRoot.Position
					lastSeenAt = time()
				end

				-- Repath toward current position
				local started = time()
				local path = computePath(root.Position, tRoot.Position)
				if path then followPath(hum, root, path) end

				-- "Touch" check (hook: attack or notify CombatServer here)
				if (tRoot.Position - root.Position).Magnitude <= HIT_DISTANCE then
					-- Example hook (optional):
					-- if _G.RPG_DealDamage then _G.RPG_DealDamage(nil, tHum, 10) end
				end

				-- Give up if unseen for too long
				if time() - lastSeenAt > LOSE_TIME_SEC then
					state = "search"
				end

				-- throttle
				local elapsed = time() - started
				if elapsed < REPATH_SEC then task.wait(REPATH_SEC - elapsed) end
			end

		elseif state == "search" then
			-- Move to last seen point (if any), linger, then resume patrol
			if lastSeenPos then
				local path = computePath(root.Position, lastSeenPos)
				if path then followPath(hum, root, path) end
			end
			local t0 = time()
			while time() - t0 < SEARCH_TIME_SEC do
				-- If we spot the player again, resume chase
				local plr = nearestVisiblePlayer(npc)
				if plr then
					target = plr
					state = "chase"
					lastSeenAt = time()
					break
				end
				task.wait(0.2)
			end
			if state ~= "chase" then
				-- Back to patrol
				setPatrolPaused(npc, false)
				state, target, lastSeenPos = "patrol", nil, nil
				task.wait(TICK_SEC)
			end
		end
	end

	-- Cleanup
	setPatrolPaused(npc, false)
end

-- === BOOTSTRAP ===
local NPCsFolder = Workspace:FindFirstChild(NPCS_FOLDER_NAME) or Instance.new("Folder", Workspace)
NPCsFolder.Name = NPCS_FOLDER_NAME

for _, m in ipairs(NPCsFolder:GetChildren()) do
	if m:IsA("Model") and getHumanoid(m) and getRoot(m) then
		task.spawn(guardLoop, m)
	end
end

NPCsFolder.ChildAdded:Connect(function(m)
	if m:IsA("Model") then
		m:WaitForChild("Humanoid", 5)
		m:WaitForChild("HumanoidRootPart", 5)
		if getHumanoid(m) and getRoot(m) then
			task.spawn(guardLoop, m)
		end
	end
end)

^ How to use:

  1. Keep your NPCPatrol running as usual (NPCs in Workspace/NPCs, waypoints in Workspace/NPC_Waypoints).
  2. Drop GuardChaseAI into ServerScriptService.
  3. (Recommended) Add the one-line patch to your patrol loop so it respects RPG_PatrolPaused when chasing (prevents movement “tug-of-war”).
  4. Tweak DETECT_RADIUS, FOV_DEG, LOSE_TIME_SEC, and REPATH_SEC to taste.

LootDropServer

Server script that exposes _G.RPG_DropLoot(Vector3). Spawns a gold “loot” Part at the position and auto-cleans after 60s. Call it on NPC death or chest open.

Example: drop loot where an NPC dies:

-- ServerScriptService/OnNPCKill
local function attachLootOnDeath(npc: Model)
    local hum = npc:FindFirstChildOfClass("Humanoid"); if not hum then return end
    hum.Died:Connect(function()
        if _G.RPG_DropLoot then
            _G.RPG_DropLoot(npc:GetPivot().Position) -- drop at NPC position
        end
    end)
end

-- attach to all existing NPCs in workspace.NPCs (optional)
local NPCs = workspace:FindFirstChild("NPCs")
if NPCs then
    for _, m in ipairs(NPCs:GetChildren()) do
        if m:IsA("Model") then attachLootOnDeath(m) end
    end
    NPCs.ChildAdded:Connect(function(m) if m:IsA("Model") then attachLootOnDeath(m) end end)
end

If a client should request a drop (e.g., chest click), use a RemoteEvent and validate on the server:

-- ServerScriptService/LootBridge
local RS = game:GetService("ReplicatedStorage")
local RE = RS:FindFirstChild("RPG_RequestDropLoot") or Instance.new("RemoteEvent", RS)
RE.Name = "RPG_RequestDropLoot"

RE.OnServerEvent:Connect(function(player, pos: Vector3)
    -- minimal validation (e.g., range check)
    local root = player.Character and player.Character:FindFirstChild("HumanoidRootPart")
    if not root or (root.Position - pos).Magnitude > 20 then return end
    if _G.RPG_DropLoot then _G.RPG_DropLoot(pos) end
end)

Bonus Script: LootTablesServer — requires LootDropServer. Per-NPC weighted loot tables with Luck and Pity. Call _G.RPG_DropFromTable(tableId, killer, position, rolls). Auto-spawns world drops (uses _G.RPG_SpawnItemDrop if present; otherwise _G.RPG_DropLoot):

-- Place in ServerScriptService
-- LootTablesServer
-- Requires: LootDropServer (provides _G.RPG_DropLoot(position))
-- Optional: GroundLootServer (provides _G.RPG_SpawnItemDrop(position, itemId, qty))
-- Purpose: Weighted loot tables + Luck + Pity. Per-NPC (or per table id).

local RS = game:GetService("ReplicatedStorage")

-- ===== RARITY RANKS =====
local RANK = {Common=1, Uncommon=2, Rare=3, Epic=4, Legendary=5}

-- ===== CONFIG: LOOT TABLES =====
-- Each entry: {id="Potion", kind="item"|"gold", qty=1 or {min,max}, weight=number, rarity="Common".."Legendary"}
-- Create as many tables as you need; choose one via NPC attribute or by name.
local LOOT_TABLES = {
	-- Example goblin table
	Goblin = {
		{ id="Gold",         kind="gold", qty={10,25}, weight=60, rarity="Common"    },
		{ id="Herb",         kind="item", qty={1,2},   weight=30, rarity="Common"    },
		{ id="Potion",       kind="item", qty=1,       weight= 8, rarity="Uncommon"  },
		{ id="Gemstone",     kind="item", qty=1,       weight= 2, rarity="Rare"      },
	},
	-- Boss example
	Boss_Ogre = {
		{ id="Gold",         kind="gold", qty={120,180}, weight=30, rarity="Uncommon" },
		{ id="HiPotion",     kind="item", qty=1,         weight=28, rarity="Uncommon" },
		{ id="Sword_Iron",   kind="item", qty=1,         weight=25, rarity="Rare"     },
		{ id="Gemstone",     kind="item", qty=1,         weight=12, rarity="Epic"     },
		{ id="Armor_Leather",kind="item", qty=1,         weight= 5, rarity="Epic"     },
	},
}

-- ===== LUCK & PITY =====
-- Luck: read from Player Attribute "Luck" in [0..1]. Higher rarity gets a stronger boost.
local LUCK_RARITY_BONUS = {Common=0.00, Uncommon=0.05, Rare=0.15, Epic=0.30, Legendary=0.50}
-- Pity: after this many rolls without >= Rare, force next roll to be >= Rare.
local PITY_ROLLS       = 15
local PITY_MIN_RARITY  = "Rare"

-- Per-player pity counters: pity[player][tableId] = countSinceRare
local pity = {}

-- ===== HELPERS =====
local function randIn(x)
	if type(x) == "table" then
		return math.random(x[1], x[2])
	else
		return tonumber(x) or 1
	end
end

local function getLuck(plr)
	local v = plr and plr:GetAttribute("Luck")
	v = (type(v)=="number") and math.clamp(v,0,1) or 0
	return v
end

local function weightedPick(entries, plr, minRank)
	-- If minRank is set (pity), filter out lower rarities.
	local pool = {}
	for _, e in ipairs(entries) do
		if not minRank or (RANK[e.rarity] or 1) >= minRank then
			table.insert(pool, e)
		end
	end
	if #pool == 0 then return nil end

	local luck = getLuck(plr)
	local total = 0
	local weights = {}
	for i, e in ipairs(pool) do
		local base = tonumber(e.weight) or 0
		local bonus = LUCK_RARITY_BONUS[e.rarity] or 0
		local eff = base * (1 + luck * bonus)
		weights[i] = eff
		total += eff
	end
	if total <= 0 then return nil end

	local r = math.random() * total
	for i, w in ipairs(weights) do
		r -= w
		if r <= 0 then
			return pool[i]
		end
	end
	return pool[#pool]
end

local function dropGoldAt(pos, amount)
	-- Fall back to spawning a few coins via _G.RPG_DropLoot
	if type(_G.RPG_DropLoot) ~= "function" then return end
	local n = math.clamp(math.floor(amount / 25 + 0.5), 1, 6) -- 1..6 coins
	for _=1,n do _G.RPG_DropLoot(pos) end
end

local function dropItemAt(pos, itemId, qty)
	-- Prefer GroundLootServer if available; else fallback to a simple loot ping
	if type(_G.RPG_SpawnItemDrop) == "function" then
		_G.RPG_SpawnItemDrop(pos, itemId, qty)
	else
		-- No item drop system installed; at least ping a loot piece
		if type(_G.RPG_DropLoot) == "function" then _G.RPG_DropLoot(pos) end
	end
end

-- ===== PUBLIC API =====
-- tableId: string OR NPC Model (reads npc:GetAttribute("LootTable") or npc.Name)
-- rolls: how many picks to make (default 1). Set to 0 to skip (no-op).
_G.RPG_DropFromTable = function(tableId, killerPlayer, position: Vector3, rolls)
	rolls = math.max(1, tonumber(rolls) or 1)

	-- Resolve table
	local tId = tableId
	if typeof(tableId) == "Instance" and tableId:IsA("Model") then
		tId = tableId:GetAttribute("LootTable") or tableId.Name
	end
	local entries = LOOT_TABLES[tostring(tId)]
	if not entries then
		warn("[LootTables] Missing loot table:", tId)
		return {}
	end

	-- Init pity counter
	pity[killerPlayer] = pity[killerPlayer] or {}
	pity[killerPlayer][tId] = pity[killerPlayer][tId] or 0

	local drops = {}
	for i=1, rolls do
		local forceRank = nil
		if pity[killerPlayer][tId] >= PITY_ROLLS then
			forceRank = RANK[PITY_MIN_RARITY] or RANK.Rare
		end

		local pick = weightedPick(entries, killerPlayer, forceRank)
		if pick then
			local qty = randIn(pick.qty or 1)
			-- Spawn into world
			if pick.kind == "gold" and pick.id == "Gold" then
				dropGoldAt(position, qty)
			else
				dropItemAt(position, pick.id, qty)
			end

			table.insert(drops, {id=pick.id, kind=pick.kind, qty=qty, rarity=pick.rarity})
			-- Pity bookkeeping
			if (RANK[pick.rarity] or 1) >= (RANK[PITY_MIN_RARITY] or 3) then
				pity[killerPlayer][tId] = 0
			else
				pity[killerPlayer][tId] += 1
			end
		end
	end

	return drops
end

-- Optional helper: set Luck [0..1] on the player (you can also set the attribute elsewhere)
_G.RPG_SetPlayerLuck = function(player, luck0to1)
	player:SetAttribute("Luck", math.clamp(tonumber(luck0to1) or 0, 0, 1))
end

Practical usage (server)

A) Drop from NPC on death:

hum.Died:Connect(function()
	local pos = npc:GetPivot().Position
	_G.RPG_DropFromTable(npc, killerPlayer, pos, 2) -- 2 rolls from npc's table
end)

B) Set NPC’s loot table in Studio or at runtime:

npc:SetAttribute("LootTable", "Goblin")

C) Give a temporary luck bonus (e.g., +20% luck item ⇒ set Luck=0.2):

_G.RPG_SetPlayerLuck(player, 0.2)

Notes

  • Requires LootDropServer (uses _G.RPG_DropLoot).
  • If you also added GroundLootServer, item drops will be proper world pickups via _G.RPG_SpawnItemDrop.
  • Pity resets when the player rolls a drop of Rare or better.
  • Add more tables/entries in LOOT_TABLES to match your game items and bosses.
  • All logic is server-side; clients only see the spawned drops.

ShopNPCModule

Central price table + helpers. Use Shop.price(itemId) and Shop.canAfford(gold, itemId). Pair with CurrencyModule (deduct Gold) and InventoryModule (grant item). Do purchases server-side only.

Short example, purchase handler: client sends itemId, server checks price & gold, deducts, and grants item:

-- ServerScriptService/ShopBuyMini
local RS = game:GetService("ReplicatedStorage")
local Shop = require(RS:WaitForChild("ShopNPCModule"))
local Cur  = require(RS:WaitForChild("CurrencyModule"))
local Inv  = require(RS:WaitForChild("InventoryModule"))

local RE = RS:FindFirstChild("RPG_RequestBuy") or Instance.new("RemoteEvent", RS); RE.Name = "RPG_RequestBuy"

RE.OnServerEvent:Connect(function(plr, itemId)
    local price = Shop.price(itemId); if not price then return warn("Unknown item:", itemId) end
    local ls = plr:FindFirstChild("leaderstats"); if not ls then return end
    if not Shop.canAfford(Cur.get(ls), itemId) then return warn(plr.Name.." lacks gold") end
    Cur.add(ls, -price); Inv.add(plr, itemId, 1) -- deduct gold, grant item
end)

Bonus Script: ShopStockServer — requires ShopNPCModule. Adds limited stock, timed restock, sell/buyback (undo mistaken sells), and a snapshot API for Gui. Use _G.RPG_Shop_Buy/Sell/Buyback or the provided RemoteEvents:

-- Place in ServerScriptService
-- ShopStockServer
-- Requires (hard):
--   ReplicatedStorage/ShopNPCModule
-- Requires (common, used here):
--   ReplicatedStorage/CurrencyModule, ReplicatedStorage/InventoryModule
-- What you get:
--   • Per-item limited stock with timed restock
--   • Sell-to-shop (pays a percentage of price)
--   • Buyback (player can repurchase last X items sold)
--   • Snapshot API for building your shop UI

local RS = game:GetService("ReplicatedStorage")

local Shop = require(RS:WaitForChild("ShopNPCModule"))
local Cur  = require(RS:WaitForChild("CurrencyModule"))
local Inv  = require(RS:WaitForChild("InventoryModule"))

-- ========== CONFIG ==========
local SELL_RATE        = 0.50  -- shop pays 50% of base price on sell
local BUYBACK_SLOTS    = 10    -- per-player buyback history size
local DYNAMIC_PRICE    = true  -- demand-based price bump as stock gets low
local PRICE_BUMP_MAX   = 0.35  -- up to +35% at 0 stock (linear)

-- Stock & restock config (extend with your catalog)
-- max: starting/max stock, restock: how many units per tick, every: restock period (sec)
local STOCK_INIT = {
    Potion        = { max = 20, restock = 5, every = 60  },
    HiPotion      = { max =  8, restock = 2, every = 90  },
    Herb          = { max = 50, restock =10, every = 45  },
    Bottle        = { max = 40, restock =10, every = 45  },
    Sword_Iron    = { max =  2, restock = 1, every = 300 },
    Armor_Leather = { max =  2, restock = 1, every = 300 },
}

-- ========== STATE ==========
-- STOCK[itemId] = { cur, max, restock, every, next }
local STOCK = {}
-- BUYBACK[player] = { {id=itemId, qty=Q, priceEach=number}, ... }
local BUYBACK = setmetatable({}, {__mode="k"}) -- auto-clean on player GC

-- ========== HELPERS ==========
local function now() return os.time() end

local function ensureLeaderstats(p)
    local ls = p:FindFirstChild("leaderstats") or Instance.new("Folder", p)
    ls.Name = "leaderstats"
    if not ls:FindFirstChild("Gold") then
        local v = Instance.new("IntValue"); v.Name="Gold"; v.Parent = ls
    end
    return ls
end

local function basePrice(itemId)
    return Shop.price(itemId) -- your module supplies the canonical price
end

local function demandPrice(itemId)
    local base = basePrice(itemId) or 0
    if not DYNAMIC_PRICE then return base end
    local s = STOCK[itemId]; if not s then return base end
    local demand = 1 - (s.cur / math.max(1, s.max)) -- 0 (full) .. 1 (empty)
    local bump = 1 + PRICE_BUMP_MAX * math.clamp(demand, 0, 1)
    return math.floor(base * bump + 0.5)
end

local function pushBuyback(p, itemId, qty, priceEach)
    BUYBACK[p] = BUYBACK[p] or {}
    table.insert(BUYBACK[p], 1, {id=itemId, qty=qty, priceEach=priceEach})
    while #BUYBACK[p] > BUYBACK_SLOTS do table.remove(BUYBACK[p]) end
end

local function snapshot()
    local list = {}
    for id, s in pairs(STOCK) do
        table.insert(list, {
            id = id,
            stock = s.cur,
            max = s.max,
            price = demandPrice(id),
            nextRestockIn = math.max(0, (s.next or 0) - now()),
        })
    end
    table.sort(list, function(a,b) return a.id < b.id end)
    return list
end

-- ========== PUBLIC API ==========
-- Buy from shop → pay Gold → add to inventory → reduce stock
_G.RPG_Shop_Buy = function(p, itemId, qty)
    qty = math.max(1, tonumber(qty) or 1)
    local s = STOCK[itemId]; if not s then return false, "Not sold here" end
    if s.cur < qty then return false, "Out of stock" end

    local ls = ensureLeaderstats(p)
    local each = demandPrice(itemId)
    local total = each * qty

    if not Shop.canAfford(Cur.get(ls), itemId) and Cur.get(ls) < total then
        return false, "Not enough gold"
    end

    Cur.add(ls, -total)
    Inv.add(p, itemId, qty)
    s.cur -= qty
    return true, total
end

-- Sell to shop → remove from inventory → pay Gold → add to stock
_G.RPG_Shop_Sell = function(p, itemId, qty)
    qty = math.max(1, tonumber(qty) or 1)
    local bag = Inv.get(p)
    local have = bag[itemId] or 0
    if have < qty then return false, "Not enough items" end

    local priceEach = math.floor((basePrice(itemId) or 0) * SELL_RATE + 0.5)
    local pay = priceEach * qty

    -- Perform transaction
    bag[itemId] = have - qty
    Cur.add(ensureLeaderstats(p), pay)

    -- Increase stock (cap at max)
    local s = STOCK[itemId]
    if s then s.cur = math.min(s.max, s.cur + qty) end

    -- Record for buyback
    pushBuyback(p, itemId, qty, priceEach)
    return true, pay
end

-- Buy back the most recent sold item (index 1 by default)
_G.RPG_Shop_Buyback = function(p, index)
    index = math.max(1, tonumber(index) or 1)
    local list = BUYBACK[p] or {}
    local entry = list[index]
    if not entry then return false, "Nothing to buy back" end

    -- Price at recorded sell price (same as player received)
    local total = entry.priceEach * entry.qty
    local ls = ensureLeaderstats(p)
    if Cur.get(ls) < total then return false, "Not enough gold" end

    -- Stock check (optional: allow negative to “pull from backroom”)
    local s = STOCK[entry.id]
    if s and s.cur < entry.qty then return false, "Out of stock" end

    Cur.add(ls, -total)
    Inv.add(p, entry.id, entry.qty)
    if s then s.cur -= entry.qty end
    table.remove(list, index)
    return true, total
end

-- ========== REMOTES FOR UI ==========
-- Use distinct names to avoid clashing with any earlier examples.
local EBuy      = RS:FindFirstChild("RPG_Shop_Buy")      or Instance.new("RemoteEvent", RS); EBuy.Name = "RPG_Shop_Buy"
local ESell     = RS:FindFirstChild("RPG_Shop_Sell")     or Instance.new("RemoteEvent", RS); ESell.Name = "RPG_Shop_Sell"
local EBuyback  = RS:FindFirstChild("RPG_Shop_Buyback")  or Instance.new("RemoteEvent", RS); EBuyback.Name = "RPG_Shop_Buyback"
local FSnapshot = RS:FindFirstChild("RPG_Shop_Snapshot") or Instance.new("RemoteFunction", RS); FSnapshot.Name = "RPG_Shop_Snapshot"

EBuy.OnServerEvent:Connect(function(p, itemId, qty) _G.RPG_Shop_Buy(p, tostring(itemId), qty) end)
ESell.OnServerEvent:Connect(function(p, itemId, qty) _G.RPG_Shop_Sell(p, tostring(itemId), qty) end)
EBuyback.OnServerEvent:Connect(function(p, index) _G.RPG_Shop_Buyback(p, index) end)
FSnapshot.OnServerInvoke = function(p) return snapshot() end

-- ========== RESTOCKER ==========
local function bootStock()
    for id, cfg in pairs(STOCK_INIT) do
        STOCK[id] = {
            cur = cfg.max,
            max = cfg.max,
            restock = cfg.restock,
            every = cfg.every,
            next = now() + cfg.every,
        }
    end
end

bootStock()

task.spawn(function()
    while true do
        local t = now()
        for id, s in pairs(STOCK) do
            if s.cur < s.max and t >= (s.next or 0) then
                s.cur = math.min(s.max, s.cur + s.restock)
                s.next = t + s.every
            end
        end
        task.wait(1)
    end
end)

client usage (Gui button examples):

A) Get stock for your shop UI (names/prices/stock):

local list = game.ReplicatedStorage.RPG_Shop_Snapshot:InvokeServer()
-- list[i] = {id, stock, max, price, nextRestockIn}

B) Buy 1 “Potion”:

game.ReplicatedStorage.RPG_Shop_Buy:FireServer("Potion", 1)

C) Sell 2 “Potion”:

game.ReplicatedStorage.RPG_Shop_Sell:FireServer("Potion", 2)

D) Buy back the last thing you sold:

game.ReplicatedStorage.RPG_Shop_Buyback:FireServer(1)

Notes:

  • Prices come from your ShopNPCModule; dynamic bump is layered on top.
  • All logic runs server-side; the client only requests actions and renders the snapshot.
  • Extend STOCK_INIT to your catalog. For global persistence between server hops, back this with your DataStoreManager (same structure; save/load STOCK).

CraftingModule

Maps sorted input item IDs to a single output. Recipe: “Bottle+Herb” → “Potion”. Call Craft.output({"Bottle","Herb"}) to get "Potion" (or nil if no match). Pair with InventoryModule to verify/consume inputs and grant the result.

Crafting helper example:

-- ServerScriptService/CraftExample
local RS   = game:GetService("ReplicatedStorage")
local Craft = require(RS:WaitForChild("CraftingModule"))     -- maps inputs -> output
local Inv   = require(RS:WaitForChild("InventoryModule"))    -- server-side inventory

-- Tries to craft a Potion using one Herb + one Bottle from the player's inventory.
-- Returns: (true, "Potion") on success, or (false, "reason") on failure.
local function craftPotion(plr)
    local inputs = {"Herb","Bottle"}              -- order doesn't matter; module sorts
    local out = Craft.output(table.clone(inputs)) -- "Potion" if recipe exists, else nil
    if not out then return false, "No recipe" end

    -- Ensure the player owns each required input (qty = 1 each)
    for _, id in ipairs(inputs) do
        if not Inv.has(plr, id, 1) then
            return false, "Missing " .. id
        end
    end

    -- Consume inputs (decrement counts) and grant the crafted item
    local bag = Inv.get(plr)                      -- get mutable inventory table
    for _, id in ipairs(inputs) do
        bag[id] = math.max(0, (bag[id] or 0) - 1) -- remove 1 of each input
    end
    Inv.add(plr, out, 1)                          -- add 1 "Potion"
    return true, out
end

-- Example call elsewhere (server):
-- local ok, result = craftPotion(player)
-- if ok then print("Crafted:", result) else warn("Craft failed:", result) end

Bonus Script: CraftQueueServer — requires CraftingModule. Timed, station-based crafting queue with per-recipe durations, proximity check, progress updates, cancel, and per-unit output. Use _G.RPG_CraftAtStation(player, {"Bottle","Herb"}, 3); tag stations CollectionService:"CraftingStation":

-- Place in ServerScriptService
-- CraftQueueServer
-- Requires: ReplicatedStorage/CraftingModule, ReplicatedStorage/InventoryModule
-- Purpose: Timed, station-based crafting queue. Server-authoritative.
-- Players enqueue recipes near a tagged "CraftingStation"; inputs are consumed up front,
-- items are produced over time (per-unit). Includes progress updates and cancel.

local RS = game:GetService("ReplicatedStorage")
local CS = game:GetService("CollectionService")
local Players = game:GetService("Players")

local Craft = require(RS:WaitForChild("CraftingModule"))   -- REQUIRED
local Inv   = require(RS:WaitForChild("InventoryModule"))  -- REQUIRED
local ShowDialogue = RS:FindFirstChild("RPG_ShowDialogue") -- optional toast RemoteEvent

-- === Remotes for UI (optional) ===
local RE_REQ   = RS:FindFirstChild("RPG_RequestCraft")        or Instance.new("RemoteEvent", RS); RE_REQ.Name   = "RPG_RequestCraft"
local RE_CANCEL= RS:FindFirstChild("RPG_RequestCraftCancel")  or Instance.new("RemoteEvent", RS); RE_CANCEL.Name= "RPG_RequestCraftCancel"
local RE_PUSH  = RS:FindFirstChild("RPG_CraftQueueUpdate")    or Instance.new("RemoteEvent", RS); RE_PUSH.Name  = "RPG_CraftQueueUpdate"
local RF_SNAP  = RS:FindFirstChild("RPG_CraftQueueSnapshot")  or Instance.new("RemoteFunction", RS); RF_SNAP.Name= "RPG_CraftQueueSnapshot"

-- === Config ===
local STATION_TAG       = "CraftingStation"  -- tag Parts/Models players must stand near
local STATION_RADIUS    = 12                 -- studs
local MAX_QUEUE         = 3                  -- jobs per player
local DEFAULT_UNIT_DUR  = 3.0                -- seconds per crafted unit if not overridden
-- Per-output custom durations (seconds per unit)
local UNIT_DUR = {
	Potion   = 4.0,
	HiPotion = 6.0,
}

-- Optional skill: reduce time by attribute "CraftSkill" (0..5 => up to -50% with 0.1 per point)
local SKILL_STEP        = 0.10               -- -10% per point
local SKILL_MIN_FACTOR  = 0.50               -- cap at 50% of base time

-- === Per-player queues ===
-- QUEUE[p] = { busy=true/false, jobs = { {id, outId, qty, left, per, startedAt} ... } }
local QUEUE = setmetatable({}, {__mode="k"})
local nextJobId = 1

-- === Helpers ===
local function nearStation(plr: Player): boolean
	local char = plr.Character
	local root = char and char:FindFirstChild("HumanoidRootPart")
	if not root then return false end
	local best = math.huge
	for _, inst in ipairs(CS:GetTagged(STATION_TAG)) do
		local pos = (inst:IsA("Model") and inst:GetPivot().Position) or (inst.Position)
		if pos then
			local d = (pos - root.Position).Magnitude
			if d < best then best = d end
		end
	end
	return best <= STATION_RADIUS
end

local function durationPerUnit(plr: Player, outId: string)
	local base = UNIT_DUR[outId] or DEFAULT_UNIT_DUR
	local skill = tonumber(plr:GetAttribute("CraftSkill")) or 0
	local factor = math.max(SKILL_MIN_FACTOR, 1 - SKILL_STEP * skill)
	return base * factor
end

local function pushUpdate(plr)
	local q = QUEUE[plr]; if not q then return end
	-- Build a minimal snapshot for the client UI
	local snap = {}
	for _, j in ipairs(q.jobs) do
		table.insert(snap, {
			id = j.id, outId = j.outId, qty = j.qty, left = j.left,
			per = j.per, startedAt = j.startedAt
		})
	end
	RE_PUSH:FireClient(plr, snap)
end

local function toast(plr, msg)
	if ShowDialogue then ShowDialogue:FireClient(plr, msg) end
end

local function ensureQueue(plr)
	QUEUE[plr] = QUEUE[plr] or {busy=false, jobs={}}
	return QUEUE[plr]
end

local function haveInputs(plr, inputs, qty)
	-- Check player has each input qty times
	for _, id in ipairs(inputs) do
		if not Inv.has(plr, id, qty) then
			return false, "Missing "..id
		end
	end
	return true
end

local function consumeInputs(plr, inputs, qty)
	-- Subtract required materials from inventory
	local bag = Inv.get(plr)
	for _, id in ipairs(inputs) do
		bag[id] = math.max(0, (bag[id] or 0) - qty)
	end
end

-- === Worker: processes a player's queue sequentially ===
local function runQueue(plr)
	local q = QUEUE[plr]; if not q or q.busy then return end
	q.busy = true
	while q.jobs[1] do
		local job = q.jobs[1]
		job.startedAt = os.clock()
		pushUpdate(plr)
		-- Produce per unit to allow partial progress & UI tick
		for i = 1, job.left do
			task.wait(job.per)
			-- Player may have left; guard
			if not Players:FindFirstChild(plr.Name) then q.jobs[1] = nil; break end
			Inv.add(plr, job.outId, 1)
			job.left -= 1
			pushUpdate(plr)
		end
		-- Remove finished job
		if q.jobs[1] == job and job.left <= 0 then
			table.remove(q.jobs, 1)
			toast(plr, ("Crafted %dx %s"):format(job.qty, job.outId))
		end
	end
	q.busy = false
end

-- === Public API: enqueue a craft job (server) ===
_G.RPG_CraftAtStation = function(plr: Player, inputs: {string}, qty: number?)
	if not plr then return false, "No player" end
	qty = math.max(1, tonumber(qty) or 1)
	if not nearStation(plr) then return false, "Not near a crafting station" end

	-- Resolve output from the CraftingModule
	local outId = Craft.output(table.clone(inputs))
	if not outId then return false, "No matching recipe" end

	-- Capacity & inputs
	local q = ensureQueue(plr)
	if #q.jobs >= MAX_QUEUE then return false, "Queue full" end
	local ok, why = haveInputs(plr, inputs, qty); if not ok then return false, why end

	-- Consume and enqueue
	consumeInputs(plr, inputs, qty)
	local per = durationPerUnit(plr, outId)
	local job = { id = nextJobId, outId = outId, qty = qty, left = qty, per = per, startedAt = 0 }
	nextJobId += 1
	table.insert(q.jobs, job)
	pushUpdate(plr)
	task.spawn(runQueue, plr)
	return true, job.id
end

-- === Public API: cancel (only pending jobs; in-progress = no refund) ===
_G.RPG_CraftCancel = function(plr: Player, jobId: number)
	local q = QUEUE[plr]; if not q then return false, "No queue" end
	for i, j in ipairs(q.jobs) do
		if j.id == jobId then
			if i == 1 and j.startedAt ~= 0 then
				return false, "Already in progress"
			end
			-- Refund inputs fully for this pending job
			-- Note: we don't know exact inputs here; pass them from client if you want perfect refund.
			-- Simpler path: refund by output mapping inverse is game-specific; skip refund to keep concise.
			table.remove(q.jobs, i)
			pushUpdate(plr)
			return true
		end
	end
	return false, "Job not found"
end

-- === Remote bindings (optional UI) ===
RE_REQ.OnServerEvent:Connect(function(plr, inputs, qty)
	if typeof(inputs) ~= "table" then return end
	_G.RPG_CraftAtStation(plr, inputs, qty)
end)

RE_CANCEL.OnServerEvent:Connect(function(plr, jobId)
	_G.RPG_CraftCancel(plr, tonumber(jobId))
end)

RF_SNAP.OnServerInvoke = function(plr)
	local q = ensureQueue(plr)
	local snap = {}
	for _, j in ipairs(q.jobs) do
		table.insert(snap, {id=j.id, outId=j.outId, qty=j.qty, left=j.left, per=j.per, startedAt=j.startedAt})
	end
	return snap
end

-- Cleanup memory
Players.PlayerRemoving:Connect(function(p) QUEUE[p] = nil end)

client usage (plug into your Gui):

A) Button to craft 3× Potion at a station:

-- LocalScript (e.g., Craft button)
game.ReplicatedStorage.RPG_RequestCraft:FireServer({"Bottle","Herb"}, 3)

B) Show queue/progress (poll or listen to pushes):

-- Get a snapshot for your UI list:
local queue = game.ReplicatedStorage.RPG_CraftQueueSnapshot:InvokeServer()
-- or react to pushes:
game.ReplicatedStorage.RPG_CraftQueueUpdate.OnClientEvent:Connect(function(list)
    -- list[i] = {id, outId, qty, left, per, startedAt}
end)

C) Cancel a pending job:

game.ReplicatedStorage.RPG_RequestCraftCancel:FireServer(jobId)

^ How it works:

  • Player must be within STATION_RADIUS of any instance tagged "CraftingStation" (use CollectionService:AddTag(part,"CraftingStation")).
  • Server calls Craft.output({"Bottle","Herb"}) → "Potion" to resolve the recipe.
  • Inputs are consumed up front; output is granted per unit over time (UNIT_DUR or DEFAULT_UNIT_DUR, reduced by optional CraftSkill).
  • Queue is per player (cap via MAX_QUEUE), with progress pushes for UI.

If you want refunds on cancel, pass the exact input list when enqueueing (store it on the job) and add a small refund function that re-adds inputs for non-started jobs.

EquipmentModule

Defines equip slots and converts equipped items into stat bonuses. Create a per-player table with Equip.default(), set weapon/armor/trinket, then call Equip.statsFrom(tbl){ATK, DEF}. Pair with leaderstats (or your combat math). Persist later with DataStores if needed.

This script uses EquipmentModule to track per-player equipment and mirrors bonuses (ATK/DEF) into leaderstats:

-- ServerScriptService/EquipMini

local Players = game:GetService("Players")
local RS     = game:GetService("ReplicatedStorage")
local Equip  = require(RS:WaitForChild("EquipmentModule"))

local EQUIP = {} -- Player -> {weapon, armor, trinket}

local function recalc(p)
	local ls = p:FindFirstChild("leaderstats"); if not ls then return end
	local b = Equip.statsFrom(EQUIP[p] or Equip.default()) -- {ATK, DEF}
	local ATK = ls:FindFirstChild("ATK") or Instance.new("IntValue", ls); ATK.Name="ATK"
	local DEF = ls:FindFirstChild("DEF") or Instance.new("IntValue", ls); DEF.Name="DEF"
	ATK.Value, DEF.Value = b.ATK, b.DEF
end

Players.PlayerAdded:Connect(function(p)
	EQUIP[p] = Equip.default()
	-- demo starter gear (matches module bonuses)
	EQUIP[p].weapon = "Sword_Iron"     -- +10 ATK
	EQUIP[p].armor  = "Armor_Leather"  -- +5  DEF
	recalc(p)
end)

Players.PlayerRemoving:Connect(function(p) EQUIP[p] = nil end)

-- Example equip action elsewhere:
-- EQUIP[player].weapon = "Sword_Iron"; recalc(player)

Here’s a compact example that loads/saves equipment with DataStoreManager, uses EquipmentModule to compute bonuses, and mirrors them into leaderstats:

-- ServerScriptService/EquipPersistence
-- Requires:
-- ReplicatedStorage/EquipmentModule
-- ServerStorage/DataStoreManager
-- Notes: Enable “Studio Access to API Services” to test in Studio.

local Players = game:GetService("Players")
local RS      = game:GetService("ReplicatedStorage")
local SS      = game:GetService("ServerStorage")

local Equip = require(RS:WaitForChild("EquipmentModule"))
local DM    = require(SS:WaitForChild("DataStoreManager"))

local EQUIP = {}  -- Player -> {weapon=..., armor=..., trinket=...}
local function key(p) return "equip_"..p.UserId end

-- Recompute bonuses from EQUIP[p] and reflect into leaderstats ATK/DEF
local function recalc(p)
	local ls = p:FindFirstChild("leaderstats"); if not ls then return end
	local b = Equip.statsFrom(EQUIP[p] or Equip.default()) -- {ATK, DEF}
	local ATK = ls:FindFirstChild("ATK") or Instance.new("IntValue", ls); ATK.Name = "ATK"
	local DEF = ls:FindFirstChild("DEF") or Instance.new("IntValue", ls); DEF.Name = "DEF"
	ATK.Value, DEF.Value = b.ATK, b.DEF
end

-- Save helper
local function saveEquip(p)
	if not EQUIP[p] then return end
	DM.saveAsync(key(p), {
		weapon  = EQUIP[p].weapon,
		armor   = EQUIP[p].armor,
		trinket = EQUIP[p].trinket,
	})
end

-- Load on join (or give defaults), then compute ATK/DEF
Players.PlayerAdded:Connect(function(p)
	local saved = DM.loadAsync(key(p))
	EQUIP[p] = saved or Equip.default()

	-- (Optional) give starter gear if nothing saved
	if not saved then
		EQUIP[p].weapon = "Sword_Iron"     -- +10 ATK (per EquipmentModule)
		EQUIP[p].armor  = "Armor_Leather"  -- +5  DEF
	end

	recalc(p)
end)

-- Save on leave and clean up memory
Players.PlayerRemoving:Connect(function(p)
	saveEquip(p)
	EQUIP[p] = nil
end)

-- Also save everyone on shutdown (extra safety)
game:BindToClose(function()
	for _, p in ipairs(Players:GetPlayers()) do saveEquip(p) end
end)

-- === (Optional) Server equip API via RemoteEvent ===
-- Client UI calls: game.ReplicatedStorage.RPG_RequestEquip:FireServer("weapon","Sword_Iron")
local RE = RS:FindFirstChild("RPG_RequestEquip") or Instance.new("RemoteEvent", RS)
RE.Name = "RPG_RequestEquip"

local VALID_SLOTS = { weapon = true, armor = true, trinket = true }
local VALID_ITEMS = { Sword_Iron = true, Armor_Leather = true } -- extend with your items

RE.OnServerEvent:Connect(function(p, slot, itemId)
	if not (VALID_SLOTS[slot] and VALID_ITEMS[itemId]) then return end
	EQUIP[p] = EQUIP[p] or Equip.default()
	EQUIP[p][slot] = itemId
	recalc(p)
	saveEquip(p) -- save immediately after change
end)

--uses DataStoreManager to load/save {weapon, armor, trinket}; computes {ATK, DEF} via --EquipmentModule and writes to leaderstats. Includes a simple RPG_RequestEquip --RemoteEvent so the server can update gear, recalc, and save.

FootstepClient

LocalScript that plays a footstep sound while the local Humanoid is moving. Drop it in StarterPlayerScripts. Edit SOUND_ID, SPEED_THRESHOLD, VOLUME. This Client is barely necessary but useful to avoid a tedious setup step and easy to boostrap from especially with the code below for Terrain based RPGs.

Here’s a short, drop-in example that swaps the footstep sound based on the ground material.
(Requires your FootstepClient to be running so _G.RPG_SetFootstepSound exists.):

-- StarterPlayerScripts/TerrainFootsteps.client.lua
-- Changes footstep SFX by terrain using Humanoid.FloorMaterial.

local player = game.Players.LocalPlayer

local SFX = {
	[Enum.Material.Grass]   = "rbxassetid://13114759", -- TODO: replace with your grass SFX
	[Enum.Material.Sand]    = "rbxassetid://12345678", -- sand
	[Enum.Material.Concrete]= "rbxassetid://23456789", -- stone/concrete
	[Enum.Material.Mud]     = "rbxassetid://34567890", -- mud
	Default                 = "rbxassetid://13114759", -- fallback
}

local function setup(char: Model)
	local hum = char:WaitForChild("Humanoid") :: Humanoid

	local function apply()
		if not _G.RPG_SetFootstepSound then return end -- FootstepClient not loaded yet
		local mat = hum.FloorMaterial
		_G.RPG_SetFootstepSound(SFX[mat] or SFX.Default)
	end

	apply() -- initial
	hum:GetPropertyChangedSignal("FloorMaterial"):Connect(apply) -- on terrain change
end

player.CharacterAdded:Connect(setup)
if player.Character then setup(player.Character) end

CameraShakeClient

LocalScript that exposes _G.RPG_ShakeCamera(magnitude, duration) to add a quick screen shake (uses Humanoid.CameraOffset when possible; falls back to camera nudge). Also listens to ReplicatedStorage.RPG_ShakeCamera so the server can trigger shakes.

Example uses:

Client (on hit/explosion):

-- Client
if _G.RPG_ShakeCamera then _G.RPG_ShakeCamera(0.25, 0.35) end

Server → Client (validated event):

-- Server
(game.ReplicatedStorage.RPG_ShakeCamera or Instance.new("RemoteEvent", game.ReplicatedStorage)).Name = "RPG_ShakeCamera"
game.ReplicatedStorage.RPG_ShakeCamera:FireClient(player, 0.3, 0.4)

-- Client handled automatically by CameraShakeClient below

Notes:
Call _G.RPG_ShakeCamera(0.2, 0.25) from client code (or fire ReplicatedStorage.RPG_ShakeCamera from the server) to add a quick, non-drifting screen shake using Humanoid.CameraOffset.

MinimapClient

Simple circular HUD. Shows the player and any instances tagged "MinimapBlip" within a set radius. The plugin drops the Client in StarterGui (or StarterPlayerScripts). Configure radius and dot size.

Typical use: tag NPCs or landmarks so they appear on the map:

-- Server (or Studio command bar) — tag things you want on the minimap
local CS = game:GetService("CollectionService")
for _, m in ipairs((workspace.NPCs and workspace.NPCs:GetChildren()) or {}) do
    local hrp = m:FindFirstChild("HumanoidRootPart")
    if hrp then CS:AddTag(hrp, "MinimapBlip") end   -- tag a part to track
end

Here’s a tiny add-on you can drop right after you create frame in your MinimapClient. It overlays a circular border image on top of the minimap:

-- Add this below your minimap `frame` creation
-- A 1:1 PNG ring (transparent center) works best, e.g., 512x512.
local BORDER_IMAGE_ID = "rbxassetid://1234567890" -- TODO: your ring PNG asset id

local border = Instance.new("ImageLabel")
border.Name = "MinimapBorder"
border.BackgroundTransparency = 1
border.AnchorPoint = Vector2.new(0.5, 0.5)
border.Position = UDim2.fromScale(0.5, 0.5)
border.Size = UDim2.fromScale(1, 1)          -- match the minimap frame
border.Image = BORDER_IMAGE_ID
border.ZIndex = 100                           -- above dots
border.Parent = frame

-- Optional tint & alpha
-- border.ImageColor3 = Color3.fromRGB(255, 255, 255)
-- border.ImageTransparency = 0

-- (If your border PNG uses 9-slice, uncomment these)
-- border.ScaleType = Enum.ScaleType.Slice
-- border.SliceCenter = Rect.new(32,32,480,480) -- adjust to your ring’s safe area

Notes:

  1. “Use a transparent ring PNG so only the rim shows.”
  • Make a square PNG (e.g., 512×512) with a transparent center and only the circular rim visible.
  • Export with alpha (no matte) to avoid a white/black halo.
  • You can tint it at runtime: border.ImageColor3 = Color3.fromRGB(255,255,255) and fade with border.ImageTransparency = 0.2.
  • Put it in an ImageLabel that exactly overlays the minimap frame:
local BORDER_IMAGE_ID = "rbxassetid://1234567890" -- your ring PNG
local border = Instance.new("ImageLabel")
border.BackgroundTransparency = 1
border.AnchorPoint = Vector2.new(0.5,0.5)
border.Position = UDim2.fromScale(0.5,0.5)
border.Size = UDim2.fromScale(1,1)   -- match frame
border.Image = BORDER_IMAGE_ID
border.ZIndex = 100                  -- above dots
border.Parent = frame

If you need the rim thickness to stay constant in pixels while resizing the map, consider UIStroke (below). 9-slice on a perfect circle usually distorts; use the image at a fixed size, or accept scaling of the rim.

  1. “Keep your minimap frame square; the ring scales cleanly.”
  • Enforce a 1:1 aspect so the ring doesn’t oval-stretch:
local ar = Instance.new("UIAspectRatioConstraint", frame)
ar.AspectRatio = 1

Make the frame circular (not just square) so any stroke/image aligns:

local corner = frame:FindFirstChildOfClass("UICorner") or Instance.new("UICorner", frame)
corner.CornerRadius = UDim.new(0.5, 0) -- perfect circle

If you position top-right, keep it square with offsets or scale both axes equally:

frame.Size = UDim2.fromOffset(150,150)  -- square
-- or frame.Size = UDim2.fromScale(0,0); use ar constraint to hold 1:1
  1. “If you just need a solid border (no image), a one-liner also works.”
  • UIStroke draws a crisp border around the actual shape of the frame (respects UICorner), and thickness stays constant in pixels:
-- Make sure the frame is circular first (UICorner 0.5,0)
local stroke = Instance.new("UIStroke")
stroke.Thickness = 2                   -- pixel thickness
stroke.Color = Color3.fromRGB(255,255,255)
stroke.Transparency = 0                -- 0 = opaque
stroke.ApplyStrokeMode = Enum.ApplyStrokeMode.Border
stroke.LineJoinMode = Enum.LineJoinMode.Round
stroke.Parent = frame

UIStroke vs Image ring:

  • UIStroke: always crisp at any size, easy to recolor, constant pixel width.
  • Image ring: can be decorative (textures, ticks, bezel), but rim thickness scales with the frame unless you keep size fixed.

MusicManager

LocalScript that creates a looping BGM Sound and exposes _G.RPG_PlayBGM(soundId?) and _G.RPG_StopBGM(). Plugin automatically drops the Manager in StarterPlayerScripts. Call from client Gui, or fire a RemoteEvent from the server to tell clients what to play.

Client (e.g., a Gui button):

-- Toggle BGM on a button click (client)
if _G.RPG_PlayBGM then _G.RPG_PlayBGM("rbxassetid://1841380907") end   -- start / switch track
-- later
if _G.RPG_StopBGM then _G.RPG_StopBGM() end                            -- stop

Server → Client (tell all players to play a track):

-- ServerScriptService/BGMBridge
local RS = game:GetService("ReplicatedStorage")
local RE = RS:FindFirstChild("RPG_PlayBGM") or Instance.new("RemoteEvent", RS)
RE.Name = "RPG_PlayBGM"

-- Play for everyone:
RE:FireAllClients("rbxassetid://1841380907")

-- Stop for everyone (send nil as id and false as play):
RE:FireAllClients(nil, false)

This is useful for overworld music, cutscenes, dungeons, sound effects, and more.

DayNightCycle

Server script that advances Lighting.ClockTime continuously. Drop in ServerScriptService. Configure DAY_SECONDS Basic script used in most games, here are some free extras:

Realtime (sync ClockTime to real-world clock):

-- Script: DayNight_Realtime
-- Syncs Lighting.ClockTime to real time (local offset).
local Lighting = game:GetService("Lighting")

local TIMEZONE_OFFSET = -5 -- hours from UTC (e.g., -5 = US Eastern Standard). Adjust for your audience.

local function update()
	-- Get UTC time, apply offset, wrap to [0..24)
	local utc = os.date("!*t")
	local hour = (utc.hour + TIMEZONE_OFFSET) % 24
	local clock = hour + (utc.min/60) + (utc.sec/3600)
	Lighting.ClockTime = clock
end

-- Update once per second (finer is unnecessary)
while true do
	update()
	task.wait(1)
end

Notes:

  • Uses server time; all clients see the same sky.
  • Change TIMEZONE_OFFSET to match your game’s region (or compute per-player on the client if you want local-per-player time).

Cinematic Sunsets (Lighting + post effects):

-- Script: DayNightCycle_Cinematic
-- 24h cycle completes in 300s (5 minutes) + warm, pretty sunsets/dawns via post effects.

local Lighting   = game:GetService("Lighting")
local RunService = game:GetService("RunService")

-- === Config ===
local DAY_SECONDS = 300       -- 5 minutes per full day
local SUNSET_WIDTH_HRS = 2    -- how wide the dawn/dusk effect is (hours)

-- Ensure helper (idempotent)
local function ensure(className, name, parent)
	local obj = parent:FindFirstChild(name)
	if not obj then
		obj = Instance.new(className)
		obj.Name = name
		obj.Parent = parent
	end
	return obj
end

-- Effects we drive
local CC    = ensure("ColorCorrectionEffect", "RPG_CC", Lighting)
local Bloom = ensure("BloomEffect",           "RPG_Bloom", Lighting)
local Atm   = Lighting:FindFirstChildOfClass("Atmosphere") or Instance.new("Atmosphere", Lighting)
Atm.Name = "RPG_Atmosphere"

-- How "sunsetty" are we right now? (0..1) peaking near 06:00 and 18:00
local function sunsetFactor(clockTime)
	local function lobe(center, width)
		local d = math.abs(clockTime - center)
		return math.clamp(1 - (d / width), 0, 1)
	end
	return math.max(lobe(6, SUNSET_WIDTH_HRS), lobe(18, SUNSET_WIDTH_HRS))
end

-- (Optional) baseline Lighting knobs; tweak to taste
local function applyBaseline()
	Lighting.EnvironmentDiffuseScale  = 1
	Lighting.EnvironmentSpecularScale = 1
	Lighting.Brightness      = 2
	Lighting.Ambient         = Color3.fromRGB(80, 80, 90)
	Lighting.OutdoorAmbient  = Color3.fromRGB(110,110,120)
end

-- Drive the day/night + effects
local t = 0
RunService.Stepped:Connect(function(_, dt)
	-- ClockTime advance (maps [0..DAY_SECONDS) -> [0..24))
	t = (t + dt) % DAY_SECONDS
	local ct = (t / DAY_SECONDS) * 24
	Lighting.ClockTime = ct

	applyBaseline()

	-- Dawn/dusk intensity
	local k = sunsetFactor(ct) -- 0..1

	-- Color correction: warm tint, gentle sat/contrast at dawn/dusk
	CC.TintColor  = Color3.fromRGB(255, 200 - 40*k, 150) -- warms as k ↑
	CC.Saturation = 0.05 + 0.25*k
	CC.Contrast   = 0.02 + 0.15*k
	-- CC.Brightness = 0 -- leave neutral; add if you want

	-- Bloom: a bit more glow near the horizon
	Bloom.Intensity = 0.5 + 1.5*k
	Bloom.Threshold = 1
	Bloom.Size      = 12

	-- Atmosphere: richer horizon and density at sunset
	Atm.Color   = Color3.fromRGB(198, 220, 255)
	Atm.Decay   = Color3.fromRGB(128 + 100*k, 100, 80) -- redder decay as k ↑
	Atm.Density = 0.35 + 0.25*k
	Atm.Offset  = 0.25
	Atm.Glare   = 0.2 + 0.3*k
	Atm.Haze    = 1 + 1.5*k
end)

Here’s a Mars realtime script that keeps Roblox’s sun/moon synced to Martian local solar time (LMST) and approximates a Mars vantage by using your chosen Mars latitude/longitude. It updates Lighting.ClockTime from Earth UTC using a standard MSD/LMST conversion, so the Sun/Moon follow a Mars-length day.

What it does: maps Earth UTC → Mars Sol Date (MSD)LMST and drives Lighting.ClockTime so the sky (sun & moon directions) behave like you’re on Mars. You can set the in-game Mars latitude and longitude. (Roblox ties the Moon’s direction to ClockTime

-- Script: DayNight_MarsRealtime
-- Realtime Mars clock: sync Lighting.ClockTime to Martian Local Mean Solar Time (LMST).
-- Sun & Moon directions follow Roblox's sky based on ClockTime + GeographicLatitude.
-- NOTE: Roblox doesn't simulate Phobos/Deimos; "Moon" follows the engine's lunar path.

local Lighting = game:GetService("Lighting")
local RunService = game:GetService("RunService")

-- ==== CONFIG ====
local MARS_LATITUDE_DEG  = 0         -- your "player" latitude on Mars (−90..+90). 0 = equator
local MARS_LONGITUDE_DEG = 0         -- local longitude on Mars, east-positive (0..360)
local UPDATE_EVERY_SEC   = 1         -- how often to refresh (1s is plenty)
-- Optional: swap sky textures (placeholders). Leave blank "" to keep defaults.
local SUN_TEXTURE_ID  = ""           -- e.g., "rbxassetid://<your_sun_tex>"
local MOON_TEXTURE_ID = ""           -- e.g., set a Phobos/Deimos image if you want

-- ==== TIME MATH (UTC → MSD → MTC → LMST) ====
-- Julian Date from UNIX UTC seconds
local function jd_from_unix_utc(unix_utc)
    return (unix_utc / 86400) + 2440587.5
end

-- Mars Sol Date (approx; ignores TT-UTC small correction)
local function msd_from_jd(jd)
    -- Constants from common Mars time conversions
    return (jd - 2405522.0028779) / 1.0274912517
end

-- Martian Coordinated Time in hours [0,24)
local function mtc_hours_from_msd(msd)
    local frac = msd - math.floor(msd)
    if frac < 0 then frac = frac + 1 end
    return frac * 24
end

-- Local Mean Solar Time in hours at a given east-positive longitude
local function lmst_hours(mtc_hours, lon_deg)
    local h = mtc_hours + (lon_deg / 15) -- 360° → 24h => 15° per hour
    h = h % 24
    if h < 0 then h = h + 24 end
    return h
end

-- Reliable UTC seconds (prefer DateTime API; fallback to os.time/!*t)
local function unix_utc_now()
    if typeof(DateTime) == "table" and DateTime.now then
        return DateTime.now().UnixTimestamp
    end
    -- Fallback: build UTC table then correct timezone using difference trick
    local localNow = os.time()
    local utcTbl   = os.date("!*t", localNow)
    local utcLike  = os.time(utcTbl)              -- interpreted as local, off by tz
    local tzOffset = localNow - utcLike           -- seconds east of UTC (positive in most US timezones)
    return localNow - tzOffset
end

-- ==== SKY SETUP ====
-- Optionally swap sun/moon textures (purely visual)
local function maybe_configure_sky()
    local sky = Lighting:FindFirstChildOfClass("Sky")
    if not sky then
        sky = Instance.new("Sky")
        sky.Name = "RPG_MarsSky"
        sky.Parent = Lighting
    end
    if SUN_TEXTURE_ID ~= "" then sky.SunTextureId  = SUN_TEXTURE_ID end
    if MOON_TEXTURE_ID ~= "" then sky.MoonTextureId = MOON_TEXTURE_ID end
end

-- Set a Mars "viewer" latitude so the sun’s arc matches that latitude.
-- (This is the player's latitude, not axial obliquity; good enough for visuals.)
Lighting.GeographicLatitude = MARS_LATITUDE_DEG
maybe_configure_sky()

-- ==== MAIN LOOP ====
task.spawn(function()
    while true do
        local unix = unix_utc_now()
        local jd   = jd_from_unix_utc(unix)
        local msd  = msd_from_jd(jd)
        local mtcH = mtc_hours_from_msd(msd)
        local lmst = lmst_hours(mtcH, MARS_LONGITUDE_DEG)

        -- Drive Roblox sky
        Lighting.ClockTime = lmst

        task.wait(UPDATE_EVERY_SEC)
    end
end)

--[[ Practical notes:
1) Set MARS_LATITUDE_DEG to where your scene is on Mars (e.g., 18.5 for Jezero-like mid-lat).
2) Set MARS_LONGITUDE_DEG to pick your local time zone. 0 = Mars "prime meridian".
3) You can give the Moon a Phobos/Deimos look via MOON_TEXTURE_ID (texture only).
4) Exact Phobos/Deimos positions require custom sprites/models; Roblox ties Moon to ClockTime.
--]]

Thank you for reading, purchasing, or simply being interested in this Plugin. Please send any bug reports or feedback to my Discord Tag (rob2002) or here in this DevForum post.

-rob

2 Likes