Skip to content

@idlekitjs/economy

Pure economic vocabulary and transaction execution for idle games: resources, accessors, amounts, costs, rewards, requirements, transactions, cost curves and formatting view models.

ts
import { createEconomy, stateKey } from "@idlekitjs/economy";
import { arrayIndex } from "@idlekitjs/economy/accessors";
import { costCurve, geometric } from "@idlekitjs/economy/cost-curves";

What it provides

@idlekitjs/economy is the vocabulary for economic actions. It declares named resources, reads and writes your state through explicit accessors, checks requirements, previews/executes transactions, calculates cost curves and formats view models for your UI.

It is deliberately not a state owner, not a state scanner, not an engine extension, not a second core, and not a replacement for simulation mechanics.

txt
Continuous simulation / dt / progress / timers -> mechanics.
Atomic actions with requirements + cost + reward + apply -> economy transactions.

That means:

  • producers still owns production cycles, cascade math and scalar purchase behavior.
  • crafting still owns jobs, start/cancel/complete and machine state.
  • projects still owns completion/repeatable/visible bookkeeping.
  • boosts still owns timers and modifier publication.
  • Economy settles explicit player actions such as purchases, claims, upgrades and token activations.

When to use it

Use Economy when an action should be evaluated as one explicit economic unit: requirements, cost, reward, state effect and UI diagnostics. Common examples are upgrades, claims, purchases, token activations and admin/debug grants.

Skip it for continuous simulation. Production cycles, crafting timers, boost expiry and offline dt advancement stay in mechanics and core systems.

Basic usage

ts
import { createEconomy, stateKey } from "@idlekitjs/economy";

interface State {
  potatoes: number;
  science: number;
  upgrades: string[];
}

const economy = createEconomy<State>()
  .resource({ id: "currency:potatoes", label: "Potatoes", accessor: stateKey("potatoes") })
  .resource({ id: "currency:science", label: "Science", accessor: stateKey("science") });

const result = economy.execute(state, {
  id: "upgrade:better-plows",
  cost: [["currency:science", 500]],
  reward: [["currency:potatoes", 100]],
  apply: (state) => {
    state.upgrades.push("better-plows");
  },
});

Boundaries and package surface

Exports:

ImportPurpose
@idlekitjs/economyMain facade: resources, amounts, requirements, transactions, formatting, cost curves
@idlekitjs/economy/accessorsAccessor helpers only
@idlekitjs/economy/cost-curvesCost curve helpers only

Internal public domains:

  • resources/
  • accessors/
  • economy/
  • amounts/
  • requirements/
  • transactions/
  • cost-curves/
  • format/

Dependencies: @idlekitjs/economy depends on @idlekitjs/utils only. It does not depend on @idlekitjs/core, @idlekitjs/mechanics, DOM, browser APIs or storage.

Official mechanic bridges live next to the mechanics:

txt
@idlekitjs/mechanics/producers/economy
@idlekitjs/mechanics/collections/economy
@idlekitjs/mechanics/crafting/economy
@idlekitjs/mechanics/projects/economy
@idlekitjs/mechanics/boosts/economy
@idlekitjs/mechanics/containers/economy
@idlekitjs/mechanics/pickups/economy

They are not exported from the @idlekitjs/mechanics barrel and they are not under @idlekitjs/economy/adapters; see Economy adapters.

Resources

A resource is a named scalar in your state.

ts
type ResourceId = string;

interface ResourceAccessor<T> {
  get(state: T): number;
  add(state: T, amount: number): void;
}

ResourceDef<T> never stores a balance. The game state remains the source of truth, and Economy reaches it only through the accessor you declare.

Resource ids are plain strings. Runtime validation rejects:

  • empty strings;
  • whitespace;
  • ids that start or end with :.

The recommended convention is namespace:name, for example currency:potatoes, producer:collective or card:collective-manager:shards. It is a convention, not a type system.

Duplicate ids throw when wiring the economy. A broken registry should fail at boot, not mid-run.

Defining Resources

Both syntaxes are equivalent:

ts
interface State {
  potatoes: number;
  science: number;
}

const economy = createEconomy<State>()
  .resource({
    id: "currency:potatoes",
    label: "Potatoes",
    accessor: stateKey("potatoes"),
  })
  .resource({
    id: "currency:science",
    label: "Science",
    get: (state) => state.science,
    add: (state, amount) => {
      state.science += amount;
    },
    integer: true,
  });

Defaults:

FieldDefault
labelid
integerfalse
min0
maxInfinity
tags[]

min and max define bounds for the balance. add and credit clamp into those bounds; spend, pay and affordability use the spendable balance current - min.

integer: true validates moved amounts. Economy never silently rounds; cost curves and callers own rounding.

Accessors

There is no automatic state scan and no nested-path magic. Every resource points at its state location explicitly.

stateKey

Use stateKey for a top-level numeric field.

ts
const potatoes = stateKey<State>("potatoes");

get reads state.potatoes; add assigns state.potatoes + amount back to the same field.

arrayIndex

Use arrayIndex for one slot of a numeric array, such as a producer tier.

ts
import { arrayIndex } from "@idlekitjs/economy/accessors";

interface ProducerState {
  total: number[];
}

const collectiveUnits = arrayIndex<ProducerState>({
  getArray: (state) => state.total,
  setArray: (state, total) => {
    state.total = total;
  },
  index: 1,
});

get returns 0 for a missing slot. add clones the array, updates the slot, then calls setArray so reactive stores observe the structural change.

recordField

Use recordField for a numeric field inside a keyed record.

ts
import { recordField } from "@idlekitjs/economy/accessors";

interface CardEntry {
  level: number;
  shards: number;
}

interface State {
  cards: Record<string, CardEntry>;
}

const managerShards = recordField<State, CardEntry>({
  getRecord: (state) => state.cards,
  setRecord: (state, cards) => {
    state.cards = cards;
  },
  key: "collective-manager",
  field: "shards",
  defaultEntry: () => ({ level: 0, shards: 0 }),
});

Reads use defaultEntry() when the key is absent. Writes create the entry on demand, clone the record and targeted entry, then reassign through setRecord.

computed

Use computed when the accessor is custom.

ts
import { computed } from "@idlekitjs/economy/accessors";

const doubledGold = computed<State>({
  get: (state) => state.gold * 2,
  add: (state, amount) => {
    state.gold += amount / 2;
  },
});

computed is a named pass-through. It adds no behavior; it is the explicit escape hatch for hand-written get/add pairs.

readonly

Use readonly for observable values that Economy may read but must never credit or spend directly, such as a card level.

ts
import { readonly } from "@idlekitjs/economy/accessors";

const cardLevel = readonly<State>(
  (state) => state.cards["collective-manager"]?.level ?? 0,
);

Resources built on readonly get ResourceDef.readonly = true. They work in requirements and formatting. Direct writes throw, and transactions report a readonly-resource diagnostic when a read-only resource appears in cost or reward.

Economy Instance

Create one economy per game in most cases:

ts
const economy = createEconomy<State>({ format: formatNumber })
  .resource({ id: "currency:potatoes", accessor: stateKey("potatoes") })
  .resources([
    { id: "currency:science", accessor: stateKey("science"), integer: true },
  ]);

Main methods:

MethodBehavior
resource(init)Register a resource, chainable; throws on invalid or duplicate id
resources(inits)Register many resources
has(id)Boolean registry check
resource(id)Return a ResourceDef; throws on unknown id
ids()Registered ids in order
get(state, id)Read through the resource accessor
add(state, id, amount)Add/clamp and return the applied delta
spend(state, id, amount)Spend exactly, atomically; false when short
canAfford(state, cost)Boolean affordability
missing(state, cost)Shortfalls per resource
pay(state, cost)Debit a cost atomically; throws when unaffordable
credit(state, reward)Credit/clamp a reward; returns overflow
preview(state, tx)Pure transaction evaluation
execute(state, tx)Preview then mutate on success
canExecute(state, tx)preview(...).ok
formatAmount(id, amount)Format one amount
formatCost(state, cost)Cost view models with affordability
formatReward(reward)Reward view models
audit(state)Dev/test invalid-state sweep over registered resources

Error channels are intentional:

  • Direct code paths (get, resource(id), add, spend, pay, credit, formatting) throw EconomyError for programming mistakes such as unknown ids, invalid amounts or read-only writes.
  • Transaction data paths (preview, execute, canExecute) return diagnostics for content-shaped input. They report all failures they can find instead of stopping at the first one.

Amounts, Cost And Reward

The shape is shared:

ts
interface ResourceAmount {
  resourceId: ResourceId;
  amount: number;
}

type Cost = readonly ResourceAmount[];
type Reward = readonly ResourceAmount[];
type AmountsInput = readonly (readonly [ResourceId, number] | ResourceAmount)[];

Tuple syntax:

ts
["currency:science", 500];

Object syntax:

ts
{ resourceId: "currency:science", amount: 500 };

They can be mixed:

ts
const cost = [
  ["currency:science", 500],
  ["card:collective-manager:shards", 10],
] as const;

Normalization:

  • duplicate resource ids are merged by sum, preserving first-seen order;
  • zero lines are filtered out, including duplicate totals that sum to zero;
  • negative, NaN and Infinity amounts are invalid;
  • fractional amounts are invalid for resources declared integer: true.

Rewards cannot be negative. Use a Cost to debit.

Helpers:

  • normalizeAmounts(input) throws on invalid lines;
  • collectAmounts(input) returns { amounts, invalid } without throwing;
  • mergeAmounts(...inputs) merges normalized lists;
  • scaleAmounts(input, factor) scales by a finite factor >= 0.

Requirements

A requirement is a non-consuming condition.

ts
interface Requirement<T> {
  id: string;
  label?: string;
  isMet(state: T, economy: EconomyReader<T>): boolean;
  progress?(state: T, economy: EconomyReader<T>): { current: number; target: number };
}

Built-in helpers:

  • resourceAtLeast(resourceId, amount);
  • resourceAtMost(resourceId, amount);
  • allOf(...requirements);
  • not(requirement, label?).

Requirement versus cost:

ts
// Gate only: science is not consumed.
requirements: [resourceAtLeast("currency:science", 500)];

// Debit: science is consumed if the transaction executes.
cost: [["currency:science", 500]];

Any condition that can block execute belongs in requirements, not in apply.

Transactions

Transactions are for atomic player actions: buy, upgrade, claim, activate, unlock.

ts
const result = economy.execute(state, {
  id: "upgrade-card:collective-manager",
  requirements: [cardLevelBelow("collective-manager", 5)],
  cost: [
    ["card:collective-manager:shards", 10],
    ["currency:science", 500],
  ],
  reward: [["currency:xp", 25]],
  apply: (state) => {
    upgradeCard(state, "collective-manager");
  },
});

preview is pure and never mutates. The current pipeline:

txt
preview, pure:
1. resolve cost/reward inputs, including function forms
2. validate resources on cost/reward lines
3. validate amounts and integer constraints
4. reject readonly resources in cost/reward
5. normalize valid amounts (merge duplicates, drop zero totals)
6. check requirements
7. validate balances for cost/reward resources that will be read
8. check affordability
9. compute reward overflow against max caps

execute, if preview ok:
1. pay cost
2. apply effect
3. credit reward

Failures are exhaustive for the data path:

  • unknown-resource;
  • invalid-amount;
  • invalid-state;
  • requirement-failed;
  • cannot-afford;
  • readonly-resource.

execute reuses the same preview logic, so preview and execute cannot disagree on whether a transaction is allowed.

There is no generic rollback. apply runs after the cost is paid and before the reward is credited. It must be infallible after preview; if it throws, the exception propagates as a programming error and already-paid cost is not rolled back. Express every blockable condition as a requirement.

Use audit(state) in tests to validate the full resource registry. Transaction preview validates only the balances it needs for the transaction's cost and reward.

Formatting

Economy does not render UI. It returns strings or view models; colors, icons, DOM and layout stay in your game.

ts
const economy = createEconomy<State>({ format: formatNumber }).resource({
  id: "currency:science",
  label: "Science",
  accessor: stateKey("science"),
  format: (amount) => `${formatNumber(amount)} science`,
});

resource.format overrides the economy-wide formatter.

ts
const lines = economy.formatCost(state, [
  ["currency:science", 500],
  ["card:collective-manager:shards", 10],
]);

for (const line of lines) {
  // line.resourceId
  // line.label
  // line.amount      formatted text
  // line.rawAmount   number
  // line.available   spendable balance
  // line.affordable
}

Types:

  • FormattedAmount;
  • FormattedCostLine;
  • describeFailure(failure, { label? }).

describeFailure is a default English sentence for logs, tests and quick tooltips. Localized or styled UIs should switch on failure.kind.

Cost Curves

Cost curves price a whole quantity from an owned count and return Economy Cost lines.

ts
import { costCurve, flat, geometric } from "@idlekitjs/economy/cost-curves";

const factoryCost = costCurve<State>({
  getOwned: (state) => state.producers.owned[2] ?? 0,
  lines: [
    flat("producer:collective", 1),
    geometric("currency:coins", {
      baseAmount: 1000,
      growth: 1.15,
      round: "ceil",
    }),
  ],
});

const amount = factoryCost.maxAffordable(state, economy);

economy.execute(state, {
  id: "purchase-producer:factory",
  cost: factoryCost.costFor(state, amount),
  apply: (state) => {
    producers.grant(2, state, amount, { owned: true });
  },
});

Surface:

  • CostCurve<T>;
  • CostCurveLine;
  • flat(resourceId, amount, options?);
  • geometric(resourceId, { baseAmount, growth, round? });
  • costCurve({ getOwned, lines });
  • curve.costFor(state, quantity);
  • curve.next(state);
  • curve.maxAffordable(state, economy).

Rules:

  • growth === 1 is linear pricing;
  • growth <= 0, Infinity, NaN throw at wiring time;
  • baseAmount < 0 and flat amount < 0 throw at wiring time;
  • quantity <= 0 or non-finite quantity returns an empty cost;
  • rounding ("none" | "floor" | "ceil" | "round") belongs to the curve line and applies per unit before summing;
  • multi-resource curves are supported;
  • duplicated resources are supported and merged per resource;
  • maxAffordable uses spendable balances (balance - min) and has floating point correction around geometric boundaries;
  • rounded decreasing geometric curves (growth < 1 with rounding) throw at wiring time;
  • no BigNumber/Decimal support yet.

Recipes

Simple Potatoes And Science

ts
interface State {
  potatoes: number;
  science: number;
}

const economy = createEconomy<State>()
  .resource({
    id: "currency:potatoes",
    label: "Potatoes",
    accessor: stateKey("potatoes"),
  })
  .resource({
    id: "currency:science",
    label: "Science",
    accessor: stateKey("science"),
    integer: true,
  });

Producer Unit Resource With arrayIndex

ts
const producerUnits = arrayIndex<State>({
  getArray: (state) => state.producers.total,
  setArray: (state, total) => {
    state.producers.total = total;
  },
  index: 0,
});

economy.resource({
  id: "producer:comrade",
  label: "Comrades",
  accessor: producerUnits,
});

Card Shards With recordField

ts
const shards = recordField<State, { level: number; shards: number }>({
  getRecord: (state) => state.cards,
  setRecord: (state, cards) => {
    state.cards = cards;
  },
  key: "collective-manager",
  field: "shards",
  defaultEntry: () => ({ level: 0, shards: 0 }),
});

economy.resource({
  id: "card:collective-manager:shards",
  label: "Collective Manager shards",
  accessor: shards,
  integer: true,
});

Preview An Upgrade Button

ts
const upgrade = {
  id: "upgrade-card:collective-manager",
  requirements: [cardLevelBelow("collective-manager", 5)],
  cost: [
    ["card:collective-manager:shards", 10],
    ["currency:science", 500],
  ],
  apply: (state: State) => {
    upgradeCard(state, "collective-manager");
  },
} satisfies Transaction<State>;

const preview = economy.preview(state, upgrade);
button.disabled = !preview.ok;
tooltip.textContent = preview.ok
  ? economy.formatCost(state, upgrade.cost).map((line) => `${line.amount} ${line.label}`).join(", ")
  : preview.failures.map((failure) => describeFailure(failure)).join("\n");

Execute A Transaction

ts
const result = economy.execute(state, upgrade);

if (!result.ok) {
  for (const failure of result.failures) {
    console.warn(describeFailure(failure));
  }
}

Geometric Multi-Resource Curve

ts
const labCost = costCurve<State>({
  getOwned: (state) => state.labsOwned,
  lines: [
    geometric("currency:coins", { baseAmount: 100, growth: 1.15, round: "ceil" }),
    geometric("currency:science", { baseAmount: 10, growth: 1.08, round: "ceil" }),
  ],
});

const buyMax = labCost.maxAffordable(state, economy);

Producers With economyPurchase

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

const economy = createEconomy<State>()
  .resource({ id: "currency:potatoes", accessor: stateKey("potatoes") })
  .resources(producerResources(PRODUCERS, column));

const purchase = economyPurchase(economy, (index) =>
  index === 0
    ? "currency:potatoes"
    : producerResourceId(PRODUCERS[index - 1].id),
);

const farm = producers<State>({
  definitions: PRODUCERS,
  getColumn: column.getColumn,
  setColumn: column.setColumn,
  resource: producerOutput(economy, "currency:potatoes"),
  purchase,
});

grant For A Reward

ts
// Crate reward: add 5 comrades as a gift. Cost curve does not advance.
farm.grant(0, state, 5);

// Transaction-settled composite purchase: advance owned and total.
farm.grant(2, state, 1, { owned: true });

grant does not replace purchase. It is for rewards, crates, debug/admin tools and purchases already validated/paid elsewhere, usually through an Economy transaction.

Design Decisions And Tradeoffs

  • No rollback in execute; apply must be infallible after preview.
  • No BigNumber/Decimal in Economy or cost curves yet; values are number.
  • ResourceId remains a simple string with runtime/wiring-time validation.
  • Amount normalization filters zero, merges duplicates and rejects negatives.
  • No state scanning and no nested path accessors.
  • Resource values live in game state, not in ResourceDef.
  • Adapters live in @idlekitjs/mechanics/<mechanic>/economy, not in Economy.
  • producers.grant lets rewards and transaction-settled composite purchases add units without using the scalar purchase seam.
  • Cost curves use floating-point correction for maxAffordable.
  • Producer resources are deliberately not integer; fractional production can exist. Whole-unit purchase semantics are enforced by economyPurchase with wholeUnits.

Dogfood: Adventure Communist

games/adventure-communist/src/content/economy.ts is the first production dogfood:

  • one Economy instance;
  • resources for potatoes, science and producer unit pools;
  • purchaseBudget and payPurchase generated via economyPurchase;
  • public signatures preserved for existing game wiring and tests;
  • games/adventure-communist/tests/economy-wiring.test.ts proves parity with the native producer purchase behavior.