Appearance
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 readboostExt.get(state, "frenzy")?.stacksin the system for a per-stack multiplier. - Manual cancel:
boostExt.remove(engine.state, "frenzy")(noonExpirefires — 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
loadedfrom game code.