Internals¶
This page is for people changing the viewer, or writing something that feeds it. The guide covers usage. Here you get the frame model, the recording format, the stream protocol, the command line, the window layout, the key table, what the viewer costs, how it is tested and what it cannot draw.
One frame model, three sources¶
Every source produces the same royaleviser.model.Frame, so the renderer never learns where
a battle came from.
royalegym.render, the offline HTML page of a trace, stays in RoyaleGym as the replay that
needs none of this package.
| Source | Given as | Format | Raw units per tile | Timeline |
|---|---|---|---|---|
CaptureSource |
a frames-*.jsonl or .jsonl.gz path |
a capture of a real battle at 20 Hz, one JSON object per line (the fields are listed on the class); a .jsonl name resolves to the gzipped file too |
1000 (native milli-tiles) | yes |
TraceSource |
a .msgpack / .json path |
royalegym.replay.Trace, recorded by ReplayRecorder on a ClashParallelEnv |
the header's subtile (18000) |
yes |
StreamSource |
--stream host:port |
msgpack Frame datagrams from royalegym.viser.ViserPublisher, and a learner's status datagrams from sources.LearningPublisher one port up (--learning) |
the first frame's | no (latest frame only) |
The invariants a source must hold:
- Positions stay in the source's own integer units (
Frame.units_per_tile) and in the native / engine frame, team 0's back edge aty = 0. Which seat sits at the bottom is a view choice made by the renderer, never a transform applied to the data. - Elixir is integer thousandths; ticks and every other quantity are integers. The model holds no floats.
- Card names are resolved up front (
model.Names, fromroyaleviser/cards.json, fromROYALEVISER_CARDS, or from a trace header'sCardInfolist). - A source says what it does not know rather than guessing.
Player.hand_knownisFalsefor an opponent whose hand a capture does not carry, and the dashboard prints "hand: not in this source".model.problems(frame)lists contract violations, and the tests run it on every source. - A building or tower carries the box it stands on,
Unit.footprint, as a closed[x0, y0, x1, y1]in the frame's own units.Nonemeans a troop, or a source that does not have one: see the next section, which is about what the window does with that.
The card table royaleviser/cards.json maps a card's register name to [id, cost]; it is the
live client's table with the hero-form Musketeer (203000014) folded into the Musketeer.
ROYALEVISER_CARDS points Names.live() at another table, and a trace header's CardInfo
list overrides both for that trace. cards.json and the tunnel-speed table
sources.LIVE_TUNNEL_SPEED are copies of the recording instrument's own tables and must
stay equal to them.
How big a building is drawn¶
A Cannon is 3 tiles by 3. Until 2026-09-22 the window drew every building as a square of twice its collision radius, which for a Cannon is 1.2 tiles, and the two crown towers from constants in the renderer. None of those numbers is the box a building occupies; the owner found it by looking at a Cannon sitting against the arena wall over about a sixth of the ground it stands on: a 1.2-tile square is 0.4 of a 3-tile side, so 0.16 of the area.
So the rule is now one line: the drawn rectangle is Unit.footprint, and where a frame
carries one nothing else has a say. Renderer.unit_rect_px is the single place that
decides, and the hp bar, the name label, the hover ring, the click target, the deploy veil
and the compare ghost all read it, so a 3x3 Cannon is clickable over all nine of its tiles and
carries a bar and a veil its own width. The collision radius decides only the circle inside.
Where a frame carries none -- every recording, and every trace and stream written before the
field existed -- the old guess is still drawn, because the window has to draw something, and
it is marked: red ticks on the building's four corners, a red count in the status block
("5 of 8 building sizes guessed"), and the reason in the inspector's box row. A wrong size
drawn plainly is indistinguishable from a right one, which is exactly how a Cannon one tile
wide sat on the board for days looking like a fact.
The box and the circle are two different things, so they are drawn as two shapes, and which one is FILLED says which is the thing itself. The filled circle is the collision radius: what other units and other buildings actually run into, and so the building's body. The outline around it is the footprint, the ground it stands on, which is a fact about the board rather than about the unit. Measured on the engine, 2026-09-22: a Cannon's body is 0.6 of a tile across a box of 3, a princess tower 1.0 across 3, a king tower 1.4 across 4.
The two shapes also carry different colours, for the same reason. The body is the TEAM's colour; the box is neutral. Whose building it is is a fact about the building, and the ground it stands on is a fact about the board.
A frame that carries no radius has no body to draw, and an outline on its own is a building you can see through. Every recording is that case, so the box is filled in the team's colour instead and the marks above say its size was guessed. Until 2026-09-22 the window drew a filled box with an inner SQUARE at half of it, on towers only, which was neither quantity.
The board under the battle¶
Two things about the ground a watcher used to have to infer.
Where nothing may be placed is filled grey: the back rows and the river's corners, taken from the arena's own NO_DEPLOY cells rather than from a list here. The KING's block is in that data and is deliberately not filled (owner, 2026-09-23): a building already says this ground is unusable by standing on it, and the grey only competed with the piece for the reader's eye. It was visible rather than hidden. A building is an unfilled footprint box around a filled collision circle, and the king's circle is 1.4 tiles against the block's 1.5-tile half-extent. The grey used to be a faint outline around each region, which asks a reader to reconstruct a shape from its border and reads as decoration beside the grass. A player cannot use that ground, so it does not look like ground they can use.
Every crown tower has a thin outline round its zone, the king's included (owner, 2026-09-24). This is not the grey fill. It is one line, and it sits inside the tower's white footprint box. The first change that stopped filling the king's block took this outline away too, and the king was left as the one tower with nothing under it. The king's outline is his no-deploy block; a princess's 2x2 outline is a mark of her zone only, as no placement rule reads that ground (RoyaleSim 0d0ccd6), and it stays by the owner's ruling of 2026-09-28.
Each bridge carries a brown rail down both long sides. A bridge is the only way across the river and its edge is where a unit stops being on it. The rails are drawn on the half-cells whose left or right neighbour is not bridge, so they follow whatever the arena says the bridges are rather than a hard-coded span.
Two things the window can say about footprints without knowing any of the engine's rules:
- B shades every carried box in its team's colour and edges it. It draws nothing about a tap inside a box: what that tap does turns on the card and on whose box it is, and no frame carries either. Measured on RoyaleSim 0d0ccd6 (2026-09-28) at the tile centres inside the boxes of two boards, both seats, for a Knight, Minions, a Cannon, a Tesla, a Fireball and a Heal. A troop or a Heal tapped in its own side's building or princess tower is moved off it, 1 or 2 tiles. A building card tapped there is moved 1.5 to 4 tiles, to where its own box fits. A Fireball lands on the tile it was tapped on. An enemy box refuses a troop only on the tile under its building's collision circle (one of a Cannon's nine), and only where that troop may be played at all. At your own king the refusals come from its no-deploy block, which the arena carries with or without a box: a troop is refused on 9 of the box's 16 tiles and moved off the other 7, a building or a Heal is refused on all 16. Until 2026-09-28 B marked every tile whose centre lies in a box as refused, as the engine's rule for the tap point. Of the 2,052 verdicts under those marks on the two boards, 954 were accepted, 1,092 were refused for the ground (out of territory, or no-deploy cells), and 6 were refused for a building in the way.
- The status block says when a carried box runs off the board, which is the shape of the defect the owner saw. Whether a placement was legal is the engine's answer; whether a box is inside the arena is two comparisons on numbers already in front of the window.
Boxes may legitimately touch each other and the side walls; only a positive-area overlap is illegal, so a building drawn flush against a tower is not by itself a defect.
Units on one spot¶
Two troops of one team and one layer (both flying or both on the ground) whose centres are
closer than a quarter of a tile (render.STACK_TILES_X100) are a stack (render.stacks;
chained, so a troop within reach of any member joins it). Troops are drawn in frame order, so
the last member is on top and the only one that shows. It is labelled with the count,
RamRider x2, and the members under it get no label, because theirs would print over its own
in the same place. Below 16 px a tile no unit is labelled, so no count is drawn either.
The rule was sized on the engine (RoyaleSim 0d0ccd6) over 48 scripted battles, 147,192 ticks, with decks of the Ram Rider, spawners and swarms. The Ram Rider's rider stands where its Ram stood a tick before: at most 0.207 tile away, 0.059 at the median, with the same radius (0.6 tile), colour and name. At 24 px a tile that is at most 5 px off a 14 px body, so the rider cannot be seen, whichever of the two the frame lists last.
| same-team troop pairs, counted per tick | within 0.1 tile | within 0.35 tile |
|---|---|---|
| a rider and its Ram (50,358) | 31,659 (62.9 %) | 50,358 (100 %) |
| any other two | 1,775 | 5,257 |
The other pairs within 0.35 tile are units that have just landed on one point (the
Tombstone's skeletons, 3,167; a Skeleton Army while it deploys, 682) and a Minion Horde flying
over ground teammates, which is drawn apart (its shadow and white ring) and so is left out.
A troop came that close to the centre of its own team's building on 44 pair-ticks, 39 of them
a Minion Horde over a Tombstone; troops are drawn after buildings, so none of those hides
anything. Under the rule as written the Ram Rider was a stack on all 50,358 of its ticks.
Every other stack lasted at most 7 ticks, 1,215 stack-ticks in all, and 1,208 of them held a
unit at most 10 ticks old: the Tombstone's skeletons as they appear (they carry the
Tombstone's name, the card that put them on the board) and a Skeleton Army landing. No two
Barbarians, Goblins, Goblin Gang members or Minion Horde minions made one (121, 116, 124 and
109 plays). stacks takes 20 us a frame at the median and 144 us on the largest frame (52
troops).
A rider its source names is drawn as a rider instead. A troop whose row says which unit it
sits on (extra["mount"], the mount's uid: RoyaleGym 3cba372's EntityState.mount_uid, which
unit_dict puts in extra for a rider only, from the mount_uid column every engine entity
row ends in since RoyaleSim 6f680d6) and whose mount is in the frame is found by
render.riders. It is left out of every stack and drawn
after all other units as a seat on its mount: a disc of 55 % of the mount's radius in the team's
colour with a white rim (theme.rider_rim), 40 % of the radius above the mount's centre. It
is placed from the MOUNT's position, not its own, because the engine puts a rider where its
mount stood a tick before and the seat would jitter. The mount's label, hp bar and effects are
the board's; the rider's own are in the inspector, and pinning the rider rings its seat. A
battle on RoyaleSim 6f680d6 draws it: a Ram Rider played there comes out as the ram and a rider
whose mount_uid is the ram's uid, and riders pairs them. A trace keeps the column, because
it stores the engine's entity rows whole. A source that names no mount (a recording of a real
match, or an engine before that column) draws the stack as above, byte for byte.
Effects, spells and shots¶
Until 2026-09-24 a stun was the only effect drawn, every spell was one magenta dot, and nothing a unit or tower fired reached the window. The owner watches battles to tell what is going on, so each of these now has a shape of its own as well as a colour. A colour alone is lost on a colour-blind reader and at the compact layout's small scale.
Status effects come from Unit.status, the engine's buffs as (name, ms left), plus
stun_ticks and extra["shield"]. A unit shows them in three layers:
| layer | what it shows |
|---|---|
| the body | the strongest ring effect: freeze (icy veil and a white rim), stun (a jagged ring), poison (a ring of dots), slow (a dashed ring), rage (a purple glow) |
| the corners | a heal's plus at the top right, a shield at the top left, beside any ring |
| the pips | one dot per effect above the hp bar, so a unit frozen AND poisoned says both |
Every mark sits on a black copy of itself, one pixel wider, so a yellow stun still reads on yellow grass. An effect the viewer cannot name still gets a grey pip, and the inspector lists every effect as its kind, its time left and the engine's name for it.
What a buff does comes from its numbers, not its name. royaleviser/engine_tables.py
maps each of the engine's buff names to the kinds it gives. It was generated from one vintage
of RoyaleSim's card data (named in the file) by the rule the engine itself composes speeds
with:
| the buff's numbers | kind |
|---|---|
| speed -100 | a HOLD: the engine stops the unit. A Freeze by name is "freeze"; every other hold (Zap, the Electro Wizard, snares, Stun) is "stun" |
| damage per second | poison, drawn in the spell's own colour when a spell has one, so an Earthquake is brown and not Poison green |
| heal per second | heal |
| a negative speed or hit speed | slow |
| a speed or hit speed above 100 | rage. 100 is the identity and 0 is a blank column, so neither is a speed change |
| an attract | pull (a Tornado) |
A buff can give several: Poison is poison AND slow, because it slows by 15 %. Poison comes before slow on the body, so a poisoned unit shows what is killing it. The first version read words in the name and drew IceWizardCold, BolaSnare, Earthquake and Stun as unknown.
Two things about holds come from how the engine keeps them. It holds a unit through its stun timer whatever holds it, so a hold arrives as a buff AND stun ticks. The buff says which hold it is, and the ticks add a stun only when no buff explains them. And the engine merges buffs whose numbers are identical under one joined name, "Freeze|ZapFreeze". When the parts of a joined name disagree, the viewer cannot tell a Zap from a Freeze, so it draws the hold as a stun: a stun claims only that the unit cannot act, and an ice veil would claim a Freeze was cast.
tests/test_status_effects.py re-derives the table from RoyaleSim's data of the same vintage
when it is on disk, restating the rules independently, and names any entry that has drifted.
Spell cards are drawn by family: a ball for Fireball and the Snowball, a volley of strokes for Arrows, a streak for the Rocket, a bar across the roll for the Log and the Barbarian Barrel ("BarbLog" in the card data), a translucent disc for Poison, Freeze, Rage and the other areas, and a disc with a jagged edge for Zap and Lightning. A spell the viewer does not know is drawn by its motion in the generic colour. The label stays: the shape says the family and the name says the card.
A spell is drawn at its card's size. The frame's extra["radius"] first, when a source
sends one, then the card's radius from engine_tables.SPELL_RADIUS_MILLI, and 1.5 tiles only
for a spell neither knows. Until 2026-09-24 every area was 1.5 tiles, so a Poison of 3.5
covered a fifth of the ground it poisons and units marked as poisoned stood outside it.
Shots come from Frame.projectiles, and each takes the shape of what fired it:
| shape | fired by |
|---|---|
| an arrow: a shaft with a head, pointing where it flies | Archer, Princess, Magic Archer, the Goblins' spears and darts |
| a bullet: a small dot with a short tail behind it | Musketeer, Hunter, Minions, a Cannon |
| a ball: a bigger dot with a tail, and a tint on the ground it will hit | Wizard, Baby Dragon, Bomber, Bowler, any shot that splashes |
| a dot with a white core | a crown tower |
The shape comes from the firing card's projectile in the engine's card data
(engine_tables.SHOT_KIND): a projectile named as an arrow, spear or dart is an arrow, a splash
of a tile or more is a ball, and anything else is a bullet. A shot whose firer the source does
not know (name None) is a plain bullet, because "unknown" must never read as "a tower fired
this". A recording's shots still arrive as spells named
"... shot", as before, and a shot is never drawn as an area.
Ground effects go under the units. An area spell's fill and a shot's splash are drawn before the units, and their edges, labels and shots after. Drawn over the units, a Poison cloud dyed every unit in it and a splash dyed its target, so a reader saw the effect's colour where the team's colour should have been.
Cards and the special forms¶
A card tile (Renderer._draw_card, 80 x 100 px). The face is theme.card_troop,
card_building or card_spell by what the card is, and a glyph in the art panel's corner says
the same (a sword, a tower, a spark), so colour is never the only cue. A card no table
describes is the plain card_bg tile with no glyph. Large in the middle, a two-letter monogram
(render.monogram: the first two words' initials, or a one-word name's first two letters);
the whole name on a dark band; an elixir drop with the cost; xN for a card that summons N;
two chevrons for a flyer. The elixir still missing is a veil from the top down
(theme.card_veil): at 1.5 of 3 elixir the top half is veiled. A free card is never veiled,
and an unknown elixir veils nothing. The white border (theme.card_border) is the "playable"
mark: a card short of elixir has none and gets it the moment it can be played (owner,
2026-09-28); the evolution and hero frames are drawn either way, because they say what the card
is, not whether it is ready. The next card is a 24 px tile beside the elixir bar, and
it and the ability buttons show a two-character code (render.tile_codes) rather than always
the monogram. Of the engine's 136 cards, 58 share their monogram with another (25 monograms:
Wi for Witch and Wizard, five cards on MM), and three pairs of monograms differ only in
case (SK and Sk, GH and Gh, EA and Ea). Even with the tile's colour and cost dot,
9 groups of 18 cards draw identical small tiles (Witch and Wizard, Minions and Miner, Balloon
and Barbarians, Skeleton Army and Super Archer, ...). So when the player's deck (without one,
the hand and the next card) holds two cards whose monograms match, case aside, each shows its
first letter and, in lower case, the first letter of its name that differs from the others' at
the same place (Witch Wt, Wizard Wz); a deck without such a pair draws as before. A third
letter does not fit: in the tiny font (pygame's default at 20) SkA is 28 px and MMa 29
against the tile's 24, and the widest monogram, WitchMother's WM, is already 23. No code that
replaces a monogram is wider than that (tests/test_stacks_and_codes.py tries every pair of
cards whose monograms match with every third card).
What a card is comes from Names.face_of(name), a model.CardFace(name, cost, kind,
count, flying) with None in any field the source does not give. An engine table
(Names.from_cards) reads the engine's card_kind column, count and flying; an engine
without the column gives no kind, and none is guessed from placement, which says where a
card may be played and not what it is (a Heal places like a troop). A live table
(Names.live) takes the kind from the card id's class (26xxxxxx troop, 27xxxxxx building,
28xxxxxx spell) and has no count or flight. The two agree on every card both know except two,
pinned by name in tests/test_cards.py: the Furnace (FirespiritHut: a building by its id, a
troop by the engine's kind, which is the row the card puts on the board) and the Spirit
Empress (MergeMaiden: an event card among the spell ids that summons a flying troop). A
stream carries no card table, so the window falls back to the live one, and those two cards,
every xN and every wing differ between a stream and a trace of the same battle.
The special forms (evolutions and heroes) are two trailing Player fields in the layout the
engine ships (RoyaleSim state_json, 2026-09-27), names for its ids. Each is empty by
default, and empty means "not said", so every frame from before them decodes unchanged and
draws as it did. model.problems checks their shapes.
| Field | One row | Drawn as |
|---|---|---|
evo |
per evolved deck entry, in deck order: (card name, the card's plays since its last evolved play, 1 when its next play is the evolution else 0) | a filled pip per play at the foot of the tile's art (one hollow pip for none yet); and when its next play is the evolution, the tile framed in theme.evo with an EVO tag |
abilities |
per button, the side's heroes in deck order and then its champion: (the card's name, or "" when the source does not know it, available 0/1, spent 0/1, the press's elixir) | a 13 px button in the elixir row where the next card's name was (three fit): gold when available, dark and crossed when spent, grey when neither; on grey, the whole seconds until it can be pressed when ability_cooldowns gives them (rounded up, so never 0 while it waits), else the card's code when named (as the next card's, above); the elixir as a dot. A named row also frames that card's tile in gold with a crown |
ability_cooldowns |
per button, parallel to abilities: the ticks its recharge has left, -1 when the source does not say. 0 does not mean it can be pressed: a champion's row reads 0, and not available, while his chain runs; available is what says |
the seconds on a grey button, above. A key of its own and not a fifth column of abilities, so a viewer from before it still decodes every frame |
There is no per-slot row: a hand slot is "evolved now" when the evo row for its card says
so (model.hand_evolved: 1, 0, or -1 for a card with no row). The engine says how many plays
a card has counted but not how many it takes, so the pips count up and promise no total. It
sends no casting state and no charges, and the button draws neither. The champion build appends
[card_id, cooldown_ticks] to each engine ability row; sources.special_fields reads the rows by
index, so rows of three and of five both convert, the card id naming the button and the
cooldown going to ability_cooldowns (the key only when some row has one). On RoyaleSim
2245f9f the Golden Knight's button is the one that runs: after a dash chain its row reads
[0, 0, 1, card_id, 219], which draws as a grey button showing 11 (219 ticks of 50 ms, rounded
up), and the frame's contract check (model.problems) comes back empty.
Where they come from. sources.frame_from_state adds them from an engine PlayerState that
carries the engine's rows (sources.special_fields: evo rows [card_id, plays,
next_evolved], abilities rows [available, spent, cost, card_id, cooldown_ticks]), unless
the frame dict already has them. Every ability row names its card, a hero's and a champion's
alike (RoyaleSim 2245f9f), so a bare state names each button and crowns its card in the hand.
An engine from before that column sends [available, spent, cost], the name is "", and only a
publisher that knows the deck's forms can name it. A running engine's stream carries them
(RoyaleGym 2aecf93 on): royalegym.viser.player_dict sends both, with names: a button by its
row's card, else by the deck's k-th form-2 entry when the env's setup has forms, else "". A
side with no evolution, hero or champion sends them empty, which reads as not said. A champion
needs no forms, so a Golden Knight deck without any sends his button.
RoyaleGym/tests/test_viser.py round-trips such a frame through this package's decoder, so ids
where names belong fail there, on the publisher's side.
A trace (RoyaleGym's royalegym-trace) records the deck's forms in its header
(setup.forms, parallel to setup.decks) but, before RoyaleGym 2f710e8 added the frame's own rows, no
per-player rows. TraceSource rebuilds what that allows. Player.heroes names the form-2
entries, and the hand crowns them as it would a card a button names; such a trace has no
buttons, because it does not say when one was ready. evo is rebuilt from the step log with the
engine's rule (a counter of the card's own plays since its last evolved one, the next play
evolved once it reaches the card's cycle). Whether a play WAS evolved is read off the units it
put down: the evolved status bit on a new unit of that card and team, looked for over
EVO_LOOK_FRAMES (60) frames, because the engine can hold each side's commands for a
set number of ticks (Battle.set_command_delay_ticks(blue, red), 0 by default), and a play
then lands that many ticks after the tap. The
cycle is the counter at the card's evolved plays in the trace; a
card never seen evolved, or a play that put nothing down (a spell), uses the engine's default,
2. On six recordings of 2,000 to 5,400 ticks the cycle read off Evo Skeletons' evolved plays
came out 2 on each. Where a frame carries the engine's own evo / abilities rows
per seat, those are used instead, buttons included. A press in the step log (slot HAND_SIZE +
k) is an event line, "Blue presses IceGolemite's ability".
On the board, the engine's status bits, Unit.extra["status_flags"], read only through
model.status_bits: None and -1 mean not reported and draw exactly as 0, for troops,
buildings and towers alike (a raw -1 & bit is the bit). 1, under ground: no body, a patch of
turned earth (theme.burrow) with the team's colour dashed round it. 2, invisible: the body
faded (theme.unseen_alpha) with its black outline kept and the team's colour dashed outside
it. 4, hidden (a Tesla), and 2 on a building: the body faded the same way. 8, evolved, and 16,
hero: rings in theme.evo and theme.hero, each on a black copy, FORM_RING_GAP (8) px
outside the body and so outside every status ring (those reach body + 5, and the Rage glow
body + 6). They are drawn after the status marks, so a Freeze and an evolution ring both
show. A hero also wears a crown over its hp bar, lifted above the status pips when it has any,
and the pin ring moves outside the form rings. The inspector's flags line names the set bits:
"none", "not reported", or for example "underground, hero", with a bit it does not know as its
number. 1, 2 and 4 are royalegym.protocol.STATUS_*; 8 and 16 are the engine's evolved and
hero bits.
The recording format¶
A recording (a capture in the code: CaptureSource, frames-*.jsonl or .jsonl.gz) is
one JSON object per line. A battle frame is {"event": "frame", "active", "seq", "tick",
"players", "entities", "effects"} plus a few timing and bookkeeping fields the viewer keeps
in Frame.meta; the other events (start/stop lines) carry no battle state.
- A player row carries
side(0/1),elixir_raw(ten-thousandths of an elixir),deck(8 card ids, or[]when not known),hand(4 deck indices, -1 when not known) andcycle(deck indices, the next card first). - An entity row carries
id(an opaque string, reused within a battle),card_id(-1 for a tower),side,x/yin native milli-tiles (1000 per tile),hp/max_hp,behavior_state,target(another entity'sid),movement_direction_x/_yandpath_nodes(half-tile cells on a 36 x 64 grid, goal first). Further fields land inUnit.extra, which is what the inspector prints under "extra". - An effect row is a projectile or a spell:
side,card_id(the shooter's, -1 for a tower, or the spell's),x/ythis tick,x2/y2the previous tick andprojectile_x/_ythe aim.
The converters (sources.capture_unit, capture_spell, capture_player, capture_frame,
CaptureEvents) are exported, so another front end can build the same Frame rows from
the same fields. CaptureSource reads the whole file up front and keeps the raw lines of
the active frames indexed by tick. A frame is parsed when it is looked at, through a bounded
cache of PARSE_CACHE = 200 parsed frames. That is why a 40 MB recording opens in about
0.2 s and a seek costs one json.loads. Events (spawns, deaths, the local player's plays) are
derived forward from consecutive frames the first time the timeline passes them and cached
per index, so seeking backwards still shows the events up to that frame.
Comparing two sources¶
The window ghosts a second source (a second path, or --compare PATH) onto the board as
hollow white shapes at the primary's tick, and compares it tick by tick. The comparison is on
the multiset of (team, name, x, y, hp) over one tick's units, that is on the bag of those
tuples with order ignored and duplicates kept: for the two seats of one battle that multiset
must be equal, since entity ids differ between clients but nothing else does.
The panel prints tick T: N entities, M differ and the running K ticks compared, D differ
(app.Compare; a replay seeks the second source to the exact tick, live sources are matched
through a 60-tick buffer).
Measured on the two seats of one recorded battle (client 16.402): 2404 ticks compared, 3 differ. All three are on tap ticks, where the two recordings disagree for a single frame. An engine trace is compared against the recording it was calibrated on in exactly the same way, in milli-tiles.
Re-run 2026-09-21 headless over the whole battle (--speed 8 --seconds 32, the two
demo-20260920-1207* recordings under tests/captures): the panel ends at GAME OVER with
2407 ticks compared, 3 differ, after 1910 draws at 2.80 ms mean. The test that pins the
same battle counts 2407 ticks both recordings hold, 2404 equal and 3 differing (the 2404
above is that count from a 28 s run on 2026-09-20). The results screen at the end of a
recording fills in the opponent's deck and hand, so the last frame is the one where both
hands are known.
On the two synthetic recordings below the same comparison gives 0 differing ticks over the
whole battle (ticks 0..394, so the panel prints 395 ticks compared, 0 differ), which is
what the tests pin.
A tolerance, for the case where exact equality is the wrong question¶
Two recordings of one battle agree to the unit, because both clients run the same lockstep simulation. An engine replaying a recorded battle does not and never will, so exact equality makes every unit differ and the count says nothing about whether the engine is close or lost.
--tolerance MILLITILES changes the question to how far apart they are. Each unit is paired
with the nearest unit of the other side of the same team and name, closest pair first, and a
pair within the tolerance agrees. Three properties are deliberate:
- A unit one side has and the other does not is never within any tolerance, however large. That is the failure a position tolerance must not hide.
- HP is not part of the pairing and not part of the differ count. A unit standing in the right
place with the wrong hp is a different finding from one in the wrong place, so it is counted
beside them:
12 entities, 1 differ, 2 hp. - Every line judged by a tolerance says which one, on the totals line. A reader who sees
0 differand no tolerance will take it for exact agreement.
The engine beside the battle it replayed¶
python -m royaleviser --parity FILE opens both sides of a RoyaleSim parity trace at
once: the recording as the main source, the engine's own run of the same battle ghosted over
it, compared within 250 milli-tiles (a quarter tile, the tightest band that report scores)
unless --tolerance says otherwise. That is one command for "where do the engine and the real
game disagree", and it needs a machine that has the fixtures and the results, the way the
parity gate does.
The file is RoyaleSim's replay harness run with --trace, which adds a row per scored
unit-tick: the tick, the unit, where the recording had it and where the engine put it, both in
native milli-tiles. royaleviser.parity turns each column into a source. Both sides key units
by the RECORDING's entity key, so the compare pairs them without guessing.
Both sides key every row by the RECORDING's entity key, so the comparison pairs them by that
key rather than by name and distance. That is not a refinement, it is the difference between a
true answer and a flattering one: pairing by name pairs two Skeletons that SWAPPED places with
each other's positions, and the tick then reads as agreeing. Compare(pair_by_uid=True) is on
only for --parity, because two recordings of one battle number their entities separately. It
is on at EVERY tolerance there, including 0: a tolerance of 0 is the strictest setting, not the
absence of one, and it used to be the single setting that fell back to pairing by name.
What a parity file does not carry, and what the viewer does about it: no elixir, hands, decks,
crowns or result, so those say "not in this source"; no radius and no footprint, so buildings
draw at the marked fallback size; the path is a COUNT of nodes rather than the nodes, so it
rides in the inspector instead of being drawn as a path nobody recorded. max_hp is the most
that unit was ever seen with ON ITS OWN SIDE, so a recording's hp bar is never drawn against a
number only the engine reached. The report's first_divergence, its one statement about a
moment rather than a total, becomes an event line to scrub to.
Two columns the viewer deliberately does not draw. The engine's target is an index into the
harness's own list of engine entities, not a recording key, and the file publishes no way back
from it, so drawing it as a target line would point at whichever unit happened to hold that
number; it is a number in the inspector instead. And the rows are MATCHED PAIRS only, so an
entity the harness could not match is in neither side: the report counts those and the status
line carries the counts, because a view that quietly dropped them would be at its most
convincing exactly where the engine and the game agree least.
The synthetic recordings¶
tests/synthetic.py scripts a two-minute battle as Frames for the renderer's tests and the
look check (tests/run_synthetic.py). The same script is written out in the recording format
that CaptureSource reads, once per seat, as tests/fixtures/frames-synthetic-A.jsonl.gz
(seat 0) and -B.jsonl.gz (seat 1), committed, about 20 KB each:
- one capture frame per six script ticks, so the 118 s script is a 394-tick battle whose
units move at six times their scripted speed; the frame's
tickis its own index, as in a recording; - a
startline, threeactive: falseframes, the battle, then 24 frames repeating tick 394 (the results screen, where both players' hands and decks fill in) and astopline; - only the recording seat's hand, cycle and deck until the results screen; both players' elixir throughout;
- entity ids are opaque hex strings from a seeded generator, different per seat, and an id
goes back into a pool when its entity dies so a later entity comes up on it (the Musketeer
on the fallen tower's id, the Giant on the Cannon's), which is why
Unit.uidcarries the card and side too; - tower shots (an attacked tower fires back, one projectile crossing to its attacker every four frames), the Fireball in flight and where it landed, the Zap as an area; targets are dropped once their entity is gone.
python tests/synthetic.py --check confirms the files are what the generator writes and
--write regenerates them; tests/test_sources.py::test_synthetic_fixtures_match_the_generator
runs the check, so a change to the script fails the suite until the fixtures are rewritten.
test_capture_frames_round_trip_the_script reads every tenth frame back and compares it with
the script's own Frame (positions, hp, kinds, targets, paths, hand, cycle, elixir, tower hp,
crowns), so the converters are tested against a known answer rather than against themselves.
Tests¶
..\.venv\Scripts\python -m pytest -q from the repo folder, and
..\.venv\Scripts\python -m ruff check royaleviser tests.
tests/test_model.py: the frame contract, the theme, the layout, the CLI parser.tests/test_sources.py: the capture source on the synthetic and the recorded battles, MockEngine traces, the UDP round trip throughPublisherandStreamSource.tests/test_render.py: headless draws of every panel and toggle, the pixel positions of both kings, the compact layout, the compare totals and the tolerance, the app's keys and pacing,runwith--shotand--geometry. The footprint tests grade the drawn PIXELS rather than the helper that positions them: a helper returning the right rectangle and a draw call using a different one is the bug they exist for.tests/test_status_effects.py: status effects, spell cards and shots, read back from the drawn pixels on unit rows shaped the way the engine sends them, andengine_tables.pyre-derived from RoyaleSim's card data of the same vintage (a loud skip without it).tests/test_cards.py: the card tiles, the special-form rows and the status bits, read back from the drawn pixels with every expected colour taken from the theme; the rows throughframe_from_statefrom a MockEngine state; a source without the rows or the bits draws byte for byte as before; the live and engine card kinds agree but for the two named cards.tests/test_parity.py: both sides of a parity trace, on a file written by hand in the harness's row shape. One test opens a file the harness itself wrote and SKIPS where no results file with rows is on this machine; it is the only one that can say the shape is still the harness's.tests/test_learner_protocol.py: this package's wire constants against RoyaleLearn's copy of them, and a status the learner builds carried all the way into the drawn panel. Skips where royalelearn is not installed.tests/test_cli.py:python -m royaleviserend to end, headless.
RoyaleGym/tests/test_viser.py round-trips a published frame through this package's decoder,
so a drift between the publisher and the viewer fails there. tests/run_synthetic.py opens
the real window on the scripted battle straight from the script, with no sibling repo and no
recording needed: the look check for the renderer, and its --compare ghosts a
half-tile-shifted copy of the same battle to exercise the compare panel.
The suite has two correct results, and both are one command apart. Measured at 500ae04 on 2026-10-01, with pytest --collect-only -q collecting 373:
| Run | Result |
|---|---|
a clone, ROYALELIVE_REPORTS pointed at an empty folder |
369 passed, 4 skipped |
| this machine, with the recordings | 372 passed, 1 skipped |
The four skips in a clone are the three tests that pin numbers only a recording of a real
battle has (2407 ticks both seats hold, 2404 equal, 3 differ; the Goblin Drill of tick 2974
surfacing 73 ticks later), plus the parity test that needs a results file written with the
harness's --trace. The first three say SKIPPED, NOT PASSED and tests/conftest.py prints
them by name at the end of the run, so a clone's "N passed, M skipped" is never read as all
green. Pointing ROYALELIVE_REPORTS at a folder holding
frames-demo-20260920-120752-A.jsonl, frames-demo-20260920-120754-B.jsonl and
frames-auto-20260920-083112-A.jsonl (or their .jsonl.gz; the default is tests/captures,
gitignored) runs those three.
Two more things move the count, in either run. Without the media extra
(imageio-ffmpeg, which royaleviser.capture needs only for mp4 and gif) ONE more skips,
the single @needs_ffmpeg test: a clone then gives 368 passed, 5 skipped (500ae04,
2026-10-01, pytest run with sys.modules["imageio_ffmpeg"] = None). Without RoyaleLearn
importable, tests/test_learner_protocol.py skips AT IMPORT: its four tests are not collected
(369 rather than 373) and the run reports ONE skip for the whole module, so a clone gives 365
passed and 5 skipped (500ae04, 2026-10-01). A count in this file is the output of the command
beside it and nothing else; the ones that stood here before were measured at a commit fifteen
behind and were wrong at that commit too.
The stream protocol¶
The viewer must never be in the tick loop and must cost nothing when nobody is watching. The
engine-side half lives in the env layer (royalegym.viser.ViserPublisher) and imports
nothing from this package, so the dependency direction stays
RoyaleLearn -> RoyaleGym -> RoyaleSim. sources.Publisher wraps it for a caller that
already holds a Frame.
- The viewer binds a UDP socket and sends the heartbeat datagram
royaleviser 1to the publisher'shost:port(default127.0.0.1:9870) once a second while it is open. - An environment calls
publishonreset()and, while a viewer is attached, once per ENGINE TICK of eachstep()(RoyaleGym f8a3c0d, 2026-09-22; before it, once per step, which at the defaults was 2 frames a second). It publishes only when it has been handed a publisher:ClashParallelEnv(..., viser=ViserPublisher()), which reads no environment variable of its own, the defaultNonecosting oneif. The vectorised env is what readsROYALEVISER=host:port.ClashSelfPlayVecEnv(..., viser="env"), the default, binds one publisher from it and hands it to game 0 (Nonenever publishes, and aViserPublisheris used as given). A viewer watches one battle, and N games each binding its one fixed port is anOSError, so the choice belongs where the games are. While no heartbeat has arrived inATTACH_TIMEOUT_S(3 s),publishreturns after one clock read, 193 ns per call, measured 2026-09-21 over 200k calls, best of three. It polls its socket for heartbeats at most once a second. - While attached it sends one msgpack datagram per call to the last heartbeat's address.
The test suite's synthetic dense frame, whose units carry more raw fields than an
engine's, measures 14.4 KB for 63 units with an effect on every troop (2026-09-24): an
upper bound for today's frames, where each unit also carries its target, facing, attack
phase, effects and shield, and a frame its shots. Before those fields existed, MockEngine
frames measured 2.6 KB for 18 units (12 troops and the 6 towers, which are units of their
own kind, not a list beside them) and 11 KB for 60 (2026-09-20, RoyaleGym
royalegym/viser.py). The limit that matters is the datagram's 65507 bytes: one over it is resent with the unit paths emptied, then dropped and counted (publisher.dropped).
What that costs a training run was measured on 2026-09-22, and the answer is nothing
this measurement could detect. Eighteen iterations alternating three attached and three
detached, three times over, so the ratio is taken inside one window: 28.13 +- 1.25 s an
iteration attached against 28.33 +- 1.65 s detached, a difference of -0.20 s with a
standard error of 0.69; inference time identical to two decimals. Every difference came
out negative, which is the tell that it is noise rather than a cost, so the honest form is
a BOUND and not a point estimate: under 1.4 s an iteration at 95 %, which is under 5 %
of one, on a two-worker 48-battle run at 8,192 timesteps an iteration with a 64x4 net on
an RTX 3050. The geometry is part of the number; quoting the bound without it says less
than nothing. (Measured by the training session; its log carries the raw iterations.)
4. royalegym.viser.frame_dict builds the wire dict from a BattleState;
sources.frame_from_state turns it into a Frame, and TraceSource builds its rows the
same way, so a trace and a stream of one battle draw identically. The one exception is the
special-form player rows, which a stream and frame_from_state carry and TraceSource
rebuilds from the header's deck forms and the plays, without buttons
(Cards and the special forms). Spawn and death event
lines come from uid diffing, play lines from the step's accepted deploys.
RoyaleGym/tests/test_viser.py round-trips a published frame through this package's
decoder, so a drift between the two ends fails there rather than in someone's window.
While a viewer is attached the stream carries one frame per engine tick, 20 a second
(RoyaleGym f8a3c0d, 2026-09-22). Before that it carried one per env step, decision_ms worth
of ticks, which at the defaults was 2 frames a second. A trace is different: it has one frame
per tick only when recorded with ReplayRecorder(frame_every_tick=True), and one per step
otherwise, whoever was watching.
The learning status¶
The dashboard's learning panel is filled by a second sender on a second port: the
learner, not the environment. Its numbers are ready once per PPO iteration rather than once
per step, and they are most interesting exactly while the learner is optimising and no frame
is moving, so they do not ride on a Frame. Putting them there would have repeated twenty
numbers on every datagram, tied them to the environment's clock, and made the panel go quiet
whenever the board did.
sources.LearningPublisherbindshost:port. By default that is the frames' port plus one (sources.learning_endpoint, 9871 against the default 9870), because two processes cannot bind one port.--learning HOST:PORTmoves it.- The viewer says hello to both addresses from its one socket, so the learner attaches on
the same heartbeat rule as the frame publisher and sends nothing until a viewer is there.
Detached,
publishkeeps the status and returns after one clock read, 435 ns per call, measured 2026-09-21 over 200k calls, best of three. It polls the socket at most once a second. It is not in anybody's tick loop either way: it is the learner's own process, and the environment's publisher is untouched by any of this. - A status datagram is msgpack
{"learning": {...}}: the one-key map makes its first bytes (model.LEARNING_PREFIX) something a frame can never start with, soStreamSourcesorts the two kinds apart with one comparison and no decode. ~300 bytes, far inside one datagram. Anything that decodes as neither is counted inStreamSource.rejectedand named in the status line rather than raised. - One message is the whole status (
model.Learning). The viewer replaces what it holds rather than merging, so the panel never shows a composite the learner never asserted at one moment; a field the learner stops sending goes back to an em dash rather than standing as a stale number, and a field it never sends was never a zero. - The last status is kept and sent again as soon as a viewer says hello, so a viewer
that attaches between two iterations fills its panel within a heartbeat instead of
waiting minutes for the next one. A daemon thread waking once a second answers that
hello, because a learner inside an optimisation step calls nothing for a long time
(
pump_thread=Falsehands that to the caller's ownpump()). A hello after longer than the attach timeout counts as a fresh attachment whatever its address, since a viewer away that long may have been restarted. - Nothing acknowledges a datagram. A lost frame is replaced a few milliseconds later; a
lost status is the panel standing still for a whole iteration, and a rollout publishing
faster than the viewer's loop drains can fill the receive queue exactly as the one status
of that minute arrives. Two things answer that: the viewer's socket asks for a megabyte of
receive buffer (
sources.STREAM_RCVBUF, a few hundred frames, against the 64 KB default that is two dozen), and each viewer is sent the standing statusLEARNING_REPEATS(3) times a heartbeat apart before the sender falls silent.
from royaleviser.model import Learning
from royaleviser.sources import LearningPublisher
learner = LearningPublisher() # 127.0.0.1:9871
for it in range(iterations):
... # rollout, then optimise
learner.publish(Learning(run="ppo-0007", iteration=it, policy_loss=0.0241, elo=1183))
Learning.extrais the open tail:{name: number or string}, drawn under the fixed rows in the order the learner sent them, so a number the fixed list has no place for needs no change on this side. The viewer only formats them (render.extra_text: an integer with thousands, a float to four significant figures, anything else as it stands,Noneas an em dash); the names and their meaning are the learner's. The panel leaves out the rows that do not fit its column, extras first, and a status too big for one datagram is counted inLearningPublisher.droppedrather than truncated. A value msgpack has no type for is counted there too, after one attempt to read it as the number it holds (model.scalar_of, which is what makes a numpy float a float); the learner's own thread survives it either way.
A key that is neither a field name nor extra is ignored: a misspelled field leaves the
em dash of the field that stayed unset, rather than showing up as a wrong number somewhere
else.
How far apart the numbers really are, and where the time goes. Measured 2026-09-22 on
the laptop profile (RustEngine, three worker processes, minibatch 512), from both ends
independently: an iteration is 518 to 544 seconds of wall clock on a quiet machine, and
806 seconds on a saturated one, which was running two cargo builds, a rustc and 36
python processes with 212 MB free. A cadence number is only a fact with the machine's state
attached.
The minibatch is part of that state, and it has moved since. The shipped default is 256 (RoyaleLearn commit 588dc04), because a minibatch of 512 does not fit a 4 GB card and spills into system memory. Nobody has timed the current default. So the timings here and the cadence below stand as they were measured, on 2026-09-22 at a minibatch of 512, and a reader on the default will not reproduce them.
The decomposition is the durable part, and it is the surprise. Timed from the viewer's side across two iterations: the environment published 228 frames over about 40 seconds, then sent nothing for 765 seconds before the next status arrived. The environment's own timers agree: collection is 13 to 19 seconds and the update is 84 to 98 per cent of the iteration, and the engine itself collects at roughly 1800 environment steps a second. The 64 steps a second an iteration averages is the UPDATE dragging the average down, not the engine being slow.
So a viewer on a real run, at those settings, sees a board that stands still for eight to thirteen minutes at a time, and a panel that moves once in that window. The board is still because the learner is thinking, not because the engine is slow. Those are different findings, owned by different people. That is the separate datagram earning its keep, and it is why the panel prints how old the status is beside its heading: at this cadence a run that is working and a run that died forty minutes ago look identical without it, and the still panel is the NORMAL case.
Two runs on one machine collide, and the panel cannot tell you so. The ports are fixed,
so the second run's learner finds the status port taken and publishes nothing. Meanwhile its
frame publisher may well get its own port and stream the battle normally. The window then
shows a live battle under a panel reading "no learner", which is nearly indistinguishable
from a run with no learner at all: the learner that failed to bind has no socket to say so
on. The panel does what little it can from this side and NAMES THE PORT it is listening on
("no learner on 127.0.0.1:9871"), so the absence is something a person can check rather than
a shrug; the asymmetry is what makes it a trap, since the frame publisher may get its port
while the learner does not, and a moving board is the first thing anyone looks at. Measured on 2026-09-22, one run holding 9871 while another streamed frames on 9870.
Give a second run its own pair (the learner's sink takes a host and port, and the viewer
takes --learning HOST:PORT), and check the learner's own log if a panel stays empty while
a battle plays. A related consequence of the same fixed-peer design: each publisher keeps
ONE peer, the address of the last heartbeat, so a second viewer saying hello to a run
silently takes the stream from the first.
A learner that would rather not import this package sends the same datagram itself, and needs these six constants to match:
| Constant | Value | What it is |
|---|---|---|
sources.STREAM_HELLO |
b"royaleviser 1" |
the viewer's heartbeat, sent to the learner's port once a second |
sources.STREAM_HEARTBEAT_S |
1.0 |
how often it arrives, and how often to look for it |
sources.STREAM_ATTACH_TIMEOUT_S |
3 |
no hello for this long: detached, send nothing |
model.LEARNING_PREFIX |
b"\x81\xa8learning" |
the first bytes of every status datagram (msgpack for a one-key map named learning) |
sources.STREAM_MAX_DATAGRAM |
65507 |
one datagram, UDP over IPv4 |
sources.LEARNING_REPEATS |
3 |
copies of one status per viewer, a heartbeat apart, because nothing is acknowledged |
So: bind host:port, read heartbeats, and when one arrives from an address that has not had
the standing status, send one msgpack map {"learning": {...}} to that address, holding any
subset of Learning's field names plus extra. Keeping the last status and re-sending it on
a fresh hello is the sender's job, and so is sending it more than once; without either, a
viewer attaching mid-run waits for the next iteration. tests/run_stream.py is the end to
end check. It publishes the scripted battle and a moving status from one process and draws
them in the real window (docs/viewer-learning.png was made with it).
The command line¶
python -m royaleviser [SOURCE [SOURCE]] [--stream HOST:PORT] [--learning HOST:PORT]
[--compare SOURCE] [--parity FILE] [--tolerance MILLITILES] plus the
view options below, which __main__.add_view_arguments adds to any parser. The first
source is the primary; a second positional, --compare or --stream is the compared one,
and more than two is an error.
| Option | Meaning |
|---|---|
--seat local\|0\|1 |
who sits at the bottom. local (the default) seats the primary source's local player once the source knows it (the side whose hand a recording holds; team 0 for a trace or a stream); 0 and 1 pin a team. Seat 1 draws the board rotated 180 degrees, which is what that player's own screen shows. |
--geometry WxH+X+Y |
the window's outer rectangle in physical pixels, for a caller that places the window itself. Without --scale it picks the largest tile scale whose layout fits; under 16 px/tile (COMPACT_BELOW) the inspector column is dropped and the compare lines move into the dashboard, and the window then fills the whole rectangle. On Windows the process is made per-monitor DPI aware first so the pixels are physical. |
--parity FILE |
a RoyaleSim parity trace written with --trace: opens BOTH sides of it, the recording with the engine's run of the same battle ghosted over the top, and compares within a quarter tile unless --tolerance says otherwise. It fills both source slots by itself, so it cannot be combined with another source. |
--tolerance MILLITILES |
count two units as agreeing while they are this far apart or less. 0, the default, is exact agreement; --parity defaults to 250. |
--learning HOST:PORT |
where a learner publishes its training status. Unset, it is the stream's port plus one, so attaching to a training run stays one flag; see The learning status. |
--scale N |
pixels per tile (24). |
--speed F |
replay speed (1.0); +/- step through SPEEDS = 0.25 ... 8. |
--start-tick T |
the first frame shown (replays). |
--seconds N |
quit by itself after N seconds (unattended runs). |
--shot PATH |
save the last drawn window as a PNG before quitting. |
--shot REFUSES to write a picture of a live source that never received a frame, prints why
on stderr and exits 2. Such a picture is a real screenshot of the real window showing an empty
board under full panels, and it reads as a photograph of a dead run. The likely cause is worth
knowing: a publisher keeps ONE peer, so a second viewer on a stream that is already being
watched gets no frames, while its learner panel fills normally from the other port. A status
with no frames is diagnostic and the message says so.
SDL_VIDEODRIVER=dummy makes --seconds and --shot work with no display at all, which is
how the README's images and the render tests are produced. Without it, a Linux machine with no
display opened no window and the process still exited 0, with nothing printed about the missing
window (Ubuntu 24.04, 2026-09-27). SDL_AUDIODRIVER=dummy is the other half: app.run calls
pygame.init(), which starts the mixer and so opens an audio device, and on a machine with no
sound device that prints ALSA errors. The viewer plays no sound. The render and CLI tests set
both with os.environ.setdefault, so a driver set by the caller wins. --help prints the key
list (app.KEYS, the same list the H footer shows):
| Key | Action |
|---|---|
| space | play / pause (replays) |
| left / right, wheel | step a frame (shift: 20) |
| home / end | first / last frame |
| + / - (also ] [) | speed x2 / x0.5, through SPEEDS |
| f | flip the seat |
| p, t, g | unit paths, target lines, tile grid |
| d | debug numbers |
| b | building footprints: every carried box shaded |
| n | contact neighbours of the pinned unit: a ring on it and on every unit whose collision circle overlaps or touches it, with a line to each. Recomputed from the frame's positions and radii (Renderer.contact_neighbours), never recorded, so a unit with no radius has none |
| v | divergence arrows against the compare source: an arrow from each unit to where the compare frame of the same tick has the unit with the same uid, for every unit whose position differs at all; only the pinned unit's while one is pinned. A unit whose row carries the engine's push (a parity trace) also gets a second arrow from it, the push applied on that tick |
| c | compare ghost |
| s / F12 | save a PNG |
| click | pin a unit / seek the timeline |
| h | the help footer |
| escape / q | quit |
The layout¶
Three columns, every rectangle from theme.layout(theme, scale, tiles) (royaleviser/theme.py);
the renderer computes no size of its own and everything is an integer.
| Column | Width at 24 px/tile | Contents |
|---|---|---|
| dashboard (left) | 345 px (dashboard_w: 4 cards of 80 px + 3 gaps of 5 = 335, flush with the window's left edge, plus a 10 px gutter before the arena) |
the top player's hand flush with the top edge (80 x 100 px card tiles, see Cards and the special forms), its elixir bar (thousandths) and the next card as a small tile, with the ability buttons (heroes' and a champion's) where the next card's name would be; a status block as tall as its content (source name, tick and clock, playing/live, the source's own status line, draw time and fps, then the last events_lines events, newest last); under it a learning panel filling the rest of the column (LEARNING_GROUPS down two columns: learner iteration, the two losses, entropy, KL, clip fraction, explained variance, grad norm and learning rate; rollout env steps/s, engine ticks/s, episode ticks, crowns and towers per episode, illegal actions and elixir wasted; ladder ELO, win rate, pool size and games against the frozen pool; then extra, whatever rows the learner named itself -- every value an em dash until a learner fills Transport.learning -- which a stream does from the status datagrams described in The learning status -- the heading naming the port it is listening on while nothing is there ("no learner on 127.0.0.1:9871" for a stream, "no learner attached" for a source that names no learner at all), and the rows that do not fit the column left out); the bottom player's elixir bar, next card and ability buttons, and its hand flush with the bottom edge. Crowns, tower hp and the cycle are not repeated here: the crowns and clock sit in the small box top right of the arena, the tower hp bars on the towers |
| arena (middle) | 18 x 24 = 432 px wide, 32 x 24 = 768 px tall | checkerboard grass, river band, bridges, each crown tower's zone outline, troops as circles and buildings and towers as squares, hp bars, names, paths, target lines, spells and projectiles; the crowns and clock in a small box top right, OVERTIME centred, the GAME OVER banner; the status line and the scrub bar underneath |
| inspector (right) | 300 px (inspector_w; 0 in the compact layout) |
the hovered or pinned unit's fields, raw and unrounded (position, hp, radius, target, the stun and deploy counters, the status bits in words (flags), then whatever else the source carries under "extra"), except each status effect's time left, which is shown in tenths of a second; then the compare lines and the H help footer |
The palette is carried over from the project's earlier Python renderer: grass
(188,195,55)/(217,215,47), river (106,230,237), bridge (255,175,120), team 0 blue
(71,204,218), team 1 red (224,73,41), UI (30,30,40). The bottom player is ViewState.seat;
the board surface is built once per seat and grid setting and blitted on every draw.
Capturing media¶
royaleviser.capture.capture writes what the window would show, with no window and no clock:
the README media of all four repos is regenerated from it when the engine changes.
battle.msgpack below is the trace the guide's
Saving a battle to a file program writes, so run that
first. It saves 200 steps, 2001 frames up to tick 2000, and the clip stops inside that.
from royaleviser.capture import capture
from royaleviser.sources import open_source
src = open_source("battle.msgpack")
capture(src, "still.png", ticks=(900, 901, 1), scale=24, crop="left")
capture(src, "clip.gif", ticks=(0, 1800, 8), scale=16, crop="left", fps=20)
- The suffix picks the format.
.pngwrites one file, orname-0000.pngupward for a range;.mp4and.gifare encoded by the ffmpeg binaryimageio-ffmpegships. ticksis(start, stop, step)in battle ticks, the clock the window shows, resolved through the source'sindex_at_tick, not frame indices. Without it the whole source is captured. Use a tick step rather than a lowfpsto shorten a long clip: stepping keeps the motion smooth where a low frame rate makes it stutter.- The output is deterministic, always. An image in a README that changes when nothing
changed is a diff nobody can review, so the two things that follow the wall clock (the
draw time and the frame rate) are zero in every capture, and the LIVE pill is never
drawn: a capture replays a file rather than watching an engine.
live_timing=Trueadds the SOURCE's own status line ("frame 412/2400 ..."), which a replay builds from what it read rather than from a clock, so that is reproducible too. A shot whose subject is the timing of a live stream is not a capture;tests/run_stream.py --shottakes that one. cropisfull,board(the arena alone),left(everything up to where the inspector starts, which keeps the dashboard and the timeline), or a rectangle. A smallscaledoes NOT drop the inspector by itself: the compact layout is chosen from a geometry byapp.fit_layout, so cropping is how a capture leaves that column out.viewandcompareare the window's own arguments: the seat, the overlays andhover_uidto pin a unit in the inspector, and a second source ghosted at the same tick.- A live stream is refused. It has no timeline to seek, so
captureraises rather than recording whatever happened to arrive;tests/run_stream.py --shotphotographs one.
Measured on the scripted battle (tests/fixtures/frames-synthetic-A.jsonl.gz: 418 frames,
ticks 0 to 394) at 76bd9a1 on 2026-09-28, with imageio-ffmpeg 0.6.0: cropped left at scale
16, every eighth tick (ticks=(0, 395, 8)), it is a 267,937-byte gif; the same range every
fourth tick is a 252,239-byte mp4; every frame (no ticks) is a 1,545,027-byte gif. ffmpeg is
the media extra (pip install "royaleviser[media]") and PNG needs nothing beyond this package,
so nobody has to install a video encoder to look at a frame.
What the tool cannot check for you. capture takes any Source, so it will happily
draw a recording of a real battle. Those are private, and they are not a source for published
media. Media that goes into a README comes from an engine trace or from tests/synthetic.py,
and that guarantee lives in whoever writes the shot list, not in this function. Two things
worth saying in a caption while you are there: the scripted battle is a script, whose units
move at six times their scripted speed in the recording form, so it is honest as "the window"
and dishonest as "how the engine plays".
The public surface other front ends use¶
royaleviser.__main__ exports the pieces a second front end needs: add_view_arguments
adds the view options to any argparse parser, run_sources builds and runs the window over
already-opened sources, and TITLE is the window title a caller can find the window by.
Those three names, capture.capture and its CROPS, the exported sources.capture_*
converters and the LIVE_* constants are the surface outside callers depend on; changing
them is a breaking change.
Performance¶
The process prints royaleviser: N draws, mean X ms, max Y ms on exit. The maximum is
always the first draw, which builds the board surface and the fonts.
| Run | Draws | Mean | Max |
|---|---|---|---|
a capture replay, --start-tick 1200 --speed 4 --seconds 6 |
319 | 4.34 ms | 199.5 ms |
the same, --seconds 10 |
564 | 2.37 ms | 152.4 ms |
the same with --compare on the other seat, --speed 8 --seconds 28 (the whole battle) |
1685 | 2.25 ms | - |
a MockEngine trace, 60 steps / 601 frames, --seconds 5 |
101 | 2.49 ms | 6.6 ms |
--stream from a MockEngine env stepping at ~43 steps/s for 7 s |
262 | 4.19 ms | 185.8 ms |
a RustEngine trace, 2001 frames, --start-tick 800 --speed 4 --seconds 6 |
336 | 5.17 ms | 75.9 ms |
--stream from a RustEngine env stepping at ~30 steps/s for 12 s |
335 | 5.06 ms | 654.4 ms |
Measured on Windows 10 with the SDL dummy driver on a machine with other work running (the
last two rows, 2026-09-21, with several other jobs on the box). The MockEngine
stream run sent 256 datagrams over 300 env steps with 0 dropped; the first ~44 steps ran
before the once-a-second heartbeat poll noticed the viewer. The RustEngine stream run
stepped 360 env steps in 11.9 s and sent 329 datagrams with 0 dropped; the viewer drew 335
frames of it, and the ~31 steps that ran before the heartbeat poll noticed the viewer are
the difference. A capture opens in 0.1-0.2 s (a 4047-frame gzipped file in 0.10 s: a line
index plus a parse cache, so a seek is one json.loads).
The window is redrawn only when the frame or the view changed, capped at FPS_CAP = 60. A
replay is paced by the frame's tick_ms times the speed (App.advance): it steps through
every frame it is due, one at a time, so the event log and the compare totals see every
frame even at 8x. A live source redraws its status line every LIVE_REFRESH_S = 0.5 s
without a new frame, so the fps and "drops" counters stay current.
The scripted battle (tests/run_synthetic.py --seconds 8, a real window, 2026-09-21): 158
draws, 3.53 ms mean, 219.84 ms max.
At 2-4 ms per draw the viewer is far below both the 20 Hz replay budget and the 60 Hz window cap, which is why it is still Python and pygame rather than a Rust process on a shared buffer.
CI runs on two operating systems, on purpose¶
.github/workflows/suite.yml runs the suite on windows-latest, ubuntu-latest and
macos-latest, with the guide's own install commands verbatim on each, and checks that the
installed royaleviser command starts. That is not thoroughness for its own sake.
.. normalises LEXICALLY on Windows and is walked COMPONENT BY COMPONENT on POSIX, so a path
built with .. and then stat-ed answers a different question per platform. The same goes for
code that compares paths as strings or assumes a separator. A single-OS matrix cannot see that
class at all, and on 2026-09-23 a sibling repo's test passed on the Windows development machine
and failed on a clean Ubuntu runner for exactly that reason -- found the first time anyone looked.
This package was audited for the same class on 2026-09-23: every parent-directory path is built
with Path(...).resolve().parents[N], which resolves before it walks, and nothing compares
paths as strings or hard-codes a separator. Passing on both runners is evidence about the paths
the suite EXERCISES; it is not evidence about the ones it does not.
The ubuntu and macOS legs are also the only machines in this project that run the POSIX install
the guide documents. There is no Linux or macOS machine here, which is why VISER-capture-posix
was deferred rather than tested.
Limitations¶
- Both engines have been drawn (2026-09-21): a 2001-frame
RustEnginetrace passesmodel.problemson every frame, and aRustEngineenv streaming live sent 329 datagrams over 360 env steps with 0 dropped, when a stream still sent one frame per step. Trace and stream go through the sameframe_from_state, so the two draw identically. - A trace has per-tick frames only if it was recorded with
frame_every_tick. One recorded per step never shows a shot shorter than a step, and a tower in it can look as if it never fires. The stream sends every tick while a viewer is attached. - Units in a capture carry no radius and no flying flag, so every one of them is drawn at the renderer's default radius and air units look like ground units. Deploying is a state there (visible for one tick), not a countdown.
- Engine units carry no path in a
BattleState, sopdraws nothing for traces and streams. They carry their target since RoyaleSim 32b3743 (2026-09-24), sotdraws a line to it in a battle recorded or streamed since then, and nothing in an older one. - Spells in a capture are limited to projectiles and the few spells that leave an effect carrying their card id (Fireball, Arrows, Rocket, Log, Barbarian Barrel); most leave none. A spell card among them is drawn by its family at its card's radius; a shot arrives as a spell named "... shot" and is never drawn as an area. A capture carries no rage, poison or freeze zones.
- An opponent's hand and deck are not in a capture, and a trace's cycle beyond the revealed cards shows as "next ?".