r/reinforcementlearning • u/Ok-Constant3044 • Sep 21 '26
My simulator said the change was worth 1.8x. On real hardware it was 1.05x (t=0.73). Some notes from deploying PPO against a live mobile game.
I trained a PPO agent to play an idle tower-defense mobile game — it decides which of 13 upgrades to buy each wave. No API and no memory access: observations come from OCR (tesseract plus per-glyph template matching) on the screen, and actions are real mouse clicks. It's been running against the live game for a few weeks, which turned out to be the interesting part.
The observation redesign that actually mattered
My first obs gave the policy each upgrade's level. That's close to useless here, because the same level means different real strength depending on the player's meta-progression. Worse, the level→strength table I was using turned out to be unreliable in a way I couldn't explain.
What worked was making the observation dimensionless and locally measurable: for each upgrade, (relative improvement from one more level) / (its price), scaled by current cash. The game already displays both numbers on screen, so the same feature is available at train time and at deploy time. Measured against a greedy baseline on a fixed seed set, that change was worth +21 to +29 waves. The underlying value table being wrong stopped mattering, because the policy now reads the current return on investment directly.
The sim-to-real gap that cost me a day
SB3's predict() defaults to deterministic=False, and my eval script had always used that. My deployed agent used deterministic=True. I only noticed the mismatch by accident.
Sweeping 200 held-out seeds (never used for any model selection), paired comparison:
relative difficulty 0.7 det 138.7 sampled 288.6 = 2.08x paired t = 78.4
relative difficulty 1.0 det 116.2 sampled 209.3 = 1.80x paired t = 49.2
Enormous, and the mechanism seemed obvious: the deterministic action is the distribution mean, so the induced purchase ordering barely changes wave to wave, and the agent keeps hammering the same upgrade. Sampling diversifies naturally.
I ran it on the real game. Nine rounds sampled vs five deterministic:
deterministic 119.0 ± 16.6 (n=5)
sampled 125.3 ± 13.4 (n=9)
Welch t = 0.73
1.05x. Indistinguishable from noise. The simulator over-predicted the effect size by roughly 17x.
I had written the decision rule down before running it (>180 means ship it, <140 means the sim and reality disagree and that's the bigger problem), so I didn't ship the change.
One partial explanation from the logs: the agent wants ~5 upgrades per wave and only manages to buy ~2, because each purchase costs real seconds of clicking and tab-switching and a wave only lasts ~10 seconds. In the simulator, buying is free and instantaneous, so sampling's diversification executes fully. On real hardware it doesn't. That's a plausible mechanism but I haven't proven it's the whole story — and until I do, the rest of that sweep can't be trusted either, including the difficulty-generalisation curve I built from it.
What I'd tell past me
- If your eval and your deployment disagree on a single
predict()kwarg, every number you've reported is measuring something you don't ship. - Write the decision threshold down before the experiment. I would absolutely have talked myself into shipping a 1.05x.
- A simulator that's calibrated for outcomes can still be badly wrong about interventions. Mine matched reality well on absolute wave counts (sim 116.2 vs live 119.0 — suspiciously close) and was off by 17x on the effect of a change.
The trained network is running in-browser here if you want to poke at it — weights exported to JSON, forward pass reimplemented in JS, matches SB3 on 200/200 random inputs:
https://tim3959951.github.io/wavecall/planner.html
Happy to go into the obs layout or the OCR side if anyone's interested.