Forensics
Keeps a rolling window of every tracked player's movement and freezes it into a replayable capture when a watched Bus topic flags that player.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Forensics - Since
- v0.6.0
- Source
- Forensics.luau
Forensics is the anti-exploit flight recorder. A violation hook says a threshold was crossed; it does not show the
movement that led there. Forensics keeps every tracked player’s recent kinematics in a ring buffer, and when a watched Bus
topic flags a player it freezes that window, keeps recording for a tail, and delivers the whole capture as data. The
buffer runs for every tracked player whether or not they are ever flagged, because evidence has to exist before the
incident. The default trigger is the Movement Monitor’s
AntiExploit.MovementViolation. The game’s policy can ban on the same flags through
Consequences, and Rewind keeps a different, smaller history for hit
validation.
Mental model
Section titled “Mental model”Who gets tracked
Section titled “Who gets tracked”Forensics follows the kernel’s session lifecycle through kernel:onSession (see Sessions). When a
session starts, the player gets a ring. When the session’s cleanup runs, Forensics finalizes any capture in progress for
that player and drops the ring. A leaver’s capture finalizes on the same teardown path as every other per-session
resource, with no separate disconnect listener.
The record job is created when the first session is tracked and cancelled when the last one ends, so an empty server has
no Forensics wakeups. SkipLoop = true never starts it.
The ring buffer
Section titled “The ring buffer”Each session’s ring holds:
Capacity = max(floor(WindowSeconds * RecordHz), 4) -- 200 frames at defaultsA Scheduler job runs record() every 1 / RecordHz seconds (10 Hz by default) at
Priority.Background and samples every tracked player through the Sampler. The default sampler reads the character’s
HumanoidRootPart and Humanoid and returns a flat array:
[t, px, py, pz, lx, ly, lz, vx, vy, vz, state]| Index | Field | Source |
|---|---|---|
| 1 | t |
The recorder’s Clock() at sample time, written into the frame after the sampler returns it |
| 2 to 4 | px, py, pz |
HumanoidRootPart.Position |
| 5 to 7 | lx, ly, lz |
HumanoidRootPart.CFrame.LookVector |
| 8 to 10 | vx, vy, vz |
HumanoidRootPart.AssemblyLinearVelocity |
| 11 | state |
Humanoid:GetState().Value, the numeric Enum.HumanoidStateType |
Every tracked player has WindowSeconds (20 s by default) of this history in memory at all times. A missing character or
Humanoid produces no frame that tick, with no error and no gap filling.
Frames are flat arrays of numbers because they are allocated at 10 Hz per player for the whole session, and
JSONEncode turns them into plain arrays with no key overhead. A custom Sampler can read anything (a vehicle, a rig
without a Humanoid) as long as it returns the same 11-number layout, because frameAt() and replay() read it by
position.
Why the ring wraps instead of growing
Section titled “Why the ring wraps instead of growing”Each ring is a fixed array written circularly: Frames (created at Capacity with table.create), a WriteIndex and a
Count. Every sample does WriteIndex = WriteIndex % Capacity + 1 and writes there, overwriting the oldest frame once
the ring is full. There is no shifting and no growth past Capacity. Count stops at Capacity and records how many
slots hold real data, so a player who just joined has no phantom history.
The ring is read in order exactly once per incident, when a flag freezes it. orderedFrames starts at
WriteIndex - Count and walks Count slots forward with (Start + Offset - 1) % Capacity + 1. That is the only place
wraparound math happens. Captures, frameAt() and replay() all work on a plain oldest-first array.
The trigger: freezing the window
Section titled “The trigger: freezing the window”Forensics subscribes to every topic in Topics (default { "AntiExploit.MovementViolation" }), each published as
(player, ...). When one fires for a tracked player who has no capture in progress:
- The player’s ring is copied out oldest-first into a new capture. This is the window that existed before the flag.
- The capture opens a tail:
TailUntil = Now + TailSeconds(5 s by default). - Until the tail ends,
record()appends each new frame for that player to the capture as well as to the ring.
The tail exists because the moment of detection is rarely the moment worth watching. A speed hack often becomes obvious a second or two after the flag, through a wall or off a ledge, rubberbanded and flagged again. A capture that ended at the flag would never show what the violation achieved. A topic that fires for a player with no ring (not tracked) is ignored.
Re-flags extend the tail
Section titled “Re-flags extend the tail”A watched topic that fires again while the player’s tail is open does not start a second capture. It appends an entry to
the capture’s Reasons and extends the tail with TailUntil = max(TailUntil, Now + TailSeconds), so a re-flag can only
lengthen a tail. A player who trips the Movement Monitor three times in quick succession produces one capture with three
reasons. A capture is one incident, however many violations kept its tail open.
Each Reasons entry is { Reason, At, Detail }. Reason is the topic name ("AntiExploit.MovementViolation"), At
is the clock time of the flag, and Detail holds the arguments the topic published after the player, at most four of
them. For the Movement Monitor’s (player, violationType, strikes), Detail is { violationType, strikes }. The cap
keeps a topic with a long or hostile argument list from bloating the exported JSON.
Tails end on time
Section titled “Tails end on time”record() checks every active capture on every run, whether or not it could sample a frame for that player. A tail
therefore ends at TailUntil even while the flagged player has no character (dead, removed by the game, waiting to
respawn), and the capture holds the frames that exist.
Leavers finalize immediately
Section titled “Leavers finalize immediately”A capture in progress belongs to the player’s session. When the player leaves before the tail ends, the session cleanup
finalizes what has been recorded instead of waiting out TailUntil. The capture is shorter (the window plus the part of
the tail that elapsed) and it is never lost or held open.
Finalizing and delivery
Section titled “Finalizing and delivery”Finalizing a capture (tail over or player gone) stamps FinishedAt and StartedAt (the first frame’s time, or
FlaggedAt when the capture has no frames) and pushes it onto the in-memory Captures list. The list is one per
Forensics instance, shared by every player, and holds at most MaxCaptures (8 by default); the oldest capture is
evicted when a new one pushes past the cap. Forensics then publishes Forensics.Captured(player, capture) and hands the
capture to Destination, which has one of two shapes:
- A function:
Destination(capture)runs throughtask.spawn, so a slow destination never stalls the record job. { Url = string }: a spawned thread POSTsHttpService:JSONEncode(capture)toUrlasEnum.HttpContentType.ApplicationJson, inside apcall.
Delivery never blocks recording. A failed POST (endpoint down, rate limited, bad URL, HTTP disabled) is swallowed: the
capture is not retried or queued again. It was already pushed into Captures before delivery started, so captures()
still returns it until it is evicted.
Forensics and Rewind
Section titled “Forensics and Rewind”Rewind also keeps a rolling per-player ring, and the two are separate on purpose. Rewind’s ring is minimal: positions only, a 1 s window at 20 Hz, queried on every shot and tested against rotation-invariant capsules. Forensics’ ring is richer: position, look direction, velocity and humanoid state over 20 s at 10 Hz, because its reader is a person or a web tool reconstructing what happened afterward. Both use a ring because both need rolling history in bounded memory.
Privacy and scope
Section titled “Privacy and scope”A capture holds recorded movement frames, the reasons that triggered it, and the player’s UserId and name. It holds no
chat, inventory, other players’ positions or session data. The reasons carry whatever a watched topic published after
the player, up to four values, so a custom topic should publish ids and short codes, never personal data. Forensics is a
movement recorder, and keeping its scope that narrow keeps it an anti-exploit tool.
Attach Forensics from the service that owns anti-cheat evidence and give it a destination:
--!strict-- EvidenceService: records movement around anti-exploit flags and ships each capture to the moderation backend.
local ReplicatedStorage = game:GetService("ReplicatedStorage")local ServerScriptService = game:GetService("ServerScriptService")
local Forensics = require(ServerScriptService.ChloeKernelServer.Forensics)local Logger = require(ReplicatedStorage.ChloeKernel.Logger)
local Log = Logger.scope("EvidenceService")
local EvidenceService = { Name = "EvidenceService",}
function EvidenceService.init(self: any, kernel: any) self.Kernel = kernel self.Recorder = Forensics.attach(kernel, { Destination = { Url = "https://moderation.example.com/forensics" }, -- POSTs the capture JSON MaxCaptures = 16, -- in-memory backstop while the endpoint is down })end
function EvidenceService.start(self: any) self.Connection = self.Kernel.Bus:subscribe("Forensics.Captured", function(_topic, player, capture) Log.info(`captured {#capture.Frames} frames for {player.Name}, {#capture.Reasons} reason(s)`) end)end
function EvidenceService.stop(self: any) if self.Connection then self.Connection:disconnect() endend
return EvidenceServiceA Bus topic needs no registration, so the order in which the Movement Monitor and Forensics attach does not matter.
export() turns any capture (from captures() or the Forensics.Captured payload) into the JSON the webhook receives,
for local inspection or a second delivery:
local Json = self.Recorder:export(self.Recorder:captures()[1])Wiring in a custom detector
Section titled “Wiring in a custom detector”Any Bus topic published as (player, reason, ...) can trigger a capture: a moderator flag, a custom combat check, a
player report. Add it to Topics:
local Recorder = Forensics.attach(kernel, { Topics = { "AntiExploit.MovementViolation", "Moderation.ManualFlag" }, Destination = { Url = "https://moderation.example.com/forensics" },})
-- Elsewhere, a moderator command or a report handler:kernel.Bus:publish("Moderation.ManualFlag", Suspect, "ReportedByPlayer", Reporter.UserId)That publish freezes the suspect’s last 20 s of movement and opens a 5 s tail, the same evidence an automatic detection gets.
Driving Forensics in a spec
Section titled “Driving Forensics in a spec”Clock, Sampler and SkipLoop are all configurable, so a game can test its own Destination policy (filtering,
formatting, escalation) without a server or a character. Forensics.spec.luau uses the same technique:
local Now = 0local Position = Vector3.zerolocal Delivered = {}
local Recorder = Forensics.attach(Kernel, { RecordHz = 10, WindowSeconds = 1, -- capacity 10 TailSeconds = 0.5, SkipLoop = true, -- the test calls record() itself 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: number) Now += seconds Recorder:record()endStart a session for a fake player, publish the watched topic, call step() to advance time, then assert on
Delivered. SkipLoop matters here: without it the attach would also start a real Scheduler job once a session is
tracked, and that job would call record() against the fake clock on the real schedule.
Replaying a capture in Studio
Section titled “Replaying a capture in Studio”Forensics.replay(capture, options?) is a Studio tool. Call it from a plugin or the command bar with a capture from
captures(), a decoded JSON export or a debug print. It builds a ghost: a neon Shell part (2 by 3 by 1 studs,
anchored, CanCollide and CanQuery off, so it never touches the game) and a Look part (0.4 by 0.4 by 1.6) that marks
the facing. The ghost goes under options.Parent (default workspace) and steps on RunService.Heartbeat.
Each Heartbeat adds dt * TimeScale to the elapsed time (TimeScale defaults to 1, the recorded speed), maps it to
T = StartT + Elapsed, and places the shell with CFrame.lookAt(Position, Position + Facing) from
Forensics.frameAt(Frames, T). Facing is the interpolated look vector normalized, or Vector3.zAxis when it is too
short to normalize. The look part sits at Shell.CFrame * CFrame.new(0, 0.9, -1.1). At the last frame the ghost is
destroyed, unless options.Loop restarts it. The shell is red (255, 70, 70) when the capture has more than two
reasons and amber (255, 170, 60) otherwise, which sorts a queue of captures at a glance.
-- Studio command bar or pluginlocal ServerScriptService = game:GetService("ServerScriptService")local Forensics = require(ServerScriptService.ChloeKernelServer.Forensics)
local Capture = Recorder:captures()[1] -- or a decoded exportlocal Handle = Forensics.replay(Capture, { TimeScale = 0.5, Loop = true })-- Handle.Stop() ends the replay early and destroys the ghostForensics.frameAt(frames, t) is the pure interpolation under replay(). Position and look interpolate linearly between
the two bracketing frames, velocity and humanoid state hold from the earlier frame, and a time outside the recorded range
clamps to the first or last frame.
The export’s JSON shape
Section titled “The export’s JSON shape”export(capture) is HttpService:JSONEncode(capture), the same text a { Url } destination POSTs:
{ "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 are the 11-number tuples described above, oldest first. A web tool holding this JSON has everything replay()
has, and the interpolation rule ports in a few lines:
// Web-side equivalent of Forensics.frameAt: same clamping and interpolation rules.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 span = b[0] - a[0]; const alpha = span > 0 ? (t - a[0]) / span : 0; const out = [t]; for (let i = 1; i <= 6; i++) out.push(a[i] + (b[i] - a[i]) * alpha); // position and look for (let i = 7; i <= 10; i++) out.push(a[i]); // velocity and state hold return out;}A moderation dashboard is this function plus a timeline scrubber and a 3D view.
Configuration
Section titled “Configuration”Forensics.attach(kernel, options?) reads these options:
| Option | Default | What it controls |
|---|---|---|
RecordHz |
10 |
Samples per second per tracked player, and the record job’s cadence (Priority.Background). |
WindowSeconds |
20 |
History kept before a flag. Capacity = max(floor(WindowSeconds * RecordHz), 4). |
TailSeconds |
5 |
Recording kept after a flag, or after the latest re-flag, before the capture finalizes. |
Topics |
{ "AntiExploit.MovementViolation" } |
Bus topics that trigger a capture, published as (player, ...). Up to four arguments after the player become Detail. |
Destination |
nil |
function(capture) or { Url = string }. With nil, captures stay in memory only. |
MaxCaptures |
8 |
Finalized captures kept for captures(), across all players. The oldest is evicted first. |
Clock |
os.clock |
Time source for frames and capture stamps. |
Sampler |
reads HumanoidRootPart and Humanoid |
(player) -> frame?. Replace it for specs or for a different rig. |
Http |
nil |
An HttpService double for specs. nil uses the real service. |
SkipLoop |
false |
true never starts the record job; the caller runs record(). |
| Member | Description |
|---|---|
Forensics.attach(kernel, options?) |
Returns the recorder. Provisions a ring per session, subscribes Topics, and records only while a session is tracked unless SkipLoop. |
recorder:record() |
Samples every tracked player once, appends frames to active captures, and finalizes captures whose tail has ended, with or without a new frame. Called by the record job unless SkipLoop. |
recorder:flag(player, reason, ...) |
Freezes the player’s window into a new capture, or appends reason and extends the tail of the active one. The Topics subscriptions call it; it ignores untracked players. |
recorder:captures() |
The finalized captures still in memory, oldest first. |
recorder:export(capture) |
HttpService:JSONEncode(capture). |
recorder:detach() |
Cancels the record job, disconnects the topic subscriptions, and clears tracked and active state. |
Forensics.replay(capture, options?) |
Studio tool. Steps a ghost through capture.Frames on Heartbeat. options is { Parent?, TimeScale?, Loop? }. Returns { Stop }. Errors on a capture with no frames. |
Forensics.frameAt(frames, t) |
Pure. Interpolates position and look at t, holds velocity and state from the earlier frame, clamps outside the range. Nil for an empty list. |
A capture is { UserId, PlayerName, Reasons, Frames, Hz, FlaggedAt, StartedAt, FinishedAt }, with each reason
{ Reason, At, Detail }.
Hooks & bus topics
Section titled “Hooks & bus topics”| Kind | Name | Payload | Notes |
|---|---|---|---|
| Bus (consumed) | AntiExploit.MovementViolation (or any topic in Topics) |
player, ... |
The default trigger, published by the Movement Monitor as player, violationType, strikes. |
| Bus (published) | Forensics.Captured |
player, capture |
Once per finalized capture, before Destination runs. |
Forensics only reads the topics it watches and never publishes on them. See Bus for subscription rules.