{
  "id": 720765,
  "title": "10th Place Solo Gold Solution",
  "content": "<p>First, I'd like to thank the hosts for organizing such an engaging competition and congratulate all the winners. Of all the competitions I've taken part in on Kaggle, this is the one I had the most fun with. </p>\n<p>I first spent a few weeks on the competition writing pure heuristic bots, and for a while I thought the top of the leaderboard would end up being either a strong heuristic or a heuristic with an RL fine-tune on top. What changed my mind was inference speed. A strong heuristic needs a lot of physics computation and a number of small optimizations. For example, just to find the most ships a player could land on a given planet by a given turn, I usually needed seconds per turn. So I took a break, and after reading <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/697725\" target=\"_blank\">3-comet's post</a>¹, a very generous share, I came back and approached the game with RL instead. My guiding philosophy for RL in this competition is to give the model the full context it needs, plus the right architectural inductive bias for efficient training.</p>\n<p><strong>TL;DR.</strong> My final submissions use a transformer with one token per planet (overall ~1.2M parameters), trained separately for the 2p and 4p tracks by self-play PPO with league training on a single GPU, 2p from a random start and 4p from an imitation-learning initialization. The final model is chosen by local evaluation, the average win-rate over all pairs in a pool of candidates. An inference-time strategy adds a small boost on top, and one uses it while the other does not. The agent beat <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/714324\" target=\"_blank\">Isaiah</a>³'s agent once during the evaluation phase. Code available at <a href=\"https://github.com/yijieyuan/kaggle-orbitwar\" target=\"_blank\">https://github.com/yijieyuan/kaggle-orbitwar</a>.</p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/luca_vs_tufa/luca_vs_tufa_ep84096506.gif\" alt=\"\"></p>\n<p>Let's get into the full solution, which covers game representation &amp; feature engineering / model architecture / game engine and environment / imitation learning / self-play PPO training / local evaluation / inference-time strategy / evaluation phase / compute and cost.</p>\n<hr>\n<h2>Game representation &amp; feature engineering</h2>\n<p>The game state is a variable-size set of planets, comets, and in-flight fleets. A transformer handles that shape naturally, since it takes a variable-length set of tokens, and I can make one token per planet and one per fleet.</p>\n<p>I ended up deciding that fleets don't get their own tokens: I wanted to keep the token types to a minimum, and the fleet count is unbounded, so my per-turn compute would grow linearly with the number of fleets in play, which can be easily taken advantage of; it also doesn't quite fit with JAX, which needs predefined tensor shapes. So I decided to incorporate each fleet's information into the planet's future state, which keeps the tokens to a bounded set of at most 44 (40 planets + 4 comets).</p>\n<p>This works because a fleet only affects its source and target planets, at its launch turn and arrival turn respectively, changing garrison or ownership. So a passive rollout (roll the game forward with no further launches from either side) captures everything: the garrison and ownership it projects for each planet, at each future turn, carry the full information about every in-flight fleet. In the worst case the rollout would need about 140 turns (a one-ship fleet crossing the full diagonal of 100·√2 at its minimum speed of 1/turn), but in practice the vast majority of fleets arrive within 20 to 30 turns.</p>\n<p><strong>Planet token</strong>: the table below covers everything that goes into it, namely some basic planet state and the passive-projection features, plus a few hand-drafted features from the series that I expect would be hard for the NN to learn from the raw projection.</p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/token_planet.png\" alt=\"\"></p>\n<p><strong>Global token</strong>: board-level information is injected via a single global token. Like the planet token, it covers some basic board state (turn, planet count, and so on), the ship and production differences at each turn of the passive rollout, plus a few hand-drafted features built on those.</p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/token_global.png\" alt=\"\"></p>\n<p><strong>Pair / edge features</strong>: planet-pair geometry enters as an edge feature (P × P × 6), used only as an attention bias inside the transformer and the pointer head rather than as a token. It is seat-independent, so it does not change for 4p.</p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/edge_features.png\" alt=\"\"></p>\n<p><strong>Design notes &amp; discussion:</strong></p>\n<ul>\n<li>For some features, the NN could in principle learn them from the raw inputs, so they could be redundant. Adding them costs extra compute each turn and goes against Sutton's Bitter Lesson. When deciding which features to use, I ran several rounds of selection judged on their imitation-learning performance (more in the imitation-learning section), and tried and later dropped a number of ideas along the way, such as planet trajectories. Really telling which features help would need an ablation study over complete runs, but that is tricky because each run is slow to reach a plateau and often needs several restarts that can land at quite different performance.</li>\n<li>Some of the one-hot features could have been a single boolean in 2p, but I kept them one-hot so the same layout extends cleanly to 4p.</li>\n<li>A fleet can hit a passing comet before it reaches its intended target, so a new comet changes the rollout; the forecast features are therefore recomputed whenever one spawns.</li>\n<li>Back when the fraction head was discretized, I tried adding each bin's edge information and its corresponding fleet speed and arrival turn as features, but they had no effect.</li>\n</ul>\n<p>That's everything the model gets. Next up is the architecture, which decides how well these features actually work together.</p>\n<hr>\n<h2>Model architecture</h2>\n<p>The OrbitNet used in the final submission has roughly 1.2 M parameters, and the same architecture serves both the 2p and 4p agents. MLPs and CNNs perform the token embedding, followed by a multi-head transformer trunk that mixes the tokens, and the result feeds three heads: a pointer head picks the target planet, a fraction head chooses how much of the garrison to send, and a value head scores the position.</p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/model_architecture.jpg\" alt=\"\"></p>\n<p><strong>Token embedding</strong>: the static and rollout-summary features and the forecast series are processed separately, then fused into one token. The former are joined into one vector through a small MLP, and the latter is run through a 1-D CNN over its 50 turns then a mean, max, and attention pool (with the attention query from the static features) so the token can focus on the turns that matter. The two embeddings are concatenated, conditioned by <a href=\"https://arxiv.org/abs/1709.07871\" target=\"_blank\">FiLM</a>² (a zero-initialized projection of the global game-summary vector scales and shifts the fused planet features), and passed through a token MLP into the 128-d planet token, up to 44 of them. The single global token is built the same way, from the game-summary vector and a 1-D CNN embedding of the economy series.</p>\n<p><strong>Main transformer</strong>: the planet tokens and the global token form a length-(P+1) sequence of 128-d tokens, run through a 6-layer pre-LN transformer with 4 heads. On top of ordinary attention, the planet-pair edge features enter as an additive bias: a tiny MLP turns the (P × P × 6) pair features into a per-head bias added to the attention scores, so board geometry (who can reach whom, and how far and fast) directly shapes which planets attend to which. The global token sits in the same sequence, so every planet can read the board-level summary through attention.</p>\n<p><strong>Heads</strong>: the transformer's output representations feed three heads.</p>\n<ul>\n<li><strong>Pointer head.</strong> Every planet produces one logit for each planet on the board through a single attention pass (query·key plus the edge-bias), and a softmax over them picks the target it launches at, with a planet pointing at itself meaning hold, so the launch-or-hold choice sits in the same softmax rather than a separate head. Each of my planets therefore picks its own target in one step, rather than the model first choosing a source planet and then a target, as <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/714324\" target=\"_blank\">Isaiah</a>³ and <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/713483\" target=\"_blank\">Billy</a>⁴ do.</li>\n<li><strong>Fraction head.</strong> This runs after the pointer head so it can see the chosen target. An MLP takes the target's token together with the source and global tokens and regresses the mean and standard deviation of a clipped-Gaussian over the fraction of the garrison to launch. Including the target's token is what lets the launch size adapt to the chosen planet, since a distant or well-defended target needs a different commitment than a weak nearby one.</li>\n<li><strong>Value head.</strong> It pools the planet tokens and the global token into a single board embedding and predicts the position's value.</li>\n</ul>\n<p><strong>Model parameter and FLOP profile.</strong> The table below breaks down the OrbitNet's roughly 1.2 M parameters and FLOPs by its components. Worth noting is that most of the compute lands on the CNNs that process the per-turn series. Perhaps, as <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/713483\" target=\"_blank\">Billy</a>⁴ does, dropping the CNN and instead flattening the series and concatenating it with everything else for the MLP would have been more reasonable. Shrinking the 50-turn forecast horizon would also help, since most fleets arrive within 20 to 30 turns anyway, and both <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/713483\" target=\"_blank\">Billy</a>⁴ and <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/713276\" target=\"_blank\">Simon</a>⁵ run shorter ones (24 and 20).</p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/model_profile.png\" alt=\"\"></p>\n<p><strong>Design notes &amp; discussion:</strong></p>\n<ul>\n<li><strong>Continuous vs discretized.</strong> I started with a discretized fraction head (4, 8, or 16 bins) and switched to the continuous clipped-Gaussian for two reasons. First, once a game was clearly won, the discrete head kept sending small fleets. That is probably harmless, since the game is already decided, but it looks bad to watch, and the continuous head stopped it. Second, for imitation learning, opponents launch with all sorts of fractions, and snapping each one to the nearest bin throws information away, whereas a continuous target keeps it. I have to admit that predicting both μ and σ and sampling during training (argmax at inference) might be a bad idea: σ never really shrinks (its mean settles around 0.88 in the [0,1] fraction space), so just predicting μ would probably have been cleaner, as <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/714324\" target=\"_blank\">Isaiah</a>³ did.</li>\n<li><strong>One action per planet per turn.</strong> My model design means each planet decides a single action per turn, and I did not make the policy autoregressive over several launches. Rolling out N sequential actions would multiply the per-turn inference cost by roughly N, and under the 1 s/turn budget that cost outweighs the extra expressiveness it would buy, so I left it out.</li>\n<li><strong>Cross-planet coordination.</strong> I strongly believe the agent would fight better if planets coordinated, concentrating an attack or a defense once they knew each other's targets. The clean way to get that is an autoregressive policy, but for the inference-cost reason above I ruled it out. So I tried cheaper substitutes in the fraction head instead: pooling the embeddings of the other sources aimed at the same target and feeding that in, and separately giving the head its own cross-planet attention with the edge features. Neither beat the plain MLP, so the final head stays simple.</li>\n</ul>\n<hr>\n<h2>Game engine and environment</h2>\n<p>Before getting into the training of the model (imitation learning and self-play RL), a few setup details worth noting.</p>\n<p><strong>The engines.</strong> I keep several engine implementations in lockstep: the official Kaggle engine, a JAX version for fast self-play training, a cross-architecture JAX version that records replays for local evaluation across 2p and 4p and pits different training updates' weights against each other, and a numpy version for deployment. Each is parity-checked against the official engine on a full game, matched step by step at every intermediate state. The boards are all pre-generated, about 131,000 for 2p and 1,024 for 4p, and evaluation always runs on fresh boards the model has never trained on.</p>\n<p><strong>A per-player fleet cap in training.</strong> The real Kaggle engine puts no limit on how many fleets a player can have in flight. My training environment does: each player is capped at 128 active fleets, independently of the others (256 total in 2p, 512 in 4p), and once a player is at the cap its further launches that turn are dropped. This started as a way to keep the JAX environment on fixed-shape arrays, but it also nudges the model to be cautious and save its launches for the ones that matter. In practice the cap only binds early in training, and afterwards the model stays well clear of it. Since the deploy engine is uncapped, I re-apply the same cap in the numpy inference code so the shipped agent behaves exactly as it did in training.</p>\n<p><strong>One viewpoint for all seats.</strong> All coordinates and features are transformed into the standard p0 viewpoint for inference, so every seat, no matter where the player sits, shares one set of weights.</p>\n<p><strong>Design notes &amp; discussion:</strong></p>\n<ul>\n<li><strong>One pass, not one per player.</strong> According to <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/714324\" target=\"_blank\">Isaiah's write-up</a>³, the per-seat transformation I use is unnecessary and inefficient: because each seat is canonicalized to its own p0 view, I run the network once per player every turn (two passes in 2p, four in 4p) instead of once for the whole board. A single pass can instead produce every source-to-target action, which you then filter by ownership, cutting the rollout forward pass by two to four times.</li>\n<li><strong>A resign mechanism I tried and dropped.</strong> To stop the model from spamming pointless small fleets in the endgame, I tried adding a resign mechanism: a game ends once one side has held over 75% of the ships for 20 straight turns. It can also improve training efficiency. However, it turns out the models would sometimes collapse in a game they had all but won, so I dropped it.</li>\n<li><strong>A first-turn off-by-one.</strong> My training env starts from the step-1 observation (turn 1), while deploy sees the first turn as 0, so only the first turn's turn-based features (turn counter, turns-left, comet countdown) come out one off. I patched it by clamping the deploy feature step to at least 1. Tiny impact anyway.</li>\n<li><strong>Don't train and evaluate on the same boards.</strong> Early on I trained and evaluated on the same 1,024 boards, so models overfit them and looked strong in evaluation. Switching to fresh unseen boards changed the picture completely.</li>\n</ul>\n<hr>\n<h2>Imitation learning</h2>\n<p>I mostly used imitation learning as a fast way to validate models and features, since cloning good play is far cheaper to iterate on than a full RL run. With the competition nearly over, I also wanted to see whether it could hand self-play a better set of initial weights, though it turned out that starting from self-play alone probably reaches a higher ceiling.</p>\n<p><strong>Data curation.</strong> I only collect official replays from after the last engine update on 2026-05-27, so every game runs the same current physics, and I keep the ones where both seats are rated above 1500 Elo, weighting the winner's moves above the loser's. This leaves about 21,000 games, roughly 10 million board states. I found that filtering to these stronger matches produced better models even though it left far fewer games to train on, which lines up with <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/713187\" target=\"_blank\">Slawek</a>⁶, who trained only on the top two players' matches each day.</p>\n<p><strong>Training.</strong> The pointer head is a softmax over target planets, trained by cross-entropy against the planet the expert actually launched at (a planet pointing at itself is a hold), with launch rows up-weighted 6x to counter their low base rate:</p>\n<p>$$\nL_\\text{ptr} = -\\sum_i w_i \\, \\log p_\\theta(t_i), \\qquad w_i = 6 \\text{ for a launch, else } 1.\n$$</p>\n<p>The fraction head predicts the mean and standard deviation (μᵢ, σᵢ) of a Gaussian clipped to [0,1], fit to the fraction the expert sent, fᵢ = ships/garrison, by a negative log-likelihood over the launches only:</p>\n<p>$$\nL_\\text{frac} = -\\sum_{i \\,\\in\\, \\text{launches}} \\log \\mathcal{N}_{[0,1]}(f_i \\mid \\mu_i, \\sigma_i).\n$$</p>\n<p>The total objective is L = L_ptr + ½·L_frac. The value head stays untrained here and is only picked up in self-play. Training runs about 5 epochs (roughly 50,000 updates) with AdamW at a 3e-4 learning rate on a warmup-cosine schedule and batch 1024.</p>\n<p><strong>Evaluation.</strong> I evaluate the imitation performance by playing against the public heuristic agents, <a href=\"https://www.kaggle.com/code/yijiey/orbit-lite-greedy-planner-regroup-submission\" target=\"_blank\">Orbit-Lite</a>⁷, <a href=\"https://www.kaggle.com/code/romantamrazov/orbit-star-wars-lb-max-1224\" target=\"_blank\">Roman</a>⁸, and <a href=\"https://www.kaggle.com/code/shummingfang/orbit-wars-exp48\" target=\"_blank\">Shummingfang</a>⁹. The table below reports it at the best checkpoint: the 2p head-to-head win-rate against each, and for the four-player games, each player's chance of finishing first together with the probability our model ranks above each opponent.</p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/il_vs_public.png\" alt=\"\"></p>\n<hr>\n<h2>Self-play PPO training</h2>\n<p>Drawing on previous agent competitions like <a href=\"https://kaggle.com/competitions/lux-ai-season-3\" target=\"_blank\">Lux AI Season 3</a>¹¹, the choice on the RL side was self-play PPO versus IMPALA. IMPALA is a distributed actor-learner architecture, built to parallelize experience collection across many actors that feed a central learner, and I would rather see how far a single GPU can take me than spend my effort on that. I went with PPO and kept it basic, with a self-play league.</p>\n<p><strong>Reward.</strong> The reward is as simple as it gets: +1 for the winner (first place in 4p) and -1 for everyone else.</p>\n<p><strong>Hyperparameters.</strong> Standard PPO with γ = 0.999, λ = 0.95, a clip of 0.2, and a value-loss weight of 0.5. The learning rate peaks at 1e-4 and cosine-anneals to 3e-5 over 100k updates, where one update is a single rollout's worth of data used for one epoch of training. That rollout is T = 128 steps from 1024 parallel games in 2p (512 in 4p), split into 64 minibatches of about 3,700 samples, with the gradient norm clipped to 1.0. The entropy bonus is 0.01 in 2p and 0.02 in 4p.</p>\n<p><strong>League.</strong> I run a league to keep the opponents diverse and to avoid catastrophic forgetting. Concretely, in 2p, 80% of the games are pure self-play and in the other 20% the learner plays a frozen past checkpoint from a pool. Members are sampled with probability proportional to 1 − winrate (a linear <a href=\"https://www.nature.com/articles/s41586-019-1724-z\" target=\"_blank\">PFSP weight</a>¹⁰), plus a 2% floor. Admission draws only from the periodic checkpoint saves (every 200 updates, and never before update 1000), not from every update: a checkpoint joins the pool once the learner's EMA win-rate against the current reference reaches 70% over at least 1024 games (or after 5000 updates, if it plateaus). The pool holds up to 30 members, evicting the oldest. The 4p league is the same idea adapted to four players: the anchor seat is always the current policy, while each of the other three seats is independently the current policy or a pool member on the same 80/20 split. With no head-to-head win-rate to go on, both the PFSP sampling and the admission gate switch to first-place rate, and a snapshot is admitted once that rate clears 0.35 over 512 games.</p>\n<p><strong>Warm-start vs. random-start.</strong> My submitted 2p agent trains from a random start, not from the imitation model. A random start is weaker at first and trails the warm-started run early on, but somewhere in the 10k to 20k update window it catches up and then pulls ahead, and in the end it is the random-start run that tops the 2p standings in the local-evaluation section. This is like <a href=\"https://www.nature.com/articles/nature24270\" target=\"_blank\">AlphaGo Zero</a>¹², which also gave up its earlier warm-start. The submitted 4p agent is warm-started from the imitation checkpoint. Because its performance was still climbing and only a few days were left, I stuck with it and didn't go back to try a random start.</p>\n<p><strong>Experiment notes &amp; discussion:</strong></p>\n<ul>\n<li><strong>An observation from hyperparameter tuning.</strong> My first 4p run, at lr 3e-4 with an entropy bonus of 0.05, collapsed after a few hundred updates: the planets stopped launching any fleets at all. Lowering the learning rate to 1e-4 and the entropy bonus to 0.02 fixed it, and it never came back.</li>\n<li><strong>Breaking moments.</strong> I did see the model pick up behaviors on its own. For example, when a planet of mine can't be defended, it sends the ships out before the enemy arrives and grabs a weaker planet elsewhere instead, for a net gain.</li>\n<li><strong>…but obvious mistakes remain.</strong> Even in the submitted version I still catch clear errors. Right at the start of a game a valuable +5-production planet sits next door defended by only 20 ships, and instead of taking it while it is cheap the model hoards its fleet and doesn't launch until it has 40 or 50, far later than it should.</li>\n</ul>\n<hr>\n<h2>Local evaluation</h2>\n<p>The gold-standard evaluation is the leaderboard. Submit an agent, let it play everyone else's for a day or two, and read off the rank range it settles into. But that is slow, and only two agents can be up at once, so almost every decision is made locally instead.</p>\n<p>Early on, while I was debugging and building out the model and the training framework, the public heuristics were my benchmark. Afterwards, I keep a pool of agents (training checkpoints and a few frozen past champions), have every possible 2p and 4p pair play each other repeatedly on a separate GPU through the cross-architecture JAX engine, and score each by its win-rate averaged across pairings.</p>\n<p>Taking 2p as the example, the daemon runs in rounds, each following the same fixed order.</p>\n<p><strong>Admit.</strong> A periodic rescan picks up new checkpoints and holds them in a queue, and at the start of a round the whole waiting batch joins the pool.</p>\n<p><strong>Play.</strong> Every pair plays up to a cap of 512 games (256 in 4p). A pool of about 36 checkpoints makes 630 pairs, 35 per checkpoint, and each agent's score is its win-rate averaged equally over its pairs, roughly 480k games in 2p and 215k in 4p across the run. Seats are randomized each game.</p>\n<p><strong>Prune.</strong> When every pair has reached the cap the round is complete, and the pool is trimmed by dropping the lowest-scoring members, active checkpoints down to 30 (10 in 4p) in total but never below 3 per experiment, and each frozen reference down to its best 3. Evicted checkpoints are remembered so they never re-enter.</p>\n<p><strong>Repeat.</strong> The next queued batch is admitted and the loop goes again.</p>\n<p>In 2p, the top two checkpoints were u55000 and u53000. In 4p, the top checkpoint u44000 was paired with u39000. Both were combined with the merge detailed in the inference-time strategy section. The two tables below give the standings behind this. Based on this, the training had not plateaued yet, with the overall win-rate still rising about 5% per 10,000 updates.</p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/cross_2p.png\" alt=\"\"></p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/cross_4p.png\" alt=\"\"></p>\n<hr>\n<h2>Inference-time strategy</h2>\n<p>In local evaluation, a single turn takes only about 20 ms, so I wanted to try whether the spare time could go toward some inference-time improvement.</p>\n<p><strong>Mechanism.</strong> Each turn, both checkpoints propose an action. Where they disagree, the engine plays each candidate forward two or three turns against the other checkpoint and keeps whichever the averaged value heads rate higher. 4p is the same, against three copies of the other. I trust the value head as referee for two reasons: its value explained variance held above 0.95 during training, and it tends to call a swing in win probability before any gap opens in the ship or production counts, as the replay at the top shows, so it reads the position earlier than the raw economy does.</p>\n<p>In local evaluation this only helped once the rollout was at least two turns deep. The table below runs at depth 3 in 2p and depth 2 in 4p, where the merge beats greedy on both tracks.</p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/final_eval.png\" alt=\"\"></p>\n<p><strong>The two submissions.</strong> I made two submissions, one with the inference-time strategy (merge, <code>53993338</code>) and one without (greedy, <code>53993524</code>, just the plain argmax decode), as an ablation to check that the merge's local edge survives the leaderboard's more diverse opponents, though I had a feeling the merge would come out ahead.</p>\n<p><strong>Handling the time budget.</strong> Running depth 3 every turn would blow the time budget, so the depth follows the 60-second overage bank instead, running at depth 3 while more than 20 seconds remain, depth 2 while more than 10 remain, and plain greedy below that. 4p uses a single stage, running depth 2 while more than 20 seconds remain and plain greedy below that.</p>\n<hr>\n<h2>Evaluation Phase</h2>\n<p>For the two weeks after the first phase closed, all agents were fixed, and I kept tracking the game episodes and the leaderboard. The analysis of that run is below.</p>\n<p>The first table pits each of my submissions against each top-20 submission, with the top-20 fixed from a leaderboard snapshot after the first day of evaluation. 2p is the head-to-head win-rate; for 4p I record the win-rate for me and for the opponent over the games where we both play.</p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/eval_h2h.png\" alt=\"\"></p>\n<p>The second table gives the overall win-rate for each submission across the whole evaluation phase.</p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/eval_field.png\" alt=\"\"></p>\n<p>The figure below traces my overall rank and each submission's rank over the evaluation phase.</p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/rank_trends.png\" alt=\"\"></p>\n<p>The results seem to show the inference-time strategy (the merge) holds more of an edge in 2p, while the plain greedy does better in 4p, possibly because the 4p merge folds in a checkpoint that is a little too early.</p>\n<p>The rank trend seems to change once midway, probably pairing nearby agents against each other more often, after which both submissions grew steadier, with the merge clearly the more stable of the two. So far across the evaluation phase my rank has reached as high as 6th and, in the early days, as low as 28th.</p>\n<p>The evaluation was somewhat controversial in the community, mainly because the rank never really converges, so the last few dozen games swing the final standing a lot. Refer to <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/714395\" target=\"_blank\">CPMP's post</a>¹³ and <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/716725\" target=\"_blank\">Ascalon's post</a>¹⁴.</p>\n<hr>\n<h2>Compute and cost</h2>\n<p>Most of the experimentation ran on my local 2× A6000. The two final models were each trained on a rented B200, one for 2p and one for 4p. The 2p run took about 100 hours and the 4p about 98 hours, roughly $630 in total. End to end (game simulation plus the PPO update), the 2p run reached about 36,000 samples per second (SPS) and the 4p run about 28,000, with each player's move in a turn counted as a separate sample, for roughly 13 billion and 9.8 billion samples in total. I only turned to the B200 because I was running out of time in the last few days, and the models still had not plateaued at the submission deadline. The lesson for next time is to settle the framework earlier and leave more time for training and fine-tuning.</p>\n<hr>\n<h2>Results</h2>\n<p>Final Elo: xx, rank xx, swinging between 6th and 28th over the evaluation phase</p>\n<hr>\n<h2>Closing thoughts</h2>\n<p>RL leaves very little room for parameter sweeps, because each run takes so long that you cannot afford many hyperparameter searches or full ablations, so I stopped agonizing over the ones I skipped. Intuition ends up mattering more here than in most supervised settings.</p>\n<p>Scaling is an art, both in RL and in this game. I tried a 4M-parameter model and it performed much worse than my final one at around 1.2M, so it was great to see how <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/714324\" target=\"_blank\">Isaiah</a>³ made scaling work.</p>\n<p>Replays are fun to watch, but beyond that, they build real intuition for what the model and its features are doing. They help tell apart the failure modes, whether the features do not carry enough information, the architecture is the bottleneck, or even a training hyperparameter is off. For example, setting gamma to 1 made the model happy to drag games out to the 500-turn limit.</p>\n<p>To make this game more fun, it could use a few physics-bending abilities, say a skill that speeds every planet up for the next 10 turns, or a comet you can drop on a path you choose.</p>\n<p>Code available at <a href=\"https://github.com/yijieyuan/kaggle-orbitwar\" target=\"_blank\">https://github.com/yijieyuan/kaggle-orbitwar</a>.</p>\n<h2>References</h2>\n<ol>\n<li>Lin Myat Ko (3-comet). <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/697725\" target=\"_blank\">\"Sharing our RL lessons so far\"</a>. Kaggle Orbit Wars discussion.</li>\n<li>Ethan Perez, Florian Strub, Harm de Vries, Vincent Dumoulin, Aaron Courville. <a href=\"https://arxiv.org/abs/1709.07871\" target=\"_blank\">\"FiLM: Visual Reasoning with a General Conditioning Layer\"</a>. AAAI 2018.</li>\n<li>Isaiah Pressman. <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/714324\" target=\"_blank\">\"Scaling Reinforcement Learning to the Stars\"</a>. Kaggle Orbit Wars discussion.</li>\n<li>Billy Bradley. <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/713483\" target=\"_blank\">\"How I made Ender\"</a>. Kaggle Orbit Wars discussion.</li>\n<li>Simon Jégou. <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/713276\" target=\"_blank\">\"N &lt; 10th Solution for Orbit Wars\"</a>. Kaggle Orbit Wars discussion.</li>\n<li>Slawek Biel. <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/713187\" target=\"_blank\">\"Last day scramble\"</a>. Kaggle Orbit Wars discussion.</li>\n<li>Slawek Biel. \"Orbit-Lite: Greedy Planner &amp; Regroup\". <a href=\"https://www.kaggle.com/code/yijiey/orbit-lite-greedy-planner-regroup-submission\" target=\"_blank\">Kaggle notebook</a>.</li>\n<li>Roman Tamrazov. <a href=\"https://www.kaggle.com/code/romantamrazov/orbit-star-wars-lb-max-1224\" target=\"_blank\">\"orbit-star-wars lb-max-1224\"</a>. Kaggle notebook.</li>\n<li>shummingfang. <a href=\"https://www.kaggle.com/code/shummingfang/orbit-wars-exp48\" target=\"_blank\">\"orbit-wars-exp48\"</a>. Kaggle notebook.</li>\n<li>Oriol Vinyals et al. <a href=\"https://www.nature.com/articles/s41586-019-1724-z\" target=\"_blank\">\"Grandmaster level in StarCraft II using multi-agent reinforcement learning\"</a>. Nature 2019.</li>\n<li>Stone Tao, Akarsh Kumar, Bovard Doerschuk-Tiberi, Isabelle Pan, Addison Howard, and Hao Su. <a href=\"https://kaggle.com/competitions/lux-ai-season-3\" target=\"_blank\">\"Lux AI Season 3\"</a>. NeurIPS 2024, Kaggle.</li>\n<li>David Silver et al. <a href=\"https://www.nature.com/articles/nature24270\" target=\"_blank\">\"Mastering the game of Go without human knowledge\"</a>. Nature 2017.</li>\n<li>CPMP. <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/714395\" target=\"_blank\">\"Will the LB stabilize?\"</a>. Kaggle Orbit Wars discussion.</li>\n<li>Ascalon. <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/716725\" target=\"_blank\">\"Proposal to improve ranking convergence\"</a>. Kaggle Orbit Wars discussion.</li>\n</ol>",
  "comments": [],
  "comment_count": 0,
  "messages": [
    {
      "id": 3490061,
      "postDate": "2026-07-06T04:35:16.800Z",
      "content": "<p>First, I'd like to thank the hosts for organizing such an engaging competition and congratulate all the winners. Of all the competitions I've taken part in on Kaggle, this is the one I had the most fun with. </p>\n<p>I first spent a few weeks on the competition writing pure heuristic bots, and for a while I thought the top of the leaderboard would end up being either a strong heuristic or a heuristic with an RL fine-tune on top. What changed my mind was inference speed. A strong heuristic needs a lot of physics computation and a number of small optimizations. For example, just to find the most ships a player could land on a given planet by a given turn, I usually needed seconds per turn. So I took a break, and after reading <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/697725\" target=\"_blank\">3-comet's post</a>¹, a very generous share, I came back and approached the game with RL instead. My guiding philosophy for RL in this competition is to give the model the full context it needs, plus the right architectural inductive bias for efficient training.</p>\n<p><strong>TL;DR.</strong> My final submissions use a transformer with one token per planet (overall ~1.2M parameters), trained separately for the 2p and 4p tracks by self-play PPO with league training on a single GPU, 2p from a random start and 4p from an imitation-learning initialization. The final model is chosen by local evaluation, the average win-rate over all pairs in a pool of candidates. An inference-time strategy adds a small boost on top, and one uses it while the other does not. The agent beat <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/714324\" target=\"_blank\">Isaiah</a>³'s agent once during the evaluation phase. Code available at <a href=\"https://github.com/yijieyuan/kaggle-orbitwar\" target=\"_blank\">https://github.com/yijieyuan/kaggle-orbitwar</a>.</p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/luca_vs_tufa/luca_vs_tufa_ep84096506.gif\" alt=\"\"></p>\n<p>Let's get into the full solution, which covers game representation &amp; feature engineering / model architecture / game engine and environment / imitation learning / self-play PPO training / local evaluation / inference-time strategy / evaluation phase / compute and cost.</p>\n<hr>\n<h2>Game representation &amp; feature engineering</h2>\n<p>The game state is a variable-size set of planets, comets, and in-flight fleets. A transformer handles that shape naturally, since it takes a variable-length set of tokens, and I can make one token per planet and one per fleet.</p>\n<p>I ended up deciding that fleets don't get their own tokens: I wanted to keep the token types to a minimum, and the fleet count is unbounded, so my per-turn compute would grow linearly with the number of fleets in play, which can be easily taken advantage of; it also doesn't quite fit with JAX, which needs predefined tensor shapes. So I decided to incorporate each fleet's information into the planet's future state, which keeps the tokens to a bounded set of at most 44 (40 planets + 4 comets).</p>\n<p>This works because a fleet only affects its source and target planets, at its launch turn and arrival turn respectively, changing garrison or ownership. So a passive rollout (roll the game forward with no further launches from either side) captures everything: the garrison and ownership it projects for each planet, at each future turn, carry the full information about every in-flight fleet. In the worst case the rollout would need about 140 turns (a one-ship fleet crossing the full diagonal of 100·√2 at its minimum speed of 1/turn), but in practice the vast majority of fleets arrive within 20 to 30 turns.</p>\n<p><strong>Planet token</strong>: the table below covers everything that goes into it, namely some basic planet state and the passive-projection features, plus a few hand-drafted features from the series that I expect would be hard for the NN to learn from the raw projection.</p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/token_planet.png\" alt=\"\"></p>\n<p><strong>Global token</strong>: board-level information is injected via a single global token. Like the planet token, it covers some basic board state (turn, planet count, and so on), the ship and production differences at each turn of the passive rollout, plus a few hand-drafted features built on those.</p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/token_global.png\" alt=\"\"></p>\n<p><strong>Pair / edge features</strong>: planet-pair geometry enters as an edge feature (P × P × 6), used only as an attention bias inside the transformer and the pointer head rather than as a token. It is seat-independent, so it does not change for 4p.</p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/edge_features.png\" alt=\"\"></p>\n<p><strong>Design notes &amp; discussion:</strong></p>\n<ul>\n<li>For some features, the NN could in principle learn them from the raw inputs, so they could be redundant. Adding them costs extra compute each turn and goes against Sutton's Bitter Lesson. When deciding which features to use, I ran several rounds of selection judged on their imitation-learning performance (more in the imitation-learning section), and tried and later dropped a number of ideas along the way, such as planet trajectories. Really telling which features help would need an ablation study over complete runs, but that is tricky because each run is slow to reach a plateau and often needs several restarts that can land at quite different performance.</li>\n<li>Some of the one-hot features could have been a single boolean in 2p, but I kept them one-hot so the same layout extends cleanly to 4p.</li>\n<li>A fleet can hit a passing comet before it reaches its intended target, so a new comet changes the rollout; the forecast features are therefore recomputed whenever one spawns.</li>\n<li>Back when the fraction head was discretized, I tried adding each bin's edge information and its corresponding fleet speed and arrival turn as features, but they had no effect.</li>\n</ul>\n<p>That's everything the model gets. Next up is the architecture, which decides how well these features actually work together.</p>\n<hr>\n<h2>Model architecture</h2>\n<p>The OrbitNet used in the final submission has roughly 1.2 M parameters, and the same architecture serves both the 2p and 4p agents. MLPs and CNNs perform the token embedding, followed by a multi-head transformer trunk that mixes the tokens, and the result feeds three heads: a pointer head picks the target planet, a fraction head chooses how much of the garrison to send, and a value head scores the position.</p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/model_architecture.jpg\" alt=\"\"></p>\n<p><strong>Token embedding</strong>: the static and rollout-summary features and the forecast series are processed separately, then fused into one token. The former are joined into one vector through a small MLP, and the latter is run through a 1-D CNN over its 50 turns then a mean, max, and attention pool (with the attention query from the static features) so the token can focus on the turns that matter. The two embeddings are concatenated, conditioned by <a href=\"https://arxiv.org/abs/1709.07871\" target=\"_blank\">FiLM</a>² (a zero-initialized projection of the global game-summary vector scales and shifts the fused planet features), and passed through a token MLP into the 128-d planet token, up to 44 of them. The single global token is built the same way, from the game-summary vector and a 1-D CNN embedding of the economy series.</p>\n<p><strong>Main transformer</strong>: the planet tokens and the global token form a length-(P+1) sequence of 128-d tokens, run through a 6-layer pre-LN transformer with 4 heads. On top of ordinary attention, the planet-pair edge features enter as an additive bias: a tiny MLP turns the (P × P × 6) pair features into a per-head bias added to the attention scores, so board geometry (who can reach whom, and how far and fast) directly shapes which planets attend to which. The global token sits in the same sequence, so every planet can read the board-level summary through attention.</p>\n<p><strong>Heads</strong>: the transformer's output representations feed three heads.</p>\n<ul>\n<li><strong>Pointer head.</strong> Every planet produces one logit for each planet on the board through a single attention pass (query·key plus the edge-bias), and a softmax over them picks the target it launches at, with a planet pointing at itself meaning hold, so the launch-or-hold choice sits in the same softmax rather than a separate head. Each of my planets therefore picks its own target in one step, rather than the model first choosing a source planet and then a target, as <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/714324\" target=\"_blank\">Isaiah</a>³ and <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/713483\" target=\"_blank\">Billy</a>⁴ do.</li>\n<li><strong>Fraction head.</strong> This runs after the pointer head so it can see the chosen target. An MLP takes the target's token together with the source and global tokens and regresses the mean and standard deviation of a clipped-Gaussian over the fraction of the garrison to launch. Including the target's token is what lets the launch size adapt to the chosen planet, since a distant or well-defended target needs a different commitment than a weak nearby one.</li>\n<li><strong>Value head.</strong> It pools the planet tokens and the global token into a single board embedding and predicts the position's value.</li>\n</ul>\n<p><strong>Model parameter and FLOP profile.</strong> The table below breaks down the OrbitNet's roughly 1.2 M parameters and FLOPs by its components. Worth noting is that most of the compute lands on the CNNs that process the per-turn series. Perhaps, as <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/713483\" target=\"_blank\">Billy</a>⁴ does, dropping the CNN and instead flattening the series and concatenating it with everything else for the MLP would have been more reasonable. Shrinking the 50-turn forecast horizon would also help, since most fleets arrive within 20 to 30 turns anyway, and both <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/713483\" target=\"_blank\">Billy</a>⁴ and <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/713276\" target=\"_blank\">Simon</a>⁵ run shorter ones (24 and 20).</p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/model_profile.png\" alt=\"\"></p>\n<p><strong>Design notes &amp; discussion:</strong></p>\n<ul>\n<li><strong>Continuous vs discretized.</strong> I started with a discretized fraction head (4, 8, or 16 bins) and switched to the continuous clipped-Gaussian for two reasons. First, once a game was clearly won, the discrete head kept sending small fleets. That is probably harmless, since the game is already decided, but it looks bad to watch, and the continuous head stopped it. Second, for imitation learning, opponents launch with all sorts of fractions, and snapping each one to the nearest bin throws information away, whereas a continuous target keeps it. I have to admit that predicting both μ and σ and sampling during training (argmax at inference) might be a bad idea: σ never really shrinks (its mean settles around 0.88 in the [0,1] fraction space), so just predicting μ would probably have been cleaner, as <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/714324\" target=\"_blank\">Isaiah</a>³ did.</li>\n<li><strong>One action per planet per turn.</strong> My model design means each planet decides a single action per turn, and I did not make the policy autoregressive over several launches. Rolling out N sequential actions would multiply the per-turn inference cost by roughly N, and under the 1 s/turn budget that cost outweighs the extra expressiveness it would buy, so I left it out.</li>\n<li><strong>Cross-planet coordination.</strong> I strongly believe the agent would fight better if planets coordinated, concentrating an attack or a defense once they knew each other's targets. The clean way to get that is an autoregressive policy, but for the inference-cost reason above I ruled it out. So I tried cheaper substitutes in the fraction head instead: pooling the embeddings of the other sources aimed at the same target and feeding that in, and separately giving the head its own cross-planet attention with the edge features. Neither beat the plain MLP, so the final head stays simple.</li>\n</ul>\n<hr>\n<h2>Game engine and environment</h2>\n<p>Before getting into the training of the model (imitation learning and self-play RL), a few setup details worth noting.</p>\n<p><strong>The engines.</strong> I keep several engine implementations in lockstep: the official Kaggle engine, a JAX version for fast self-play training, a cross-architecture JAX version that records replays for local evaluation across 2p and 4p and pits different training updates' weights against each other, and a numpy version for deployment. Each is parity-checked against the official engine on a full game, matched step by step at every intermediate state. The boards are all pre-generated, about 131,000 for 2p and 1,024 for 4p, and evaluation always runs on fresh boards the model has never trained on.</p>\n<p><strong>A per-player fleet cap in training.</strong> The real Kaggle engine puts no limit on how many fleets a player can have in flight. My training environment does: each player is capped at 128 active fleets, independently of the others (256 total in 2p, 512 in 4p), and once a player is at the cap its further launches that turn are dropped. This started as a way to keep the JAX environment on fixed-shape arrays, but it also nudges the model to be cautious and save its launches for the ones that matter. In practice the cap only binds early in training, and afterwards the model stays well clear of it. Since the deploy engine is uncapped, I re-apply the same cap in the numpy inference code so the shipped agent behaves exactly as it did in training.</p>\n<p><strong>One viewpoint for all seats.</strong> All coordinates and features are transformed into the standard p0 viewpoint for inference, so every seat, no matter where the player sits, shares one set of weights.</p>\n<p><strong>Design notes &amp; discussion:</strong></p>\n<ul>\n<li><strong>One pass, not one per player.</strong> According to <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/714324\" target=\"_blank\">Isaiah's write-up</a>³, the per-seat transformation I use is unnecessary and inefficient: because each seat is canonicalized to its own p0 view, I run the network once per player every turn (two passes in 2p, four in 4p) instead of once for the whole board. A single pass can instead produce every source-to-target action, which you then filter by ownership, cutting the rollout forward pass by two to four times.</li>\n<li><strong>A resign mechanism I tried and dropped.</strong> To stop the model from spamming pointless small fleets in the endgame, I tried adding a resign mechanism: a game ends once one side has held over 75% of the ships for 20 straight turns. It can also improve training efficiency. However, it turns out the models would sometimes collapse in a game they had all but won, so I dropped it.</li>\n<li><strong>A first-turn off-by-one.</strong> My training env starts from the step-1 observation (turn 1), while deploy sees the first turn as 0, so only the first turn's turn-based features (turn counter, turns-left, comet countdown) come out one off. I patched it by clamping the deploy feature step to at least 1. Tiny impact anyway.</li>\n<li><strong>Don't train and evaluate on the same boards.</strong> Early on I trained and evaluated on the same 1,024 boards, so models overfit them and looked strong in evaluation. Switching to fresh unseen boards changed the picture completely.</li>\n</ul>\n<hr>\n<h2>Imitation learning</h2>\n<p>I mostly used imitation learning as a fast way to validate models and features, since cloning good play is far cheaper to iterate on than a full RL run. With the competition nearly over, I also wanted to see whether it could hand self-play a better set of initial weights, though it turned out that starting from self-play alone probably reaches a higher ceiling.</p>\n<p><strong>Data curation.</strong> I only collect official replays from after the last engine update on 2026-05-27, so every game runs the same current physics, and I keep the ones where both seats are rated above 1500 Elo, weighting the winner's moves above the loser's. This leaves about 21,000 games, roughly 10 million board states. I found that filtering to these stronger matches produced better models even though it left far fewer games to train on, which lines up with <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/713187\" target=\"_blank\">Slawek</a>⁶, who trained only on the top two players' matches each day.</p>\n<p><strong>Training.</strong> The pointer head is a softmax over target planets, trained by cross-entropy against the planet the expert actually launched at (a planet pointing at itself is a hold), with launch rows up-weighted 6x to counter their low base rate:</p>\n<p>$$\nL_\\text{ptr} = -\\sum_i w_i \\, \\log p_\\theta(t_i), \\qquad w_i = 6 \\text{ for a launch, else } 1.\n$$</p>\n<p>The fraction head predicts the mean and standard deviation (μᵢ, σᵢ) of a Gaussian clipped to [0,1], fit to the fraction the expert sent, fᵢ = ships/garrison, by a negative log-likelihood over the launches only:</p>\n<p>$$\nL_\\text{frac} = -\\sum_{i \\,\\in\\, \\text{launches}} \\log \\mathcal{N}_{[0,1]}(f_i \\mid \\mu_i, \\sigma_i).\n$$</p>\n<p>The total objective is L = L_ptr + ½·L_frac. The value head stays untrained here and is only picked up in self-play. Training runs about 5 epochs (roughly 50,000 updates) with AdamW at a 3e-4 learning rate on a warmup-cosine schedule and batch 1024.</p>\n<p><strong>Evaluation.</strong> I evaluate the imitation performance by playing against the public heuristic agents, <a href=\"https://www.kaggle.com/code/yijiey/orbit-lite-greedy-planner-regroup-submission\" target=\"_blank\">Orbit-Lite</a>⁷, <a href=\"https://www.kaggle.com/code/romantamrazov/orbit-star-wars-lb-max-1224\" target=\"_blank\">Roman</a>⁸, and <a href=\"https://www.kaggle.com/code/shummingfang/orbit-wars-exp48\" target=\"_blank\">Shummingfang</a>⁹. The table below reports it at the best checkpoint: the 2p head-to-head win-rate against each, and for the four-player games, each player's chance of finishing first together with the probability our model ranks above each opponent.</p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/il_vs_public.png\" alt=\"\"></p>\n<hr>\n<h2>Self-play PPO training</h2>\n<p>Drawing on previous agent competitions like <a href=\"https://kaggle.com/competitions/lux-ai-season-3\" target=\"_blank\">Lux AI Season 3</a>¹¹, the choice on the RL side was self-play PPO versus IMPALA. IMPALA is a distributed actor-learner architecture, built to parallelize experience collection across many actors that feed a central learner, and I would rather see how far a single GPU can take me than spend my effort on that. I went with PPO and kept it basic, with a self-play league.</p>\n<p><strong>Reward.</strong> The reward is as simple as it gets: +1 for the winner (first place in 4p) and -1 for everyone else.</p>\n<p><strong>Hyperparameters.</strong> Standard PPO with γ = 0.999, λ = 0.95, a clip of 0.2, and a value-loss weight of 0.5. The learning rate peaks at 1e-4 and cosine-anneals to 3e-5 over 100k updates, where one update is a single rollout's worth of data used for one epoch of training. That rollout is T = 128 steps from 1024 parallel games in 2p (512 in 4p), split into 64 minibatches of about 3,700 samples, with the gradient norm clipped to 1.0. The entropy bonus is 0.01 in 2p and 0.02 in 4p.</p>\n<p><strong>League.</strong> I run a league to keep the opponents diverse and to avoid catastrophic forgetting. Concretely, in 2p, 80% of the games are pure self-play and in the other 20% the learner plays a frozen past checkpoint from a pool. Members are sampled with probability proportional to 1 − winrate (a linear <a href=\"https://www.nature.com/articles/s41586-019-1724-z\" target=\"_blank\">PFSP weight</a>¹⁰), plus a 2% floor. Admission draws only from the periodic checkpoint saves (every 200 updates, and never before update 1000), not from every update: a checkpoint joins the pool once the learner's EMA win-rate against the current reference reaches 70% over at least 1024 games (or after 5000 updates, if it plateaus). The pool holds up to 30 members, evicting the oldest. The 4p league is the same idea adapted to four players: the anchor seat is always the current policy, while each of the other three seats is independently the current policy or a pool member on the same 80/20 split. With no head-to-head win-rate to go on, both the PFSP sampling and the admission gate switch to first-place rate, and a snapshot is admitted once that rate clears 0.35 over 512 games.</p>\n<p><strong>Warm-start vs. random-start.</strong> My submitted 2p agent trains from a random start, not from the imitation model. A random start is weaker at first and trails the warm-started run early on, but somewhere in the 10k to 20k update window it catches up and then pulls ahead, and in the end it is the random-start run that tops the 2p standings in the local-evaluation section. This is like <a href=\"https://www.nature.com/articles/nature24270\" target=\"_blank\">AlphaGo Zero</a>¹², which also gave up its earlier warm-start. The submitted 4p agent is warm-started from the imitation checkpoint. Because its performance was still climbing and only a few days were left, I stuck with it and didn't go back to try a random start.</p>\n<p><strong>Experiment notes &amp; discussion:</strong></p>\n<ul>\n<li><strong>An observation from hyperparameter tuning.</strong> My first 4p run, at lr 3e-4 with an entropy bonus of 0.05, collapsed after a few hundred updates: the planets stopped launching any fleets at all. Lowering the learning rate to 1e-4 and the entropy bonus to 0.02 fixed it, and it never came back.</li>\n<li><strong>Breaking moments.</strong> I did see the model pick up behaviors on its own. For example, when a planet of mine can't be defended, it sends the ships out before the enemy arrives and grabs a weaker planet elsewhere instead, for a net gain.</li>\n<li><strong>…but obvious mistakes remain.</strong> Even in the submitted version I still catch clear errors. Right at the start of a game a valuable +5-production planet sits next door defended by only 20 ships, and instead of taking it while it is cheap the model hoards its fleet and doesn't launch until it has 40 or 50, far later than it should.</li>\n</ul>\n<hr>\n<h2>Local evaluation</h2>\n<p>The gold-standard evaluation is the leaderboard. Submit an agent, let it play everyone else's for a day or two, and read off the rank range it settles into. But that is slow, and only two agents can be up at once, so almost every decision is made locally instead.</p>\n<p>Early on, while I was debugging and building out the model and the training framework, the public heuristics were my benchmark. Afterwards, I keep a pool of agents (training checkpoints and a few frozen past champions), have every possible 2p and 4p pair play each other repeatedly on a separate GPU through the cross-architecture JAX engine, and score each by its win-rate averaged across pairings.</p>\n<p>Taking 2p as the example, the daemon runs in rounds, each following the same fixed order.</p>\n<p><strong>Admit.</strong> A periodic rescan picks up new checkpoints and holds them in a queue, and at the start of a round the whole waiting batch joins the pool.</p>\n<p><strong>Play.</strong> Every pair plays up to a cap of 512 games (256 in 4p). A pool of about 36 checkpoints makes 630 pairs, 35 per checkpoint, and each agent's score is its win-rate averaged equally over its pairs, roughly 480k games in 2p and 215k in 4p across the run. Seats are randomized each game.</p>\n<p><strong>Prune.</strong> When every pair has reached the cap the round is complete, and the pool is trimmed by dropping the lowest-scoring members, active checkpoints down to 30 (10 in 4p) in total but never below 3 per experiment, and each frozen reference down to its best 3. Evicted checkpoints are remembered so they never re-enter.</p>\n<p><strong>Repeat.</strong> The next queued batch is admitted and the loop goes again.</p>\n<p>In 2p, the top two checkpoints were u55000 and u53000. In 4p, the top checkpoint u44000 was paired with u39000. Both were combined with the merge detailed in the inference-time strategy section. The two tables below give the standings behind this. Based on this, the training had not plateaued yet, with the overall win-rate still rising about 5% per 10,000 updates.</p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/cross_2p.png\" alt=\"\"></p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/cross_4p.png\" alt=\"\"></p>\n<hr>\n<h2>Inference-time strategy</h2>\n<p>In local evaluation, a single turn takes only about 20 ms, so I wanted to try whether the spare time could go toward some inference-time improvement.</p>\n<p><strong>Mechanism.</strong> Each turn, both checkpoints propose an action. Where they disagree, the engine plays each candidate forward two or three turns against the other checkpoint and keeps whichever the averaged value heads rate higher. 4p is the same, against three copies of the other. I trust the value head as referee for two reasons: its value explained variance held above 0.95 during training, and it tends to call a swing in win probability before any gap opens in the ship or production counts, as the replay at the top shows, so it reads the position earlier than the raw economy does.</p>\n<p>In local evaluation this only helped once the rollout was at least two turns deep. The table below runs at depth 3 in 2p and depth 2 in 4p, where the merge beats greedy on both tracks.</p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/final_eval.png\" alt=\"\"></p>\n<p><strong>The two submissions.</strong> I made two submissions, one with the inference-time strategy (merge, <code>53993338</code>) and one without (greedy, <code>53993524</code>, just the plain argmax decode), as an ablation to check that the merge's local edge survives the leaderboard's more diverse opponents, though I had a feeling the merge would come out ahead.</p>\n<p><strong>Handling the time budget.</strong> Running depth 3 every turn would blow the time budget, so the depth follows the 60-second overage bank instead, running at depth 3 while more than 20 seconds remain, depth 2 while more than 10 remain, and plain greedy below that. 4p uses a single stage, running depth 2 while more than 20 seconds remain and plain greedy below that.</p>\n<hr>\n<h2>Evaluation Phase</h2>\n<p>For the two weeks after the first phase closed, all agents were fixed, and I kept tracking the game episodes and the leaderboard. The analysis of that run is below.</p>\n<p>The first table pits each of my submissions against each top-20 submission, with the top-20 fixed from a leaderboard snapshot after the first day of evaluation. 2p is the head-to-head win-rate; for 4p I record the win-rate for me and for the opponent over the games where we both play.</p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/eval_h2h.png\" alt=\"\"></p>\n<p>The second table gives the overall win-rate for each submission across the whole evaluation phase.</p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/eval_field.png\" alt=\"\"></p>\n<p>The figure below traces my overall rank and each submission's rank over the evaluation phase.</p>\n<p><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/rank_trends.png\" alt=\"\"></p>\n<p>The results seem to show the inference-time strategy (the merge) holds more of an edge in 2p, while the plain greedy does better in 4p, possibly because the 4p merge folds in a checkpoint that is a little too early.</p>\n<p>The rank trend seems to change once midway, probably pairing nearby agents against each other more often, after which both submissions grew steadier, with the merge clearly the more stable of the two. So far across the evaluation phase my rank has reached as high as 6th and, in the early days, as low as 28th.</p>\n<p>The evaluation was somewhat controversial in the community, mainly because the rank never really converges, so the last few dozen games swing the final standing a lot. Refer to <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/714395\" target=\"_blank\">CPMP's post</a>¹³ and <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/716725\" target=\"_blank\">Ascalon's post</a>¹⁴.</p>\n<hr>\n<h2>Compute and cost</h2>\n<p>Most of the experimentation ran on my local 2× A6000. The two final models were each trained on a rented B200, one for 2p and one for 4p. The 2p run took about 100 hours and the 4p about 98 hours, roughly $630 in total. End to end (game simulation plus the PPO update), the 2p run reached about 36,000 samples per second (SPS) and the 4p run about 28,000, with each player's move in a turn counted as a separate sample, for roughly 13 billion and 9.8 billion samples in total. I only turned to the B200 because I was running out of time in the last few days, and the models still had not plateaued at the submission deadline. The lesson for next time is to settle the framework earlier and leave more time for training and fine-tuning.</p>\n<hr>\n<h2>Results</h2>\n<p>Final Elo: xx, rank xx, swinging between 6th and 28th over the evaluation phase</p>\n<hr>\n<h2>Closing thoughts</h2>\n<p>RL leaves very little room for parameter sweeps, because each run takes so long that you cannot afford many hyperparameter searches or full ablations, so I stopped agonizing over the ones I skipped. Intuition ends up mattering more here than in most supervised settings.</p>\n<p>Scaling is an art, both in RL and in this game. I tried a 4M-parameter model and it performed much worse than my final one at around 1.2M, so it was great to see how <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/714324\" target=\"_blank\">Isaiah</a>³ made scaling work.</p>\n<p>Replays are fun to watch, but beyond that, they build real intuition for what the model and its features are doing. They help tell apart the failure modes, whether the features do not carry enough information, the architecture is the bottleneck, or even a training hyperparameter is off. For example, setting gamma to 1 made the model happy to drag games out to the 500-turn limit.</p>\n<p>To make this game more fun, it could use a few physics-bending abilities, say a skill that speeds every planet up for the next 10 turns, or a comet you can drop on a path you choose.</p>\n<p>Code available at <a href=\"https://github.com/yijieyuan/kaggle-orbitwar\" target=\"_blank\">https://github.com/yijieyuan/kaggle-orbitwar</a>.</p>\n<h2>References</h2>\n<ol>\n<li>Lin Myat Ko (3-comet). <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/697725\" target=\"_blank\">\"Sharing our RL lessons so far\"</a>. Kaggle Orbit Wars discussion.</li>\n<li>Ethan Perez, Florian Strub, Harm de Vries, Vincent Dumoulin, Aaron Courville. <a href=\"https://arxiv.org/abs/1709.07871\" target=\"_blank\">\"FiLM: Visual Reasoning with a General Conditioning Layer\"</a>. AAAI 2018.</li>\n<li>Isaiah Pressman. <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/714324\" target=\"_blank\">\"Scaling Reinforcement Learning to the Stars\"</a>. Kaggle Orbit Wars discussion.</li>\n<li>Billy Bradley. <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/713483\" target=\"_blank\">\"How I made Ender\"</a>. Kaggle Orbit Wars discussion.</li>\n<li>Simon Jégou. <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/713276\" target=\"_blank\">\"N &lt; 10th Solution for Orbit Wars\"</a>. Kaggle Orbit Wars discussion.</li>\n<li>Slawek Biel. <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/713187\" target=\"_blank\">\"Last day scramble\"</a>. Kaggle Orbit Wars discussion.</li>\n<li>Slawek Biel. \"Orbit-Lite: Greedy Planner &amp; Regroup\". <a href=\"https://www.kaggle.com/code/yijiey/orbit-lite-greedy-planner-regroup-submission\" target=\"_blank\">Kaggle notebook</a>.</li>\n<li>Roman Tamrazov. <a href=\"https://www.kaggle.com/code/romantamrazov/orbit-star-wars-lb-max-1224\" target=\"_blank\">\"orbit-star-wars lb-max-1224\"</a>. Kaggle notebook.</li>\n<li>shummingfang. <a href=\"https://www.kaggle.com/code/shummingfang/orbit-wars-exp48\" target=\"_blank\">\"orbit-wars-exp48\"</a>. Kaggle notebook.</li>\n<li>Oriol Vinyals et al. <a href=\"https://www.nature.com/articles/s41586-019-1724-z\" target=\"_blank\">\"Grandmaster level in StarCraft II using multi-agent reinforcement learning\"</a>. Nature 2019.</li>\n<li>Stone Tao, Akarsh Kumar, Bovard Doerschuk-Tiberi, Isabelle Pan, Addison Howard, and Hao Su. <a href=\"https://kaggle.com/competitions/lux-ai-season-3\" target=\"_blank\">\"Lux AI Season 3\"</a>. NeurIPS 2024, Kaggle.</li>\n<li>David Silver et al. <a href=\"https://www.nature.com/articles/nature24270\" target=\"_blank\">\"Mastering the game of Go without human knowledge\"</a>. Nature 2017.</li>\n<li>CPMP. <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/714395\" target=\"_blank\">\"Will the LB stabilize?\"</a>. Kaggle Orbit Wars discussion.</li>\n<li>Ascalon. <a href=\"https://www.kaggle.com/competitions/orbit-wars/discussion/716725\" target=\"_blank\">\"Proposal to improve ranking convergence\"</a>. Kaggle Orbit Wars discussion.</li>\n</ol>",
      "rawMarkdown": "First, I'd like to thank the hosts for organizing such an engaging competition and congratulate all the winners. Of all the competitions I've taken part in on Kaggle, this is the one I had the most fun with. \n\nI first spent a few weeks on the competition writing pure heuristic bots, and for a while I thought the top of the leaderboard would end up being either a strong heuristic or a heuristic with an RL fine-tune on top. What changed my mind was inference speed. A strong heuristic needs a lot of physics computation and a number of small optimizations. For example, just to find the most ships a player could land on a given planet by a given turn, I usually needed seconds per turn. So I took a break, and after reading [3-comet's post](https://www.kaggle.com/competitions/orbit-wars/discussion/697725)¹, a very generous share, I came back and approached the game with RL instead. My guiding philosophy for RL in this competition is to give the model the full context it needs, plus the right architectural inductive bias for efficient training.\n\n**TL;DR.** My final submissions use a transformer with one token per planet (overall ~1.2M parameters), trained separately for the 2p and 4p tracks by self-play PPO with league training on a single GPU, 2p from a random start and 4p from an imitation-learning initialization. The final model is chosen by local evaluation, the average win-rate over all pairs in a pool of candidates. An inference-time strategy adds a small boost on top, and one uses it while the other does not. The agent beat [Isaiah](https://www.kaggle.com/competitions/orbit-wars/discussion/714324)³'s agent once during the evaluation phase. Code available at https://github.com/yijieyuan/kaggle-orbitwar.\n\n<p align=\"center\"><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/luca_vs_tufa/luca_vs_tufa_ep84096506.gif\" alt=\"\" width=\"100%\"></p>\n\nLet's get into the full solution, which covers game representation & feature engineering / model architecture / game engine and environment / imitation learning / self-play PPO training / local evaluation / inference-time strategy / evaluation phase / compute and cost.\n\n---\n\n## Game representation & feature engineering\n\nThe game state is a variable-size set of planets, comets, and in-flight fleets. A transformer handles that shape naturally, since it takes a variable-length set of tokens, and I can make one token per planet and one per fleet.\n\nI ended up deciding that fleets don't get their own tokens: I wanted to keep the token types to a minimum, and the fleet count is unbounded, so my per-turn compute would grow linearly with the number of fleets in play, which can be easily taken advantage of; it also doesn't quite fit with JAX, which needs predefined tensor shapes. So I decided to incorporate each fleet's information into the planet's future state, which keeps the tokens to a bounded set of at most 44 (40 planets + 4 comets).\n\nThis works because a fleet only affects its source and target planets, at its launch turn and arrival turn respectively, changing garrison or ownership. So a passive rollout (roll the game forward with no further launches from either side) captures everything: the garrison and ownership it projects for each planet, at each future turn, carry the full information about every in-flight fleet. In the worst case the rollout would need about 140 turns (a one-ship fleet crossing the full diagonal of 100·√2 at its minimum speed of 1/turn), but in practice the vast majority of fleets arrive within 20 to 30 turns.\n\n**Planet token**: the table below covers everything that goes into it, namely some basic planet state and the passive-projection features, plus a few hand-drafted features from the series that I expect would be hard for the NN to learn from the raw projection.\n\n<p align=\"center\"><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/token_planet.png\" alt=\"\" width=\"95%\"></p>\n\n**Global token**: board-level information is injected via a single global token. Like the planet token, it covers some basic board state (turn, planet count, and so on), the ship and production differences at each turn of the passive rollout, plus a few hand-drafted features built on those.\n\n<p align=\"center\"><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/token_global.png\" alt=\"\" width=\"95%\"></p>\n\n**Pair / edge features**: planet-pair geometry enters as an edge feature (P × P × 6), used only as an attention bias inside the transformer and the pointer head rather than as a token. It is seat-independent, so it does not change for 4p.\n\n<p align=\"center\"><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/edge_features.png\" alt=\"\" width=\"95%\"></p>\n\n**Design notes & discussion:**\n\n- For some features, the NN could in principle learn them from the raw inputs, so they could be redundant. Adding them costs extra compute each turn and goes against Sutton's Bitter Lesson. When deciding which features to use, I ran several rounds of selection judged on their imitation-learning performance (more in the imitation-learning section), and tried and later dropped a number of ideas along the way, such as planet trajectories. Really telling which features help would need an ablation study over complete runs, but that is tricky because each run is slow to reach a plateau and often needs several restarts that can land at quite different performance.\n- Some of the one-hot features could have been a single boolean in 2p, but I kept them one-hot so the same layout extends cleanly to 4p.\n- A fleet can hit a passing comet before it reaches its intended target, so a new comet changes the rollout; the forecast features are therefore recomputed whenever one spawns.\n- Back when the fraction head was discretized, I tried adding each bin's edge information and its corresponding fleet speed and arrival turn as features, but they had no effect.\n\nThat's everything the model gets. Next up is the architecture, which decides how well these features actually work together.\n\n---\n\n## Model architecture\n\nThe OrbitNet used in the final submission has roughly 1.2 M parameters, and the same architecture serves both the 2p and 4p agents. MLPs and CNNs perform the token embedding, followed by a multi-head transformer trunk that mixes the tokens, and the result feeds three heads: a pointer head picks the target planet, a fraction head chooses how much of the garrison to send, and a value head scores the position.\n\n<p align=\"center\"><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/model_architecture.jpg\" alt=\"\" width=\"95%\"></p>\n\n**Token embedding**: the static and rollout-summary features and the forecast series are processed separately, then fused into one token. The former are joined into one vector through a small MLP, and the latter is run through a 1-D CNN over its 50 turns then a mean, max, and attention pool (with the attention query from the static features) so the token can focus on the turns that matter. The two embeddings are concatenated, conditioned by [FiLM](https://arxiv.org/abs/1709.07871)² (a zero-initialized projection of the global game-summary vector scales and shifts the fused planet features), and passed through a token MLP into the 128-d planet token, up to 44 of them. The single global token is built the same way, from the game-summary vector and a 1-D CNN embedding of the economy series.\n\n**Main transformer**: the planet tokens and the global token form a length-(P+1) sequence of 128-d tokens, run through a 6-layer pre-LN transformer with 4 heads. On top of ordinary attention, the planet-pair edge features enter as an additive bias: a tiny MLP turns the (P × P × 6) pair features into a per-head bias added to the attention scores, so board geometry (who can reach whom, and how far and fast) directly shapes which planets attend to which. The global token sits in the same sequence, so every planet can read the board-level summary through attention.\n\n**Heads**: the transformer's output representations feed three heads.\n\n- **Pointer head.** Every planet produces one logit for each planet on the board through a single attention pass (query·key plus the edge-bias), and a softmax over them picks the target it launches at, with a planet pointing at itself meaning hold, so the launch-or-hold choice sits in the same softmax rather than a separate head. Each of my planets therefore picks its own target in one step, rather than the model first choosing a source planet and then a target, as [Isaiah](https://www.kaggle.com/competitions/orbit-wars/discussion/714324)³ and [Billy](https://www.kaggle.com/competitions/orbit-wars/discussion/713483)⁴ do.\n- **Fraction head.** This runs after the pointer head so it can see the chosen target. An MLP takes the target's token together with the source and global tokens and regresses the mean and standard deviation of a clipped-Gaussian over the fraction of the garrison to launch. Including the target's token is what lets the launch size adapt to the chosen planet, since a distant or well-defended target needs a different commitment than a weak nearby one.\n- **Value head.** It pools the planet tokens and the global token into a single board embedding and predicts the position's value.\n\n**Model parameter and FLOP profile.** The table below breaks down the OrbitNet's roughly 1.2 M parameters and FLOPs by its components. Worth noting is that most of the compute lands on the CNNs that process the per-turn series. Perhaps, as [Billy](https://www.kaggle.com/competitions/orbit-wars/discussion/713483)⁴ does, dropping the CNN and instead flattening the series and concatenating it with everything else for the MLP would have been more reasonable. Shrinking the 50-turn forecast horizon would also help, since most fleets arrive within 20 to 30 turns anyway, and both [Billy](https://www.kaggle.com/competitions/orbit-wars/discussion/713483)⁴ and [Simon](https://www.kaggle.com/competitions/orbit-wars/discussion/713276)⁵ run shorter ones (24 and 20).\n\n<p align=\"center\"><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/model_profile.png\" alt=\"\" width=\"95%\"></p>\n\n**Design notes & discussion:**\n\n- **Continuous vs discretized.** I started with a discretized fraction head (4, 8, or 16 bins) and switched to the continuous clipped-Gaussian for two reasons. First, once a game was clearly won, the discrete head kept sending small fleets. That is probably harmless, since the game is already decided, but it looks bad to watch, and the continuous head stopped it. Second, for imitation learning, opponents launch with all sorts of fractions, and snapping each one to the nearest bin throws information away, whereas a continuous target keeps it. I have to admit that predicting both μ and σ and sampling during training (argmax at inference) might be a bad idea: σ never really shrinks (its mean settles around 0.88 in the [0,1] fraction space), so just predicting μ would probably have been cleaner, as [Isaiah](https://www.kaggle.com/competitions/orbit-wars/discussion/714324)³ did.\n- **One action per planet per turn.** My model design means each planet decides a single action per turn, and I did not make the policy autoregressive over several launches. Rolling out N sequential actions would multiply the per-turn inference cost by roughly N, and under the 1 s/turn budget that cost outweighs the extra expressiveness it would buy, so I left it out.\n- **Cross-planet coordination.** I strongly believe the agent would fight better if planets coordinated, concentrating an attack or a defense once they knew each other's targets. The clean way to get that is an autoregressive policy, but for the inference-cost reason above I ruled it out. So I tried cheaper substitutes in the fraction head instead: pooling the embeddings of the other sources aimed at the same target and feeding that in, and separately giving the head its own cross-planet attention with the edge features. Neither beat the plain MLP, so the final head stays simple.\n\n---\n\n## Game engine and environment\n\nBefore getting into the training of the model (imitation learning and self-play RL), a few setup details worth noting.\n\n**The engines.** I keep several engine implementations in lockstep: the official Kaggle engine, a JAX version for fast self-play training, a cross-architecture JAX version that records replays for local evaluation across 2p and 4p and pits different training updates' weights against each other, and a numpy version for deployment. Each is parity-checked against the official engine on a full game, matched step by step at every intermediate state. The boards are all pre-generated, about 131,000 for 2p and 1,024 for 4p, and evaluation always runs on fresh boards the model has never trained on.\n\n**A per-player fleet cap in training.** The real Kaggle engine puts no limit on how many fleets a player can have in flight. My training environment does: each player is capped at 128 active fleets, independently of the others (256 total in 2p, 512 in 4p), and once a player is at the cap its further launches that turn are dropped. This started as a way to keep the JAX environment on fixed-shape arrays, but it also nudges the model to be cautious and save its launches for the ones that matter. In practice the cap only binds early in training, and afterwards the model stays well clear of it. Since the deploy engine is uncapped, I re-apply the same cap in the numpy inference code so the shipped agent behaves exactly as it did in training.\n\n**One viewpoint for all seats.** All coordinates and features are transformed into the standard p0 viewpoint for inference, so every seat, no matter where the player sits, shares one set of weights.\n\n**Design notes & discussion:**\n\n- **One pass, not one per player.** According to [Isaiah's write-up](https://www.kaggle.com/competitions/orbit-wars/discussion/714324)³, the per-seat transformation I use is unnecessary and inefficient: because each seat is canonicalized to its own p0 view, I run the network once per player every turn (two passes in 2p, four in 4p) instead of once for the whole board. A single pass can instead produce every source-to-target action, which you then filter by ownership, cutting the rollout forward pass by two to four times.\n- **A resign mechanism I tried and dropped.** To stop the model from spamming pointless small fleets in the endgame, I tried adding a resign mechanism: a game ends once one side has held over 75% of the ships for 20 straight turns. It can also improve training efficiency. However, it turns out the models would sometimes collapse in a game they had all but won, so I dropped it.\n- **A first-turn off-by-one.** My training env starts from the step-1 observation (turn 1), while deploy sees the first turn as 0, so only the first turn's turn-based features (turn counter, turns-left, comet countdown) come out one off. I patched it by clamping the deploy feature step to at least 1. Tiny impact anyway.\n- **Don't train and evaluate on the same boards.** Early on I trained and evaluated on the same 1,024 boards, so models overfit them and looked strong in evaluation. Switching to fresh unseen boards changed the picture completely.\n\n---\n\n## Imitation learning\n\nI mostly used imitation learning as a fast way to validate models and features, since cloning good play is far cheaper to iterate on than a full RL run. With the competition nearly over, I also wanted to see whether it could hand self-play a better set of initial weights, though it turned out that starting from self-play alone probably reaches a higher ceiling.\n\n**Data curation.** I only collect official replays from after the last engine update on 2026-05-27, so every game runs the same current physics, and I keep the ones where both seats are rated above 1500 Elo, weighting the winner's moves above the loser's. This leaves about 21,000 games, roughly 10 million board states. I found that filtering to these stronger matches produced better models even though it left far fewer games to train on, which lines up with [Slawek](https://www.kaggle.com/competitions/orbit-wars/discussion/713187)⁶, who trained only on the top two players' matches each day.\n\n**Training.** The pointer head is a softmax over target planets, trained by cross-entropy against the planet the expert actually launched at (a planet pointing at itself is a hold), with launch rows up-weighted 6x to counter their low base rate:\n\n$$\nL_\\text{ptr} = -\\sum_i w_i \\, \\log p_\\theta(t_i), \\qquad w_i = 6 \\text{ for a launch, else } 1.\n$$\n\nThe fraction head predicts the mean and standard deviation (μᵢ, σᵢ) of a Gaussian clipped to [0,1], fit to the fraction the expert sent, fᵢ = ships/garrison, by a negative log-likelihood over the launches only:\n\n$$\nL_\\text{frac} = -\\sum_{i \\,\\in\\, \\text{launches}} \\log \\mathcal{N}_{[0,1]}(f_i \\mid \\mu_i, \\sigma_i).\n$$\n\nThe total objective is L = L\\_ptr + ½·L\\_frac. The value head stays untrained here and is only picked up in self-play. Training runs about 5 epochs (roughly 50,000 updates) with AdamW at a 3e-4 learning rate on a warmup-cosine schedule and batch 1024.\n\n**Evaluation.** I evaluate the imitation performance by playing against the public heuristic agents, [Orbit-Lite](https://www.kaggle.com/code/yijiey/orbit-lite-greedy-planner-regroup-submission)⁷, [Roman](https://www.kaggle.com/code/romantamrazov/orbit-star-wars-lb-max-1224)⁸, and [Shummingfang](https://www.kaggle.com/code/shummingfang/orbit-wars-exp48)⁹. The table below reports it at the best checkpoint: the 2p head-to-head win-rate against each, and for the four-player games, each player's chance of finishing first together with the probability our model ranks above each opponent.\n\n<p align=\"center\"><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/il_vs_public.png\" alt=\"\" width=\"95%\"></p>\n\n---\n\n## Self-play PPO training\n\nDrawing on previous agent competitions like [Lux AI Season 3](https://kaggle.com/competitions/lux-ai-season-3)¹¹, the choice on the RL side was self-play PPO versus IMPALA. IMPALA is a distributed actor-learner architecture, built to parallelize experience collection across many actors that feed a central learner, and I would rather see how far a single GPU can take me than spend my effort on that. I went with PPO and kept it basic, with a self-play league.\n\n**Reward.** The reward is as simple as it gets: +1 for the winner (first place in 4p) and -1 for everyone else.\n\n**Hyperparameters.** Standard PPO with γ = 0.999, λ = 0.95, a clip of 0.2, and a value-loss weight of 0.5. The learning rate peaks at 1e-4 and cosine-anneals to 3e-5 over 100k updates, where one update is a single rollout's worth of data used for one epoch of training. That rollout is T = 128 steps from 1024 parallel games in 2p (512 in 4p), split into 64 minibatches of about 3,700 samples, with the gradient norm clipped to 1.0. The entropy bonus is 0.01 in 2p and 0.02 in 4p.\n\n**League.** I run a league to keep the opponents diverse and to avoid catastrophic forgetting. Concretely, in 2p, 80% of the games are pure self-play and in the other 20% the learner plays a frozen past checkpoint from a pool. Members are sampled with probability proportional to 1 − winrate (a linear [PFSP weight](https://www.nature.com/articles/s41586-019-1724-z)¹⁰), plus a 2% floor. Admission draws only from the periodic checkpoint saves (every 200 updates, and never before update 1000), not from every update: a checkpoint joins the pool once the learner's EMA win-rate against the current reference reaches 70% over at least 1024 games (or after 5000 updates, if it plateaus). The pool holds up to 30 members, evicting the oldest. The 4p league is the same idea adapted to four players: the anchor seat is always the current policy, while each of the other three seats is independently the current policy or a pool member on the same 80/20 split. With no head-to-head win-rate to go on, both the PFSP sampling and the admission gate switch to first-place rate, and a snapshot is admitted once that rate clears 0.35 over 512 games.\n\n**Warm-start vs. random-start.** My submitted 2p agent trains from a random start, not from the imitation model. A random start is weaker at first and trails the warm-started run early on, but somewhere in the 10k to 20k update window it catches up and then pulls ahead, and in the end it is the random-start run that tops the 2p standings in the local-evaluation section. This is like [AlphaGo Zero](https://www.nature.com/articles/nature24270)¹², which also gave up its earlier warm-start. The submitted 4p agent is warm-started from the imitation checkpoint. Because its performance was still climbing and only a few days were left, I stuck with it and didn't go back to try a random start.\n\n**Experiment notes & discussion:**\n\n- **An observation from hyperparameter tuning.** My first 4p run, at lr 3e-4 with an entropy bonus of 0.05, collapsed after a few hundred updates: the planets stopped launching any fleets at all. Lowering the learning rate to 1e-4 and the entropy bonus to 0.02 fixed it, and it never came back.\n- **Breaking moments.** I did see the model pick up behaviors on its own. For example, when a planet of mine can't be defended, it sends the ships out before the enemy arrives and grabs a weaker planet elsewhere instead, for a net gain.\n- **...but obvious mistakes remain.** Even in the submitted version I still catch clear errors. Right at the start of a game a valuable +5-production planet sits next door defended by only 20 ships, and instead of taking it while it is cheap the model hoards its fleet and doesn't launch until it has 40 or 50, far later than it should.\n\n---\n\n## Local evaluation\n\nThe gold-standard evaluation is the leaderboard. Submit an agent, let it play everyone else's for a day or two, and read off the rank range it settles into. But that is slow, and only two agents can be up at once, so almost every decision is made locally instead.\n\nEarly on, while I was debugging and building out the model and the training framework, the public heuristics were my benchmark. Afterwards, I keep a pool of agents (training checkpoints and a few frozen past champions), have every possible 2p and 4p pair play each other repeatedly on a separate GPU through the cross-architecture JAX engine, and score each by its win-rate averaged across pairings.\n\nTaking 2p as the example, the daemon runs in rounds, each following the same fixed order.\n\n**Admit.** A periodic rescan picks up new checkpoints and holds them in a queue, and at the start of a round the whole waiting batch joins the pool.\n\n**Play.** Every pair plays up to a cap of 512 games (256 in 4p). A pool of about 36 checkpoints makes 630 pairs, 35 per checkpoint, and each agent's score is its win-rate averaged equally over its pairs, roughly 480k games in 2p and 215k in 4p across the run. Seats are randomized each game.\n\n**Prune.** When every pair has reached the cap the round is complete, and the pool is trimmed by dropping the lowest-scoring members, active checkpoints down to 30 (10 in 4p) in total but never below 3 per experiment, and each frozen reference down to its best 3. Evicted checkpoints are remembered so they never re-enter.\n\n**Repeat.** The next queued batch is admitted and the loop goes again.\n\nIn 2p, the top two checkpoints were u55000 and u53000. In 4p, the top checkpoint u44000 was paired with u39000. Both were combined with the merge detailed in the inference-time strategy section. The two tables below give the standings behind this. Based on this, the training had not plateaued yet, with the overall win-rate still rising about 5% per 10,000 updates.\n\n<p align=\"center\"><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/cross_2p.png\" alt=\"\" width=\"95%\"></p>\n\n<p align=\"center\"><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/cross_4p.png\" alt=\"\" width=\"95%\"></p>\n\n---\n\n## Inference-time strategy\n\nIn local evaluation, a single turn takes only about 20 ms, so I wanted to try whether the spare time could go toward some inference-time improvement.\n\n**Mechanism.** Each turn, both checkpoints propose an action. Where they disagree, the engine plays each candidate forward two or three turns against the other checkpoint and keeps whichever the averaged value heads rate higher. 4p is the same, against three copies of the other. I trust the value head as referee for two reasons: its value explained variance held above 0.95 during training, and it tends to call a swing in win probability before any gap opens in the ship or production counts, as the replay at the top shows, so it reads the position earlier than the raw economy does.\n\nIn local evaluation this only helped once the rollout was at least two turns deep. The table below runs at depth 3 in 2p and depth 2 in 4p, where the merge beats greedy on both tracks.\n\n<p align=\"center\"><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/final_eval.png\" alt=\"\" width=\"95%\"></p>\n\n**The two submissions.** I made two submissions, one with the inference-time strategy (merge, `53993338`) and one without (greedy, `53993524`, just the plain argmax decode), as an ablation to check that the merge's local edge survives the leaderboard's more diverse opponents, though I had a feeling the merge would come out ahead.\n\n**Handling the time budget.** Running depth 3 every turn would blow the time budget, so the depth follows the 60-second overage bank instead, running at depth 3 while more than 20 seconds remain, depth 2 while more than 10 remain, and plain greedy below that. 4p uses a single stage, running depth 2 while more than 20 seconds remain and plain greedy below that.\n\n---\n\n## Evaluation Phase\n\nFor the two weeks after the first phase closed, all agents were fixed, and I kept tracking the game episodes and the leaderboard. The analysis of that run is below.\n\nThe first table pits each of my submissions against each top-20 submission, with the top-20 fixed from a leaderboard snapshot after the first day of evaluation. 2p is the head-to-head win-rate; for 4p I record the win-rate for me and for the opponent over the games where we both play.\n\n<p align=\"center\"><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/eval_h2h.png\" alt=\"\" width=\"95%\"></p>\n\nThe second table gives the overall win-rate for each submission across the whole evaluation phase.\n\n<p align=\"center\"><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/eval_field.png\" alt=\"\" width=\"95%\"></p>\n\nThe figure below traces my overall rank and each submission's rank over the evaluation phase.\n\n<p align=\"center\"><img src=\"https://raw.githubusercontent.com/yijieyuan/kaggle-orbitwar/main/figures/rank_trends.png\" alt=\"\" width=\"95%\"></p>\n\nThe results seem to show the inference-time strategy (the merge) holds more of an edge in 2p, while the plain greedy does better in 4p, possibly because the 4p merge folds in a checkpoint that is a little too early.\n\nThe rank trend seems to change once midway, probably pairing nearby agents against each other more often, after which both submissions grew steadier, with the merge clearly the more stable of the two. So far across the evaluation phase my rank has reached as high as 6th and, in the early days, as low as 28th.\n\nThe evaluation was somewhat controversial in the community, mainly because the rank never really converges, so the last few dozen games swing the final standing a lot. Refer to [CPMP's post](https://www.kaggle.com/competitions/orbit-wars/discussion/714395)¹³ and [Ascalon's post](https://www.kaggle.com/competitions/orbit-wars/discussion/716725)¹⁴.\n\n---\n\n## Compute and cost\n\nMost of the experimentation ran on my local 2× A6000. The two final models were each trained on a rented B200, one for 2p and one for 4p. The 2p run took about 100 hours and the 4p about 98 hours, roughly $630 in total. End to end (game simulation plus the PPO update), the 2p run reached about 36,000 samples per second (SPS) and the 4p run about 28,000, with each player's move in a turn counted as a separate sample, for roughly 13 billion and 9.8 billion samples in total. I only turned to the B200 because I was running out of time in the last few days, and the models still had not plateaued at the submission deadline. The lesson for next time is to settle the framework earlier and leave more time for training and fine-tuning.\n\n---\n\n## Results \n\nFinal Elo: xx, rank xx, swinging between 6th and 28th over the evaluation phase\n\n---\n\n## Closing thoughts\n\nRL leaves very little room for parameter sweeps, because each run takes so long that you cannot afford many hyperparameter searches or full ablations, so I stopped agonizing over the ones I skipped. Intuition ends up mattering more here than in most supervised settings.\n\nScaling is an art, both in RL and in this game. I tried a 4M-parameter model and it performed much worse than my final one at around 1.2M, so it was great to see how [Isaiah](https://www.kaggle.com/competitions/orbit-wars/discussion/714324)³ made scaling work.\n\nReplays are fun to watch, but beyond that, they build real intuition for what the model and its features are doing. They help tell apart the failure modes, whether the features do not carry enough information, the architecture is the bottleneck, or even a training hyperparameter is off. For example, setting gamma to 1 made the model happy to drag games out to the 500-turn limit.\n\nTo make this game more fun, it could use a few physics-bending abilities, say a skill that speeds every planet up for the next 10 turns, or a comet you can drop on a path you choose.\n\nCode available at https://github.com/yijieyuan/kaggle-orbitwar.\n\n## References\n\n1. Lin Myat Ko (3-comet). [\"Sharing our RL lessons so far\"](https://www.kaggle.com/competitions/orbit-wars/discussion/697725). Kaggle Orbit Wars discussion.\n2. Ethan Perez, Florian Strub, Harm de Vries, Vincent Dumoulin, Aaron Courville. [\"FiLM: Visual Reasoning with a General Conditioning Layer\"](https://arxiv.org/abs/1709.07871). AAAI 2018.\n3. Isaiah Pressman. [\"Scaling Reinforcement Learning to the Stars\"](https://www.kaggle.com/competitions/orbit-wars/discussion/714324). Kaggle Orbit Wars discussion.\n4. Billy Bradley. [\"How I made Ender\"](https://www.kaggle.com/competitions/orbit-wars/discussion/713483). Kaggle Orbit Wars discussion.\n5. Simon Jégou. [\"N < 10th Solution for Orbit Wars\"](https://www.kaggle.com/competitions/orbit-wars/discussion/713276). Kaggle Orbit Wars discussion.\n6. Slawek Biel. [\"Last day scramble\"](https://www.kaggle.com/competitions/orbit-wars/discussion/713187). Kaggle Orbit Wars discussion.\n7. Slawek Biel. \"Orbit-Lite: Greedy Planner & Regroup\". [Kaggle notebook](https://www.kaggle.com/code/yijiey/orbit-lite-greedy-planner-regroup-submission).\n8. Roman Tamrazov. [\"orbit-star-wars lb-max-1224\"](https://www.kaggle.com/code/romantamrazov/orbit-star-wars-lb-max-1224). Kaggle notebook.\n9. shummingfang. [\"orbit-wars-exp48\"](https://www.kaggle.com/code/shummingfang/orbit-wars-exp48). Kaggle notebook.\n10. Oriol Vinyals et al. [\"Grandmaster level in StarCraft II using multi-agent reinforcement learning\"](https://www.nature.com/articles/s41586-019-1724-z). Nature 2019.\n11. Stone Tao, Akarsh Kumar, Bovard Doerschuk-Tiberi, Isabelle Pan, Addison Howard, and Hao Su. [\"Lux AI Season 3\"](https://kaggle.com/competitions/lux-ai-season-3). NeurIPS 2024, Kaggle.\n12. David Silver et al. [\"Mastering the game of Go without human knowledge\"](https://www.nature.com/articles/nature24270). Nature 2017.\n13. CPMP. [\"Will the LB stabilize?\"](https://www.kaggle.com/competitions/orbit-wars/discussion/714395). Kaggle Orbit Wars discussion.\n14. Ascalon. [\"Proposal to improve ranking convergence\"](https://www.kaggle.com/competitions/orbit-wars/discussion/716725). Kaggle Orbit Wars discussion."
    }
  ],
  "complete": true,
  "source": "live",
  "fetched_at": "2026-10-19T06:28:16Z",
  "competition_id": 81933,
  "competition_slug": "child-mind-institute-problematic-internet-use",
  "team_id": 12857252,
  "solution_url": ""
}