Appearance
Collections
The gacha + upgrade hub: packs the player opens, collectibles (cards, artifacts, pets...) with levels and duplicates, and effects that feed the modifier registry while active. Draws use a seeded, persisted PRNG — reproducible across reloads and immune to save-scumming.
ts
import { collection } from "@idlekitjs/mechanics/collections";Mental model
- Rarities define drop weights and shared upgrade defaults.
- Collectibles belong to a rarity, carry
effects(level-scaled modifiers) and an upgrade economy (duplicates + currency costs per level). - Packs define what a pull yields — either the legacy rarity-weighted
draws, or declarativerewardslines (cards and/or currency). - The player opens packs → accumulates copies → spends copies + currencies to level collectibles up → leveled collectibles publish stronger effects.
Minimal example
ts
import { collection } from "@idlekitjs/mechanics/collections";
import { modifiers } from "@idlekitjs/mechanics/modifiers";
const modifiersExt = modifiers<State>();
const cards = collection<State>({
rarities: [
{ id: "common", weight: 90 },
{ id: "rare", weight: 10, defaults: { duplicates: (level) => 2 + level, maxLevel: 5 } },
],
collectibles: [
{
id: "comrade-power",
rarity: "common",
effects: [
{
target: { kind: "tag", tag: "producer" },
stat: "yield",
op: "mult",
value: (level) => 1 + 0.1 * level,
},
],
},
],
packs: [{ id: "basic", draws: 3 }],
currencies: [
{
id: "science",
get: (s) => s.science,
spend: (s, amount) => {
s.science -= amount;
},
},
],
registry: modifiersExt.registry,
getData: (s) => s.collection,
setData: (s, data) => {
s.collection = data;
},
});
engine.use(modifiersExt).use(cards);Opening packs
ts
const result = cards.openPack("basic", engine.state);
for (const reward of result.rewards) {
if (reward.kind === "card") {
// reward.collectibleId, reward.quantity, reward.wasNew,
// reward.previousQuantity -> newQuantity, reward.canUpgradeAfter
} else {
// reward.kind === "currency": reward.currencyId, reward.amount
}
}Each reward line carries everything a reveal animation needs — including whether this was the first copy ever (wasNew) and whether the card became upgradeable (canUpgradeAfter).
Packs can gate themselves (eligibility.openable); an ineligible open returns an empty reward list. An unknown pack id throws — that's a static-data bug, not a game state.
Declarative rewards replace the legacy draw model when present:
ts
{
id: "premium",
rewards: [
{ kind: "cards", count: { min: 3, max: 5 }, rarityWeights: { rare: 30, common: 70 } },
{ kind: "cards", count: 1, rarity: "rare" }, // guaranteed rare
{ kind: "currency", currency: "science", amount: { choices: [
{ value: 10, weight: 9 }, { value: 100, weight: 1 },
] } },
],
}Upgrading
canUpgrade returns a UI-ready check — requirements plus the blocking reason, never an exception:
ts
const check = cards.canUpgrade("comrade-power", engine.state);
// check.requirements: { level, nextLevel, duplicatesRequired,
// duplicatesAvailable, costs: [{ currency, required, available }] }
if (check.ok) {
cards.upgrade("comrade-power", engine.state); // spends copies + currencies, levels up
} else {
check.reason; // "not-owned" | "max-level" | "not-enough-duplicates"
// | "not-enough-currency" | "not-eligible"
}Level 0 means owned-but-inactive: effects publish only from level 1 up.
Economy adapter
The optional Economy bridge lives at @idlekitjs/mechanics/collections/economy:
ts
import {
cardLevelBelow,
cardResources,
cardShardResourceId,
} from "@idlekitjs/mechanics/collections/economy";cardResources(collectibles, accessors) declares two resources per card:
card:<id>:shards— writable integer shards/copies;card:<id>:level— read-only integer level for requirements and display.
It does not duplicate the collection upgrade lifecycle. Keep levels and pack drops owned by collections; use Economy when a surrounding action needs requirements, multi-resource cost, reward formatting or transaction diagnostics.
ts
const result = economy.execute(state, {
id: "upgrade-card:comrade-power",
requirements: [cardLevelBelow("comrade-power", 5)],
cost: [
[cardShardResourceId("comrade-power"), 10],
["currency:science", 500],
],
apply: (state) => {
levelCardInCollection(state, "comrade-power");
},
});Use unprefixed business ids with cardResources. If a collectible id already starts with card:, passing it directly produces ids like card:card:red-star:shards.
Effects and the registry
Effect values are curves of the level — the same shape as a Modifier, with value: (level) => number:
ts
effects: [
{
target: { kind: "id", id: "farm" },
stat: "speed",
op: "mult",
value: (level) => 1 + 0.05 * level,
},
];Active collectibles publish under collectible:<id>; upgrades republish at the new level; the loaded event rebuilds everything from the saved data (rebuildModifiers is also public if you change eligibility conditions mid-game).
State & saving
Everything runtime lives in one serializable slice:
ts
interface CollectionData {
collectibles: Record<string, { quantity: number; level: number }>;
rngState: number; // persisted PRNG -> reproducible drops
}Seed the first pull with the seed option (default: time-based); after that the PRNG state travels with the save. onChange fires after every mutating operation — the natural place to trigger a save.
Eligibility gates
Per collectible: visible (shown in UI), droppable (can drop), active (effects apply), upgradeable (can level). Per pack: visible, openable. All default to permissive — you only write the gates you need. Saturated collectibles (max level reached, enough duplicates banked) stop dropping automatically, so packs never waste pulls.
Limits
- No pity timers or guaranteed-after-N-pulls logic (compose it game-side with a guaranteed-rarity reward line).
- No trading/dismantling economy — copies only flow in and get spent on levels.
- The Economy adapter exposes shards and levels; it does not replace
collection.upgradeunless your game deliberately moves that upgrade into a custom transactionapply.
Related
- Modifiers — where effects land.
- Economy adapters.
- Mechanics API — collections.