Skip to content

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 dt from 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 with economyPurchase instead of hand-writing getBudget/pay.

Next