Esposter

Game Loop and Saves

Production runs on one worker-based game tick, and the entire game state is one Clicker entity in memory, persisted as a normalized id-based ClickerSave to a per-user Azure blob (authenticated) or localStorage (anonymous).

How it works

pages/clicker.vue loads the save with useReadClicker, then useTimers starts two timer composables. Both intervals come from worker-timers, which runs in a Web Worker so production continues when the tab is backgrounded (browsers throttle main-thread timers there).

  • useGameTickTimer — one 60 FPS interval calling applyGameTick: it computes each bought building's power once per tick, accumulates the building's lifetime producedValue, and adds the summed power to noPoints. Powers are read fresh every tick, so purchases apply on the next tick with no watch/teardown machinery.
  • useAutosaveTimer — saves the full state every 60 seconds.

Clicking the central item goes through the popup store: it adds mousePower points and spawns a floating +N popup at the cursor that despawns after 10 seconds.

flowchart TD
  click[click on item] -->|mousePower| points[clicker.noPoints]
  tick[60 FPS game tick] -->|sum of building powers / 60| points
  tick -->|per-building power / 60| produced[boughtBuilding.producedValue]
  buy[buy building / upgrade] --> save60[virtualClicker watch: immediate save]
  auto[60 s autosave timer] --> persist
  save60 --> persist{useSave + toClickerSave}
  persist -->|authed| blob[clicker.saveClicker → Azure blob userId/save]
  persist -->|anonymous| ls[localStorage ClickerStore]
  blob --> load[useReadClicker]
  ls --> load
  load -->|clickerSaveSchema + toClicker| clicker[in-memory Clicker]

Save timinguseReadClicker watches a virtualClicker computed that deep-omits noPoints and producedValue; only manual state changes (purchases, type switches) trigger an immediate save, while the ever-ticking counters are picked up by the periodic autosave. The omitted view is reference-stabilized with deepEqual so the watch doesn't fire on every tick, and useSave stamps updatedAt on the serialized copy rather than the in-memory state so saving never re-triggers the watch.

Normalized save data — the save stores only what the player did: boughtUpgrades as UpgradeId[] and boughtBuildings as { id, amount, producedValue }[] (the ClickerSave entity). On write, toClickerSave strips the in-memory definitions down to ids, and on load toClicker resolves them back through UpgradeMap/BuildingMap — so a balance change to the content maps reaches every existing save on its next load. The in-memory Clicker keeps full definition objects, leaving the effect engine and components untouched. Per the latest-shape-only convention, there is no migration or self-heal path: a save that fails clickerSaveSchema (old shape, removed content ids) resets to a fresh game.

PersistenceuseSave and useReadData are the app-wide single-blob-per-user pattern (shared with dungeons): authenticated users read/write through clicker.readClicker / clicker.saveClicker (generic blob-state procedures over the clicker-assets container, blob name ${userId}/save, validated by clickerSaveSchema); anonymous users get the same state in localStorage under ClickerStore. Why games stay off the resource layer: games integration.

Procedures

ProcedureAuthInputPurpose
clicker.readClickeruserread the user's save blob
clicker.saveClickeruserclickerSaveSchemaoverwrite the user's save blob

saveClicker is also the trigger path for all ten clicker achievements: five save-count thresholds (1/5/10/100/1000) and five milestones whose condition reads the save payload (unlock pipeline) — ClickerMillionaire / ClickerBillionaire / ClickerTrillionaire (noPoints at 1e6/1e9/1e12), ClickerArchitect (every building owned), and ClickerCompletionist (every upgrade bought). The 60-second autosave cadence works for the milestones: progress is evaluated at least once a minute while playing, and unlocks are idempotent.

Key files

Paths relative to packages/app.

FileRole
app/composables/clicker/useReadClicker.tsload + hydrate save, immediate-save watch
app/composables/clicker/useTimers.tsstarts the two timers
app/composables/clicker/useGameTickTimer.tsthe single 60 FPS game tick interval
app/composables/clicker/useAutosaveTimer.tsperiodic save
app/services/clicker/applyGameTick.tsper-tick production math (points + producedValue)
app/services/clicker/save/toClickerSave.tsserialize in-memory state to ids/counters
app/services/clicker/save/toClicker.tsparse + hydrate ids back
app/store/clicker/index.tssave root, useSave wiring
app/store/clicker/popup.tsclick handling + floating point popups
server/trpc/routers/clicker.tsread/save + content-map procedures
shared/models/clicker/data/Clicker.tsin-memory game state entity
shared/models/clicker/data/ClickerSave.tspersisted save entity + schema

Notes

  • Time away from the page is compensated by offline progress: the load path awards capped production for the gap since the save was last stamped.
  • Late-game blob size shrank by an order of magnitude with normalization (28 full upgrade objects → 28 short ids).