Appearance
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);
}| Method | Behavior |
|---|---|
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));
},
}),
);