RPG Game (single-player)
TL;DR: A minimal RPG shell — three action buttons (Attack / Heal / Rest), four stat labels (HP / Level / XP / Gold), automatic level-up on XP threshold, and FileStorage-backed saves. Each player has their own save because world storage is per-world but the file contents are per-local-client. Designed to be a starting scaffold; drop in enemy encounters, inventory, and quests around the state already tracked.
Context: World. FileStorage is the main reason.
Scene setup
Section titled “Scene setup”- A world Canvas with four TMP labels:
hpLabel,levelLabel,xpLabel,goldLabel. - A
statusLabelfor action feedback. - Three gameplay buttons: Attack / Heal / Rest, wired to
OnAttackClicked/OnHealClicked/OnRestClicked. A Reset button wired toOnResetClicked. RpgGamecomponent on the world root with all labels wired andsaveFileNameset (defaultrpg-save.bin).
Save format
Section titled “Save format”[1 byte ] SAVE_VERSION[4 bytes ] maxHp[4 bytes ] hp[4 bytes ] level[4 bytes ] xp[4 bytes ] gold[4 bytes ] xpForNext25 bytes. Loads and writes in one binding call each.
Level-up curve
Section titled “Level-up curve”private void AddXp(int amount){ xp += amount; while (xp >= xpForNext) { xp -= xpForNext; level++; maxHp += 10; hp = maxHp; xpForNext = 100 + (level - 1) * 50; }}Each level-up grants +10 max HP, refills HP, and bumps the next-level cost by 50. Tune to taste.
- Save on meaningful state changes only. Here, every button press triggers a save because the cost is trivial (25 bytes once per click).
- Handle level-ups in a
whileloop to absorb large XP bonuses that would cross multiple thresholds in one call. - Initialize fresh on corrupt/missing/version-mismatched save — the user’s progress is lost but the game stays playable.
- Keep state in primitives.
intandbytepack densely intoBufferReaderWriter; avoid serializable custom classes unless they give a real authoring benefit. - Use
UnityEngine.Random.Range(...)for random rolls — bound, deterministic (until seeded), and guest-side cheap.
Don’ts
Section titled “Don’ts”- Don’t store HP as a
float. Integer math avoids precision bugs and serializes in fewer bytes. - Don’t call
FileStorage.GetTotalSize()every save to check quota. The quota is generous; failure is reported by the write path when it matters. - Don’t design a save format that grows unbounded (e.g. “append every action”). Keep the save as a snapshot; derive history from events or separate logs.
- Don’t read/write on
Update— a save per frame exceeds quota quickly and does nothing useful. - Don’t reach for reflection-based serialization libraries like
Newtonsoft.Jsonexpecting them to work. The sandbox disables reflection by default;BufferReaderWriteris the idiomatic path.
Validator
Section titled “Validator”Paste RpgGame.cs into the validator. Expect:
err 0,warn 0under World.info:Unity events detected: StartandGame events detected: OnWorldPermissionsChanged.- Switch context to Avatar / Prop — the validator flags
FileStorageandWorldPermissionsas world-only.
Extensions
Section titled “Extensions”- Inventory — an
int[] itemIdsor abyte[] itemCountsarray. Serialize length + bytes. - Enemies — a
TurnBasedGame-style authority loop where the “owner” is the world and the “enemy” is NPC logic. - Quests — a
byte[] questFlagsarray; specific bits flip on item pickup / location visit. - Multiplayer — see Networked RPG for how to extend this into a shared-world multiplayer RPG.
Full source
Section titled “Full source”using System;using System.Text;using TMPro;using UnityEngine;using WasmScripting;
public partial class RpgGame : WasmBehaviour{ public TextMeshProUGUI hpLabel; public TextMeshProUGUI levelLabel; public TextMeshProUGUI xpLabel; public TextMeshProUGUI goldLabel; public TextMeshProUGUI statusLabel; public string saveFileName = "rpg-save.bin";
[WasmSerialized] private int maxHp = 100; [WasmSerialized] private int hp; [WasmSerialized] private int level = 1; [WasmSerialized] private int xp; [WasmSerialized] private int gold; [WasmSerialized] private int xpForNext = 100; [WasmSerialized] private bool loaded; [WasmSerialized] private bool permissionRequested;
private const byte SAVE_VERSION = 1;
void Start() { if (!WorldPermissions.CurrentPermissions.FileStorageApiAllowed) { if (!permissionRequested) { WorldPermissions.Request(new WorldPermissions { FileStorageApiAllowed = true }); permissionRequested = true; } SetStatus("waiting for storage permission..."); InitializeFresh(); Refresh(); return; } Load(); Refresh(); }
public void OnWorldPermissionsChanged() { if (WorldPermissions.CurrentPermissions.FileStorageApiAllowed && !loaded) { Load(); Refresh(); } }
// --- gameplay buttons ---
public void OnAttackClicked() { int roll = UnityEngine.Random.Range(1, 11); // 1..10 damage int xpGain = UnityEngine.Random.Range(10, 26); int goldGain = UnityEngine.Random.Range(0, 8); AddXp(xpGain); gold += goldGain; SetStatus($"hit for {roll}. +{xpGain} xp, +{goldGain} gp"); Refresh(); Save(); }
public void OnHealClicked() { int cost = 10; if (gold < cost) { SetStatus("not enough gold"); return; } gold -= cost; hp = Mathf.Min(maxHp, hp + 30); SetStatus($"healed (-{cost} gp)"); Refresh(); Save(); }
public void OnRestClicked() { hp = maxHp; SetStatus("fully rested"); Refresh(); Save(); }
public void OnResetClicked() { InitializeFresh(); try { if (FileStorage.FileExists(saveFileName)) FileStorage.DeleteFile(saveFileName); } catch (Exception ex) { SetStatus($"reset delete failed: {ex.Message}"); } Refresh(); SetStatus("reset"); }
// --- state math ---
private void InitializeFresh() { maxHp = 100; hp = maxHp; level = 1; xp = 0; gold = 0; xpForNext = 100; loaded = true; }
private void AddXp(int amount) { xp += amount; while (xp >= xpForNext) { xp -= xpForNext; level++; maxHp += 10; hp = maxHp; xpForNext = 100 + (level - 1) * 50; } }
// --- persistence ---
private void Save() { if (!WorldPermissions.CurrentPermissions.FileStorageApiAllowed) return; try { var w = new BufferReaderWriter(48); w.Write(SAVE_VERSION); w.Write(maxHp); w.Write(hp); w.Write(level); w.Write(xp); w.Write(gold); w.Write(xpForNext); FileStorage.WriteFile(saveFileName, w.Buffer.Slice(0, w.Length)); } catch (Exception ex) { SetStatus($"save failed: {ex.Message}"); } }
private void Load() { try { if (!FileStorage.FileExists(saveFileName)) { InitializeFresh(); return; } CVRFile file = FileStorage.ReadFile(saveFileName); if (file == null || file.Bytes == null || file.Length < 25) { InitializeFresh(); return; } var r = new BufferReaderWriter(file.Bytes); r.Read(out byte version); if (version != SAVE_VERSION) { InitializeFresh(); return; } r.Read(out int mHp); r.Read(out int curHp); r.Read(out int lvl); r.Read(out int x); r.Read(out int g); r.Read(out int xNext); maxHp = mHp; hp = curHp; level = lvl; xp = x; gold = g; xpForNext = xNext; loaded = true; } catch (Exception ex) { SetStatus($"load failed: {ex.Message}"); InitializeFresh(); } }
// --- UI ---
private void Refresh() { if (hpLabel != null) hpLabel.text = $"HP {hp} / {maxHp}"; if (levelLabel != null) levelLabel.text = $"Lvl {level}"; if (xpLabel != null) xpLabel.text = $"XP {xp} / {xpForNext}"; if (goldLabel != null) goldLabel.text = $"GP {gold}"; }
private void SetStatus(string s) { if (statusLabel != null) statusLabel.text = s; }}Related
Section titled “Related”- File Storage — save API.
- BufferReaderWriter — binary packing.
- Idle — similar save/load pattern, time-focused.
- Networked RPG — the multiplayer version.