Skip to main content

Data-Driven Player Stats Architecture

The foundation of any action or combat system is the player’s state: health, stamina, and other resources that govern what the player can do. In many Unity projects, these values are hardcoded directly into scripts or prefabs. While simple, this approach becomes increasingly rigid as a game grows: tweaking balance requires code edits, sharing attributes between characters leads to duplication, and multiplayer contexts introduce bugs when state is unintentionally shared across instances.

A data-driven player stats system solves these problems by cleanly separating static configuration (the data that defines a character) from runtime state (the data that changes during play).

  • Instead of burying numbers inside scripts, the system externalizes all configuration into ScriptableObjects, which serve as editable assets in the Unity editor
  • Meanwhile, transient gameplay data—current stamina, cooldown timers, temporary buffs—is kept in plain runtime classes instantiated per player
info

This architecture combines the readability of data-oriented design with the flexibility needed for complex, evolving systems. It’s a foundational principle in scalable gameplay frameworks and underpins more advanced subsystems like combo managers, stamina-based combat, or RPG-style stat growth.

Design Philosophy

The key idea is separation of responsibility:

AspectDescriptionStorage
Static ConfigurationDefines what the player is — max health, stamina, regeneration rate, etc.ScriptableObject (PlayerStats)
Runtime StateTracks what the player is doing now — current stamina, active timers, temporary effects.C# class (PlayerRuntimeStats)
Context and OwnershipProvides centralized access to player systems, ensuring all components operate on the same state.MonoBehaviour (PlayerContext)

This separation has several design goals:

  1. Data Reusability: Game designers can create multiple PlayerStats assets (e.g., WarriorStats, RogueStats) without modifying any code.
  2. Runtime Safety: Each player or AI entity gets its own copy of runtime data, preventing accidental state sharing.
  3. Flexibility: Programmers can adjust gameplay logic without touching balance data; designers can rebalance without touching logic. This is super important!
  4. Inspectability: Runtime state can be logged, visualized, or reset independently of configuration assets.

The result is an architecture that scales smoothly from single-player prototypes to multiplayer or modular combat systems.

Implementation Walkthrough

PlayerStats: The Static Blueprint

PlayerStats is a simple ScriptableObject containing all the immutable parameters that define a character’s base abilities.

[CreateAssetMenu(menuName = "Player/Stats/Player Stats")]
public class PlayerStats : ScriptableObject
{
[Header("Core")]
public float maxStamina = 100f;
public float staminaRegenRate = 10f;
public float staminaRegenDelay = 1f;

[Header("Health")]
public float maxHealth = 100f;
}

This asset can be duplicated and adjusted for any character variant. Because it is read-only at runtime, it is safe to reference from multiple places. For example, by enemies, UI systems, or a stats manager.

PlayerRuntimeStats: The Mutable Runtime Copy

To represent the player’s live condition, PlayerRuntimeStats wraps around a PlayerStats instance. It copies the static data and manages all mutable state (like current stamina).

info

We will later instantiate PlayerRuntimeStats per player.

[System.Serializable]
public class PlayerRuntimeStats
{
private PlayerStats baseStats;
private float currentStamina;
private float staminaRegenTimer;

public PlayerRuntimeStats(PlayerStats baseStats)
{
this.baseStats = baseStats;
currentStamina = baseStats.maxStamina;
}

public float CurrentStamina => currentStamina;
public float MaxStamina => baseStats.maxStamina;

public bool HasEnoughStamina(float cost) => currentStamina >= cost;

public void UseStamina(float cost)
{
currentStamina = Mathf.Max(0, currentStamina - cost);
staminaRegenTimer = baseStats.staminaRegenDelay;
}

public void TickRegen(float dt)
{
if (staminaRegenTimer > 0)
{
staminaRegenTimer -= dt;
return;
}

currentStamina = Mathf.MoveTowards(
currentStamina,
baseStats.maxStamina,
baseStats.staminaRegenRate * dt
);
}

public void ResetStamina() => currentStamina = baseStats.maxStamina;
}

Key design notes:

  • Isolation: No ScriptableObject data is modified directly. Every player instance gets its own runtime stats object.
  • Responsibility: This class only manages numerical values; it doesn’t know about input, animation, or physics.
  • Regeneration Timing: The stamina regeneration delay ensures the player can’t immediately recover stamina after attacking.

PlayerContext: The Runtime Anchor

PlayerContext is a MonoBehaviour that ties the data and runtime systems together. It lives on the player’s root GameObject and provides a central reference point for other components like the ComboManager, HealthSystem, or UIManager.

[DisallowMultipleComponent]
public class PlayerContext : MonoBehaviour
{
[Header("Static Config Data")]
public PlayerStats baseStats;
public CombatConfig combatConfig;

[Header("Runtime State")]
public PlayerRuntimeStats runtimeStats { get; private set; }

[Tooltip("Flag for identifying which player is controlled locally (if applicable).")]
public bool isLocalPlayer = false;

private float lastHitTime;

void Awake()
{
runtimeStats = new PlayerRuntimeStats(baseStats);
GameManager.Instance?.RegisterPlayer(this);
}

void OnDestroy()
{
GameManager.Instance?.UnregisterPlayer(this);
}

public void RegisterHit()
{
lastHitTime = Time.time;
}

public bool HasRecentlyHitEnemy(float within = 0.5f)
{
return (Time.time - lastHitTime) <= within;
}
}

This class ensures every subsystem operates with consistent data:

  • The ComboManager can access stamina through context.runtimeStats.
  • The GameManager can register and track all active players through the context reference.
  • Future systems (like health or equipment) can extend PlayerContext without modifying the existing code.
note

The pattern mirrors an Entity-Component-System (ECS) mindset: to centralize state ownership, distribute functionality through components.

Use Cases and Benefits

This design is deceptively simple but foundational for building scalable and maintainable gameplay architecture. The separation between static data (PlayerStats) and runtime state (PlayerRuntimeStats) unlocks a level of flexibility that benefits both designers and programmers. Below are several reflections on how these advantages manifest in practice.

Balancing Without Code Changes

Because all player attributes live inside a ScriptableObject, designers can rebalance gameplay directly from the Unity Inspector without modifying or recompiling code. For instance, adjusting stamina recovery for different characters is as simple as creating new assets:

// WarriorStats.asset
maxStamina = 120f
staminaRegenRate = 6f

// RogueStats.asset
maxStamina = 80f
staminaRegenRate = 14f

The same PlayerContext and ComboManager logic automatically adapt to these differences. Designers can test changes live, duplicate configurations, and compare results—without touching a single script.

Multiplayer Safety: No Shared Mutable Data

By instantiating a fresh PlayerRuntimeStats object for every player, no two entities ever share the same stamina or cooldown state. This prevents the classic Unity pitfall of shared ScriptableObject state.

void Awake()
{
// Each player context gets its own runtime copy
runtimeStats = new PlayerRuntimeStats(baseStats);
}

Even if multiple players reference the same PlayerStats asset, their stamina values evolve independently. This isolation makes the system inherently multiplayer-safe:

// Player A
contextA.runtimeStats.UseStamina(20f);

// Player B unaffected
Debug.Log(contextB.runtimeStats.CurrentStamina); // still full

Testing and Debugging: Runtime Control Without Side Effects

Since runtime and static data are separate, developers can safely manipulate live values without risking persistent corruption.

// Developer console or debug script
if (Input.GetKeyDown(KeyCode.R))
{
playerContext.runtimeStats.ResetStamina();
Debug.Log("Stamina refilled for testing");
}

In play mode, this change affects only the current session; the underlying PlayerStats asset remains pristine. This makes it easy to test stamina usage, regeneration, or combo flow without ever editing design data.

Extensibility: Adding New Stats Effortlessly

Extending the system with a new resource type doesn’t require rewriting existing logic. Suppose we want to introduce an Adrenaline meter that builds up on successful hits:

public class PlayerRuntimeStats
{
public float adrenaline; // new stat

public void GainAdrenaline(float amount)
{
adrenaline = Mathf.Clamp(adrenaline + amount, 0, 100);
}
}

The rest of the architecture—PlayerContext, ComboManager, and stamina logic—remains untouched. Any new systems (like an “Adrenaline Finisher”) can now query or consume this new stat seamlessly through the same context reference.

Compatibility: A Unified Data Layer for Other Systems

Because PlayerContext exposes a consistent runtime interface, any other gameplay subsystem can plug into it without duplication. For example, a UI script can easily display stamina in real time:

void Update()
{
staminaBar.fillAmount =
playerContext.runtimeStats.CurrentStamina /
playerContext.runtimeStats.MaxStamina;
}

Similarly, an AI or difficulty manager could read these values for adaptive behavior:

if (enemyPlayer.runtimeStats.CurrentStamina < 20f)
aiController.SwitchToAggressiveMode();

This unified access layer eliminates the need for fragile cross-component references or ad-hoc variable sharing. Everything about the player’s active state is reachable through PlayerContext.

We utilise PlayerContext in our Combo System tutorial.

Summary

In short, the data-driven player stats system defines a clean architecture where:

  • PlayerStats describes who the player is (static data),
  • PlayerRuntimeStats tracks what the player is doing (runtime data),
  • PlayerContext anchors how all systems interact with the player.
Data Driven Elegance

Each system remains modular, predictable, and extensible. Designers gain direct control over balance through data assets; programmers gain reliable state isolation and clarity; and the overall gameplay codebase becomes cleaner and easier to evolve as new mechanics are introduced.

By drawing a clear line between configuration and state, this approach prevents a host of common Unity pitfalls—shared data corruption, duplicated logic, and tangled dependencies—and lays the groundwork for more advanced gameplay systems like stamina-driven combat, hit confirmation, or combo chaining. It’s an elegant, extensible foundation for any game architecture that values modularity, designer control, and clean state management.