Sprinting that never runs out makes movement feel flat, and sprinting that drains too fast feels punishing. A good Unity stamina and sprint system sits in between: stamina drains while running, regenerates after a short delay, stops you sprinting when empty until it recovers a bit, and shows all of that clearly on a bar. This guide builds that system as a reusable component, wires it to movement and the new Input System, and covers the tuning details that make it feel fair.
How the System Should Behave
- Stamina has a maximum, such as 100.
- Sprinting drains it per second, only while actually moving.
- Regeneration starts after a short delay once you stop spending.
- When stamina hits zero, you’re exhausted: you can’t sprint again until stamina refills past a threshold. Without this, players stutter-sprint at zero.
- One-off actions (dodge, jump, attack) spend a chunk and fail if there isn’t enough.
Keeping stamina in its own component, separate from movement, means enemies, mounts and different player controllers can all reuse it.
The Stamina Component
using System;
using UnityEngine;
public class Stamina : MonoBehaviour
{
[SerializeField] float max = 100f;
[SerializeField] float regenPerSecond = 25f;
[SerializeField] float regenDelay = 0.8f;
[SerializeField] float exhaustRecoverAt = 30f;
public float Current { get; private set; }
public float Max => max;
public float Normalized => Current / max;
public bool Exhausted { get; private set; }
public event Action<float> Changed; // normalized value
public event Action<bool> ExhaustedChanged;
float lastSpendTime = -999f;
void Awake() => Current = max;
void Update()
{
if (Time.time - lastSpendTime < regenDelay || Current >= max) return;
Set(Current + regenPerSecond * Time.deltaTime);
if (Exhausted && Current >= exhaustRecoverAt) SetExhausted(false);
}
/// Continuous drain, e.g. sprinting. Returns false if nothing could be spent.
public bool Drain(float perSecond)
{
if (Exhausted) return false;
lastSpendTime = Time.time;
Set(Current - perSecond * Time.deltaTime);
if (Current <= 0f) SetExhausted(true);
return true;
}
/// One-off cost, e.g. a dodge. Only spends if there is enough.
public bool TrySpend(float amount)
{
if (Exhausted || Current < amount) return false;
lastSpendTime = Time.time;
Set(Current - amount);
if (Current <= 0f) SetExhausted(true);
return true;
}
void Set(float value)
{
float clamped = Mathf.Clamp(value, 0f, max);
if (Mathf.Approximately(clamped, Current)) return;
Current = clamped;
Changed?.Invoke(Normalized);
}
void SetExhausted(bool value)
{
Exhausted = value;
ExhaustedChanged?.Invoke(value);
}
}
Events let the UI, audio (heavy breathing) and animation react without the component knowing about any of them. If you used the health bar guide, the pattern will look familiar.
Hooking Up Sprinting
In your movement script, sprint only when the button is held, the player is actually moving, and stamina allows it:
using UnityEngine;
using UnityEngine.InputSystem;
[RequireComponent(typeof(CharacterController), typeof(Stamina))]
public class PlayerMove : MonoBehaviour
{
[SerializeField] float walkSpeed = 4f;
[SerializeField] float sprintSpeed = 7f;
[SerializeField] float sprintCost = 20f; // per second
[SerializeField] InputActionReference move;
[SerializeField] InputActionReference sprint;
CharacterController cc;
Stamina stamina;
void Awake()
{
cc = GetComponent<CharacterController>();
stamina = GetComponent<Stamina>();
}
void OnEnable() { move.action.Enable(); sprint.action.Enable(); }
void OnDisable() { move.action.Disable(); sprint.action.Disable(); }
void Update()
{
Vector2 input = move.action.ReadValue<Vector2>();
bool moving = input.sqrMagnitude > 0.01f;
bool sprinting = sprint.action.IsPressed() && moving && stamina.Drain(sprintCost);
float speed = sprinting ? sprintSpeed : walkSpeed;
Vector3 dir = transform.right * input.x + transform.forward * input.y;
cc.Move(dir * speed * Time.deltaTime + Vector3.down * 2f * Time.deltaTime);
}
}
The check order matters: Drain is only called when sprint is held and the player is moving, so standing still with Shift held doesn’t burn stamina. For proper gravity and jumping, combine this with the controller from the CharacterController vs Rigidbody guide.
Exhaustion and the Re-sprint Threshold
Without a threshold, the player reaches zero, regenerates a sliver, sprints for one frame, drains to zero again, and so on. It looks and feels like stuttering. The exhaustRecoverAt value fixes this: once exhausted, sprinting stays locked until stamina refills to, say, 30. Make exhaustion obvious, with a bar colour change, a breathing sound or a slower walk, so players understand why they can’t sprint.
An alternative some games use: allow sprinting again as soon as the player releases and re-presses the button. That’s friendlier but lets players tap their way through. Pick the one that suits your game.
Spending Stamina on Other Actions
void OnDodge()
{
if (!stamina.TrySpend(25f)) { PlayDenySound(); return; }
StartDodge();
}
Dodges, power attacks, climbing and swimming all fit this model. Decide whether actions can push into exhaustion. Letting a dodge at 10 stamina still happen (spending to zero) feels generous; requiring the full amount feels strict. Change the check in TrySpend to Current <= 0f for the generous version. A dash pairs naturally with this; see the dash mechanic guide.
The Stamina Bar
using UnityEngine;
using UnityEngine.UI;
public class StaminaBar : MonoBehaviour
{
[SerializeField] Stamina stamina;
[SerializeField] Image fill;
[SerializeField] CanvasGroup group;
[SerializeField] Color normal = Color.green, exhausted = Color.red;
void OnEnable()
{
stamina.Changed += OnChanged;
stamina.ExhaustedChanged += OnExhausted;
}
void OnDisable()
{
stamina.Changed -= OnChanged;
stamina.ExhaustedChanged -= OnExhausted;
}
void OnChanged(float n) => fill.fillAmount = n;
void OnExhausted(bool e) => fill.color = e ? exhausted : normal;
void Update()
{
// fade the bar out when full, like many action games
float target = stamina.Normalized >= 1f ? 0f : 1f;
group.alpha = Mathf.MoveTowards(group.alpha, target, 3f * Time.deltaTime);
}
}
Use an Image with Image Type set to Filled. Fading the bar when full keeps the HUD clean.
Tuning Numbers
- Sprint duration = max / sprint cost. 100 / 20 gives five seconds of sprinting. Start with 4 to 8 seconds.
- Full recovery time = regen delay + max / regen rate. 0.8 + 100 / 25 is just under five seconds.
- Regen delay of 0.5 to 1.5 seconds stops players from feathering sprint to keep stamina topped up.
- Slower regen while moving: multiply regen by 0.5 when walking to reward stopping.
Expose these on a ScriptableObject so designers can try different presets without touching code.
📚 Design better mechanics
Resource systems like stamina are a core game design topic. Our list of game design books covers how to balance them.
Troubleshooting
Stamina drains while standing still
Drain is called whenever sprint is held. Check the move input magnitude first.
Player stutters when sprinting at zero
Add the exhaustion threshold so sprinting locks until stamina recovers.
Stamina never regenerates
Something calls Drain or TrySpend every frame, resetting the delay. Log calls to find it.
Bar doesn’t update
The bar subscribes in OnEnable before Stamina exists, or to the wrong instance. Assign the reference in the Inspector.
Sprint button does nothing
The sprint action isn’t enabled or is bound to the wrong control. Check the Input Actions asset.
FAQ
Should stamina use Update or FixedUpdate?
Update with Time.deltaTime is fine. Stamina isn’t physics.
How do I make sprint a toggle?
Flip a bool on the sprint action’s performed event, and clear it when movement stops or stamina runs out.
Can enemies use the same component?
Yes. That’s the point of keeping it separate from player input.
How do I save stamina?
Usually you don’t: refill on load. If needed, add a setter and save Current.
Should sprinting affect FOV?
A small FOV increase while sprinting adds a strong sense of speed. Lerp it smoothly.
Reader questions
Asked by readers of this article and answered by AI. Behavior can differ between Unity versions, so test in yours.




Leave a Reply