← All projects
Moderation

Replay System

A CS2-style demo viewer for Roblox. The server records every match as a delta-compressed position stream, and a moderator can scrub back through it, spectate any player, and watch the keys they were pressing.

Challenge

Roblox moderation runs on screenshots and eyewitnesses. A player reports a cheater, a moderator arrives after the fact, and the only evidence is a clip the accuser chose to record. There is no way to go back and look.

Solution

Record the match itself. The server samples every tracked entity's transform at a fixed sub-rate, writes only what changed, quantises what it does write, and packs the result into a DataStore key small enough to be an afterthought. Anyone with permission can then replay the round from any angle.

  • Roblox
  • Luau
  • Compression
  • DataStore
  • Moderation
  • Anti-cheat
A recorded Roblox round played back in freecam, green and cyan trails showing where each player has been, scrubber and playback controls along the bottom
First-person spectate view of a recorded player, with a WASD, space and left-mouse overlay in the corner showing live input
A dark replay browser panel open over the game, with a search field, a most-recent filter and a result card showing the two players in the clip
Freecam over a recorded round, with movement trails behind each player1/3
Replay System media

Recording a game that was never designed to be recorded

There are two ways to build a replay system and only one of them is available here.

The first is to record inputs and re-simulate. It is how a fighting game or an RTS stores a match in a few kilobytes: keep the commands, replay them through the same deterministic engine, and the match reconstructs itself exactly. Roblox offers no deterministic simulation contract — no promise that the same inputs on the same physics produce the same result twice — so this is not merely hard here, it is unavailable.

The second is to record state. Sample where everything is, often enough that the gaps can be smoothed over, and store the samples. That is what this does, and every design decision after it follows from one constraint: the only persistent storage on Roblox is DataStore, and a DataStore key holds 4 MB.

The recording pipeline on the server: sample at a fixed sub-rate, drop entities whose state did not change, quantise positions and pack rotations, encode the binary payload as an ASCII string, and split it across DataStore keys if it exceeds the per-key limit. The client reverses it: reassemble the chunks, decode, sort the frames into order, and interpolate between them for playback. SERVER · RECORD Sample10 Hz Deltaunchanged = 0 bytes Compressquantise · pack Encodebinary to ASCII Chunkpast the key limit DATASTORE · 4 MB PER KEY CLIENT · PLAY Readchunks reassembled Decodestring to binary Sortwrite order varies Interpolatesmoothstep
Five stages down, four back up. Everything between the sample and the key exists to make the payload smaller, because the payload is the only thing that is actually scarce.

Four reductions, multiplied

Nothing here is a clever single trick. It is four ordinary ones applied in order, and the reason the result is large is that they compose.

Subsampling. The physics tick runs at 60 Hz; recording runs at 10. A timer accumulator on the tick decides when a sample is due, so the recorder never runs on its own clock and never drifts against the simulation. The saving is the obvious one:

R  =  1frecftick  =  11060    83%R \;=\; 1 - \frac{f_{\text{rec}}}{f_{\text{tick}}} \;=\; 1 - \frac{10}{60} \;\approx\; 83\%

before anything else has happened.

Delta encoding. Each entity’s last written state is retained. If the current sample is identical, that entity is omitted from the frame entirely — not written as zeroes, not written at all. A player standing still, dead, or spectating contributes nothing. A per-entity bitmask records which fields actually changed, so a player who rotated but did not move pays for the rotation only.

This is where the difference between a demo viewer and a video recorder shows up. A video costs the same whether or not anything happens. This costs proportional to how much the match moved.

Quantisation. Positions are stored at a tuned precision rather than as full floats, roughly halving the position bytes. The precision is chosen against the thing being rendered — a semi-transparent ghost, seen at a distance, being watched to answer “did that player see this one through a wall” rather than “was that a headshot by two pixels”.

Rotation packing. Rotations compress about four to one, trading a negligible fraction of angular precision for the same reason.

Put together, the payload relative to a naive full-rate recording is roughly

bytesbytesnaive    16subsample  ×  dˉfraction moving  ×  qˉquantise + pack\frac{\text{bytes}}{\text{bytes}_{\text{naive}}} \;\approx\; \underbrace{\tfrac{1}{6}}_{\text{subsample}} \;\times\; \underbrace{\bar{d}}_{\text{fraction moving}} \;\times\; \underbrace{\bar{q}}_{\text{quantise + pack}}

which measures out end to end at 3–10% — the 90–97% reduction, arrived at by multiplication rather than by any one stage being remarkable.

The binary payload is then encoded into ASCII, because DataStore stores strings, and split across keys with a chunk count at the primary key if it is still too big. Reassembly on read is transparent; nothing above the storage layer knows whether it was one key or six.

What that actually costs

The interesting number is not the percentage. It is what the percentage means once it lands in a real DataStore.

A logarithmic comparison of DataStore payload sizes. A basic progression save is one to five kilobytes, a simulator save twenty to fifty, and a highly dynamic game's save a hundred to five hundred. A replay spans six kilobytes for a short measured entry up to roughly four hundred for a full five-minute round, which overlaps all three and stays far below the four megabyte per-key limit. DATASTORE BYTES PER KEY · LOG SCALE basic progression save simulator or tycoon save highly dynamic save one measured entry, 6 104 bytes → a full 5-minute round 1 KB 10 KB 100 KB 1 MB 4 MB limit
A real stored round came to 6 104 bytes. Storing an entire match for moderation can cost less DataStore capacity than a simulator spends on one player's pet collection.

A five-minute round with a moderate player count lands in the hundreds of kilobytes — seven to ten per cent of a single key. One real entry pulled back out of the DataStore measured 6 104 bytes, because the delta encoder had almost nothing to write: few entities, little movement, and the stages above compound hardest exactly when a match is quiet.

Server cost is a lightweight accumulator check per physics tick, and at the sample rate, one transform encode and one comparison per tracked entity. The hot path is annotated for native compilation. Memory is a rolling window:

bytes  =  nframes×nentities×s\text{bytes} \;=\; n_{\text{frames}} \times n_{\text{entities}} \times s

which for a 30-second window over twenty entities stays well under a megabyte including Lua table overhead — small enough that the buffer is not a consideration.

Decoding is linear in payload length and in frame count, plus a sort, because frames do not necessarily arrive in write order. For typical replay lengths it finishes in under 100 ms.

Where the fidelity goes

Playback interpolates between samples with smoothstep rather than a straight lerp, which matters more than it sounds: linear interpolation between position samples produces visible direction changes at every keyframe, and a ghost that snaps every tenth of a second reads as broken rather than as approximate.

The error is bounded by how far an entity travels between samples:

Δs  =  vfrec\Delta s \;=\; \frac{v}{f_{\text{rec}}}

At a walking pace of 16 studs per second that is 1.6 studs between samples, and the deviation between the real path and the smoothed one stays well under a stud — imperceptible on a semi-transparent ghost. It grows linearly with speed, so vehicles and teleport abilities are where the approximation shows. That is the honest limit of the whole approach: this is evidence for a moderator, not a frame-accurate spectator feed.

Which is also why the sample rate is not the thing to raise. Doubling it doubles the payload to halve an error nobody watching a ghost can see.

Why not do it the way CS2 does

Counter-Strike 2 records demos by tapping the server’s own outbound network stream into a file. The Source engine already delta-compresses entity updates in its netcode, so recording is close to free — but the file is a complete reconstruction of the match at a 64 to 128 Hz tick, weapons and grenades and physics props included, and it runs 400 MB to 1.1 GB per match and needs the game client to open.

Valorant shipped replays in September 2025 on a deterministic 128-tick lockstep architecture, and had to build a separate state-recording layer because the network layer, designed for competitive integrity, does not emit replay-compatible data. Riot have been open that retrofitting this was a significant engineering problem — which is the general case, not a Riot problem. Adding replay to an engine that already exists is much harder than building it alongside one.

Both routes need something Roblox does not offer: a network layer you can tap, or a simulation you can trust to repeat. Roblox servers are also single-threaded, so a recorder that costs real CPU costs it out of the game loop everyone is playing in. State snapshots at a low sample rate with aggressive compression is not a compromise chosen for convenience — it is the only one of the three that fits the platform.

The trade is per-tick fidelity for server headroom, which is the correct way round when the output is moderation evidence.

The parts that are not compression

Two of them, and both are the sort of thing that decides whether a system gets used after the first week.

Nothing is configured by editing code. Every tunable — sample rate, window length, UI colours, icons, sound ids, localised strings, group-rank permissions — lives in one constants file that a package update does not overwrite. Maps discover themselves through CollectionService tags, and an NPC joins a recording by being tagged, not by being registered somewhere.

The browser is a player feature, not just a staff tool. /report writes the last thirty seconds to its own clip and tags the accused, which is the moderation path. But the same browser, on F4, lets anyone search and watch their own rounds back — and a game where players can clip and review their own matches gets something out of the system on the days nobody is cheating.

What it shipped

  • Delta-compressed position stream at 10 Hz: an estimated 90–97% smaller than recording every physics tick
  • One real stored round measured 6 104 bytes — smaller than a single player's save file in most games
  • Automatic chunking across DataStore keys, reassembled transparently on read
  • In-game browser with search, date and map filters, built on CollectionService tags so map discovery needs no configuration
  • Freecam and per-player spectate, with a live keystroke overlay showing WASD, space and mouse
  • /report writes the last 30 seconds to its own clip and tags the accused player
  • Group-rank permissions over who may view, record or delete
  • Every tunable in one constants file that a package update never overwrites

More projects