Skip to content
pokemontcgapi.com

tools

Build a Pokémon TCG deck builder

Filter on structured card data, hydrate a decklist in one request, and validate format legality against the legality data on each card.

Who this is for

You are building a deck builder, a decklist parser, or a tournament tool that has to say yes or no to a list and be right.

Plan you need

Everything here works on the free tier. The reference lists that populate filter dropdowns do not count against quota at all.

A deck builder has one hard requirement and one soft one. The hard requirement is correctness: if the tool says a list is legal and it is not, the user finds out at a tournament, which is the worst possible place. The soft one is browsing — filtering sixty thousand cards down to the twenty worth considering — and that is a query problem more than a UI problem.

Both are easier when card attributes are structured rather than embedded in prose. Energy costs are arrays, converted costs are integers, retreat cost comes both ways, damage is the string that is printed on the card because "100+" and "100×" are damage values too. Legality is per format with the date it took effect. A validator built on that is a few dozen lines; one built on parsing rules text is a permanent maintenance project.

The code

A decklist validator: hydrate every id in one request, then check format legality, the four-copy rule and the deck size. The card data does all the work the rules would otherwise require you to hardcode.

validate.js
const BASE = "https://api.pokemontcgapi.com";
const KEY = process.env.PTCG_API_KEY;

// decklist: [{ id: "sv3-125", count: 3 }, ...]
export async function validate(decklist, format = "standard") {
  const ids = decklist.map((line) => line.id);

  // Up to 100 ids per request; a 60-card deck is one call.
  const res = await fetch(
    BASE + "/v1/cards/batch?ids=" + ids.join(",") + "&include=legalities" +
      "&select=id,name,supertype,subtypes,rarity,regulation_mark",
    { headers: { "X-Api-Key": KEY } },
  );
  const { data, requested, found } = await res.json();
  const byId = new Map(data.map((card) => [card.id, card]));

  const problems = [];
  if (found !== requested) {
    for (const id of ids) if (!byId.has(id)) problems.push("Unknown card: " + id);
  }

  let total = 0;
  const byName = new Map();

  for (const line of decklist) {
    const card = byId.get(line.id);
    if (!card) continue;
    total += line.count;

    // An absent format row means the card was never legal there. Absence is
    // the answer, not a missing lookup.
    const legality = card.legalities?.find((l) => l.format === format);
    if (!legality || legality.status !== "LEGAL") {
      problems.push(card.name + " is not legal in " + format + ".");
    }

    // Four copies by NAME, not by id: two printings are still the same card.
    // Basic Energy is the documented exception.
    const isBasicEnergy =
      card.supertype === "Energy" && card.subtypes?.includes("Basic");
    if (!isBasicEnergy) {
      const seen = (byName.get(card.name) ?? 0) + line.count;
      byName.set(card.name, seen);
      if (seen > 4) problems.push("More than 4 copies of " + card.name + ".");
    }
  }

  if (total !== 60) problems.push("Deck has " + total + " cards, expected 60.");

  return { ok: problems.length === 0, problems };
}

Filters come from the data, not from a hardcoded list

Four reference endpoints return the closed vocabularies present in the catalogue: energy types, card subtypes, supertypes and printed rarities. Build your dropdowns from those and two failure modes disappear. A filter can never return zero results because the option does not exist, and a new subtype introduced by a new set appears in your UI without a deploy.

They are cached for a day and cost nothing against quota, so fetching them at startup is free. Everything else is a query: fielded terms, ranges, wildcards, negation and grouping, in a Lucene-style query grammar. A browse screen is one request with a query, a sort key and a field selection, and the field selection is what keeps a fifty-card grid from transferring a megabyte.

Hydrate the list in one request

A decklist is a set of ids and counts. Resolving it card by card is sixty requests for something that fits in one: the batch endpoint accepts up to 100 ids and answers with the cards plus a requested-versus-found count, which is how you detect a typo without diffing arrays yourself.

Ask for the fields the validator reads and include the legality relation. Legalities arrive as a list of format code, status and the date the status took effect, which matters for rotation: a card that rotated out has a status and a date, not a blank. The distinction lets you tell a user "this rotates on 2027-04-01" instead of just "not legal".

Validate against data, not against memory

Three rules cover most of a legality check. The deck is sixty cards. No more than four copies of a card with the same name, with basic energy exempted. Every card is legal in the chosen format. All three read directly off the card objects, which means your validator does not encode a rules snapshot that goes stale the day a set rotates.

The four-copy rule is worth care because it counts by name, not by id. Two different printings of the same Trainer are the same card for deck construction, and a validator that keys on id will happily approve eight copies split across two sets. Grouping by the name field is the whole fix, and it is the single most common bug in homemade deck checkers.

What usually goes wrong

Not a disclaimer list: these are the failure modes that cost people time on this specific integration.

Treating an absent legality row as unknown
A format with no row is a format the card was never legal in. That is an answer, not missing data — render it as "not legal" rather than as a loading state that never resolves.
Sorting collector numbers as strings
Collector numbers are strings because TG12 and SV107 exist, but sorting them as strings puts 100 before 2. Sort by the number key, which orders on the extracted numeric part first and lands cards where a collector expects them.
Fetching the whole card object for a grid
A browse grid renders five fields. Asking for forty and discarding thirty-five is bandwidth you pay for on every scroll, and on mobile it is the difference between smooth and not.

Endpoints used

  • GET/v1/cardsThe browse query: fielded search, multi-key sort, field projection.
  • GET/v1/cards/batchHydrate a whole decklist — up to 100 ids — in one request.
  • GET/v1/referenceEvery vocabulary for filters in one call: types, subtypes, supertypes, rarities.
  • GET/v1/setsBuild a set or block filter from release dates and series.

Related