tools
Build a Pokémon TCG deck builder
Filter on structured card data, hydrate a decklist in one request, and check the construction rules — deck size and the four-copy rule — against real card data. Format legality is the one thing this API does not carry yet.
Who this is for
You are building a deck builder, a decklist parser, or a list-sharing tool, and you need browsing and hydration to be one request each rather than sixty.
Plan you need
Everything here works on the trial. 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 fine and it is not, the user finds out at a tournament, which is the worst possible place. The soft one is browsing — filtering fifty 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. Attack costs are arrays, retreat cost comes as both the icons and the number, damage is the string printed on the card because "100+" and "100×" are damage values too. One thing is not there: format legality. The legalities object and the regulation mark are modelled and empty on every card today, so a validator built on this API can enforce the construction rules but not rotation, and the honest tool says so instead of guessing.
The code
A decklist checker: hydrate every id in one request, then check the deck size and the four-copy rule. Rotation is a table you own, keyed by set code, until the API carries legality data.
const BASE = "https://api.pokemontcgapi.com";
const KEY = process.env.PTCG_API_KEY;
// Rotation is yours to maintain: the API carries no legality data today, so a
// set-code allow-list is the honest source. Update it when a rotation lands.
const STANDARD_SETS = new Set(["sv8", "sv8pt5", "sv9", "sv10", "meg", "mep"]);
// decklist: [{ id: "sv3-125", count: 3 }, ...]
export async function check(decklist) {
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(",") +
"&select=id,name,supertype,subtypes,rarity,set_code",
{ headers: { "X-Api-Key": KEY } },
);
// requested and found sit next to data: diff without comparing arrays.
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;
if (!STANDARD_SETS.has(card.set_code)) {
problems.push(card.name + " (" + card.set_code + ") is outside your Standard table.");
}
// 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
One reference endpoint returns every closed vocabulary present in the catalogue — energy types, card subtypes, supertypes, printed rarities and the rest — in a single response. Build your dropdowns from it 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.
It is cached for a day, costs nothing against quota and needs no key, so fetching it 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. One caveat to design around: types, subtypes and attacks are populated on the Western printings and not on the Japanese and Chinese ones, so a filter on them is a filter on the Western catalogue.
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 reports unresolved ids in the optional `missing` array, including verified historical alternatives when available. The field is absent when every id resolves; repeated ids and valid aliases for the same card produce one card row.
Ask for the fields the checker reads and nothing else: id, name, supertype, subtypes and set_code are enough for the construction rules. The response carries requested and found next to data, so a typo in the list is a count mismatch rather than an array diff, and the missing ids are the ones absent from the response.
Check construction against data, rotation against your own table
Two rules read straight off the card objects. The deck is sixty cards. No more than four copies of a card with the same name, with basic energy exempted. Neither encodes a rules snapshot that goes stale, because the data they read is the card itself.
The third rule — is this card legal in Standard right now — is not one the API can answer today: the legalities object and the regulation mark are modelled and empty on every card, and a null there means "we do not hold it", never "not legal". Keep a set-code allow-list in your own configuration, name it as the source in the UI, and update it when a rotation is announced. When legality data lands in the catalogue it will appear on the same card object and in the changelog, and the allow-list becomes a fallback rather than the source.
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 checker 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.
- Reading legality off the card
- legalities and regulation_mark are empty on every card today. A checker that reads them will mark every deck illegal, or every deck legal, depending on which comparison you wrote. Keep rotation in a table you own until the changelog says the field carries rows.
- 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, and the set codes for your rotation table.