Candy Tycoon: How It's Built
A technical deep-dive into the framework-free rebuild: the layered architecture, the fixed-step game loop, the data-driven balance, the market system, the custom chart, and how saves stay forward-compatible.
The problem
Recover a playable game from a ~500-line minified 2019 bundle and turn it into something I could actually extend, without reaching for a framework or a single runtime dependency.
My role
Architecture and implementation, solo.
Outcome
A layered, dependency-free codebase: a pure model that knows nothing about the DOM, a thin view of self-contained panels, and a versioned persistence layer, about a dozen small ES modules.
model/ is pure game logic and knows nothing about rendering or storage. ui/ reads the model and calls its actions. persistence/ serialises it. The model is the single source of truth; every dependency points inward, which makes the logic testable in plain Node.
main.js runs an accumulator loop on requestAnimationFrame: real time is sliced into fixed simulation ticks, so the economy is deterministic regardless of frame rate. A cap ignores huge gaps when the tab is backgrounded, and a bounded offline catch-up simulates time away on load.
Every candy, shop upgrade and global upgrade is a plain object in balance.js with a declarative `effect` descriptor. Derived getters (margins, liquidation rate, market config, which upgrades to reveal) are computed from an `upgradesOwned` Set, so loading a save needs no effect-replay.
A small MarketSystem rides the price hook: it schedules rare, lasting events that ease each candy's price multiplier up or down. Owned upgrades reshape its config (crash odds, spike size and frequency) purely through derived getters, never by mutating the system directly.
The original shipped a watermarked CanvasJS. The rebuild draws doughnut, bar and line charts by hand on a 2D canvas, with hover tooltips and lazy re-sizing to survive a zero-size canvas. A few hundred lines, zero libraries.
The view is a thin composition root that fans a per-frame update() out to self-contained panels (Upgrades, Candy, Information, Shops) plus a news ticker. Each panel rebuilds its DOM only when its data signature changes, so 60fps updates stay cheap.
A versioned save envelope plus graceful field defaults means older saves load without migration code. The game tracks its release version, shows a patch-notes popup when you return after an update, and records lifetime metrics in the save.
State stays recoverable and serialisable, free of the stale balance numbers and serialised methods that plagued the original. The pure model is verified with small Node scripts; the browser build is tested on a fresh port each time to defeat ES-module caching.