Appearance
@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:
producersstill owns production cycles, cascade math and scalar purchase behavior.craftingstill owns jobs, start/cancel/complete and machine state.projectsstill owns completion/repeatable/visible bookkeeping.boostsstill 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:
| Import | Purpose |
|---|---|
@idlekitjs/economy | Main facade: resources, amounts, requirements, transactions, formatting, cost curves |
@idlekitjs/economy/accessors | Accessor helpers only |
@idlekitjs/economy/cost-curves | Cost 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/economyThey 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:
| Field | Default |
|---|---|
label | id |
integer | false |
min | 0 |
max | Infinity |
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:
| Method | Behavior |
|---|---|
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) throwEconomyErrorfor 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,
NaNandInfinityamounts 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 finitefactor >= 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 rewardFailures 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 === 1is linear pricing;growth <= 0,Infinity,NaNthrow at wiring time;baseAmount < 0and flatamount < 0throw at wiring time;quantity <= 0or 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;
maxAffordableuses spendable balances (balance - min) and has floating point correction around geometric boundaries;- rounded decreasing geometric curves (
growth < 1with 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;applymust be infallible after preview. - No BigNumber/Decimal in Economy or cost curves yet; values are
number. ResourceIdremains 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.grantlets 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 byeconomyPurchasewithwholeUnits.
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;
purchaseBudgetandpayPurchasegenerated viaeconomyPurchase;- public signatures preserved for existing game wiring and tests;
games/adventure-communist/tests/economy-wiring.test.tsproves parity with the native producer purchase behavior.