Add survival-stats engine + designer-editable HUD

Server tick (src/systems/SurvivalStats) simulates per-stat drain/regen stored as
Player Attributes, which auto-replicate so the client HUD (src/client/Hud) binds
reactively via GetAttributeChangedSignal — no RemoteEvents. Stats are defined in
src/stats/StatDefs and resolved through three layers (defaults < Config.override <
a Studio SurvivalStatsConfig instance) by src/stats/StatConfig, so owners retune
rates with no code.

The HUD is a real, designer-editable ScreenGui in StarterGui, authored as
diff-friendly JSON (assets/hud/SurvivalHud.model.json); bars bind by a `Stat`
attribute + a `Fill` child, so re-texturing needs zero code. It ships for every
distribution: the demo/Rojo source mount it, the drop-in .rbxm bundles it under a
Templates folder and start()/installHud() deploys it, and HudFallback guarantees a
HUD always appears. Adds the engine's first client layer (startClient()).

Allow committing *.rbxmx/model templates (.gitignore) and skip the CI-fetched
globalTypes.d.luau in selene.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Samuel Lison
2026-06-19 13:30:12 +10:00
co-authored by Claude Opus 4.8
parent f407056588
commit 649ff0c192
13 changed files with 1864 additions and 11 deletions
+3 -2
View File
@@ -6,9 +6,10 @@ globalTypes.d.luau
*.rbxl.lock
*.rbxlx.lock
# Build output (the .rbxm is built in CI and attached to releases)
# Build output (the .rbxm is built in CI and attached to releases).
# NOTE: *.rbxmx (XML models — e.g. the HUD/config templates under assets/) are
# version-controlled source, NOT build output, so they are intentionally tracked.
*.rbxm
*.rbxmx
/build/
# Toolchain managers
+13
View File
@@ -0,0 +1,13 @@
--!nonstrict
--[[
SurvivorCore HUD loader. Ships into StarterPlayerScripts (mounted by the demo,
cloned by SurvivorCore.start()/installHud() for drop-in consumers). It just boots
the client HUD; all logic lives in the engine module so it stays versioned.
Editing or restyling the HUD is done on the `SurvivalHud` ScreenGui in StarterGui
never here.
]]
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local SurvivorCore = require(ReplicatedStorage:WaitForChild("SurvivorCore"))
SurvivorCore.startClient()
@@ -0,0 +1,89 @@
{
"className": "Configuration",
"children": [
{
"className": "Configuration",
"name": "Health",
"attributes": {
"RatePerSecond": 0.0,
"Max": 100,
"Start": 100,
"WarnAt": 25,
"Invert": false,
"Display": true
}
},
{
"className": "Configuration",
"name": "Energy",
"attributes": {
"RatePerSecond": 0.0,
"Max": 100,
"Start": 100,
"WarnAt": 25,
"Invert": false,
"Display": true
}
},
{
"className": "Configuration",
"name": "Hunger",
"attributes": {
"RatePerSecond": 0.055556,
"Max": 100,
"Start": 0,
"WarnAt": 25,
"Invert": true,
"Display": true
}
},
{
"className": "Configuration",
"name": "Thirst",
"attributes": {
"RatePerSecond": 0.083333,
"Max": 100,
"Start": 0,
"WarnAt": 25,
"Invert": true,
"Display": true
}
},
{
"className": "Configuration",
"name": "Fatigue",
"attributes": {
"RatePerSecond": 0.027778,
"Max": 100,
"Start": 0,
"WarnAt": 25,
"Invert": true,
"Display": true
}
},
{
"className": "Configuration",
"name": "Blood",
"attributes": {
"RatePerSecond": 0.0,
"Max": 100,
"Start": 100,
"WarnAt": 25,
"Invert": false,
"Display": true
}
},
{
"className": "Configuration",
"name": "Poison",
"attributes": {
"RatePerSecond": 0.0,
"Max": 100,
"Start": 0,
"WarnAt": 25,
"Invert": true,
"Display": true
}
}
]
}
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -1,6 +1,12 @@
{
"name": "SurvivorCore",
"tree": {
"$path": "src"
"$path": "src",
"Templates": {
"$className": "Folder",
"SurvivalHud": { "$path": "assets/hud/SurvivalHud.model.json" },
"SurvivalStatsConfig": { "$path": "assets/config/SurvivalStatsConfig.model.json" },
"HudLoader": { "$path": "assets/client/HudLoader.client.luau" }
}
}
}
+13 -5
View File
@@ -3,13 +3,21 @@
"tree": {
"$className": "DataModel",
"ReplicatedStorage": {
"SurvivorCore": {
"$path": "src"
}
"SurvivorCore": { "$path": "src" },
"SurvivalStatsConfig": { "$path": "assets/config/SurvivalStatsConfig.model.json" }
},
"ServerScriptService": {
"Demo": {
"$path": "demo/server"
"Demo": { "$path": "demo/server" }
},
"StarterGui": {
"$className": "StarterGui",
"SurvivalHud": { "$path": "assets/hud/SurvivalHud.model.json" }
},
"StarterPlayer": {
"$className": "StarterPlayer",
"StarterPlayerScripts": {
"$className": "StarterPlayerScripts",
"HudLoader": { "$path": "assets/client/HudLoader.client.luau" }
}
},
"Workspace": {
+2 -2
View File
@@ -2,8 +2,8 @@
# Uses the Roblox standard library (globals like `game`, `task`, `typeof`, `warn`).
std = "roblox"
# Generated Wally/Rojo artifacts are not ours to lint.
exclude = ["Packages", "ServerPackages", "DevPackages"]
# Generated Wally/Rojo artifacts (and the CI-fetched Roblox type defs) are not ours to lint.
exclude = ["Packages", "ServerPackages", "DevPackages", "globalTypes.d.luau"]
[lints]
# Roblox code legitimately leaves some values unused (e.g. connection handles);
+203
View File
@@ -0,0 +1,203 @@
--[[
Hud the client HUD binder. CLIENT-ONLY.
Drives any `SurvivorStatBar`-tagged GUI from the player's stat Attributes (which
the server writes and Roblox auto-replicates — no RemoteEvents). The owner authors
and re-textures the actual ScreenGui in StarterGui; this only ever sets each bar's
`Fill` size + color, so restyling needs zero code.
Per-bar contract (attributes on the tagged Frame; most default from the stat config,
so owners usually set only `Stat`):
Stat (string) Player attribute to bind, e.g. "Hunger" (required)
Max (number) fill denominator
Invert (bool) fill = 1 - value/Max (bar full = healthy)
WarnAt (number) warn when the displayed bar drops below this percent
FillColor/WarnColor (Color3), FillAxis ("X"|"Y")
Required child: a GuiObject named `Fill`. Everything else is free-form styling.
]]
local CollectionService = game:GetService("CollectionService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
assert(RunService:IsClient(), "SurvivorCore HUD is client-only — boot it via SurvivorCore.startClient()")
local StatConfig = require(script.Parent.Parent.stats.StatConfig)
local HudFallback = require(script.Parent.HudFallback)
local BAR_TAG = "SurvivorStatBar"
local HUD_NAME = "SurvivalHud"
local FALLBACK_WAIT = 1 -- seconds to wait for an authored HUD before building the fallback
local DEFAULT_WARN_COLOR = Color3.fromRGB(200, 40, 40)
local Hud = {}
local started = false
-- installHud() + the StarterGui->PlayerGui copy can briefly produce two HUDs; keep one.
local function dedupeHuds(playerGui: Instance)
local kept: Instance? = nil
for _, gui in playerGui:GetChildren() do
if gui:IsA("ScreenGui") and gui.Name == HUD_NAME then
if kept then
gui:Destroy()
else
kept = gui
end
end
end
end
function Hud.start(_options: { [string]: any }?)
if started then
-- warn-and-return (not assert): the loader may re-run across respawns.
warn("[SurvivorCore] startClient() called more than once — ignoring")
return
end
started = true
local localPlayer = Players.LocalPlayer
local playerGui = localPlayer:WaitForChild("PlayerGui")
local byName = StatConfig.resolve().byName
local bound: { [Instance]: () -> () } = {}
local renderers: { [Instance]: () -> () } = {}
local function bindBar(bar: Instance)
local statName = bar:GetAttribute("Stat")
if typeof(statName) ~= "string" or statName == "" then
warn(`[SurvivorCore HUD] bar '{bar:GetFullName()}' is missing a 'Stat' attribute`)
return
end
local fill = bar:FindFirstChild("Fill")
if not fill or not fill:IsA("GuiObject") then
warn(`[SurvivorCore HUD] bar '{bar:GetFullName()}' is missing a 'Fill' GuiObject child`)
return
end
local fillObject: GuiObject = fill
local isImage = fillObject:IsA("ImageLabel") or fillObject:IsA("ImageButton")
local authoredColor = if isImage
then (fillObject :: ImageLabel).ImageColor3
else (fillObject :: Frame).BackgroundColor3
local function setColor(color: Color3)
if isImage then
(fillObject :: ImageLabel).ImageColor3 = color
else
(fillObject :: Frame).BackgroundColor3 = color
end
end
local function render()
local def = byName[statName]
local max = bar:GetAttribute("Max") or (def and def.max) or 100
local invert = bar:GetAttribute("Invert")
if invert == nil then
invert = (def and def.invert) or false
end
local warnAt = bar:GetAttribute("WarnAt") or (def and def.warnAt) or 25
local axis = bar:GetAttribute("FillAxis") or "X"
local normalColor = bar:GetAttribute("FillColor") or authoredColor
local warnColor = bar:GetAttribute("WarnColor") or DEFAULT_WARN_COLOR
local value = localPlayer:GetAttribute(statName)
local ratio = 0
if typeof(value) == "number" and max > 0 then
ratio = math.clamp(value / max, 0, 1)
end
local fillRatio = if invert then 1 - ratio else ratio
local size = fillObject.Size
if axis == "Y" then
fillObject.Size = UDim2.new(size.X.Scale, size.X.Offset, fillRatio, 0)
else
fillObject.Size = UDim2.new(fillRatio, 0, size.Y.Scale, size.Y.Offset)
end
setColor(if fillRatio * 100 < warnAt then warnColor else normalColor)
end
local connection = localPlayer:GetAttributeChangedSignal(statName):Connect(render)
renderers[bar] = render
bound[bar] = function()
connection:Disconnect()
end
render()
end
local function tryBind(bar: Instance)
if bound[bar] or not bar:IsA("GuiObject") or not bar:IsDescendantOf(playerGui) then
return
end
bindBar(bar)
end
dedupeHuds(playerGui)
-- A stat bar is any GuiObject carrying a `Stat` attribute (how the authored
-- template marks them) OR tagged `SurvivorStatBar` (for the Builder UI / fallback).
local function isBar(instance: Instance): boolean
return instance:IsA("GuiObject") and instance:GetAttribute("Stat") ~= nil
end
for _, instance in playerGui:GetDescendants() do
if isBar(instance) then
tryBind(instance)
end
end
playerGui.DescendantAdded:Connect(function(instance)
if isBar(instance) then
tryBind(instance)
end
end)
playerGui.DescendantRemoving:Connect(function(instance)
local disconnect = bound[instance]
if disconnect then
disconnect()
end
bound[instance] = nil
renderers[instance] = nil
end)
for _, bar in CollectionService:GetTagged(BAR_TAG) do
tryBind(bar)
end
CollectionService:GetInstanceAddedSignal(BAR_TAG):Connect(tryBind)
-- Re-render bars live when the owner edits the Studio config (WarnAt / Max / colors).
local function rerenderAll()
byName = StatConfig.resolve().byName
for _, render in renderers do
render()
end
end
local function watch(instance: Instance)
instance.AttributeChanged:Connect(rerenderAll)
for _, child in instance:GetChildren() do
child.AttributeChanged:Connect(rerenderAll)
end
instance.ChildAdded:Connect(function(child)
child.AttributeChanged:Connect(rerenderAll)
rerenderAll()
end)
end
local configInstance = StatConfig.getConfigInstance()
if configInstance then
watch(configInstance)
end
ReplicatedStorage.ChildAdded:Connect(function(child)
if child.Name == StatConfig.CONFIG_INSTANCE_NAME then
rerenderAll()
watch(child)
end
end)
-- Zero-setup safety net: if nothing showed up, build the minimal fallback.
task.delay(FALLBACK_WAIT, function()
if next(bound) == nil then
HudFallback.build(playerGui, StatConfig.resolve().stats)
end
end)
end
return Hud
+95
View File
@@ -0,0 +1,95 @@
--[[
HudFallback the zero-setup safety net. CLIENT-ONLY.
If no designer-authored HUD reaches the player (no template mounted, none
bundled, none installed), the client binder calls this to build a deliberately
minimal, unstyled bar stack so the HUD ALWAYS appears. It is intentionally plain
the authored `SurvivalHud` template is the real thing. Uses zero asset IDs.
The bars it creates follow the same `SurvivorStatBar` tag + `Fill` child contract
as the template, so the binder drives them identically.
]]
local CollectionService = game:GetService("CollectionService")
local BAR_TAG = "SurvivorStatBar"
local HUD_NAME = "SurvivalHud"
-- Plain, readable colors for the fallback only (the template carries its own art).
local FALLBACK_COLORS = {
Health = Color3.fromRGB(232, 70, 70),
Energy = Color3.fromRGB(90, 205, 120),
Hunger = Color3.fromRGB(220, 140, 40),
Thirst = Color3.fromRGB(60, 160, 230),
Fatigue = Color3.fromRGB(160, 100, 220),
Blood = Color3.fromRGB(180, 40, 40),
Poison = Color3.fromRGB(120, 180, 60),
}
local HudFallback = {}
function HudFallback.build(playerGui: Instance, stats: { any })
local screen = Instance.new("ScreenGui")
screen.Name = HUD_NAME
screen.ResetOnSpawn = false
screen.IgnoreGuiInset = true
screen.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
local container = Instance.new("Frame")
container.Name = "Bars"
container.Position = UDim2.fromOffset(18, 18)
container.Size = UDim2.fromOffset(220, 0)
container.AutomaticSize = Enum.AutomaticSize.Y
container.BackgroundTransparency = 1
container.Parent = screen
local layout = Instance.new("UIListLayout")
layout.Padding = UDim.new(0, 6)
layout.SortOrder = Enum.SortOrder.LayoutOrder
layout.Parent = container
-- Parent the ScreenGui BEFORE tagging bars, so the tag-added signal fires while
-- each bar is already a descendant of PlayerGui (the binder requires that).
screen.Parent = playerGui
local order = 0
for _, stat in stats do
if stat.display then
order += 1
local bar = Instance.new("Frame")
bar.Name = stat.name
bar.LayoutOrder = order
bar.Size = UDim2.new(1, 0, 0, 18)
bar.BackgroundColor3 = Color3.fromRGB(28, 32, 42)
bar.BorderSizePixel = 0
bar:SetAttribute("Stat", stat.name)
local fill = Instance.new("Frame")
fill.Name = "Fill"
fill.Size = UDim2.fromScale(1, 1) -- driven by the binder
fill.BackgroundColor3 = FALLBACK_COLORS[stat.name] or Color3.fromRGB(150, 150, 150)
fill.BorderSizePixel = 0
fill.Parent = bar
local label = Instance.new("TextLabel")
label.Name = "Label"
label.BackgroundTransparency = 1
label.Size = UDim2.fromScale(1, 1)
label.Text = stat.name
label.TextColor3 = Color3.fromRGB(245, 245, 245)
label.TextSize = 12
label.Font = Enum.Font.GothamMedium
label.ZIndex = 2
label.Parent = bar
CollectionService:AddTag(bar, BAR_TAG)
-- Parent last, fully built, so the binder sees Stat + Fill when it fires.
bar.Parent = container
end
end
return screen
end
return HudFallback
+28 -1
View File
@@ -3,7 +3,10 @@
local SurvivorCore = require(ReplicatedStorage.SurvivorCore)
SurvivorCore.Items.register({ id = "reed", name = "Reed" })
SurvivorCore.start()
SurvivorCore.start() -- server: boots content + the survival-stats sim
-- on the client (the built-in HUD template's loader LocalScript does this):
SurvivorCore.startClient() -- builds + binds the reactive survival HUD
Two extension layers:
Programmatic registries (Items, Recipes, Stats, Mobs, ...) register content from code.
@@ -17,6 +20,11 @@ local Hooks = require(script.foundation.Hooks)
local Registries = require(script.registries)
local Components = require(script.components)
-- Eagerly install the built-in survival stats: defines the "SurvivalStats" Config
-- section and registers the default stats, so Config.override("SurvivalStats", …)
-- works any time before start(). Runs on both server and client (idempotent per side).
require(script.stats.StatDefs)
local SurvivorCore = {}
SurvivorCore.VERSION = "0.1.0"
@@ -40,6 +48,7 @@ SurvivorCore.Mobs = Registries.Mobs
SurvivorCore.Components = Components
local started = false
local clientStarted = false
-- Boot the engine. Call once, from the server, after registering content.
function SurvivorCore.start(_options: { [string]: any }?)
@@ -52,6 +61,24 @@ function SurvivorCore.start(_options: { [string]: any }?)
-- TODO (extraction): boot order — Config merge → Assets → persistence → systems.
Components.scan()
-- Survival-stats simulation (server-only): ticks stats as Player Attributes and
-- installs the built-in HUD for drop-in consumers that haven't supplied their own.
require(script.systems.SurvivalStats).start(_options)
return SurvivorCore
end
-- Boot the client layer. Call once per client — the built-in HUD template's loader
-- LocalScript does this for you. Builds + binds the reactive survival HUD.
function SurvivorCore.startClient(_options: { [string]: any }?)
if clientStarted then
warn("[SurvivorCore] startClient() called more than once — ignoring")
return SurvivorCore
end
clientStarted = true
require(script.client.Hud).start(_options)
return SurvivorCore
end
+103
View File
@@ -0,0 +1,103 @@
--[[
StatConfig resolves the effective survival-stat models by merging three layers
(last wins):
1. engine defaults the Stats registry (populated by StatDefs).
2. developer path Config.override("SurvivalStats", { Hunger = { ratePerSecond = } }).
3. owner / no-code a `SurvivalStatsConfig` Configuration instance in
ReplicatedStorage, whose per-stat children carry Attributes
the owner edits in Studio (highest priority).
SHARED: the server reads this for the tick loop; the client reads it for per-bar
defaults (Max / Invert / WarnAt) and the fallback HUD.
]]
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Config = require(script.Parent.Parent.foundation.Config)
local Registries = require(script.Parent.Parent.registries)
local StatConfig = {}
StatConfig.CONFIG_SECTION = "SurvivalStats"
StatConfig.CONFIG_INSTANCE_NAME = "SurvivalStatsConfig"
-- Attribute name on the Studio Configuration child -> resolved field name.
local STUDIO_ATTR_MAP = {
RatePerSecond = "ratePerSecond",
Max = "max",
Start = "start",
WarnAt = "warnAt",
Invert = "invert",
Display = "display",
}
local function applyTable(target: { [string]: any }, override: { [string]: any }?)
if typeof(override) ~= "table" then
return
end
for key, value in override do
target[key] = value
end
end
local function applyStudio(target: { [string]: any }, configInstance: Instance?, statName: string)
if not configInstance then
return
end
local node = configInstance:FindFirstChild(statName)
if not node then
return
end
for attr, field in STUDIO_ATTR_MAP do
local value = node:GetAttribute(attr)
if value ~= nil then
target[field] = value
end
end
end
export type ResolvedStat = {
name: string,
attribute: string,
start: number,
max: number,
ratePerSecond: number,
invert: boolean,
warnAt: number,
display: boolean,
}
-- Returns the merged stat models, both as an ordered array and a name lookup.
function StatConfig.resolve(): { stats: { ResolvedStat }, byName: { [string]: ResolvedStat } }
local section = Config.get(StatConfig.CONFIG_SECTION) -- nil if StatDefs never installed
local configInstance = ReplicatedStorage:FindFirstChild(StatConfig.CONFIG_INSTANCE_NAME)
local stats = {}
local byName = {}
for _, def in Registries.Stats.getAll() do
local resolved = {
name = def.name,
attribute = def.attribute or def.name,
start = def.start,
max = def.max,
ratePerSecond = def.ratePerSecond,
invert = def.invert,
warnAt = def.warnAt,
display = def.display,
}
if section then
applyTable(resolved, section[def.name])
end
applyStudio(resolved, configInstance, def.name)
table.insert(stats, resolved)
byName[def.name] = resolved
end
return { stats = stats, byName = byName }
end
-- The live Studio config instance, if present (used to watch for owner edits).
function StatConfig.getConfigInstance(): Instance?
return ReplicatedStorage:FindFirstChild(StatConfig.CONFIG_INSTANCE_NAME)
end
return StatConfig
+111
View File
@@ -0,0 +1,111 @@
--[[
StatDefs the survival-stat roster the engine ships with, and the one-time
install that registers it. SHARED: safe to require on server and client.
Each stat is a single signed-rate model: `ratePerSecond` moves the raw value
toward `max` (positive) or `0` (negative). `invert` affects ONLY HUD display /
warnings — an inverted bar shows the *healthy* amount (full = good), so a stat
that rises to be bad (Hunger 0→100) renders as a bar that depletes.
Owners retune without code via the Studio `SurvivalStatsConfig` instance;
developers may instead `Config.override("SurvivalStats", { ... })`. See StatConfig.
]]
local Config = require(script.Parent.Parent.foundation.Config)
local Registries = require(script.Parent.Parent.registries)
export type StatDef = {
name: string,
start: number,
max: number,
ratePerSecond: number, -- signed: + rises toward max, - falls toward 0
invert: boolean, -- display only: true => bar full = healthy (value 0 = full)
warnAt: number, -- warn when the displayed bar drops below this percent (0-100)
display: boolean, -- show in the HUD by default
attribute: string, -- Player attribute name (defaults to `name`)
}
local StatDefs = {}
-- Reach `max` (from 0) in N minutes — readable way to express drift rates.
local function perMinutes(minutes: number): number
return 100 / (minutes * 60)
end
-- The shipped defaults. Numbers ported from The Counter Earth; content-free.
-- Hunger/Thirst/Fatigue/Poison start at 0 and rise toward 100 = bad (invert = true,
-- so their bars deplete). Blood starts full and 0 = death. Health/Energy are
-- display-only in this build (drain/regen + consequences arrive with #5/#6).
StatDefs.DEFAULTS = {
{ name = "Health", start = 100, max = 100, ratePerSecond = 0, invert = false, warnAt = 25, display = true },
{ name = "Energy", start = 100, max = 100, ratePerSecond = 0, invert = false, warnAt = 25, display = true },
{
name = "Hunger",
start = 0,
max = 100,
ratePerSecond = perMinutes(30),
invert = true,
warnAt = 25,
display = true,
},
{
name = "Thirst",
start = 0,
max = 100,
ratePerSecond = perMinutes(20),
invert = true,
warnAt = 25,
display = true,
},
{
name = "Fatigue",
start = 0,
max = 100,
ratePerSecond = perMinutes(60),
invert = true,
warnAt = 25,
display = true,
},
{ name = "Poison", start = 0, max = 100, ratePerSecond = 0, invert = true, warnAt = 25, display = true },
{ name = "Blood", start = 100, max = 100, ratePerSecond = 0, invert = false, warnAt = 25, display = true },
}
local installed = false
-- Register each default stat into the Stats registry and define the tunable
-- `SurvivalStats` Config section. Idempotent per Luau VM (server and client each
-- install once). Called eagerly at require time so `Config.override("SurvivalStats", )`
-- works any time before `start()`.
function StatDefs.install()
if installed then
return
end
installed = true
local section = {}
for _, def in StatDefs.DEFAULTS do
section[def.name] = {
start = def.start,
max = def.max,
ratePerSecond = def.ratePerSecond,
invert = def.invert,
warnAt = def.warnAt,
display = def.display,
}
Registries.Stats.register({
name = def.name,
start = def.start,
max = def.max,
ratePerSecond = def.ratePerSecond,
invert = def.invert,
warnAt = def.warnAt,
display = def.display,
attribute = def.name,
})
end
Config.defineSection("SurvivalStats", section)
end
StatDefs.install()
return StatDefs
+158
View File
@@ -0,0 +1,158 @@
--[[
SurvivalStats the server-side survival simulation. SERVER-ONLY.
Stores each stat as a Player Attribute (which auto-replicates to that player's
client, so the HUD needs no RemoteEvents), and ticks every stat by its signed
`ratePerSecond` toward 0 / max. Owners retune live via the Studio
`SurvivalStatsConfig` instance; this service re-resolves when it changes.
`installHud()` deploys the bundled HUD/config templates for drop-in consumers
that haven't supplied their own.
]]
local CollectionService = game:GetService("CollectionService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local StarterGui = game:GetService("StarterGui")
assert(RunService:IsServer(), "SurvivorCore.SurvivalStats is server-only — require it via SurvivorCore.start()")
local StatConfig = require(script.Parent.Parent.stats.StatConfig)
local HUD_NAME = "SurvivalHud"
local LOADER_NAME = "HudLoader"
local BAR_TAG = "SurvivorStatBar"
local TICK_INTERVAL = 0.1 -- 10 Hz: smooth bars without flooding attribute replication
local SurvivalStats = {}
local started = false
local resolved: any = nil -- cached StatConfig.resolve() result
local function refresh()
resolved = StatConfig.resolve()
end
local function initPlayer(player: Player)
if not resolved then
return
end
for _, stat in resolved.stats do
if player:GetAttribute(stat.attribute) == nil then
player:SetAttribute(stat.attribute, stat.start)
end
end
end
-- Re-resolve the moment the owner edits the Studio config instance, so no-code
-- rate tweaks take effect without a restart.
local function watchConfig()
local function bindInstance(instance: Instance)
instance.AttributeChanged:Connect(refresh)
for _, child in instance:GetChildren() do
child.AttributeChanged:Connect(refresh)
end
instance.ChildAdded:Connect(function(child)
child.AttributeChanged:Connect(refresh)
refresh()
end)
end
local existing = StatConfig.getConfigInstance()
if existing then
bindInstance(existing)
end
ReplicatedStorage.ChildAdded:Connect(function(child)
if child.Name == StatConfig.CONFIG_INSTANCE_NAME then
refresh()
bindInstance(child)
end
end)
end
local function hudPresentInStarterGui(): boolean
if StarterGui:FindFirstChild(HUD_NAME) then
return true
end
for _, instance in CollectionService:GetTagged(BAR_TAG) do
if instance:IsDescendantOf(StarterGui) then
return true
end
end
return false
end
-- Deploy the bundled templates if the consumer hasn't supplied their own. No-op for
-- source/demo consumers (who mount the templates directly) and when an owner HUD
-- already exists. Cloned into StarterGui so it reaches players via the native
-- StarterGui -> PlayerGui copy on spawn.
function SurvivalStats.installHud()
local root = script.Parent.Parent -- the SurvivorCore root model
local templates = root:FindFirstChild("Templates")
if not templates then
return
end
local configTemplate = templates:FindFirstChild(StatConfig.CONFIG_INSTANCE_NAME)
if configTemplate and not ReplicatedStorage:FindFirstChild(StatConfig.CONFIG_INSTANCE_NAME) then
configTemplate:Clone().Parent = ReplicatedStorage
end
local hudTemplate = templates:FindFirstChild(HUD_NAME)
if hudTemplate and not hudPresentInStarterGui() then
hudTemplate:Clone().Parent = StarterGui
end
-- The client boot loader rides StarterPlayerScripts (copied to each joiner).
local loaderTemplate = templates:FindFirstChild(LOADER_NAME)
if loaderTemplate then
local starterScripts = game:GetService("StarterPlayer"):FindFirstChild("StarterPlayerScripts")
if starterScripts and not starterScripts:FindFirstChild(LOADER_NAME) then
loaderTemplate:Clone().Parent = starterScripts
end
end
end
function SurvivalStats.start(_options: { [string]: any }?)
assert(not started, "SurvivalStats.start() called twice")
started = true
SurvivalStats.installHud()
refresh()
watchConfig()
for _, player in Players:GetPlayers() do
initPlayer(player)
end
Players.PlayerAdded:Connect(initPlayer)
local accumulator = 0
RunService.Heartbeat:Connect(function(dt)
if not resolved then
return
end
accumulator += dt
if accumulator < TICK_INTERVAL then
return
end
local step = accumulator
accumulator = 0
for _, player in Players:GetPlayers() do
for _, stat in resolved.stats do
if stat.ratePerSecond ~= 0 then
local current = player:GetAttribute(stat.attribute)
if typeof(current) == "number" then
local nextValue = math.clamp(current + stat.ratePerSecond * step, 0, stat.max)
if nextValue ~= current then
player:SetAttribute(stat.attribute, nextValue)
end
end
end
end
end
end)
end
return SurvivalStats