All work

PokéAgent and Metamon

Competition, a search hybrid, self-play infrastructure, and Metamon contributions

When
2025–2026

The PokéAgent Challenge was a NeurIPS 2025 competition for game-playing agents. I entered its competitive battling track as MetaHorns. In Gen 9 OU my entry was a hybrid of my own that puts Metamon, an offline-RL agent, inside PokéChamp’s minimax search. Alongside the entry, I built self-play infrastructure for Metamon, and after the tournament I contributed to Metamon itself.

My part, briefly

  • Per-branch history. Metamon reads a battle as a sequence, so its critic can’t score a position on its own. In the hybrid, every live battle message is mirrored into Metamon’s trajectory format, and each simulated branch gets its own copy of that history, so the critic scores each leaf with the turns that led to it, not with one history that every branch overwrites.
  • Slow team validation. Metamon’s team validation started a new Showdown process, and in practice a full rebuild of Showdown, for every team file. I replaced it with one persistent validator process (details below).
  • Timing the fix. In October 2026 I timed both paths on the same 200 Gen 9 OU teams: about 1 s per team before, about 1 ms after, with the same accept and reject decisions, including on teams I made invalid on purpose. The full 25,192-team set had already gone through the new path in January.

Competition result

MetaHorns qualified for the eight-entrant tournament in both Gen 1 OU and Gen 9 OU. Each match was best of 99. It lost its quarterfinal in both formats: 28–50 to ED-Testing in Gen 1 OU and 16–50 to Q in Gen 9 OU.

Gen 1 OU best of 99, games won

Quarterfinals

  1. PA-Agent 50 , won
    FoulPlay 12
  2. ED-Testing 50 , won
    MetaHorns 28
  3. 4thLesson 50 , won
    GCOGS 11
  4. srsk-1729 50 , won
    Exp-05 29

Semifinals

  1. ED-Testing 13
    PA-Agent 50 , won
  2. srsk-1729 20
    4thLesson 50 , won

Final

  1. PA-Agent 50 , won
    4thLesson 28

Gen 9 OU best of 99, games won

Quarterfinals

  1. FoulPlay 50 , won
    Porygon2AI 12
  2. piploop 37
    PA-Agent 50 , won
  3. ED-Testing 50 , won
    srsk-1729 20
  4. Q 50 , won
    MetaHorns 16

Semifinals

  1. FoulPlay 50 , won
    PA-Agent 39
  2. Q 50 , won
    ED-Testing 22

Final

  1. Q 14
    FoulPlay 50 , won
The eight-entrant tournament brackets for both Track 1 formats, transcribed from the official PokéAgent Challenge leaderboard. MetaHorns’ matches are outlined.

In Gen 9 OU an earlier Metamon-based version played the qualifying week, and the tournament entry was the hybrid described below. The hybrid supports Gen 9 only, so in Gen 1 OU MetaHorns built on Metamon’s pretrained agents. I didn’t record the exact settings each tournament match ran with.

Qualifying record on the official leaderboard

These figures come from the leaderboard data the organizers froze on 11 November 2025, after qualifying closed. Each covers one format’s qualifying week of ladder battles, against other entrants and organizer-hosted baselines.

The top two players by Elo qualified automatically, and the other six places went by History Rating, fitted to ladder battle outcomes.

Gen 1 OUGen 9 OU
Qualifying week13–19 Oct 202520–26 Oct 2025
Won–lost168–114177–141
Elo17491754

A Metamon and PokéChamp hybrid

Metamon is an offline-RL agent trained on human battles. PokéChamp is an LLM agent that searches a minimax tree, looking ahead with a battle simulator but asking an LLM to score each position. Metamon has learned which moves are likely and how likely a position is to be won. In late October 2025 I combined them in a fork of PokéChamp. Metamon’s policy proposes our candidate moves, PokéChamp predicts the opponent’s replies and plays each pair one turn ahead, Metamon’s critic scores every resulting position, and minimax takes the move whose worst case is best. A turn needs no LLM call (an earlier version still asked the LLM for the candidate moves).

  • Metamon offline RL agent
  • PokéChamp minimax search
One turn of my Gen 9 OU entry: PokéChamp’s minimax search, with Metamon’s policy and critic inside it. Schematic, with illustrative scores.

The hard part was the bridge between the two codebases. Metamon reads a battle as a sequence of observations, so metamon_bridge.py mirrors every message of the live battle into Metamon’s trajectory format, normalizes the simplified messages the simulator emits, and clones that context for every simulated branch, so the critic scores each leaf with the history that led to it. I kept no search logs, so the scores in the diagram are illustrative.

Competition repository and experiments

For experiments, I packaged Metamon self-play as a container image that runs as a Cloud Run Job. The image bundles Metamon, AMAGO and a built Pokémon Showdown server. Each task starts its own Showdown server on localhost, waits until it answers, and then runs battles between two Metamon agents logged in as different users.

A task manifest names the agents, format, teams and requested battle count. Each worker reads its own row, loads pretrained models or local checkpoints, and saves trajectories under a separate run and task path.

Task manifest

One JSON line per task: both agents, format, team set, battle count

selfplay_rlv2.jsonl

Model checkpoints

Metamon pretrained models from Hugging Face, or a local checkpoint

SyntheticRLV2

Cloud Run Job task i one container, nothing shared with other tasks

Metamon agent 1

its own username

Metamon agent 2

its own username

Pokémon Showdown server

local to the container, started before the battles

Trajectory files

per run, configuration tag and task (no checkpoints are saved)

run=…/cfg=…/task-i/agent1/

The self-play setup as implemented in my competition repository. A task reads its line of the manifest, starts a private Showdown server, and runs two Metamon agents against each other.

Each container runs its own Showdown server, so tasks share nothing while they run and adding tasks needs no coordination between them. The runner prints the training command rather than launching it.

The repository has no run logs, so I don’t give battle counts or costs. It also holds training-side experiments from the same period, such as a win-probability head on the agent’s sequence encoder. None of them is documented as part of a tournament entry.

How a task finds its configuration

The image is built with Cloud Build and pushed to Artifact Registry. Its entry point is tini -- with-showdown run-self-play: with_showdown.sh starts the Showdown server on 127.0.0.1 and polls it every half second, for up to 90 seconds, before handing over to the runner.

run_self_play.sh reads line CLOUD_RUN_TASK_INDEX + 1 of the manifest named in JSONL_TASK_MANIFEST and exports its fields as environment variables. One line of selfplay_rlv2.jsonl, formatted for reading:

{
  "battle_format": "gen1ou",
  "agent1": "SyntheticRLV2",
  "agent2": "SyntheticRLV2",
  "team_set": "competitive",
  "num_battles": 6000,
  "cfg_tag": "gen1ou_SyntheticRLV2_SyntheticRLV2_competitive"
}

num_battles is a requested setting, not a record of battles played. The output path is /selfplay/run=<UTC run id>/cfg=<cfg_tag>/task-<index>, with separate agent1/ and agent2/ directories for each format. A local agent is given as a checkpoint directory, an epoch and its model and training configuration files instead of a pretrained name. The second agent starts a few seconds after the first, five by default.

Metamon contributions

From January to March 2026 I worked on Metamon with Jake Grigsby, the PhD student at UT Austin’s Robot Perception and Learning Lab who maintains it.

The main change, in #65, was to team validation. Metamon predicts opponents’ teams from tens of thousands of team files taken from replays, and each one is checked against the format’s rules with Pokémon Showdown’s validator. The script started a new Showdown command-line process for every file. That process reloaded Showdown’s game data, and because the script ran from the repository root, it also rebuilt Showdown from source before checking a single team. I replaced it with one long-running Node process that loads Showdown once, keeps a validator for each format and receives teams one line at a time over a pipe. The old command stays as a fallback.

In October 2026 I timed both paths on my machine on the first 200 Gen 9 OU team files. The old path took a median of 1.0 s per team (0.27 s when Showdown didn’t need rebuilding), and the new one took about 1 ms per team after a third of a second to start. All 200 teams took 0.6 s instead of 3.4 minutes. At those rates the 25,192-team Gen 9 OU set would take about seven hours the old way and under a minute the new way. Both paths accepted and rejected the same teams, including seven I made invalid on purpose.

The same pull request let the usage statistics be filtered by a Smogon rank baseline, and added exponential backoff and a concurrency limit to the Smogon scraper, which was timing out on some files. It was merged on 31 January 2026, and #70, a small fix needed once #65 reached dev, followed the next day. The maintainers kept the persistent validator and later added parallel workers around it, and it’s still on main.

A third piece of work stayed an experiment. It started as timestep-encoder profiling in #78 and continued as #111 in the lab’s private development repository. There I rewrote the grouped timestep encoder to run under torch.compile’s reduce-overhead mode (CUDA graphs), and built a profiler so a coding agent could try many small optimizations. The profiler accepts a change only if its outputs match the baseline’s and it is no slower. In inference-only A/B runs (single timesteps, batches of 1 to 16), the rewrite’s forward pass was about 10% faster than the current encoder’s. The two weren’t compiled in the same mode, though, so I can’t say how much of that came from the rewrite itself, and I never measured training speed.