# 3. Leveling and XP Curves
Part of [[Stat-Driven RPG Systems]]. Requires Unity 6000.5+. Previous: [[2.Modifiers and Timed Buffs]]. Next: [[4.Equipment Items and Classes]].
## 3.1 Curve vs formula
The two common approaches:
- **Formula**: `xpForLevel(n) = round(base × n^exponent)`. It's compact and easy to reason about, and exponents of 1.5 to 2.5 are typical.
- **AnimationCurve**: designers drag keys in the Inspector to shape the early game (fast levels) and late game (slow grind) independently.
The `LevelCurve` asset below uses an `AnimationCurve` with **x = normalized level (0..1)** and **y = normalized total XP (0..1)**, scaled by `maxLevel` and `xpAtMaxLevel`. Normalizing means you can change the level cap without redrawing the curve. `AnimationCurve.Evaluate(float)` samples the curve ([docs](https://docs.unity3d.com/6000.0/Documentation/ScriptReference/AnimationCurve.Evaluate.html)).
```csharp
// LevelCurve.cs
using UnityEngine;
[CreateAssetMenu(menuName = "RPG/Level Curve")]
public class LevelCurve : ScriptableObject
{
[Min(2)] public int maxLevel = 50;
[Min(1)] public long xpAtMaxLevel = 1_000_000;
[Tooltip("x: level 1..max normalized to 0..1. y: total XP normalized 0..1. Must be increasing.")]
public AnimationCurve curve = new AnimationCurve(
new Keyframe(0f, 0f, 0f, 0.2f),
new Keyframe(1f, 1f, 2.5f, 0f));
long[] _table;
/// <summary>Total XP required to *reach* this level (level 1 = 0).</summary>
public long TotalXpForLevel(int level)
{
BuildTableIfNeeded();
return _table[Mathf.Clamp(level, 1, maxLevel) - 1];
}
public int LevelForTotalXp(long xp)
{
BuildTableIfNeeded();
int lvl = 1;
while (lvl < maxLevel && xp >= _table[lvl]) lvl++;
return lvl;
}
void BuildTableIfNeeded()
{
if (_table != null && _table.Length == maxLevel) return;
_table = new long[maxLevel];
long prev = -1;
for (int i = 0; i < maxLevel; i++)
{
float t = i / (float)(maxLevel - 1);
long xp = (long)Mathf.Round(Mathf.Clamp01(curve.Evaluate(t)) * xpAtMaxLevel);
if (xp <= prev) xp = prev + 1; // force strictly increasing even if the curve dips
_table[i] = xp;
prev = xp;
}
_table[0] = 0;
}
void OnValidate() => _table = null; // rebuild after Inspector edits
}
```
## 3.2 Per-level stat growth
Growth belongs to the class (part 4): "Warrior gains +3 STR and +2 VIT per level". On level-up, the base value is recalculated as `classBase + growth × (level − 1) + spentPoints`, rather than incremented. Recalculating from scratch means loading a save or changing class can never drift the numbers.
## 3.3 Experience component
```csharp
// CharacterLevel.cs
using System;
using UnityEngine;
[RequireComponent(typeof(CharacterStats))]
public class CharacterLevel : MonoBehaviour
{
[SerializeField] LevelCurve curve;
[SerializeField] int statPointsPerLevel = 2;
public long TotalXp { get; private set; }
public int Level { get; private set; } = 1;
public int UnspentPoints { get; private set; }
public event Action<int> LeveledUp; // new level
public event Action<long, long, long> XpChanged; // total, levelStart, nextLevel
CharacterStats _stats;
void Awake() => _stats = GetComponent<CharacterStats>();
void Start() => ApplyGrowth();
public void AddXp(long amount)
{
if (amount <= 0 || Level >= curve.maxLevel) return;
TotalXp += amount;
int newLevel = curve.LevelForTotalXp(TotalXp);
while (Level < newLevel) // handles multi-level jumps
{
Level++;
UnspentPoints += statPointsPerLevel;
ApplyGrowth();
LeveledUp?.Invoke(Level);
}
RaiseXp();
}
public float Progress01()
{
if (Level >= curve.maxLevel) return 1f;
long a = curve.TotalXpForLevel(Level), b = curve.TotalXpForLevel(Level + 1);
return (TotalXp - a) / (float)(b - a);
}
/// <summary>Recomputes base values from class + level. Spent points are stored by CharacterStats allocation (part 6).</summary>
public void ApplyGrowth()
{
var cls = _stats.Class;
if (!cls) return;
foreach (var e in cls.baseStats)
{
float growth = cls.GrowthFor(e.stat);
_stats.Get(e.stat).BaseValue = e.value + growth * (Level - 1) + _stats.AllocatedPoints(e.stat);
}
}
public bool SpendPoint(StatDefinition stat)
{
if (UnspentPoints <= 0 || stat.isDerived) return false;
UnspentPoints--;
_stats.Allocate(stat, 1);
ApplyGrowth();
return true;
}
public void Restore(long totalXp, int unspent)
{
TotalXp = totalXp;
Level = curve.LevelForTotalXp(totalXp);
UnspentPoints = unspent;
ApplyGrowth();
RaiseXp();
}
void RaiseXp() => XpChanged?.Invoke(TotalXp, curve.TotalXpForLevel(Level), curve.TotalXpForLevel(Level + 1));
}
```
Add these allocation helpers to `CharacterStats` from part 1:
```csharp
// Add inside CharacterStats
readonly Dictionary<StatDefinition, int> _allocated = new();
public int AllocatedPoints(StatDefinition s) => _allocated.TryGetValue(s, out var v) ? v : 0;
public void Allocate(StatDefinition s, int n) => _allocated[s] = AllocatedPoints(s) + n;
public IReadOnlyDictionary<StatDefinition, int> Allocations => _allocated;
public void ClearAllocations() => _allocated.Clear();
```