Replay format¶
A .gdr file is a recorded game: every frame of one session, so a
front end can play the game back without running Python. The web
showcase's gallery plays them (the web plan,
phase 2), and grounded-decision record writes them. This page
specifies version 2 of the format, the commands that read and write it,
and what each part costs in bytes. The frames themselves are the
protocol's frames, described on Session protocol.
Recording and checking a replay¶
uv run grounded-decision record hexxagon --start ring --seed 1 -o hexxagon-ring.gdr
uv run grounded-decision replay-info hexxagon-ring.gdr --verify
record GAME takes play's match options (--agents, --world,
--seed, --size, --players, --max-steps, --until-length and the
game's rule options such as --start or --mines), builds the same
SessionSpec from them and refuses what play refuses, with the same
message (exit 2). Every seat must be an agent: a person's moves cannot
be recorded in advance. The match runs to its end or its move budget.
Without --seed, a fresh seed is drawn and stored in the header.
--keyframe-interval N sets how often a whole frame is stored (default
32).
replay-info FILE prints the header and checks the frames against its
content hash. --verify also records the header's spec again and checks
that the new frames hash the same, which proves the replay is what this
version of the package plays for that spec. --json prints the header
and the results as JSON. The exit status is 0 when every check passes, 2
for a file that is not a replay and 3 when a check fails.
From Python:
from grounded_decision.session import SessionSpec
from grounded_decision.session.replay import Replay
replay = Replay.record(SessionSpec("snake", world="adversarial", size="10x10", seed=1))
data = replay.encode() # the .gdr bytes
again = Replay.decode(data) # again.frames == replay.frames, exactly
again.problems() # () when the frames match the header
again.reproduced() # True when recording the spec again gives the same frames
The module (grounded_decision.session.replay) is pure, like the rest
of the session layer: bytes in and bytes out, no file system.
The file¶
A .gdr file is gzip of UTF-8 text with one JSON value per line, and
\n after every line:
- The header, an object (below).
- Every keyframe, frame 0 first:
{"k": FRAME}, the whole frame in its protocol form, for frame 0 and every frame whose index is a multiple ofkeyframe_interval. - One channel per part of the frame that changes: every change to that part, in frame order.
- The transitions, if any frame between keyframes has in-between pictures.
Changes of the same kind lie together (every cell change in one line, every metric in another), which gzip compresses far better than each frame's changes side by side. The gallery takes 270 KB where a line per frame took 730 KB, and a long snake game a fifth of what it took (see Sizes).
The writer uses gzip level 9 with no timestamp and no file name, and writes every JSON value with sorted keys and no spaces, so the same frames always give the same text. Compressed bytes can still differ between zlib builds, so tests compare the text.
Header¶
| Field | Contents |
|---|---|
format |
Always "gdr". |
format_version |
2. A reader refuses any version it does not know; a replay of another version is recorded again. |
protocol_version |
The session protocol version of the frames, such as "1.0.0". A reader refuses another major version. |
package_version |
The grounded-decision version that recorded the file. |
game |
The game's name, equal to spec.game. |
spec |
The SessionSpec in its protocol form. Recording it again reproduces the replay. |
seed |
The seed, equal to spec.seed. |
session |
The protocol's SessionInfo: seats, world and the panel's fixed rows, as in a frames reply. |
frame_count |
Number of frames: moves played plus one. |
keyframe_interval |
Frames whose index is a multiple of it are keyframes (256 by default). |
content_hash |
"sha256:" followed by the hex SHA-256 of the canonical frame stream (below). |
outcome |
The protocol's Outcome of the last frame. |
Readers ignore header fields they do not know, so a minor version of the format may add fields.
Frames between keyframes¶
Every frame that is not a keyframe is the frame before it, with its
index one higher (frames are consecutive, so the index is never
stored) and without its transition, changed by its operations in
every channel, and given its transition pictures (none if the
transitions line does not name it).
An operation changes the value at a path, a list of object keys and array indices from the frame's root. It has one of three forms:
| Form | Meaning |
|---|---|
[path, value] |
Set: replace what is at path with value. |
[path, "+", amount] |
Add: add the whole number amount to the whole number at path. |
[path, start, delete, items] |
Splice: in the array at path, replace delete items from index start with the array items. |
A path may only name something that exists, and an empty path names the whole value. A count that goes up by one is the same add on every move; a list that shifts (a snake's body gains a head and loses its tail, a sparkline's window moves on, a sorted list loses one entry and gains another) is a splice or two, not the whole list.
A channel is {"c": keys, "o": changes}. keys are the leading
object keys its paths share, at most two (["board", "cells"],
["decision", "metrics"], ["arrows"]); each change is
[gap, operation], where gap counts the frames since the channel's
previous change (from frame 0 for its first) and the operation's path is
the part after keys:
{"c":["board","cells"],"o":[[1,[[0,10],"snake_head"]],[0,[[12,10],"empty"]],[1,[[0,11],"snake_head"]]]}
{"c":["panel","counters"],"o":[[1,[["moves"],"+",1]],[1,[["moves"],"+",1]]]}
The writer never changes, in one frame, both a value and a part of it, so a frame's operations from different channels can be applied in any order; those of one channel keep their order. No channel changes a keyframe or a frame past the last.
The transitions are {"t": [[gap, pictures], ...]}, the gaps
counted like a channel's, each picture [weight, operations]: the
first picture's board is the previous frame's board with its operations
applied (paths from the board's root), and each later picture's board is
the picture before it with its own.
The writer chooses operations: it descends into objects with the same keys, and for an array takes the shortest of its items' own changes (for arrays of the same length), one splice around the parts that stayed at the start and end, two splices for a list shifted by up to four places, one splice per stretch that changed, or the whole array. Readers only apply operations, so a later writer may choose differently without a new format version.
Seeking¶
To show frame n, a reader takes the keyframe at
n − (n mod keyframe_interval) and applies the frames' changes after
it, at most keyframe_interval − 1 of them. The TypeScript reader reads
every channel once into each frame's operations, parses a keyframe only
when it needs it, and keeps the frames it has decoded, so playing
forward applies one frame's changes per frame.
The content hash¶
The canonical frame stream is each frame's protocol JSON, written with
sorted keys and no spaces (Python's json.dumps(frame, sort_keys=True,
separators=(",", ":"), ensure_ascii=False)), each followed by \n. The
content hash is the SHA-256 of that text in UTF-8. It depends only on
the frames, not on the keyframe interval, the channels or the
compression. It is computed in Python, which keeps the difference
between 1 and 1.0 that JavaScript's JSON.parse loses (the writer
keeps it too: a value is only left unchanged, or kept in a splice, when
its JSON text is the same), so the browser reader checks the schema and
the frame count, and Python (replay-info) checks the hash.
Sizes¶
Measured on the gallery set (make replays; bytes of the .gdr file
divided by the moves played):
| Replay | Moves | Bytes | Bytes per move |
|---|---|---|---|
| Tic-tac-toe, rule of thumb against grounded | 9 | 1,573 | 175 |
| Connect Four, gated against search | 20 | 2,085 | 104 |
| Checkers | 64 | 8,008 | 125 |
| Go, 9 × 9 | 74 | 7,206 | 97 |
| Hexxagon, ring opening | 76 | 16,675 | 219 |
| 2048, evil world | 90 | 7,564 | 84 |
| Tetris, S and Z pieces only (with every falling-piece transition) | 136 | 19,415 | 143 |
| Chinese checkers, six players | 193 | 29,220 | 151 |
| Minesweeper, expert | 286 | 15,933 | 56 |
| Snake, random apples, 10 × 10, to a full board | 1,403 | 33,928 | 24 |
| Snake, partly adversarial apples, 10 × 10, to a full board | 1,641 | 39,163 | 24 |
| Breakout, adversarial spin, to the cleared level | 2,597 | 85,867 | 33 |
Every gallery game plays to its end; the whole gallery is about 270 KB,
and a test keeps each replay under 128 KB.
replay-info FILE --bytes shows where a replay's bytes go: each
channel, the keyframes and the transitions, each compressed on its own.
For the 10 × 10 snake most of what is left is information: the cells
the head and tail move through (10 KB), the agent's metrics on every
move (6 KB), the score's sparkline (4 KB) and the body's path (4 KB);
the move counters, the same add every move, take 0.3 KB.
Format 1, a line per frame with set operations only, took 117,721 bytes for the same snake and 2,093,283 for a complete game on 24 × 16 (18,489 moves), which format 2 stores in about 380,000: a snake's body was written whole on every move (87 MB of text for that game), and every count and index as a new number. Keyframes cost little now: one every 32 frames instead of every 256 makes the snake 14% larger.
The curated replays¶
make replays records two committed sets
(grounded_decision.session.gallery):
- the gallery in
web/apps/site/static/replays/: one replay per game, with interesting worlds, sizes and openings, andindex.json, a manifest giving each replay's title, description, thegrounded-decision recordcommand that reproduces it, its frame count and its content hash; - the golden files in
web/packages/replay/fixtures/: a few moves of every game, a keyframe every 4 frames, each withNAME.frames.json, the frames as Python exports them. The TypeScript decoder's tests decode each.gdrand compare the result with that file exactly.
They are committed so the site builds and the TypeScript tests run
without Python. A Python test records every one again and compares the
content (all but the header's package_version), so a change to a rule
or an agent fails the test until make replays is run.
Reading replays in the browser¶
web/packages/replay (@grounded-decision/replay) decodes .gdr
files with the platform's DecompressionStream, validates the header
and frames against the protocol schema, gives random access to frames
and plays them with a ReplayPlayer (play, pause, step, seek, speed).
It needs no DOM. Its README documents the API, and its bundle is
checked against the 40 KB gzipped budget on every build.