Simulate before you strike: how my bot picks a fight
Luchamon Bot doesn't guess which opponent to fight — it asks the game's own battle engine, over and over, and plays the least bad option. A look at simulation-driven selection, Monte-Carlo win probabilities, and the economics of never wasting an energy.
Luchamon Bot plays an on-chain auto-battler on Base. The rules that matter here are simple: each of my Luchamons gets seven energies a week, and every fight starts the same way — the game hands me eight random opponents and I pick one. Win or lose, the energy is spent. Unused energy evaporates at the weekly reset.
So the whole game, from a bot's point of view, collapses to a single question asked thousands of times a week: of these eight, which one do I fight? This article is about how I answer it — and why the answer turned out to be stranger than "pick the one I'm most likely to beat."
The tempting wrong answer
My first version was a heuristic. Luchamon has a type chart — a green matchup gives a small damage edge, a red one works against you — so I leaned on it: prefer green, avoid red, aim for an opponent rated a little below me, lightly penalize suspiciously strong stat lines.
It worked. It also ignored almost everything that decides a fight.
Combat resolution depends on level, four stats, three skills, and a passive — and on how all of those interact. Type is one input among many, and not the dominant one. A "safe green" opponent with a brutal skill combo will end you; a "red" one with junk skills is free. A heuristic built mostly on the type chart is guessing with extra steps. I wanted to stop guessing.
Ask the game, don't model it
Here's the realization that reshaped the project: I don't have to model the battle. The game already contains a perfect model of itself — its battle resolution is deterministic, and it's readable on-chain without sending a transaction or spending anything. I can hand it a matchup and it tells me who wins. Not an approximation of the engine. The engine.
That immediately killed the alternative I'd been dreading: re-implementing the combat math in TypeScript. Porting someone else's game logic is a trap — it's correct exactly until they ship an update, and then it's silently wrong and you don't find out until you lose. Reading the real thing means I can never diverge from the game. Whatever it does, I do.
There's one catch, and it's the interesting one.
One catch: the dice haven't been rolled yet
A Luchamon fight isn't fully determined by the two builds. It also depends on a random seed — drawn at fight time, by the chain's verifiable randomness, and unknown when I'm choosing my opponent. So asking "who wins?" doesn't return a yes or no. It returns a yes-or-no for one specific roll of the dice.
The fix is the obvious one once you say it out loud: don't ask once, ask many times. Replay the same matchup against a spread of seeds and count. If an opponent wins me 39 fights out of 40 simulated rolls, that's a ~98% matchup. This is just Monte-Carlo estimation, and it turns a coin-flip oracle into a probability:
interface WinProbResult {
oppId: bigint;
wins: number;
samples: number; // successful simulations (RPC errors excluded)
errors: number;
prob: number; // wins / samples, in [0, 1]
}
That samples field looks like bookkeeping. It's actually the most important line in the type, and it cost me a bug before I respected it.
"Unknown" is not "sure loss"
Each simulated roll is a network read, and network reads fail. When one does, I don't get to count it — so samples is the number of rolls that actually came back, not the number I asked for. Which means prob = wins / samples can look confident off a tiny denominator: two successful reads, two wins, "100%."
Worse is the zero case. If every read for an opponent failed, wins / samples is 0 / 0 — which I report as prob = 0. Read naively, that's "guaranteed loss, never pick him." It actually means "I have no idea." Those are opposite conclusions, and conflating them makes the bot throw away winnable fights because the RPC hiccuped.
So the rule everywhere downstream is: a probability is only allowed to vote if it's backed by enough successful samples, and samples === 0 is no-data, not a verdict. Honest uncertainty beats a confident number built on nothing.
Making it cheap enough to be honest
There's a tension in Monte-Carlo: confidence comes from samples, and here every sample has a cost. The trick that bought most of the accuracy back is paired sampling.
I don't actually care about each opponent's win rate in isolation — I care which opponent is better. So instead of rolling fresh random dice for each of the eight candidates, I roll one set of dice and replay all eight against the same seeds. The seed-to-seed noise is now shared across candidates, so when I compare two of them, that common noise cancels. The variance of the difference — the thing I'm ranking on — collapses, and I get the same ranking confidence from far fewer rolls.
The same seeded generator gives me a second property for free: reproducibility. Seed it per-Luchamon and two runs of the analysis return the same recommendation instead of drifting around in the Monte-Carlo noise. A recommendation that flickers every time you ask is one you stop trusting.
The twist: play the least bad one
Now the part I didn't see coming. With win probabilities in hand, the obvious policy is "fight the one you're most likely to beat, and if none look good, skip." I shipped exactly that. It was wrong.
Two facts about the game break the intuition:
- The loser still earns $LUCHA. A lost fight isn't a wasted fight economically — it still pays out.
- Energy is use-it-or-lose-it. Whatever I don't spend before the weekly reset is gone.
Put those together and abstaining is the only truly bad outcome. A loss costs me about 15 ELO and still earns tokens; skipping earns nothing and the energy evaporates. So the bot stopped skipping. It now always plays the best available opponent — even when "best available" means a coin flip, even when it means the least bad of eight bad options.
The only thing left for a threshold to do is express appetite, not permission:
type SelectionOutcome =
| { kind: "sim"; opp: OpponentProfile; prob: number; samples: number }
| { kind: "fallback"; opp: OpponentProfile }
| { kind: "skip"; reason: string };
Two objectives ride on top. safe takes the surest win. aggressive climbs the ladder: among opponents above a comfort bar, it picks the strongest one, trading a little win probability for ELO. The threshold gates that eligibility — it's no longer a gate that makes the bot sit on its hands. The only thing that still produces a skip is a hard floor, and that floor defaults to zero: never abstain, unless I explicitly raise it to protect ELO on a hopeless pool.
Fallbacks, fidelity, and what I'd watch
Simulation is the default, not the whole story. When the reads fail wholesale, or no opponent clears the minimum-samples bar, selection falls back to the old green-first heuristic — a worse answer is better than a crash, and fallback is a first-class outcome rather than an error. That old heuristic earned a second life as the safety net.
One honest caveat I keep flagged in the code: the simulation reads each opponent's current build. The strictly correct source is the build frozen for the week, which can differ for an opponent who just re-specced. For stable, low-level opponents they're identical, and the empirical check held — a matchup the simulation called 100% was won for real — so I left the frozen-snapshot switch as a future fidelity tweak, to revisit the day I see the simulation and reality disagree. Measuring that drift, rather than assuming it away, is the part I'd tell anyone building this to take seriously.
The bill
All of this has a cost I've been quietly stepping around: every decision is dozens to thousands of on-chain reads, and a full analysis across all 49 Luchamons runs into the hundreds of thousands. Asking the game who wins is free per call and ruinous in aggregate. Making that affordable — without paying for a beefier RPC or running a full node — is its own story, and it's the one I'll tell next: how a local fork of the chain turned a forty-minute calibration into less than a minute.
— Pato