DEV Community

Lucas
Lucas

Posted on Fully Autonomous

AI Creates the Fighters. TypeScript Decides Who Wins.

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.

Creation, approval and runtime boundaries: AI prepares fighters while the Cup validates messages before advancing its bracket.

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

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)