Skip to content
pokemontcgapi.com

bots

Build a Pokémon TCG Discord bot

A /card slash command that answers in under a second: search, disambiguate, and reply with an embed carrying the art, the rules text and a price.

Who this is for

You run a trading or deck-building server and want a bot that answers card questions faster than someone can alt-tab to a browser.

Plan you need

The free tier is generous for a bot: images cost nothing, and a cached lookup costs nothing either. A busy server rarely exceeds it.

A card bot lives or dies on latency and on getting the right printing. Nobody minds a bot that takes 200 milliseconds; everybody minds one that answers "Charizard" with a card from the wrong set, because in this game the set is most of the answer. Discord gives you three seconds to reply to an interaction before it expires, which sounds generous until a cold lookup, an image fetch and an embed build have all happened in series.

The design below fits comfortably inside that budget. Autocomplete narrows to a specific printing before the command is even submitted, so the handler receives an id rather than a phrase. The lookup asks for exactly the fields the embed renders. And because responses carry an ETag and the catalogue changes only when a set releases, an in-process cache turns almost every repeat lookup into a local read.

The code

The two handlers that matter: autocomplete, which turns typing into a card id, and the command itself, which builds the embed. Both use a small in-process cache keyed by URL.

card-command.js
import { EmbedBuilder } from "discord.js";

const BASE = "https://api.pokemontcgapi.com";
const KEY = process.env.PTCG_API_KEY;
const cache = new Map(); // url -> { etag, body }

async function api(path) {
  const hit = cache.get(path);
  const headers = { "X-Api-Key": KEY };
  if (hit) headers["If-None-Match"] = hit.etag;

  const res = await fetch(BASE + path, { headers });
  // A 304 costs no quota and no database work upstream. Reuse what we have.
  if (res.status === 304 && hit) return hit.body;

  const body = await res.json();
  const etag = res.headers.get("etag");
  if (res.ok && etag) cache.set(path, { etag, body });
  return body;
}

// Autocomplete: the user picks a printing, so the command never has to guess.
export async function autocomplete(interaction) {
  const typed = interaction.options.getFocused();
  if (typed.length < 2) return interaction.respond([]);

  const q = encodeURIComponent('name:"' + typed + '*"');
  const { data } = await api(
    "/v1/cards?q=" + q + "&orderBy=-release_date&limit=25" +
      "&select=id,name,number,set_name,release_date",
  );

  await interaction.respond(
    data.map((card) => ({
      name: card.name + " · " + card.set_name + " #" + card.number,
      value: card.id,
    })),
  );
}

export async function handleCard(interaction) {
  const id = interaction.options.getString("card", true);
  const card = await api("/v1/cards/" + id + "?include=images,prices,legalities");

  const art = card.images?.find((i) => i.face === "FRONT" && i.size === "NORMAL");
  const price = card.prices?.find((p) => p.variant === "INDEX");

  const embed = new EmbedBuilder()
    .setTitle(card.name + " — " + card.set_name + " #" + card.number)
    .setURL("https://pokemontcgapi.com/cards/" + card.id)
    .addFields(
      { name: "Rarity", value: card.rarity ?? "—", inline: true },
      { name: "Types", value: card.types?.join(", ") || "—", inline: true },
      { name: "HP", value: String(card.hp ?? "—"), inline: true },
    );

  if (art) embed.setImage(art.url);

  for (const attack of card.attacks ?? []) {
    embed.addFields({
      name: attack.name + "  " + (attack.damage || ""),
      value: attack.text || attack.cost.join(" "),
    });
  }

  if (price) {
    // Attribution belongs in the footer, next to the number it describes.
    embed.setFooter({
      text: price.price + " " + price.currency + " · " + price.provenance +
        " · " + price.as_of,
    });
  }

  await interaction.reply({ embeds: [embed] });
}

Autocomplete is the disambiguation step

Discord autocomplete fires as the user types and accepts up to 25 choices. That is exactly the right place to resolve "charizard" into one of the many Charizards, because the user is the only one who knows which they mean. Show the set name and collector number in the label and put the card id in the value, and your command handler never sees an ambiguous string.

Sorting by release date descending puts the printings people are most likely asking about at the top, since current-block cards dominate real questions. Trim the payload with a field selection: the autocomplete list needs five fields, and asking for the whole card object to render five fields wastes bandwidth on every keystroke.

Cache with ETags, not with timers

A card object changes when a set releases or a correction lands — not on a schedule you can guess. That makes time-based expiry a bad fit: too short and you refetch constantly, too long and you serve stale rules text after an errata. Conditional requests solve it exactly: keep the ETag, send it back as If-None-Match, and the server answers 304 when nothing changed.

A 304 does not consume quota, so a bot that revalidates aggressively costs about the same as one that never checks. In practice a Map keyed by request path, holding the ETag and the parsed body, is the whole implementation. Restart the process and the first lookup of each card pays full price again, which is fine — that is a handful of requests, not a rate-limit problem.

Respect the interaction budget

If a command might take longer than three seconds, defer the reply first and edit it afterwards. In practice a cached lookup returns in single-digit milliseconds and a cold one well under a hundred, so deferring is a safety net rather than the normal path. What does blow the budget is chaining requests: one call to search, another to fetch, another for prices, another for images.

Do it in one instead. The lookup endpoint takes an include parameter, so images, prices and legalities arrive with the card in a single round trip. Rate limits ride on every response as headers, so a bot can read what it has left and back off before it gets a 429 rather than after.

What usually goes wrong

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

Searching on every keystroke without a floor
Require at least two characters before issuing a query. A one-character search matches thousands of cards, returns the least useful 25 of them, and burns a request for every letter of every word anybody types.
Hotlinking images through your own process
Put the image URL in the embed and let Discord fetch it. Proxying the bytes through your bot adds latency, adds bandwidth cost, and gains you nothing — image requests do not count against quota anyway.
Dropping the attribution
If you show a price, show its provenance and its date. It fits in an embed footer, it is the condition under which the data is redistributable, and it is the difference between a number and a rumour.

Endpoints used

  • GET/v1/cardsAutocomplete search with a fielded query and a trimmed field set.
  • GET/v1/cards/{id}One round trip for the card, its images, prices and legalities.
  • GET/v1/setsPopulate a set filter, or answer "what is in the current block".
  • GET/v1/referenceEvery vocabulary in one call — rarities, types, subtypes, supertypes — for filter dropdowns that cannot return zero rows.

Related