# 6. Saving Stats and Character Sheet UI Part of [[Stat-Driven RPG Systems]]. Requires Unity 6000.5+. Previous: [[5.Damage Formulas]]. Next: [[7.RPG Editor Tooling Tips]]. ## 6.1 What to save, and what not to Save the **inputs**, and rebuild the outputs: | Save | Don't save (rebuild instead) | |---|---| | Class id, total XP, unspent points, allocated points per stat | Final stat values | | Equipped item ids + rolled affixes | Modifiers from equipment (re-applied by `Equip`) | | Active buffs + remaining time (optional) | Derived stats | | Current HP/MP (they're state, not stats) | | If you saved final values, every balance patch would leave old characters with stale numbers. Rebuilding from inputs means patches apply automatically. Assets are referenced by a stable **string id**, not a direct asset reference, because `JsonUtility` can't serialize asset references to a file. Look them up with an `ItemDatabase` / `StatDatabase` ScriptableObject that lists every asset. ```csharp // RpgDatabase.cs using System.Collections.Generic; using UnityEngine; [CreateAssetMenu(menuName = "RPG/Database")] public class RpgDatabase : ScriptableObject { public List<StatDefinition> stats = new(); public List<ItemDefinition> items = new(); public List<CharacterClass> classes = new(); public StatDefinition Stat(string assetName) => stats.Find(s => s && s.name == assetName); public ItemDefinition Item(string id) => items.Find(i => i && i.id == id); public CharacterClass Class(string assetName) => classes.Find(c => c && c.name == assetName); } ``` ```csharp // CharacterSaveable.cs using System; using System.Collections.Generic; using UnityEngine; /// <summary>Plugs the RPG character into SaveSystem from [[High Scores, Saves and Player Stats]].</summary> [RequireComponent(typeof(SaveableId), typeof(CharacterStats), typeof(CharacterLevel))] [RequireComponent(typeof(Equipment))] public class CharacterSaveable : MonoBehaviour, ISaveable { [SerializeField] RpgDatabase db; [Serializable] class Alloc { public string stat; public int points; } [Serializable] class Affix { public string stat; public float value; public ModType type; } [Serializable] class Equipped { public string itemId; public List<Affix> affixes = new(); } [Serializable] class State { public long totalXp; public int unspent; public List<Alloc> allocations = new(); public List<Equipped> equipped = new(); } public string SaveId => GetComponent<SaveableId>().Id; void OnEnable() => SaveSystem.Register(this); void OnDisable() => SaveSystem.Unregister(this); public string CaptureState() { var stats = GetComponent<CharacterStats>(); var lvl = GetComponent<CharacterLevel>(); var eq = GetComponent<Equipment>(); var s = new State { totalXp = lvl.TotalXp, unspent = lvl.UnspentPoints }; foreach (var kv in stats.Allocations) s.allocations.Add(new Alloc { stat = kv.Key.name, points = kv.Value }); foreach (var kv in eq.Equipped) { var e = new Equipped { itemId = kv.Value.def.id }; foreach (var a in kv.Value.rolledAffixes) e.affixes.Add(new Affix { stat = a.stat.name, value = a.value, type = a.type }); s.equipped.Add(e); } return JsonUtility.ToJson(s); } public void RestoreState(string json) { var s = JsonUtility.FromJson<State>(json); var stats = GetComponent<CharacterStats>(); var lvl = GetComponent<CharacterLevel>(); var eq = GetComponent<Equipment>(); foreach (EquipSlot slot in Enum.GetValues(typeof(EquipSlot))) eq.Unequip(slot); stats.Build(); // fresh stats from the class stats.ClearAllocations(); foreach (var a in s.allocations) { var def = db.Stat(a.stat); if (def) stats.Allocate(def, a.points); } lvl.Restore(s.totalXp, s.unspent); // applies growth + allocations foreach (var e in s.equipped) { var def = db.Item(e.itemId); if (!def) { Debug.LogWarning(quot;Missing item {e.itemId} in save"); continue; } var inst = new ItemInstance(def); foreach (var a in e.affixes) { var st = db.Stat(a.stat); if (st) inst.rolledAffixes.Add(new ItemDefinition.Mod { stat = st, value = a.value, type = a.type }); } eq.Equip(inst); } } } ``` > [!warning] Asset renames > Stats are saved by asset `name`. If you rename `Strength.asset` after shipping, add a migration step (see the migration section in [[High Scores, Saves and Player Stats]]), or give `StatDefinition` an explicit `id` field like `ItemDefinition` has. ## 6.2 Character sheet with UI Toolkit This sheet uses a view-model plus runtime data binding, following the pattern in [[4.Data Binding and Custom Controls]]. Stat rows are generated from the stat list, so adding a new stat asset adds a row without any UI changes. This example uses `UIDocument`. On the `PanelRenderer` workflow, swap the component as described in [[2.UI Toolkit Project Setup]]. ```xml <!-- CharacterSheet.uxml --> <ui:UXML xmlns:ui="UnityEngine.UIElements"> <Style src="CharacterSheet.uss" /> <ui:VisualElement name="sheet" class="sheet"> <ui:Label name="class-level" class="sheet__title" /> <ui:ProgressBar name="xp-bar" low-value="0" high-value="1" /> <ui:Label name="points" class="sheet__points" /> <ui:ScrollView name="stat-list" /> </ui:VisualElement> </ui:UXML> ``` ```css /* CharacterSheet.uss */ .sheet { width: 360px; padding: 12px; background-color: rgba(10, 12, 20, 0.9); border-radius: 8px; } .sheet__title { font-size: 20px; -unity-font-style: bold; color: rgb(240, 220, 160); } .sheet__points { color: rgb(120, 220, 120); margin: 6px 0; } .stat-row { flex-direction: row; justify-content: space-between; align-items: center; height: 26px; } .stat-row__name { flex-grow: 1; color: rgb(210, 210, 210); } .stat-row__value { width: 80px; -unity-text-align: middle-right; color: white; } .stat-row__value--buffed { color: rgb(120, 200, 255); } .stat-row__plus { width: 24px; height: 22px; margin-left: 6px; } ``` ```csharp // CharacterSheetViewModel.cs using System; using Unity.Properties; using UnityEngine.UIElements; /// <summary>Bindable header data. Notifies UI Toolkit when properties change so bindings update without polling.</summary> public class CharacterSheetViewModel : INotifyBindablePropertyChanged { public event EventHandler<BindablePropertyChangedEventArgs> propertyChanged; string _header; float _xp01; string _points; [CreateProperty] public string Header { get => _header; set { if (_header == value) return; _header = value; Notify(nameof(Header)); } } [CreateProperty] public float Xp01 { get => _xp01; set { if (_xp01 == value) return; _xp01 = value; Notify(nameof(Xp01)); } } [CreateProperty] public string Points { get => _points; set { if (_points == value) return; _points = value; Notify(nameof(Points)); } } void Notify(string property) => propertyChanged?.Invoke(this, new BindablePropertyChangedEventArgs(property)); } ``` ```csharp // CharacterSheetView.cs using System.Collections.Generic; using Unity.Properties; using UnityEngine; using UnityEngine.UIElements; [RequireComponent(typeof(UIDocument))] public class CharacterSheetView : MonoBehaviour { [SerializeField] CharacterStats stats; [SerializeField] CharacterLevel level; readonly CharacterSheetViewModel _vm = new(); readonly Dictionary<Stat, (Label value, Button plus)> _rows = new(); void OnEnable() { var root = GetComponent<UIDocument>().rootVisualElement; root.dataSource = _vm; root.Q<Label>("class-level").SetBinding("text", new DataBinding { dataSourcePath = new PropertyPath(nameof(CharacterSheetViewModel.Header)), bindingMode = BindingMode.ToTarget }); root.Q<ProgressBar>("xp-bar").SetBinding("value", new DataBinding { dataSourcePath = new PropertyPath(nameof(CharacterSheetViewModel.Xp01)), bindingMode = BindingMode.ToTarget }); root.Q<Label>("points").SetBinding("text", new DataBinding { dataSourcePath = new PropertyPath(nameof(CharacterSheetViewModel.Points)), bindingMode = BindingMode.ToTarget }); BuildRows(root.Q<ScrollView>("stat-list")); stats.AnyStatChanged += OnStatChanged; level.XpChanged += OnXp; level.LeveledUp += OnLevel; RefreshHeader(); } void OnDisable() { stats.AnyStatChanged -= OnStatChanged; level.XpChanged -= OnXp; level.LeveledUp -= OnLevel; } void BuildRows(ScrollView list) { list.Clear(); _rows.Clear(); foreach (var stat in stats.All) { var row = new VisualElement(); row.AddToClassList("stat-row"); var name = new Label(stat.Definition.displayName); name.AddToClassList("stat-row__name"); name.tooltip = stat.Definition.description; var value = new Label(); value.AddToClassList("stat-row__value"); var plus = new Button { text = "+" }; plus.AddToClassList("stat-row__plus"); var captured = stat; plus.clicked += () => { level.SpendPoint(captured.Definition); RefreshHeader(); }; plus.style.display = stat.Definition.isDerived ? DisplayStyle.None : DisplayStyle.Flex; row.Add(name); row.Add(value); row.Add(plus); list.Add(row); _rows[stat] = (value, plus); UpdateRow(stat); } } void OnStatChanged(Stat s) { if (_rows.ContainsKey(s)) UpdateRow(s); } void OnXp(long total, long start, long next) => RefreshHeader(); void OnLevel(int _) => RefreshHeader(); void UpdateRow(Stat s) { var (value, _) = _rows[s]; value.text = s.Definition.Format(s.Value); value.EnableInClassList("stat-row__value--buffed", s.Value > s.BaseValue + 0.0001f); value.tooltip = BuildModifierTooltip(s); } static string BuildModifierTooltip(Stat s) { var sb = new System.Text.StringBuilder(quot;Base {s.BaseValue:0.##}"); foreach (var m in s.Modifiers) sb.Append(quot;\n{m.Type} {m.Value:+0.##;-0.##} ({(m.Source as Object)?.name ?? m.Source?.GetType().Name ?? "?"})"); return sb.ToString(); } void RefreshHeader() { _vm.Header = quot;{(stats.Class ? stats.Class.displayName : "Adventurer")} Lv {level.Level}"; _vm.Xp01 = level.Progress01(); _vm.Points = level.UnspentPoints > 0 ? quot;{level.UnspentPoints} points to spend" : ""; foreach (var kv in _rows) kv.Value.plus.SetEnabled(level.UnspentPoints > 0); } } ``` > [!note] Why a mix of binding and events? > The fixed header fields use data binding. The stat rows are created dynamically and updated from `Stat.Changed`, which is simpler than writing bindable wrappers for every row. If you prefer pure binding, bind a `ListView` whose `itemsSource` is a list of `StatRowViewModel`s (see [[4.Data Binding and Custom Controls]]). For a diegetic version, such as a character sheet on an in-world tablet or a VR wrist panel, reuse the same UXML on a world-space panel ([[6.Diegetic Terminal and Wrist Display]]).