William Kamfar

Agents / October 2026

I built an agent that plays Cuphead, then found out why it kept losing

A search-based agent that fights Cuphead bosses from the game's exact internal state, and what it took to learn why it lost.

Repository
Wkamfar/cuphead-agent
Stack
Python 3.11, C# BepInEx plugin
Size
45k lines Python, 13k C#
Tests
1,956 passing
Built with
Claude Code, 7 subagents
License
MIT

How it works

A BepInEx plugin runs Cuphead in lockstep with Python and exports every entity's exact position and hitbox. Every two frames the agent simulates 16 short input scripts against a forward model of the hazards, drops the ones predicted to be hit and lets a judge pick from the rest. A slow loop diagnoses each hit afterwards, asks an LLM reviewer for playbook edits, and keeps or reverts them through replay tests and paired statistics.

  your copy of Cuphead 1.3.4                        cupagent/ (Python; Linux, WSL or Windows)
 +---------------------------+   TCP, lockstep   +------------------------------------------------+
 | mod/CupheadGym            |  protocol v4      | fast loop, every tick (cupagent/sim, judge,    |
 | BepInEx 5 plugin: inputs  | <---------------> | control): state -> forward model -> maneuver   |
 | in; exact state, entities,|  (docs/SPEC.md)   | library -> safety filter -> judge -> executor  |
 | frames, events, hashes out|                   +------------------------+-----------------------+
 +---------------------------+                                            | every tick
   cupagent/mock: the same protocol, no game                              v
                                                   episode logs and labels (cupagent/data, DATASET.md)
                                                                          | per batch of attempts
                                                                          v
                                                   slow loop (cupagent/review): hindsight diagnosis ->
                                                   clusters -> reviewer model -> playbook edit ->
                                                   replay tests + paired gate (cupagent/eval)
                                                                          |
                                                   judges, compared on identical candidate sets:
                                                   heuristic | cup_s1 | fine-tuned Laya (cupagent/learn)
                                                                          |
                                                                          v
                                                   vision-only student (planned; VISION_STUDENT.md)
Architecture, as drawn in the repository's README.

Why it was losing

The infrastructure held up: replays were deterministic and the forward model rarely called a hit safe. The agent still won about 8% of fights, and four rounds of mechanical fixes didn't change that. The logs pointed at position. In the final Carrot phase the agent dealt nearly all its damage from under the boss and took nearly all its hits at the walls. Every time it left the middle, the judge had chosen to, by a small score gap.

Where the agent spends time, takes hits and deals damage in the Carrot phase Under the carrot: 48% of time, 0 of 682 hits, 99.9% of damage. At a wall: 28% of time, 591 of 682 hits, 0.05% of damage. Time in the phase 119 min 48% 24% 28% Hits taken 682 hits 13% 87% 0 under the carrot Boss damage dealt 75,472 HP 99.9% under the carrot (|x| < 150) between at a wall (|x| ≥ 550)
Carrot phase, 234 fights. This is observational: the agent leaves the middle when it sees threats there, so it does not prove the middle is safe. Data: findings, section 3.4.

Testing the fix

I compared the current judge against six weightings of two position terms, firing_position (fp) and center_bias (cb), each playing the same 100 seeds from the start of the Carrot phase. Every weighting won more often. The strongest won 39 times against 6: 36 seeds were won only with it and 3 only without it (exact McNemar p = 3.6 × 10−8). It is still the best of six on one block of seeds, at the edge of the grid, so it needs confirming on fresh seeds.

Carrot-phase wins by judge weighting Wins out of 100 paired episodes, with Wilson 95% intervals. Incumbent 6; six position weightings 15, 18, 22, 23, 22 and 39. 0% 10% 20% 30% 40% 50% 60% 6/100 incumbent 15/100 fp 0.2 cb 0.1 18/100 fp 0.2 cb 0.4 22/100 fp 0.4 cb 0.1 23/100 fp 0.4 cb 0.4 22/100 fp 0.8 cb 0.1 39/100 fp 0.8 cb 0.4
Wins out of 100 paired episodes, with Wilson 95% intervals. Data: findings, section 3.5.

What didn't work

The LLM review loop's first run on the real game accepted nothing. Its replays branched after the agent had already left the firing zone, so they tested escapes from positions that were already lost.

  1. 118hits diagnosed from 40 fights
  2. 41sent to the reviewer in 3 clusters
  3. 3edits proposed, all about position
  4. 3passed the schema check
  5. 0passed the replay check (60% bar)

Learned judges, trained only on the mock game, learned to copy the hand-written heuristic, because the mock's labels disagreed with it on 0.35% of ticks.

What went well

  • Reading ground truth from the game instead of pixels made every test exact and repeatable.
  • Versioned data with provenance, and small verified commits.
  • Reading the mechanics off the logs before writing code.
  • An independent evaluator, which caught a gain that didn't replicate and test seeds that had been worn out.

What didn't

  • I followed the milestone checklist instead of asking why it was losing. The position analysis was one script over logs that existed from the first hour.
  • Strategy was left to the slowest part of the system, the review loop.
  • Testing on the mock game first gave false confidence.
  • The milestones measured infrastructure, so they read as passed while the agent won 7% to 8%.
  • The orchestration was expensive: one session ran about 24 hours with about 30 subagent runs.

Status and next steps

MilestoneOn the real gameEvidence
M1 lockstep modAll eight exit criteria metsuccessful
M2 fast loopFalse-safe 0.33% against a 5% criterionsuccessful as worded
M2b win the Carrot phaseProposed, not metnone
M3 dataset tooling355 episodes and a one-hour pilotvalidated
M4 slow loopOne iteration, nothing acceptedimplemented
M5 learned judgesMock game onlymock validated
M6 vision-only studentNot startednone

Next: confirm the best weighting on fresh seeds 60400 to 60599, then spend the never-played test seeds once, on whole fights. After that, the goal is a vision-only student that learns from this agent.

Not affiliated with or endorsed by StudioMDHR; Cuphead is their trademark. The repository contains no game files. Project data from the repository at commit 7ebf4e0.

More from the archives