Skip to main content
← All writingKylan Thomson

Generative AI · Game Engines

How I Keep Generative AI From Lying About Game State

Video models are unreliable narrators. In a card game where every attack becomes a generated clip, the only safe design is one where the model is never asked a question the rules engine has not already answered.

Published
Reading time
9 minutes
Project
Pokémon TCG Live Attacks case study

Pokémon TCG Live Attacks is a complete digital Pokémon Trading Card Game where every attack becomes a short generated video of that attack landing. The first design decision, made before a single prompt was written, was that the video model would never be allowed to decide anything. This piece is about what that decision cost, what it bought, and how it is enforced in code rather than in good intentions.

The failure mode is easy to picture. A player declares Thunderbolt. A video model is asked to show Pikachu hitting Squirtle. The clip comes back with Squirtle shrugging it off, or fainting when it should not have, or a coin visibly landing tails when the rules said heads. Now the board and the video disagree, and the player, quite reasonably, believes the video. A generative model is a wonderful illustrator and an unreliable narrator, and a game engine cannot have an unreliable narrator anywhere near the rules.

Resolve first, animate second

The fix is an ordering constraint. When an attack is declared, the rules engine computes the complete outcome, and only then does anything generative begin. The outcome is a plain data structure, and it is deliberately exhaustive: base and applied damage, whether weakness or resistance applied, every coin flip and how it landed, any status condition inflicted, self-damage, bench damage, whether the effect text was simulated at all, and a field called knockout that is one of certain, possible, or none.

The video pipeline receives that structure and nothing else about the rules. It is a renderer of a settled fact. Its prompt says the defender ends the clip fainted because the engine already knows the defender is fainted, and it says the attack fizzles because the engine already flipped the coin and got tails. Nothing the model produces can flow back into the game, because there is no channel for it to flow through.

/**
 * The complete, pre-computed outcome of an attack. Produced when the
 * attack is declared -- before any video exists -- so the prompt describes
 * a decided outcome. Coin flips are already resolved here, which is why the
 * same resolution can be applied later without re-rolling.
 */
export type AttackResolution = {
  appliedDamage: number;
  weaknessApplied: boolean;
  flips: CoinFlip[];
  inflicted: Condition[];
  knockout: "certain" | "possible" | "none";
  effectSimulated: boolean;
  // ...forty more fields
};
types.ts: the resolution exists before the prompt does, so the prompt describes a decided outcome.
Resolve first, animate second An architecture diagram generated by Archify. Attack panel · previewAttack · Architecture component Attack panel previewAttack Rules engine · pure reducer · seeded RNG · Architecture component · zero Math.random Rules engine pure reducer · seeded RNG zero Math.random AttackResolution · damage · flips · knockout · Server · decided before any prompt AttackResolution damage · flips · knockout decided before any prompt Prompt builder · deterministic · no coins named · Server Prompt builder deterministic · no coins named Portrait cache · battle · hurt · fainted frames · Server Portrait cache battle · hurt · fainted frames fal.ai video · image-to-video · frames pinned · Architecture component fal.ai video image-to-video · frames pinned Battle overlay · coins revealed · board holds · Architecture component Battle overlay coins revealed · board holds DECLARE_ATTACK parked settled outcome queue request keyframes clip URL, polled RESOLVE_ATTACK after the clip Server Legend Frontend Backend Database External
The order of operations. The engine resolves the attack and parks the outcome on the state; the prompt is built from that outcome and the cached portraits; the clip is queued and polled; and only when the overlay has played it does the engine apply the outcome to the board.

The board waits for the video

Deciding first is necessary but not sufficient. The engine also has to not apply the outcome until the clip has played, or the player sees damage land on the board and then watches a video of it landing again. So declaring an attack is one engine action, which parks the resolution on the state and moves the match into an attack-declared phase, and resolving it is a second, separate action that applies the parked resolution and ends the turn. The overlay that plays the clip dispatches the second action when the video finishes. If the video never arrives, the player is told the attack still resolves and can continue; the outcome does not depend on the clip, so nothing is lost except the picture.

Coin flips get the same treatment, with one twist. The engine has already settled them, so what the player sees is a reveal, not a roll. The coins are shown one at a time, interactively, while the clip keeps generating underneath, which means a three-flip attack costs the player no extra waiting. The damage line stays hidden until the last coin has landed, because knowing the total would spoil the flips.

An engine with no randomness of its own

None of the above works if the engine can produce different outcomes from the same inputs, because then a preview shown in the attack panel and the real resolution could disagree, and the video could be built for one and applied as the other. So the engine is a pure reducer: the same state plus the same action always yields the same next state. Every shuffle and every coin flip is drawn from the match seed plus a cursor stored on the state, advanced on each draw.

export class Rng {
  constructor(private seed: number, public cursor: number) {}
  next(): number { return valueAt(this.seed, this.cursor++); }
  flip(): boolean { return this.next() < 0.5; }
  shuffle<T>(items: readonly T[]): T[] { /* Fisher-Yates over this.int */ }
}
rng.ts: the entire source of randomness. There are zero calls to Math.random in the engine.

That one decision does a surprising amount of work. Replays are exact: a match is its seed plus its action list, and the engine reconstructs every board from those. Online play needs to exchange only actions, because both clients and the server will reach the same state. And the attack panel can show the real outcome before the player commits, because previewing an attack reads the same seeded stream the real declaration will read, just a little early, without writing the cursor back. The preview is not a guess at the flip. It is the flip.

A parser that admits what it does not understand

The engine is only as honest as its reading of the cards. There are 20,444 cards in the pool and 27,927 printed attacks, and hand-coding them was never an option, so attack text is parsed into runnable structure. The dangerous failure here is not a sentence the parser cannot read. It is a sentence the parser reads half of. A matcher that sees “this attack does 60 more damage” inside “If you played a Supporter this turn, this attack does 60 more damage” has not understood the card, and applying the bonus unconditionally would be a rules engine quietly lying.

So a matcher reports exactly the text it consumed, and the sentence only counts if what it did not consume carries no condition. The leftover is tested against a list of words that signal one: if, unless, as long as, for each, instead, only, next turn, and so on. Any hit fails the whole attack. A failed attack deals its printed damage, the attack panel labels it “effect not simulated”, and the knockout prediction widens from certain to possible, so the game never claims something it did not apply.

let rest = form.replace(got, " ");
if (gated) rest = rest.replace(COIN_PHRASE, " ");
if (RESIDUAL.test(rest)) continue;
attackEffects.ts: the unread remainder of a sentence must carry no condition, or the matcher did not understand it.

Coverage is measured by a script that parses every attack in the pool and reports how many resolve exactly. It is 67 percent overall, and the test suite holds a fixture of the totals so coverage can only go up: a change that parses one fewer attack fails the build.

  • Resolves exactly
  • Printed damage only (effect not simulated)
ATTACKS THAT RESOLVE EXACTLY, BY SERIESBaseBase: 503 of 673 attacks resolve exactly (75%)75%673 attacksGymGym: 187 of 316 attacks resolve exactly (59%)59%316 attacksNeoNeo: 272 of 490 attacks resolve exactly (56%)56%490 attacksE-CardE-Card: 452 of 714 attacks resolve exactly (63%)63%714 attacksEXEX: 1,606 of 2,505 attacks resolve exactly (64%)64%2,505 attacksDiamond & PearlDiamond & Pearl: 728 of 1,335 attacks resolve exactly (55%)55%1,335 attacksPlatinumPlatinum: 465 of 826 attacks resolve exactly (56%)56%826 attacksHeartGold & SoulSilverHeartGold & SoulSilver: 496 of 771 attacks resolve exactly (64%)64%771 attacksBlack & WhiteBlack & White: 1,610 of 2,128 attacks resolve exactly (76%)76%2,128 attacksXYXY: 1,749 of 2,539 attacks resolve exactly (69%)69%2,539 attacksSun & MoonSun & Moon: 2,707 of 4,292 attacks resolve exactly (63%)63%4,292 attacksSword & ShieldSword & Shield: 3,325 of 4,796 attacks resolve exactly (69%)69%4,796 attacksScarlet & VioletScarlet & Violet: 3,316 of 4,672 attacks resolve exactly (71%)71%4,672 attacksMega EvolutionMega Evolution: 756 of 1,131 attacks resolve exactly (67%)67%1,131 attacks
View as table
Attack coverage by series
SeriesExactTotalShare
Base50367375%
Gym18731659%
Neo27249056%
E-Card45271463%
EX1,6062,50564%
Diamond & Pearl7281,33555%
Platinum46582656%
HeartGold & SoulSilver49677164%
Black & White1,6102,12876%
XY1,7492,53969%
Sun & Moon2,7074,29263%
Sword & Shield3,3254,79669%
Scarlet & Violet3,3164,67271%
Mega Evolution7561,13167%
Share of printed attacks that resolve exactly, by series, from the project's coverage script over all 27,927 attacks (run 21 September 2026). The older sets are not systematically easier; Neo and Diamond & Pearl are the hardest series, and the modern Black & White through Scarlet & Violet sets sit near 70 percent.

The remaining third is not random. When the script prints the most common opening sentences of the attacks it declined, the list is dominated by coin flips with consequences the parser does not yet model, plus the genuinely interactive effects: choose one of your opponent's Pokémon, your opponent reveals their hand, shuffle your hand into your deck. Each of those is a feature to build, not a card to fudge.

ATTACKS DECLINED, BY OPENING SENTENCEFlip a coin."Flip a coin.": 405 attacks not simulated405Flip 2 coins."Flip 2 coins.": 93 attacks not simulated93Flip 3 coins."Flip 3 coins.": 83 attacks not simulated83Choose 1 of your opponent's Pokémon."Choose 1 of your opponent's Pokémon.": 56 attacks not simulated56Your opponent reveals their hand."Your opponent reveals their hand.": 56 attacks not simulated56Flip a coin until you get tails."Flip a coin until you get tails.": 54 attacks not simulated54Choose 1 of the Defending Pokémon's attacks."Choose 1 of the Defending Pokémon's attacks.": 48 attacks not simulated48Shuffle your hand into your deck."Shuffle your hand into your deck.": 47 attacks not simulated47
The eight most common opening sentences among attacks the parser does not simulate. Four hundred and five attacks begin “Flip a coin.” and continue into an effect the parser will not claim without reading in full.

What the prompt is allowed to say

Once the outcome is decided, the prompt is built deterministically from it, and that construction has its own rules about honesty. Two are worth calling out.

  • Coins are never mentioned.The results are baked into the outcome the prompt describes. Naming the flip would put a literal coin in the shot, and the sentences of card text that get spliced into the prompt are filtered to drop anything containing “coin”, “heads”, or “tails”.
  • Both ends of the clip are pinned.The clip opens on the attacker's battle portrait and must close on the defender's portrait in the state the engine decided: standing, hurt, or fainted. The request sends both frames to the video model as the first and last image, and the prompt instructs it to hold each as a locked-off still. The model is free in the middle and constrained at the edges, which is exactly where the truth of the outcome lives.

Whether the defender ends the clip “hurt” rather than “standing” is itself a read of the resolution: below a fraction of remaining health the hurt portrait is used. Weakness gets its own sentence in the prompt, resistance gets its own, a Defender card gets its own, and a knockout that ends the match gets a celebration beat. None of that is the model being creative. It is the engine's decision, rendered.

What the constraint costs

  1. Latency lands in the worst place. Generation can only start once the attack is declared, which is exactly when the player is waiting. Hiding that is a whole engineering problem of its own, covered in the article on predictive cache warming.
  2. The model cannot improvise the rules. A more permissive design could let a language model resolve the 33 percent of effects the parser declines. It would look complete and it would be wrong in ways nobody could audit. This design would rather be 67 percent exact and say so on every card.
  3. Every prompt is a small program. Because the prompt is a pure function of the resolution, its logic lives in a 1,700-line module of tables and branches rather than in a paragraph of instructions. That is more code than most people expect a prompt to be. It is also testable.

The principle generalises well beyond card games. Anywhere a generative model sits next to a system of record, decide the fact in the system of record first, hand the model the decided fact, and pin the parts of the output that carry it. Let the model be an illustrator. Never let it be a witness. The rest of the system, from the Gemini director to the server-authoritative online mode, is in the Pokémon TCG Live Attacks case study.

Book a 30-min callEmail about this work