Skip to content

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) × Π mult

The 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" }); // 2

Targeting

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 tag

Consumers 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 everything

Namespace source keys ("upgrade:*", "collectible:*", "boost:*") — the built-in publishers already do.

Who publishes, who consumes

ExtensionRole
CollectionsPublishes each active collectible's effects under collectible:<id>
BoostsPublishes active boost effects under boost:<id>, retracts on expiry
Your gamePublishes upgrade/project effects under keys you choose
ProducersConsumes 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": set replaces the source's whole list. That's the API working as intended — keep sources granular (upgrade:<id>, not upgrades).
  • Resolving in a hot loop with garbage queries. resolve is cheap but not free; producers call it per tier per tick, which is fine — just avoid building new tags arrays inside the getter.