In short
Put a Tool named Blaster with a Handle in StarterPack. A LocalScript in the Tool turns the mouse, a tap or the screen centre into a 3D aim point and sends only that point with a RemoteEvent. A Script in ServerScriptService checks the fire rate, the ammo and that the player is holding the blaster, casts workspace:Raycast from the Handle towards the aim point with RaycastParams that exclude the shooter, calls Humanoid:TakeDamage on what it hits, and fires a second RemoteEvent so every client draws the beam.
- The client sends where it aimed, never who it hit or how much damage. The server's own ray decides.
workspace:RaycastwithRaycastParamsreplacesworkspace:FindPartOnRay, which Roblox marks deprecated (it does not need aRayobject).- No check on a single shot can stop an aimbot, because aiming is the client's job. The server can stop shooting too fast, through walls, past the range, without ammo or while dead.
On this page
How a raycast gun works
A gun that works in a live game is split between the player's device and the server. The device knows where the player is aiming, so it works out an aim point and sends it. The server, the only side you can trust, decides everything else: whether the player may fire, what the shot hits and how much damage it does. Gun scripts that only work for the person testing them, or that exploiters abuse, usually make one of those decisions on the device.
This guide builds a hitscan blaster: each shot is one raycast, an invisible straight line that the engine traces from the gun until it meets the first part in its way. There is no bullet part for the physics engine to move or for a client to fake.
| Piece | Where it lives | What it does |
|---|---|---|
Blaster Tool | StarterPack | What the player holds. Shots start from the centre of its Handle. |
GunClient LocalScript | Inside the Blaster Tool | Works out the aim point and sends it, shows the ammo, and binds the Reload action. |
GunService Script | ServerScriptService | Creates the three RemoteEvents, checks every shot, casts the ray, deals the damage and runs ammo and reload. |
GunEffects LocalScript | StarterPlayerScripts | Draws a short beam on every player's screen for each shot the server confirms. |
GunFire RemoteEvent | ReplicatedStorage (made by GunService) | Client to server: the aim point. |
GunReload RemoteEvent | ReplicatedStorage (made by GunService) | Client to server: a request to reload. |
GunShot RemoteEvent | ReplicatedStorage (made by GunService) | Server to every client: where to draw the shot. |
ReplicatedStorage
GunFire RemoteEvent new
GunReload RemoteEvent new
GunShot RemoteEvent new
ServerScriptService
GunService Script new
StarterPack
Blaster Tool new
Handle Part new
GunClient LocalScript new
StarterPlayer
StarterPlayerScripts
GunEffects LocalScript new
Step 1: Build the Blaster Tool
The Tool is what the player holds. Its Handle matters most: the player's hand grips it, and every shot starts from its centre.
- In the Explorer, hover over StarterPack, click the + button and insert a Tool. Rename it
Blaster. The server script only accepts shots from a held Tool with exactly this name. - Inside the Tool, insert a Part and name it
Handle. For a first test the Handle can be the whole blaster: a new Part is a 4 by 1 by 2 brick, so set its Size to about0.6, 1, 2.5. Make sure Anchored is unticked, or the character gets stuck in place when it equips the tool. - To add more parts, such as a grip or a sight, put them inside the Tool, untick Anchored on them too, and join each one to the Handle with a WeldConstraint so the blaster stays in one piece. Keep only one part named Handle.
- Select the Blaster and untick CanBeDropped in the Properties window. When it is ticked, pressing Backspace drops the blaster into the Workspace.
You do not need to add an ammo value. The server keeps the ammo in an Ammo attribute on the Tool and treats a blaster that has no attribute yet as a full magazine. The way the Handle faces does not affect aiming either: the server aims from the Handle's centre towards the point the player picked.
Step 2: Add the server script
This Script does all the deciding. It creates the three RemoteEvents itself, so there is nothing else to insert, and checks every shot before it casts a ray. Hover over ServerScriptService, insert a Script named GunService and paste this in:
-- Script in ServerScriptService named GunService.
-- The client only says where the player is aiming. The server checks the shot,
-- casts its own ray from the gun, deals the damage and tells everyone to draw it.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
-- Settings
local GUN_NAME = "Blaster" -- the Tool's name in StarterPack
local DAMAGE = 25
local HEADSHOT_MULTIPLIER = 2
local FIRE_INTERVAL = 0.25 -- seconds between shots; keep the same value in GunClient
local MAX_RANGE = 300 -- studs
local MAGAZINE_SIZE = 12
local RELOAD_SECONDS = 1.5
local FRIENDLY_FIRE = false
-- Shots sent exactly FIRE_INTERVAL apart can arrive a little closer together after a
-- network delay, so the server allows this much slack instead of dropping a real shot
local NETWORK_SLACK = 0.05
local function newRemote(name: string): RemoteEvent
local remote = Instance.new("RemoteEvent")
remote.Name = name
remote.Parent = ReplicatedStorage
return remote
end
local fireEvent = newRemote("GunFire") -- client to server: the aim point
local reloadEvent = newRemote("GunReload") -- client to server: please reload
local shotEvent = newRemote("GunShot") -- server to every client: draw this shot
local lastShot: { [Player]: number } = {}
local reloading: { [Tool]: boolean } = {}
-- Returns the gun the player is holding, but only while they are alive
local function getHeldGun(player: Player): (Tool?, BasePart?)
local character = player.Character
local humanoid = character and character:FindFirstChildOfClass("Humanoid")
if not (character and humanoid) or humanoid.Health <= 0 then
return nil, nil
end
local gun = character:FindFirstChild(GUN_NAME)
local handle = gun and gun:FindFirstChild("Handle")
if gun and gun:IsA("Tool") and handle and handle:IsA("BasePart") then
return gun, handle
end
return nil, nil
end
local function reload(gun: Tool)
local ammo = gun:GetAttribute("Ammo")
if reloading[gun] or ammo == nil or ammo == MAGAZINE_SIZE then -- nil: never fired, so already full
return
end
reloading[gun] = true
gun:SetAttribute("Reloading", true)
task.wait(RELOAD_SECONDS)
reloading[gun] = nil
if gun.Parent then
gun:SetAttribute("Ammo", MAGAZINE_SIZE)
gun:SetAttribute("Reloading", false)
end
end
local function isTeammate(attacker: Player, target: Player?): boolean
if FRIENDLY_FIRE or not target then
return false
end
return not attacker.Neutral and not target.Neutral and attacker.Team == target.Team
end
local function onFire(player: Player, aimPoint: unknown)
-- 1. The only argument is a position. Check its type, and refuse NaN, which is never equal to itself.
if typeof(aimPoint) ~= "Vector3" or aimPoint ~= aimPoint then
return
end
-- 2. Fire rate, held gun and ammo are all decided here, not on the client
local now = os.clock()
local last = lastShot[player]
if last and now - last < FIRE_INTERVAL - NETWORK_SLACK then
return
end
local gun, handle = getHeldGun(player)
if not (gun and handle) or reloading[gun] then
return
end
local stored = gun:GetAttribute("Ammo")
local ammo: number = if type(stored) == "number" then stored else MAGAZINE_SIZE -- no attribute yet: a full magazine
if ammo <= 0 then
task.spawn(reload, gun)
return
end
-- 3. The server casts its own ray: from the gun, towards the aim point, at most MAX_RANGE long
local origin = handle.Position
local offset = aimPoint - origin
if offset.Magnitude < 0.5 or offset.Magnitude > 100000 then
return -- also refuses an infinite position, which would break the ray
end
local params = RaycastParams.new()
params.ExcludeInstances = { player.Character :: Model } -- skip the shooter's own body and blaster
-- A blaster poking through a thin wall must not fire from the far side of it
local root = (player.Character :: Model):FindFirstChild("HumanoidRootPart")
if root and root:IsA("BasePart") and workspace:Raycast(root.Position, origin - root.Position, params) then
return
end
lastShot[player] = now -- only a shot that is really fired uses the fire rate and a round
gun:SetAttribute("Ammo", ammo - 1)
local direction = offset.Unit * MAX_RANGE
local result = workspace:Raycast(origin, direction, params)
-- 4. Damage whatever Humanoid the ray hit
local hitPosition = origin + direction
if result then
hitPosition = result.Position
local model = result.Instance:FindFirstAncestorOfClass("Model")
local target = model and model:FindFirstChildOfClass("Humanoid")
if model and target and target.Health > 0 and not isTeammate(player, Players:GetPlayerFromCharacter(model)) then
local damage = if result.Instance.Name == "Head" then DAMAGE * HEADSHOT_MULTIPLIER else DAMAGE
target:TakeDamage(damage)
end
end
-- 5. Every client draws the shot, from the gun to where the server's ray ended
shotEvent:FireAllClients(handle, hitPosition)
end
fireEvent.OnServerEvent:Connect(onFire)
reloadEvent.OnServerEvent:Connect(function(player: Player)
local gun = getHeldGun(player)
if gun then
reload(gun)
end
end)
Players.PlayerRemoving:Connect(function(player: Player)
lastShot[player] = nil
end)
Ammo and the reload state are attributes on the Tool, Ammo and Reloading. Attributes the server sets replicate to the player's device, so GunClient can show them. A change a player makes to their own copy never reaches the server.
| Setting | Value | What it changes |
|---|---|---|
DAMAGE | 25 | Damage per body hit. A Humanoid starts with 100 Health, so four body hits knock out a default character. |
HEADSHOT_MULTIPLIER | 2 | A hit on a part named Head does 25 times 2, so 50. |
FIRE_INTERVAL | 0.25 | Seconds between shots, so GunClient sends at most four a second. Keep the same number in GunClient. |
NETWORK_SLACK | 0.05 | How much closer together than FIRE_INTERVAL two shots may arrive, because network delay can bunch them up. |
MAX_RANGE | 300 | How far a shot reaches, in studs. |
MAGAZINE_SIZE | 12 | Shots before a reload. Keep the same number in GunClient. |
RELOAD_SECONDS | 1.5 | How long a reload takes. |
FRIENDLY_FIRE | false | When false, players on the same team cannot hurt each other. Players with no team (Neutral) can always be hit. |
The checks in onFire run in a fixed order, and each one stops the shot if it fails: the argument must be a real position, the player must not be firing faster than the fire rate, they must be alive and holding the blaster, the blaster must not be reloading, and it must have ammo. An empty magazine starts a reload instead of firing. The aim point must also be at a sensible distance from the Handle. Last, a short ray from the character's HumanoidRootPart to the Handle must hit nothing, so a blaster pushed through a thin wall does not fire from the far side. Only then does the server spend a round and cast the ray, so a refused shot costs nothing.
A reload request from the client goes through the same getHeldGun check, and reload ignores a blaster that is already full or already reloading.
Step 3: Aim and fire from the client
- In the Explorer, hover over the Blaster Tool in StarterPack and insert a LocalScript named
GunClient. - Replace its contents with the code below.
-- LocalScript inside the Blaster Tool, named GunClient.
-- Works out where the player is aiming (mouse, tap or the screen centre on a gamepad),
-- sends that point to the server, and shows the ammo while the gun is held.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local UserInputService = game:GetService("UserInputService")
local ContextActionService = game:GetService("ContextActionService")
local tool = script.Parent :: Tool
local fireEvent = ReplicatedStorage:WaitForChild("GunFire") :: RemoteEvent
local reloadEvent = ReplicatedStorage:WaitForChild("GunReload") :: RemoteEvent
local FIRE_INTERVAL = 0.25 -- the same value as in GunService
local MAGAZINE_SIZE = 12 -- the same value as in GunService
local AIM_DISTANCE = 1000
local player = Players.LocalPlayer
local lastShot = 0
local lastTouch: Vector2? = nil
-- Phones have no mouse, so remember where the last tap on the 3D world was
UserInputService.TouchStarted:Connect(function(touch: InputObject, processedByUI: boolean)
if not processedByUI then
lastTouch = Vector2.new(touch.Position.X, touch.Position.Y)
end
end)
local function getAimPoint(): Vector3
local camera = workspace.CurrentCamera
local ray: Ray
local input = UserInputService.PreferredInput
if input == Enum.PreferredInput.Touch and lastTouch then
ray = camera:ScreenPointToRay(lastTouch.X, lastTouch.Y) -- a touch Position is measured below the top bar, as this expects
elseif input == Enum.PreferredInput.KeyboardAndMouse then
local mouse = UserInputService:GetMouseLocation()
ray = camera:ViewportPointToRay(mouse.X, mouse.Y)
else -- a gamepad, or a phone before its first tap: the centre of the screen
local size = camera.ViewportSize
ray = camera:ViewportPointToRay(size.X / 2, size.Y / 2)
end
local params = RaycastParams.new()
params.ExcludeInstances = { player.Character :: Model }
local result = workspace:Raycast(ray.Origin, ray.Direction * AIM_DISTANCE, params)
return if result then result.Position else ray.Origin + ray.Direction * AIM_DISTANCE
end
tool.Activated:Connect(function()
local now = os.clock()
if now - lastShot < FIRE_INTERVAL then
return
end
lastShot = now
if UserInputService.PreferredInput == Enum.PreferredInput.Touch then
task.wait() -- wait one frame so TouchStarted has recorded this tap, whichever event fires first
end
fireEvent:FireServer(getAimPoint())
end)
-- R on a keyboard, X on a gamepad, and an on-screen button on touch devices
local function onReloadAction(_actionName: string, state: Enum.UserInputState, _input: InputObject): Enum.ContextActionResult
if state == Enum.UserInputState.Begin then
reloadEvent:FireServer()
end
return Enum.ContextActionResult.Pass
end
-- A small ammo label, shown only while the gun is out
local ammoGui = Instance.new("ScreenGui")
ammoGui.Name = "AmmoGui"
-- ResetOnSpawn stays at its default (true): this label belongs to one life of the character
local ammoLabel = Instance.new("TextLabel")
ammoLabel.AnchorPoint = Vector2.new(1, 1)
ammoLabel.Position = UDim2.new(1, -24, 1, -24)
ammoLabel.Size = UDim2.fromOffset(140, 44)
ammoLabel.BackgroundColor3 = Color3.fromRGB(20, 16, 30)
ammoLabel.BackgroundTransparency = 0.25
ammoLabel.TextColor3 = Color3.new(1, 1, 1)
ammoLabel.FontFace = Font.fromEnum(Enum.Font.BuilderSansBold)
ammoLabel.TextSize = 24
ammoLabel.Parent = ammoGui
local function updateAmmo()
local ammo = tool:GetAttribute("Ammo")
if tool:GetAttribute("Reloading") == true then
ammoLabel.Text = "Reloading..."
else
ammoLabel.Text = `{if type(ammo) == "number" then ammo else MAGAZINE_SIZE} / {MAGAZINE_SIZE}`
end
end
tool:GetAttributeChangedSignal("Ammo"):Connect(updateAmmo)
tool:GetAttributeChangedSignal("Reloading"):Connect(updateAmmo)
local isOut = false -- true while this blaster is equipped
local diedConnection: RBXScriptConnection? = nil
local function putAway()
if not isOut then
return -- already put away, so the Reload action may now belong to another blaster
end
isOut = false
ammoGui.Parent = nil
ContextActionService:UnbindAction("ReloadGun")
if diedConnection then
diedConnection:Disconnect()
diedConnection = nil
end
end
tool.Equipped:Connect(function()
isOut = true
updateAmmo()
ammoGui.Parent = player:WaitForChild("PlayerGui")
ContextActionService:BindAction("ReloadGun", onReloadAction, true, Enum.KeyCode.R, Enum.KeyCode.ButtonX)
ContextActionService:SetTitle("ReloadGun", "Reload") -- the text on the touch button
-- Unequipped may not fire if the holder dies with the blaster out, so hide the label on Died too
local humanoid = tool.Parent and tool.Parent:FindFirstChildOfClass("Humanoid")
if humanoid and not diedConnection then
diedConnection = humanoid.Died:Connect(putAway)
end
end)
tool.Unequipped:Connect(putAway)
-- Also tidy up if the blaster leaves the game while it is out, for example with a character removed without dying
tool.AncestryChanged:Connect(function()
if not tool:IsDescendantOf(game) then
putAway()
end
end)
Tool.Activated fires for a mouse click, a tap on the screen and the right trigger on a gamepad, so one handler serves every device. The client keeps its own copy of the fire rate only to avoid sending shots the server would refuse anyway.
How the aim point is found
The player aims at a spot on their screen. getAimPoint turns that spot into a ray from the camera into the world, casts it up to 1,000 studs, and returns where it lands, or the end of the ray if it lands nowhere. That 3D point is the only thing sent to the server. Which spot on the screen depends on UserInputService.PreferredInput, which reports whether the player is mainly using a keyboard and mouse, touch or a gamepad.
| Input | Spot on the screen | Camera method |
|---|---|---|
| Keyboard and mouse | The pointer, from UserInputService:GetMouseLocation() | Camera:ViewportPointToRay. Both measure from the very top of the screen, top bar included, so they match. |
| Touch | The last tap on the 3D world, remembered from UserInputService.TouchStarted. Taps that a button handled are skipped. A tap fires both TouchStarted and Tool.Activated, so the fire handler waits one frame on touch before it aims. | Camera:ScreenPointToRay, because a touch position is measured from below the top bar, which is what this method expects. |
| Gamepad, or anything else | The centre of the screen, from Camera.ViewportSize. A phone that has not recorded a tap yet aims here too. | Camera:ViewportPointToRay. Add a crosshair in the middle of the screen so controller players can see where that is. |
Reload and the ammo label
ContextActionService:BindAction ties the reload to R on a keyboard and X on a gamepad. Its third argument, true, also asks for an on-screen button on touch-enabled devices, and SetTitle writes "Reload" on it. The action is bound when the blaster is equipped and unbound when it is put away, so R does nothing while the player holds something else.
The ammo label is a small ScreenGui that the script builds itself. It shows the server's Ammo attribute, or "Reloading..." while Reloading is true, and updates whenever the server changes either one. When the blaster is unequipped, the player dies while holding it, or the blaster leaves the game while it is out, putAway hides the label and removes the Reload action. It only acts while this blaster is out, so it never removes a second blaster's Reload action.
Step 4: Draw the shot for everyone
- In the Explorer, expand StarterPlayer, hover over StarterPlayerScripts and insert a LocalScript named
GunEffects. - Paste the code below.
-- LocalScript in StarterPlayerScripts named GunEffects.
-- Draws every shot the server confirms, for every player, as a short neon beam.
local Debris = game:GetService("Debris")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local shotEvent = ReplicatedStorage:WaitForChild("GunShot") :: RemoteEvent
local BEAM_SECONDS = 0.08
shotEvent.OnClientEvent:Connect(function(handle: BasePart, hitPosition: Vector3)
if not handle or not handle.Parent then
return -- the gun is gone, or has not streamed in on this device
end
local origin = handle.Position
local distance = (hitPosition - origin).Magnitude
local beam = Instance.new("Part")
beam.Anchored = true
beam.CanCollide = false
beam.CanQuery = false
beam.CanTouch = false
beam.Material = Enum.Material.Neon
beam.Color = Color3.fromRGB(255, 214, 90)
beam.Size = Vector3.new(0.12, 0.12, distance)
beam.CFrame = CFrame.lookAt(origin, hitPosition) * CFrame.new(0, 0, -distance / 2)
beam.Parent = workspace
Debris:AddItem(beam, BEAM_SECONDS)
end)
The beam is a thin yellow Neon part that lives for 0.08 seconds. Debris:AddItem schedules its removal, so the script never has to wait.
After every shot, the server fires GunShot to all clients with two values: the blaster's Handle and the point where the server's ray ended. Each device then draws its own short beam between them. The server makes no parts at all, and every player sees the shot, including the shooter.
- The beam never gets in the way. CanCollide, CanQuery and CanTouch are all off, so players walk through it, it sets off no Touched events, and no later raycast can hit it. Raycasts skip parts whose CanQuery is off; that setting only counts once CanCollide is off, which is why both are set.
- The Handle can arrive as nil. When a RemoteEvent passes an object the receiving device cannot see, Roblox passes
nilinstead. With instance streaming on, a far-away shooter's blaster may not exist on this device yet, so the script skips that beam. - The shooter's beam follows the network delay. It appears when the server's message arrives, so on a slow connection it shows a moment after the click. If that bothers you, draw a beam in GunClient straight away and, in GunEffects, skip shots whose Handle belongs to the local player's character. The damage still comes only from the server.
How the server's raycast decides a hit
workspace:Raycast(origin, direction, params) traces a line from origin along direction and returns a RaycastResult for the first part it meets, or nil if it meets nothing. The length of direction is how far the ray reaches: anything further away is not tested, and Roblox allows at most 15,000 studs.
GunService builds that direction as offset.Unit * MAX_RANGE: the direction from the Handle to the aim point, stretched to exactly 300 studs. Every shot therefore reaches the full range, whether the player aimed at a nearby wall or at the sky. The aim point only says which way to shoot.
- Not hitting yourself. The ray starts at the shooter's own blaster, which is part of their character while it is held, right next to their arm.
params.ExcludeInstances = { player.Character }tells the raycast to skip the character and everything inside it. Older scripts setFilterTypetoExcludeand fillFilterDescendantsInstances; that still works, but Roblox's reference now recommendsExcludeInstancesfor new work. - Finding the target.
result.Instanceis the part that was hit.FindFirstAncestorOfClass("Model")climbs from that part to the first object above it whose class is exactly Model. For a body part, a hat or a held tool, that is the character. If that Model has a Humanoid with Health left, the server callsTakeDamageon it. - Headshots. If the part hit is named
Head, the damage is doubled. Hats and hair are separate parts, so a shot that only clips a large hat counts as a body hit. - Players and NPCs alike. The test is "a Model with a Humanoid", so the blaster damages dummies and NPCs as well as players. The team check only applies when both sides are players.
- Damage that respects ForceFields.
Humanoid:TakeDamagelowers Health unless a ForceField protects the Humanoid, such as the one a SpawnLocation gives players just after they spawn. SettingHealthdirectly ignores ForceFields.
Patterns from older gun tutorials
Many gun scripts online still use code that Roblox has replaced, or that trusts the client. If you are fixing one, these are the lines to look for.
| Older code | The problem | What this guide does |
|---|---|---|
workspace:FindPartOnRay or FindPartOnRayWithIgnoreList with Ray.new | Roblox marks the FindPartOnRay functions deprecated and points to WorldRoot:Raycast with RaycastParams. A Ray itself is fine; Raycast simply does not need one. | workspace:Raycast with RaycastParams |
Enum.RaycastFilterType.Blacklist or Whitelist | The enum now lists only Exclude and Include. | ExcludeInstances |
Player:GetMouse() and mouse.Hit | The Mouse object is legacy, superseded by UserInputService and ContextActionService. | The pointer, the last tap or the screen centre, then the server's own ray |
| The client sends the character it hit (Roblox's own laser tutorial does this) | Roblox's security guide says the client should not tell the server who to damage. An exploiter can name any player. | The client sends one position, and the server finds the target |
| The fire rate is checked only in the LocalScript | Roblox's security guide says never to rely only on a client-side rate limit. | FIRE_INTERVAL is checked in GunService |
humanoid.Health -= damage | Setting Health directly ignores ForceFields. | Humanoid:TakeDamage |
A bullet part that deals damage in its Touched event | Touched events follow network ownership, so a client can fire them for parts it owns even when the server saw no touch. | A ray cast on the server |
wait() and tick() for timing | wait() is deprecated and superseded by task.wait(). tick() is not deprecated, but Roblox's reference lists its problems and suggests os.clock() or time(). | task.wait, os.clock and Debris:AddItem |
What exploiters can and cannot do
Exploiters run their own code on their device. They can read every LocalScript and fire GunFire with any value, as often as they like. Roblox's security guide has a checklist for exactly this case, weapon targeting: check where the shot comes from, walls in the way, the fire rate, the ammo, teammates, that the target is alive, and states such as reloading. GunService covers that list, mostly by never trusting the client's idea of a hit at all.
| An exploiter tries to | What GunService does |
|---|---|
| Fire faster than the fire rate | Refuses the shot. The server's own clock allows one shot per 0.25 seconds, with 0.05 seconds of slack for network delay, so a cheater gains at most that slack. |
| Keep firing with an empty magazine, or while reloading | Refuses. The ammo and the reload live on the server, and an empty magazine starts a reload instead of firing. |
| Send NaN, an infinite position or something that is not a Vector3 | Refuses. typeof rejects other types, NaN is never equal to itself, and an infinite position fails the distance check. |
| Shoot through walls | Refuses. The server's own ray from the blaster stops at the first wall, and a ray from the HumanoidRootPart to the Handle must hit nothing, so a blaster pushed through a thin wall cannot fire from the far side. A character moved inside a wall needs the movement check in the next row. |
| Damage a player they choose | Cannot. There is no argument for it: the server finds the target with its own ray. |
| Shoot from a position they make up | Cannot. The ray starts at the server's copy of the Handle, not at a position the client sends. A player's device does move its own character, though, so speed and teleport cheats need a separate movement check. |
| Shoot while dead, or without holding the blaster | Refuses. getHeldGun needs a living character holding a Tool named Blaster. |
| Hurt teammates | Refuses while FRIENDLY_FIRE is false and both players are on the same team. |
| Aim perfectly with an aimbot | Cannot be stopped by these checks. Aiming is the client's job, and a perfect aim point looks like any other Vector3. |
On a single shot, no server check can tell a great player from an aimbot. A server can watch many shots, for example accuracy no person could keep up, and treat that as one signal: Roblox's server-side detection guide describes heuristics like this and a suspicion score that adds up several signals before acting.
Casting the ray on the server has one cost. The server checks each shot against where everything is when the shot arrives. Because of network delay, other players are not quite where the shooter saw them, so a shot at a fast-moving player can miss on the server even though it looked like a hit on the shooter's screen. Roblox's laser tag sample takes the other route: the client reports its hit, the server checks the angle and the distance against tolerance values, and casts its own ray to see whether a wall was in the way. That is kinder to players on slow connections but needs more checking code. For a first gun, the server's own ray is the safer place to start.
The same rules apply to every remote you write: check the type and the value of each argument, and let the server decide the outcome. The RemoteEvents guide covers that validation in more detail.
Step 5: Test it
- Add a dummy to shoot at: open the Avatar tab and click Character to open the Rig Builder, then choose R15 and Block Avatar. Move the dummy a short walk away from the spawn.
- Press Play and equip the blaster by pressing 1 or clicking it in the hotbar. The label in the bottom right corner reads 12 / 12.
- Shoot the dummy's body twice. A yellow beam flashes each time, a health bar appears over the dummy, and its Health drops from 100 to 75 and then to 50. Then shoot its head: 50 damage knocks it out.
- Keep firing at a wall. The beam stops at the wall, and the label counts down to 0 / 12. The next click shows "Reloading..." for 1.5 seconds, then the label returns to 12 / 12. Fire a few shots and press R to reload early.
- Reset your character from the in-game menu while holding the blaster. After the respawn there is no leftover ammo label, and the new blaster shows 12 / 12 when you equip it.
- Test with two players: choose Server & Clients in the test mode menu, set 2 clients and press Play. Each player sees the other's beams, and a body hit takes 25 Health from the other player. Wait until the ForceField from the SpawnLocation has gone first:
TakeDamagedoes nothing while it is up. - Check phones and controllers with Studio's Device Simulator and Controller Emulator. On a phone, a tap on the dummy fires where you tapped, the first tap included, and a Reload button appears while the blaster is out. With a gamepad, the right trigger fires at the centre of the screen and X reloads.
If nothing happens when you click, open the Output window from Studio's Window menu and look for a red error or an "Infinite yield possible" warning. Common errors below lists the usual causes, and the script errors guide explains each message.
Extending it: automatic fire, spread, projectiles
Hold to fire
For an automatic blaster, start a loop in GunClient when tool.Activated fires and stop it on tool.Deactivated, which fires when the player releases the button or lifts their finger. Each pass of the loop sends one aim point with fireEvent:FireServer(getAimPoint()) and then waits FIRE_INTERVAL with task.wait. Nothing changes on the server: GunService still refuses anything faster than its own fire rate, so a modified client cannot turn it into a faster gun.
Shotgun spread
A shotgun casts several rays per shot. Keep the client sending one aim point and let the server work out the pellets: turn the aim direction by a few degrees for each pellet, cast one ray per pellet with the same RaycastParams, and add up the damage for each target. Roblox's laser tag sample builds its spread blaster the same way, in a function called getDirectionsForBlast. Take one round from the magazine per trigger pull, not per pellet.
Projectiles: arrows, rockets and grenades
A slow or falling shot needs a projectile. Create it on the server, move it a short step every frame (for example from RunService.Heartbeat), and on each step cast a ray along the stretch it just covered, so a fast projectile cannot skip through a thin wall between two frames. When a step's ray hits something, deal the damage and remove the projectile. Do not use the projectile's Touched event for damage, for the network ownership reason in the table above.
Kills, rounds and a shop
To count kills, check the target right after TakeDamage. GunService only damages targets with Health above 0, so if Health is 0 after the call, that shot was the kill. Add 1 to a Kills value in the shooter's leaderstats, as in awarding points in the leaderboard guide, and fire a RemoteEvent to all clients with both names if you want a kill feed.
To hand out the blaster only during a round, move it from StarterPack to ServerStorage and give it with the GiveRoundTool script from the round system guide, with RoundTool in that script changed to Blaster. To sell it instead, add it to the ShopItems folder from the shop GUI guide with a matching catalogue entry. For close combat, the sword guide uses the same split: the client asks to swing, and the server checks the cooldown and finds who was hit.
Common errors and fixes
Open the Output window (Window menu, then Output) before you press Play. Most gun problems print a warning or an error there, with the script name and line number.
Infinite yield possible on 'ReplicatedStorage:WaitForChild("GunFire")'
GunClient is waiting for a RemoteEvent that GunService creates, so GunService is not running. Check that it is a Script, not a LocalScript, directly inside ServerScriptService, and that Output shows no earlier error from it.
Clicking does nothing at all
Check three things in order. The Tool has exactly one part named Handle as a direct child, or the player cannot hold it. GunClient is a LocalScript inside the Tool. The Tool is named Blaster, the same as GUN_NAME in GunService, which ignores shots from a Tool with any other name. If it only fails right next to a wall, the blaster is poking through the wall, and GunService refuses that shot on purpose.
My character gets stuck when it equips the blaster
A part of the blaster is anchored. Untick Anchored on the Handle and on every other part inside the Tool.
Shots stop at glass or an invisible wall
A raycast ignores CanCollide by default. If players can walk through the part, turn off its CanQuery too, or set params.RespectCanCollide = true in GunService. If the part is solid, such as window glass, CanQuery has no effect while CanCollide is on: put it in a ShootThrough folder and exclude that folder, as shown in How the server's raycast decides a hit.
The beam hits an NPC but it takes no damage
GunService looks for a Humanoid directly inside the first Model above the part that was hit. If the NPC's parts sit in a Model inside the NPC, the search stops at that inner Model; ungroup it so the parts sit directly in the NPC, as they do in a Rig Builder dummy. A ForceField on the NPC also blocks TakeDamage.
Headshots only do normal damage
The head part is not named Head, which happens with some custom rigs, or the shot hit a hat or hair, which are separate parts. Rename the part, or extend the check in GunService.
Teammates cannot hurt each other
That is FRIENDLY_FIRE = false at work: players on the same team are skipped. Set it to true to allow team damage.
Build it with RoCode
If you would rather not wire this up by hand, RoCode can build it in the place you have open. It is an independent AI agent for Roblox Studio, not made by Roblox: you describe the feature in its web chat, and its Studio plugin creates the Tool and the scripts in your Explorer, so you are not copying code out of a chat window.
Add a raycast blaster: a Tool named Blaster in StarterPack, a LocalScript that sends the aim point on Activated, a server Script that casts the ray from the Handle, does 25 damage (double on the head), allows one shot every 0.25 seconds, 12 shots then a 1.5 second reload, and a LocalScript that draws a short neon beam for everyone.
- Can look through your place first with Search Scripts and Read Script, so it builds on weapon or leaderstats code you already have instead of adding a second copy
- Creates the Tool and its Handle with Create Instance, sets properties such as CanBeDropped with Set Property, and writes GunService, GunClient and GunEffects with Create Script
- Is told to check every script it writes with Check Script, which compiles the code inside Studio without running it. That catches syntax errors, not aiming logic
- Sets an undo point before and after each batch of changes, so Ctrl+Z steps back one batch at a time
RoCode cannot insert Roblox's Weapons Kit or other Creator Store models, upload a gun mesh or make animations, so build or import those yourself. With plugin 3.9 or newer RoCode can run a short playtest and read the errors, but it does not aim, click or move a character, so you test the shots yourself with Server & Clients. For the bigger picture, see making a whole Roblox game with AI, system by system. How RoCode connects to Studio.
Questions
Why does my gun shoot myself or stop in mid-air?
The ray starts at your own blaster, right next to your arm and body, so without a filter it can hit you first. Exclude your character with params.ExcludeInstances = { player.Character }, as GunService does. If shots still stop in empty space, look for an invisible part there: raycasts hit parts even when their CanCollide is off.
Should a Roblox gun use raycasting or projectiles?
Raycasting hits instantly, which suits lasers and most guns, and the server can check a shot with a single ray. Projectiles suit slow or falling shots such as arrows and rockets: move the projectile a step at a time on the server and raycast the short distance it covered on each step.
Why does my gun hit on my screen but deal no damage?
The hit was decided in a LocalScript, so it only happened on your device. Send the aim point to the server with a RemoteEvent and let a server Script cast the ray and call TakeDamage, as this guide does. The Script vs LocalScript guide explains why.
What does FE mean in a gun script?
FE is short for FilteringEnabled, an old Workspace setting about whether changes made on a player's device reach the server. Roblox's reference lists the property as discontinued: it no longer takes effect, and most changes a LocalScript makes stay on that device (the player's own character movement is the main exception). A gun that "works with FE" is simply one where the server does the shooting and the damage, like this one.
How do I add headshots?
Check whether the part the ray hit is named Head and multiply the damage, as GunService does with HEADSHOT_MULTIPLIER. Hats are separate parts, so a shot that only clips a large hat counts as a body hit.
Can I stop aimbots?
Not with these checks. Aiming happens on the player's device, and a perfect aim point looks like any other. The server stops impossible shots: too fast, without ammo, through walls, out of range or while dead. Spotting aim cheats takes statistics on the server over many shots, treated as one signal among several.
Sources and further reading
- Roblox Creator Docs: WorldRoot:Raycast. origin, direction length, the 15,000 stud limit and the deprecated FindPartOnRay functions
- Roblox Creator Docs: RaycastParams. ExcludeInstances and RespectCanCollide
- Roblox Creator Docs: Securing the client-server boundary. the weapon targeting checklist, NaN and rate limiting
- Roblox Creator Docs: Detect hits. Roblox's laser tag sample: blast validation, spread, CanCollide and TakeDamage with ForceFields
- Roblox Creator Docs: In-game tools. the Handle, StarterPack and the Activated, Deactivated, Equipped and Unequipped events
- Roblox Creator Docs: Remote events and callbacks. FireAllClients and objects that arrive as nil
- Roblox Creator Docs: Input. UserInputService.PreferredInput for mouse, touch and gamepad
- Roblox Creator Docs: ContextActionService. BindAction, touch buttons and SetTitle
- Roblox Creator Docs: Server-side detection and consequencing. heuristics and suspicion scores
How the code was checked: every script on this page passes Luau's strict type checker against Roblox's API definitions (luau-lsp, 13 October 2026). A type check catches misspelt APIs and wrong types, not game logic, so playtest in Studio before you publish.