Skip to main content
← All projectsKylan Thomson

Generative Video · Game Engines · Google Cloud

Pokémon TCG Live Attacks

A playable digital Pokémon Trading Card Game where a deterministic rules engine decides every attack first, and a generative video pipeline animates exactly that outcome — so the clip can never contradict the board.

Role
Sole engineer — rules engine, generation pipeline, online play, and deployment
Status
Deployed on Cloud Run
Period
2026 – present
20,444
Cards across 174 sets
67%
Of 27,927 attacks resolve exactly
3
Generative models per clip
0
Calls to Math.random

Overview

What it does

Pokémon TCG Live Attacks is a complete digital Pokémon Trading Card Game — 20,444 cards from 174 sets, playable against three tiers of CPU or online — where every attack produces a short AI-generated video of that attack landing. The ordering is the whole design: the rules engine resolves damage, weakness, coin flips, and knockouts before a single token of the prompt is written, so the video describes a decided outcome.

The hard part is not calling a video model. It is making 28,000 printed attacks resolve faithfully, keeping two players and a server in agreement without trusting any of them, and hiding a minute of generation latency behind work that is already done. Most of the engineering is a deterministic engine, cache-warming that starts the moment a card reaches a hand, and prompts that are honest about what the board decided.

It runs on Google Cloud: Next.js on Cloud Run, live match state in Firestore so any instance can serve either player, API keys mounted from Secret Manager, and a Gemini director that authenticates as the service account so there is no key to leak.

Architecture

One request path, three generative models

The browser talks to Next.js route handlers on Cloud Run. Online matches are server-authoritative: the engine applies every action on the server and streams a redacted snapshot back over Server-Sent Events, while the same engine runs in the browser for matches against the CPU. Generation hangs off the resolved attack — portraits from an image-edit model, choreography from a Gemini director, the clip from fal.ai — with every result cached in Firestore so nothing is generated twice.

Pokémon TCG Live Attacks An architecture diagram generated by Archify. Match UI · React 19 · MatchScreen · Architecture component Match UI React 19 · MatchScreen Rules engine · pure reducer · seeded RNG · Cloud Run · us-central1 · shared by client + server Rules engine pure reducer · seeded RNG shared by client + server Route handlers · Next.js 16 · REST + SSE · Cloud Run · us-central1 · 20,444 cards read from disk Route handlers Next.js 16 · REST + SSE 20,444 cards read from disk Generation · keyframes · prompt · clip · Cloud Run · us-central1 · ffmpeg watermark Generation keyframes · prompt · clip ffmpeg watermark Firestore · matches · portraits · jobs · Cloud Run · us-central1 Firestore matches · portraits · jobs Gemini director · gemini-3.8-flash · ADC · Architecture component Gemini director gemini-3.8-flash · ADC fal.ai · portraits · video clips · Architecture component fal.ai portraits · video clips Card art CDN · images.pokemontcg.io · Architecture component Card art CDN images.pokemontcg.io actions · animate applyAction vs CPU: in-browser resolved attack queue · poll direction, cached jobs · clips liveMatches fetches card art Cloud Run · us-central1 Legend Frontend Backend Database External

Pipeline

Six-stage pipeline

01Warm02Resolve03Compose04Generate05Play06Apply
  1. 01

    Warm

    The moment a Pokémon reaches a hand, its scene portraits and its Gemini direction start generating in the background — turns before either is needed.

  2. 02

    Resolve

    The engine settles damage, weakness and resistance, every coin flip, any condition, and whether the defender is knocked out, before any prompt exists.

  3. 03

    Compose

    Keyframes and a prompt are built from the resolution: the attacker's battle portrait opens the clip and the defender's standing or fainted portrait closes it.

  4. 04

    Generate

    The request is queued with fal.ai, polled, watermarked with the card being played, and stored. The job is persisted, so a redeploy cannot lose it.

  5. 05

    Play

    Coin flips are revealed on every screen in the same order, then the clip plays in the overlay while the board holds still.

  6. 06

    Apply

    Only after the video does the engine apply the outcome, so what the panel promised, what the clip showed, and what the board does can never disagree.

Approach

How it works

Resolve first, animate second

Every attack is decided by the rules engine before the prompt is written: exact damage, whether weakness applies, how each coin landed, and whether the defender is about to faint. The preview shown in the attack panel and the real resolution share one pipeline, so what the UI promises and what the board does are the same computation. The video is a rendering of a settled fact, never a source of truth.

A rules engine with no randomness of its own

The engine is a pure reducer — same state plus same action always yields the same next state — because every shuffle and coin flip is drawn from the match seed plus a cursor stored on the state. That one decision makes replays exact, lets the server and both clients agree by exchanging only actions, and is what allows a prompt to describe a coin flip that has already landed.

Printed card text parsed into structure

Hand-coding 28,000 attacks was never an option, so effect text is parsed into runnable structure. A matcher may only claim a sentence it has read in full — a leftover "unless" or "for each" it does not understand fails the whole attack rather than landing an effect unconditionally. The result is that 67% of attacks across every generation resolve exactly, and the rest deal printed damage while the UI says so.

Two portraits per Pokémon, cached forever

Each Pokémon gets a battle portrait and a fainted portrait, edited from its card art under a fixed composition and lighting contract so any two cut together as the same camera in a different place. The fainted one is edited from the battle one rather than from the card, which is why the knockout keyframe shares its environment. Generated once per prompt version and stored, so an attack declared turns later is two cache reads.

A director that reads the card

One Gemini pass per attack reads the whole card — species, flavour text, energy cost, printed damage, rules text, and the illustration itself — and writes the choreography, charge-up, impact, camera move, and how both names should be pronounced. It is cached per card and attack for the life of the app, warmed alongside the portraits, and entirely optional: without it the prompt composes from deterministic regex tables, exactly as the app worked before.

Server-authoritative online play

In an online match the server owns the state and applies every action, so a client cannot fake a legal move. Each subscriber receives a snapshot redacted for its own seat — the opponent's hand, both decks, and both prize piles become placeholder ids that are absent from the card map. Turn clocks and inactivity deadlines are absolute timestamps in the match document, so whichever instance looks next enforces them.

Hiding latency behind work already done

Video generation takes tens of seconds, so the pipeline makes sure everything except the clip is finished before an attack is declared: portraits and directions warm when a card reaches a hand, the likely opening attack is queued before it is chosen, and the CPU's repeat attacks can reuse an identical earlier clip a few times. A per-player monthly budget caps generated seconds, and a text-only mode never generates at all.

Honest about what is simulated

Nothing is hidden. The attack panel labels an unparsed effect, every deck carries a coverage score in the builder and the list, and an unsimulated attack downgrades its knockout prediction from "certain" to "possible" rather than overclaiming. A game that is 67% exact and says so is more useful than one that pretends to be complete.

Rigor

Measuring it honestly

Hundreds of games as the safety net

A CPU-vs-CPU simulator plays a few hundred matches in about a second and covers far more branches than clicking through the UI. It is what surfaced the rulebook's corners — a double knockout on the last prize, the opponent drawing for every mulligan, nobody evolving on their own first turn — each of which then became an engine test.

Coverage measured, ratcheted, and shown

A coverage script parses every attack in the pool and reports, by era, how many resolve exactly. Fixtures pin the parse of about a hundred texts plus the totals, and the test suite fails if any attack parses differently or coverage slips. The fixtures are a snapshot of what the parser does, not of what is right — after an intentional change, the regenerated diff is the review.

Motion that matches the card

The first version turned "Tail Whip" into a generic slap with no tail in it. Attack names are now read as an instrument plus an action and composed into a clause, which gives 59% of all 27,927 attacks a motion that matches what the card says; the rest are genuinely abstract names and fall back to an elemental strike.

Verified, not reported

A daily-challenge score is replayed through the engine on the server rather than trusted from the client, achievements are detected by replaying the match, and sealed and draft pools are pure functions of the set and a seed so the server re-derives them. Where the client could lie, the engine checks.

Trade-offs

Why it's built this way

The video never changes the game

Generative models are unreliable narrators, so the clip is treated as a rendering of a decided outcome and the board only changes after it plays. When generation fails or a budget is spent, the attack still resolves and the two keyframes play with a caption. The game does not depend on the model; the model decorates the game.

Bundle the card database, don't call the API

The canonical dataset is normalized into one 17 MB file read from disk at runtime, never imported into a bundle. That keeps the game deterministic and immune to the public API's rate limits and outages, and the browser gets a compact search index so the deck builder stays instant over 20,444 cards.

One Firestore document per live match

Everything a match needs to continue lives in one document, so any Cloud Run instance can serve either player and a redeploy does not end the game. The cost is that an instance learns about a write made elsewhere on its next two-second poll, which for a turn-based card game is a fair trade for surviving scale-out.

No secret for the director

On Cloud Run the Gemini director authenticates as the runtime service account through Application Default Credentials, so there is no key to create, store, rotate, or leak. The two secrets that must exist live in Secret Manager and are mounted at runtime — they appear in neither the image, the repo, nor the environment list.

Built with

Stack & data sources

Engine

  • TypeScript
  • Pure reducer
  • Seeded RNG
  • node:test
  • CPU-vs-CPU simulator

Generation

  • fal.ai queue
  • MiniMax image-to-video
  • Nano Banana image edit
  • Gemini 3.8 Flash
  • ffmpeg

Platform

  • Next.js 16
  • React 19
  • Cloud Run
  • Firestore
  • Secret Manager
  • Auth.js
  • Server-Sent Events

Card data

  • PokemonTCG/pokemon-tcg-data
  • images.pokemontcg.io
  • PTCG Live deck format

Models

  • minimax/h3-max
  • google/nano-banana-lite
  • gemini-3.8-flash
← All projectsTalk about this work