Action Parsers¶
An action parser turns the bot's choice into something the game understands: which card to play, and where. It also builds the action mask, the list of moves that are legal right now.
The Default: 2305 Moves¶
TileActionParser gives the bot 2305 moves, numbered from 0:
- 0 means wait.
- 1 to 2304 mean "play the card in hand slot
sloton tile (x,y)", whereaction = 1 + slot * 576 + y * 18 + x.
The arena is 18 tiles wide and 32 tiles long. Tiles are counted from the bot's own side: x
goes from 0 to 17, left to right, and y from 0 at the bot's own back edge to 31 at the
enemy's. Both seats use the same numbers, so one bot can play either side.
You don't have to do the arithmetic yourself:
from royalegym import make_env
env = make_env()
parser = env.action_parser
action = parser.encode(2, 9, 10) # hand slot 2, tile x=9, y=10
print(action)
print(parser.decode(action))
The Action Mask¶
Most moves are not legal most of the time: the card costs more elixir than the bot has, the
tile is on the enemy's side of the river, it's water, or the battle has only just begun. Every
observation carries action_mask, one number per move: 1 if the move is legal right now, 0 if
not. A move the mask forbids is played as "wait".
mask_planes is the same mask laid out as a grid, one 32 x 18 layer per hand slot. Here is how
many tiles each card in Blue's hand could be played on, five seconds into a battle:
from royalegym import make_env
env = make_env()
obs, info = env.reset(seed=0)
for _ in range(10): # nobody can play a card in the first few seconds
obs, rewards, terminated, truncated, info = env.step({"blue": 0, "red": 0})
names = {card.card_id: card.name for card in env.engine.cards()}
hand = env.battle_state.players[0].hand
planes = obs["blue"]["mask_planes"] # [hand slot, y, x]
for slot in range(4):
print(names[hand[slot]], int(planes[slot].sum()))
Spells can go anywhere, so they have all 576 tiles. Troops and buildings fit only on your own half. RoyaleLearn always picks from the legal moves, so you only need the mask if you write your own bot or trainer.
Ability Buttons¶
Heroes and champions have an ability button. TileActionParser(ability_buttons=True) adds one
move per button slot after the 2305 tile moves, so there are 2308. A button move is legal only
while that ability can be used. make_env(heroes=[...]) turns the buttons on for you.
Other Options¶
| Option | What it does |
|---|---|
TileActionParser(ability_buttons=True) |
Adds the hero and champion ability buttons. |
TileActionParser(buildings="taps_where_the_building_stays") |
Only offers building taps where the building lands on the tile you chose. By default the game moves a building that doesn't fit to the nearest place it does. Not for RoyaleLearn: its start-up check refuses it when the deck has a building. |
HalfTileActionParser() |
Half-tile precision: 9217 moves instead of 2305. |
Which parsers RoyaleLearn can train
RoyaleLearn's trainer is built for TileActionParser's grid of moves, with its default
buildings setting, with or without ability buttons. Any other action parser, including
your own, works with the environment and with a trainer you write or bring yourself. With
another parser in build_env, training stops with spatial planes are (32, 18) tiles,
tiles says (64, 36) or the observation space has no 'mask_planes' key: that's this limit.
In your quickstart.py
The parser is the action_parser = ... line in build_env. Changing it changes the bot's
moves, so it needs a new bot: change save_dir to a new name too.
How They Work¶
Every action parser has these methods:
# Called once when the environment is made. Read what you need from the engine here.
def bind(self, engine): ...
# How many moves there are, as a gymnasium Discrete space.
@property
def space(self): ...
# One number per move: 1 if it is legal for `team` in `state`, 0 if not.
def action_mask(self, state, team): ...
# The card play for one move, or None to wait.
def parse(self, action, state, team): ...
Creating Your Own¶
Here is a parser with only 13 moves: wait, or play a card in one of three spots. It borrows
TileActionParser to work out which of its moves are legal.
import numpy as np
from gymnasium import spaces
from royalegym import ActionParser, TileActionParser
class FewSpotsActionParser(ActionParser):
"""Plays a card in one of three spots: in front of either bridge, or behind the king tower."""
SPOTS = [(3, 13), (14, 13), (8, 0)] # (tile x, tile y), seen from the bot's own side
def bind(self, engine):
super().bind(engine)
self.tiles = TileActionParser() # it works out what is legal for us
self.tiles.bind(engine)
@property
def space(self):
return spaces.Discrete(1 + 4 * len(self.SPOTS)) # wait, or a card in a spot
def _tile_action(self, action):
slot, spot = divmod(action - 1, len(self.SPOTS))
x, y = self.SPOTS[spot]
return self.tiles.encode(slot, x, y)
def action_mask(self, state, team):
tile_mask = self.tiles.action_mask(state, team)
mask = np.zeros(self.space.n, dtype=np.int8)
mask[0] = 1 # waiting is always allowed
for action in range(1, self.space.n):
mask[action] = tile_mask[self._tile_action(action)]
return mask
def parse(self, action, state, team):
if action == 0:
return None # wait
return self.tiles.parse(self._tile_action(action), state, team)
Pass it to the environment like any other piece: