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.
Mental model
Section titled “Mental model”The sweep
Section titled “The sweep”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 —
CharacterAddedresets 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
Section titled “The teleport check”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
Section titled “The speed check”The speed check uses horizontal displacement only:
HorizontalSpeed = |Δ(X, Z)| / max(DeltaTime, 1e-6)Violation when HorizontalSpeed > TrustedWalkSpeed × SpeedToleranceVertical 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.
Trusted walk speed
Section titled “Trusted walk speed”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:
- If the config provides
GetTrustedWalkSpeed(sample), its return value is used and the replicatedWalkSpeedis never consulted. This is the strong option: if your game tracks sprint states or movement effects server-side, report the speed you granted. - Otherwise the replicated
WalkSpeedis used — after sanitizing.NaN,math.huge, and negative values all collapse toMaxTrustedWalkSpeed. - 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.
Strikes, decay, and rubberbanding
Section titled “Strikes, decay, and rubberbanding”Each player carries a strike counter. On a violation:
- If the previous strike is older than
StrikeDecaySeconds(default 10s), the counter resets to zero first — old strikes do not haunt a player forever. - The counter increments, and the
AntiExploit.MovementViolationhook and bus event fire with the full context. - 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").ChloeKernelServerlocal 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)endRubberbanding happens automatically at 2+ strikes regardless of your handler — the hook is where you add escalation beyond it.
Forgiving server moves
Section titled “Forgiving server moves”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 = DestinationForgiveness 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.
Server-authoritative speeds
Section titled “Server-authoritative speeds”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.
Configuration
Section titled “Configuration”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.
Hooks & bus topics
Section titled “Hooks & bus topics”| 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.