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
- PA-Agent 50 , wonFoulPlay 12
- ED-Testing 50 , wonMetaHorns 28
- 4thLesson 50 , wonGCOGS 11
- srsk-1729 50 , wonExp-05 29
Semifinals
- ED-Testing 13PA-Agent 50 , won
- srsk-1729 204thLesson 50 , won
Final
- PA-Agent 50 , won4thLesson 28
Gen 9 OU best of 99, games won
Quarterfinals
- FoulPlay 50 , wonPorygon2AI 12
- piploop 37PA-Agent 50 , won
- ED-Testing 50 , wonsrsk-1729 20
- Q 50 , wonMetaHorns 16
Semifinals
- FoulPlay 50 , wonPA-Agent 39
- Q 50 , wonED-Testing 22
Final
- Q 14FoulPlay 50 , won
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 OU | Gen 9 OU | |
|---|---|---|
| Qualifying week | 13–19 Oct 2025 | 20–26 Oct 2025 |
| Won–lost | 168–114 | 177–141 |
| Elo | 1749 | 1754 |
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
1 Metamon’s policy proposes our top options
2 PokéChamp predicts the opponent’s replies
3 Its simulator plays each pair one turn ahead
4 Metamon’s critic scores each position, in place of an LLM
5 Minimax: expect the worst reply, take the best option
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
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=…/
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.