Skip to content

Producers

Tiered production — the beating heart of most incremental games. producers owns the cycle math, geometric cost curves, bulk purchasing and the cascade (each tier producing the tier below); your game supplies the tiers as data.

ts
import { producers } from "@idlekitjs/mechanics/producers";

Mental model

Each tier accumulates time and, when a cycle completes, emits a whole batch at once (count × yieldPerUnit) — the AdVenture-Communist model. A continuous, AdVenture-Capitalist feel is the limit case of a tiny cycleTime; the carried-over remainder keeps the average rate exact either way.

Tiers cascade: tier 0 produces the resource, tier N produces units of tier N-1. Owned counts drive costs; total counts (owned + produced) drive output.

Minimal example

ts
import { producers, type ProducerDef } from "@idlekitjs/mechanics/producers";

interface State {
  potatoes: number;
  owned: number[];
  total: number[];
  progress: number[];
}

const TIERS: ProducerDef[] = [
  { id: "farm", cycleTime: 2, yieldPerUnit: 1, baseCost: 10, costGrowth: 1.15 },
  { id: "commune", cycleTime: 10, yieldPerUnit: 2, baseCost: 200, costGrowth: 1.2 },
];

const farm = 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.potatoes,
    add: (s, amount) => {
      s.potatoes += amount;
    },
  },
});

engine.use(farm);

farm.purchase(0, engine.state); // purchase one farm
farm.grant(1, engine.state, 5); // reward five communes without paying
farm.ratePerSecond(engine.state); // display: resource/s of tier 0

State: the column

Producer data is parallel arrays, indexed like definitions:

ts
interface ProducerColumn {
  owned: number[]; // purchased units  -> drives the cost curve
  total: number[]; // present units    -> drives output (owned + cascaded)
  progress: number[]; // seconds toward the next cycle
  running?: boolean[]; // manual-cycle flags (only read for non-automated tiers)
}

owned/total/running are reassigned on change (reactive); progress is mutated in place — bind bars with progressFraction and a per-frame binding.

running is optional: saves that predate it (or games without manual tiers) never need to store it — absent means "no manual cycle in flight".

Purchasing

All the storefront math is built in:

ts
farm.cost(0, state); // price of the next unit
farm.costFor(0, state, 10); // price of the next 10 (geometric series)
farm.maxAffordable(0, state); // how many the current budget affords
farm.purchase(0, state); // purchase 1 -> boolean
farm.purchaseMany(0, state, 10); // purchase up to 10 -> PurchaseResult
farm.purchaseWithBudget(0, state, 500); // spend at most 500 -> PurchaseResult
farm.grant(0, state, 5); // add units without using the purchase seam

PurchaseResult reports { bought, spent, remaining } — exactly what a purchase toast needs. Costs follow baseCost × costGrowth^owned; bulk pricing uses the closed-form geometric series (no loops, no drift).

Why "purchase", not "buy"?

The API says purchase* because what is spent may not be a shop currency at all — the purchase seam lets each tier pay from any scalar value in your state. cost, budget and pay remain the right words: the mechanic computes a cost, checks it against a budget, and pays it.

Purchase seam

By default the resource plays two roles: it is what tier 0 emits and the currency purchases spend. The optional purchase option splits them — the mechanic keeps the cost math, the game decides what a purchase consumes:

ts
const farm = producers<State>({
  // ...
  purchase: {
    // Available scalar budget for purchasing tier `index`.
    getBudget: (state, index) => state.wallets[index],
    // Pays `amount` from that budget.
    pay: (state, index, amount) => {
      state.wallets[index] -= amount;
    },
  },
});

With the seam in place, purchase, purchaseMany, purchaseWithBudget and maxAffordable (when no explicit budget is passed) all read getBudget(state, index) and debit through pay(state, index, amount). The mechanic never knows what is consumed — main currency, an inventory count, any scalar per tier. Without the seam, behavior is unchanged: the main resource is the currency.

When using @idlekitjs/economy, the official economyPurchase adapter can implement this seam for you:

ts
import {
  economyPurchase,
  producerOutput,
  producerPurchase,
  producerResourceId,
} from "@idlekitjs/mechanics/producers/economy";

const farm = producers<State>({
  // ...
  resource: producerOutput(economy, "currency:potatoes"),
  purchase: economyPurchase(economy, (index) =>
    index === 0 ? "currency:potatoes" : producerResourceId(TIERS[index - 1].id),
  ),
});

For multi-resource or otherwise composite costs, use Economy's CostCurve + Transaction path and grant the units in the transaction's apply:

ts
const transaction = producerPurchase({
  producers: farm,
  index: 2,
  curve: factoryCost,
  quantity: 1,
});

economy.execute(state, transaction);

That keeps the scalar seam simple while giving composite purchases full requirements, diagnostics and cost/reward formatting.

Granting Units

grant(index, state, quantity, { owned? }) adds whole units without paying through purchase.

ts
farm.grant(0, state, 10); // reward: total only
farm.grant(2, state, 1, { owned: true }); // external purchase: total + owned

Use grant for mission rewards, crates, debug/admin tools and purchases already validated and paid elsewhere (for example an Economy transaction). By default it is a pure gift: only total increases, like starter units. With { owned: true }, owned also increases, so the producer cost curve advances.

grant does not replace purchase; it intentionally bypasses costs only when the caller has chosen to grant or has already paid.

Modifiers integration

Yield and speed multipliers are read through optional hooks — typically wired to a ModifierRegistry:

ts
const registry = modifiersExt.registry;

const farm = producers<State>({
  // ...
  getYieldMultiplier: (state, index) =>
    registry.resolve({ stat: "yield", id: TIERS[index].id, tags: TIERS[index].tags }),
  getSpeedMultiplier: (state, index) =>
    registry.resolve({ stat: "speed", id: TIERS[index].id, tags: TIERS[index].tags }),
});

Speed divides the cycle time; a speed of 0 (or less) pauses the tier — which is how a boost debuff can stop a factory.

Manual cycles & automation

By default every tier is automated: as soon as it has units, cycles chain on their own (the classic idle loop). The optional getIsAutomated hook makes a tier manual — it only advances while a player-started cycle is in flight:

ts
const farm = producers<State>({
  // ...
  getIsAutomated: (state, index) => state.automationUnlocked[index],
});

farm.run(0, state); // start ONE cycle of a manual tier -> boolean
farm.isRunning(state, 0); // is a cycle in flight? (always true for automated tiers with units)

Semantics:

  • Automated tier (getIsAutomated returns true, the default): current behavior, running flags are ignored, isRunning is true whenever the tier has units.
  • Manual tier: idle until run() is called. run() returns false for an invalid index, an empty tier, an automated tier, or a cycle already in flight; otherwise it sets running[index] = true and returns true.
  • Manual completion: exactly one cycle is credited, progress and running reset to 0/false, and nothing chains automatically — a huge (offline) dt completes that single cycle and discards the excess.
  • Manual idle ≠ pause: speed <= 0 freezes a tier keeping its progress (a debuff); a manual tier at rest simply banks no time. The two are orthogonal — a running manual cycle frozen by speed <= 0 resumes where it left off.
  • Automation flips: if a manual tier becomes automated mid-cycle, the running flag is ignored and the cycle continues (and chains) automatically. If an automated tier turns manual, its progress is kept but it only advances again once run() is called — unless a manual cycle was already armed, in which case that one cycle finishes.

Display helpers

ts
farm.ratePerSecond(state, index); // average output/s (yield & speed applied)
farm.progressFraction(state, index); // [0, 1] toward the next cycle
farm.effectiveCycleTime(state, index); // real seconds/cycle; Infinity if stopped

effectiveCycleTime returning a tiny value is your cue to switch the UI from a filling bar to a continuous indicator (cycles complete faster than a frame).

Options

OptionRequiredRole
definitionsThe tiers (ProducerDef[])
getColumn / setColumnRead/write the column arrays in your state
resource.get / resource.addThe resource tier 0 emits (and the default purchase currency)
purchase.getBudget / payPurchase seam: per-tier budget and payment
getIsAutomatedManual cycles (default: always automated)
getYieldMultiplierOutput multiplier per tier (default 1)
getSpeedMultiplierCycle-speed multiplier per tier (default 1; ≤ 0 pauses)
onPurchaseCalled after a successful purchase (e.g. save)

Behavior worth knowing

  • Offline for free: a large dt credits floor(progress / cycleTime) cycles — every batch that completed while away.
  • Cascade snapshot: within one tick every tier consumes the counts from the start of the tick; a tier's output reaches the tier below on the next tick, keeping the order deterministic.
  • Empty tiers are inert: they bank no time, and stale banked time from old saves is healed to zero — a newly bought first unit always starts a fresh cycle.
  • grant follows activation rules: granting the first unit to an empty tier resets stale progress to zero, like a purchase.

Limits

  • Numbers are numbers: fine into the e300 range; the core Decimal is not wired into producers yet.
  • One produced resource per producer column (purchases can spend a different scalar per tier via the purchase seam); multi-output tiers are a game-side composition (multiple producers instances work fine).
  • Economy bridges expose producer totals as resources, but those resources are intentionally not integer by default; production can be fractional. Whole-unit buying is enforced by economyPurchase when configured with wholeUnits.