Skip to content

Boosts

Temporary, stackable effects with guaranteed-clean expiry. A boost is granted, runs its timer, optionally publishes modifiers while active, and retracts them the instant it ends — no effect can leak. What grants a boost (a pickup, a reward, an ad, a purchase) is entirely your game's business; the mechanic is the primitive only.

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

Minimal example (no registry)

Without a registry, boosts are pure timed statuses you can query:

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

interface State {
  boosts: { active: Record<string, ActiveBoost> };
}

const boostExt = boosts<State>({
  definitions: [{ id: "warp", duration: 30 }],
  getActive: (s) => s.boosts.active,
  setActive: (s, active) => {
    s.boosts.active = active;
  },
});

engine.use(boostExt);

boostExt.grant(engine.state, "warp");
boostExt.isActive(engine.state, "warp"); // true
boostExt.get(engine.state, "warp"); // { id: "warp", remaining: 30, stacks: 1 }
// ...30 simulated seconds later it's gone.

With modifiers

Wire the optional registry and boost effects flow into the same stat stack everything else uses. Effects reuse the Modifier shape — published under boost:<id> while active, removed on expiry:

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

const modifiersExt = modifiers<State>();

const boostExt = boosts<State>({
  definitions: [
    {
      id: "double-production",
      duration: 30,
      effects: [{ target: { kind: "all" }, stat: "yield", op: "mult", value: 2 }],
    },
    // Debuffs are just reducing values:
    {
      id: "sabotage",
      duration: 5,
      effects: [{ target: { kind: "id", id: "mine" }, stat: "speed", op: "mult", value: 0.5 }],
    },
  ],
  getActive: (s) => s.boosts.active,
  setActive: (s, active) => {
    s.boosts.active = active;
  },
  registry: modifiersExt.registry,
});

engine.use(modifiersExt).use(boostExt);

boostExt.grant(engine.state, "double-production");
modifiersExt.registry.resolve({ stat: "yield" }); // 2 — until it expires

Consumers (producers, your systems) resolve against the registry and never know boosts exist.

Stacking policies

What re-granting an already-active boost does is a per-definition choice:

stackingRe-grant behavior
"refresh" (default)Timer restarts at duration
"extend"Adds duration seconds, capped by maxDuration
"stack"Adds a stack (up to maxStacks) and restarts the timer
ts
{ id: "coffee", duration: 10, maxDuration: 25, stacking: "extend" }
{ id: "frenzy", duration: 8, stacking: "stack", maxStacks: 3,
  effects: [{ target: { kind: "all" }, stat: "yield", op: "add", value: 0.5 }] }

With N stacks, effect values scale:

  • op: "add" scales linearlyvalue × N (frenzy at 3 stacks: +1.5);
  • op: "mult" compoundsvalue ^ N (a ×2 at 3 stacks: ×8).

The API

ts
boostExt.grant(state, id); // apply stacking policy; returns the ActiveBoost
boostExt.extend(state, id, seconds); // manual +/- time, capped by maxDuration
boostExt.remove(state, id); // deactivate now (does NOT fire onExpire)
boostExt.isActive(state, id);
boostExt.get(state, id); // ActiveBoost | undefined
boostExt.active(state); // ActiveBoost[] snapshot for the UI

Semantics worth knowing:

  • grant throws on an unknown id — definitions are static data, a typo is a bug.
  • extend accepts negative seconds; reaching ≤ 0 expires the boost (and fires onExpire). It returns false when the boost isn't active.
  • onExpire(id, state) fires on timer expiry and negative-extend expiry — not on manual remove (rule of thumb: expiry is the timer's doing, removal is yours).

Economy adapter

The optional bridge lives at @idlekitjs/mechanics/boosts/economy:

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

boostTokenResourceId("double-production") returns boost-token:double-production.

activateBoost(boostExt, options) returns an Economy transaction that spends a token resource and calls boostExt.grant in apply.

ts
const transaction = activateBoost(boostExt, {
  boostId: "double-production",
});

const result = economy.execute(state, transaction);

Pattern:

  • a reward credits a token resource;
  • a transaction consumes the token;
  • apply grants the boost;
  • boost timers, stacking and modifier publication stay in boosts.

Custom token ids and token counts are supported:

ts
activateBoost(boostExt, {
  boostId: "double-production",
  tokenId: "currency:stars",
  tokens: 3,
});

Timing, saving, offline

Active entries are plain JSON in your state ({ id, remaining, stacks } per boost). update(state, dt) decrements remaining in place and expires due boosts — a large offline dt expires everything that should have ended, in one pass.

On the loaded event the mechanic heals stale saves — unknown ids dropped, remaining clamped to maxDuration, stacks clamped to maxStacks — then republishes the active modifiers, so a reload never loses or leaks an effect.

Should boosts tick while offline?

update runs only when the simulation advances — so boosts do burn down during offline catch-up, matching the production they boost. If you want wall-clock-only boosts (premium items that shouldn't burn while away), grant/extend them from game code on loaded instead of letting catch-up run them down.

Options

OptionRequiredRole
definitionsThe boost catalog (BoostDef[])
getActive / setActiveThe active map in your state
registryModifierRegistry to publish effects to
onGrantAfter any grant (initial or re-grant)
onExpireAfter a timer/negative-extend expiry

Limits

  • No per-boost pause/freeze.
  • Registry integration is one-way: boosts publish, never read other sources.
  • Out of scope: rewarded ads, purchases, inventories, UI.
  • Economy can model activation tokens and transactions; it does not own boost timers or modifiers.