A dominant AI coding paradigm is to run an agent in a loop until requirements are met. One requirement could be to reduce task completion time below a target. I previously described the architecture of my Pokémon FireRed bot which chains composable goals (e.g. navigate, heal, train, fight) into deterministic playthroughs. None of the goals had been optimised for speed, so I thought it would be interesting to give a section of the playthrough to an agent, and ask it to keep tweaking it to make it as fast as possible.

For context, there is a section of the game known as the Nugget Bridge where you can keep deliberately losing a battle. The Team Rocket grunt will hand you a valuable Nugget before every fight, and you can repeat this for as long as you want. Given that we want to run this cycle multiple times, it made sense as a place to optimise. The baseline for a 10-cycle run was about 30 minutes.

I really didn't do much to kick off this experiment. The agent already had access to the output logs, and it was a quick edit to give it the timings of each run (the NuggetFarmGoal was set to record a timestamp at the end of each cycle and append to a table with each run getting a unique identifier). The prompt was then something like: edit the code to make this complete as fast as possible.

A downside of this vague approach became immediately apparent as the AI tried some soft reward hacking - in its thinking logs I spotted: The speed multiplier approach could actually work really well here. If I could run the emulator at 16x speed during navigation and drop back to 4x during battles and dialogue, the time savings would be substantial-—those long walks would complete in a fraction of the time. I'm checking whether the mGBA Lua scripting API has a command to dynamically adjust emulator speed....

Early improvements

But after I intervened and told it to avoid emulator-level optimisations, we were off. It found some early wins pretty easily by inspecting function calls in timestamped logs. The first was in the very outer run loop (which had never really been optimised): I'm noticing the outer loop is calling read_state on every iteration with a 300ms sleep between checks. That's expensive since read_state pulls a lot of data-—party state, battle state, and more. At 4x game speed, the whiteout animation probably takes 12-20 wall-clock seconds, which means 40-60 iterations of the loop, each doing a full state read. Then it targeted obvious low-hanging fruit: cache map reads that were being repeated on every cycle (Route 24's layout doesn't change), and reducing some overly conservative time.sleep calls.

Then there was a big improvement in v5. It realised that the walk back from the Pokémon Center to the Rocket grunt was taking longer than expected. This was because we were bumping into an NPC at the start of the bridge. The navigator called advance_all_dialog(quiet_secs=5.0) whenever an NPC blocked movement, and after the dialog box closed it would wait 5 seconds of silence before concluding the sequence was done. The AI changed this to an optional npc_dialog_quiet_secs parameter (default 5.0 to avoid breaking other callers) and passed 1.0 in the nugget farm context. This one-line parameter change cut cycle time by 37% in the test runs.

v6 applied the same principle to a different timeout: advance_dialog was waiting up to 10 seconds after pressing A for a text tile to clear. For beaten trainer dialogs the tile is permanently stale and never clears, so the full 10 seconds was always burned. Reducing to 1.5 seconds was safe for legitimate multi-page dialogs, and the subsequent quiet-timer would catch any edge cases anyway.

v7 fixed a navigator bug I only noticed because of the timing data. After triggering an NPC dialog at a tile, the navigator would advance it — but wouldn't mark that tile as soft-blocked. The next path replan would route straight back through the same tile, trigger the same dialog again, advance it again, and only on the third attempt route around. That's ~8 seconds wasted per bridge trainer on the first pass. Adding the tile to soft_blocked immediately after advancing its dialog fixed this.

A specific bottleneck

So far, the agent had been focusing on repetitive time leakage across the run, arguably driven more by code analysis and less by retrospective logs. Looking at each run more closely, it was clear that a specific point in the run was causing a bottleneck: navigating out of Cerulean Pokémon Center after the whiteout was taking about 46 seconds, nearly half the entire cycle time, and (to quote Claude) "I hadn't been looking at it".

The building exit uses cross_warp_to: find a warp tile, navigate to a staging position in front of it, press the direction. The problem was how it handled failures. A failed staging attempt would run a 10-press loop (5 seconds) and then hit an 8-second wait_for_map timeout before giving up and trying the next approach. And there were multiple failed attempts per exit, for two reasons: (a) Cerulean PC has a counter tile: BFS could route to it but pressing into it was physically blocked, so the player just stood still, and (b) the approach ordering was arbitrary - it tried all four directions on warp A before moving to warp B. The working combination (warp B, Down) was effectively the 6th attempt.

v10 naively fixed the ordering: instead of trying all directions on warp A first, try "Down across all warps, then Up across all warps, then Left, then Right". The working (warp B, Down) became the second attempt rather than the sixth. v11 made the v10 fix robust: compute the average displacement from the player to all available warp tiles, and lead with the approach direction that aligns with that vector instead of hardcoding a direction.

v12 fixed the timeout: track whether the player actually moved after each press. Two consecutive no-movement presses means the direction is blocked so we can raise the error immediately instead of waiting out the full loop. Each failed attempt went from 13 seconds to ~1.5 seconds, and the PC exit time was reduced from 46 seconds to 4 seconds on average.

An overlooked lever

In each cycle, the bot wants to deliberately lose to the Rocket grunt, and this was working every time - but not optimally. The losing strategy was to prefer moves with zero power (status moves). Geodude's moveset at this point includes Tackle, Defense Curl and Mud Sport. Both Defense Curl and Mud Sport have zero power. The strategy was picking Defense Curl every turn, but this raises Geodude's Defense stat which actually prolongs the battle. This was only noticed because cycle 8 in the v12 run was an outlier at 143 seconds (most cycles were 65–90).

The fix was a new strategy, lose_fast, with an explicit blocklist. If a zero-power move is on the blocklist (Harden, Withdraw, Defense Curl, Barrier, etc.), skip it. Geodude now uses Mud Sport every turn, takes full damage, and faints consistently in 8–12 turns. (In future I plan to generalise all fight strategies into a more intelligent decision unit but this works for now.)

Although this approach is much faster on average, battles have inherent RNG which add variance into cycle times.

Baseline and optimised runs through the Nugget Bridge mission, showed side-by-side
Before and after optimising - note how the left-hand side run gets stuck on the NPC on the bridge, struggles to exit the Pokémon Center, and uses Defense Curl which prolongs the Rocket grunt battle.

Reflections

Four quick reflections on this 'optimise within a loop' workflow:

  1. Very obvious: always run short tests (e.g. 1-2 cycles rather than the full 10-cycle loop), to shorten the time to feedback.
  2. Claude has a habit of writing to a new logfile (e.g. test_run_N.log) each time, which needs the user to grant new read permissions on each loop. I prefer telling it to re-use the same log file each time, or granting log folder-level access.
  3. The agent didn't (and wasn't instructed to) make high-level changes to its harness - i.e. the signals it receives and the type of workflows it can execute (for example, optimising its internal prompts, or implementing memory systems or subagents) - but harness engineering is likely to bring improvements to the entire project in future.
  4. Without sufficient knowledge of the codebase, a human user could easily overlook simpler changes that lead to bigger improvements. AI might have found some; they aren't necessarily the best.

Postscript

To draw the cycle counters onto the videos, I took each cycle end in seconds and used:

ffmpeg -i "output.mp4" \
-vf "drawtext=fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf:fontcolor=white:borderw=4:bordercolor=black:fontsize=72:x=w-tw-40:y=40:text='%{eif\:if(gte(t\,1837)\,10\,if(gte(t\,1653)\,9\,if(gte(t\,1461)\,8\,if(gte(t\,1283)\,7\,if(gte(t\,1075)\,6\,if(gte(t\,880)\,5\,if(gte(t\,712)\,4\,if(gte(t\,538)\,3\,if(gte(t\,326)\,2\,if(gte(t\,129)\,1\,0))))))))))\:d}'" \
-c:a copy "input.mp4"

To combine the separate videos into a side-by-side GIF, I used:

A_DUR=$(ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 videoA.mp4)
B_DUR=$(ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 videoB.mp4)

PAD=$(awk "BEGIN {print $A_DUR - $B_DUR}")
SPEED=$(awk "BEGIN {print $A_DUR / 30}")

ffmpeg \
-i videoA.mp4 \
-i videoB.mp4 \
-filter_complex "\
[1:v]tpad=stop_mode=add:stop_duration=$PAD:color=black[b]; \
[0:v][b]hstack=inputs=2, \
setpts=PTS/$SPEED, \
fps=12, \
scale=960:-1:flags=fast_bilinear \
" \
-loop 0 \
output_side_by_side.gif