Skip to content

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.

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.

Forensics.attach(kernel, options?) gives every session a ring buffer sized:

Capacity = max(floor(WindowSeconds × RecordHz), 4) -- 200 frames at defaults

A 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.

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.

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:

  1. The player’s current ring is copied out oldest-first into a new capture — this is the window that already existed before the flag.
  2. The capture opens a tail: TailUntil = Now + TailSeconds (default 5s).
  3. Everything after that keeps recording: record() appends live frames directly onto the active capture’s Frames array (not just the ring) until Now >= 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.

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 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 functionDestination(capture), run via task.spawn so a slow or blocking destination never stalls the record sweep.
  • { Url = string } — POSTs HttpService:JSONEncode(capture) to Url as Enum.HttpContentType.ApplicationJson, also inside task.spawn, and wrapped in pcall.

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.

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.

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").ChloeKernelServer
local 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 Flight
end

export() 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])

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 Flight
end

That 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 = 0
local Position = Vector3.zero
local 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.

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 script
local Root = game:GetService("ServerScriptService").ChloeKernelServer
local Forensics = require(Root.Forensics)
local Capture = Flight:captures()[1] -- or json-decode a stored export
local 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(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.

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 }.

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.

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.

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.