After working on Paradigm’s Kryptos puzzles, I tried their Dogfight competition. You train a neural network to fly a fighter in a 2D arena, upload one ONNX file, and let it fight everyone else’s models.
My entry, async-was-right, won 31 of its 60 scored matches and placed fifth immediately after submission. Getting there took two submissions and an overnight training run on my M4 Pro. The first model was pretty good at beating the local bots and pretty bad at the actual competition.
What the network sees
The simulator runs physics at 120 Hz, with the model choosing turn, throttle, and shoot at 12 Hz. Matches last up to 90 seconds. Climbing costs speed, stalling takes away control, and hitting the ground kills you. Rear armor also means following directly behind someone and firing isn’t necessarily useful.
The input is 224 numbers: four frames of 56 values describing both fighters, nearby bullets, relative motion, and time remaining. The submission has to be stateless, so those four frames are all the memory it gets.
I used a two-layer, 256-wide network with an extra temporal branch. That branch runs the same small encoder over each frame and the differences between adjacent frames, then adds the result to the main network’s features. This gives it a way to learn motion from the supplied history.
The temporal branch starts with a zeroed final projection, preserving the imported policy’s behavior while training learns the additions. A separate critic estimates how promising a state is during training; it gets left out of the submitted file.
The final actor had 223,875 parameters against a 250,000 limit and exported to a roughly 903 KB ONNX file. Small enough that running the experiments mattered more than fitting the model in memory.
Simulations on the M4 Pro
I wrapped the competition’s Rust simulator in a native Python extension, so PyTorch could train against the actual physics. Each environment step advances one decision interval, or ten physics ticks.
On this 14-core M4 Pro, a matched 65,536-step PPO benchmark gave:
| Training device | Decisions per second | Approximate time for 65,536 steps |
|---|---|---|
| CPU | 13,300 | 4.9 seconds |
| Apple GPU through MPS | 6,500 | 10.1 seconds |
Those are training benchmark rates, including the PPO workload, rather than isolated physics throughput. CPU was about twice as fast for this setup. The single-worker CPU configuration used 128 arenas and ten PyTorch threads.
For the long run I switched to eight concurrent workers, each with two threads and 128 arenas. It completed 768 million environment steps in 9 hours 39 minutes, training all 64 challengers across eight generations. That’s about 22,100 training steps per second averaged over the whole run, including evaluation and other overhead.
I kept a 5 GiB system-memory reserve and a 5 GiB memory cap per worker, with pauses for memory or thermal pressure. The run finished without watchdog, memory, or thermal failures.
Learning from the first loss
The first scored submission went 12 wins, 5 draws, 43 losses. Unfortunately, beating the built-in opponents wasn’t enough.
Paradigm exposes public match replays, so I looked at how stronger entries actually flew. The replays contain positions and fighter states, not model weights or raw controls. I reconstructed turn and throttle using the simulator physics, then checked that reconstruction against my own known model: yaw correlation was 0.940, throttle 0.992, over 11,337 decisions.
In the sampled replays, Rottweiler Deadeye II landed 25.8% of its shots against my previous champion’s 4.8%, and finished matches in 16.2 seconds on average against 79.7. It was much better at getting its nose pointed at the opponent at close range.
I imitated its successful turn and throttle decisions, held out whole matches for validation, and kept the shooting output unchanged. Another 50,000 states from the incumbent and native policies helped retain what the model already knew. Then I resumed PPO, which improves the policy by trying actions and learning from their rewards, against scripted opponents and a league of earlier models.
Making improvements prove themselves
Every challenger had to pass fresh, side-balanced matches against the opponent suite, fight the current champion directly, avoid regressing on ground deaths, and pass ONNX validation before replacing it.
Of 64 challengers, 24 passed their generation’s checks. Six of eight generations promoted a new champion; two kept the existing one.
On 10,240 fresh local matches against the four built-ins and starter, the final model’s win rate rose from 94.4% for the pre-run model to 99.7%. Ground deaths fell from 0.93% to 0.16%. In another 4,096 matches directly against that pre-run model, it won 3,985, drew 69, and lost 42: 97.3% wins.
How it actually ranked
The September 3 submission went 31–0–29, a 51.7% win rate, with all 31 wins by elimination. A substantial improvement over 12–5–43, but still a long way from the local 99.7%.
The live leaderboard still showed fifth in cross-play Elo when I captured it on September 8. The separate daily tournament placed us sixth, with two rounds won and a reported 62.0% win rate. Those are different evaluations from the original 60-match submission score.
The useful lesson was how easily a model can look solved against familiar opponents. More simulations helped, but watching what the stronger models did gave the training a much better starting point. Apparently shooting accurately is better than flying around for 90 seconds. Who knew.