Blog · Kaggle

Orbit Wars: How Adopting Someone Else's Bot Beat My Own, and Why Self-Play Kept Lying to Me

Orbit Wars (Kaggle) · 10 min read

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 agentlight, 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:

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:

ChangeSelf-play verdictLive result
Adopt light1249.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.

← Back to all posts