Server-Side Win Validation & Movement Security
Server-authoritative checkpoint validation with speed checks, traversal heuristics, and rollback on invalid claims.
Obstacle courses on Roblox are common targets for exploit scripts that skip stages or teleport directly to the finish. This system validates each checkpoint claim on the server using state the server owns — not data the client provides.
Client-reported positions and checkpoint references can be spoofed. Trusting a client-supplied BasePart or position allows arbitrary win claims. Speed checks based on client velocity are equally unreliable.
The server maintains a checkpoint registry built from Workspace.Checkpoints at startup. ValidateCheckpoint() resolves the expected part internally — the client only sends the requested stage number. Speed is estimated from server-measured position delta over os.clock() intervals.
›Server-owned checkpoint registry
Stage parts are registered at startup from Workspace.Checkpoints. The client cannot provide or override the expected BasePart reference.
›Positional delta speed estimate
Speed is derived from (currentPos - lastValidPos).Magnitude / os.clock() delta, avoiding reliance on client-reported velocity.
›Configurable tolerance (1.35×)
Accounts for jump momentum, shift-lock, and network variance. Dynamic speed modifiers support sprint pads without raising the global limit.
›Direct-path traversal heuristic
Optional straight-line raycast between consecutive checkpoints. Only flags blocked paths on linear segments — not a substitute for full pathfinding.
›Dedicated authorized teleport flow
ApplyAuthorizedTeleport() updates position and timestamp state directly for server-triggered teleports (respawns, cutscenes), without using broad bypass flags.
| --!strict |
| --[[ |
| Server-Side Win Validation |
| Server-authoritative checkpoint and movement validation for obstacle courses. |
| ]] |
| local Players = game:GetService("Players") |
| local Workspace = game:GetService("Workspace") |
| local Config = { |
| DEFAULT_MAX_SPEED = 16, |
| SPEED_LEEWAY_MULTIPLIER = 1.35, |
| MAX_TOUCH_DISTANCE = 16, |
| MAX_VIOLATIONS_BEFORE_KICK = 3, |
| ENABLE_DIRECT_PATH_OCCLUSION = true, |
| ROLLBACK_ON_VIOLATION = true, |
| DEBUG_LOGGING = false, |
| } |
| export type PlayerSecurityState = { |
| currentStage: number, |
| lastStageTime: number, |
| lastValidCFrame: CFrame, |
| violationCount: number, |
| lastTouchDebounce: number, |
| speedModifier: number, |
| speedModifierExpiry: number, |
| } |
| local AntiAutoWin = {} |
| local sessionStates: { [Player]: PlayerSecurityState } = {} |
| local checkpointRegistry: { [number]: BasePart } = {} |
| local raycastParams = RaycastParams.new() |
| raycastParams.FilterType = RaycastFilterType.Exclude |
| raycastParams.IgnoreWater = true |
| local function logWarning(player: Player, reason: string) |
| warn(string.format("[SECURITY] [%s | %d]: %s", player.Name, player.UserId, reason)) |
| end |
| local function getRootPart(player: Player): BasePart? |
| local character = player.Character |
| if not character then return nil end |
| return character:FindFirstChild("HumanoidRootPart") :: BasePart? |
| end |
| function AntiAutoWin.BuildCheckpointRegistry(checkpointsFolder: Folder) |
| table.clear(checkpointRegistry) |
| for _, child in ipairs(checkpointsFolder:GetChildren()) do |
| if child:IsA("BasePart") then |
| local stageNumber = child:GetAttribute("Stage") or tonumber(string.match(child.Name, "%d+")) |
| if stageNumber and typeof(stageNumber) == "number" then |
| checkpointRegistry[stageNumber] = child |
| end |
| end |
| end |
| end |
| function AntiAutoWin.InitPlayer(player: Player) |
| sessionStates[player] = { |
| currentStage = 0, |
| lastStageTime = os.clock(), |
| lastValidCFrame = CFrame.new(0, 10, 0), |
| violationCount = 0, |
| lastTouchDebounce = 0, |
| speedModifier = 1.0, |
| speedModifierExpiry = 0, |
| } |
| end |
| function AntiAutoWin.CleanupPlayer(player: Player) |
| sessionStates[player] = nil |
| end |
| function AntiAutoWin.SetPlayerSpeedModifier(player: Player, multiplier: number, duration: number) |
| local state = sessionStates[player] |
| if not state then return end |
| state.speedModifier = math.max(1.0, multiplier) |
| state.speedModifierExpiry = os.clock() + duration |
| end |
| function AntiAutoWin.ApplyAuthorizedTeleport(player: Player, targetCFrame: CFrame, newStage: number?) |
| local state = sessionStates[player] |
| if not state then return end |
| state.lastValidCFrame = targetCFrame |
| if newStage and checkpointRegistry[newStage] then |
| state.currentStage = newStage |
| end |
| state.lastStageTime = os.clock() |
| local rootPart = getRootPart(player) |
| if rootPart then |
| rootPart.AssemblyLinearVelocity = Vector3.zero |
| rootPart.AssemblyAngularVelocity = Vector3.zero |
| rootPart.CFrame = targetCFrame |
| end |
| end |
| local function handleViolation(player: Player, state: PlayerSecurityState, reason: string) |
| state.violationCount += 1 |
| logWarning(player, string.format("Violation #%d -> %s", state.violationCount, reason)) |
| if Config.ROLLBACK_ON_VIOLATION then |
| local rootPart = getRootPart(player) |
| if rootPart then |
| rootPart.AssemblyLinearVelocity = Vector3.zero |
| rootPart.AssemblyAngularVelocity = Vector3.zero |
| rootPart.CFrame = state.lastValidCFrame |
| end |
| end |
| if state.violationCount >= Config.MAX_VIOLATIONS_BEFORE_KICK then |
| player:Kick("[Security Notice] Irregular progression or movement pattern detected.") |
| end |
| end |
| function AntiAutoWin.ValidateCheckpoint(player: Player, requestedStage: number): boolean |
| local state = sessionStates[player] |
| if not state then return false end |
| local now = os.clock() |
| if now - state.lastTouchDebounce < 0.15 then |
| return false |
| end |
| state.lastTouchDebounce = now |
| local rootPart = getRootPart(player) |
| if not rootPart then |
| return false |
| end |
| local checkpointPart = checkpointRegistry[requestedStage] |
| if not checkpointPart then |
| logWarning(player, string.format("Requested non-existent checkpoint stage: %d", requestedStage)) |
| return false |
| end |
| local expectedStage = state.currentStage + 1 |
| if requestedStage ~= expectedStage then |
| handleViolation(player, state, string.format("Stage mismatch: Expected %d, got %d", expectedStage, requestedStage)) |
| return false |
| end |
| local playerPos = rootPart.Position |
| local checkpointPos = checkpointPart.Position |
| local touchDistance = (playerPos - checkpointPos).Magnitude |
| if touchDistance > Config.MAX_TOUCH_DISTANCE then |
| handleViolation(player, state, string.format("Proximity mismatch: %d studs from stage %d", math.floor(touchDistance), requestedStage)) |
| return false |
| end |
| local travelDistance = (playerPos - state.lastValidCFrame.Position).Magnitude |
| local timeElapsed = math.max(now - state.lastStageTime, 0.001) |
| local effectiveSpeed = travelDistance / timeElapsed |
| local currentModifier = (now < state.speedModifierExpiry) and state.speedModifier or 1.0 |
| local maxAllowedSpeed = Config.DEFAULT_MAX_SPEED * Config.SPEED_LEEWAY_MULTIPLIER * currentModifier |
| if travelDistance > 15 and effectiveSpeed > maxAllowedSpeed then |
| handleViolation(player, state, string.format("Speed anomaly: %.1f studs at %.1f studs/s (Max: %.1f)", travelDistance, effectiveSpeed, maxAllowedSpeed)) |
| return false |
| end |
| if Config.ENABLE_DIRECT_PATH_OCCLUSION and travelDistance > 12 then |
| raycastParams.FilterDescendantsInstances = { player.Character :: Instance, checkpointPart } |
| local rayDirection = checkpointPos - state.lastValidCFrame.Position |
| local rayResult = Workspace:Raycast(state.lastValidCFrame.Position, rayDirection, raycastParams) |
| if rayResult and rayResult.Instance and rayResult.Instance.CanCollide then |
| local permeableFolder = Workspace:FindFirstChild("PermeableObstacles") |
| local isPermeable = permeableFolder and rayResult.Instance:IsDescendantOf(permeableFolder) |
| if not isPermeable then |
| handleViolation(player, state, string.format("Direct-path collision at %s", rayResult.Instance.Name)) |
| return false |
| end |
| end |
| end |
| state.currentStage = requestedStage |
| state.lastStageTime = now |
| state.lastValidCFrame = rootPart.CFrame |
| state.violationCount = math.max(0, state.violationCount - 1) |
| if Config.DEBUG_LOGGING then |
| print(string.format("[SECURITY] %s validated Stage %d in %.2fs", player.Name, requestedStage, timeElapsed)) |
| end |
| return true |
| end |
| local defaultFolder = Workspace:FindFirstChild("Checkpoints") |
| if defaultFolder and defaultFolder:IsA("Folder") then |
| AntiAutoWin.BuildCheckpointRegistry(defaultFolder) |
| end |
| Players.PlayerAdded:Connect(AntiAutoWin.InitPlayer) |
| Players.PlayerRemoving:Connect(AntiAutoWin.CleanupPlayer) |
| return AntiAutoWin |
- ›Server-authoritative state: checkpoint resolution owned entirely by the server.
- ›Positional kinematics: deriving traversal speed from position deltas without trusting client velocity.
- ›Graduated response: violation counting, rollback, and conditional kick instead of immediate termination.