Appearance
Idle production
Passive income done properly with the producers mechanic: geometric costs, buy-1/buy-10/buy-max, per-second display and cycle progress — no hand-rolled math.
State
ts
import type { ProducerColumn } from "@idlekitjs/mechanics/producers";
interface State {
gold: number;
owned: number[];
total: number[];
progress: number[];
}
const initialState: State = {
gold: 15,
owned: [0, 0],
total: [0, 0],
progress: [0, 0],
};Wiring
ts
import { createEngine, formatNumber, formatInteger } from "@idlekitjs/core";
import { Renderer, bindText, bindDisabled } from "@idlekitjs/dom";
import { createRafScheduler } from "@idlekitjs/browser/raf-scheduler";
import { producers, type ProducerDef } from "@idlekitjs/mechanics/producers";
const TIERS: ProducerDef[] = [
{ id: "miner", cycleTime: 1, yieldPerUnit: 1, baseCost: 10, costGrowth: 1.15 },
{ id: "foreman", cycleTime: 8, yieldPerUnit: 2, baseCost: 150, costGrowth: 1.2 },
];
const renderer = new Renderer();
const engine = createEngine<State>({ initialState, renderer, scheduler: createRafScheduler() });
const mine = producers<State>({
definitions: TIERS,
getColumn: (s) => ({ owned: s.owned, total: s.total, progress: s.progress }),
setColumn: (s, patch) => {
if (patch.owned) s.owned = patch.owned;
if (patch.total) s.total = patch.total;
if (patch.progress) s.progress = patch.progress;
},
resource: {
get: (s) => s.gold,
add: (s, amount) => {
s.gold += amount;
},
},
});
engine.use(mine);miner produces gold every second; foreman produces miners every 8 s — the cascade is built in.
Storefront UI
For each tier (index 0 and 1, elements suffixed accordingly):
ts
const goldEl = document.querySelector<HTMLElement>("#gold")!;
const buyEl = document.querySelector<HTMLButtonElement>("#buy-miner")!;
const buyMaxEl = document.querySelector<HTMLButtonElement>("#buy-miner-max")!;
const rateEl = document.querySelector<HTMLElement>("#rate")!;
renderer.add(bindText(goldEl, () => formatNumber(engine.state.gold)));
renderer.add(bindText(buyEl, () => `Buy miner — ${formatNumber(mine.cost(0, engine.state))} gold`));
renderer.add(bindDisabled(buyEl, () => mine.maxAffordable(0, engine.state) < 1));
renderer.add(
bindText(buyMaxEl, () => `Buy ×${formatInteger(mine.maxAffordable(0, engine.state))}`),
);
renderer.add(bindText(rateEl, () => `${formatNumber(mine.ratePerSecond(engine.state))} gold/s`));
buyEl.addEventListener("click", () => mine.purchase(0, engine.state));
buyMaxEl.addEventListener("click", () => mine.purchaseWithBudget(0, engine.state, engine.state.gold));
engine.start();Cycle progress bar
Cycle progress is mutated in place (deliberately invisible to dirty-key tracking), so bind it per frame:
ts
import type { Binding } from "@idlekitjs/dom";
function bindBar(el: HTMLElement, getter: () => number): Binding {
let last = -1;
return {
update() {
const next = Math.round(getter() * 100);
if (next !== last) {
last = next;
el.style.width = `${next}%`;
}
},
};
}
const barEl = document.querySelector<HTMLElement>("#miner-bar")!;
renderer.addFrame(bindBar(barEl, () => mine.progressFraction(engine.state, 0)));Fast cycles
When mine.effectiveCycleTime(engine.state, 0) gets tiny (speed boosts, tuning), cycles complete faster than a frame — switch the UI from a filling bar to a continuous "+N/s" indicator.
Notes
- Offline catch-up needs nothing: a big
dtfrom offline progress credits every completed cycle. purchaseMany(0, state, 10)gives exact 10-buy pricing via the geometric series;PurchaseResult(bought,spent,remaining) feeds a purchase toast.- If the same game also uses
@idlekitjs/economy, wire the scalar purchase seam witheconomyPurchaseinstead of hand-writinggetBudget/pay.
Next
- Boosted production — multiply this output with boosts and modifiers.
- Save & load — persist the column.