feat(movement): sprint/jump/energy + low-stat feedback (ported from TheCounterEarth)

Sprinting did nothing because Energy shipped display-only. This ports TCE's
proven movement system into the engine, server-authoritative:

- Hold Shift → sprint: drains Energy while moving, raises WalkSpeed to SprintSpeed,
  forces ExhaustedSpeed at 0 energy; Energy regenerates after an idle delay.
- Jumps cost energy and are gated below MinToJump (JumpPower → 0), with the same
  0.15s multi-signal throttle TCE uses.
- Energy is written through the stat-effects layer (Stats.adjust), so it stays a
  replicated Player Attribute the HUD already shows — no extra remotes for state.

Adds the engine's FIRST RemoteEvent plumbing (shared/Remotes.luau → SprintIntent,
created server-side, awaited client-side) and a tunable "Movement" Config section
(shared/MovementConfig.luau) carrying TCE's exact numbers + the free vignette/
breathing/heartbeat asset IDs.

Client (MovementFeedback, booted by startClient): Shift input → SprintIntent, plus
the low-stat feedback — a screen vignette + breathing loop that intensify as Energy
drops and a heartbeat loop below 40% health, all smoothed per frame.

New files: src/shared/{Remotes,MovementConfig}.luau, src/systems/Movement.luau,
src/client/MovementFeedback.luau. Wired into start()/startClient().

Gate green (stylua/selene/luau-lsp/build x3). Needs an in-Play test after a Studio
restart (Play-Solo stale-bytecode cache). Thirst/fatigue/campfire regen gating is a
follow-on with the full consequence-system port (StatsConfig is the blueprint).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Samuel Lison
2026-06-20 18:49:41 +10:00
co-authored by Claude Opus 4.8
parent bbcbdff5fd
commit b72350757f
7 changed files with 577 additions and 0 deletions
+7
View File
@@ -8,6 +8,13 @@ is promoted to the new version and `main` is tagged `vX.Y.Z`.
## Unreleased
### Added
- **Sprinting, jumping & energy** — hold **Shift** to sprint (server-authoritative): it drains
the Energy stat, speeds you up, and forces an exhausted crawl at 0 energy; energy regenerates
after a short idle delay. Jumps cost energy and are blocked below a threshold. Ships a low-stat
**feedback** layer too — a screen vignette + breathing loop that intensify as energy drops, and
a heartbeat loop below 40% health. Adds the engine's first RemoteEvent (`SprintIntent`) and a
`Movement` Config section (`Config.override("Movement", …)`). Logic + tuning + free default art
ported from The Counter Earth. See [docs/survival-stats.md](docs/survival-stats.md).
- **Dynamic stat effects** — a per-player, server-side modifier layer over the base rates, so
stats can be driven by events instead of only a constant drift. `SurvivorCore.Stats.adjust`
(one-time clamped delta), `addModifier` / `removeModifier` (named, optionally-timed rate
+25
View File
@@ -113,6 +113,31 @@ A modifier with the same `source` replaces the old one (no implicit stacking); a
makes it expire on its own; a player's modifiers are dropped when they leave. These are **server-only**
(authoritative) — drive them from your gameplay code, never trust the client.
## Sprint, jump & energy
The engine ships a server-authoritative movement system that spends the **Energy** stat (ported
from The Counter Earth's proven tuning):
- **Hold Shift** to sprint — the client sends intent over the `SprintIntent` RemoteEvent; the
server speeds the character up (`SprintSpeed`) and drains energy while you're actually moving.
- At **0 energy** you're forced to a slow `ExhaustedSpeed` until it recovers.
- **Energy regenerates** after `RegenDelaySeconds` of not exerting (and not moving).
- **Jumps cost energy** (`JumpCost`) and are **blocked below `MinToJump`** (JumpPower drops to 0).
It's all tunable via the **`Movement`** Config section — no engine edits:
```lua
SurvivorCore.Config.override("Movement", {
Energy = { SprintDrainPerSecond = 20, RegenPerSecond = 10, JumpCost = 8 },
Movement = { SprintSpeed = 28, WalkSpeed = 16, ExhaustedSpeed = 6, JumpPower = 80 },
})
```
**Low-stat feedback (client).** A full-screen **vignette** + a **breathing** loop fade in as energy
drops, and a **heartbeat** loop fades in below `Health.HeartbeatStartRatio` (40%) of health. The
engine ships default art for these (overridable under `Movement.Assets` / the audio + ratio knobs
in the same section). It boots automatically from `SurvivorCore.startClient()`.
## The HUD — restyle it freely
The HUD is the `SurvivalHud` ScreenGui in **StarterGui**. Restyle anything — colors, gradients,
+165
View File
@@ -0,0 +1,165 @@
--!nonstrict
--[[
MovementFeedback client sprint input + low-stat feedback. CLIENT-ONLY.
Ported from The Counter Earth's HudController. Two jobs:
• SPRINT INPUT — hold Left/Right Shift to sprint; fires the `SprintIntent` RemoteEvent
(the server is authoritative for speed + energy). Releases on key-up / focus loss.
• LOW-STAT FEEDBACK — a full-screen vignette + breathing loop that intensify as Energy
drops, and a heartbeat loop that fades in below HeartbeatStartRatio of health. All
driven by reading the replicated Energy attribute + the character's Humanoid health,
smoothed each frame.
Booted by SurvivorCore.startClient(). Tuning + asset ids come from MovementConfig.
]]
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local SoundService = game:GetService("SoundService")
local UserInputService = game:GetService("UserInputService")
assert(RunService:IsClient(), "SurvivorCore.MovementFeedback is client-only — boot it via SurvivorCore.startClient()")
local MovementConfig = require(script.Parent.Parent.shared.MovementConfig)
local Remotes = require(script.Parent.Parent.shared.Remotes)
local MovementFeedback = {}
local started = false
-- Frame-rate-independent approach toward a target (the TCE `smooth` helper).
local function smooth(current: number, target: number, speed: number, dt: number): number
return current + (target - current) * math.clamp(dt * speed, 0, 1)
end
local function makeLoopedSound(name: string, soundId: string): Sound
local sound = Instance.new("Sound")
sound.Name = name
sound.SoundId = soundId
sound.Looped = true
sound.Volume = 0
sound.PlaybackSpeed = 1
sound.Parent = SoundService
if soundId ~= "" then
sound:Play()
end
return sound
end
function MovementFeedback.start(_options: { [string]: any }?)
if started then
return
end
started = true
local player = Players.LocalPlayer
local cfg = MovementConfig.get()
-- --- Sprint input → server -------------------------------------------------
local sprintRemote = Remotes.event("SprintIntent")
local sprintHeld = false
local function setSprint(value: boolean)
if sprintHeld == value then
return
end
sprintHeld = value
sprintRemote:FireServer(value)
end
UserInputService.InputBegan:Connect(function(input, gameProcessed)
if gameProcessed then
return
end
if input.KeyCode == Enum.KeyCode.LeftShift or input.KeyCode == Enum.KeyCode.RightShift then
setSprint(true)
end
end)
UserInputService.InputEnded:Connect(function(input)
if input.KeyCode == Enum.KeyCode.LeftShift or input.KeyCode == Enum.KeyCode.RightShift then
setSprint(false)
end
end)
UserInputService.WindowFocusReleased:Connect(function()
setSprint(false)
end)
-- --- Vignette overlay ------------------------------------------------------
local playerGui = player:WaitForChild("PlayerGui")
local vignetteGui = Instance.new("ScreenGui")
vignetteGui.Name = "SurvivorCoreVignette"
vignetteGui.ResetOnSpawn = false
vignetteGui.IgnoreGuiInset = true
vignetteGui.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
vignetteGui.DisplayOrder = 1
vignetteGui.Parent = playerGui
local vignette = Instance.new("ImageLabel")
vignette.Name = "EnergyVignette"
vignette.BackgroundTransparency = 1
vignette.Size = UDim2.fromScale(1, 1)
vignette.Image = cfg.Assets.Vignette
vignette.ImageColor3 = Color3.fromRGB(255, 255, 255)
vignette.ImageTransparency = 1
vignette.ScaleType = Enum.ScaleType.Stretch
vignette.Visible = cfg.Assets.Vignette ~= ""
vignette.Parent = vignetteGui
-- --- Breathing + heartbeat loops -------------------------------------------
local breathing = makeLoopedSound("SurvivorCoreBreathing", cfg.Assets.Breathing)
local heartbeat = makeLoopedSound("SurvivorCoreHeartbeat", cfg.Assets.Heartbeat)
RunService.RenderStepped:Connect(function(dt)
local energy = player:GetAttribute("Energy")
if typeof(energy) ~= "number" then
energy = 0
end
local maxEnergy = cfg.Energy.Max
if typeof(maxEnergy) ~= "number" or maxEnergy <= 0 then
maxEnergy = 100
end
local energyRatio = math.clamp(energy / maxEnergy, 0, 1)
-- Health from the live character Humanoid (the real health bar).
local healthRatio, alive = 1, false
local character = player.Character
local humanoid = character and character:FindFirstChildOfClass("Humanoid")
if humanoid and humanoid.MaxHealth > 0 then
healthRatio = math.clamp(humanoid.Health / humanoid.MaxHealth, 0, 1)
alive = humanoid.Health > 0
end
-- Vignette: intensifies as energy drops.
if vignette.Visible then
local intensity = math.clamp(1 - energyRatio, 0, 1) ^ 1.2
vignette.ImageTransparency = smooth(vignette.ImageTransparency, 1 - (intensity * 0.9), 5, dt)
end
-- Breathing: tracks low energy.
local breathingIntensity = math.clamp(1 - energyRatio, 0, 1)
breathing.Volume = smooth(breathing.Volume, breathingIntensity * cfg.Audio.BreathingMaxVolume, 5, dt)
breathing.PlaybackSpeed = smooth(
breathing.PlaybackSpeed,
cfg.Audio.BreathingMinSpeed
+ (cfg.Audio.BreathingMaxSpeed - cfg.Audio.BreathingMinSpeed) * breathingIntensity,
5,
dt
)
-- Heartbeat: fades in below HeartbeatStartRatio of health.
local heartbeatStart = math.clamp(cfg.Health.HeartbeatStartRatio, 0.05, 1)
local heartbeatIntensity = 0
if alive and healthRatio < heartbeatStart then
heartbeatIntensity = math.clamp((heartbeatStart - healthRatio) / heartbeatStart, 0, 1)
end
heartbeat.Volume = smooth(heartbeat.Volume, heartbeatIntensity * cfg.Audio.HeartbeatMaxVolume, 7, dt)
heartbeat.PlaybackSpeed = smooth(
heartbeat.PlaybackSpeed,
cfg.Audio.HeartbeatMinSpeed
+ (cfg.Audio.HeartbeatMaxSpeed - cfg.Audio.HeartbeatMinSpeed) * heartbeatIntensity,
7,
dt
)
end)
end
return MovementFeedback
+10
View File
@@ -25,6 +25,10 @@ local Components = require(script.components)
-- works any time before start(). Runs on both server and client (idempotent per side).
require(script.stats.StatDefs)
-- Define the "Movement" Config section (sprint/jump/energy + feedback tuning) on both
-- sides, so Config.override("Movement", …) works any time before start()/startClient().
require(script.shared.MovementConfig)
local SurvivorCore = {}
SurvivorCore.VERSION = "0.1.0"
@@ -77,6 +81,9 @@ function SurvivorCore.start(_options: { [string]: any }?)
SurvivorCore.Stats.removeModifier = survival.removeModifier
SurvivorCore.Stats.getValue = survival.getValue
-- Movement: server-authoritative sprint/jump/energy (creates the SprintIntent RemoteEvent).
require(script.systems.Movement).start(_options)
return SurvivorCore
end
@@ -91,6 +98,9 @@ function SurvivorCore.startClient(_options: { [string]: any }?)
require(script.client.Hud).start(_options)
-- Sprint input + low-stat vignette/breathing/heartbeat feedback.
require(script.client.MovementFeedback).start(_options)
return SurvivorCore
end
+59
View File
@@ -0,0 +1,59 @@
--!nonstrict
--[[
MovementConfig tuning for the sprint / jump / energy system and its low-stat feedback.
SHARED (server reads movement/energy; client reads audio/feedback). Defines the "Movement"
Config section so games retune via `Config.override("Movement", { Energy = { ... } })`.
Numbers + asset ids ported from The Counter Earth (its GameplayConfig + AssetIds) proven,
tuned values. Read the merged section with MovementConfig.get().
]]
local Config = require(script.Parent.Parent.foundation.Config)
local MovementConfig = {}
MovementConfig.SECTION = "Movement"
MovementConfig.DEFAULTS = {
Energy = {
Max = 100, -- matches the Energy stat's max in StatDefs
SprintDrainPerSecond = 16,
JumpCost = 10,
RegenPerSecond = 14,
RegenDelaySeconds = 0.75, -- regen waits this long after the last sprint/jump
MinToJump = 10, -- can't jump below this much energy
},
Movement = {
WalkSpeed = 16,
SprintSpeed = 24,
ExhaustedSpeed = 6, -- forced speed at 0 energy
JumpPower = 80,
},
Health = {
HeartbeatStartRatio = 0.4, -- heartbeat audio fades in below this fraction of health
},
Audio = {
BreathingMaxVolume = 0.9,
BreathingMinSpeed = 0.9,
BreathingMaxSpeed = 1.5,
HeartbeatMaxVolume = 1,
HeartbeatMinSpeed = 0.9,
HeartbeatMaxSpeed = 1.8,
},
-- Free default art (the engine ships these; override per game). "" disables a piece.
Assets = {
Vignette = "rbxassetid://117281807378470",
Breathing = "rbxassetid://109256307915148",
Heartbeat = "rbxassetid://90073835550134",
},
}
-- Define the section once per Luau VM (server and client each require this module once).
Config.defineSection(MovementConfig.SECTION, MovementConfig.DEFAULTS)
-- The merged (defaults + overrides) Movement config table.
function MovementConfig.get(): any
return Config.get(MovementConfig.SECTION) or MovementConfig.DEFAULTS
end
return MovementConfig
+60
View File
@@ -0,0 +1,60 @@
--!nonstrict
--[[
Remotes the engine's RemoteEvent plumbing. SHARED (server + client).
SurvivorCore is attribute-driven and has needed no RemoteEvents until now; the first
is sprint intent (client → server). This keeps that contract in one place: the SERVER
creates each named RemoteEvent under a `SurvivorCoreRemotes` folder in ReplicatedStorage;
the CLIENT waits for it. Works for every distribution (Rojo source, demo, drop-in `.rbxm`)
because the folder + events are created at runtime, not baked into the model.
-- server: local remote = Remotes.event("SprintIntent"); remote.OnServerEvent:Connect(...)
-- client: local remote = Remotes.event("SprintIntent"); remote:FireServer(true)
]]
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Remotes = {}
local FOLDER_NAME = "SurvivorCoreRemotes"
local WAIT_TIMEOUT = 10
local function getFolder(): Instance
local existing = ReplicatedStorage:FindFirstChild(FOLDER_NAME)
if existing then
return existing
end
if RunService:IsServer() then
local folder = Instance.new("Folder")
folder.Name = FOLDER_NAME
folder.Parent = ReplicatedStorage
return folder
end
local folder = ReplicatedStorage:WaitForChild(FOLDER_NAME, WAIT_TIMEOUT)
assert(
folder,
`[SurvivorCore] Remotes folder '{FOLDER_NAME}' not found — is the server running SurvivorCore.start()?`
)
return folder
end
-- Get (server: create-if-absent) a named RemoteEvent. Idempotent.
function Remotes.event(name: string): RemoteEvent
local folder = getFolder()
local existing = folder:FindFirstChild(name)
if existing and existing:IsA("RemoteEvent") then
return existing
end
if RunService:IsServer() then
local event = Instance.new("RemoteEvent")
event.Name = name
event.Parent = folder
return event
end
local event = folder:WaitForChild(name, WAIT_TIMEOUT)
assert(event and event:IsA("RemoteEvent"), `[SurvivorCore] RemoteEvent '{name}' not found`)
return event
end
return Remotes
+251
View File
@@ -0,0 +1,251 @@
--!nonstrict
--[[
Movement server-side sprinting, jumping and energy. SERVER-ONLY.
Ported from The Counter Earth's PlayerStateService. The client sends sprint intent over the
`SprintIntent` RemoteEvent; the server is authoritative:
• While sprinting AND moving AND energy > 0 → drain energy, set WalkSpeed = SprintSpeed.
• At 0 energy → forced to ExhaustedSpeed (can't sprint until it recovers).
Idle for RegenDelaySeconds after the last exertion energy regenerates.
Jumps cost energy and are gated below MinToJump (JumpPower drops to 0).
Energy is the engine's "Energy" stat (a Player Attribute) written through the stat-effects
layer (Stats.adjust), so it replicates to the HUD with no extra RemoteEvents.
Tuning lives in MovementConfig ("Movement" Config section). Started by SurvivorCore.start().
]]
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
assert(RunService:IsServer(), "SurvivorCore.Movement is server-only — require it via SurvivorCore.start()")
local MovementConfig = require(script.Parent.Parent.shared.MovementConfig)
local Remotes = require(script.Parent.Parent.shared.Remotes)
local SurvivalStats = require(script.Parent.SurvivalStats)
local Movement = {}
local TICK_INTERVAL = 0.1 -- 10 Hz: energy + walk-speed updates (jumps are event-driven)
local JUMP_DRAIN_THROTTLE = 0.15 -- seconds; jumps fire several Humanoid signals at once
local MOVING_MAGNITUDE = 0.05
local started = false
type State = {
humanoid: Humanoid?,
sprintRequested: boolean,
lastEnergyUseTime: number,
lastJumpDrainTime: number,
currentWalkSpeed: number,
jumpEnabled: boolean,
connections: { RBXScriptConnection },
}
local states: { [Player]: State } = {}
local function cfg(): any
return MovementConfig.get()
end
local function energyOf(player: Player): number
return SurvivalStats.getValue(player, "Energy") or 0
end
local function setWalkSpeed(state: State, speed: number)
local humanoid = state.humanoid
if not humanoid then
return
end
if state.currentWalkSpeed ~= speed or humanoid.WalkSpeed ~= speed then
state.currentWalkSpeed = speed
humanoid.WalkSpeed = speed
end
end
local function setJumpEnabled(state: State, enabled: boolean, jumpPower: number)
local humanoid = state.humanoid
if not humanoid then
return
end
local desired = if enabled then jumpPower else 0
if state.jumpEnabled ~= enabled or humanoid.JumpPower ~= desired then
state.jumpEnabled = enabled
humanoid:SetStateEnabled(Enum.HumanoidStateType.Jumping, enabled)
humanoid.UseJumpPower = true
humanoid.JumpPower = desired
if not enabled and humanoid.Jump then
humanoid.Jump = false
end
end
end
-- A jump fires Humanoid.Jump + Jumping + StateChanged at once, so throttle the energy cost.
local function drainJumpEnergy(player: Player, state: State)
local c = cfg()
local now = os.clock()
if now - state.lastJumpDrainTime < JUMP_DRAIN_THROTTLE then
return
end
state.lastJumpDrainTime = now
if energyOf(player) < c.Energy.MinToJump then
setJumpEnabled(state, false, c.Movement.JumpPower)
local humanoid = state.humanoid
if humanoid and humanoid.Jump then
humanoid.Jump = false
end
return
end
SurvivalStats.adjust(player, "Energy", -c.Energy.JumpCost)
state.lastEnergyUseTime = now
end
local function setupCharacter(player: Player, character: Model)
local state = states[player]
if not state then
return
end
local humanoid = character:FindFirstChildOfClass("Humanoid") or character:WaitForChild("Humanoid", 10)
if not humanoid or not humanoid:IsA("Humanoid") then
return
end
for _, conn in state.connections do
conn:Disconnect()
end
table.clear(state.connections)
local c = cfg()
state.humanoid = humanoid
state.currentWalkSpeed = c.Movement.WalkSpeed
state.jumpEnabled = true
state.lastEnergyUseTime = os.clock()
state.lastJumpDrainTime = 0
humanoid.UseJumpPower = true
humanoid.WalkSpeed = c.Movement.WalkSpeed
humanoid.JumpPower = c.Movement.JumpPower
humanoid:SetStateEnabled(Enum.HumanoidStateType.Jumping, true)
table.insert(
state.connections,
humanoid:GetPropertyChangedSignal("Jump"):Connect(function()
if humanoid.Jump then
drainJumpEnergy(player, state)
end
end)
)
table.insert(
state.connections,
humanoid.Jumping:Connect(function(isJumping)
if isJumping then
drainJumpEnergy(player, state)
end
end)
)
table.insert(
state.connections,
humanoid.StateChanged:Connect(function(_, newState)
if newState == Enum.HumanoidStateType.Jumping then
drainJumpEnergy(player, state)
end
end)
)
end
local function addPlayer(player: Player)
states[player] = {
humanoid = nil,
sprintRequested = false,
lastEnergyUseTime = os.clock(),
lastJumpDrainTime = 0,
currentWalkSpeed = cfg().Movement.WalkSpeed,
jumpEnabled = true,
connections = {},
}
player.CharacterAdded:Connect(function(character)
setupCharacter(player, character)
end)
if player.Character then
setupCharacter(player, player.Character)
end
end
local function removePlayer(player: Player)
local state = states[player]
if state then
for _, conn in state.connections do
conn:Disconnect()
end
end
states[player] = nil
end
function Movement.start(_options: { [string]: any }?)
assert(not started, "Movement.start() called twice")
started = true
local sprintRemote = Remotes.event("SprintIntent")
sprintRemote.OnServerEvent:Connect(function(player, wantsToSprint)
if typeof(wantsToSprint) ~= "boolean" then
return -- ignore malformed client input
end
local state = states[player]
if state then
state.sprintRequested = wantsToSprint
end
end)
for _, player in Players:GetPlayers() do
addPlayer(player)
end
Players.PlayerAdded:Connect(addPlayer)
Players.PlayerRemoving:Connect(removePlayer)
local accumulator = 0
RunService.Heartbeat:Connect(function(dt)
accumulator += dt
if accumulator < TICK_INTERVAL then
return
end
local step = accumulator
accumulator = 0
local c = cfg()
for player, state in states do
local humanoid = state.humanoid
if not humanoid or humanoid.Parent == nil or humanoid.Health <= 0 then
continue
end
local moving = humanoid.MoveDirection.Magnitude > MOVING_MAGNITUDE
local swimming = humanoid:GetState() == Enum.HumanoidStateType.Swimming
local energy = energyOf(player)
local shouldSprint = state.sprintRequested and moving and energy > 0
if shouldSprint then
SurvivalStats.adjust(player, "Energy", -c.Energy.SprintDrainPerSecond * step)
state.lastEnergyUseTime = os.clock()
elseif
not moving
and not swimming
and os.clock() - state.lastEnergyUseTime >= c.Energy.RegenDelaySeconds
then
SurvivalStats.adjust(player, "Energy", c.Energy.RegenPerSecond * step)
end
-- Jump gate: no jump below MinToJump (drop JumpPower to 0).
setJumpEnabled(state, energy >= c.Energy.MinToJump, c.Movement.JumpPower)
-- Movement speed reflects energy state.
if energy <= 0 then
setWalkSpeed(state, c.Movement.ExhaustedSpeed)
elseif shouldSprint then
setWalkSpeed(state, c.Movement.SprintSpeed)
else
setWalkSpeed(state, c.Movement.WalkSpeed)
end
end
end)
end
return Movement