Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/brockmartin/roblox-game-skill/llms.txt

Use this file to discover all available pages before exploring further.

The fundamental rule of Roblox security is: the client is enemy territory. Every LocalScript, every value in ReplicatedStorage, and every RemoteEvent argument is accessible and modifiable by anyone running exploit software. A fully server-authoritative architecture is not optional for competitive or economy-driven games — it is the minimum viable security posture.
The Golden Rule: Never trust the client. Treat every RemoteEvent:FireServer() call as an HTTP request from an anonymous stranger. Validate everything. The client decides what to display; the server decides what actually happens.

What Exploiters Can Do

  • Modify any LocalScript — inject code, change variables, hook functions.
  • Fire any RemoteEvent with arbitrary arguments — types, values, and argument counts are all attacker-controlled.
  • Speed hack, fly, and teleport — character physics can be overridden entirely on the client.
  • Read all client-accessible code — everything in StarterPlayerScripts, StarterGui, ReplicatedStorage, and ReplicatedFirst is fully readable.
  • Intercept and replay network traffic — RemoteSpy tools record every remote call and let exploiters replay or modify them.
  • Modify client-side state — health displays, cooldown timers, and UI flags are cosmetic only.

RemoteEvent Validation Patterns

The Problem

A bare remote handler is trivially exploitable:
-- BAD: No validation at all
DamageRemote.OnServerEvent:Connect(function(player, targetName, damage)
    local target = Players:FindFirstChild(targetName)
    target.Character.Humanoid:TakeDamage(damage) -- exploiter sends 99999
end)

Production-Ready Validation Module

Place this in ServerScriptService/Modules/RemoteValidator.luau:
local RemoteValidator = {}

-- Type Checking --
type TypeSpec = string | (value: any) -> boolean

function RemoteValidator.checkType(value: any, expected: TypeSpec): boolean
    if type(expected) == "function" then
        return expected(value)
    end
    return typeof(value) == expected
end

function RemoteValidator.validateArgs(
    args: { any },
    schema: { { name: string, type: TypeSpec, optional: boolean? } }
): (boolean, string?)
    for i, spec in schema do
        local value = args[i]

        if value == nil then
            if not spec.optional then
                return false, `Missing required argument: {spec.name}`
            end
            continue
        end

        if not RemoteValidator.checkType(value, spec.type) then
            return false, `Invalid type for {spec.name}: expected {tostring(spec.type)}, got {typeof(value)}`
        end
    end

    -- Reject extra arguments beyond the schema
    if #args > #schema then
        return false, `Too many arguments: expected {#schema}, got {#args}`
    end

    return true, nil
end

-- Range Checking --
function RemoteValidator.checkRange(value: number, min: number, max: number): boolean
    return type(value) == "number"
        and value == value -- NaN check
        and value >= min
        and value <= max
end

function RemoteValidator.checkIntegerRange(value: number, min: number, max: number): boolean
    return RemoteValidator.checkRange(value, min, max)
        and math.floor(value) == value
end

-- Cooldown Tracking --
local cooldowns: { [Player]: { [string]: number } } = {}

function RemoteValidator.checkCooldown(player: Player, action: string, cooldownSeconds: number): boolean
    local now = os.clock()
    local playerCooldowns = cooldowns[player]

    if not playerCooldowns then
        playerCooldowns = {}
        cooldowns[player] = playerCooldowns
    end

    local lastUsed = playerCooldowns[action]
    if lastUsed and (now - lastUsed) < cooldownSeconds then
        return false
    end

    playerCooldowns[action] = now
    return true
end

function RemoteValidator.clearPlayerCooldowns(player: Player)
    cooldowns[player] = nil
end

-- Existence and Ownership Checks --
function RemoteValidator.characterAlive(player: Player): boolean
    local character = player.Character
    if not character then return false end

    local humanoid = character:FindFirstChildOfClass("Humanoid")
    if not humanoid then return false end

    return humanoid.Health > 0
end

function RemoteValidator.playerOwnsItem(player: Player, itemId: string, inventoryFolder: Folder?): boolean
    local folder = inventoryFolder or player:FindFirstChild("Inventory") :: Folder?
    if not folder then return false end
    return folder:FindFirstChild(itemId) ~= nil
end

-- Distance Check --
function RemoteValidator.playerWithinRange(player: Player, targetPos: Vector3, maxDistance: number): boolean
    local character = player.Character
    if not character then return false end

    local root = character:FindFirstChild("HumanoidRootPart")
    if not root then return false end

    return (root.Position - targetPos).Magnitude <= maxDistance
end

-- Cleanup --
game:GetService("Players").PlayerRemoving:Connect(function(player)
    RemoteValidator.clearPlayerCooldowns(player)
end)

return RemoteValidator

Full Validation Chain in a Handler

-- ServerScriptService/RemoteHandlers/DamageHandler.server.luau
local Validator = require(ServerScriptService.Modules.RemoteValidator)

local MAX_DAMAGE = 50
local DAMAGE_COOLDOWN = 0.5  -- seconds
local ATTACK_RANGE = 15      -- studs

local ARG_SCHEMA = {
    { name = "targetPlayer", type = "Instance" },
    { name = "damage",       type = "number" },
}

DamageRemote.OnServerEvent:Connect(function(player: Player, ...: any)
    local args = { ... }

    -- 1. Validate argument types
    local valid, err = Validator.validateArgs(args, ARG_SCHEMA)
    if not valid then
        warn(`[DamageHandler] {player.Name}: {err}`)
        return
    end

    local targetPlayer: Player = args[1]
    local damage: number = args[2]

    -- 2. Validate the target is actually a Player
    if not targetPlayer:IsA("Player") then return end

    -- 3. Validate damage range
    if not Validator.checkIntegerRange(damage, 1, MAX_DAMAGE) then
        warn(`[DamageHandler] {player.Name}: damage out of range ({damage})`)
        return
    end

    -- 4. Cooldown check
    if not Validator.checkCooldown(player, "DealDamage", DAMAGE_COOLDOWN) then return end

    -- 5. Verify attacker is alive
    if not Validator.characterAlive(player) then return end

    -- 6. Verify target is alive
    if not Validator.characterAlive(targetPlayer) then return end

    -- 7. Range check
    local targetRoot = targetPlayer.Character and targetPlayer.Character:FindFirstChild("HumanoidRootPart")
    if not targetRoot then return end
    if not Validator.playerWithinRange(player, targetRoot.Position, ATTACK_RANGE) then
        warn(`[DamageHandler] {player.Name}: target out of range`)
        return
    end

    -- 8. Weapon authorization
    local character = player.Character
    local weapon = character and character:FindFirstChildOfClass("Tool")
    if not weapon or not weapon:GetAttribute("CanDealDamage") then
        warn(`[DamageHandler] {player.Name}: no valid weapon equipped`)
        return
    end

    -- 9. Server calculates actual damage from weapon config, not client value
    local serverDamage = math.min(damage, weapon:GetAttribute("MaxDamage") or MAX_DAMAGE)

    -- 10. Apply damage
    local targetHumanoid = targetPlayer.Character:FindFirstChildOfClass("Humanoid")
    if targetHumanoid then
        targetHumanoid:TakeDamage(serverDamage)
    end
end)

Client-Side Currency: Wrong vs Right

Client-side currency manipulation is the most common exploit in Roblox games. If a client can tell the server how much money to add, the game economy is broken.
-- Client fires with amount (attacker sends 999999)
AddGoldRemote:FireServer(999999)

-- Server blindly applies it
AddGoldRemote.OnServerEvent:Connect(function(player, amount)
    player.leaderstats.Gold.Value += amount
end)
The same principle applies to all economy actions: purchases, drops, trades, and achievements. The server owns the reward values. The client only sends intent.

Rate Limiting Implementation

-- ServerScriptService/Modules/RateLimiter.luau

local Players = game:GetService("Players")
local RateLimiter = {}

export type RateLimitConfig = {
    maxRequests: number,
    windowSeconds: number,
    cooldownSeconds: number?,
    kickAfterViolations: number?,
    kickMessage: string?,
}

type PlayerActionData = {
    timestamps: { number },
    violations: number,
    cooldownUntil: number,
}

local tracking: { [Player]: { [string]: PlayerActionData } } = {}
local DEFAULT_KICK_MESSAGE = "Too many requests. Please try again later."

function RateLimiter.check(player: Player, action: string, config: RateLimitConfig): boolean
    local now = os.clock()

    if not tracking[player] then tracking[player] = {} end
    local playerActions = tracking[player]

    if not playerActions[action] then
        playerActions[action] = { timestamps = {}, violations = 0, cooldownUntil = 0 }
    end

    local data = playerActions[action]

    if now < data.cooldownUntil then return false end

    -- Prune old timestamps
    local windowStart = now - config.windowSeconds
    local pruned = {}
    for _, ts in data.timestamps do
        if ts > windowStart then table.insert(pruned, ts) end
    end
    data.timestamps = pruned

    if #data.timestamps >= config.maxRequests then
        data.violations += 1

        if config.cooldownSeconds then
            data.cooldownUntil = now + config.cooldownSeconds
        end

        if config.kickAfterViolations and data.violations >= config.kickAfterViolations then
            local message = config.kickMessage or DEFAULT_KICK_MESSAGE
            warn(`[RateLimiter] Kicking {player.Name}: {data.violations} violations on "{action}"`)
            task.defer(function() player:Kick(message) end)
        else
            warn(`[RateLimiter] {player.Name}: rate limited on "{action}" (violation #{data.violations})`)
        end

        return false
    end

    table.insert(data.timestamps, now)
    return true
end

function RateLimiter.wrapRemote(
    remote: RemoteEvent,
    config: RateLimitConfig,
    handler: (player: Player, ...any) -> ()
)
    remote.OnServerEvent:Connect(function(player: Player, ...: any)
        if not RateLimiter.check(player, remote.Name, config) then return end
        handler(player, ...)
    end)
end

Players.PlayerRemoving:Connect(function(player) tracking[player] = nil end)

return RateLimiter

Usage

local RateLimiter = require(ServerScriptService.Modules.RateLimiter)

-- Shoot: 10 shots per 5 seconds, kick after 3 violations
RateLimiter.wrapRemote(ShootRemote, {
    maxRequests = 10,
    windowSeconds = 5,
    cooldownSeconds = 2,
    kickAfterViolations = 3,
    kickMessage = "Firing too fast. Disconnected.",
}, function(player, ...)
    handleShoot(player, ...)
end)

Anti-Exploit Patterns

Movement Validation (Speed Hack Detection)

-- ServerScriptService/Security/MovementValidator.server.luau
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")

local MAX_SPEED = 50             -- studs per second
local MAX_VERTICAL_SPEED = 100   -- studs per second
local VIOLATION_THRESHOLD = 5
local CHECK_INTERVAL = 0.5

local playerData: { [Player]: {
    lastPosition: Vector3,
    lastCheck: number,
    violations: number,
} } = {}

Players.PlayerAdded:Connect(function(player)
    player.CharacterAdded:Connect(function(character)
        local root = character:WaitForChild("HumanoidRootPart")
        playerData[player] = {
            lastPosition = root.Position,
            lastCheck = os.clock(),
            violations = 0,
        }
    end)
end)

Players.PlayerRemoving:Connect(function(player)
    playerData[player] = nil
end)

RunService.Heartbeat:Connect(function()
    local now = os.clock()

    for player, data in playerData do
        if (now - data.lastCheck) < CHECK_INTERVAL then continue end

        local character = player.Character
        if not character then continue end

        local root = character:FindFirstChild("HumanoidRootPart")
        if not root then continue end

        local dt = now - data.lastCheck
        local displacement = root.Position - data.lastPosition
        local horizontalSpeed = Vector3.new(displacement.X, 0, displacement.Z).Magnitude / dt
        local verticalSpeed = math.abs(displacement.Y) / dt

        if horizontalSpeed > MAX_SPEED or verticalSpeed > MAX_VERTICAL_SPEED then
            data.violations += 1
            warn(`[MovementValidator] {player.Name}: speed violation #{data.violations} (h={math.floor(horizontalSpeed)}, v={math.floor(verticalSpeed)})`)

            if data.violations >= VIOLATION_THRESHOLD then
                -- Teleport back to last valid position
                root.CFrame = CFrame.new(data.lastPosition)
                -- Or kick for persistent abuse:
                -- player:Kick("Movement anomaly detected.")
            end
        else
            data.violations = math.max(0, data.violations - 1)
            data.lastPosition = root.Position
        end

        data.lastCheck = now
    end
end)

Fly Hack Detection

local function isGrounded(character: Model): boolean
    local root = character:FindFirstChild("HumanoidRootPart")
    if not root then return false end

    local params = RaycastParams.new()
    params.FilterDescendantsInstances = { character }
    params.FilterType = Enum.RaycastFilterType.Exclude

    local result = workspace:Raycast(root.Position, Vector3.new(0, -10, 0), params)
    return result ~= nil
end

local airTime: { [Player]: number } = {}
local MAX_AIR_TIME = 5 -- seconds before flagging

RunService.Heartbeat:Connect(function(dt)
    for _, player in Players:GetPlayers() do
        local character = player.Character
        if not character then continue end

        if isGrounded(character) then
            airTime[player] = 0
        else
            airTime[player] = (airTime[player] or 0) + dt
            if airTime[player] > MAX_AIR_TIME then
                warn(`[FlyDetect] {player.Name}: airborne for {math.floor(airTime[player])}s`)
                -- Take action: teleport to ground, kick, etc.
            end
        end
    end
end)

Item Duplication Prevention

local claimedRewards: { [Player]: { [string]: boolean } } = {}

local function claimReward(player: Player, rewardId: string): boolean
    local claimed = claimedRewards[player]
    if not claimed then
        claimed = {}
        claimedRewards[player] = claimed
    end

    -- Idempotency: already claimed
    if claimed[rewardId] then return false end

    -- Mark as claimed BEFORE granting (prevents race condition)
    claimed[rewardId] = true

    local reward = RewardDatabase[rewardId]
    if reward then
        grantItem(player, reward.ItemId)
    end

    return true
end

Restricted Zone Enforcement (Noclip Defense)

local RESTRICTED_ZONE_CENTER = Vector3.new(100, 5, 200)
local RESTRICTED_ZONE_RADIUS = 30

RunService.Heartbeat:Connect(function()
    for _, player in Players:GetPlayers() do
        local root = player.Character and player.Character:FindFirstChild("HumanoidRootPart")
        if not root then continue end

        local distance = (root.Position - RESTRICTED_ZONE_CENTER).Magnitude
        if distance < RESTRICTED_ZONE_RADIUS then
            if not player:GetAttribute("HasZoneAccess") then
                root.CFrame = CFrame.new(
                    RESTRICTED_ZONE_CENTER + Vector3.new(RESTRICTED_ZONE_RADIUS + 5, 0, 0)
                )
                warn(`[Noclip] {player.Name}: entered restricted zone without access`)
            end
        end
    end
end)

Data Exposure Risks

Safe Placement Guide

ContentLocation
Client UI scriptsStarterGui / StarterPlayerScripts
Shared types/constants (non-sensitive)ReplicatedStorage
Remote definitionsReplicatedStorage
Server game logicServerScriptService
Server data (configs, databases)ServerStorage
Admin toolsServerScriptService / ServerStorage
Never put these in ReplicatedStorage:
  • Admin command lists or logic
  • Secret keys or API tokens
  • Server configuration (spawn rates, drop tables, economy tuning)
  • Anti-cheat detection logic
  • Complete item databases with hidden stats
All of ReplicatedStorage is fully readable by every client.

Minimize Remote Payloads

-- BAD: broadcasting full player data to everyone
UpdateRemote:FireAllClients({
    name = player.Name,
    gold = player.leaderstats.Gold.Value,
    secretInventory = getFullInventory(player),
    adminLevel = player:GetAttribute("AdminLevel"),
})

-- GOOD: send only what this specific client needs
UpdateRemote:FireClient(player, {
    gold = player.leaderstats.Gold.Value,
})
Never broadcast other players’ currency balances, inventory contents, admin status, or any internal session tokens.

Obfuscation vs Real Security

ApproachDoes it work?
Obfuscating LocalScriptsNo. Deobfuscators exist.
Renaming RemoteEvents to random stringsNo. RemoteSpy shows calls regardless of name.
Encrypting remote payloadsNo. The client must have the key, so the exploiter does too.
Hiding remotes in nested foldersNo. game:GetDescendants() finds everything.
Server-side validationYes. This is the only real security.
Obfuscation may slow down casual script kiddies, but it must never be relied upon. If your game breaks when someone reads your client code, your security model is wrong.

Pre-Ship Security Checklist

Before shipping any remote-connected feature, verify:
  • All arguments are type-checked with typeof()
  • All numeric values are range-checked (including NaN: value == value)
  • A server-side cooldown prevents rapid firing
  • The player is authorized to perform the action
  • The target/object exists before operating on it
  • The server calculates outcomes (damage, rewards, prices) — never the client
  • No sensitive data is exposed in ReplicatedStorage or remote payloads
  • Rate limiting is in place
  • Suspicious activity is logged with player identification
  • Extra arguments beyond the schema are rejected

Common Anti-Patterns

-- BAD: client tells server where to teleport them
MoveRemote.OnServerEvent:Connect(function(player, position)
    player.Character.HumanoidRootPart.CFrame = CFrame.new(position)
end)

-- GOOD: server reads position directly from the character
-- and validates against expected movement speed
-- BAD: client-side check is bypassable — exploiter fires the remote directly
AdminKickRemote.OnServerEvent:Connect(function(player, targetName)
    local target = Players:FindFirstChild(targetName)
    if target then target:Kick("Kicked by admin") end
end)

-- GOOD: server validates admin status using a server-owned list
local ADMIN_LIST = { [12345678] = true } -- UserId list in ServerScriptService

AdminKickRemote.OnServerEvent:Connect(function(player, targetName)
    if not ADMIN_LIST[player.UserId] then
        warn(`[Admin] Unauthorized attempt by {player.Name} ({player.UserId})`)
        return
    end
    if typeof(targetName) ~= "string" then return end
    local target = Players:FindFirstChild(targetName)
    if target then target:Kick("Kicked by admin") end
end)
-- BAD: "They'll never guess the remote name"
local remote = Instance.new("RemoteEvent")
remote.Name = "x7f2k9_internal_do_not_use"
remote.Parent = ReplicatedStorage

-- RemoteSpy reveals it instantly regardless of the name.
-- Validation is what protects the server, not naming.

Build docs developers (and LLMs) love