Blog · Kaggle
Orbit Wars: How Adopting Someone Else's Bot Beat My Own, and Why Self-Play Kept Lying to Me
The game: capture planets, count ships at the buzzer
Orbit Wars is a Kaggle simulation competition built around a Galcon-style real-time-strategy game. Planets orbit a sun in a continuous 100×100 plane, each producing 1–5 ships per turn. You launch fleets to capture neutral or enemy planets, and there is exactly one number that matters: your total ship count at step 498. That is your score. Not planets held, not kills — ships.
A few mechanics shape everything downstream. Fleet speed scales with size (bigger fleets move faster, capped at 6), so a trickle of small fleets is slow and loses head-on fights. Home planets start with exactly 10 ships; neutral planets sit behind garrisons of 9–66. You submit a Python agent, it plays on a TrueSkill ladder, and games are either 2-player duels or 4-player free-for-alls. Two rules dominated my strategy: only your last two submissions stay active, and you get roughly 100 submissions a day. The prize was $50k; the deadline, 2026-06-23.
The agent architecture: a "producer-lite" planner
All my agents shared one core — a package I called orbit_lite (earlier comet_lite). The strategy lived in main.py: a frozen config of knobs plus how candidate launches get scored and selected. The core is a tensor pipeline (PyTorch, run under torch.no_grad()) that each turn shortlists targets, builds one candidate launch per (source, target) lane, scores them, and greedily commits a handful of waves.
flowchart TD S[Observation: planets, fleets, ownership] --> P[parse_obs and build movement + distance cache] P --> A[Adjust config by strength ratio and time] A --> SH[build_target_shortlist: offensive + defensive] SH --> D[safe_drain: how many ships each source can spare] D --> F[capture_floor with reinforcement-risk beta] F --> SC[score_candidates by ROI, distance, garrison] SC --> L[Late-game suppression of doomed arrivals] L --> G[greedy_select up to max_waves] G --> R[Plan regroup with leftover ships] R --> M[Emit moves: from_planet, angle, num_ships]
The key design decisions are all in that loop. safe_drain computes how many ships a planet can send without leaving itself flippable. capture_floor sets the minimum fleet needed to actually take a target given its garrison at the projected arrival time. score_candidates weighs return-on-investment against distance and cost, and a greedy selector commits waves until the budget or the ROI threshold runs out. Everything else — the difference between a 1141 agent and a 1250 agent — is how well those floors and thresholds are chosen.
The lineage, and the move that actually worked
My original line evolved comet → comet2 → comet3 → comet3_nfpw. It plateaued hard at ~1141–1157. I tuned it for weeks. The comet3 planner tried to be clever: it evaluated multiple fleet fractions per lane (size_multipliers = (0.5, 0.75, 1.0)), had a "danger-aware" target discount, a terminal end-game phase, comet-evacuation logic. More knobs did not mean more score.
flowchart LR C1[comet] --> C2[comet2] --> C3[comet3] --> C4[comet3_nfpw ~1141] C3 -.plateau.-> C4 LT[light: adopted public agent 1249.6] --> O2[light_o2 overhead=2: 1129.6 FAIL] C4 -.superseded.-> LT
The winning move was not a tweak. It was adopting a stronger public agent — light, from alycemiki/light-ver-1200-simple-orbit-intruder. It scored ~1249.6 live (peaked 1256.7) and beat my entire comet3 line outright. Reading its main.py was humbling, because it was simpler than mine and smarter in four specific ways:
- Single-size launches. No multi-fraction search — it sends the full safe garrison drain in one shot (
sizes = drain). My multi-tier search was wasted compute. - A reinforcement-risk floor (β=2.2). It models ETA-aware enemy reinforcement and dynamically raises the capture requirement, so it never launches doomed attacks. This was a smarter, targeted version of the static
capture_overheadidea I'd been hardcoding. - Continuous dynamic adjustment. Its
_adjust_configreads my strength ÷ the leader's strength and smoothly ramps the ROI threshold and wave count — attack harder when behind, hold when ahead. No phase switches, just interpolation. That is the adaptive behavior I'd tried and failed to hardcode. - Late-game suppression. It stops launching attacks that would arrive too late to repay their cost before step 498.
The self-play saga: it lied to me twice
Here is the part that stings, and it is the real lesson. I have an arena harness (arena.py for mirrored 2p, arena4_rot.py for 4p with full seat rotation so win rates are seat-bias-free). Start position dominates 4p outcomes, so the rotation matters. I trusted these numbers. I should not have — at least, not the way I did.
My tweak light_o2 added capture_overhead=2 in 4-player games. In self-play it looked +8pp across multiple batches and two architectures. So I submitted it. On the live ladder it scored 1129.6 versus light's 1249.6 — a 120-point loss — and its 4p win rate dropped from 58% to 50%. I reverted.
Self-play lies. The ladder is the only truth. Beating a clone of your own base is not the same as beating the diverse ladder — and even seat-rotated 4p self-play can't fix it, because every opponent is still a copy of the exact thing you're tuning.
This was the second time. Earlier, comet3_nfpw looked +12pp in self-play and plateaued at 1141 live. The rule I finally adopted, and enforced for the rest of the competition:
| Change | Self-play verdict | Live result |
|---|---|---|
Adopt light | — | 1249.6 (best) |
light_o2 (overhead=2, 4p) | "+8pp" | 1129.6 (−120) |
comet3_nfpw (neutral-focus) | "+12pp" | 1141 (plateau) |
Self-play is a regression screen only, never a ranking signal. A single 4p batch could swing from 30% to 19% on different seeds — pure noise dressed up as insight. And there was a structural reason my numbers looked good and then faded live: new submissions start with high TrueSkill uncertainty, so early lucky wins inflate the score before it regresses to true skill. A peak-then-decline doesn't mean the agent got worse; it means the early run was optimistic.
The 4-player diagnosis
I mined 61 live replays (download episodes via kaggle competitions replay, parse planet/fleet tensors per step). The verdict was clear: my 2p win rate was healthy at 62.5%, but my 4p rate was ~28% — barely above the 25% random baseline. And 4p is roughly half the games. I had been tuning the half that already worked.
The failure mode in 4p was specific and almost poetic: I'd grab an early lead by step 40 by out-expanding everyone, then collapse by step 80. Leading the pack in a free-for-all paints a target on your back — the other three players snipe you back down. Even when leading at step 40, I only converted it to a win 38% of the time. This is exactly what light's β=2.2 reinforcement floor handles gracefully: it garrisons captured planets against ETA-aware counterattacks instead of leaving them thin. Which is why bolting my static capture_overhead on top of it only made things worse — I was over-garrisoning a policy that already defended intelligently, so I expanded slower and turtled.
The honest ceiling: heuristics vs. RL
Then I scraped all 20 discussion threads (with scrape_threads.py, a headless-Chromium scraper, because the Kaggle API has no discussions endpoint) to answer one question: what are the top players actually doing? The answer was uncomfortable. They're running reinforcement learning. The 1500–1788 top tier uses entity-transformer policies trained with PPO self-play against a JAX environment at ~10k steps/sec over multiple days.
The entire heuristic "producer-lite" family — mine, light, olasadek, and the others sharing that core — clusters at ~1240–1260. They trade coin-flips against each other. That's not a tuning gap; it's a structural ceiling. We are policy-bound, not representation-bound: more engineering will not break the wall, only a learned policy will. I confirmed the dead ends too — behavior-cloning the top RL bot lost roughly 5:1 to a public heuristic and cratered to ~4% win rate after PPO. Imitation captures moves, not search.
With a week left, no RL pipeline, and a CPU-only environment, the honest call was: do not churn. Any new submission evicts an active agent and forces it to re-climb from a provisional ~700. So I held my two strongest distinct agents (light + olasadek) and stopped submitting. The biggest win of the whole competition was recognizing when a stranger's code was better than mine, and the biggest trap was believing my own simulator. The ladder was always the only judge.