Appearance
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 0State: 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 seamPurchaseResult 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 + ownedUse 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 (
getIsAutomatedreturnstrue, the default): current behavior,runningflags are ignored,isRunningistruewhenever the tier has units. - Manual tier: idle until
run()is called.run()returnsfalsefor an invalid index, an empty tier, an automated tier, or a cycle already in flight; otherwise it setsrunning[index] = trueand returnstrue. - Manual completion: exactly one cycle is credited,
progressandrunningreset to0/false, and nothing chains automatically — a huge (offline)dtcompletes that single cycle and discards the excess. - Manual idle ≠ pause:
speed <= 0freezes 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 byspeed <= 0resumes where it left off. - Automation flips: if a manual tier becomes automated mid-cycle, the
runningflag is ignored and the cycle continues (and chains) automatically. If an automated tier turns manual, its progress is kept but it only advances again oncerun()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 stoppedeffectiveCycleTime 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
| Option | Required | Role |
|---|---|---|
definitions | ✓ | The tiers (ProducerDef[]) |
getColumn / setColumn | ✓ | Read/write the column arrays in your state |
resource.get / resource.add | ✓ | The resource tier 0 emits (and the default purchase currency) |
purchase.getBudget / pay | Purchase seam: per-tier budget and payment | |
getIsAutomated | Manual cycles (default: always automated) | |
getYieldMultiplier | Output multiplier per tier (default 1) | |
getSpeedMultiplier | Cycle-speed multiplier per tier (default 1; ≤ 0 pauses) | |
onPurchase | Called after a successful purchase (e.g. save) |
Behavior worth knowing
- Offline for free: a large
dtcreditsfloor(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.
grantfollows 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 coreDecimalis 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
producersinstances 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
economyPurchasewhen configured withwholeUnits.