Skip to content

Temporary boost

A button that grants a 30-second frenzy, a live countdown, and clean expiry — the boosts mechanic without any modifier wiring (see boosted production for that half).

Wiring

ts
import { createEngine } from "@idlekitjs/core";
import { Renderer, bindText, bindVisible } from "@idlekitjs/dom";
import { createRafScheduler } from "@idlekitjs/browser/raf-scheduler";
import { boosts, type ActiveBoost } from "@idlekitjs/mechanics/boosts";

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

const renderer = new Renderer();
const engine = createEngine<State>({
  initialState: { cookies: 0, boosts: { active: {} } },
  renderer,
  scheduler: createRafScheduler(),
});

const boostExt = boosts<State>({
  definitions: [{ id: "frenzy", duration: 30, stacking: "extend", maxDuration: 90 }],
  getActive: (s) => s.boosts.active,
  setActive: (s, active) => {
    s.boosts.active = active;
  },
  onExpire: (id) => console.log(`${id} ended`),
});

engine.use(boostExt);

Consume it in a system

Without a registry, query the boost directly where it matters:

ts
engine.addSystem((state, dt) => {
  const multiplier = boostExt.isActive(state, "frenzy") ? 7 : 1;
  state.cookies += 1 * multiplier * dt;
});

Grant + countdown UI

ts
const grantEl = document.querySelector<HTMLButtonElement>("#grant")!;
const timerEl = document.querySelector<HTMLElement>("#timer")!;

grantEl.addEventListener("click", () => {
  boostExt.grant(engine.state, "frenzy"); // "extend" stacking: repeated clicks add time, cap 90 s
});

// `remaining` is decremented in place each tick -> per-frame binding
renderer.addFrame(
  bindText(timerEl, () => {
    const frenzy = boostExt.get(engine.state, "frenzy");
    return frenzy ? `Frenzy! ${Math.ceil(frenzy.remaining)}s` : "";
  }),
);
renderer.add(bindVisible(timerEl, () => boostExt.isActive(engine.state, "frenzy")));

engine.start();

Wait — bindVisible reads isActive, which reads the active map… whose entries mutate in place while the map only reassigns on grant/expire. That's exactly the split you want: visibility flips on the reactive grant/expire writes, while the countdown text updates per frame.

Variations

  • Refresh instead of extend: drop stacking/maxDuration — each grant resets to 30 s.
  • Stacks: { stacking: "stack", maxStacks: 5 } and read boostExt.get(state, "frenzy")?.stacks in the system for a per-stack multiplier.
  • Manual cancel: boostExt.remove(engine.state, "frenzy") (no onExpire fires — that's reserved for the timer).

What you get for free

  • Saving: the active map is plain JSON — mid-boost saves resume with the remaining time intact.
  • Offline: catch-up burns boost time consistently with the production it affects; if you'd rather premium boosts not burn while away, grant their remainder back on loaded from game code.