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.
Mental model
Section titled “Mental model”The sweep
Section titled “The sweep”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
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.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
Section titled “The speed check”The speed check uses horizontal displacement only:
HorizontalSpeed = |delta(X, Z)| / max(DeltaTime, 1e-6)Violation when HorizontalSpeed > TrustedWalkSpeed * SpeedToleranceVertical 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.
Invalid samples
Section titled “Invalid samples”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.
Moving platforms
Section titled “Moving platforms”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 = trueand excludes the character, so a non-colliding zone or trigger volume between the root and the platform never hides it. The monitor keeps oneRaycastParamsand 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 makesGetNetworkOwnerthrow and also grants nothing. - The part’s
AssemblyLinearVelocitymust be finite and at leastBeltMinSpeed(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.
Trusted walk speed
Section titled “Trusted walk speed”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:
- If the config provides
GetTrustedWalkSpeed(sample)and it returns a number, that number is used and the replicatedWalkSpeedis never read. This is the strong option: report the speed the server granted (sprint, hastes, slows). - Otherwise the replicated
WalkSpeedis used. - From either source, NaN,
math.hugeand negative values becomeMaxTrustedWalkSpeed, 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.
Strikes and rubberbanding
Section titled “Strikes and rubberbanding”Each player carries a strike count. On a violation:
- If the previous strike is more than
StrikeDecaySeconds(10 s) old, the count resets first. - The count goes up by one. The
AntiExploit.MovementViolationhook fires with the full context, then the Bus topic of the same name publishes. - 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.
Log-only mode and Server Authority
Section titled “Log-only mode and Server Authority”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:
--!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 permanentend
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 AntiCheatServiceAdd 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.
Forgiving server moves
Section titled “Forgiving server moves”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 pivotsMovement.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.
Standing exemption
Section titled “Standing exemption”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 beginskernel.Bus:publish("AntiExploit.Exempt", player, false) -- the ride ends and checking resumesWhile 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.
Server-authoritative speeds
Section titled “Server-authoritative speeds”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.
Configuration
Section titled “Configuration”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.
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", "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.