Project Description
A tarot deck reimagined with Pokémon — a static Astro site with a deterministic type→suit mapping algorithm, curated Major Arcana, build-time data pipeline, and multi-provider AI reading endpoint with template fallback.
Two ideas that shouldn't fit together: 386 Pokémon and 78 tarot cards. One is a Nintendo bestiary spanning the first three generations of the franchise, the other is a divination system codified in the late nineteenth century. The joy of the project was in the seam — figuring out an assignment rule that felt inevitable rather than arbitrary, so that when someone lands on their card and reads "Mewtwo — The Devil" the reaction is "of course."
Poké-Arcana is a static Astro site that reimagines the Rider-Waite-Smith tarot deck with Pokémon in every slot. Browse the deck, look up your card, draw a spread of 1, 3, or 10, and let an AI reader interpret the result. Everything about it is deterministic, thematic, and built at compile time — no PokéAPI calls at runtime, no server other than the AI reading endpoint.
Two Populations, Two Assignment Rules
The 78-card tarot splits into 22 Major Arcana — archetypes like The Fool, Death, The Tower — and 56 Minor Arcana across four suits (Cups, Wands, Swords, Pentacles), each running Ace through King. That structural asymmetry is what forced the assignment rule to be two rules:
- Minor Arcana is fully algorithmic. A common Pokémon has a type (or two), a base-stat total, and a Pokédex ID. Those three fields are enough to pick a suit and a rank with no human intervention.
- Major Arcana is hand-curated. 22 archetypes with legendary weight need to feel right — algorithmic assignment kept producing "Zapdos, The Hierophant" combinations that were technically valid and thematically dead.
Splitting the algorithm along the population boundary is the decision that made everything else possible. Trying to unify both under one rule kept producing either a broken minor system or a boring major system.
Types → Suits, With a Weighted Vote
There are 18 Pokémon types (Fire, Water, Grass, Fighting, Ghost, Fairy, and so on) and four tarot suits. The mapping is a lookup table with the elemental logic you'd expect: Water/Ice/Ghost/Fairy → Cups (spirit and enchantment); Fire/Fighting/Dragon → Wands (primal drive); Flying/Electric/Psychic/Poison/Dark → Swords (air and conflict); Ground/Rock/Grass/Steel/Normal/Bug → Pentacles (earth and material).
Pokémon commonly have two types. Charizard is Fire and Flying, and those types belong to different suits (Wands and Swords respectively). Rather than picking one arbitrarily, the resolver uses a weighted vote — primary type worth two votes, secondary type worth one, ties break in favor of the primary:
export function resolveSuit(types: TypeName[]): Suit {
const weights = { cups: 0, wands: 0, swords: 0, pentacles: 0 };
types.forEach((t, i) => {
weights[TYPE_SUIT_TABLE[t]] += i === 0 ? 2 : 1;
});
const max = Math.max(...Object.values(weights));
const primarySuit = TYPE_SUIT_TABLE[types[0]];
return weights[primarySuit] === max
? primarySuit
: SUITS.find((s) => weights[s] === max)!;
}
Charizard lands in Wands — the Fire vote (2) outweighs the Flying vote (1). Gyarados (Water/Flying) lands in Cups. Machamp (Fighting) lands in Wands. Every dual-typed edge case falls out of the vote naturally, and the algorithm never had to grow a special case for a specific Pokémon.
Rank as Base-Stat-Total Percentile
Once a Pokémon's suit is known, its rank within the suit (Ace, 2 through 10, Page, Knight, Queen, King) is its base-stat-total percentile within the population of Pokémon in that same suit.
The bottom 7% of Cups becomes the Ace of Cups (weakest in the suit — Magikarp lives around here). The top 7% of Cups becomes the King of Cups. Everything in between is a linear interpolation across the fourteen ranks.
This has a nice property: the rank isn't just some number, it's comparative within the suit's own population. A Gyarados isn't strong "in absolute terms" — it's strong compared to other Cups Pokémon, and that's what the rank encodes. The tarot rank system already leans on relative-position semantics ("court cards represent higher archetypes than pip cards"), so this maps cleanly.
The Major Arcana, Hand-Curated
Legendaries and mythicals don't get thrown at an algorithm. Twenty-two archetypes need twenty-two thematic matches:
export const MAJOR_ARCANA_ASSIGNMENT: Record<number, string> = {
151: 'The Fool', // Mew — origin of all Pokémon, boundless potential
150: 'The Devil', // Mewtwo — created as a weapon, bound by its own shadow
145: 'The Tower', // Zapdos — the storm bird struck by its own lightning
144: 'The Star', // Articuno — cold, clear, serene hope in the ice
146: 'The Sun', // Moltres — radiant, vital, phoenix-warm fire
249: 'The Moon', // Lugia — deep-sea guardian of dreams and mystery
250: 'Temperance', // Ho-Oh — rainbow bird blending fire, light, and color
382: 'Death', // Kyogre — primal sea force of destruction and renewal
383: 'The Emperor', // Groudon — continent-shaping authority and order
384: 'The World', // Rayquaza — the sky-spanning journey come full circle
// ...
};
Mewtwo → The Devil is the card the whole system needed to exist for. The Devil in Rider-Waite is not evil — it's bondage, self-imposed shadow, the potential to break free. Mewtwo is a lab-grown superweapon whose entire arc is about breaking the constraints of its own creation. The card and the Pokémon share a shape.
Same for Zapdos → The Tower: the archetype of sudden catastrophic upheaval, of lightning splitting stone. Or Rayquaza → The World: completion, the ouroboros, the sky-spanning journey home. Every entry has a comment explaining the thematic bridge because six months from now I won't remember why I picked what I picked, and future me deserves the reasoning.
One inconvenient detail: Gen 1–3 has 21 legendaries and mythicals, but tarot needs 22 Major Arcana slots. So Dragonite — technically a pseudo-legendary — gets promoted into the Major set as The Magician. It's the only exception to the "legendary → major, common → minor" split, and it's documented as such rather than hidden behind a boolean flag.
Build-Time Data, Not Runtime
The Pokémon data pipeline runs exactly once, in the local dev environment, and the output is committed to git.
bun run sync # fetch PokéAPI, write src/data/generated/
git add src/data/generated
git commit
At build time, astro build reads the committed JSON. No PokéAPI calls during the build. No PokéAPI calls in production. The site could stay live for a decade with PokéAPI offline and nothing would break.
This isn't performance optimization — the site is small enough that a build-time fetch would be fine. It's about determinism. Two different developers running bun run build on two different machines should get byte-identical output, and network-dependent builds don't have that property. The generated data is treated like source code: reviewed, versioned, tagged.
The .env file controls the Pokédex range with DEX_START / DEX_END. The current build covers Gen 1–3 (#1–386) because that's the smallest range where the legendary population is large enough to fill all 22 Major Arcana slots. Expanding to further generations is one line change plus a bun run sync. The Major Arcana map needs an update when the legendary population changes, and the algorithm throws loudly if a legendary is missing an entry — better a build failure than a silent "The Fool → The Fool" fallback.
Reading — With a Provider Chain That Refuses to Fail
The /reading page is the one interactive surface. Draw a spread, watch the shuffle animation, flip each card, and optionally ask an AI to interpret the layout.
The interpretation lives behind a Vercel Edge function that fans out across three providers — Gemini, Groq, and OpenRouter — in a chain with exponential backoff and automatic failover. The user's .env controls which providers are available:
- If all three keys are set, the chain tries Gemini first, then Groq, then OpenRouter.
- If only one is set, that's the only provider tried.
- If none are set, the endpoint returns a template-based reading that walks each card position ("past, present, future" for a three-card spread) with the card's Rider-Waite meaning, no AI required.
The template fallback matters for two reasons. First, the site should never appear broken to a visitor who has never set up an API key. Second, the template reading is honestly not bad — it's structurally similar to what a beginner tarot reader would give, and the AI reading is a "premium touch" rather than the entire feature.
Reading history is stored in localStorage, capped at the twenty most recent. Nothing about a reading leaves the browser except the anonymous AI request; there is no server-side account, no history sync, no telemetry.
What I Learned
I've built plenty of tools where the interesting problem was performance, or scaling, or resilience. Poké-Arcana was the first where the interesting problem was thematic coherence — an algorithm's output had to feel right to a reader who knows both Pokémon and tarot, or the whole project failed. That framing shaped every technical decision.
Three things I'd keep from this project:
-
Split the algorithm along the natural population boundary, not the technical one. Trying to unify Minor and Major Arcana under one rule produced worse output than accepting that they're two different problems. Two rules that each do one thing well beat one rule that does both badly.
-
Deterministic > "smart" when the output is user-facing. The type→suit table is embarrassingly simple. A machine-learning classifier could have generated the same table with some accuracy, and a lookup table is auditable, editable, and comprehensible in ten seconds. Choose boring when boring works.
-
Failover chains with a template floor. The AI reading has three providers and a template fallback because no chain has 100% availability. A "graceful degradation" that still produces something useful — not just an error page — is what separates a hobby project from a durable one.
Poké-Arcana is small on purpose. It ships a static site, one edge function, no database. But underneath the deck of cards is an assignment algorithm I genuinely enjoyed designing, and every card in the deck has a story you can point at.



