Appearance
Modifiers
The decoupling hub of the toolkit. modifiers exposes a ModifierRegistry — a bag of stat bonuses keyed by source, resolved per target — so that upgrades, cards and boosts can affect production without any of them knowing the others exist.
ts
import { modifiers, ModifierRegistry } from "@idlekitjs/mechanics/modifiers";Mental model
Sources publish lists of modifiers under a key; consumers resolve a stat for a target. The formula is the standard, order-independent idle stack:
txt
effective = (base + Σ add) × Π multThe registry holds no game state and is never serialized — on load, each source simply republishes (collections and boosts do it automatically on the loaded event).
Minimal example
ts
const modifiersExt = modifiers<State>();
engine.use(modifiersExt);
const registry = modifiersExt.registry;
// An upgrade contributes a bonus:
registry.set("upgrade:better-tools", [
{ target: { kind: "all" }, stat: "yield", op: "mult", value: 2 },
]);
// A consumer resolves the effective value:
registry.resolve({ stat: "yield" }); // 2Targeting
A modifier applies to everything, one entity, or a tag group:
ts
{ target: { kind: "all" } } // every consumer of the stat
{ target: { kind: "id", id: "farm" } } // one specific entity
{ target: { kind: "tag", tag: "building" } } // every entity carrying the tagConsumers describe themselves when resolving:
ts
registry.resolve({ stat: "yield", id: "farm", tags: ["building", "food"] });Everything matching (all + id + matching tags) contributes to the same (base + Σ add) × Π mult stack. base defaults to 1; stats are plain strings ("yield", "speed", or anything you invent).
Sources: atomic replace
Modifiers are grouped by source so a contributor can update or remove its whole contribution in one call — no bookkeeping of individual entries:
ts
registry.set("card:comrade-power", [...]); // replace this source's modifiers
registry.set("card:comrade-power", []); // empty list removes the source
registry.remove("card:comrade-power"); // same thing
registry.clear(); // remove everythingNamespace source keys ("upgrade:*", "collectible:*", "boost:*") — the built-in publishers already do.
Who publishes, who consumes
| Extension | Role |
|---|---|
| Collections | Publishes each active collectible's effects under collectible:<id> |
| Boosts | Publishes active boost effects under boost:<id>, retracts on expiry |
| Your game | Publishes upgrade/project effects under keys you choose |
| Producers | Consumes via getYieldMultiplier / getSpeedMultiplier |
The dependency stays one-way: publishers write, consumers read, nobody references a concrete source. Adding a new bonus source to your game touches zero consumer code.
Realistic wiring
ts
const modifiersExt = modifiers<State>();
engine.use(modifiersExt);
const farm = producers<State>({
definitions: TIERS,
// ...
getYieldMultiplier: (state, i) =>
modifiersExt.registry.resolve({ stat: "yield", id: TIERS[i].id, tags: TIERS[i].tags }),
});
const boostExt = boosts<State>({
definitions: BOOSTS,
// ...
registry: modifiersExt.registry, // boosts publish here while active
});
engine.use(farm).use(boostExt);Common pitfalls
- Serializing the registry. Don't — it's derived state. Persist the causes (owned upgrades, active boosts) and republish on
loaded. - Per-entry updates. There is no "update one modifier":
setreplaces the source's whole list. That's the API working as intended — keep sources granular (upgrade:<id>, notupgrades). - Resolving in a hot loop with garbage queries.
resolveis cheap but not free; producers call it per tier per tick, which is fine — just avoid building newtagsarrays inside the getter.
Related
- Boosts — temporary publishers.
- Collections — level-scaled publishers.
- Boosted production recipe.
- Mechanics API — modifiers.