Forensics
Forensics answers the question every anti-exploit system eventually needs answered: what actually happened? A hook firing “Speed” or “Teleport” is a verdict, not evidence — it tells you a threshold was crossed, not what the movement leading up to it looked like. Forensics keeps every tracked player’s recent kinematics rolling through a ring buffer at all times, so when Movement Monitor (or any other system) flags a player, there is already a window of history to freeze. The design decision that defines it: the buffer runs continuously for every player, whether or not they ever get flagged. Evidence has to exist before the incident, because you cannot go back and record the past.
Mental model
Section titled “Mental model”Who gets tracked
Section titled “Who gets tracked”Forensics doesn’t track raw Player instances directly — it hooks into kernel:onSession, the same session lifecycle every other kernel-integrating module (Movement Monitor, Rewind, NetGovernor) rides. See Sessions for the full lifecycle. When a session opens, Forensics provisions that player’s ring; when the session’s cleanup runs (session:bind(...)), Forensics finalizes any in-progress capture for that player and drops their ring. This is also why a leaver’s capture finalizes immediately rather than on a separate timeout — it’s riding the same teardown path every other per-session resource in the kernel uses, not a bespoke disconnect listener.
The ring buffer
Section titled “The ring buffer”Forensics.attach(kernel, options?) gives every session a ring buffer sized:
Capacity = max(floor(WindowSeconds × RecordHz), 4) -- 200 frames at defaultsA Scheduler task runs record() every 1 / RecordHz seconds (10Hz by default) at Priority.Background, sampling every tracked player through an injectable Sampler. The default sampler reads the character’s HumanoidRootPart and Humanoid and produces a flat array:
[t, px, py, pz, lx, ly, lz, vx, vy, vz, state]— timestamp, root position (px,py,pz), the root’s CFrame.LookVector (lx,ly,lz), AssemblyLinearVelocity (vx,vy,vz), and Humanoid:GetState().Value. Every player, flagged or not, has WindowSeconds (default 20s) of this history sitting in memory at all times. A missing character or Humanoid simply produces no frame that tick — no error, no gap-filling.
Spelled out, the eleven indices are:
| Index | Field | Source |
|---|---|---|
| 1 | t |
Recorder’s Clock() at sample time, stamped into the frame after the sampler returns it |
| 2–4 | px, py, pz |
HumanoidRootPart.Position |
| 5–7 | lx, ly, lz |
HumanoidRootPart.CFrame.LookVector |
| 8–10 | vx, vy, vz |
HumanoidRootPart.AssemblyLinearVelocity |
| 11 | state |
Humanoid:GetState().Value — the numeric Enum.HumanoidStateType |
Flat arrays of numbers, not named tables: cheaper to allocate 10Hz-per-player forever, and it’s exactly the shape JSONEncode turns into a plain array with no key overhead. A custom Sampler can return any 11-number array it wants (a different rig, a vehicle seat, a non-Humanoid entity) as long as it keeps this layout, because frameAt() and replay() both index into it positionally.
Why the ring wraps instead of growing
Section titled “Why the ring wraps instead of growing”The ring is not a growing list that gets trimmed — it is a fixed-size array written circularly. Each tracked player’s state carries Frames (sized to Capacity up front via table.create), a WriteIndex, and a Count. Every sample does WriteIndex = WriteIndex % Capacity + 1 and writes there, so the ring wraps back to slot 1 once it fills, overwriting the oldest sample in place — no shifting, no per-sample allocation, no growth past Capacity for the life of the session. Count clamps at Capacity and tracks how many slots actually hold real data (so a player who just joined doesn’t get phantom pre-history).
Reading the ring back out in chronological order — which happens exactly once per player, the moment a flag freezes the window — walks backward from WriteIndex by Count slots and forward again: Start = WriteIndex - Count, then for each of the Count offsets, Index = (Start + Offset - 1) % Capacity + 1. That single linearization (orderedFrames) is the only place wraparound math happens; everywhere else (the active capture’s tail, frameAt(), replay()) just walks a normal oldest-first Lua array, because by the time a capture exists it has already been unwrapped once.
The trigger: freezing the window
Section titled “The trigger: freezing the window”Forensics subscribes to a configurable list of bus topics (Topics, default { "AntiExploit.MovementViolation" } — see Movement Monitor) whose handler signature is (player, ...). The moment one fires for a tracked player with no capture already in progress:
- The player’s current ring is copied out oldest-first into a new capture — this is the window that already existed before the flag.
- The capture opens a tail:
TailUntil = Now + TailSeconds(default 5s). - Everything after that keeps recording:
record()appends live frames directly onto the active capture’sFramesarray (not just the ring) untilNow >= TailUntil.
The tail exists because the moment of detection is rarely the moment worth watching. A speedhack often only becomes visually obvious a second or two after the flag — through a wall, off a ledge, rubber-banded and re-triggering. Without the tail, a capture would end exactly at the flag and never show what the violation actually accomplished.
Re-flags during the tail extend, they don’t fork
Section titled “Re-flags during the tail extend, they don’t fork”If the watched topic fires again for the same player while a capture’s tail is still open, flag() does not start a second capture. It appends a new entry to the existing capture’s Reasons list and pushes TailUntil out further: TailUntil = max(TailUntil, Now + TailSeconds) — a max, not an overwrite, so a re-flag can only extend the tail, never shorten one already running longer. Practically, this means a player who trips the Movement Monitor three times in quick succession — Teleport, then two Speed strikes as they keep running — produces one capture with three reasons, not three overlapping captures. A capture corresponds to an incident, however many individual violations it took to keep the tail alive.
Each Reasons entry is { Reason, At, Detail }: Reason is the topic name itself ("AntiExploit.MovementViolation", not the violation type), At is the timestamp flag() ran at, and Detail is whatever extra arguments the topic published — captured with table.pack(...) and truncated to at most 4 values (table.unpack(Detail, 1, min(Detail.n, 4))). For the Movement Monitor’s default publish shape (player, violationType, strikes), Detail is { violationType, strikes }. The cap exists because Reasons rides along in the JSON export — an attacker-controlled or pathologically large argument list should not be able to bloat a capture’s payload.
Leavers finalize immediately
Section titled “Leavers finalize immediately”A capture in progress is tied to the player’s session, not a standalone timer. If the player leaves before the tail completes, the session’s cleanup binding finalizes whatever has been recorded so far rather than waiting out TailUntil on a player who is no longer there to record. A leaver’s capture is simply shorter — window plus however much tail elapsed before they left — instead of being lost or held open forever.
Finalizing and delivery
Section titled “Finalizing and delivery”Finalizing a capture (tail complete or player left) stamps FinishedAt and StartedAt (the timestamp of the capture’s first frame, or the flag time if somehow empty), pushes it onto the in-memory Captures list, and evicts the oldest entry once the list exceeds MaxCaptures (default 8) — a bounded FIFO, not unlimited growth. Captures is a single list on the Forensics instance shared by every player, not one list per player: MaxCaptures bounds the total number of captures the whole server keeps in memory at once, so a server with many simultaneously-flagged players evicts old captures faster than one with few. It then publishes Forensics.Captured and hands the capture to Destination, which is one of two shapes:
- A function —
Destination(capture), run viatask.spawnso a slow or blocking destination never stalls the record sweep. { Url = string }— POSTsHttpService:JSONEncode(capture)toUrlasEnum.HttpContentType.ApplicationJson, also insidetask.spawn, and wrapped inpcall.
Both paths are fire-and-forget from the sweep’s point of view — delivery latency or failure never blocks recording the next player’s frame. If the HTTP POST throws (endpoint down, rate limited, bad URL), the pcall swallows it silently: the capture is not retried and not re-queued, but it was already pushed into Captures before delivery was attempted, so captures() still has it. Delivery failure loses the webhook, never the record.
Forensics vs. Rewind
Section titled “Forensics vs. Rewind”Rewind also keeps a rolling per-player ring buffer, and it’s worth being precise about why Forensics doesn’t just reuse it. Rewind’s buffer is deliberately minimal — position only, no orientation, a 1-second window by default — because its job is real-time lag-compensated hit validation: cheap enough to sample at 20Hz and query on every shot, and rotation-invariant because it’s tested against capsules. Forensics’ buffer is deliberately richer — position, look, velocity, humanoid state, a 20-second window by default — because its job is reconstructing a story for a human (or a web tool) to look at afterward: you need to see which way a player was facing and how fast they were moving, not just where their capsule was. Different windows, different fields, different consumers; both happen to be ring buffers because “rolling history, bounded memory” is the right shape for both problems.
Privacy and scope
Section titled “Privacy and scope”A capture holds exactly three kinds of data: recorded movement frames, the reasons that triggered it, and the player’s UserId/name. Nothing else — no chat, no inventory, no other players’ positions, no arbitrary session data. This is a deliberate scope boundary, not an oversight: Forensics is a movement flight recorder, and widening what it captures would turn an anti-exploit tool into a surveillance one.
Attach after Movement Monitor so the topic it publishes already exists, and wire a webhook destination:
local Root = game:GetService("ServerScriptService").ChloeKernelServerlocal Movement = require(Root.AntiExploit.Movement)local Forensics = require(Root.Forensics)
return function(kernel) Movement.attach(kernel)
local Flight = Forensics.attach(kernel, { Destination = { Url = "https://your-backend.example.com/forensics/ingest" }, MaxCaptures = 16, -- keep more in the in-memory ring as a local backstop })
-- Optional: react locally too, independent of the HTTP delivery kernel.Bus:subscribe("Forensics.Captured", function(_, player, capture) print(`Forensics: captured {#capture.Frames} frames for {capture.PlayerName}, reasons: {#capture.Reasons}`) end)
return Flightendexport() turns any capture (from captures() or the Forensics.Captured payload) into the same JSON your webhook receives, for local inspection or re-delivery:
local Json = Flight:export(Flight:captures()[1])Wiring in a custom detector
Section titled “Wiring in a custom detector”Forensics isn’t limited to watching the Movement Monitor. Any bus topic published as (player, reason, ...) qualifies — a manual moderator flag, a custom combat-anomaly check, anything. Add it to Topics and Forensics captures it the same way:
return function(kernel) local Flight = Forensics.attach(kernel, { Topics = { "AntiExploit.MovementViolation", "Moderation.ManualFlag" }, Destination = { Url = "https://your-backend.example.com/forensics/ingest" }, })
-- Elsewhere, a slash command or admin panel: kernel.Bus:publish("Moderation.ManualFlag", SuspectPlayer, "ReportedByPlayer", ReporterUserId)
return FlightendThat single publish freezes SuspectPlayer’s last 20 seconds of movement and opens a 5-second tail, exactly as if the Movement Monitor had fired — the manual report gets the same flight-recorder evidence an automated detection would.
Driving Forensics deterministically in a spec
Section titled “Driving Forensics deterministically in a spec”Because Clock, Sampler, and SkipLoop are all injectable, you can unit-test your own Destination policy (escalation rules, formatting, filtering) against Forensics without a running server or a real character — the same technique Forensics.spec.luau uses internally:
local Forensics = require(Root.Forensics)
local Now = 0local Position = Vector3.zerolocal Delivered = {}
local Flight = Forensics.attach(kernel, { RecordHz = 10, WindowSeconds = 1, -- capacity 10 TailSeconds = 0.5, SkipLoop = true, -- this test steps record() manually Clock = function() return Now end, Sampler = function() return { 0, Position.X, Position.Y, Position.Z, 0, 0, -1, 0, 0, 0, 8 } end, Destination = function(capture) table.insert(Delivered, capture) end,})
local function step(seconds) Now += seconds Flight:record()end
-- ...start a session for a fake player, publish the watched topic, call step()-- to advance time deterministically, then assert on `Delivered`.SkipLoop matters here specifically: without it, attach() would also schedule a real Scheduler task racing against your manual step() calls, and Now (driven only by your fake Clock) would drift out of sync with whatever the scheduler samples on the real clock.
Replaying a capture in Studio
Section titled “Replaying a capture in Studio”Forensics.replay(capture, options?) is a Studio tool, not a runtime feature — call it from a plugin or command-bar script against a capture you’ve loaded (from captures(), a stored JSON export, or a debug print). It builds a small ghost rig — a neon Shell part (2x3x1 studs, anchored, CanCollide/CanQuery both false so it never interferes with the actual game) and a Look “beak” part (0.4x0.4x1.6) offset in front of and slightly above the shell — parents it under options.Parent (default workspace), and steps it forward on RunService.Heartbeat.
Each Heartbeat, elapsed time accumulates as Elapsed += dt * TimeScale (TimeScale default 1, so replay runs at the recorded rate unless you slow it down or speed it up), and T = StartT + Elapsed maps to an absolute timestamp within the capture. Forensics.frameAt(Frames, T) interpolates the position and look vector at that instant, and the ghost’s CFrame is set with CFrame.lookAt(Position, Position + Facing) — where Facing normalizes the interpolated look vector, falling back to Vector3.zAxis if its magnitude is near zero (an exact-zero look vector can’t be normalized). The beak sits at Shell.CFrame * CFrame.new(0, 0.9, -1.1) — up and forward of the shell, marking which way it’s “looking.”
Reaching EndT destroys the ghost automatically, unless options.Loop is set, in which case elapsed time resets to zero and playback restarts from StartT. The shell’s color reflects severity at a glance: red (255, 70, 70) if the capture has more than two reasons, amber (255, 170, 60) otherwise — a fast visual triage across a queue of captures without reading the reason list first.
-- Studio command bar / plugin scriptlocal Root = game:GetService("ServerScriptService").ChloeKernelServerlocal Forensics = require(Root.Forensics)
local Capture = Flight:captures()[1] -- or json-decode a stored exportlocal Handle = Forensics.replay(Capture, { TimeScale = 0.5, Loop = true })
-- later, to stop early:-- Handle.Stop()Forensics.frameAt(frames, t) is the pure interpolation function underneath replay() and is exported on its own: position and look vector lerp between the two bracketing frames, velocity and humanoid state hold from the earlier frame (they’re not meaningfully interpolatable), and a timestamp outside the recorded range clamps to the first or last frame rather than extrapolating.
export()’s JSON shape
Section titled “export()’s JSON shape”export(capture) is HttpService:JSONEncode(capture) — the exact same object your { Url } destination POSTs. A capture, and therefore the JSON, is precisely:
{ "UserId": 123456, "PlayerName": "Suspect", "Reasons": [ { "Reason": "AntiExploit.MovementViolation", "At": 12.4, "Detail": ["Speed", 3] } ], "Frames": [ [12.1, 10.5, 4.2, -3.0, 0.1, 0.0, -0.99, 5.0, 0.0, 1.2, 8], [12.2, 10.9, 4.2, -2.8, 0.1, 0.0, -0.99, 5.1, 0.0, 1.1, 8] ], "Hz": 10, "FlaggedAt": 12.4, "StartedAt": 12.1, "FinishedAt": 17.4}Frames is an array of the exact [t, px, py, pz, lx, ly, lz, vx, vy, vz, state] tuples described above, oldest first. That is a concrete, verifiable claim: a web tool with this JSON has everything Forensics.replay() has — it can walk Frames in order, interpolate the same way frameAt() does, and reconstruct the run frame by frame without ever touching the Roblox server again. There’s nothing Roblox-specific left to decode: every field is a plain JSON number, and the same lerp-position/lerp-look/hold-velocity/hold-state rule frameAt() implements is a handful of lines in any language:
// Sketch of a web-side equivalent of Forensics.frameAt — same clamping and// lerp rules, no Roblox runtime required.function frameAt(frames, t) { if (frames.length === 0) return null; if (t <= frames[0][0]) return frames[0]; if (t >= frames.at(-1)[0]) return frames.at(-1); let lo = 0, hi = frames.length - 1; while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (frames[mid][0] <= t) lo = mid; else hi = mid; } const [a, b] = [frames[lo], frames[hi]]; const alpha = (t - a[0]) / (b[0] - a[0]); const out = [t]; for (let i = 1; i <= 6; i++) out.push(a[i] + (b[i] - a[i]) * alpha); // position + look for (let i = 7; i <= 10; i++) out.push(a[i]); // velocity + state hold return out;}A moderation dashboard built on export()’s JSON is exactly this function plus a timeline scrubber and a 3D viewport — no Roblox dependency, no re-derivation of the capture format from the source.
Configuration
Section titled “Configuration”Forensics.attach(kernel, options?) merges your table over these defaults:
| Option | Default | What it controls |
|---|---|---|
RecordHz |
10 |
Samples per second, per tracked player. Also sets the scheduler cadence (Priority.Background). |
WindowSeconds |
20 |
Ring length kept before a flag arrives. Capacity = max(floor(WindowSeconds × RecordHz), 4). |
TailSeconds |
5 |
Recording continued after a flag (or after the latest re-flag) before finalizing. |
Topics |
{ "AntiExploit.MovementViolation" } |
Bus topics that trigger a capture. Handler signature (player, ...); everything after player becomes Detail (capped at 4 values) on the Reasons entry. |
Destination |
nil |
function(capture) or { Url: string }. nil means captures land only in the in-memory ring. |
MaxCaptures |
8 |
In-memory captures kept via captures(). Oldest evicted first past this count. |
Clock |
os.clock |
Injectable for deterministic specs. |
Sampler |
reads HumanoidRootPart/Humanoid |
(player) -> frame? — injectable frame source, e.g. for specs or a custom kinematics model. |
Http |
nil |
Injectable HttpService double for specs. nil uses the real service. |
SkipLoop |
false |
When true, the scheduler task never starts — specs call record() manually instead. |
| Member | Description |
|---|---|
Forensics.attach(kernel, options?) → Forensics |
Provisions per-session rings, subscribes Topics, and starts the record loop unless SkipLoop. |
flight:record() |
Samples every tracked player once; appends to active captures whose tail has not yet expired, and finalizes those whose tail just did. Called automatically unless SkipLoop. |
flight:flag(player, reason, ...) |
Freezes the player’s current window into a new capture, or — if one is already active — appends reason and extends the tail. Normally called by the Topics subscription, not directly. |
flight:captures() → { capture } |
The last MaxCaptures finalized captures, oldest first. |
flight:export(capture) → string |
HttpService:JSONEncode(capture) — the exact JSON described above. |
Forensics.replay(capture, options?) → { Stop: () -> () } |
Studio-only: steps an interpolated ghost rig through capture.Frames on Heartbeat. options: { Parent?: Instance, TimeScale?: number, Loop?: boolean }. |
Forensics.frameAt(frames, t) → frame? |
Pure. Interpolates position/look at t between bracketing frames; velocity and state hold from the earlier frame. Clamps outside the recorded range. nil for an empty frames. |
flight:detach() |
Cancels the record loop, disconnects topic subscriptions, and clears tracked/active state. |
A capture’s shape: { UserId, PlayerName, Reasons: { {Reason, At, Detail} }, Frames: { frame }, Hz, FlaggedAt, StartedAt, FinishedAt }.
Lifecycle: attach() and detach()
Section titled “Lifecycle: attach() and detach()”attach() does four things: registers an onSession handler that provisions a ring for each joining player and binds session-cleanup finalization for leavers, subscribes every topic in Topics, and — unless SkipLoop — schedules record() on the Scheduler at Priority.Background. detach() unwinds the scheduler handle and the topic subscriptions, then clears Tracked and ActiveCaptures.
Hooks & bus topics
Section titled “Hooks & bus topics”| Kind | Name | Payload | Notes |
|---|---|---|---|
| Bus (consumed) | AntiExploit.MovementViolation (configurable via Topics) |
player, reason, ... |
Default watched topic, published by Movement Monitor as player, violationType, strikes. Any topic matching this shape works. |
| Bus (published) | Forensics.Captured |
player, capture |
Fired once per finalized capture, right before Destination is invoked. |
Forensics never publishes anything on the topics it watches — it only reads them. Wiring in a different detector (a custom cheat check, a manual moderator flag) just means adding its topic to Topics, as long as the handler’s first argument after player is a string reason.
Design notes
Section titled “Design notes”See also
Section titled “See also”Movement Monitor is the default source of the flags Forensics captures. Rewind keeps its own per-player ring buffer for a different job — see Forensics vs. Rewind above for how the two compare. For the bus and hook mechanics underneath both Topics subscription and Forensics.Captured, see Bus.