How to enforce fire rate and ammo on the server
Cooldowns, ammo and reloads kept only in a LocalScript can be skipped. How to track shots, ammo and reload time on the server.
What it looks like
A gun, bow, spell or ability has a fire rate, a magazine, a reload time or a cooldown, but only the LocalScript enforces it. The server fires every shot it's asked to, or tracks ammo but lets the client refill it whenever it likes.
Luau
remote.OnServerEvent:Connect(function(player, direction) shoot(player, direction) end)
How to detect it
- For every weapon or ability remote, list the limits the game's design has: time between shots or casts, ammo or charges, reload time, and states that block attacking, such as reloading, sprinting or being stunned. Client scripts and tool settings usually show what they are.
- Check that each one is enforced in the server handler, from values the server keeps: a timestamp per player, an ammo count per player or per tool, a reload that the server times.
- A reload or refill remote that sets ammo back to full straight away lets the client decide the reload time. So does a reload the server starts but doesn't block shots during.
- Values the client sends about its own timing, such as the time it fired or its remaining ammo, don't count.
What an exploiter could do
They can fire as fast as they can send requests, never run out of ammo and never wait for a reload. In a fight they deal many times the damage anyone else can, and abilities meant to be used once a minute can be used constantly.
How to fix it
- Keep each player's last shot time on the server and drop shots that come too soon after it. Use
os.clock()and a small allowance for network jitter. - Keep ammo on the server. Take one per shot and reject shots at zero.
- Start reloads on the server, refill after the reload time has passed, and reject shots while a reload is running.
- Clear the player's entries when they leave or respawn.
Vulnerable example
Vulnerable
local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local Workspace = game:GetService("Workspace") local range = 200 local damage = 12 ReplicatedStorage.Remotes.FireRifle.OnServerEvent:Connect(function(player: Player, direction: unknown) if typeof(direction) ~= "Vector3" or not (direction.Magnitude > 0 and direction.Magnitude < math.huge) then return end local character = player.Character local head = character and character:FindFirstChild("Head") if not character or not head or not head:IsA("BasePart") or not character:FindFirstChild("Rifle") then return end local params = RaycastParams.new() params.FilterType = Enum.RaycastFilterType.Exclude params.FilterDescendantsInstances = { character } local result = Workspace:Raycast(head.Position, direction.Unit * range, params) local model = result and result.Instance:FindFirstAncestorOfClass("Model") local humanoid = model and model:FindFirstChildOfClass("Humanoid") if humanoid and humanoid.Health > 0 and Players:GetPlayerFromCharacter(model) then humanoid:TakeDamage(damage) end end)
Fixed example
Fixed
local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local Workspace = game:GetService("Workspace") local range = 200 local damage = 12 local fireInterval = 0.15 local magazine = 20 local reloadTime = 2 type Gun = { lastShot: number, ammo: number, reloadEnds: number } local guns: { [Player]: Gun } = {} local function gunFor(player: Player): Gun local gun = guns[player] if not gun then gun = { lastShot = -math.huge, ammo = magazine, reloadEnds = 0 } guns[player] = gun end return gun end ReplicatedStorage.Remotes.ReloadRifle.OnServerEvent:Connect(function(player: Player) local gun = gunFor(player) local now = os.clock() if now < gun.reloadEnds or gun.ammo == magazine then return end gun.reloadEnds = now + reloadTime gun.ammo = 0 task.delay(reloadTime, function() if guns[player] == gun then gun.ammo = magazine end end) end) ReplicatedStorage.Remotes.FireRifle.OnServerEvent:Connect(function(player: Player, direction: unknown) if typeof(direction) ~= "Vector3" or not (direction.Magnitude > 0 and direction.Magnitude < math.huge) then return end local character = player.Character local head = character and character:FindFirstChild("Head") if not character or not head or not head:IsA("BasePart") or not character:FindFirstChild("Rifle") then return end local gun = gunFor(player) local now = os.clock() if now < gun.reloadEnds or gun.ammo <= 0 or now - gun.lastShot < fireInterval then return end gun.lastShot = now gun.ammo -= 1 local params = RaycastParams.new() params.FilterType = Enum.RaycastFilterType.Exclude params.FilterDescendantsInstances = { character } local result = Workspace:Raycast(head.Position, direction.Unit * range, params) local model = result and result.Instance:FindFirstAncestorOfClass("Model") local humanoid = model and model:FindFirstChildOfClass("Humanoid") if humanoid and humanoid.Health > 0 and Players:GetPlayerFromCharacter(model) then humanoid:TakeDamage(damage) end end) Players.PlayerRemoving:Connect(function(player) guns[player] = nil end)
Common false positives
- A server cooldown that's slightly shorter than the client's, to allow for network jitter, is deliberate.
- Unlimited ammo or no reload can be the game's design. Only report limits the game actually has, as shown by its client scripts, UI or tool settings.
- Damage to non-player targets that give nothing, such as practice dummies, can't harm anyone. A server that hands out a reward per hit still counts.
- A missing server cooldown on something that only costs the server work, with no combat advantage, belongs to Remote with no rate limit instead.