Appearance
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 expiresConsumers (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:
stacking | Re-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 linearly —value × N(frenzy at 3 stacks:+1.5);op: "mult"compounds —value ^ 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 UISemantics worth knowing:
grantthrows on an unknown id — definitions are static data, a typo is a bug.extendaccepts negative seconds; reaching ≤ 0 expires the boost (and firesonExpire). It returnsfalsewhen the boost isn't active.onExpire(id, state)fires on timer expiry and negative-extend expiry — not on manualremove(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;
applygrants 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
| Option | Required | Role |
|---|---|---|
definitions | ✓ | The boost catalog (BoostDef[]) |
getActive / setActive | ✓ | The active map in your state |
registry | ModifierRegistry to publish effects to | |
onGrant | After any grant (initial or re-grant) | |
onExpire | After 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.