Skip to content

Movement Monitor

Samples every tracked character on the server, flags speed and teleport violations with pure verdict math, and snaps repeat offenders back to their last clean position.

Realm
Server
Require
ServerScriptService.ChloeKernelServer.AntiExploit.Movement
Since
v0.1.0
Source
AntiExploit/Movement.luau

The Movement Monitor is the server’s check on character motion. It samples each tracked character’s root every 0.2 s, runs the sample through Movement.evaluate (a pure function), and reports speed and teleport violations through a hook and a Bus topic. Detection is the framework’s job and the penalty is the game’s. The monitor counts strikes and corrects position; it never kicks or bans. Consequences turns strikes into bans, Forensics records the movement around each flag, and Rewind listens to the same AntiExploit.Forgive topic.

The client keeps network ownership of its character. The monitor never calls SetNetworkOwner, so movement feels like stock Roblox, and a correction is a plain CFrame write that happens only after consecutive violations.

Movement.attach(kernel, config?) registers a session handler. The sweep is one Scheduler job, every(IntervalSeconds) at Priority.Low, created when the first session is tracked and cancelled when the last one ends. An empty server has no Movement wakeups.

Each sweep finds every tracked player’s Humanoid (FindFirstChildOfClass) and reads the root through Humanoid.RootPart instead of searching for a part named HumanoidRootPart. A player with no Humanoid, no root or Health <= 0 loses their baseline and is skipped. A character that differs from the one sampled last time also loses its baseline. The check compares identities, so a respawn is caught even when the sweep runs before the new character’s deferred CharacterAdded handlers.

A sample with a finite position becomes the new baseline without being evaluated when any of these hold:

  • There is no baseline yet (first sample, respawn, death, missing root).
  • The player was forgiven (see Forgiving server moves).
  • The player holds a standing exemption (see Standing exemption).
  • The player is seated (Humanoid.SeatPart ~= nil). Vehicles legitimately exceed walk speed.

A new baseline also clears the player’s strike count.

Speed is measured from the last accepted sample

Section titled “Speed is measured from the last accepted sample”

The clock belongs to the baseline. DeltaTime is the time since the last accepted sample, and it moves only when a sample is accepted, taken as a new baseline or rubberbanded. A flagged sample keeps both the baseline position and its clock, so the next sample is measured over the whole window since the baseline.

This protects honest players. If a flagged sample advanced the clock but not the position, the next reading would divide two intervals of distance by one interval of time, and a single replication hitch would double it. The spec pins the case: a runner at 16 studs/s whose third sample arrives short (8.0 instead of 9.6) is flagged once on the next sample, at 24 studs/s. The sample after that is measured from 8.0 over 0.4 s, reads 20 studs/s and clears the strike.

The teleport check runs first and uses full 3D displacement:

TeleportThreshold = TeleportStuds * max(1, DeltaTime / IntervalSeconds)

With defaults that is 80 studs per 0.2 s sweep, a sustained 400 studs/s. The threshold prorates up when the window stretches (a server hitch, a missed sweep, a flagged sample in between) and never prorates down. Without the proration a player running through two missed sweeps would cover the extra ground and flag as a teleporter.

The speed check uses horizontal displacement only:

HorizontalSpeed = |delta(X, Z)| / max(DeltaTime, 1e-6)
Violation when HorizontalSpeed > TrustedWalkSpeed * SpeedTolerance

Vertical motion never flags: falling and jumping are physics. The 1e-6 floor keeps a zero delta time from dividing by zero. SpeedTolerance defaults to 1.4 and absorbs momentum, slopes and replication jitter. At WalkSpeed = 16 the limit is 22.4 studs/s, so 21 studs/s passes and 60 studs/s flags.

A non-finite position (the new one or the baseline) or a non-finite delta time returns the verdict "Invalid", with HorizontalSpeed and Displacement both math.huge. An exploiter who writes NaN or infinity into their root takes a strike on every sample. A non-finite position never becomes a baseline, even while the player is forgiven, exempt or seated, and never clears a strike. The rubberband for an "Invalid" verdict writes CFrame.new(LastValidPosition) and drops the rotation, which may be non-finite as well.

A player standing on a moving part covers more ground than their walk speed. When the first verdict is "Speed", the monitor casts a ray BeltProbeStuds (12) straight down from the root and reads the part it hits:

  • The ray sets RespectCanCollide = true and excludes the character, so a non-colliding zone or trigger volume between the root and the platform never hides it. The monitor keeps one RaycastParams and reassigns its filter only when it probes a different character.
  • An anchored part counts. This covers conveyors that set AssemblyLinearVelocity.
  • An unanchored part counts only while the server simulates it, meaning GetNetworkOwner() returns nil. Constraint elevators and server-driven boats qualify. A client-owned platform grants nothing, because its owner could report any velocity. A part welded to an anchored one makes GetNetworkOwner throw and also grants nothing.
  • The part’s AssemblyLinearVelocity must be finite and at least BeltMinSpeed (4 studs/s).

The platform velocity is subtracted from the measured horizontal velocity and the sample is evaluated again. The teleport check always runs on the real world displacement before the platform credit applies, so a platform cannot hide a teleport. In the spec, a rider who moved 8 studs in 0.2 s on a 24 studs/s platform passes, 30 studs in the same window still flags "Speed", and 500 studs flags "Teleport" even under a claimed 2,500 studs/s platform.

Humanoid.WalkSpeed is client-owned and forgeable. An exploiter can set it to 1e9 or NaN to make any speed look legal. The monitor picks the trusted speed in three steps:

  1. If the config provides GetTrustedWalkSpeed(sample) and it returns a number, that number is used and the replicated WalkSpeed is never read. This is the strong option: report the speed the server granted (sprint, hastes, slows).
  2. Otherwise the replicated WalkSpeed is used.
  3. From either source, NaN, math.huge and negative values become MaxTrustedWalkSpeed, and the result is capped: TrustedWalkSpeed = math.min(Reported, MaxTrustedWalkSpeed).

With defaults (MaxTrustedWalkSpeed = 100, SpeedTolerance = 1.4), the fastest horizontal speed a forged WalkSpeed can make legal is 140 studs/s.

Each player carries a strike count. On a violation:

  1. If the previous strike is more than StrikeDecaySeconds (10 s) old, the count resets first.
  2. The count goes up by one. The AntiExploit.MovementViolation hook fires with the full context, then the Bus topic of the same name publishes.
  3. If Strikes >= RubberbandStrikes (default 2) and a last valid position exists, the root snaps back: Root.CFrame = Root.CFrame.Rotation + LastValidPosition. Only the position rewinds, so the character keeps its facing. The baseline becomes the last valid position with a fresh clock, so the correction is never measured as a teleport.

A clean sample clears the count, and so does a new baseline. A correction therefore needs consecutive violations: one bad sample between clean ones costs a strike and nothing visible. StrikeDecaySeconds is a backstop for a sweep that stalled for more than 10 s between two strikes.

Flagged positions never become the baseline

Section titled “Flagged positions never become the baseline”

The monitor keeps two positions per player. LastPosition is the comparison anchor and LastValidPosition is the rubberband target. Both move only when a sample passes. A flagged sample moves neither.

This closes a laundering hole. If a flagged position became the next baseline, an exploiter could teleport once, take one strike and stand at the destination with a clean baseline. Instead every sample keeps measuring from the last honest position, and the exploiter keeps striking until the monitor snaps them back to it.

RubberbandStrikes = math.huge is log-only mode. Strikes, the hook and the Bus topic still fire, and the monitor never writes a CFrame.

Use it under Server Authority (Workspace.AuthorityMode = Server). In that mode the engine simulates characters from client input and rolls clients back to server state, and a monitor correction would fight the rollback. Game scripts cannot read AuthorityMode, so the monitor cannot detect the mode itself. Attach it with RubberbandStrikes = math.huge, or do not attach it. Rewind is unaffected.

Attach the monitor from the game service that owns the anti-cheat policy, and write the policy as a hook handler:

src/Server/Game/Services/AntiCheatService.luau
--!strict
-- AntiCheatService: movement checks and what a strike costs.
local ServerScriptService = game:GetService("ServerScriptService")
local Consequences = require(ServerScriptService.ChloeKernelServer.AntiExploit.Consequences)
local Movement = require(ServerScriptService.ChloeKernelServer.AntiExploit.Movement)
local BanStrikes = 5
local AntiCheatService = {
Name = "AntiCheatService",
}
function AntiCheatService.init(self: any, kernel: any)
self.Kernel = kernel
self.Monitor = Movement.attach(kernel) -- 0.2 s sweep, 1.4x speed tolerance, 80-stud teleports
self.Bans = Consequences.attach(kernel) -- 1 hour, then 1 day, then permanent
end
function AntiCheatService.start(self: any)
self.StopPolicy = self.Kernel.Hooks:on("AntiExploit.MovementViolation", function(context)
-- context: Player, Session, Type ("Speed", "Teleport" or "Invalid"), HorizontalSpeed, Displacement, Strikes
if context.Strikes >= BanStrikes then
self.Bans:ban(context.Player, {
DisplayReason = "Banned for exploiting.",
Violation = context.Type,
Strikes = context.Strikes,
})
end
return nil
end)
end
function AntiCheatService.stop(self: any)
if self.StopPolicy then
self.StopPolicy()
end
self.Monitor:detach()
self.Bans:detach()
end
return AntiCheatService

Add the service to src/Server/Game/Registry.luau. The monitor rubberbands at 2 strikes whatever the handler does; the handler adds the escalation. Because a clean sample clears the count, Strikes >= 5 means five violating samples in a row, about one second of continuous violation. ban never yields, so it is safe to call inside the hook.

Every server-initiated move (a teleporter pad, a blink, a knockback, a cutscene reposition) must announce itself, or the monitor measures it as a violation. The complete path is Movement.teleport:

Movement.teleport(kernel, player, Destination) -- streams the destination in, forgives, then pivots

Movement.teleport(kernel, player, cframe, options?) returns at once and does the work on its own thread. With StreamingEnabled the destination may not be loaded on the player’s client, and a character pivoted into unloaded space falls through it. The helper first calls player:RequestStreamAroundAsync(cframe.Position, StreamTimeout) (default 5 s) inside a pcall. Then, if the same character is still in the world, it publishes AntiExploit.Forgive and calls Character:PivotTo(cframe). A failed stream request still moves the character. StreamTimeout = 0 skips the request, because the engine reads a zero timeout as no limit. A player with no character is left alone. See StreamingEnabled.

For a move you write yourself, publish the Forgive first:

kernel.Bus:publish("AntiExploit.Forgive", player) -- or Monitor:forgive(player)
Character:PivotTo(Destination)

A Forgive pardons the next sample: the sweep takes it as the new baseline without evaluating it. ForgiveSeconds (default 0) widens the pardon to every sample in the next N seconds, for server moves that span several sweeps such as a scripted knockback arc. Rewind resets the player’s position history on the same topic and Hazards does not count the move as a crossing, so one publish covers all three. TeamKit spawn relocation and MoveKit abilities with Forgive = true publish it for you.

Forgive is for one-shot repositioning. A server-sanctioned free mover that stays fast for a while without a Humanoid.SeatPart (a broom ride, a seatless vehicle, a scripted launch sequence) takes a standing exemption:

kernel.Bus:publish("AntiExploit.Exempt", player, true) -- the ride begins
kernel.Bus:publish("AntiExploit.Exempt", player, false) -- the ride ends and checking resumes

While the exemption is set, every sample with a finite position becomes a new baseline without evaluation. The difference from Forgive matters for Rewind: each Forgive also empties the player’s Rewind history, so publishing Forgive every sweep to fake an exemption would reject every shot the rider fires with NoShooterHistory. Exempt never touches Rewind. An exemption published before the player’s session is tracked is ignored, and one that is set survives respawns until it is cleared.

A game that changes movement speed on the server (sprint, hastes, slows) should report the speed it granted instead of trusting replication:

Movement.attach(kernel, {
GetTrustedWalkSpeed = function(sample)
-- sample.Session is the player's kernel session
return sample.Session and sample.Session.Data.GrantedSpeed or 16
end,
})

The returned value is still sanitized and capped at MaxTrustedWalkSpeed.

Movement.attach(kernel, config?) merges the table over these defaults:

Option Default What it controls
IntervalSeconds 0.2 Sweep cadence, and the nominal interval the teleport threshold prorates against.
ForgiveSeconds 0 How long a Forgive keeps re-baselining samples. 0 pardons the next sample only.
BeltProbeStuds 12 Length of the downward platform probe.
BeltMinSpeed 4 Platform speed in studs/s below which no credit is given.
SpeedTolerance 1.4 Multiplier over the trusted walk speed before a "Speed" verdict.
TeleportStuds 80 3D displacement per nominal interval that makes a "Teleport" verdict.
StrikeDecaySeconds 10 A strike older than this resets the count before the next one is added.
RubberbandStrikes 2 Strike count at which the root snaps back. math.huge is log-only mode.
MaxTrustedWalkSpeed 100 Cap on any reported walk speed, and the value a non-finite or negative one becomes.
GetTrustedWalkSpeed nil (sample) -> number?. A returned number replaces the replicated WalkSpeed.
Member Description
Movement.attach(kernel, config?) Returns the monitor. Defines the fail-open hook point AntiExploit.MovementViolation, subscribes AntiExploit.Forgive and AntiExploit.Exempt, and tracks sessions. The sweep runs only while a session is tracked.
monitor:forgive(player) Pardons the player’s next sample, or every sample for ForgiveSeconds. The same as publishing AntiExploit.Forgive. Does nothing for an untracked player.
monitor:detach() Cancels the sweep, disconnects the session handler and both Bus subscriptions, and clears tracking.
Movement.teleport(kernel, player, cframe, options?) Server move helper. Streams the destination in (options.StreamTimeout, default 5 s, 0 skips), then publishes AntiExploit.Forgive and pivots the character if it is the same one and still in the world. Never yields.
Movement.evaluate(sample, config) The pure verdict function. config needs SpeedTolerance and TeleportStuds, and reads IntervalSeconds, MaxTrustedWalkSpeed and GetTrustedWalkSpeed when present. Returns a Verdict.

Sample is { LastPosition, Position, DeltaTime, WalkSpeed, Player?, Session?, ExternalVelocity? }, where ExternalVelocity is the platform velocity the sweep fills in. Verdict is { Violation, HorizontalSpeed, Displacement }, with Violation one of "Speed", "Teleport", "Invalid" or nil for a pass. evaluate is exported so policy math can be unit-tested without a kernel.

Kind Name Payload Notes
Hook (fail-open) AntiExploit.MovementViolation { Player, Session, Type, HorizontalSpeed, Displacement, Strikes } Fired on every violation. Type is "Speed", "Teleport" or "Invalid".
Bus (published) AntiExploit.MovementViolation player, violationType, strikes Published right after the hook. Forensics watches it by default.
Bus (consumed) AntiExploit.Forgive player Pardons the player’s next sample. Publish it before any server-initiated move.
Bus (consumed) AntiExploit.Exempt player, exempt Sets (true) or clears (false) a standing exemption. Does not touch the Rewind history.

The hook point is defined with { FailOpen = true } on purpose, because it is an observation point. A crashing policy handler must not silence the other listeners, and the monitor ignores the chain’s result since detection already happened. The security audit flags fail-open points only on the Intent. and Request. gate namespaces. See Hooks for the chain 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.