Flashback Replay Format (RPLY v1)
Version 3. Binary, little-endian, written with Luau buffer. Everything is op-stream based so
the recorder can append incrementally and the player can seek by scanning a keyframe index.
Layout
Header
StringTable
KeyframeIndex
OpStream
Header
| field | type | notes |
|---|---|---|
| magic | u32 | "RPLY" |
| version | u8 | 3 |
| tickRate | u8 | snapshots per second (e.g. 15) |
| placeVersion | u32 | game.PlaceVersion — refuse mismatches |
| startUnix | f64 | os.time() at record start |
| tickCount | u32 | total ticks |
| stringCount | u16 | |
| keyframeCount | u16 | |
| opBytes | u32 | length of OpStream |
StringTable
stringCount × (u16 len, bytes). Asset descriptors, property names, event names,
animation ids, custom channel names all go through this so the op stream only carries u16 ids.
KeyframeIndex
keyframeCount × (u32 tick, u32 byteOffset). A keyframe is a tick where every live
entity emitted a full XFORM (no delta). Seeking = jump to nearest keyframe ≤ target,
replay forward.
OpStream
Sequence of ops. Each op starts with u8 opcode.
| op | name | payload |
|---|---|---|
| 0 | TICK | u32 tick |
| 1 | SPAWN | u16 entityId, u16 descriptorStr, u16 parentEntityId (0=none) |
| 2 | DESPAWN | u16 entityId |
| 3 | XFORM | u16 entityId, u8 mask, [pos: 3×f32] [rot: 3×i16 quantized] |
| 4 | PROP | u16 entityId, u16 nameStr, u8 typeTag, value |
| 5 | ANIM_PLAY | u16 entityId, u16 animIdStr, u8 handle, u8 fade/100, i16 speed×256 |
| 6 | ANIM_STOP | u16 entityId, u8 handle, u8 fade/100 |
| 7 | ANIM_SPEED | u16 entityId, u8 handle, i16 speed×256 |
| 8 | EVENT | u16 nameStr, u16 payloadStr (JSON) |
| 9 | CUSTOM | u16 channelStr, u16 byteLen, bytes |
| 10 | TICK_NEXT | (none) advances tick by one |
| 11 | XFORM_D | id entityId, u8 mask, deltas (see below) |
| 14 | SNAPSHOT | u16 entityId, u32 byteLen, bytes (geometry snapshot, see below) |
XFORM mask bits: 1 = position present, 2 = full rotation present, 4 = yaw-only rotation (i16, ±π).
Rotation is quantized as axis-angle scaled to i16 (±π → ±32767). Upright entities (up vector
within 0.005% of world up) use yaw-only samples and i8 yaw deltas afterwards.
id is one byte below 255, otherwise 0xFF then u16. XFORM_D mask bits: 1 = 3×i8 position delta (1/32 stud), 2 = 3×i16 position delta
(same unit, for fast movers), 4 = i8 yaw delta (1/64 rad, yaw-only entities), 8 = 3×i8 axis-angle rotation delta (1/128 rad, tilted entities).
Deltas accumulate from the last XFORM; the recorder tracks the quantized value so error does not drift. Deltas that overflow, or a switch
between yaw-only and tilted, force a new XFORM.
Ticks with no changes emit nothing; TICK_NEXT precedes the first op of a new tick.
Position is raw f32 (fine for most maps; quantize to i16 with a per-map origin/scale if size matters).
SNAPSHOT
Written once per tracked part or model, player characters included. The capture is taken at SPAWN but held back while
descendants keep arriving (a character still loading limbs and accessories) and retaken once the entity has been quiet
for a second, then written; pending captures are flushed on despawn, stop and finalize. The player keeps the last one it decodes.
u8 version (10), u8 isModel, str16 name, str16 sourceKey (unique path when recorded), str16 rootPath (path of the root part inside the entity), u16 unionCount + str16 union keys (the union's unique path when recorded), u16 meshCount + str16 mesh ids (so the player can prefetch MeshParts), then for models a rig block (u8 hasHumanoid, and if set
u8 rigType, str16 shirt, str16 pants, str16 shirtGraphic, u8 hasBodyColors + 6×3 u8 colours, then u8 meshCount × CharacterMesh
(u8 bodyPart, f64 meshId, f64 baseTextureId, f64 overlayTextureId asset ids)), then
u16 count and per BasePart: u8 kind (0 Part, 1 MeshPart, 2 Wedge, 3 CornerWedge, 4 other, 5 Union),
for unions u16 unionKeyIndex, then str16 name, str16 parentPath (slash-joined segments of the wrappers between the entity and the part, rebuilt as Folders), 3×f32 offset from root, 3×i16 rotation, 3×f32 size, 3×u8 color, u16 material, u8 transparency, u8 reflectance, u8 shape, 6×u8 surface types, for meshes str16 meshId, str16 textureId, 3×f32 meshSize,
then an optional SpecialMesh block, an optional SurfaceAppearance block, a list of Decals/Textures and an
effects list. After the parts comes a joint list: u16 count, per joint u8 kind, str16 name, u16 holderIndex then for
kind 1 Weld / 2 Motor6D u16 part0Index, u16 part1Index, C0, C1, and for kind 3 AnimationConstraint /
4 RigidConstraint u16 part0Index, str16 attachment0Name, u16 part1Index, str16 attachment1Name
(indices into the part list, 1-based; attachments are rebuilt from the effects list first).
The player rebuilds this as an anchored proxy when no prototype is supplied; MeshPart meshes are rebuilt as real
MeshParts through AssetService:CreateMeshPartAsync (Snapshot.prefetch, which Player.new calls and which
yields the first time each mesh id is seen), with a scaled SpecialMesh as fallback; the root part always comes
first so it becomes the proxy's PrimaryPart; joints are recreated so animations drive the rig, and
rigs get a Humanoid so clothing renders. Union geometry cannot be serialized: on playback a key resolves to
the UnionOperation at the recorded path, or to one linked manually through Snapshot.link (the plugin's
Prefab links list), and is cloned; otherwise a plain Part of the same size stands in. Linking a snapshot's
source key to a prefab (typically kept under ReplicatedStorage) makes the player clone that prefab instead of
rebuilding the snapshot at all, which is the way to play back models that cannot be serialized faithfully; the clone's
PrimaryPart is moved to the part named like the recorded root so recorded transforms land where the original did.
Effects list: u8 count, per effect u8 class (1 Attachment, 2 Sound, 3 ParticleEmitter, 4 Beam, 5 Trail, 6 Fire, 7 Smoke, 8 Sparkles, 9 PointLight, 10 SpotLight, 11 SurfaceLight), u8 parentIndex (0 = the part, else an earlier attachment), str16 name, u8 propCount and per property u8 propIndex (into the class's property list), u8 tag, value with tags 0 bool, 1 f32,
2 str16, 3 Color3, 4 Vector3, 5 Vector2, 6 NumberRange, 7 NumberSequence (u8 n × time/value/envelope f32),
8 ColorSequence (u8 n × f32 time + Color3), 9 enum name, 10 attachment reference by name.
Watched properties (the recorder's Class.Property watch list, whose default covers part appearance, humanoid health and
effect state such as Enabled, Playing, Rate, Size, Volume, Brightness) of the entity and the instances inside it are written as PROP ops whose name is a slash path inside the entity as the
rebuilt proxy sees it, e.g. Body/Exhaust/Enabled or Hat/Handle/Transparency. Initial values of properties the snapshot
already carries (part Color, Transparency, Size, Material, Reflectance and effect state) are not written; only changes are.
Attributes of a tracked instance are written as @Name (and set back with SetAttribute), and a tracked model's scale
as Scale, the ratio of Model:GetScale() to its value at spawn (applied with ScaleTo). A watched Sound's Played
event writes <path>/TimePosition = 0 and <path>/Playing = true so restarts of an already playing sound replay. Which properties, attributes,
scale, animations and descendants (properties and effects inside the entity) are recorded is set per recorder and
overridable per instance through the FlashbackWatch, FlashbackAttributes, FlashbackScale, FlashbackAnimations
and FlashbackDescendants attributes.
PROP typeTags
0 bool, 1 number(f64), 2 string(strId), 3 Color3 (3×u8), 4 Vector3 (3×f32), 5 enum(u16 strId of name).
Descriptor strings
A descriptor tells the player how to build a proxy. Convention:
model:<path.in.ReplicatedStorage.Prototypes>— clone a prototypepart:<Shape>— create a bare Part, PROP ops fill Size/Color/Materialchar:<userId>— player character; rebuilt from its SNAPSHOT (limbs, accessories, clothing, body colours, joints), falling back toPlayers:CreateHumanoidModelFromUserIdfor recordings without onesub:<path>— a BasePart inside a tracked ancestor; the player drives the part at that path inside the parent proxy instead of creating a new one. Tagged parts jointed to a tracked ancestor (Motor6D, Weld, WeldConstraint) are not written at all: the rig's own joints and recorded animation tracks move them. When every joint holding such a part disappears it is spawned as asub:entity from that tick; the player removes the proxy's joint and drives itmodel:<name>is also used for Tools carried inside a tracked entity: they spawn as child entities (parent id set) with their own snapshot when equipped and despawn when they leaveasset:<assetId>— InsertService (rarely worth it; prefer prototypes)
Paths
Every path (sub: descriptors, PROP names, snapshot parent paths, source and union keys) is made of segments that are the
instance name, suffixed with #n when siblings share that name (n counts same-named siblings in child order), e.g.
Wheel#2/Transparency. A segment is resolved by exact name first, then by the Name#n form, so names containing # still work.
Size budget (rough)
20 players @ 15 Hz, position+rotation each tick: 20 × 15 × (1+2+1+12+6) ≈ 6.6 KB/s ≈ 4 MB per 10 min raw. Delta thresholds (skip XFORM if moved < 0.05 studs / 0.5°) usually cut that 3–5×. Chunk at ~3.5 MB per DataStore key or push to your own backend.