The engine¶
This is the thing the battle actually happens in. You play a card, a Giant walks out, a tower
shoots it, the crowns get counted. All of that is RoyaleSim. It is written in Rust and you drive
it from Python by importing royalesim.
There is no game to run and nothing to connect to. No phone, no copy of the game, no account and nothing to wait for over a network. You install a Python module and call it.
If you are here to make a bot, the honest summary is this. You will almost never open this repo. Your bot talks to the environments, and the environments talk to the engine.
-
It plays the whole match
Elixir, hands and the cycle, deploys, walking, targeting, fighting, spells, towers, double elixir, two minutes of overtime, the three-crown win and the tiebreak.
-
The same seed gives the same battle
Run it twice with the same seed and you get the same match, unit for unit, on any machine. It does whole-number arithmetic only, so nothing drifts. A battle that went wrong once can be made to go wrong again.
-
It is fast enough to stop being the problem
About 16,100 three-minute battles an hour from one worker process, and about 65,000 from six of them. Add cores and you add battles. The engine stepped on its own, with nothing built on top, goes about three times faster again. That is the point: when a training run plays battles, the slow part is the Python around the engine, not the engine.
-
The movement rules were measured
How units pick routes, walk and shove each other apart was measured against recordings of real matches. Every constant records which game version it came from and whether it is a measurement or a guess.
What it is not¶
It is not the real game and it does not claim to be. It knows nothing about rewards, observations or training. It does not know what a bot is. It hands you a board and moves it forward.
It is also not perfect. How accurate is the engine gives the numbers and says where it is still wrong.
Try it¶
This is the smallest interesting thing the engine does. A Giant is played for Blue, which is team 0 and the bottom half of the arena, and then left alone for 24 seconds of game time. Nobody tells it where to walk.
You need the install from Build from source first, up to and including the engine build.
import json, royalesim
deck = ["Giant", "Knight", "Archers", "Musketeer", "Fireball", "Arrows", "Minions", "Zap"]
b = royalesim.Battle(card_names=deck, slot_of_k=[[0, 1, 2], [0, 1, 2]])
b.reset(seed=1, decks=[list(range(8))] * 2, shuffle=0, start_tick=0,
elixir_milli=[10_000, 10_000], tower_hp=None, spawns=[])
# Like the real game, a match refuses every deploy for its opening seconds. Wait them out.
calib = json.loads(royalesim.EMBEDDED_CALIBRATION_JSON)
b.step([], calib["match"]["DEPLOY_LOCKOUT_TICKS"]["value"])
T = royalesim.SUBTILE # positions are in subtiles: 18000 to one arena tile
played = b.step([(0, 0, 5 * T, 10 * T)], 0) # Blue plays hand slot 0 (the Giant) on tile (5, 10)
print("play:", royalesim.DEPLOY_REASONS[played[0][1]])
for _ in range(6):
b.step([], 80) # 80 ticks = four seconds of game time, one call
s = json.loads(bytes(b.state_json()))
giant = next(e for e in s["entities"] if e[3] == 0)
print(f"t={s['tick']} giant at ({giant[5] / T:.2f}, {giant[6] / T:.2f})"
f" hp={giant[7]} red left tower hp={s['players'][1]['tower_hp'][1]}")
play: OK
t=170 giant at (4.40, 12.83) hp=3968 red left tower hp=3052
t=250 giant at (3.85, 16.41) hp=3968 red left tower hp=3052
t=330 giant at (3.77, 20.08) hp=3532 red left tower hp=3052
t=410 giant at (3.77, 22.58) hp=2987 red left tower hp=2799
t=490 giant at (3.77, 22.58) hp=2442 red left tower hp=2040
t=570 giant at (3.77, 22.58) hp=1897 red left tower hp=1534
Run on engine build 1cb11c66cdd25ced (RoyaleSim 0.1.1) and engine build ca780d18ba66da8b
(RoyaleSim 0.1.2), both with the 15.535 card table, which print the same. The
play: OK line is there on purpose. step does not raise when a play is refused, it returns the
reason. An earlier version of this program played at tick 0, was refused as TOO_EARLY, and then
failed looking for a Giant that was never placed.
Read that as a story. The Giant slid left onto the bridge column, crossed the river around t=250, walked into princess-tower fire, stopped within its own reach of the tower at t=410 and started hitting it. Nobody steered it. It picked its own route.
Your hitpoints may not match, and that is fine
On the same engine and card table, the positions, the timing and the hitpoints above are
the same on any checkout. The engine keeps changing, though, and a newer one can move all
three. Card levels come from the card table your machine built, and the run above used the 15.535
table, which is the one a clean install reads. On the --vintage 2018 table the two
right-hand columns move. If your route matches and your hitpoints do not, nothing is wrong.
To watch a battle instead of reading numbers, run this from the RoyaleSim folder:
It plays a random three-minute match, runs five checks on it, and opens a self-contained HTML page you can scrub tick by tick. The whole thing took 2.07 seconds here, and all five checks came back green:
winner: RED
crowns: [2, 3]
final_tick: 2968
troops: 46
[OK ] determinism: re-simulated hash-for-hash on a fresh engine
[OK ] vacuity: 2969 frames (floor 50); blue accepted 18 deploys; red accepted 17 deploys; ...
[OK ] arena: trace grid == data/derived/arena.json (64x36 half-cells)
[OK ] dry: 74 of 27489 ground entity positions on water (the live game allows it; bound 5 %)
[OK ] render: battle.html, 1841257 bytes, 2969 frames, self-contained
EVERY GATE GREEN.
Expect different numbers from these. The tool deals each side a random deck out of the card
table your clone built, and the engine and its card list keep changing, so your install can
play a different battle. --seed defaults to 1, so one checkout repeats its own battle;
--seed N gives another, and --open opens the page in a browser.
The five [OK ] lines are the part that is the same everywhere, and they are what the tool is
for.
When you would touch it¶
Four reasons, and they are all "the battle itself is wrong or missing something".
A card behaves wrong. A unit walks somewhere it should not, a spell does the wrong damage, a building sits in the wrong place. That is engine behaviour, so it is fixed here.
A mechanic is not modelled. These are the ones the engine does not do yet, in plain words:
morph, the push of most troops' own shots, like the Zappies', air units doing anything cleverer
than flying straight at their target, and tower troops. Every evolution, hero form and
champion's button of a ladder card runs. Rage and Heal load, but parts of how they work
are not measured yet. The 18 cards in thin_slice are the ones the engine has been checked on.
For the other cards it loads from the 15.535 table, only some rules are measured against
recordings, such as the Inferno damage ramp and the Mortar's minimum range.
RoyaleSim's mechanics page
lists which.
You want a constant changed. The engine's constants live in data/calibration.json. Each
one has a status, from guess to measured, and nearly all of them say where they came from. You
can change one and rebuild.
You want to help close the accuracy gap. Measured at build d872d792711934c2, the two
biggest sources are when a unit dies, 31.2% of what is left, and where multi-unit cards put
their units, 29.4%. Both are open work.
That ranking is less stable than it looks, and the accuracy page explains why: each battle is counted against whatever went wrong FIRST in it, so correcting one cause reshuffles the rest. Contact was the second-biggest source until the spawn point was corrected and is now the smallest. Pick a cause because you can fix it, not because it is top of the table.
Rebuild after you change the constants or the arena
The engine compiles data/calibration.json and data/derived/arena.json into itself. So
change those first and build second. RoyaleGym refuses an engine whose built-in copies do not
match the files on disk, which is the error you will see if you forget.
The card table works differently. Every time you create an engine, a RustEngine or a
royalesim.Battle, it reads data/derived/cards.json fresh from the RoyaleSim folder it was
built in. So re-running tools/extract_cards.py in that folder changes the cards of every
engine you create after that, with no rebuild. RoyaleGym's rebuild check does not look at
the card table, so nothing warns you. The ROYALESIM_DATA_DIR setting, which tells
RoyaleGym where to find RoyaleSim's data, does not change which card table the engine reads.
Build in the folder whose card data you want.
When you would not¶
| You want to | Go here instead |
|---|---|
| change what your bot is rewarded for | Writing a reward function |
| change what your bot sees, or what its moves mean | What the bot sees and does |
| start a match from a mid-game position | The environments |
| run a training loop | The learner |
| watch a battle, or save a picture of one | The viewer |
None of those need the engine rebuilt. That is the point of the split. The engine knows nothing about rewards, so you can change a reward without recompiling anything.
The numbers, and where they come from¶
Cards. The card table that comes with a clone has 145 cards: the 15.535 client's 144, plus
the Minion Giant. The engine loads 136 of them and refuses the other 9, each with a reason. The
default catalogue, which is what you get when you name no cards, holds all 136, the Mirror, the
Miner and the Goblin Drill included. A card that becomes loadable is appended, so no card's id
moves: the Minion Giant, then the Little Prince, Goblinstein and the Boss Bandit come last.
Counted on RoyaleSim 92a3abb on
2026-09-30.
Speed. On a 4-core laptop with 8 GB of RAM, with other programs running, the engine did 18,000 ticks in 0.35 to 0.42 seconds on one core. That is 43,000 to 51,000 ticks a second.
There is a piece of arithmetic here worth keeping. A three-minute battle is 3,600 ticks, and an hour is 3,600 seconds. So a ticks-per-second figure is also a battles-per-hour figure for one process. 51,582 ticks a second is 51,582 whole battles an hour on one core.
RoyaleSim's README measures how that scales across worker processes. The scaling is the part that should hold on your machine:
| workers | speed-up over one worker |
|---|---|
| 1 | 1.00x |
| 2 | 1.93x |
| 4 | 3.06x |
| 6 | 4.05x |
The fall-off past four workers is four cores running out. The absolute rates behind those ratios, on that laptop with other programs running, were about 16,100 battles an hour on one worker and 65,200 on six. Treat those as an illustration: the same measurement on this hardware has moved by a factor of two inside one evening.
What that means for you: overnight rather than a fortnight, on a laptop, for a run of the size people usually reach for. Nobody has trained a bot yet, so that is arithmetic on the battle rate rather than experience.
Accuracy. Real matches are replayed in the engine and compared tick by tick. The six towers
are left out, because towers do not move and counting them flatters the result. Without them, a
unit is within a quarter of a tile of where it really was 56.5% of the time, measured at build
d872d792711934c2. Single units are much better than swarms, by a wide margin.
The per-card figures used to be repeated here and are not any more. The same claim living on three pages meant fixing one of them left two wrong, twice over, and the numbers on this page were two runs behind before anyone noticed. They live in one place now.
Check How accurate is the engine before you rely on a specific interaction, and check it again in a month, because these numbers are moving.
Tests. The engine's Python suite was 112 passed in 192 seconds on the maintainer's laptop on
the morning of 2026-09-22. From a fresh clone at RoyaleSim 1d661b0 it gave 859 passed, 34
skipped and 7 xfailed in 143 seconds, on a 4-CPU Linux machine on 2026-09-27. Tests are still
being added, so your count may differ.
Where the detail is¶
The engine has its own docs, and they go far deeper than this page.
- RoyaleSim's README for the whole picture.
docs/architecture.mdfor how the engine is built.docs/pathfinding.mdfor the measured routes and the contact law, with the evidence.docs/mechanics.mdfor what is modelled, what is not, and the two known collision defects.docs/calibration.mdfor the constants file and what each status word means.docs/replay-parity.mdfor the full accuracy table and how it is produced.
Engine questions and calibration work happen in the Discord.