Skip to content

Mechanics API

Signatures and types for every mechanic. Concepts, examples and pitfalls live on each mechanic's own page.

Producers

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

function producers<T extends object>(options: ProducersOptions<T>): ProducersExtension<T>;
ts
interface ProducerDef {
  id: string;
  cycleTime: number; // seconds per batch
  yieldPerUnit: number; // units emitted per owned unit per cycle
  baseCost: number;
  costGrowth: number; // cost multiplier per purchased unit
  tags?: string[]; // modifier targeting groups
}

interface ProducerColumn {
  owned: number[]; // purchased -> cost curve
  total: number[]; // present (owned + cascaded) -> output
  progress: number[]; // seconds toward next cycle (mutated in place)
  running?: boolean[]; // manual-cycle flags; absent = none in flight
}

interface ProducersOptions<T> {
  definitions: ProducerDef[];
  getColumn(state: T): ProducerColumn;
  setColumn(state: T, patch: Partial<ProducerColumn>): void;
  resource: { get(state: T): number; add(state: T, amount: number): void };
  purchase?: {
    getBudget(state: T, index: number): number; // scalar budget for tier `index`
    pay(state: T, index: number, amount: number): void; // debit that budget
  }; // default: resource.get / resource.add(-amount)
  getIsAutomated?(state: T, index: number): boolean; // default: () => true
  getYieldMultiplier?(state: T, index: number): number; // default 1
  getSpeedMultiplier?(state: T, index: number): number; // default 1; <= 0 pauses
  onPurchase?(index: number, state: T, result: PurchaseResult): void;
}

interface PurchaseResult {
  bought: number;
  spent: number;
  remaining: number;
}

ProducersExtension<T> methods:

MethodReturnsNotes
purchase(index, state)booleanPurchase one unit
purchaseMany(index, state, quantity)PurchaseResultPurchase up to quantity
purchaseWithBudget(index, state, budget)PurchaseResultSpend at most budget
grant(index, state, quantity, options?)booleanAdd whole units without paying; { owned: true } advances cost curve
cost(index, state)numberNext unit's price (Infinity for bad index)
costFor(index, state, quantity)numberExact bulk price (geometric series)
maxAffordable(index, state, budget?)numberUnits the budget (default: tier's budget) affords
run(index, state)booleanArm one manual cycle of a non-automated tier
isRunning(state, index)booleanCycle in flight (always true for automated + units)
ratePerSecond(state, index?)numberAverage output/s with multipliers applied
progressFraction(state, index)number[0, 1] toward the next cycle
effectiveCycleTime(state, index)numberReal s/cycle; Infinity when stopped/empty

Modifiers

ts
import { modifiers, ModifierRegistry } from "@idlekitjs/mechanics/modifiers";

function modifiers<T extends object>(): ModifiersExtension<T>;
// ModifiersExtension<T> = Extension<T> & { registry: ModifierRegistry }
ts
type ModifierTarget = { kind: "all" } | { kind: "id"; id: string } | { kind: "tag"; tag: string };

interface Modifier {
  target: ModifierTarget;
  stat: string; // "yield", "speed", or anything you define
  op: "add" | "mult";
  value: number;
}

interface ResolveQuery {
  stat: string;
  base?: number; // default 1
  id?: string;
  tags?: readonly string[];
}

ModifierRegistry:

MethodBehavior
set(source, modifiers)Replace a source's contribution atomically (empty = remove)
remove(source)Remove a source
clear()Remove everything
resolve(query)(base + Σ add) × Π mult over matching modifiers

Never serialized — sources republish on loaded.

Collections

ts
import { collection } from "@idlekitjs/mechanics/collections";

function collection<T extends object>(options: CollectionOptions<T>): CollectionExtension<T>;

Key types (full definitions in the source types are mirrored by your editor's IntelliSense):

ts
interface RarityDef { id: string; weight: number; defaults?: UpgradeDef; metadata?: ... }
interface CollectibleDef<T> {
  id: string;
  rarity: string;
  tags?: string[];
  effects?: CollectibleEffect[];  // Modifier shape with value: (level) => number
  upgrade?: UpgradeDef;           // { duplicates?, costs?, maxLevel? } — curves of level
  eligibility?: Eligibility<T>;   // visible / droppable / active / upgradeable predicates
  metadata?: Record<string, unknown>;
}
interface PackDef<T> {
  id: string;
  draws?: WeightedInt;                    // legacy rarity-weighted model
  rarityWeights?: Record<string, number>;
  rewards?: PackRewardDef[];              // declarative model (cards/currency lines)
  eligibility?: { visible?; openable? };
  metadata?: Record<string, unknown>;
}
interface CollectionData {
  collectibles: Record<string, { quantity: number; level: number }>;
  rngState: number; // persisted PRNG
}

Options: rarities, collectibles, packs, currencies ({ id, get, spend, add? }), registry (a ModifierRegistry), getData/setData, seed?, onChange?.

CollectionExtension<T> methods:

MethodReturnsNotes
openPack(packId, state)OpenPackResultThrows on unknown pack id
canUpgrade(id, state)UpgradeCheck{ ok, reason?, requirements } — UI-ready
upgrade(id, state)booleanSpends duplicates + currencies, levels up
getCollectible(id, state)CollectibleView | undefinedUI view (level, quantity, metadata)
getVisible(state)CollectibleView[]Every currently visible collectible
rebuildModifiers(state)voidRepublish all active effects

Upgrade block reasons: "not-owned" | "max-level" | "not-enough-duplicates" | "not-enough-currency" | "not-eligible".

Projects

ts
import { projects, ProjectManager } from "@idlekitjs/mechanics/projects";

function projects<T extends object>(options: ProjectsOptions<T>): ProjectsExtension<T>;

interface ProjectsOptions<T> {
  projects: Project<T>[];
  getCompleted(state: T): string[];
  setCompleted(state: T, ids: string[]): void;
  onComplete?(id: string, state: T): void;
}

ProjectsExtension<T>: buy(id, state): boolean and manager: ProjectManager<T>.

ProjectManager<T>:

MethodBehavior
available(state)Triggered, not-completed projects
isCompleted(id)Completion check
complete(id, state)Check affordability, apply effect, mark completed
completedIds()Serializable completion list
restore(ids)Rebuild from a save (unknown ids ignored)

The Project<T> shape is exported by this mechanic — see Projects.

Crafting

ts
import {
  crafting,
  addResources,
  subtractResources,
  canAfford,
  missingResources,
} from "@idlekitjs/mechanics/crafting";

function crafting<T extends object>(options: CraftingOptions<T>): CraftingExtension<T>;
ts
type ResourceBag = Record<string, number>;

interface RecipeDef {
  id: string;
  inputs: ResourceBag;
  outputs: ResourceBag;
  duration: number; // seconds, finite > 0 (validated at wiring)
  machineType?: string;
  metadata?: Record<string, unknown>;
}

interface MachineDef {
  id: string;
  type?: string;
  metadata?: Record<string, unknown>;
}

interface CraftingJob {
  id: string; // default "<machineId>:<recipeId>"
  recipeId: string;
  machineId: string;
  elapsed: number; // mutated in place
  duration: number;
}

type CraftingStatus =
  | { kind: "ready" }
  | { kind: "unknown-recipe" }
  | { kind: "unknown-machine" }
  | { kind: "wrong-machine-type"; required: string; actual?: string }
  | { kind: "machine-busy"; jobId: string } // running another recipe
  | { kind: "missing-inputs"; missing: ResourceBag }
  | { kind: "crafting"; job: CraftingJob; progress: number }; // running THIS recipe

Options: recipes, machines, getResources/setResources, getJobs/setJobs, jobId?(recipe, machine, state), onStart?(job, state), onComplete?(job, outputs, state).

CraftingExtension<T> methods:

MethodReturnsNotes
status(state, recipeId, machineId)CraftingStatusNever throws for runtime refusals
canStart(state, recipeId, machineId)booleanstatus(...).kind === "ready"
start(state, recipeId, machineId)CraftingStatusConsumes inputs, creates the job on ready
cancel(state, machineId, { refund? })booleanRefunds inputs by default
activeJobs(state)CraftingJob[]Snapshot copy
jobFor(state, machineId)CraftingJob | undefined
progressFraction(state, machineId)number[0, 1]; 0 when idle

ResourceBag helpers (pure, non-mutating; missing keys = 0): addResources(bag, delta), subtractResources(bag, cost), canAfford(bag, cost), missingResources(bag, cost).

Boosts

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

function boosts<T extends object>(options: BoostsOptions<T>): BoostsExtension<T>;
ts
type BoostStacking = "refresh" | "extend" | "stack";

interface BoostDef {
  id: string;
  duration: number; // seconds, finite > 0 (validated at wiring)
  maxDuration?: number; // cap for "extend" and extend(); >= duration
  stacking?: BoostStacking; // default "refresh"
  maxStacks?: number; // integer >= 1, for "stack"
  effects?: Modifier[]; // published under "boost:<id>" while active
  metadata?: Record<string, unknown>;
}

interface ActiveBoost {
  id: string;
  remaining: number; // mutated in place
  stacks: number; // >= 1
}

Options: definitions, getActive/setActive (Record<string, ActiveBoost>), registry? (ModifierRegistry), onGrant?(boost, state), onExpire?(id, state) (timer/negative-extend expiry only — not manual remove).

BoostsExtension<T> methods:

MethodReturnsNotes
grant(state, id)ActiveBoostApplies the stacking policy; throws on unknown id
extend(state, id, seconds)booleanCapped by maxDuration; result ≤ 0 expires
remove(state, id)booleanRetracts modifiers; no onExpire
isActive(state, id)boolean
get(state, id)ActiveBoost | undefined
active(state)ActiveBoost[]Snapshot
rebuildModifiers(state)voidRepublish all active effects

Stacked effect scaling: addvalue × stacks; multvalue ^ stacks.

Containers

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

function containers<T extends object>(options: ContainersOptions<T>): ContainersExtension<T>;
ts
interface ContainerDef<T> {
  id: string;
  capacity: number | ((state: T) => number); // finite > 0 (validated)
  volumeOf?: (contentId: string, state: T) => number; // default 1 per unit
  metadata?: Record<string, unknown>;
}

type ContainerContents = Record<string, number>; // entries always > 0
type ContainersData = Record<string, ContainerContents>;

Options: definitions, getData/setData (ContainersData, reassigned on change). Containers own finite capacity with multiple content types — not rewards, not periodic logic. used = sum(amount × volumeOf(contentId)).

ContainersExtension<T> methods:

MethodReturnsNotes
used(state, containerId)number0 for unknown ids
free(state, containerId)numbercapacity - used; negative if a dynamic capacity shrank
capacity(state, containerId)numberDynamic capacities resolving invalid throw
contents(state, containerId)ContainerContentsSnapshot
canFit(state, containerId, contentId, n?)booleann defaults to 1
fill(state, containerId, contentId, n?)booleanAll-or-nothing; false without mutation when it does not fit
fillUpTo(state, containerId, contentId, n)numberAccepts what fits; returns the accepted amount
drain(state, containerId, input?)DrainResultNo input = everything; { contentId } = one type; { contentId, amount } = partial

drain returns { removed, remaining }. User-facing misuse (unknown container, bad amounts) is safe: reads report empty/zero, fills fail, drains remove nothing. Duplicate ids and invalid static capacities throw at wiring.

Timers

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

function timers<T extends object>(options: TimersOptions<T>): TimersExtension<T>;
ts
interface TimerDef {
  id: string;
  every: number; // seconds between firings, finite > 0 (validated)
  autoStart?: boolean; // default true
  metadata?: Record<string, unknown>;
}

interface TimerState {
  remaining: number; // mutated in place
  running: boolean;
}

Options: definitions, getData/setData (Record<string, TimerState>; missing entries are materialized from the defs on the first update), onFire?(id, state, fires).

Timers own generic periodic firing — the effect stays in onFire. A large dt (offline catch-up) covering several periods calls onFire once with fires > 1, so the game settles missed firings in one pass.

TimersExtension<T> methods:

MethodReturnsNotes
start(state, id)booleanResumes; progress is kept, not reset
stop(state, id)booleanPauses, freezing progress
isRunning(state, id)boolean
trigger(state, id)booleanFires once manually (running or not), resets the period
progressFraction(state, id)number[0, 1], for a bar

Unknown ids return false/0.

Pickups

ts
import { createPickupsData, pickups } from "@idlekitjs/mechanics/pickups";

function pickups<T extends object>(options: PickupsOptions<T>): PickupsExtension<T>;
function createPickupsData(seed?: number): PickupsData; // fresh sub-state
ts
interface PickupDef<T> {
  id: string; // type id
  lifetime?: number; // seconds before expiry; omit = never
  spawn?: {
    every: number; // seconds between spawns
    max?: number; // cap on simultaneously active items
    when?: (state: T) => boolean; // while false, no time accumulates
  };
  position?: (state: T, random: () => number) => { x: number; y: number };
  metadata?: Record<string, unknown>; // rendering hints, never interpreted here
}

interface PickupItem {
  id: string; // "<type>#<n>"
  type: string;
  remaining?: number; // mutated in place
  lifetime?: number;
  position?: { x: number; y: number };
  metadata?: Record<string, unknown>;
}

type PickupView = PickupItem & { lifetimeFraction?: number }; // 0 fresh -> 1 expiring

Options: definitions, getData/setData (PickupsData: items, id counter, persisted PRNG state, spawn accumulators), onSpawn?, onExpire?.

Pickups own temporary, individually identifiable items the player can act on before they expire. Spawning never grants anything — settle collects through an Economy transaction whose apply calls take (see the pickups Economy adapter). Auto-spawned types must declare lifetime and/or spawn.max (validated at wiring), which bounds offline catch-up: a large dt back-spawns only items that would still be alive, with staggered countdowns, never above max.

PickupsExtension<T> methods:

MethodReturnsNotes
spawn(state, type, options?)PickupItem | undefinedManual spawn; undefined for unknown types. Options: position, metadata (merged over the def's), lifetime override
status(state, id)PickupStatus"ready" | "unknown" | "expired" discriminated union
take(state, id)PickupItem | undefinedRemoves and returns a ready item — the collect settlement primitive
visible(state)PickupView[]Active items with lifetimeFraction, for rendering
active(state, type?)numberCount of active items

Economy adapter subpaths

These adapters are official but optional. They are not exported by the @idlekitjs/mechanics barrel.

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

import {
  cardLevelAtLeast,
  cardLevelBelow,
  cardLevelResourceId,
  cardResources,
  cardShardResourceId,
  hasCardShards,
} from "@idlekitjs/mechanics/collections/economy";

import {
  amountsToBag,
  bagToAmounts,
  craftingResources,
  recipeCost,
  recipeReward,
} from "@idlekitjs/mechanics/crafting/economy";

import { projectFromTransaction } from "@idlekitjs/mechanics/projects/economy";
import { activateBoost, boostTokenResourceId } from "@idlekitjs/mechanics/boosts/economy";

import {
  containerContentResources,
  containerFreeSpaceResource,
  containerHasSpace,
} from "@idlekitjs/mechanics/containers/economy";

import { collectPickup, pickupAvailable } from "@idlekitjs/mechanics/pickups/economy";

See Economy adapters for behavior and examples.