Skip to content

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 declarative rewards lines (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.upgrade unless your game deliberately moves that upgrade into a custom transaction apply.