Searching Pokémon cards by what they do

Published on - Updated on 12 min read


Searching Pokémon cards by what they do

I wanted to type “gust” into a search box and get every card that gusts, whether or not the card says so. So I built a multi-modal search that combines an LLM, embeddings, keywords, and real deck data. It only took a little tuning.

This post is about how card search on PokeDojo works, and the tuning that got it there.

Wait, what’s a gust?

If you don’t play the Pokémon TCG, here’s the 30 second version. Each player has one Active Pokémon doing the fighting and up to five on the Bench waiting their turn. Trainer cards (Items, Supporters, Stadiums) do things for you, and many Pokémon have Abilities that do things too. Players name cards by what they do: a gust card drags your opponent’s Benched Pokémon into the Active spot so you can knock it out. Draw cards draw. Heal cards heal.

The problem is the cards don’t use those words. Here’s Crispin, a card every player would call energy acceleration:

Search your deck for up to 2 Basic Energy cards of different types, reveal them, and put 1 of them into your hand. Attach the other to 1 of your Pokémon. Then, shuffle your deck.

No “accelerate” anywhere. Search for it by keyword and you’ll never find it. And that’s the question players actually ask when building a deck: what else does this job?

The big picture

Two halves. An offline classifier labels every legal card with what it does, and an online search turns your question into filters and a ranked list.

flowchart LR
  subgraph Ingest["Ingest (daily)"]
    T[TCGdex API] --> BQ[(BigQuery raw)]
    BQ --> N[Normalizer]
    N --> C[(Firestore cards)]
  end
  subgraph Index["Indexer (daily, gap-filling)"]
    C --> CL[Gemini classifier]
    C --> EM[text-embedding-005]
    CL --> CF[(Firestore card_functions)]
    EM --> GCS[(GCS embeddings JSON)]
  end
  subgraph Query["Query (per search)"]
    Q[User query] --> P[Gemini parser]
    P --> R[Hybrid retrieval + fusion]
    CF --> R
    GCS --> R
    C --> R
    R --> UI[Ranked results]
  end

Card data comes from TCGdex, lands raw in BigQuery, and gets normalized into a Firestore cards collection. That’s about 3,000 Standard-legal cards, and everything below reads from there.

Tags? No, verbs.

The first decision was to not use tags. A tag like draw doesn’t tell you whether it’s an Ability you get every turn or a Supporter you get once. So a card function is a small relation instead:

function × target × source × scope, plus strength and confidence

Boss’s Orders is gust on opponent-pokemon from a supporter. Budew is lock on item from an attack. There are 24 verbs: draw, tutor, accel, gust, pivot, lock, heal, snipe, spread, and so on.

It started at 16. Remember that, it comes back.

Definitions are data

Every verb is defined in code. What it means, which sources and targets it allows, and real cards that are textbook examples:

{
  verb: "gust",
  definition:
    "Force an opponent's Pokémon (usually from the bench) into the Active spot. " +
    "NEVER for effects that merely damage the bench.",
  examples: ["Boss's Orders", "Prime Catcher", "Counter Catcher", "Hariyama"],
  sources: ["supporter", "item", "ability", "attack"],
  targets: ["opponent-pokemon"],
  scopes: ["bench-to-active"],
},

That one table gets rendered into the classifier prompt, rendered into the search parser prompt, and used to validate what the model sends back. Three consumers, one definition. If they lived in two prompts and a validator, they’d drift by Tuesday.

Classification

The classifier sends each card’s name and full rules text to gemini-2.5-flash and asks for JSON. Empty is a valid answer. A Basic Energy has no function, and I’d rather store nothing than let the model get creative.

Then nothing the model says is trusted until code checks it:

flowchart TD
  CARD[Card text: name, effect, abilities, attacks] --> LLM[gemini-2.5-flash + taxonomy prompt]
  LLM --> DEC{Schema decode}
  DEC -- invalid --> FAIL[Record failure, continue run]
  DEC -- ok --> V[Validate each entry]
  V --> V1{Known verb and source?}
  V1 -- no --> DROP[Drop entry]
  V1 -- yes --> V2{Source and target allowed for verb?}
  V2 -- no --> DROP
  V2 -- yes --> CLAMP[Clamp strength and confidence to 0..1]
  CLAMP --> PRI[Highest strength becomes primary]
  PRI --> MAN{Existing manual tag?}
  MAN -- yes --> KEEP[Keep manual]
  MAN -- no --> SAVE[(card_functions, classifiedBy llm)]

No gust from a Stadium. No heal targeting your opponent’s deck. Hand-corrected entries are marked manual and a re-run never overwrites them.

New sets keep coming, so this can’t be a one-time script. A daily indexer finds cards missing a classification or embedding and fills only those gaps, 8 classifications at a time and embeddings in batches of 128. The first version re-ran the whole pool on demand, which cost money every time and left new cards unsearchable until I remembered to kick it off. I did not always remember.

Embeddings, and no vector database

Every card also gets a 768-dimension embedding from Vertex AI’s text-embedding-005. The text is just the card flattened out: name, stage, effect, abilities, attack effects. Attack costs and damage numbers are left out on purpose. “120 damage” and “130 damage” aren’t meaningfully close in embedding space, and filters handle numbers better anyway.

Where do the vectors live? A JSON file in Cloud Storage. Seriously.

for (const [cardId, vec] of vectors) {
  scored.push({ cardId, score: cosineSimilarity(queryVector, vec) });
}
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, Math.max(0, topK));

The server loads it once and brute-forces cosine similarity over every card. Three thousand vectors is a few megabytes and the scan takes milliseconds. A vector database would add infrastructure, cost, and approximate results to save time I don’t need to save. If the pool were millions of cards, sure. It isn’t.

The LLM reads the question

Your query goes to gemini-2.5-flash with the same verb definitions. It comes back as:

  • functions: verbs like heal or lock:item
  • attributes: category, type, stage, HP, retreat cost
  • residual text: whatever’s left, for semantic and keyword matching
  • tilt: is this query about meaning, exact words, or a function?

The parser has one strict rule: only filter on what you explicitly asked for. “Heal a big ex” wants a healing card. The “ex” is the thing being healed. If the parser filtered to Pokémon ex, every healing Item and Supporter would disappear.

Code checks the LLM’s homework

LLMs are good at intent and sloppy about specifics. So anything a regex can read, a regex reads, and it overrides the model:

// A user who explicitly names a card kind ("pokemon", "supporter", …) is
// unambiguous, so extract it deterministically rather than trust the LLM.
const explicitCategory = extractExplicitCategory(text);
const category = explicitCategory ?? normalizeCategory(parsed.attributes.category);

Same for “water-type” versus “water energy attack,” which are different questions the model would happily mix up.

Four signals walk into a ranker

Then four signals score the candidates:

flowchart TD
  Q["water pokemon that draw"] --> P[Gemini parser + deterministic overrides]
  P --> F["Hard facets: category=pokemon, type=water, fn=draw"]
  P --> V["Residual text: that draw"]
  P --> TL["Tilt: structured"]
  V --> D["Dense: embed, cosine top 200"]
  V --> S["Sparse: token overlap top 200"]
  F --> CAND{"Facets present?"}
  D --> CAND
  S --> CAND
  CAND -- yes --> POOL["Candidates: whole legal pool, filtered (cached 1h)"]
  CAND -- no --> MERGE["Candidates: dense + sparse, up to 500"]
  POOL --> SCORE["Score each candidate: dense, sparse, structured (function tags), usage (play rate)"]
  MERGE --> SCORE
  TL --> FU{"Weighted fusion by tilt"}
  SCORE --> FU
  FU --> SORT["Sort: fused, play rate, tie-breaks"]
  SORT --> DEDUP[Latest printing per name]
  DEDUP --> OUT[Results]
SignalWhat it measures
DenseEmbedding similarity between the query and the card
SparseHow many query words appear in the card text
StructuredWhether the card’s function tags match the asked verb
UsageHow often the card shows up in real tournament decks

The default blend is dense 0.4, structured 0.3, sparse 0.2, usage 0.1. Here’s my favorite part though. When the query is about a function, text similarity gets kicked out of the score completely:

case "structured":
  return { dense: 0, sparse: 0, structured: 1, usage: base.usage };

Why? Search “draw” and a Supporter that says “draw 3 cards” matches the word perfectly. Drakloak’s Recon Directive says “look at the top 2 cards of your deck and put 1 of them into your hand.” That’s draw too, it just doesn’t say so. Both are draw cards, so both get the same score, and play rate decides the order. That’s the order you want: the stuff people actually play, first.

When there are hard filters, they run against the whole legal pool, not the top 200 results. Filter after retrieval and you get 3 water Pokémon that draw when there are 30.

The parse result also gets baked into the URL, so reloading, going back, or sharing a link doesn’t call Gemini again:

sequenceDiagram
  participant B as Browser
  participant L as Route loader
  participant G as Gemini parser
  participant S as Search
  B->>L: GET ?q=water pokemon that draw
  L->>G: parse(q)
  G-->>L: facets, residual text, tilt
  L-->>B: 302 to ?q=...&pcat&pfn&ptype&t&v&m=1
  B->>L: GET materialized URL
  L->>S: search(facets, residual, tilt)
  S-->>B: ranked results
  Note over B,L: Reload, back, and shared links start here. No Gemini call.

Okay, so what broke?

Fezandipiti is not a gust card

My first real gust search came back with 19 results. Every one of them scored exactly 0.500, Prime Catcher (one of the most-played gust cards in the game) wasn’t one of them, and Fezandipiti ex was. Here’s its attack, Cruel Arrow:

This attack does 100 damage to 1 of your opponent’s Pokémon. (Don’t apply Weakness and Resistance for Benched Pokémon.)

Opponent’s Pokémon. Benched. Attack. The model saw the right words, filled in the rest, and tagged it gust. With high confidence, I might add.

The first prompt only listed the 16 verb names, no definitions. The model had no word for “hit one Benched Pokémon,” so it grabbed the closest one. Checking allowed sources wouldn’t have caught it, because gust from an attack is legit (that’s Hariyama). The fix was giving it the missing concept: a new snipe verb that says “NOT gust,” and a gust definition that says “NEVER for effects that merely damage the bench.” Most of the verbs past 16 got added the same way, one wrong tag at a time.

Two things I took away. Confidence is not correctness. The bad tag had high confidence. And fix the data before the ranking. I reclassified everything before touching retrieval, because tuning a ranker over wrong labels just makes wrong answers look more convincing.

Everyone’s a winner at 0.500

The tie was a separate bug. When the parser returned no residual text, a fallback quietly turned off dense and sparse retrieval. Every card was left with only its function score, which was the same for all of them. Hence 19 cards at exactly 0.500 and a top result picked by document ID.

Now fusion only averages the signals that are actually there, and every sort ends in a chain of deliberate tie-breaks: play rate, then text similarity, then tag strength, newer printing, and name.

The filter that wouldn’t leave

The first filter UI combined what the parser found with what you clicked. Search “water,” click Fire, get water or fire. Then filters started following you to the next search. Range filters were worse: you click HP 200 to 300, the parser reads “HP 60 or more,” and you get 60 to 300. Your narrowing just… evaporated.

The rule now is simple: the text wins. If your query says something about a filter, it replaces whatever you clicked for that filter. Clicks only add filters the query doesn’t mention.

I also built a button to dismiss a single parsed filter, then deleted it. Dismissing type: water widened the results from 6 cards to 67, but the top of the list barely moved, because “water” was still in the embedded text and was now the only thing ranking the results. The button looked broken, because it kind of was. Clearing the search box turned out to be the honest exit: the word leaves both the filters and the ranking, and your clicks stick around.

Where it landed

Here’s what search returns today, top results only:

QueryWhat the parser didTop results
gustgust on opponent’s Pokémon, structured tiltBoss’s Orders, Prime Catcher, Hariyama, Roto-Stick, Hop’s Dubwool, Pokémon Catcher
heal a big exheal on damage, no “ex” filter, dense tiltWally’s Compassion, Jumbo Ice Cream, AZ’s Tranquility, Ludicolo, Super Potion, Cook
water pokemon that drawdraw + Pokémon + Water type, structured tiltSeaking, Froakie, Keldeo, Quaquaval, Panpour, Frosmoth (all 6 matches)

The gust cards everyone actually plays come first. Prime Catcher and Hariyama are back. Fezandipiti isn’t on the list. “Heal a big ex” leads with Wally’s Compassion, which heals all damage from a Mega Evolution Pokémon ex, without filtering out the Items and Supporters that heal everything else. And “water pokemon that draw” returns exactly six cards, with no padding from cards that only kind of match.

Go break it: pokedojo.pro/toolbelt/card-search. Try a function (“lock items”), a vibe (“what accelerates energy for three colors”), or a mix of filters and text. Every search lands on a shareable URL, so if you find a query that ranks something wrong, send me the link.

Conclusion

The LLM wasn’t the hard part. What made the results trustworthy was everything around it: a 24-verb vocabulary with written definitions, code that validates every tag, a brute-force vector scan instead of a database, four signals with explicit weights, and tie-breaks I chose on purpose.

Every bug in this post came from trusting the model to fill a gap I hadn’t defined. It invented a gust because it had no word for snipe. Ranking collapsed into a tie because no rule said what to do when signals were missing. Filters stuck around because nobody had decided whether text or clicks win. The fixes were never a better model. They were definitions, validation, and deterministic rules. If you’re building search on top of an LLM, start by writing down exactly what your words mean. The model can only be as precise as you are.