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 callingapplyGameTick: it computes each bought building's power once per tick, accumulates the building's lifetimeproducedValue, and adds the summed power tonoPoints. 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 timing — useReadClicker 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.
Persistence — useSave 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
| Procedure | Auth | Input | Purpose |
|---|---|---|---|
clicker.readClicker | user | — | read the user's save blob |
clicker.saveClicker | user | clickerSaveSchema | overwrite 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.
| File | Role |
|---|---|
app/composables/clicker/useReadClicker.ts | load + hydrate save, immediate-save watch |
app/composables/clicker/useTimers.ts | starts the two timers |
app/composables/clicker/useGameTickTimer.ts | the single 60 FPS game tick interval |
app/composables/clicker/useAutosaveTimer.ts | periodic save |
app/services/clicker/applyGameTick.ts | per-tick production math (points + producedValue) |
app/services/clicker/save/toClickerSave.ts | serialize in-memory state to ids/counters |
app/services/clicker/save/toClicker.ts | parse + hydrate ids back |
app/store/clicker/index.ts | save root, useSave wiring |
app/store/clicker/popup.ts | click handling + floating point popups |
server/trpc/routers/clicker.ts | read/save + content-map procedures |
shared/models/clicker/data/Clicker.ts | in-memory game state entity |
shared/models/clicker/data/ClickerSave.ts | persisted 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).