Skip to content

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:

  1. The header, an object (below).
  2. 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 of keyframe_interval.
  3. One channel per part of the frame that changes: every change to that part, in frame order.
  4. 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.

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, and index.json, a manifest giving each replay's title, description, the grounded-decision record command 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 with NAME.frames.json, the frames as Python exports them. The TypeScript decoder's tests decode each .gdr and 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.