Skip to content

Movement Monitor

The Movement Monitor is the server-side check over character motion: it catches speedhackers and teleporters by sampling every tracked character on a fixed sweep and running the sample through pure verdict math. The design decision that defines it: detection is the kernel’s job; the punishment policy is yours. The monitor fires a hook and a bus event with full context — kicking, logging, or shadow-banning is a handler you write.

The second decision matters just as much: the client keeps network ownership of its character. SetNetworkOwner is never called, so movement stays as responsive as stock Roblox. Corrections are plain CFrame writes (rubberbanding), applied only after repeated strikes.

Movement.attach() schedules a sweep on the Scheduler every IntervalSeconds (default 0.2s) at Priority.Low. Each sweep reads every tracked player’s HumanoidRootPart.Position, computes the delta since the last sweep, and hands the sample to Movement.evaluate().

evaluate() is a pure function — no Instance access, no clock access, every input injected. That is why the detection math is fully spec-verified: walking, the tolerance band, speed hacks, vertical falls, teleports, zero delta times, and custom trusted speeds all have exact-value tests. The sweep is just the plumbing that feeds it.

A sample is skipped and re-baselined (no evaluation) when any of these hold:

  • First sample after attach, respawn, or a dead/missing character — CharacterAdded resets the baseline so the spawn jump is never evaluated.
  • The player was forgiven (see Forgiving server moves).
  • The player is seated (Humanoid.SeatPart ~= nil) — vehicles legitimately exceed walk speed.

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

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

With defaults that is 80 studs per 0.2s sweep — a sustained displacement rate of 400 studs/s. The threshold prorates up when a sweep stretches (server hitch, missed frames): without proration, a player running normally through two missed sweeps would cover extra ground and flag as a teleporter. The threshold never prorates down.

The speed check uses horizontal displacement only:

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

Vertical motion never flags — falling and jumping are physics, not cheating. The 1e-6 clamp means a zero delta time cannot divide by zero.

SpeedTolerance defaults to 1.4× and absorbs legit variance: momentum, slopes, and replication jitter. The spec pins the band exactly — at WalkSpeed = 16, a measured 21 studs/s passes (limit 22.4) and 60 studs/s flags.

Humanoid.WalkSpeed is client-owned and therefore forgeable — an exploiter can set it to 1e9 (or NaN) and make any speed look legal. The monitor defends in layers:

  1. If the config provides GetTrustedWalkSpeed(sample), its return value is used and the replicated WalkSpeed is never consulted. This is the strong option: if your game tracks sprint states or movement effects server-side, report the speed you granted.
  2. Otherwise the replicated WalkSpeed is used — after sanitizing. NaN, math.huge, and negative values all collapse to MaxTrustedWalkSpeed.
  3. Whatever survives is capped: TrustedWalkSpeed = math.min(Reported, MaxTrustedWalkSpeed).

With defaults (MaxTrustedWalkSpeed = 100, SpeedTolerance = 1.4), the fastest horizontal speed a forged WalkSpeed can legalize is 140 studs/s. Past that, the speed check fires no matter what the client claims.

Each player carries a strike counter. On a violation:

  1. If the previous strike is older than StrikeDecaySeconds (default 10s), the counter resets to zero first — old strikes do not haunt a player forever.
  2. The counter increments, and the AntiExploit.MovementViolation hook and bus event fire with the full context.
  3. If Strikes >= RubberbandStrikes (default 2) and a last-valid position exists, the character is rubberbanded: Root.CFrame = Root.CFrame.Rotation + LastValidPosition. Only the position rewinds — the character keeps its facing, so the correction reads as a snap-back, not a spin.

So with defaults: the first strike in a 10-second window only fires the hook; the second and every later strike inside the window also rubberbands.

Flagged positions never become the baseline

Section titled “Flagged positions never become the baseline”

The monitor keeps two positions per player: LastPosition (the comparison anchor for the next sample) and LastValidPosition (the rubberband target). Both update only when a sample passes. A flagged sample updates neither.

This closes the laundering hole: if a flagged position became the next baseline, an exploiter could teleport once, absorb one strike, and stand at the destination with a clean baseline — trading a strike for a teleport. Instead, every sweep keeps measuring from the last honest position, so the exploiter keeps striking until rubberbanded back to it. After a rubberband, LastPosition is set to LastValidPosition so the correction itself is never measured as a teleport.

Attach in your Bootstrap (between boot() and start() — see Kernel & Boot) and write your policy as a hook handler:

local Root = game:GetService("ServerScriptService").ChloeKernelServer
local Movement = require(Root.AntiExploit.Movement)
return function(kernel)
local Monitor = Movement.attach(kernel) -- defaults: 1.4x speed tolerance, 80-stud teleport
-- Detection is the kernel's; the policy is yours.
kernel.Hooks:on("AntiExploit.MovementViolation", function(context)
-- context: Player, Session, Type ("Speed" | "Teleport"),
-- HorizontalSpeed, Displacement, Strikes
if context.Strikes >= 5 then
context.Player:Kick("Suspicious movement")
end
end)
end

Rubberbanding happens automatically at 2+ strikes regardless of your handler — the hook is where you add escalation beyond it.

Any legit server-initiated move — teleporter pads, blink spells, knockback, cutscene repositioning — must announce itself first, or the monitor treats it as a violation and rubberbands it:

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

Forgiveness pardons exactly one sample: the next sweep re-baselines at the new position without evaluating it, then normal checking resumes. Rewind subscribes to the same topic and resets its position history, so one publish covers both systems.

If your game changes movement speed server-side (sprint, hastes, slows), report the granted speed 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 your table over these defaults:

Option Default What it controls
IntervalSeconds 0.2 Sweep cadence. Also the nominal interval the teleport threshold prorates against.
SpeedTolerance 1.4 Multiplier over the trusted walk speed before a Speed verdict. Covers momentum, slopes, replication jitter.
TeleportStuds 80 Single-sample 3D displacement threshold for a Teleport verdict, per nominal interval.
StrikeDecaySeconds 10 A strike older than this resets the counter before the next increment.
RubberbandStrikes 2 Strike count at which the character snaps back to the last valid position.
MaxTrustedWalkSpeed 100 Hard cap on any reported walk speed. WalkSpeed is client-owned and forgeable; this bounds the damage.
GetTrustedWalkSpeed nil (sample) -> number? — server-authoritative speed source. When it returns a number, replicated WalkSpeed is never consulted.
Member Description
Movement.attach(kernel, config?) → Movement Defines the hook point, subscribes AntiExploit.Forgive, tracks sessions, and starts the sweep at Priority.Low.
monitor:forgive(player) Pardons the player’s next sample. Equivalent to publishing AntiExploit.Forgive.
monitor:detach() Cancels the sweep, disconnects the session and bus subscriptions, and clears tracking.
Movement.evaluate(sample, config) → Verdict The pure verdict function. sample: {LastPosition, Position, DeltaTime, WalkSpeed, Player?, Session?}. config: {SpeedTolerance, TeleportStuds, IntervalSeconds?, MaxTrustedWalkSpeed?, GetTrustedWalkSpeed?}.

Verdict is {Violation: ("Speed" | "Teleport")?, HorizontalSpeed: number, Displacement: number}Violation = nil means the sample passed. evaluate() is exported precisely so you can unit-test policy math against it 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" or "Teleport".
Bus (published) AntiExploit.MovementViolation player, violationType, strikes Same event for subscribers that don’t need the full context.
Bus (consumed) AntiExploit.Forgive player Pardons that player’s next sample. Publish before any server-initiated move.

The hook point is defined { FailOpen = true } deliberately: it is an observation point, not a gate. A crashing policy handler must not silence other listeners, and the monitor ignores the chain’s verdict — detection already happened. The security audit does not flag it, because fail-open is only dangerous on Intent./Request. gate namespaces. See Hooks for the chain semantics.