Idle Game
TL;DR: Resources accumulate at perSecond per second, even while the player isn’t in the world. When they return, offline progress is calculated from a persisted Unix-ms timestamp. Uses BufferReaderWriter to pack state into a compact binary save and FileStorage to persist it. Requires the FileStorageApiAllowed world permission.
Context: World. FileStorage and WorldPermissions are world-only.
Scene setup
Section titled “Scene setup”- A world Canvas with
resourceLabel(TMP) andstatusLabel(TMP). - Three buttons: Save / Load / Reset, wired to
OnSaveClicked,OnLoadClicked,OnResetClicked. - An
Idlecomponent on the world root with the TMP fields wired,perSecondset to your desired rate, andsaveFileNameset to a stable path (defaultidle-save.bin).
Save format
Section titled “Save format”[1 byte ] SAVE_VERSION[8 bytes ] resources (double)[8 bytes ] lastTickUnixMs (long)17 bytes per save. Packing via BufferReaderWriter — one host call on write, one on read.
Offline progress
Section titled “Offline progress”The key trick: instead of saving “current resources” and decoupling time, save (resources, lastTickUnixMs) together. On load:
- Read persisted
(resources, lastTickUnixMs). - Compute
offlineSeconds = (now - lastTickUnixMs) / 1000. resources += offlineSeconds * perSecond.- Set
lastTickUnixMs = now.
Now the live Update loop only needs to handle online progress — accumulate in real time from the last tick.
- Use
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()for wall-clock time. WASI exposes a workingclock_time_getso UTC ticks correctly inside the sandbox. - Save on discrete events (button press, upgrade, quit). Don’t save every frame.
- Version your save format. A single byte lets you migrate silently without losing saves when you change the schema.
- Gate every access on
WorldPermissions.CurrentPermissions.FileStorageApiAllowed. Fresh worlds or denial-by-user should degrade gracefully. - Handle missing file — a first-run player has no save; skip
ReadFileand initialize cleanly.
Don’ts
Section titled “Don’ts”- Don’t use
Time.timefor wall-clock. It resets across scene loads and doesn’t tick while the player is away. Wall-clock belongs to the OS clock. - Don’t write the entire state every frame.
FileStorage.WriteFileis a full-rewrite host call with a quota. - Don’t use JSON. The sandbox’s serialization cost matters; a 17-byte binary blob is orders of magnitude cheaper than parsing JSON text.
- Don’t cache resources in a UI-facing float. Integer or
double— avoidfloatprecision loss once the value grows past ~16 million.
Validator
Section titled “Validator”Paste Idle.cs into the validator. Expect:
err 0,warn 0under World context.inforeporting Unity events (Start,Update) and Game events (OnWorldPermissionsChanged).- Switch context to Avatar or Prop — the validator flags
FileStorageandWorldPermissionsas world-only.
Extensions
Section titled “Extensions”- Multiple resource types — pack one byte of type count + per-type
(tag, value)pairs. - Manual clicks contribute — combine with Clicker:
OnClickedadds a lump sum;Updatecontinues the passive drip. - Upgrades that multiply
perSecond— store as anint[]of purchased levels and recompute rate on load. - Leaderboard — broadcast
(userId, resources)occasionally viaNetworking.SendMessagefor a shared HUD.
Full source
Section titled “Full source”using System;using System.Text;using TMPro;using UnityEngine;using WasmScripting;
public partial class Idle : WasmBehaviour{ public TextMeshProUGUI resourceLabel; public TextMeshProUGUI statusLabel; public float perSecond = 1.0f; public string saveFileName = "idle-save.bin";
[WasmSerialized] private double resources; [WasmSerialized] private long lastTickUnixMs; [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..."); return; }
Load(); ApplyOfflineProgress(); Refresh(); }
public void OnWorldPermissionsChanged() { if (WorldPermissions.CurrentPermissions.FileStorageApiAllowed) { Load(); ApplyOfflineProgress(); Refresh(); } }
void Update() { if (!WorldPermissions.CurrentPermissions.FileStorageApiAllowed) return; long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); double delta = (now - lastTickUnixMs) / 1000.0; if (delta <= 0.0) return; lastTickUnixMs = now; resources += delta * perSecond; Refresh(); }
public void OnSaveClicked() { Save(); SetStatus("saved"); } public void OnLoadClicked() { Load(); ApplyOfflineProgress(); Refresh(); SetStatus("loaded"); } public void OnResetClicked() { resources = 0.0; lastTickUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); try { if (FileStorage.FileExists(saveFileName)) FileStorage.DeleteFile(saveFileName); } catch (Exception ex) { SetStatus($"reset delete failed: {ex.Message}"); return; } Refresh(); SetStatus("reset"); }
private void ApplyOfflineProgress() { if (lastTickUnixMs <= 0L) { lastTickUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); return; } long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); double offlineSeconds = (now - lastTickUnixMs) / 1000.0; if (offlineSeconds < 0.0) offlineSeconds = 0.0; resources += offlineSeconds * perSecond; lastTickUnixMs = now; }
private void Save() { try { var w = new BufferReaderWriter(32); w.Write(SAVE_VERSION); w.Write(resources); w.Write(lastTickUnixMs); 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)) { resources = 0.0; lastTickUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); return; } CVRFile file = FileStorage.ReadFile(saveFileName); if (file == null || file.Bytes == null || file.Length < 17) return; var r = new BufferReaderWriter(file.Bytes); r.Read(out byte version); if (version != SAVE_VERSION) return; r.Read(out double loadedResources); r.Read(out long loadedTick); resources = loadedResources; lastTickUnixMs = loadedTick; } catch (Exception ex) { SetStatus($"load failed: {ex.Message}"); } }
private void Refresh() { if (resourceLabel != null) resourceLabel.text = $"{resources:N0}"; } private void SetStatus(string s) { if (statusLabel != null) statusLabel.text = s; }}Related
Section titled “Related”- File Storage — the save API.
- World Permissions — prompt flow.
- BufferReaderWriter — binary packing.