# 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(); ```