Lunar Sim: Fast OGame Battles for Fleet Optimization
Lunar Sim is an OGame fleet optimizer. The user-facing question is usually something like "what should I build?", "which fleet is better against this benchmark?", or "can I send a smaller fleet and make more profit after losses and flight cost?" The hard part is that an optimizer does not need one combat simulation. It needs thousands of simulations while searching the neighborhood around a candidate fleet.
That makes the internal simulator the core of the project. It has to preserve the parts of OGame combat that matter for optimization, especially rapid fire, explosions, win/draw/loss rates, debris, loot, and fuel cost. But it also has to be fast enough to run inside a search loop.
OGame combat looks simple from the outside: each side brings ships and defenses, they shoot for up to six rounds, and the remaining units decide whether the attacker wins, the defender wins, or the battle draws. The annoying part is that the rules are discrete, nonlinear, and full of threshold behavior.
The mechanics that matter for the simulator are:
- Each unit type has a count, attack value, shield value, hull value, and rapid fire table.
- A battle has at most six rounds.
- At the start of each round, surviving units have their shields restored.
- Every surviving unit fires once.
- Rapid fire can add more shots after a shot resolves. If a ship has rapid fire factor against the selected target type, then the extra-shot probability is .
- Targets are selected randomly in proportion to the number of live target units. If 70% of the defender's live units are light fighters, then 70% of ordinary shots target light fighters before rapid-fire filtering is applied.
- Target selection is locked for the round's shot allocation. A unit destroyed by one shot does not cause the remaining already-allocated shots in that round to be retargeted.
- Damage first depletes shield, then hull.
- Once hull damage exceeds 30%, damaging hits can make the unit explode.
- If a side has no surviving units, combat stops early. Otherwise, after six rounds, the result is a draw if both sides still have units.
- After combat, losses imply debris, loot depends on attacker win rate, and flight/recycler costs turn the battle result into an economic result.
An optimizer wraps this simulator in an objective function. A candidate fleet is scored by expected value, loss rate, or average performance against a benchmark portfolio:
The benchmark fleets may be mirrors, fast fleets, fodder-heavy fleets, or known opponents. The exact objective can change, but the computational pressure is always the same: every optimizer step asks for many battle evaluations.
A literal simulator can track every individual light fighter, cruiser, and plasma turret. That is easy to reason about, but it solves the wrong scaling problem. Players care about very large fights, and in those fights the state size is dominated by physical unit count. A fleet with 4,000,000 light fighters does not contain 4,000,000 interesting objects. It contains one interesting ship type repeated 4,000,000 times.
lunar_sim uses a different representation. The core object is a probability
cloud over damage and shield states for one representative unit:
Here is accumulated hull damage, is remaining shield, is a damage interval, and is a shield interval. This distribution is the conceptual model. A named unit type only supplies a parameter vector : attack, hull, initial shield, rapid-fire coefficients, and count. The computation can be read as "one representative unit with parameters has this distribution over states, and there are such units." The simulator evolves and multiplies by when converting the distribution back into expected survivors and losses.
That is the first trick, and the problem it solves is memory and runtime. The simulator does not ask "what happened to this particular light fighter?" It asks "how much probability mass moved from state to each later state ?" Units with the same parameters and same coarse damage/shield state are exchangeable, so their labels carry no useful information.
Light fighters, cruisers, and plasma turrets only matter because they provide different parameters. The transition shape is the same. That is why the implementation can evaluate many parameter vectors together instead of branching through separate logic for each named unit.
That changes the scale of the problem. For one parameter vector, the work is roughly proportional to:
instead of:
Across the whole battle this same update is applied for each live parameter vector . That is an implementation detail; the conceptual object remains the distribution over damage and shield.
This also makes the update a linear-algebra problem. A round can be viewed as building transition matrices that move probability mass from old states to new states:
In practice there are several such matrix-shaped operations: target shares are computed from live count vectors, rapid-fire probabilities come from multiplying target probabilities by the rapid-fire table, hit rates are broadcast across attacker and defender state grids, and the final state movement is an indexed accumulation of transition probabilities. The matrices are often sparse or implicit, but the operations are still vector operations over contiguous numeric arrays.
That solves a second performance problem. Once the battle is expressed as array operations, the hot loops move into the linear algebra libraries underneath NumPy/SciPy instead of staying in Python. The simulator still models the same stochastic combat rules, but the CPU sees dense arithmetic kernels, broadcasts, and reductions.
Target Selection and Rapid Fire
The next problem is rapid fire. A naive simulator can handle it by walking every shot chain one shot at a time: choose a target, resolve the shot, roll rapid fire, maybe choose another target, and continue. That is faithful, but it puts the inner loop on the most explosive part of the battle. Cruisers into light fighters or battlecruisers into battleships can create enormous numbers of extra shots.
The useful observation is that target choice gives us the continuation probability for the whole firing type. Target shares are computed from the live population at the start of the allocation. If target type has live units, then an ordinary shot targets that type with probability
That target distribution is then locked for the round's shot allocation. The simulator does not re-normalize after each destroyed unit, because doing so would create a different game: later shots would avoid targets that had already been selected and killed. In Lunar Sim, the target lock is what lets the round be represented as one allocation problem instead of a long sequence of state-dependent retargeting steps.
Rapid fire changes the number of shots, not just the target mix. Let be the rapid-fire continuation matrix, where is the continuation probability for an attacker with parameter vector against target class . Then the continuation probabilities for all attacking classes are one matrix-vector product:
For one attacker class , this is the scalar equation:
so a shot from class continues with probability and stops with probability . This is one of the places where the simulator is literally doing linear algebra: all continuation rates are computed together by multiplying the target-share vector by the rapid-fire matrix.
That turns the "how many extra shots do these chains create?" question into a negative-binomial question. The distribution counts the number of continuations before a fixed number of stops. For initial attackers of type , there must eventually be terminal shots, one for each chain. The number of extra rapid-fire shots is:
This preserves the random length of rapid-fire chains without simulating the chain step by step. Once the extra-shot count is known, the simulator still has to place those shots onto target classes. That placement is multinomial.
For class , let be the number of rapid-fire shots generated by the negative-binomial draw. The target-count vector is:
The rapid-fire target probabilities are the conditional probabilities implied by the same target-lock and rapid-fire rules:
The terminal shots have their own multinomial allocation. Since a terminal shot is a shot that did not continue, its conditional target probability is:
So the negative binomial solves the shot-count problem, while the conditional multinomial allocation solves the target-mix problem. The game rule is still random target selection proportional to live counts; the simulator is only compressing how the repeated rapid-fire loop is sampled.
Shots as Poisson Events
After rapid fire, the simulator knows how many shots of each attacking type are aimed at each defending type. The remaining expensive question is assignment: which exact damage/shield state inside that defending type did each shot hit? Doing that shot by shot would throw away the probability-cloud compression.
The Poisson approximation solves this assignment problem. When many similar shots are spread across many similar target states, the hit count on any one state is well approximated by a Poisson distribution. This is the usual rare-event limit of binomial or multinomial allocation. If shots are independently assigned to a state with small probability , then
when is large, is small, and is moderate. OGame battles often land exactly in that regime: huge shot counts distributed over many target states. If a target state receives hits at rate , then
This is relevant because the state update only needs the distribution of hit counts on the representative ship-state cloud. It does not need to assign every shot to every physical ship. For each attacking damage value, the simulator computes a hit-rate vector against live target states and updates each state by the probability of receiving zero hits, one hit, two hits, and so on.
The Poisson distribution has infinite support, but the transition table must be finite. That creates a second problem: how many hit counts should the simulator carry? It solves that by truncating the distribution and folding the remaining tail probability into the final bin:
This keeps the state transition finite. The cutoff is chosen from a high quantile of the largest rate in that batch, capped by the number of hits needed to guarantee destruction. In code this is essentially:
The tail is small enough that treating "everything larger" as the last case is usually a better tradeoff than carrying an unbounded number of hit counts.
The Hard Part: Explosions
Damage and shields would be straightforward if units only died when damage exceeded hull. A hit-count distribution would be enough: add the damage, cap at death, and move probability mass to the right damage bin. OGame has an extra rule: after a unit has taken more than 30% hull damage, further damaging hits can cause it to explode. The explosion chance is the current damage fraction.
That means the transition is not just:
The simulator also needs the probability that the unit survives the explosion checks created by those hits. That is the main mathematical complication. The code does not enumerate every order of every hit. Instead, for a starting state and a hit count , it derives the hit ranges that matter:
- hits absorbed by shields,
- hits that first push damage over the 30% threshold,
- hits that can trigger explosion rolls,
- hits that necessarily destroy the unit.
Suppose the unit has hull , starts at damage , starts the round with shield , and receives hits of attack . After shield absorption, the cumulative hull damage from those hits is:
The ending damage before explosion removal is:
For the explosion calculation, it is useful to count only the hits that reach hull. Let be the hull-damaging hit index after shields are exhausted. The damage after that hit is:
Explosion checks only begin after . For a hit that leaves damage , the explosion probability is:
So the probability of surviving the explosion checks from hull hit through hull hit is:
That product is the core of the explosion calculation. Let:
be the first hull hit that can trigger an explosion check, and let:
be the hull hit count that guarantees destruction by raw damage. For a given hull-hit count :
- if , the unit cannot explode and survives the explosion rule;
- if , the unit is destroyed by hull damage;
- if , the unit survives with probability and explodes with probability .
Because the product is over an arithmetic sequence, it has a closed form. With and :
The simulator evaluates this in log space:
This gives the no-explosion survival probability for every relevant hit count without multiplying a long list of small terms.
The transition then combines this survival probability with the Poisson hit-count probability. If is the hull-hit count, the explosion-destruction probability for a state is:
The raw hull-destruction probability is:
Everything else remains alive and moves to the damage bin implied by . That is what makes explosion handling part of the transition matrix rather than a cleanup pass after the damage update.
The no-explosion product can be very small, especially for heavily damaged large ships taking many hits. The log form above is both faster and more stable than multiplying many small probabilities directly. The same idea is used elsewhere in the simulator whenever repeated products or combinatorial factors would otherwise underflow:
with gammaln handling factorial-like terms.
This solves a correctness problem, not just a speed problem. Explosion probability depends on the path through the hits, so it cannot be bolted on after the damage update. The simulator folds destruction probability into the transition itself: some probability mass moves to later damage states, and some probability mass disappears as destroyed units.
Bins, Critical Points, and Numerical Stability
The probability cloud needs bins; otherwise it would still have too many possible damage states. Binning creates an accuracy problem: if a game rule changes sharply inside a bin, the simulator smears probability mass across a discontinuity.
The 30% hull threshold is exactly such a discontinuity, so the simulator places a bin boundary there. Below that point, damage matters mostly for future threshold crossing. Above that point, damage changes explosion risk. Aligning a bin boundary with the rule boundary avoids averaging together states that obey different rules.
This trick appears in other numerical domains. In finance, grids are often aligned around option strikes, coupon dates, barrier levels, earnings dates, or other prices and times where the payoff function changes shape. A uniform grid is simple, but a grid that respects the problem's critical boundaries usually buys more accuracy per cell.
The same idea applies here. The simulator spends bins where the rules change, not merely where the axis happens to be wide.
There are other small stability choices:
- Poisson tails would make the hit-count axis unbounded, so they are collapsed into the last explicit hit count.
- Long survival products can underflow, so they are computed through logarithms
and
gammaln. - A full transition matrix would be mostly empty, so state updates use sparse indexed accumulation.
- Shields reset every round, so the simulator explicitly aggregates surviving mass back to the full-shield shield bin between rounds.
Each of these is a numerical answer to a specific failure mode: unbounded support, underflow, dense memory use, or stale shield state.
Why This Shape Works
The simulator is not trying to be a perfect microscopic replay. It is trying to estimate the distribution of outcomes for fleet planning:
- expected losses,
- win/draw/loss rates,
- survivor ranges,
- debris and loot value,
- deuterium costs,
- optimization steps against benchmark fleets.
For those outputs, exchangeability is powerful. If one million identical ships have the same damage and shield state, their individual labels do not matter. What matters is how much probability mass moves to each later state after the round's hits.
The resulting algorithm sits between two extremes:
- unit-by-unit simulation, which is accurate but slow for huge counts;
- pure deterministic expectation, which is fast but loses combat variance and threshold effects.
lunar_sim keeps the random shot allocation and rapid-fire structure, because
those are where much of the combat variance comes from. It compresses the
damage bookkeeping, because individual ship labels are mostly irrelevant once
ships are grouped by type and state. The price is approximation error around
bin boundaries. The payoff is that large battles become cheap enough to run many
trials, compare optimizer candidates, and still account for the nonlinear parts
of OGame combat.