Skip to content

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.

  • A world Canvas with resourceLabel (TMP) and statusLabel (TMP).
  • Three buttons: Save / Load / Reset, wired to OnSaveClicked, OnLoadClicked, OnResetClicked.
  • An Idle component on the world root with the TMP fields wired, perSecond set to your desired rate, and saveFileName set to a stable path (default idle-save.bin).
[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.

The key trick: instead of saving “current resources” and decoupling time, save (resources, lastTickUnixMs) together. On load:

  1. Read persisted (resources, lastTickUnixMs).
  2. Compute offlineSeconds = (now - lastTickUnixMs) / 1000.
  3. resources += offlineSeconds * perSecond.
  4. 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 working clock_time_get so 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 ReadFile and initialize cleanly.
  • Don’t use Time.time for 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.WriteFile is 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 — avoid float precision loss once the value grows past ~16 million.

Paste Idle.cs into the validator. Expect:

  • err 0, warn 0 under World context.
  • info reporting Unity events (Start, Update) and Game events (OnWorldPermissionsChanged).
  • Switch context to Avatar or Prop — the validator flags FileStorage and WorldPermissions as world-only.
  • Multiple resource types — pack one byte of type count + per-type (tag, value) pairs.
  • Manual clicks contribute — combine with Clicker: OnClicked adds a lump sum; Update continues the passive drip.
  • Upgrades that multiply perSecond — store as an int[] of purchased levels and recompute rate on load.
  • Leaderboard — broadcast (userId, resources) occasionally via Networking.SendMessage for a shared HUD.
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; }
}