Skip to content

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.

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.

Each session’s ring holds:

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

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

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.

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:

  1. The player’s ring is copied out oldest-first into a new capture. This is the window that existed before the flag.
  2. The capture opens a tail: TailUntil = Now + TailSeconds (5 s by default).
  3. 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.

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.

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.

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 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 through task.spawn, so a slow destination never stalls the record job.
  • { Url = string }: a spawned thread POSTs HttpService:JSONEncode(capture) to Url as Enum.HttpContentType.ApplicationJson, inside a pcall.

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.

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.

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:

src/Server/Game/Services/EvidenceService.luau
--!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()
end
end
return EvidenceService

A 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])

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.

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 = 0
local Position = Vector3.zero
local 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()
end

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

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 plugin
local ServerScriptService = game:GetService("ServerScriptService")
local Forensics = require(ServerScriptService.ChloeKernelServer.Forensics)
local Capture = Recorder:captures()[1] -- or a decoded export
local Handle = Forensics.replay(Capture, { TimeScale = 0.5, Loop = true })
-- Handle.Stop() ends the replay early and destroys the ghost

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

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.

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

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.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.