Skip to content

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.

  • A world Canvas with four TMP labels: hpLabel, levelLabel, xpLabel, goldLabel.
  • A statusLabel for action feedback.
  • Three gameplay buttons: Attack / Heal / Rest, wired to OnAttackClicked / OnHealClicked / OnRestClicked. A Reset button wired to OnResetClicked.
  • RpgGame component on the world root with all labels wired and saveFileName set (default rpg-save.bin).
[1 byte ] SAVE_VERSION
[4 bytes ] maxHp
[4 bytes ] hp
[4 bytes ] level
[4 bytes ] xp
[4 bytes ] gold
[4 bytes ] xpForNext

25 bytes. Loads and writes in one binding call each.

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 while loop 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. int and byte pack densely into BufferReaderWriter; 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’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.Json expecting them to work. The sandbox disables reflection by default; BufferReaderWriter is the idiomatic path.

Paste RpgGame.cs into the validator. Expect:

  • err 0, warn 0 under World.
  • info: Unity events detected: Start and Game events detected: OnWorldPermissionsChanged.
  • Switch context to Avatar / Prop — the validator flags FileStorage and WorldPermissions as world-only.
  • Inventory — an int[] itemIds or a byte[] itemCounts array. Serialize length + bytes.
  • Enemies — a TurnBasedGame-style authority loop where the “owner” is the world and the “enemy” is NPC logic.
  • Quests — a byte[] questFlags array; specific bits flip on item pickup / location visit.
  • Multiplayer — see Networked RPG for how to extend this into a shared-world multiplayer RPG.
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; }
}