royaleviser¶
The viewer. It draws a battle in its own window, and it can also write frames to a file with no window at all.
Everything on this page is generated from the docstrings in the code.
For what the viewer is for and how to open one, read The viewer.
Opening a window¶
royaleviser.app
¶
The window, the loop, the input and the timeline around render.Renderer.
from royaleviser.app import run
run(source) # one source, the layout's window
run([capture, other], ViewState(seat=1), geometry=(0, 0, 604, 0), seconds=8, shot="s.png")
run takes Source objects (model.Source: sources.py builds them from the command
line, tests use a list-backed one) -- the first is the main source, an optional second is
the compare source ghosted onto the board at the main frame's tick. Replays are paced by
Frame.tick_ms times the playback speed; live sources are polled every iteration and
drawn when a new frame arrives. The window is redrawn only when the frame or the view
changed, at most 60 times a second; otherwise the loop sleeps. On exit it prints the draw
statistics ("royaleviser: N draws, mean X ms, max Y ms") and closes the sources.
KEYS is the one list (the help footer, python -m royaleviser --help and the README
quote it). --seconds N closes the window by itself and --shot PATH saves the last drawn
window as PNG for unattended runs; with SDL_VIDEODRIVER=dummy no window opens at all.
Windows: the process is made per-monitor DPI aware before pygame starts so --geometry is physical pixels (what a caller placing the window measures), and the position is handed to SDL through SDL_VIDEO_WINDOW_POS. The window's SIZE is always the layout's (dashboard + arena + inspector); a geometry's WxH only picks the largest tile scale that fits when no --scale was given.
App
¶
One window over a main source and an optional compare source.
pull()
¶
The main source's current frame and the compare source's at the same tick.
A source that hears from a learner (StreamSource) also carries a learning
status; it is read with getattr so every other source simply has none, and the
panel keeps saying no learner is attached. A new status is a new object, so the
window redraws when one arrives even though the board has not moved.
pull_compare(f, changed)
¶
A replay compare source is seeked to the main frame's tick; a live one is polled every loop, so its frames feed the agreement even while the main frame stands.
advance(dt_ms)
¶
Replay pacing: one frame per tick_ms / speed of wall time, catching up in steps.
run(sources, view=None, *, geometry=None, seconds=None, shot=None, scale=None, speed=1.0, start_tick=None, title='RoyaleViser', theme=DEFAULT, follow_local=False, tolerance=0, pair_by_uid=False)
¶
Open the window over sources and run until quit, --seconds or a closed window.
geometry is (w, h, x, y) as __main__.parse_geometry returns it: the window's
OUTER rectangle in physical pixels (what a caller placing the window measures); x, y
place the window and w, h pick the largest tile scale whose layout fits when scale
is None (fit_layout); the window then fills w x h, the layout at its top left and
the UI background elsewhere, so it covers its slot. shot is written at exit (and
S / F12 write one during the run).
follow_local seats the main source's local side at the bottom as soon as the source
knows it (--seat local; a live source learns it when a battle starts). Returns the
exit code.
Saving pictures with no window¶
capture is how many of the pictures in this project's READMEs are made. It needs no display and
no clock, and the same source and arguments give you the same bytes every time.
royaleviser.capture
¶
What the window would show, written to a file with no window: stills, sequences, mp4, gif.
from royaleviser.capture import capture
from royaleviser.sources import open_source
src = open_source("battle.msgpack")
capture(src, "still.png", ticks=(900, 901, 1), crop="left")
capture(src, "clip.gif", ticks=(0, 2400, 4), scale=16, crop="left", fps=20)
WHY THIS IS NOT app.run
run opens a window and paces a replay against the wall clock: it draws what it has
time to draw. A capture has no clock. It seeks the source to each tick in turn, draws
that frame, and hands the pixels straight to a file, so a busy machine changes how long
the capture takes and nothing about what comes out. Nothing here opens a display -- the
renderer draws to its own off-screen surface, and no pygame.init() is needed.
WHAT IS FROZEN, AND WHY
The status block prints the draw time and the frames per second, which differ every run.
An image in a README that changes when nothing changed is a diff nobody can review, so a
capture always zeroes both, and never draws the LIVE pill: a capture is a replay of a
file, not a window onto a running engine. Every capture is reproducible, including with
live_timing=True -- which restores only the SOURCE's own status line ("frame 412/2400
...", built from the file rather than from a clock). A shot whose subject is the timing
of a live stream is not a capture at all; tests/run_stream.py --shot takes that one.
WHAT IT COSTS TO CARRY
PNG needs nothing beyond this package. mp4 and gif are encoded by ffmpeg, which arrives
with imageio-ffmpeg -- the media extra (pip install "royaleviser[media]").
Without it, capture to those formats raises and says so; PNG keeps working, so
nobody has to install a video encoder to look at a frame.
A capture reads a replay: a capture file, a trace, or any Source with a timeline. A live
stream has no timeline to seek, so it raises rather than quietly recording whatever happened
to arrive; tests/run_stream.py --shot is the way to photograph a live stream.
crop_rect(layout, crop)
¶
The rectangle a crop name means for this layout, or the rectangle it already is.
full the whole window, board the arena alone, left everything up to where the
inspector column starts -- which keeps the dashboard and the timeline and drops the
inspector. NOTE that a small scale does not drop the inspector by itself: the compact
layout is chosen from a geometry by app.fit_layout, so cropping is how a capture
leaves the column out.
tick_indices(source, ticks)
¶
Source indices for (start, stop, step) battle ticks, or every frame it holds.
A tick the source does not have lands on the frame it does have -- a stream carries one frame per environment step, so a tick range over one asks for frames that repeat, which is what a clock-accurate clip of it looks like.
draw_frames(source, indices, *, scale=24, view=None, crop='full', compare=None, theme=DEFAULT, live_timing=False)
¶
Draw each index in turn and yield (surface, index), cropped.
The surface is reused between frames: copy it if you keep it. This is the loop every output format shares, and the only place that touches the renderer.
capture(source, out, *, ticks=None, scale=24, view=None, crop='full', fps=DEFAULT_FPS, compare=None, theme=DEFAULT, live_timing=False)
¶
Write source to out; the suffix picks the format. Returns the files written.
ticks is (start, stop, step) in BATTLE ticks (the clock the window shows), not
frame indices; without it the whole source is captured. A .png writes one file per
frame, numbered name-0000.png when the range holds more than one; .mp4 and
.gif encode the range at fps frames a second.
crop is one of CROPS or a rectangle, compare ghosts a second source at the same
tick, and view carries everything else the window can be told to show -- the seat,
the overlays, and hover_uid to pin a unit in the inspector.
The same source and arguments give the same bytes, always: nothing on screen moves with
the wall clock. live_timing=True adds the source's own status line, which is built
from the file and is reproducible too.
Where frames come from¶
A recording, a trace saved from the engine, or a live stream from an environment stepping in another process.
royaleviser.sources
¶
The three sources, each turning its own format into model.Frame.
CaptureSource a RoyaleLive capture, frames-*.jsonl(.gz): one JSON object per line, a
battle frame of the live client at 20 Hz (the format is described on
the class; RoyaleLive's tools write it)
TraceSource a royalegym.replay trace (.msgpack / .json) recorded from an engine
StreamSource a running engine publishing frames over UDP (``Publisher`` is the other
end; royalegym.viser.ViserPublisher is the same protocol inside the env),
and, on a second port, a learner's training status (``LearningPublisher``)
The RoyaleLive instrument drives this viewer the same way any other caller does: it imports
this package and calls app.run.
Every source fills Frame.units_per_tile with its own raw unit (1000 for the live client,
the trace header's subtile for the engine), keeps positions in the NATIVE / ENGINE frame
(team 0's back edge at y=0) and says what it does not know through the *_known flags.
src = open_source("frames-demo-20260920-120752.jsonl.gz")
src.seek(src.index_at_tick(1000)); frame = src.frame()
Nothing below imports pygame. royalegym is imported only where the engine's formats are read (TraceSource, the arena helpers, Publisher / frame_from_state), so CaptureSource and StreamSource work from the model alone (pyproject.toml).
CaptureEvents
¶
The event log of a capture stream: feed every active frame in order, read lines.
Besides the spawn / death / play lines of _EventLog it adds one line when a unit is
first seen tunnelling (capture_surfacing):
"t2974 Blue GoblinDrill -> (3250,23250) ~73 ticks", the goal in native units because
that is where the building will stand.
CaptureSource
¶
Frames of a RoyaleLive capture, by index, with the events the stream implies.
THE FORMAT. One JSON object per line. A battle frame is {"event": "frame",
"active": bool, "seq", "tick", "players": [...], "entities": [...], "effects": [...]}
plus a few timing and bookkeeping fields (kept in meta); the other events
(start/stop lines) carry no battle state. A
player row: 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), cycle
(deck indices, the next card first). An entity row: id (an opaque string; it may be
reused within a battle), card_id (-1 for a tower), side, x/y
in native millitiles, hp/max_hp, behavior_state, target (another
entity's id), movement_direction_x/_y, path_nodes (half-tile cells,
goal first) and further fields kept in Unit.extra. An effect row is a projectile
or a spell: side, card_id (the shooter's, -1 a tower, or the spell's), x/
y this tick, x2/y2 the previous tick, projectile_x/_y the aim.
Reads the whole file up front (a 184 s friendly at 20 Hz is ~3700 lines; the demo files
are 12-30 MB, gzip 0.7 MB) and keeps the RAW LINES of the "frame" events with active
true, indexed by tick; the others (start/stop lines, inactive frames before the
battle) are counted for status(). A frame is parsed when it is looked at (a bounded
cache of PARSE_CACHE parsed frames), so opening a 40 MB capture costs ~0.2 s and a seek
one json.loads. Events are derived forward from consecutive frames (spawns, deaths, the
local player's plays) the first time the timeline passes a frame and cached per index,
so seeking backwards shows the events up to that frame.
Conversions, all measured on 2026-09-20 captures (capture_* above):
elixir players[i].elixir_raw // 10 -> elixir_milli, elixir_known True for BOTH sides
hand deck indices -> names through players[i].deck when the deck is populated;
hand_known False (hand [-1]*4, deck []) for the opponent until the results
screen, where both players' deck and hand fill in
cycle players[i].cycle indices -> names, same rule (next_card = the first)
units every entity; uid = "id:card:side" (capture_uid); kind from card_id
and the tower positions; path_nodes decoded and REVERSED to start-first;
target the target entity's uid; direction (movement_direction_x, _y);
state behavior_state; extra = the raw entity minus path_nodes
spells every effect (projectiles and spells alike), see capture_spell
towers tower_hp [king, left, right] in the owning team's frame (LIVE_TOWER_X), 0 for a
destroyed tower (it leaves the entity list); crowns from the OTHER side's
missing towers (a princess 1, the king 3)
overtime tick > 3690; game_over: the trailing run of one frozen tick (>= 1 s)
meta {"source": "capture", "path", "seq", the frame's timing and bookkeeping
fields, "local_side"}
local_side (the side whose hand alone the frames hold, capture_local_side) is
what --seat local seats at the bottom; None when no frame in the first 10 s shows one.
TraceSource
¶
Frames of a royalegym.replay trace, by index; the arena drawn from the trace header.
load_trace decodes .msgpack or .json. Per frame: entities -> units through
royalegym.viser.unit_dict (the rows a running engine publishes, so a trace and a stream
of the same battle draw identically: uid the engine's, towers named by kind, path [] --
the engine does not record paths -- and target, facing, attack phase, effects and shield
as the engine exported them, None or empty in a trace recorded before it did), spells ->
Spell the same way, shots -> Projectile,
elixir_milli / crowns / hands from the frame, deck from the header's setup, next_card and
the cycle from the hand history (the cycle rule: the front of the 8-card queue enters
the hand and the played card goes to the back; with ShuffleMode.NONE the queue is
deck[4:] from the start, otherwise a position is "?" until a play reveals it), tower_hp
from the tower entities by tower_slot. king_active is not recorded (None).
overtime is tick >= the calibration's regulation ticks, game_over / winner
the last frame with trace.result. Events: spawns and deaths by uid diffing, plays
from the step log (commands with status 0, with their position). units_per_tile is
header.subtile (18000). arena is a royalegym.protocol.Arena assembled from the
header (grid, bridges, water rows, tiles, subtile; tower centres from the default
arena, which the header does not carry).
close()
¶
Nothing to release: the trace was loaded whole.
StreamSource
¶
Frames a running engine publishes over UDP (see Publisher), newest only.
Binds an ephemeral UDP socket, sends STREAM_HELLO to (host, port) once a second (from
frame(), which the app calls every loop; heartbeat() forces one), and decodes
every datagram that arrives with model.decode_frame. frame() drains the socket
and returns the newest decoded frame, or the last one when nothing new arrived, or
None before the first; index counts the frame datagrams received; status()
reports frames/s and the datagrams dropped (sequence gaps in meta["seq"]).
units_per_tile is taken from the first frame (0 before). close() stops the
heartbeat and closes the socket; the publisher notices within STREAM_ATTACH_TIMEOUT_S
and goes quiet.
learning_peer is a second address the same socket says hello to: a learner's
LearningPublisher, which sends a status datagram once per iteration instead of a
frame per engine tick. The last one received is learning, which the app hands the panel;
it stays None while nothing sends one, and a status keeps standing until the next one
replaces it -- including while the environment is between rollouts and no frame moves.
open_source and the command line pair it with learning_endpoint(host, port);
None means the viewer never asks for a status. A datagram that decodes as neither
(anything at all can reach a UDP port) is counted in rejected and named in
status() rather than raised.
heartbeat()
¶
One hello to the publisher and one to the learner, if a learner was named. Both ends learn where to send from it, so a hello is also how a viewer attaches.
take_frame(data, read=1)
¶
Decode one frame datagram into _last, counting the datagrams LOST before it.
read is how many frame datagrams this drain took off the socket, of which this is
the newest and the only one decoded. Lost is the gap in seq MINUS the ones that
arrived and were skipped, because those two are not the same thing and only the first
is a fault.
This used to count the whole seq gap. It read 0 while the environment published once per decision, and became wrong the moment gym published once per engine tick (RoyaleGym f8a3c0d): 10 datagrams per step, 9 of them superseded before the next draw, so a healthy stream reported 9 drops a step -- about 90 % loss -- on a link that had lost nothing. Measured here before and after. A counter that reports normal operation as failure is worse than no counter, because it is the one a person checks when the window looks wrong.
take_learning(data)
¶
Replace the learning status with the one in data. A whole status replaces a
whole status: a field the learner left out is unset, never the last value it had.
seek(index)
¶
Ignored: a stream has no timeline.
step(delta)
¶
Ignored: a stream has no timeline.
quiet_seconds()
¶
How long the board has been standing still, or None while it is keeping up.
None below STREAM_QUIET_S rather than a small number, so the policy for what
counts as quiet lives here beside quiet_for instead of being decided again by
whoever draws it. The owner's report on 2026-09-22 was that the viewer looked broken
during a training run; the status line HAD been saying "last 12s ago" the whole time,
in small text, which nobody reads while watching a battle.
quiet_for()
¶
" last N ago" when no frame has arrived for a while, else "".
A publisher keeps ONE peer, the address of the last heartbeat it read, so a second viewer saying hello to a run takes the stream: the first viewer's board simply stops (measured 2026-09-22, two sources on one publisher: the second got 60 frames of 60). Nothing tells it so, and "0.0 fps" does not, because a board that stands still is the NORMAL case on a training run -- the environment publishes for about forty seconds and then the learner thinks for eight to thirteen minutes.
What separates the two is how long it has been. A quiet stream inside an iteration is minutes old and expected; one that has been quiet since about when someone else opened a window is the stolen case. The viewer cannot tell them apart by itself, and saying how old the board is lets a person do it in one look instead of watching for a while.
Publisher
¶
The engine side of StreamSource for a script that has a Frame: sends ONLY while
a viewer is attached.
The rule the viewer is built on: a separate process, never in the tick loop,
zero cost when nobody is watching. It is royalegym.viser.ViserPublisher (the env's
publisher, which takes a BattleState) under one socket: publish(frame) costs one
clock read while detached, and while attached encodes the frame with msgspec and sends
one datagram to the last heartbeat's address (over STREAM_MAX_DATAGRAM: resent without
unit paths, then dropped). frame_from_state builds a Frame from a BattleState.
LearningPublisher
¶
The learner's end of the dashboard's learning panel: one small datagram per iteration.
WHY IT IS NOT THE ENVIRONMENT'S PUBLISHER The numbers are the learner's, not the environment's. They are ready once per iteration, not once per step, so putting them on a Frame would repeat them on every datagram and, worse, stop them the moment the environment stops -- a learner's numbers are most interesting exactly while it is optimising and nothing is moving on the board. Carrying them separately also keeps the frame publisher untouched: it still costs one clock read while nobody watches, whatever a learner is doing.
THE SAME RULES AS THE FRAME PUBLISHER
Binds host:port (the frames' port plus one by default), waits for the viewer's
STREAM_HELLO, and sends nothing until one arrives. publish(status) keeps the
status and sends it if a viewer is attached; while detached it is one clock read.
A VIEWER THAT ATTACHES MID-RUN
A status can be an hour old and still be the truth, so the last one is kept and sent
again as soon as a viewer says hello -- the panel fills within a heartbeat instead of
waiting for the next iteration. A background thread wakes once a second to look for
that hello, because a learner deep in an optimisation step calls nothing for minutes;
pump_thread=False leaves that to the caller's own pump().
UDP ACKNOWLEDGES NOTHING A frame lost on the way is replaced a few milliseconds later; a status lost on the way is the panel standing still for a whole iteration, and a rollout can fill the viewer's receive queue with frames just as the one status of that minute arrives. So a viewer is sent the standing status LEARNING_REPEATS times, a heartbeat apart, and then the sender falls silent until the next status or the next viewer. NOTE that the budget follows the ONE peer this sender keeps, the address of the last hello: two viewers heart-beating at once take it in turns, so it keeps sending and each of them sees some of the statuses. One viewer per learner is the shape this protocol has.
One message is the whole status (model.Learning): the viewer replaces rather than
merges, so a field the learner stops sending goes back to an em dash rather than standing
as a stale number. Learning.extra carries whatever the fixed rows cannot hold; a
status too big for one datagram is counted in dropped rather than truncated.
status
property
¶
The last status published, which is what a viewer attaching now would be sent.
attached
property
¶
Whether a viewer said hello within STREAM_ATTACH_TIMEOUT_S. Costs one clock read; the socket itself is looked at once a second.
publish(status)
¶
Keep status as the standing one and send it if a viewer is attached. Returns
whether a datagram went out.
pump()
¶
Look at the socket now and send the standing status to a viewer that is still owed it. Returns whether a datagram went out; the thread calls it once a second.
learning_endpoint(host, port)
¶
Where a learner's status publisher is by default for a stream at host:port: the
same host, one port up. open_source and the command line pair them this way. None
when there is no port above this one, which is a stream on 65535 and nothing else.
udp_socket(host, port)
¶
A bound non-blocking UDP socket with room to queue a burst.
The default receive buffer is 64 KB on Windows, some two dozen frames of a busy battle: a rollout publishing faster than the viewer's loop drains overruns it, and what the kernel throws away is whatever arrived last -- which can be the one status datagram a learner sends that minute. A megabyte is a few hundred frames of slack and costs nothing while the queue is empty.
open_source(spec, names=None, learning=None)
¶
A source for one command-line argument, by shape.
*.jsonl / *.jsonl.gz -> CaptureSource; *.msgpack / *.json -> TraceSource;
host:port -> StreamSource, listening for a learner at learning or, unset, at
learning_endpoint of the same pair. Anything else raises ValueError naming the spec.
tiles_text(v, units_per_tile)
¶
Integer units -> tiles rounded to a tenth, without a float (royalegym.viser._tiles).
play_line(tick, team, name, at, upt)
¶
One play line, "t120 Blue plays Knight (3.5, 14.5)"; no position when the source has none.
capture_uid(e)
¶
An entity's identity as a string: the entity id (an opaque string that may be
reused for a later entity within a battle) with the card and the side.
capture_node_xy(n)
¶
A path node -> its half-tile cell centre in native units (col = n % 36, row = n // 36).
capture_surfacing(e)
¶
(x, y, eta ticks) where a tunnelling entity comes out: the centre of its goal cell (the first path node) and the remaining path length over its tunnel speed, floored. Measured 2026-09-20 on client 16.402 (RoyaleLive traces): the drill of tick 2974 at (9178,3569) predicts (3250,23250) in 73 ticks and the building appeared at (3000,23000) at tick 3047. None for anything else.
capture_local_side(f)
¶
The one side whose hand this frame knows, None when neither or both are known. A source that knows only one player's hand leaves the other's slots at -1.
capture_spell(ef, names)
¶
An effect row -> Spell. Measured on the 12:07 and 13:54 captures: x/y is the
projectile's centre this tick, x2/y2 the previous tick's, projectile_x/_y
the aim (the tracked target's position for a shot, the placement for a Fireball);
card_id is the SHOOTER's card (-1 a tower) or the spell card (28xxxxxx).
capture_frame(f, names, events, game_over, meta)
¶
One capture frame dict (active) -> Frame; events and game_over come from the caller.
resolve_capture(path)
¶
The capture as it exists: the path, else its .gz twin (RoyaleLive gzips its captures
in place, so a .jsonl name still resolves), else the path itself.
regular_ticks(tick_ms)
¶
Regulation length in ticks from calibration.json (the trace header does not carry BattleState.regular_ticks; MockEngine derives it the same way).
frame_from_state(state, names, units_per_tile, events=(), meta=None)
¶
A Frame from a royalegym.protocol.BattleState, the rows royalegym.viser publishes.
Entities -> units (kind, tower_slot, timers as they are; path [], radius the engine's;
target, facing, attack phase, status effects and shield as the engine exports them, None or
empty from an engine that exports none), spells -> Spell, projectiles -> Projectile,
players -> Player with every flag True (the engine knows
everything), hand/next_card names through names, the cycle beyond next_card empty
and the deck unknown (a BattleState does not expose them), winner/overtime/
game_over as reported.
special_fields(p, name_of)
¶
A PlayerState's special-form rows in the viewer's shape (model.Player), names for ids.
The engine's layout (RoyaleSim state_json): evo rows [card_id, plays, next_evolved];
abilities rows [available, spent, cost], one per button (heroes in deck order, then the
champion), to which the champion build appends [card_id, cooldown_ticks]. Read BY INDEX,
so a row of three and a row of five both convert: the card's name from its card_id where the
row has one (else "", which a publisher that knows the deck's forms fills), and the cooldown
into ability_cooldowns (-1 where a row has none; the key only when some row has one). A
PlayerState without them -- every engine and royalegym before the special forms -- gives {},
which the model reads as "not said".
arena_from_header(header)
¶
royalegym.protocol.Arena from a royalegym.replay.TraceHeader (tower centres from the default arena, which the header does not carry).
default_arena()
¶
royalegym.protocol.Arena.load(default_calibration()): the geometry for live sources.
The frame model¶
One Frame is everything the viewer knows about one tick. Any other front end can draw from it.
royaleviser.model
¶
The one frame model every source produces and the renderer draws.
WHY ONE MODEL
Three things can feed the viewer -- a RoyaleLive capture (frames-*.jsonl), an engine
trace (royalegym.replay) and a running engine (UDP stream) -- and they disagree on
units, names and what is knowable. The sources translate into Frame; the renderer,
the timeline and the inspector read only Frame. Nothing downstream knows which
source it is looking at.
UNITS AND FRAMES
Positions stay in the source's RAW integer units; Frame.units_per_tile says how
many make one tile (1000 for the live client, 18000 for the engine, both measured:
client 16.402 on 2026-09-18 (RoyaleLive), RoyaleSim/data/calibration.json
representation.SUBTILE_PER_TILE). The renderer divides once when it projects. All
positions are in the NATIVE / ENGINE frame: team 0's back edge at y=0, x to the right,
y away from team 0. Whichever team the viewer seats at the bottom is a view choice
(render.ViewState.seat), not a data transform.
WHAT IS AND IS NOT KNOWN
The *_known flags are the honest version of a field, not a default. A capture knows
both players' elixir and only the recording player's hand and deck; a trace knows
everything. A source that cannot know something says so with the flag and fills the
field with "?" / [] / -1, and the renderer prints the reason instead of a wrong number.
Wire form: encode_frame / decode_frame are msgspec msgpack of these dataclasses,
so the engine-side publisher and sources.StreamSource share one codec. Learning --
what a learner reports, once per iteration rather than once per step -- rides the same
socket as its own kind of datagram (encode_learning / is_learning).
Projectile
dataclass
¶
A shot in flight that is NOT a spell card: a crown tower's bolt, a Musketeer's bullet,
an Archer's arrow, a Wizard's fireball. Spell CARDS -- Fireball, Arrows, Rocket, the Log --
are Spells, because the engine models them as spells; this is everything a unit or a
tower fires. The engine keeps them in their own list, and until 2026-09-24 none of it
reached the viewer.
Learning
dataclass
¶
Training status from a learner, for the dashboard panel under the match log.
The fields are the ones a PPO run against a frozen-pool ladder reports: the learner's own losses, the rollout's throughput and what it scored, and the standing against the pool. Every number is None until a learner supplies it, and the panel shows an em dash in its place, so the field list a reader sees is the same whether or not anything is attached.
It travels on its own datagram (encode_learning), not on a Frame: the numbers are the
learner's, they change once per iteration rather than once per step, and they keep
arriving while the environment is between rollouts and no frame is moving. One message is
the WHOLE status the learner currently knows -- the viewer replaces what it holds rather
than merging, so the panel never shows a number the learner never asserted at one moment.
A field the learner does not send stays None and stays an em dash, and so does a field
whose name it misspells: an unknown key is ignored rather than guessed at.
Source
¶
Bases: Protocol
Where frames come from. A replay exposes frames by index; a live source the latest.
name what the status line calls it ("demo-1", "trace seed 7", "127.0.0.1:9870")
live True: frame() is the newest state and length is None; the
timeline is disabled and seek/step are no-ops
units_per_tile the source's raw units per tile (every Frame it returns repeats it)
length number of frames for a replay, None for live
index the current frame index (replays); the frames received so far (live)
frame() the current frame, or None before the first one arrives
seek(i) replays: jump to frame i (clamped); live: ignored
step(delta) replays: move by delta frames (clamped); live: ignored
status() one line for the status bar (frame counter, sample latency, drops)
close() release the file / the socket / whatever the source holds open
A source may also carry learning: the last Learning it heard from a learner, or
None. It is read with getattr and is deliberately NOT a member here -- this Protocol
is runtime_checkable and app.run does an isinstance on it, so a member would
turn every source that has no learner into something that is not a Source.
CardFace
dataclass
¶
What the renderer draws a card tile from. None in a field: the source does not say.
Names
¶
card id -> (name, elixir cost) for one source, plus what each card is (face_of).
Names.live() reads the live client's card table (path, else the ROYALEVISER_CARDS
environment variable, else the package's cards.json) plus LIVE_FORMS.
Names.from_cards(cards) takes any sequence with card_id, name and elixir
attributes -- a trace header's cards or engine.cards() -- and reads card_kind,
count and flying where they are there. Unknown ids print as #<id> so a hole in
a table is visible, never silent.
face_of(name)
¶
What a card is, by name; None for a name the table does not have.
id_of(name)
¶
The card id a name belongs to, or None when the table does not have the name.
The lowest id wins, so a card whose other form shares its name (LIVE_FORMS) answers with the register card rather than with whichever form was inserted first.
status_bits(unit)
¶
The unit's status bits, or None when the source did not report them.
THE ONLY SAFE WAY TO READ THEM (royalegym.protocol.status_of says the same). The engine's
"not reported" is -1, and -1 & STATUS_UNDERGROUND is 1: a raw mask would draw every
unit of an engine that said nothing as under ground, invisible, evolved and a hero at once.
hand_evolved(p)
¶
Per hand slot: 1 its next play is evolved, 0 the card has an evolution not yet due,
-1 it has none (or the source did not say). Read from p.evo by card name.
live_card_kind(card_id)
¶
A live card id's class (LIVE_ID_CLASSES), None for an id outside them.
engine_card_kind(card)
¶
An engine CardInfo's kind: its card_kind column, None from an engine without it.
Not from placement: that says where a card may be played, not what it is (a Heal
places like a troop), and a guess drawn as a fact is worse than a plain tile.
encode_frame(frame)
¶
msgpack bytes of a Frame: what an engine-side publisher sends per tick.
scalar_of(obj)
¶
A value msgpack has no type of its own for, as a number it does.
A learner computes in whatever its framework returns, and a one-element array or a numpy
float is not a float to msgpack. Anything with item() is the value it holds; anything
else is the sender's mistake and says so rather than going out as something wrong.
encode_learning(status)
¶
msgpack bytes of a Learning: what a learner sends once per iteration.
decode_learning(data)
¶
The Learning in a status datagram. A key the sender left out keeps its None, which is what the panel draws as an em dash -- an absent field is never a zero.
is_learning(data)
¶
Whether a datagram is a learning status rather than a frame (see LEARNING_PREFIX).
problems(frame)
¶
Every way frame breaks the contract, one line each; [] when it is sound.
For source builders and tests: shape checks only (two players, hand and tower lengths, kinds, teams, one unit per uid), not game rules.
Drawing¶
royaleviser.render
¶
Drawing one model.Frame onto a pygame surface with the old renderer's layout.
dashboard (theme.dashboard_w) | arena (tiles * scale) | inspector (theme.inspector_w)
top player's hand + elixir | checkerboard grass, | hovered / selected unit:
status + the events list | river band, bridges, | every field and Unit.extra
bottom player's elixir + hand | tower zones, units, | compare panel, help footer
| spells, paths, timer |
| status line, timeline |
The bottom player is ViewState.seat: the board is drawn rotated 180 degrees when the
seat is team 1 (what that client shows; measured 2026-09-18, RoyaleLive). Positions are the
frame's raw units divided by Frame.units_per_tile once, in to_px. Colours are per
TEAM (team 0 blue, team 1 red) whatever the seat, like the old renderer. The renderer holds
no game data: a Theme, a Layout, the board geometry (Board) and its caches.
r = Renderer(scale=24) # off-screen surface of layout.window
r.surface = pygame.display.set_mode(r.layout.window) # or keep the off-screen one
r.set_arena(arena, arena.subtile) # a royalegym.protocol.Arena (optional)
r.draw(frame, view, transport) # everything, every call
r.save("shot.png")
PERFORMANCE (measured 2026-09-20 on a laptop, SDL dummy driver, scale 24): the static board is rendered once per (seat, grid) into a Surface and blitted; text surfaces are cached by (text, font, colour, shadow) in a bounded OrderedDict; translucent discs and the card veils are cached by size; target lines are at most 16 dashes each. A 100-troop frame with paths, targets and labels on: 5.1 ms mean on a quiet machine, 3.8-5.8 ms best-of-60 on a busy machine (mean then 9-11 ms from contention); the synthetic battle's frames 2.7 ms mean; a real capture frame ~5 ms (tests/test_render.py prints the numbers). Nothing here decides WHEN to draw: app.py redraws only when the frame or the view changed.
Layout note: theme.Layout.debug (between the two hand blocks) holds the status block
and the events list; Layout.hover is the inspector; Layout.events (the inspector's
lower half) holds the compare panel and the help footer.
ViewState
dataclass
¶
What the viewer is looking at, apart from the frame. Owned by the app, read by draw().
Transport
dataclass
¶
Playback and source facts for the status line and the timeline; owned by the app.
Board
dataclass
¶
The static board in HALF-CELLS (2 per tile, arena.json half_tiles_per_tile).
water and bridge are half-cell sets; no_deploy too (their zones are outlined);
king_zones / princess_zones are (hx0, hy0, hx1, hy1) half-cell rects, end
exclusive. Native orientation: team 0's back edge is hy 0.
from_arena(arena, arena_units_per_tile)
classmethod
¶
From a royalegym.protocol.Arena (grid bits WATER 32 / NO_DEPLOY 16, centres).
builtin()
classmethod
¶
The 2026-09 arena.json numbers, for when royalegym is not importable.
18x32 tiles, water half-rows 30..33, bridges at half-cols 5..8 and 27..30, kings 3x3 at (9, 3) / (9, 29) tiles, princesses 2x2 at x 3.5 | 14.5, y 6.5 | 25.5.
THE NO-DEPLOY CELLS ARE CARRIED HERE, and this is the part that was wrong. They used to be left empty, on the reasoning that they were only OUTLINES and came from the grid. On 2026-09-22 they became a filled grey area -- the ground a player cannot use, drawn rather than left to be inferred -- and an empty set stopped meaning "no outlines" and started meaning the feature is missing. It is missing exactly for the reader who followed the README's short way, which installs this package alone, and silently: the board looks finished and is wrong. Found by the first CI run on a bare clone.
The three families below reproduce all 176 cells of the 2026-09 arena grid's bit 16. Written as the rule rather than as 176 pairs so the next reader can see WHAT they are: the two back rows either side of each king's lane, each king's own 3x3 block, and the river's four corners.
THE KING CELLS ARE CARRIED EVEN THOUGH NOTHING DRAWS THEM ANY MORE. This is the arena's data and must say what the grid says, not what the renderer currently uses; a reader asking "which ground is undeployable" has to get the truth from here.
An earlier version of this docstring said the set is checked against the real arena by
test_board_from_the_default_arena_matches_the_builtin. IT IS NOT: that test asserts
only that no_deploy is non-empty, and the pixel comparison beside it never reaches
these cells, so dropping all 72 king cells from this data would have passed the whole
suite in silence. test_builtin_no_deploy_matches_the_arena_cell_for_cell now does
what that sentence claimed.
Renderer
¶
Draws frames at a fixed integer scale; owns the pygame fonts and caches it needs.
surface is an off-screen Surface of layout.window until the app replaces it with
the display surface. set_arena gives the board geometry (a royalegym.protocol.Arena
and the unit its centres are in) so water, bridges and tower zones land on the right
half-cells whatever the frame's units are; without it the default arena is drawn.
set_arena(arena, arena_units_per_tile)
¶
The board to draw: royalegym.protocol.Arena and the unit its centres are in.
to_px(x, y, units_per_tile, seat)
¶
Raw native/engine units -> window pixels, seat 1 rotated 180 degrees.
unit_radius_px(unit, units_per_tile)
¶
Half the drawn size: the circle radius or half the square side, in pixels.
For a building or a tower this is the FALLBACK size, used only when the frame carries
no footprint (see footprint_px); the numbers it reads are the collision radius and
the two tower constants, none of which is the box the unit stands on.
box_px(box, units_per_tile, seat)
¶
A raw-unit box [x0, y0, x1, y1] as a window rect, whatever the seat.
The seat turns the board 180 degrees, which swaps which corner is which, so both corners are projected and the rect is taken from their extremes rather than from one corner and a width. A box of three tiles comes out three tiles wide on either seat.
footprint_px(unit, units_per_tile, seat)
¶
The box the frame says unit stands on, in window pixels, or None if it carries
none. Nothing here invents a size: a unit without a footprint gets the marked fallback.
unit_rect_px(unit, units_per_tile, seat)
¶
Where a non-troop unit is drawn: its carried footprint, else the fallback square.
unit_at(px, py, frame, view)
¶
The uid of the unit under the pixel: the shape it is DRAWN as, nearest centre first.
A troop is its disc. A building or tower is the rect it was drawn as, so a 3x3 Cannon is clickable over all nine tiles and a unit standing on the pocket behind a tower is not picked up from the tower's far corner.
timeline_index_at(px, py, length)
¶
Frame index for a click on the scrub bar, None when the pixel is off the bar.
save(path)
¶
Write the current surface as PNG (--shot).
text(s, font='tiny', color=None, shadow=False)
¶
A rendered line, cached by (text, font, colour, shadow); the shadow is baked in.
draw(frame, view, transport=None)
¶
Redraw everything for frame into surface: board, dashboard, inspector.
contact_neighbours(frame, uid)
¶
The units whose collision circles overlap uid's, by the engine's own rule.
RECOMPUTED FROM POSITIONS AND RADII. Nothing in any source records which neighbours a separation step actually saw, or the push it applied, so this is what the law SHOULD have been looking at on this tick rather than what it did look at. That distinction is the whole value: when the engine's displacement does not match the ring drawn here, either the ring is wrong or the engine's scan is, and the disagreement is the finding. Sim asked for it on that understanding and is adding the applied push vector to its trace rows so the other half stops being inferred.
The rule, from the measured contact law: every overlapping neighbour of EITHER side
counts, buildings and towers included, and touching counts -- d2 <= (R1+R2)^2. A
mover's own radius is capped at 500 against a static, which is why a unit pressed
against a tower is pushed as if it were smaller than it is.
divergence_arrows(frame, other, tolerance)
¶
Pairs (this side's unit, the other side's unit) that are further apart than
tolerance, keyed by uid.
BY UID, never by name and distance. Two units of one card that swapped places pair with each other's positions and the tick reads as agreeing, which is the defect the pairing is supposed to show; the two sides of a parity trace both key by the recording's entity key, so the uid is available and is the only honest join.
A uid on one side only is NOT returned here. That is a presence difference, it has no
direction to draw, and drawing it as an arrow to nowhere would put the shape death
timing makes into the shape contact makes. ParitySource.first_divergence reports
it, with a distance of None.
ring_disagrees_with_the_file(frame, uid)
¶
"" unless the recomputed ring and the engine's own neighbour count differ.
THIS IS WHAT TURNS THE RING FROM A PICTURE INTO A CHECK, and it is the reason the count was asked for alongside the push vector. The ring is recomputed from positions and radii, so on its own a wrong-looking ring is equally consistent with the rule here being wrong. Against the count the engine recorded, exactly one of the two is wrong and the disagreement is worth chasing.
Most ticks agree trivially: on the 38,107-row trace sim measured, 33,724 rows have a count of ZERO because nothing overlaps. A ring showing neighbours where the file says 0 is the interesting case rather than a fault in the field.
Empty when the file carries no push for this unit, because then there is nothing to disagree with -- not a silent pass.
spell_radius_px(sp, units_per_tile)
¶
A spell's radius in pixels, or None when nothing says how big it is.
The source's own extra["radius"] first (raw units), then the card's radius from the
engine's card data (engine_tables.SPELL_RADIUS_MILLI). The fill under the units and
the edge over them both read it here, so they cannot come apart.
No source sent a radius until 2026-09-24, and every area was drawn 1.5 tiles across: a Poison of 3.5 covered a fifth of the ground it poisons, and units frozen or poisoned by it stood outside the drawn cloud with the effect's marks on them.
area_radius_px(sp, units_per_tile)
¶
An area's radius for drawing: the real one, or the generic size when none is known.
tint(w, h, rgba)
¶
A translucent w x h rectangle (a card's veil), cached by size and colour.
outside_arena(frame)
¶
One line per unit whose CARRIED box runs off the board, newest board geometry.
This is the shape of the defect the owner found by looking at the window: a building standing where its own box does not fit. Whether a placement was legal is the engine's answer, and the viewer does not have the rule -- but "this box is not inside the arena" needs only the box and the board, both of which are in front of it, so the window can say it instead of drawing it and leaving it to be noticed.
notes(frame, tr)
¶
The status block's warning lines for this frame: what the board cannot be trusted on.
Three kinds, and all of them are about the window lying quietly rather than loudly: a guessed size looks like a measured one, a box that runs off the board looks like a placement the engine allowed, and frames from one run under a status from another look like one run.
learning_blocks(ln)
¶
The panel's rows, one list per group, each starting with its heading (a row with
an empty value): LEARNING_GROUPS formatted field by field, then the learner's own
extra rows in the order it sent them, if it sent any.
learning_columns(ln, rows)
¶
learning_blocks packed into two columns of at most rows rows each.
A group goes whole into the first column with room for all of it, so it is never broken while space for it is going spare and a short group still fits after a tall one has moved on. A group too tall for either column is split across what is left, and rows that fit nowhere are left out -- a heading among them, because a heading with no row under it says nothing.
learning_lines(ln)
¶
Every learning row in order, however the panel happens to column them. Each value
is UNSET when ln is None, and so is a field the learner left unset.
compare_lines(view)
¶
The compare panel's lines: the source and the ghost state, then app.Compare.text (the result at this tick, the running totals).
norm_name(name)
¶
A card or buff name as the tables key it: lower case, letters and digits only.
buff_kinds(name)
¶
Every status kind one engine buff name gives, in STATUS_ORDER; ["other"] for none.
classify_status(name)
¶
The strongest status kind an engine buff name gives, "other" when nothing matches.
status_kinds(unit)
¶
Every status kind on a unit, strongest first (STATUS_ORDER).
shot_kind(p)
¶
"tower", "arrow", "ball" or "bullet" for a shot in flight.
The firer's card decides it, through engine_tables.SHOT_KIND (generated from the card
data). An arrow stays an arrow whatever its splash: a Magic Archer's arrow carries a 0.25
tile splash, the width it pierces. Otherwise a shot the frame says splashes is a ball, which
also covers a firer the table does not know; anything else is a bullet.
effect_lines(name, ms)
¶
One status effect as the inspector lists it, on TWO lines: what this viewer draws it as, then, indented under it, its time left and its ENGINE name, joined names intact.
Two lines because each is cut to the column's width, and one line lost what it existed to show twice. Name first, "Freeze|ZapFreeze (fr..." lost the kind and the time. Kind first on one line, Linux's wider monospace font cut "poison+slow 7.5s" to "7.5..." (CI, 2026-09-24), where Windows' narrower one had passed it. Now the kind and the time each lead a short line, and only the engine name, last, is ever cut. ms -1: the source has the effect but not its duration.
damage_colour_attr(unit)
¶
The theme colour for a unit's damage over time: its spell's own, not always Poison's.
Earthquake and Tornado damage over time too, and drawn in Poison's green they told a reader a Poison had been cast. A part of the buff's name that is a spell card with a style of its own gives that card's colour; anything else is Poison's.
spell_style(name, motion)
¶
(theme colour attribute, shape) for a spell; the generic colour by motion if unknown.
field_text(value, fmt)
¶
One fixed row's value, in its own format.
Two things the plain format gets wrong on a real run. A number that rounds away to nothing would print as "0.000" or, worse, "-0.000" -- which reads as a bug and hides whether it was a millionth or a thousandth, so a value that is not zero but formats as though it were is shown to one significant figure instead (measured on a real PPO iteration, 2026-09-22: a policy loss of -3e-05 drew as "-0.000"). And a value that IS zero never carries a minus sign, however the float is signed: an honest zero is "0.000".
extra_text(value)
¶
One of the learner's own Learning.extra rows, which has no format of its own: an
integer with thousands, a float to four significant figures, anything else as it stands.
None is unset here as everywhere, so a learner can carry a row it does not always have.
age_text(seconds)
¶
How long ago the status arrived, short enough for a panel heading.
A real iteration is MINUTES apart -- 8.9 of them on the laptop profile, measured 2026-09-22 -- so a panel with no age on it cannot tell a run that is working from one that died half an hour ago: both show the same numbers, standing still. "8m" is the cadence; "47m" is a learner that is gone.
learning_run(ln, peer='')
¶
What the panel writes beside its "learning" heading.
Whether a learner is attached and what it calls itself are two questions: a status with no run name is still a learner, so it gets the em dash of any unset field rather than the "nothing here" line standing over its own numbers.
With nothing attached it NAMES THE PORT it is listening on, because the alarming case looks identical to the ordinary one. A learner whose status port was already taken -- by another training run on the same machine, which is the usual cause -- publishes nothing and has no socket to say so on, while its environment may well have got its own port and be streaming a battle. The panel then reads "no learner" over a moving board. Naming the port turns that from a shrug into something a person can check.
default_board()
¶
royalegym's default arena (RoyaleSim/data/derived/arena.json) or the built-in copy.
split_name(name, limit)
¶
A card name in lines of at most limit characters, split at CamelCase / spaces.
name_words(name)
¶
A card name's words: split at spaces, underscores, digits and CamelCase.
monogram(name)
¶
The two letters a card tile shows large: the first two words' initials (HogRider "HR", Elixir Collector "EC"), or a one-word name's first two letters (Knight "Kn"). "?" for a name with no letters (an unknown card, "#26000099").
tile_codes(among)
¶
name -> what a small tile (the next card, an ability button) shows for it among these cards: its monogram, unless another of them shares it.
Two cards can share a monogram (Witch and Wizard are both "Wi", both 5-elixir troops), or have two that differ only in case (Skeletons "Sk", SkeletonKing "SK"), and a small tile shows nothing else that tells them apart. Each such card gets its first letter and, in lower case, the first letter of its name that differs from the letter at the same place in every other one's (Witch "Wt", Wizard "Wz"), passing over a code another card already shows. A name with no such letter keeps its monogram (RoyalRecruits "RR" beside RoyalRecruits_Chess "Rc"). Still two characters, because three do not fit a 24 px tile in the tiny font ("SkA" is 28 px). A card whose monogram nothing else here shares keeps it, so such a deck draws as it did.
tile_code(name, among=())
cached
¶
name's code among among (tile_codes). A tuple, so the answer is cached: a
deck does not change during a battle, and working one out takes about 0.14 ms.
known_cards(p)
¶
The cards a player's small tiles are told apart among: the deck when the source knows it, else the hand and the next card.
riders(units)
¶
rider uid -> the uid of the troop it rides, for every troop whose source says which unit
it sits on (extra["mount"], RoyaleGym's EntityState.mount_uid) and whose mount is in the
frame. Empty from a source that does not say, which draws exactly as before.
stacks(units, units_per_tile)
¶
The troops drawn over teammates they hide: uid -> how many units the stack holds, for the one drawn LAST (on top), and 0 for each one under it. Empty when nothing is stacked.
One team, one layer (a flyer over a ground troop is drawn apart, with its shadow and ring),
centres closer than STACK_TILES_X100 hundredths of a tile, chained: a unit within
reach of any member joins the stack. Troops are drawn in frame order, so the last member
in the frame is the one on top.
shade(color, f)
¶
color scaled by f (below 1 darker), each channel clamped to 0..255.
card_color(theme, kind)
¶
A card tile's face colour by what the card is; the plain tile when nobody says.
footprint_note(frame)
¶
What the status block says about the sizes on the board, or "" when nothing is guessed.
Counting the guessed units is the whole of it: "3 of 9" is a fact about THIS frame that a reader can act on, where a fixed sentence about which sources carry footprints would be a claim about every frame the viewer will ever open, and would go stale the day a source starts carrying them.
footprint_line(unit, units_per_tile)
¶
The inspector's footprint row: the box in tiles, or why there is not one.
A troop has none and says so; a building without one says the size on the board is the viewer's guess, in the same place a reader looks for the number.
tiles_x10(v, units_per_tile)
¶
Raw units as tiles to a tenth, without a float (sources.tiles_text's rule).
status_words(unit)
¶
The unit's status bits in words for the inspector: "none", "not reported", or the set bits by name, with any bit this viewer does not know as its number.
dashed_circle(surface, color, center, r, width=1)
¶
A circle drawn as eight dashes, half ink.
dashed_line(surface, color, a, b, max_dashes=16, width=1)
¶
At most max_dashes dashes (12 px each when the line is short), 60 % ink.
zigzag_ring(surface, color, c, r, width=2)
¶
A jagged ring: points alternating between r and r+3, the stun's lightning.
dashed_ring(surface, color, c, r, width=2)
¶
A ring broken into dashes: the slow's interrupted motion. An arc's width grows INWARD
from r, so a wider underlay is drawn at r + 1 by the caller to border both sides.
dotted_ring(surface, color, c, r, dot=2)
¶
A ring of small dots: the poison's bubbles.
plus_sign(surface, color, c, r, width=3)
¶
A plus, for a heal: the one effect that is a symbol rather than a ring.
shield_glyph(surface, color, c, r)
¶
A small heater shield, point down, outlined in black: the unit carries a shield.
fit_text(s, font, width)
¶
s shortened with an ellipsis so it renders within width pixels.
Control characters are dropped first: a learner names its own rows and its own run, and a NUL reaching the font layer takes the window down. One edit here covers every string the panel draws rather than trusting each call site.
royaleviser.theme
¶
Colours, fonts, sizes and the window layout, in one place.
The palette is carried over from the project's earlier Python renderer: the checkerboard grass, the river, the bridges, blue team 0 / red team 1, the dark UI. The layout is that renderer's, reduced to numbers: a dashboard column on the left (the top player's hand flush with the top-left corner, its elixir row, a short status block with the event log, the bottom player's elixir row and hand flush with the bottom-left corner), the arena beside it at a fixed integer pixel-per-tile scale, and an inspector column on the right for the hovered unit, the events list and the status line.
from royaleviser.theme import DEFAULT, layout
lay = layout(DEFAULT, scale=24, tiles=(18, 32)) # -> Layout with every rect in pixels
Everything here is an integer; the renderer never computes a size of its own.
Theme
dataclass
¶
hp_color(hp, max_hp)
¶
Green above 60 %, yellow above 30 %, red below (the old thresholds), integers only.
Layout
dataclass
¶
Every rect of the window for one scale and arena size, in pixels (x, y, w, h).
layout(theme, scale, tiles)
¶
Old renderer's arrangement: dashboard | arena | inspector, arena height sets the window.
With theme.inspector_w 0 the inspector column is left out (its rects are empty and
the window ends a margin after the arena): the compact layout for a narrow window, where
the compare lines move into the dashboard's status block.