Skip to content

Pickups

pickups manages temporary, individually identified items the player can act on before they expire.

ts
import { createPickupsData, pickups } from "@idlekitjs/mechanics/pickups";

Mental model

A pickup item is not a reward by itself. Spawning an item does not grant resources. Rewards should be settled when the item is successfully taken, usually through the Economy bridge.

Each item has a stable instance id such as scrap#1, a type id, optional position, optional metadata and optional lifetime fields.

State

ts
interface State {
  pickups: PickupsData;
}

const state: State = {
  pickups: createPickupsData(42),
};

createPickupsData(seed?) creates the serializable sub-state:

  • active items;
  • the next id counter;
  • persisted RNG state;
  • scheduled spawn progress.

Passing a seed makes random positions deterministic across reloads and tests. Without a seed, the initial state is time-based.

Minimal example

ts
const pickupExt = pickups<State>({
  definitions: [
    {
      id: "scrap",
      lifetime: 20,
      spawn: { every: 4, max: 3 },
      position: (_state, random) => ({ x: random(), y: random() }),
    },
    { id: "relic" },
  ],
  getData: (state) => state.pickups,
  setData: (state, data) => {
    state.pickups = data;
  },
});

engine.use(pickupExt);

const item = pickupExt.spawn(state, "scrap");

Spawning

Manual spawning uses spawn(state, type, options?). It returns the created item or undefined for an unknown type.

ts
pickupExt.spawn(state, "scrap", {
  position: { x: 0.5, y: 0.25 },
  metadata: { quality: "rare" },
  lifetime: 10,
});

Scheduled spawning is defined on the pickup type:

ts
{
  id: "scrap",
  lifetime: 20,
  spawn: {
    every: 4,
    max: 3,
    when: (state) => state.population > 0,
  },
}

While when is false, no spawn time accumulates. Auto-spawned types must declare lifetime and/or spawn.max so offline catch-up remains bounded.

Lifetime and expiration

Items with lifetime have remaining seconds. update(state, dt) decrements remaining time, removes expired items and calls onExpire for expirations.

Items without lifetime do not expire.

visible(state) returns active view models. Timed items include lifetimeFraction, where 0 is fresh and 1 is expiring.

Acting on items

ts
const status = pickupExt.status(state, item.id);

if (status.kind === "ready") {
  const taken = pickupExt.take(state, item.id);
}
MethodBehavior
status(state, id)"ready", "unknown" or "expired".
take(state, id)Removes and returns a ready item, or undefined.
visible(state)Active items for rendering.
active(state, type?)Count active items by type or overall.

take does not grant anything by itself. It is the settlement primitive used by collect actions.

Economy adapter

The optional bridge lives at @idlekitjs/mechanics/pickups/economy.

ts
import { collectPickup, pickupAvailable } from "@idlekitjs/mechanics/pickups/economy";

pickupAvailable(pickups, itemId) is a requirement that the item exists and is ready.

collectPickup(pickups, options) returns a transaction. It checks pickup availability in preview, then takes the item in apply and credits any reward only after the transaction succeeds.

ts
const result = economy.execute(
  state,
  collectPickup(pickupExt, {
    itemId: "scrap#1",
    reward: [["currency:coins", 25]],
  }),
);

A failed requirement leaves the pickup in place and credits nothing.

Rendering

Use visible(state) to produce view models. With @idlekitjs/dom, bindEach can render one keyed element per pickup without importing gameplay code into the binding.

ts
import { bindEach } from "@idlekitjs/dom/bind-each";

renderer.addFrame(
  bindEach(container, {
    items: () => pickupExt.visible(engine.state),
    key: (item) => item.id,
    create: (item) => {
      const el = document.createElement("button");
      el.addEventListener("click", () => collect(item.id));
      return el;
    },
    update: (el, item) => {
      el.style.opacity = String(1 - (item.lifetimeFraction ?? 0));
    },
  }),
);