Building an AI fighter pipeline with MCP while keeping the match runtime authoritative
An AI-generated fighter may look convincing and still be a bad game asset: it can lack a required motion, contain mismatched metadata, or arrive after a newer version was chosen. A valid fighter also needs a runtime that can say exactly who won.
ODDBOUT’s public Twitch Cup client makes one half of this system concrete. It is a TypeScript/Vite app with a Phaser battle host: four participants form two semifinals and a final, played automatically. Viewers can join with !join <fighter>. Approved creator fighters may be loaded from a public service. The creator workflow and MCP server are separate; this Cup repository does not implement fighter generation or MCP tools.
That distinction gives us a useful engineering boundary: an agent can help make and prepare a fighter, while the game remains responsible for the result.
Concept diagram. The creation workflow is separate from the public Cup client; only validated results for the active bout can update the bracket.
From creative request to playable package
In an agent-assisted flow, MCP gives an assistant a structured way to call tools and receive results. It can turn an idea into a work plan, coordinate asset generation, present previews, and prepare files. The creator reviews the images and animations, chooses versions, and decides when to register.
Generation success means a job completed; it does not mean the output looks right. A package validator should inspect files and required motions, and the receiving service should check uploaded content before making it available.
These are properties of the wider creator workflow and general design guidance, not implementation claims about the Cup client’s MCP surface. The Cup’s own job is narrower: load an approved roster and run a bracket.
Treat every bout message as untrusted
The Cup page owns tournament state. It sends a bout request to a same-origin fighter.html iframe, which runs the battle host and returns ready, error, or result messages. A result can advance the bracket only when it belongs to the currently active bout and names one of its two fighters.
The source validator checks the browser message origin and source window, then matches the bout ID, both fighter IDs, and seed against the active request. A simplified excerpt of that check looks like this:
// Abbreviated illustration; the real validator also checks message shape and result fields.
function matchesActiveBout(event, origin, frame, request) {
if (event.origin !== origin || frame === null || event.source !== frame) return false;
const message = event.data;
return message?.boutId === request.boutId
&& message?.a === request.a
&& message?.b === request.b
&& message?.seed === request.seed;
}
The real validator also checks message type, bounds error text, and validates result fields including winner, finish type, elapsed time, health, contacts, digest, and winner identity. bout-ready and bout-error never count as wins.
Origin identifies the site; source identifies the window. Both matter when a frame navigates. Request identity matters too: a message from the right frame may still describe the wrong match.
Make retries safe and outcomes reproducible
Suppose a bout fails to load and is retried with the same pairing and seed. During iframe navigation, the browser keeps the same WindowProxy; a delayed host-ready from the old document can arrive after the new one starts. The controller resends the current start request on each validated host-ready, while the host start gate consumes a bout ID once. A late result from an earlier bout fails the active bout ID check and is discarded.
This keeps retries from changing the bracket or resolving twice. Agent jobs need the same discipline: bind responses to a request version and ignore late results after replacement.
Combat is seed-based, and results include a digest. With the same inputs and seed, engineers can investigate outcome changes. Determinism depends on stable random-number consumption and update order; changing either can alter the bout. Keep presentation effects outside authoritative calculations where possible.
Run the Cup locally
With Node.js 22 and npm installed:
git clone https://github.com/jeonghwanko/oddbout-twitch-cup.git
cd oddbout-twitch-cup
npm ci
npm run dev
Open http://localhost:5178/twitch.html?lang=en, choose Try it solo → Try without Twitch, select Fill all 4 slots, then Start cup. No Twitch credentials or API key are needed for this local demo. The repository’s automated checks cover tournament rules and message boundaries; a separate local Chrome browser check runs three bouts through champion and cup reset. Neither substitutes for checking a real Twitch login or every production browser.
The audience sees a simple bracket and fighters. Underneath, trust comes from a clear division of labor: agents help create assets, validators decide whether packages meet the contract, and TypeScript checks which runtime result can change the tournament. AI can make more kinds of fighters possible. The game still decides who wins.
Explore the code
Both sides of this workflow have public repositories, with different responsibilities:
- oddbout-starter: assisted character creation, animation review, package validation, registration and MCP integration. Start with the README and its creation guide to understand how selected assets become a fighter package.
-
oddbout-twitch-cup: the standalone TypeScript/Vite/Phaser Cup client. Start with
TwitchTournament.tsfor state transitions, thenTwitchArenaBout.tsfor message validation andTwitchBoutRecovery.tsfor retry rules. The architecture guide connects those modules.
A useful reading exercise: follow one result from the iframe to the bracket. Find the checks that reject the wrong origin, a stale bout ID and a duplicate result. Those boundaries are useful well beyond games.
Watch the Cup demo to see the audience-facing flow before tracing the implementation.

Top comments (0)