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.
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()
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
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.
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(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).
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.
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.
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.
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.
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.
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.
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).
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).
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 |
This is the context that isn't obvious from the code β read it before retuning.
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.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.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.)Cross-cutting learnings:
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.
The user is OK shipping these for now; revisit when desired.
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.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.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):
.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).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..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.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).<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.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).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.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):
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.
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.