What You’ll Learn
- Setting up a Tool with correct client/server structure
- Raycasting bullets with
workspace:Raycast() - Server-side hit validation to prevent exploits
- Ammo tracking with Attributes
- Reload logic with
UserInputService - Bullet tracer visual effects
Project Structure
Inside ReplicatedStorage, create a Tool named Gun. Add the following children:
| Object | Type | Purpose |
|---|---|---|
GunScript | LocalScript | Client input, visuals, fire requests |
ServerHandler | Script | Hit validation, damage, anti-exploit |
FireEvent | RemoteEvent | Client → Server fire request |
ReloadEvent | RemoteEvent | Client → Server reload request |
Muzzle | Attachment | Raycast origin point |
Rule #1: Never deal damage on the client. The client requests; the server decides.
Step 1 — Client-Side Firing
Create a LocalScript named GunScript inside the Tool.
-- GunScript (LocalScript inside Tool)
local Tool = script.Parent
local Player = game:GetService("Players").LocalPlayer
local UIS = game:GetService("UserInputService")
local Debris = game:GetService("Debris")
local Mouse = Player:GetMouse()
local FireEvent = Tool:WaitForChild("FireEvent")
local ReloadEvent = Tool:WaitForChild("ReloadEvent")
-- Ammo state
local MaxAmmo = 30
local Ammo = MaxAmmo
local ReserveAmmo = 90
local CanFire = true
local Reloading = false
-- Sync ammo to Attributes so the UI can read them
local function SyncAmmo()
Tool:SetAttribute("Ammo", Ammo)
Tool:SetAttribute("Reserve", ReserveAmmo)
end
SyncAmmo()
local function Fire()
if not CanFire or Reloading or Ammo <= 0 then return end
if Tool.Parent ~= Player.Character then return end
Ammo -= 1
CanFire = false
SyncAmmo()
-- Build the ray from muzzle through mouse
local Camera = workspace.CurrentCamera
local Muzzle = Tool:WaitForChild("Muzzle")
local RayOrigin = Muzzle.WorldPosition
local RayDirection = (Mouse.Hit.Position - RayOrigin).Unit * 500
local rayParams = RaycastParams.new()
rayParams.FilterDescendantsInstances = {Player.Character, Tool}
rayParams.FilterType = Enum.RaycastFilterType.Exclude -- Use Exclude, not Blacklist
local Result = workspace:Raycast(RayOrigin, RayDirection, rayParams)
-- Bullet tracer visual
local Distance = Result and (Result.Position - RayOrigin).Magnitude or 500
local Tracer = Instance.new("Part")
Tracer.Anchored = true
Tracer.CanCollide = false
Tracer.Size = Vector3.new(0.05, 0.05, Distance)
Tracer.CFrame = CFrame.lookAt(RayOrigin, RayOrigin + RayDirection)
* CFrame.new(0, 0, -Distance / 2)
Tracer.Color = Color3.fromRGB(255, 220, 50)
Tracer.Material = Enum.Material.Neon
Tracer.Parent = workspace
Debris:AddItem(Tracer, 0.05) -- Auto-remove after 0.05s
-- Send to server for validation
local HitPosition = Result and Result.Position or (RayOrigin + RayDirection)
local HitInstance = Result and Result.Instance or nil
FireEvent:FireServer(HitPosition, HitInstance, RayOrigin, RayDirection)
task.wait(0.1) -- Fire rate cooldown
CanFire = true
end
local function Reload()
if Reloading or Ammo == MaxAmmo or ReserveAmmo <= 0 then return end
Reloading = true
ReloadEvent:FireServer()
-- Play reload animation here (AnimationTrack:Play())
task.wait(2) -- Reload duration
local Needed = MaxAmmo - Ammo
local Taken = math.min(Needed, ReserveAmmo)
Ammo += Taken
ReserveAmmo -= Taken
Reloading = false
SyncAmmo()
end
-- Bind firing to mouse click
Tool.Activated:Connect(Fire)
-- Bind reload to R key
UIS.InputBegan:Connect(function(input, gameProcessed)
if gameProcessed then return end
if input.KeyCode == Enum.KeyCode.R then
Reload()
end
end)
Step 2 — Server-Side Validation
Create a Script (not LocalScript) named ServerHandler inside the Tool. This re-runs the raycast independently and validates the client’s hit claim.
-- ServerHandler (Script inside Tool)
local Tool = script.Parent
local FireEvent = Tool:WaitForChild("FireEvent")
local ReloadEvent = Tool:WaitForChild("ReloadEvent")
local DAMAGE = 25
local MAX_DISTANCE = 500
local FIRE_RATE = 0.1 -- Minimum seconds between shots
local HIT_TOLERANCE = 10 -- Max stud difference between client and server hit
local LastFire = {}
FireEvent.OnServerEvent:Connect(function(Player, ClientHitPos, ClientHitInst, ClientOrigin, ClientDirection)
-- Guard: player must be holding the tool
if not Player.Character then return end
if Tool.Parent ~= Player.Character then return end
-- Rate-limit check (anti-autoclicker / speed exploit)
local Now = tick()
if LastFire[Player.UserId] and (Now - LastFire[Player.UserId]) < FIRE_RATE then
return
end
LastFire[Player.UserId] = Now
-- Re-run the raycast on the server (authoritative)
local rayParams = RaycastParams.new()
rayParams.FilterDescendantsInstances = {Player.Character}
rayParams.FilterType = Enum.RaycastFilterType.Exclude
local Result = workspace:Raycast(ClientOrigin, ClientDirection.Unit * MAX_DISTANCE, rayParams)
if not Result then return end
-- Reject if server and client disagree on hit position by more than tolerance
if (Result.Position - ClientHitPos).Magnitude > HIT_TOLERANCE then
return
end
-- Apply damage to a valid Humanoid
local HitModel = Result.Instance:FindFirstAncestorOfClass("Model")
if HitModel then
local Humanoid = HitModel:FindFirstChildOfClass("Humanoid")
if Humanoid and HitModel ~= Player.Character then
Humanoid:TakeDamage(DAMAGE)
end
end
end)
ReloadEvent.OnServerEvent:Connect(function(Player)
-- Optional: add server-side ammo tracking here
-- e.g. validate that reload is legitimate before allowing it
end)
Step 3 — Ammo HUD
In StarterGui, create:
ScreenGui→AmmoGuiTextLabel→AmmoDisplay(position it wherever you like)
Then add a LocalScript inside AmmoGui:
-- LocalScript inside AmmoGui
local Player = game:GetService("Players").LocalPlayer
local AmmoDisplay = script.Parent:WaitForChild("AmmoDisplay")
local function WatchTool(tool)
if not tool:FindFirstChild("GunScript") then return end
-- Update display when Attributes change
tool.AttributeChanged:Connect(function()
local ammo = tool:GetAttribute("Ammo") or 0
local reserve = tool:GetAttribute("Reserve") or 0
AmmoDisplay.Text = string.format("%d / %d", ammo, reserve)
end)
-- Set initial values
AmmoDisplay.Text = string.format(
"%d / %d",
tool:GetAttribute("Ammo") or 0,
tool:GetAttribute("Reserve") or 0
)
end
local Character = Player.Character or Player.CharacterAdded:Wait()
Character.ChildAdded:Connect(function(child)
if child:IsA("Tool") then WatchTool(child) end
end)
Character.ChildRemoved:Connect(function(child)
if child:IsA("Tool") then AmmoDisplay.Text = "" end
end)
Optional Enhancements
Recoil
Add this to the Fire() function in your GunScript, after firing:
local Camera = workspace.CurrentCamera Camera.CFrame = Camera.CFrame * CFrame.Angles(math.rad(2), math.rad(math.random(-1, 1)), 0)
Headshots (Double Damage)
In ServerHandler, check which part was hit before applying damage:
local HEADSHOT_MULTIPLIER = 2
local damage = DAMAGE
if Result.Instance.Name == "Head" then
damage = DAMAGE * HEADSHOT_MULTIPLIER
end
Humanoid:TakeDamage(damage)
FAQ
Q: Is this FilteringEnabled (FE) compatible?
Yes. All damage is applied on the server. The client only handles visuals and sends requests.
Q: Can I use this for automatic fire?
Yes — connect Tool.Activated to a loop, or use Mouse.Button1Down with a RunService.Heartbeat loop. Keep the CanFire cooldown in place to rate-limit shots.
Q: How do I add a reload animation?
Load an AnimationTrack from an Animation object in the Tool. Call :Play() at the start of Reload() and use the animation’s length as your task.wait() duration.
Q: Should I track ammo server-side?
For casual games, client-side prediction is fine. For competitive games, track ammo in a NumberValue or IntValue on the server and validate before letting the client reload.
Source Code
https://www.roblox.com/games/137869661367784/Simple-Gun-System