circuit-idle Svelte Themes

Circuit Idle

F1-themed idle game with a pure, fully-tested TypeScript engine + Three.js dashboard. Live demo included.

🏁 Circuit Idle

A car/automation-themed idle/incremental game in the spirit of Antimatter Dimensions (AD) β€” a fully static web app that saves to localStorage, hostable anywhere (e.g. GitHub Pages) with no backend.

β–Ά Play it live: https://hafizradzi8901.github.io/circuit-idle/

Stack: TypeScript Β· Svelte 5 Β· Three.js (WebGL hero scene + bloom) Β· Vite Β· Vitest Β· break_infinity.js for arbitrary-precision numbers Β· GitHub Actions β†’ Pages CI/CD. Highlights: a pure, UI-agnostic simulation engine (no DOM/storage coupling) Β· 4 stacked prestige layers Β· a procedurally-infinite R&D tree Β· 47 engine tests Β· two headless balance-simulation bots that verify the whole progression is reachable.

Status: the entire progression spine is built, tested, and validated for reachability. The guiding philosophy has been get the numbers right first, dress it up second β€” and the dressing-up has begun. Visuals v1 has shipped: the game now boots into a 3D F1-broadcast dashboard (Svelte UI + a Three.js hero scene of cars lapping a non-circular track, with bloom). The old text UI is still available behind an in-app "Text UI" button as a ground-truth dump. See "Visuals β€” v1 (shipped) & what's next" below.

This README doubles as the project's design document. It captures not just what exists but why β€” the design decisions and the balancing journey behind each system. If you're picking the project up, read "Design journey & rationale" and "Known issues / next patches" below before changing balance.


Run it

npm install
npm run dev          # localhost dev server with hot reload (the 3D F1 dashboard)
npm test             # engine test suite (vitest) β€” 47 tests
npm run build        # type-check + static build into dist/
npm run preview      # serve the built dist/
npm run sim          # headless sim: time to FIRST run / Championship (1e308)
npm run sim:career   # headless sim: late game (tree β†’ Hall of Fame, NG+)

Open the printed localhost URL β†’ a live text dump + buttons. You can also drive everything from the browser console via window.game:

game.state               // the live GameState (Decimals)
game.buyOne(0)            // buy one Car (tier 0); also buyUntilGroup(i), buyMax(i)
game.buyRpm()             // buy one RPM (tickspeed) level
game.tuneUp()             // Tune-Up (1st prestige) if requirement met
game.galaxy()             // Galaxy (2nd prestige) if requirement met
game.championship()       // Championship (3rd prestige) at 1e308 β†’ +1 RP, full reset
game.buyTreeNode(0, 0)    // buy R&D tree node at depth 0, column 0
game.induct()             // Hall of Fame / New Game+ at 1e30000 β†’ full reset + Legacy
game.buyAutoBuyer(0)      // buy the Car autobuyer; game.buyRpmAutoBuyer() for RPM
game.save() / game.hardReset()

// dev cheats (test any part without grinding):
game.addCash(1e9) Β· game.fastForward(3600) Β· game.setSpeed(100) Β· game.giveTier(1, 50) Β· game.unlockAll()

Architecture

The engine is pure and UI-agnostic (no DOM, no storage at module load) β€” that's the single most important architectural fact, and it's what makes (a) the headless sims possible and (b) visuals easy to add: a new renderer just reads the same GameState each tick.

File Responsibility
src/game/types.ts GameState + tier config types
src/game/config.ts All tunable constants + the TIERS array (data-driven: add a tier = add an entry)
src/game/state.ts createInitialState()
src/game/engine.ts The pure simulation: tick, buying, all 4 prestige layers, autobuyers, multipliers
src/game/tree.ts The procedural infinite R&D tree + Breakthrough reward pool (pure; no engine cycle)
src/game/save.ts save/load (localStorage; Decimal ⇄ string), simulate, applyOffline
src/game/format.ts formatDecimal β€” scientific notation
src/ui/textui.ts Legacy text renderer; render(state) is still used (behind the in-app "Text UI" toggle) as ground truth
src/main.ts Legacy text-UI bootstrap (kept, not the entry anymore); same tick loop + window.game
src/ui3d/boot.ts Current entry (index.html): mounts the Svelte app, owns the 100 ms tick loop + autosave + window.game + cheats
src/ui3d/store.ts Reactivity bridge: owns the live GameState, publish()es it to a Svelte store each tick, wraps player actions
src/ui3d/App.svelte + components/*.svelte The F1-broadcast dashboard (TopBar, GeneratorPanel, RpmPanel, TelemetryPanel, PrestigeBar, TextUiToggle)
src/ui3d/theme.css F1-broadcast design tokens (charcoal panels, team-color accents, fonts, gauge/bar primitives)
src/ui3d/track/TrackScene.ts Three.js hero: non-circular track + procedural cars + bloom; own 60 fps RAF loop, mount(el)/dispose()
src/ui3d/track/carMappings.ts Pure GameState β†’ {speed, carCount, glow} mapping (log-scaled); the one place to tune "what number drives what on screen"
src/sim/firstRun.ts Headless bot: time to the first Championship (npm run sim)
src/sim/career.ts Headless bot: late-game reachability (npm run sim:career)
src/game/engine.test.ts 47 engine tests

Numbers use break_infinity.js Decimal (same lib AD uses) so values scale far past 1e308 β€” which the Hall of Fame milestone (1e30000) leans on.

The live global production multiplier stacks every source:

globalMultiplier =
  BASE_GLOBAL_MULTIPLIER
  Γ— TUNEUP_MULTIPLIER^tuneUps                 // Tune-Up (Dimension Boost)
  Γ— rpmPerLevel(state)^rpm                    // RPM (tickspeed), boosted by Galaxies + Turbo nodes
  Γ— treeBonuses(treeNodes).productionMult     // R&D tree (Horsepower)
  Γ— breakthroughEffects(...).productionMult   // Breakthrough rewards (Overclock)
  Γ— legacy                                    // Hall of Fame / NG+ permanent multiplier

Game mechanics (current, authoritative)

Generator cascade

8 tiers, each auto-producing the one below; tier 0 (Car) produces Cash. Chain: Conglomerate β†’ Manufacturer β†’ Division β†’ Plant β†’ Factory β†’ Team β†’ Garage β†’ Car β†’ Cash. Buying a high tier cascades down into accelerating Cash β€” that cascade is the automation hook. Base costs 1e1 … 1e8 (Γ—10/tier); per-group cost multipliers 40 … 320.

Bulk buying (AD-style)

Each tier tracks bought. Every completed group of 10: raises per-unit cost by costMultiplier (all 10 in a group share one price) and multiplies that tier's production by PRODUCTION_MULT_PER_GROUP (Γ—1.5). So cost = baseCost Γ— costMultiplier^floor(bought/10) and the per-group mult = 1.5^floor(bought/10). Actions: Buy 1 / Until 10 / Max.

Tick semantics

tick(state, dt) snapshots all tier amounts at the step start, then applies production together β€” so a unit made this tick only produces next tick (order-independent, matches AD). Live loop = 100 ms (dt=0.1s).

RPM (tickspeed)

A purchasable global multiplier β€” AD's tickspeed in continuous-time form (a Γ—0.89 tick-interval reduction equals a Γ—~1.125 production multiplier, which is what we apply). Each level costs Γ—10 more (RPM_BASE_COST=1e3, RPM_COST_MULT=10); rpmPerLevel = RPM_PRODUCTION_MULT(1.125) + galaxiesΓ—GALAXY_RPM_BOOST + Turbo nodes. Reset by Tune-Up and Galaxy. Rename via RPM_NAME.

Prestige layer 1 β€” Tune-Up (= AD Dimension Boost)

Game starts with STARTING_TIERS (2) tiers unlocked. A Tune-Up resets cash, tiers, RPM, unlocks the next tier, and grants a permanent Γ—2 (TUNEUP_MULTIPLIER^tuneUps). Requirement: flat 20 of your highest unlocked tier while tiers remain locked, then +15 per Tune-Up once all 8 are open (20 β†’ 35 β†’ 50 …). Reset by a Galaxy.

Purchase soft-cap β€” what forces Galaxies

MAX_BOUGHT_PER_TIER = 190: you can't buy a tier past 190 units (production can still grow lower tiers). Mirrors AD's ~190-of-the-8th-dimension wall. As the Tune-Up requirement climbs past 190 you can no longer Tune-Up β†’ you must Galaxy. This is the mechanic that makes Galaxies essential.

Prestige layer 2 β€” Galaxy (= AD Antimatter Galaxy)

Resets everything a Tune-Up does plus your Tune-Ups (tiers back to STARTING_TIERS), permanently boosting RPM power (GALAXY_RPM_BOOST = 0.014 added to rpmPerLevel per Galaxy). Requires all tiers unlocked + GALAXY_REQ_BASE(80) + GALAXY_REQ_STEP(10)Γ—galaxies of the top tier. Galaxies wall out around #12 (requirement exceeds the cap), capping a single era's power.

Prestige layer 3 β€” Championship (= AD Infinity) + the infinite R&D tree

Reaching CHAMPIONSHIP_MILESTONE (1e308) wins a Championship: full reset (cash, tiers, RPM, Tune-Ups, Galaxies) for exactly +1 Research Point. Autobuyers + the tree persist. Meta becomes "how fast can you win the next Championship?" RP is scarce (1/win) and spent in the procedural infinite R&D tree (tree.ts): every node is derived from (depth, index), so it's literally infinite. Normal depths have 3 columns (Horsepower Γ—2 production, Turbo +RPM/level, Pit Crew Γ—0.9 Tune-Up cost); every 5th depth is one pricier Breakthrough. A node at depth d needs any node from dβˆ’1; RP cost grows with depth.

Breakthrough nodes roll a random reward from an all-positive pool (BREAKTHROUGH_REWARDS) β€” loot energy, rigged in the player's favour (rolling never hurts; the roll is stored so reloads don't re-roll; rewards stack). Pool: Overclock (Γ—100 prod), Nitrous (Tune-Ups keep RPM), Pit Wall (Γ—0.7 Galaxy req), Turbocharged Start (start with cash), Factory Preset (+1 starting tier), Spare Engine (+RPM/level), Ghost Car (autobuyers +1Γ—/tick), Lucky Number (+1 RP now). RNG injectable for tests.

Prestige layer 4 β€” Hall of Fame (New Game+ / the final milestone)

Reaching HALL_OF_FAME_MILESTONE (1e30000) in a single run unlocks Induct (NG+): a full reset of everything β€” cash, all prestiges, RP, the tree, autobuyers β€” keeping only a permanent stacking Legacy production multiplier (state.legacy) and the legends count. Legacy gained scales with how far past the milestone you rode (legacyGainOnInduct = max(LEGACY_MIN_GAIN(2), cash/milestone)), so a stronger tree that rides higher earns more β€” "how far do I push?" at the top. Reaching 1e30000 means not cashing out for Championships, so it's a "bank RP vs gun for the win" decision.

Autobuyers

Bought once with cash, and they survive Tune-Up / Galaxy / Championship (only a Hall-of-Fame Induct/NG+ wipes them, since NG+ resets to a fresh game). Per-tier + an RPM autobuyer (AUTOBUYER_COSTS, RPM_AUTOBUYER_COST). True autobuy β€” owned autobuyers run every tick from the first run (unlike AD's manual first run). Toggle singles ↔ until-10; until-10 unlocks after your first Galaxy (BUY_TEN_GALAXY_REQ=1). runAutoBuyers() runs once/tick (highest tier first, then RPM).

Anti-soft-lock & offline

New game seeds exactly the first Car's cost; ensureNotSoftlocked() rescues a truly-dead state on load. applyOffline() credits production since last save via chunked tick() (OFFLINE_STEPS=1000, capped at OFFLINE_CAP_SECONDS=8h).


Current balance snapshot (all in config.ts)

Knob Value Note
Tier base costs 1e1 … 1e8 (Γ—10/tier) gentle on purpose (see journey)
Tier cost mults (per group) 40, 55, 75, 100, 135, 180, 240, 320
PRODUCTION_MULT_PER_GROUP 1.5
MAX_BOUGHT_PER_TIER 190 forces Galaxies
STARTING_TIERS 2
TUNEUP_MULTIPLIER 2
TUNEUP_REQ_AMOUNT / _LINEAR_STEP 20 / 15
RPM: base/cost-mult/prod-mult 1e3 / 10 / 1.125
GALAXY_REQ_BASE / _STEP / _RPM_BOOST 80 / 10 / 0.014 most sensitive first-run knob
CHAMPIONSHIP_MILESTONE 1e308 first run β‰ˆ 6.5 days (sim-validated)
HALL_OF_FAME_MILESTONE 1e30000 chosen so the tree gates it, not Galaxies
LEGACY_MIN_GAIN 2
Tree: HP/Turbo/PitCrew Γ—2 / +0.03 / Γ—0.9 per node
Tree shape 3 cols/depth, Breakthrough every 5

Design journey & rationale (the "why")

This is the context that isn't obvious from the code β€” read it before retuning.

  1. AD-clone, car-themed, backend-first. Built the math engine + text UI before any visuals. Single currency (Cash) produced by an 8-tier cascade.
  2. Bulk buying is per-group-of-10 (AD-style): flat price within a group, jump between groups; each group also boosts that tier's production. The per-group production mult was Γ—2, then the user lowered it to Γ—1.5.
  3. The cost-balancing saga (key learning): costs went 3–15 (too fast) β†’ AD's real 1e3–1e15 (glacial β€” those assume AD's huge multiplier engine, which we don't have) β†’ gentle 5–12 (too fast, 13h) β†’ 40–320. Then the sim revealed unit costs barely affect pacing at all β€” because an attentive player/bot converts almost all cash into production each tick, so the growth rate is set by the cascade + multiplier structure, not unit prices. Don't tune pacing with costs.
  4. RPM = tickspeed, modeled as a production multiplier (the continuous-time equivalent of AD's tick-interval reduction).
  5. Tune-Up / Galaxy / soft-cap. Tune-Ups are the cheap repeatable Γ—2; the 190 soft-cap walls them and forces Galaxies (the AD "must galaxy" wall). GALAXY_RPM_BOOST=0.014 was tuned via npm run sim to land the first run at ~6.5 days (target was 5–7). This is the most sensitive first-run knob.
  6. A real bug the sim caught: globalMultiplier was applying the raw RPM constant instead of rpmMultiplier(), so Galaxies did nothing β€” would have shipped broken. The sim's "trajectory identical regardless of Galaxies" tell exposed it. (There's now a regression test.)
  7. Championship = Infinity: 1e308 β†’ +1 RP, full reset. The user wanted AD's "1 IP per crunch" feel, so RP is deliberately scarce and the game becomes a speed loop.
  8. Infinite R&D tree is procedural (no hand-authored nodes). Breakthroughs are random but always-good rewards (the user explicitly wanted "weird/random for the player, never punishing"; declined rarity tiers β€” flat odds are intended).
  9. Hall of Fame = NG+. The career sim showed the tree was underused: at the original 1e3000, Galaxies alone (β‰ˆ1e1047) basically got you there in ~5 Championships. Raising the milestone to 1e30000 makes the tree the height-driver (Galaxies + a long ride top out β‰ˆ1e483, so ~10 Championships' worth of tree are needed). A Galaxy nerf was tried and reverted β€” it only slowed the first run without being needed.

Cross-cutting learnings:

  • Pacing is driven by the cascade + multipliers, not unit costs.
  • Galaxies are extremely strong for single-run height.
  • The cascade is super-exponential, so reachability is a sharp threshold and time-to-milestone is very sensitive to multipliers β€” tune with the sim, not by hand.
  • The sims are best for structural validation + reachability; trust those numbers. (Caveat below.)

The user's style/preferences (worth honoring): loves AD; wants big numbers; "weird but rewarding, never punishing" randomness; iterates balance via the sim + playtest feel; likes a build-then-verify loop each step; wants soft spots flagged honestly rather than hidden.


Known issues / next patches (flagged, intentionally deferred)

The user is OK shipping these for now; revisit when desired.

  1. Career sim's absolute times are noisy. Its galaxy-banking isn't optimized like firstRun's (it reported a 89-day first cycle vs firstRun's validated 6.5 days). Its reachability numbers are trustworthy (that's how 1e30000 was chosen); its day estimates are not. To trust late-game timing, refine career.ts to match firstRun.ts's budgets/optimal banking.
  2. "First Hall of Fame" is a sharp threshold, not a smooth grind (~8–10 Championships: C=5 reaches ~1e1572, C=10 jumps to ~1e30021). Inherent to the super-exponential cascade. May want to smooth the late-game curve if it feels abrupt in play.
  3. Late-game economy not finely tuned. Whether NG+ "Induct" clearly beats "just keep playing", and the exact per-Championship / per-induction speed-up, aren't precisely validated (depends on #1).
  4. Breakthrough reward magnitudes are first-draft β€” Overclock/Nitrous are jackpots, Spare Engine is minor. Intentionally flat odds (user declined rarity tiers); revisit only if the spread feels bad.
  5. Offline progress uses chunked simulation, not the closed-form polynomial (a fine future optimization).
  6. Car motion β€” DONE (was: robotic). Cars now have real accel/decel: a precomputed curvature profile (buildCurvature/curvatureAhead) makes them brake before corners and accelerate out, easing current speed toward a curvature-based target. Both pace AND accel/decel sharpness scale with RPM (carVisuals.rpmFactor): sluggish at RPM 0, full F1 attack at high RPM, hard-capped (TOP_SPEED in TrackScene). Cars also run spread across a wide road (per-car lane) so you see inside/outside lines. Track scaled very large (~200 half-extent). Tunables: TOP_SPEED, BASE_STRAIGHT, roadHalf, the scale target in buildTrack, and accel/decel rates in loop.

Visuals β€” v1 (shipped) & what's next

Direction chosen (with the user): 3D hero + telemetry hybrid, F1-broadcast art style, Svelte UI, built as a vertical slice first. Performance is explicitly not a constraint.

What v1 ships (the core-economy slice):

  • A Svelte F1-broadcast dashboard rendered as frosted-glass panels floating over a full-bleed 3D hero (the track fills the screen; .panel uses backdrop-filter blur). Panels: TopBar (cash / cashΒ·s⁻¹ / global Γ—, save indicator + Save/Reset/Text-UI), GeneratorPanel (AD-style buying: primary Buy Γ—N completes a group β†’ Γ—1.5, per-tier Max, small +1 for the early/affordability case since buyUntilGroup/buyMax only act on full groups, plus a global Max All; each row also has its autobuyer + mode toggle), ObjectivePanel (the prominent "Next Objective" tracker β€” shows the active gate Tune-Upβ†’Galaxyβ†’Championshipβ†’Hall-of-Fame with progress, payoff, and a glowing action button when ready), RpmPanel (shift-light bar + buy + RPM autobuyer), TelemetryPanel (prestige/RP/Legacy stat grid), PrestigeBar (the four prestige actions, can*-gated).
  • A Three.js hero scene: cars lapping the real Sepang International Circuit, traced from the Wikipedia track-map SVG β€” the on-curve anchor points of the centreline path live in SEPANG_CENTERLINE (TrackScene.ts) and are centred + scaled at build time (centripetal Catmull-Rom; extruded road ribbon + glowing kerb edges), UnrealBloomPass. Cars are procedural detailed F1 models (floor/monocoque/nose/sidepods/airbox/halo/helmet/multi-element wings/exposed wheels β€” no asset files yet) and hero speed is synced to the RPM level (carMappings.ts). The camera is a broadcast director that randomly cycles between a wide overview orbit, a chase cam on a random car, and a trackside shot. Runs on its own 60 fps RAF loop.
  • Responsive: desktop = 3-column grid over the hero; mobile (≀860px) = single scrollable column (the .rail uses display:contents to reorder), glass panels over the full-bleed track, sticky prestige bar. Dev server is exposed on the LAN (server.host) so phones can load http://<ip>:5173.
  • Autosave runs every 5 s (and on tab-hide/close); the save indicator in the top bar pulses "Saved". Manual Save + Hard Reset buttons are in the top bar.
  • R&D Tree (RnDTree.svelte): a full-screen radial node-graph overlay (rings = depths, 3 archetype spokes, Breakthroughs on milestone rings), windowed around the frontier; opened from the top-bar R&D β—‡N button (pulses when affordable).
  • "Moments" & modals (all portal'd to <body> so they're true full-screen): Hall of Fame / NG+ induction ceremony (HallOfFameCeremony.svelte, replaces the old confirm(); opened via the shared ui.ts openInduction() from PrestigeBar/Objective/Toast), Breakthrough reward popup (BreakthroughPopup.svelte, fired by showBreakthrough() when a 🎲 node rolls), offline Welcome-Back (WelcomeBack.svelte, driven by offlineGain set in store on load), and a Settings overlay (Settings.svelte β€” "Pause 3D" low-power toggle via TrackScene.setPaused). Overlay state lives in src/ui3d/ui.ts.
  • Overlay portal fix: src/ui3d/portal.ts re-parents overlays to <body> so position:fixed escapes the glass panels' backdrop-filter containing block (otherwise they get trapped/clipped).
  • DEV panel (DevPanel.svelte, temporary): a top-bar DEV button opens a tester to add cash/RP/RPM, unlock tiers, fast-forward, set speed, and trigger each moment (breakthrough / induction / welcome-back). To remove when done: delete DevPanel.svelte and its <DevPanel /> line + import in App.svelte.
  • The engine was not touched β€” the bridge is src/ui3d/store.ts: it owns the live GameState and publish()es it to a Svelte writable each game tick (10 Hz), while the 3D animates at 60 Hz. window.game + all dev cheats still work; the "Text UI" button shows textui.render(state) as ground truth.

Architectural rule (still holds): don't touch the engine. Any new surface = read GameState + call existing engine functions. Number display reuses formatDecimal.

What's next (deferred β€” next visual milestones):

  • Rich prestige/tree surfaces: R&D tree visualization, Breakthrough reward popups, autobuyer panels, the induct/NG+ "ceremony" (PrestigeBar is just buttons today).
  • Swap procedural cars for GLTF models (Kenney Car Kit / Quaternius β€” CC0); optional HDRI reflections.
  • Offline "welcome back" modal, settings, notation toggle, sound; mobile layout polish; camera polish.
  • Then git init + GitHub Pages deploy (already a static dist/ with relative base, so trivial).

Free/CC0 asset & reference pointers: car models β€” Kenney Car Kit (kenney.nl), Quaternius vehicle packs (quaternius.com); fonts β€” Titillium Web / Rajdhani / Orbitron (Google Fonts, loaded via CSS @import in theme.css); HDRI β€” Poly Haven (polyhaven.com); references β€” Antimatter Dimensions source (IvarK/AntimatterDimensionsSourceCode) for UI density, official three.js examples for bloom/GLTF/path-following.


Tuning quick-reference

Everything is in src/game/config.ts and src/game/tree.ts; the engine reads it generically (adding a tier = adding a TIERS entry, no engine changes). After any balance change, re-run npm run sim (first run) and npm run sim:career (late game) to re-validate β€” that's the established workflow.

Top categories

Loading Svelte Themes