Build a Server-Authoritative Weapon System
Build a raycast weapon system in Roblox using OOP, modular services, and secure networking.
Introduction
30 Second Summary
In multiplayer games, every click of a weapon triggers a chain of trust decisions. The server needs to verify that the shot is legitimate before applying damage, because clients can be manipulated by exploiters.
In this project, you will build a professional-grade raycast weapon system in Roblox using metatable-based OOP, modular service architecture, and server-authoritative hit detection.
What You'll Build
A complete weapon system where the client fires intent to the server, and the server validates every input before performing hit detection and applying damage.
By the end of this project, you'll have:
- A Weapon class built with metatables that uses a typed constructor, methods, and cooldown enforcement.
- A WeaponService singleton using the ModuleScript service pattern that handles server-side validation and raycasting hit detection.
- A client-side WeaponController that handles player input and sends fire intent to the server via RemoteEvents with a full 5-step server validation pipeline that prevents exploits.
- Secret Mission: Add weapon inheritance with a Shotgun class that fires multiple spread rays from a single shot.
Are there any prerequisites?
You should be comfortable with basic Roblox scripting concepts like events, variables, and functions. You also need Roblox Studio installed on your computer.
This project teaches you the architecture patterns that separate hobbyist scripters from commission developers earning $60-100/hr. No prior OOP or networking experience is needed.
Set Up the Project Structure
Every professional Roblox system starts with a clean architecture. Without proper organization, scripts become tangled and impossible to maintain as the system grows.
In this step, you'll create the folder structure and script files that form the skeleton of your weapon system. You'll also enable strict type checking to catch bugs at edit time instead of at runtime.
In this step, get ready to:
- Set up the folder hierarchy in Explorer.
- Create the four script files for the weapon system.
- Enable strict type checking across the entire experience.
Create the folder structure
Your weapon system needs three containers in the Explorer panel to keep server code, shared code, and networking separated.
- In the Explorer panel on the right side of Roblox Studio, find ReplicatedStorage.
- Right-click on ReplicatedStorage and select Insert Object.
- Search for Folder and click it to insert.
- Rename the new folder to Weapons.
- Right-click on ReplicatedStorage again and select Insert Object.
- Search for RemoteEvent and click it to insert.
- Rename it to WeaponAction.
- Now find ServerScriptService in Explorer.
- Right-click on ServerScriptService and select Insert Object > Folder.
- Rename the new folder to Services.
Why this folder structure?
ReplicatedStorage holds code that both the client and server can access. Your Weapon class lives here because both sides need the same cooldown logic.
ServerScriptService holds code that only the server can see. Your WeaponService lives here because exploiters must never be able to read your validation logic.
The RemoteEvent named WeaponAction is the single communication bridge between client and server for weapon actions.
Your Explorer panel should now show Weapons and WeaponAction inside ReplicatedStorage, and Services inside ServerScriptService.
Don't see the new objects?
Make sure you right-clicked on the correct parent (ReplicatedStorage or ServerScriptService) and not on a child inside them. If an object ended up in the wrong place, drag it to the correct parent in Explorer.
help me find the right place to insert objects in Explorer
Create the script files
Now you'll add four scripts to the folder structure. Each script type has a specific role in the Roblox execution model.
- Right-click on the Weapons folder inside ReplicatedStorage.
- Select Insert Object > ModuleScript.
- Rename it to Weapon.
- Right-click on the Services folder inside ServerScriptService.
- Select Insert Object > ModuleScript.
- Rename it to WeaponService.
- Right-click on ServerScriptService itself (not the Services folder).
- Select Insert Object > Script.
- Rename it to ServerInit.
- In Explorer, expand StarterPlayer and find StarterPlayerScripts.
- Right-click on StarterPlayerScripts and select Insert Object > LocalScript.
- Rename it to WeaponController.
What is a ModuleScript?
A ModuleScript is a reusable code container that other scripts load with require(). It runs once and returns the same reference on every subsequent require() call.
A regular Script runs on the server automatically. A LocalScript runs on the client (inside the player's game session). ModuleScripts only run when another script requires them.
Verify your Explorer hierarchy matches this structure:
- ReplicatedStorage > Weapons > Weapon (ModuleScript).
- ReplicatedStorage > WeaponAction (RemoteEvent).
- ServerScriptService > Services > WeaponService (ModuleScript).
- ServerScriptService > ServerInit (Script).
- StarterPlayer > StarterPlayerScripts > WeaponController (LocalScript).
Script ended up in the wrong location?
Drag the script to the correct parent in Explorer. Make sure ServerInit is directly under ServerScriptService, not inside the Services folder.
help me fix my Explorer hierarchy for the weapon system scripts
Enable strict type checking
Luau's strict type checking catches type errors at edit time instead of letting them crash at runtime. Professional commission developers use strict mode on every script.
- In Explorer, click on Workspace to select it.
- In the Properties panel, scroll down to the Scripting section.
- Find LuauTypeCheckMode and set it to Strict (value 3).
What does strict mode do?
Strict mode forces you to annotate types on function parameters and return values. The type solver then checks every call site against those annotations and flags mismatches with red underlines in the script editor.
Setting LuauTypeCheckMode on Workspace applies the default for all scripts in the experience. Adding --!strict at the top of each file reinforces it per-script, so the setting is explicit even if the workspace property changes later.
Now add the strict mode directive to each of your four script files.
- Double-click the Weapon ModuleScript in Explorer to open it in the script editor.
- Select all the default code and delete it.
- Type the following at the top of the file:
--!strict
- Repeat this for the other three scripts: open each one, clear the default code, and add --!strict at the top.
- Open WeaponService, clear it, and add --!strict.
- Open ServerInit, clear it, and add --!strict.
- Open WeaponController, clear it, and add --!strict.
Verify by checking the script editor tab for each file. You should see only --!strict at line 1 with no red underlines.
Seeing red underlines or errors?
Make sure you deleted all the default code before adding --!strict. The default ModuleScript template includes local module = {} and return module which you don't need right now.
help me fix type checking errors in my Roblox scripts
✔️ Awesome, I've got everything!
Great. Double check you've saved your place file with Ctrl+S (Windows) or Cmd+S (macOS).
ⓧ I'd like to double check the full code
Each of your four script files should contain only this single line:
--!strict
The same single line applies to WeaponService, ServerInit, and WeaponController. No other code should be in any of these files yet.
Your Explorer hierarchy should look like this:
- ReplicatedStorage > Weapons > Weapon (ModuleScript).
- ReplicatedStorage > WeaponAction (RemoteEvent).
- ServerScriptService > Services > WeaponService (ModuleScript).
- ServerScriptService > ServerInit (Script).
- StarterPlayer > StarterPlayerScripts > WeaponController (LocalScript).
- Workspace > LuauTypeCheckMode set to Strict.
Your project skeleton is ready. Next up, you'll fill the Weapon ModuleScript with a full metatable-based class that has typed constructors and methods.
Build the Weapon Class with Metatables
Your project structure is ready. Every script file is in the right place, strict mode is enabled, and the networking channel is set up.
Now you need the core building block of the weapon system: a Weapon class. In professional Roblox codebases, classes are built using metatables. A metatable lets you define methods once on a shared table, then give every instance access to those methods through the __index metamethod.
In this step, get ready to:
- Define exported types for the Weapon class with strict annotations.
- Build a constructor and firing methods using metatable-based OOP.
- Test the module to confirm metatable inheritance works.
Define the Weapon types and constructor
Every class in strict Luau starts with type definitions. These tell the type checker what shape your data takes, so errors get caught at edit time instead of runtime.
- Open the Weapon ModuleScript inside ReplicatedStorage > Weapons by double-clicking it in the Explorer panel.
- Below the --!strict line, add the following type definitions:
export type WeaponConfig = {
name: string,
damage: number,
fireRate: number,
maxDistance: number,
}
export type Weapon = typeof(setmetatable({} :: {
name: string,
damage: number,
fireRate: number,
maxDistance: number,
_lastFireTime: number,
}, {} :: { __index: any }))
What do these types do?
export type WeaponConfig defines the shape of the configuration table you pass when creating a weapon. Any script that requires this module can use this type for its own annotations.
export type Weapon uses typeof(setmetatable(...)) to describe what a Weapon instance looks like after construction. This lets strict mode validate every property access on any Weapon object across your entire codebase.
- Check that no red underlines appear in your code. The Script Editor highlights type errors as you type.
Seeing red underlines?
Make sure every field inside the type definitions ends with a comma. Missing commas on the last field before the closing brace are the most common cause of type-annotation errors.
help me fix type annotation errors in my Weapon ModuleScript
- Now build the class table and constructor by adding this code below the type definitions:
local Weapon = {}
Weapon.__index = Weapon
function Weapon.new(config: WeaponConfig): Weapon
local self = setmetatable({}, Weapon)
self.name = config.name
self.damage = config.damage
self.fireRate = config.fireRate
self.maxDistance = config.maxDistance
self._lastFireTime = 0
return self :: any
end
What does this code do?
Weapon.__index = Weapon is the metatable magic. When you call a method on an instance and the instance doesn't have that method directly, Lua follows the __index chain and finds it on the Weapon table instead.
setmetatable({}, Weapon) creates a fresh table and connects it to the Weapon class. Each property from config is copied onto self so every instance carries its own data while sharing the same methods.
- Confirm no red underlines appear in the constructor code.
Red underline on the return statement?
The :: any cast on the return is intentional. Strict mode sometimes struggles with complex metatable return types, so the cast tells the checker to trust your type annotation on the function signature.
help me understand why my Weapon constructor has a type error on the return
Add the cooldown and fire methods
The weapon needs two behaviors: checking whether it can fire (cooldown enforcement) and actually firing (stamping the time and returning damage).
- Add the following methods below the constructor, before the final return Weapon line:
function Weapon.canFire(self: Weapon): boolean
local currentTime = os.clock()
local timeSinceLastFire = currentTime - self._lastFireTime
return timeSinceLastFire >= (1 / self.fireRate)
end
function Weapon.fire(self: Weapon): number
self._lastFireTime = os.clock()
return self.damage
end
What does this code do?
canFire calculates how much time has passed since the last shot using os.clock(). It divides 1 by the fire rate to get the minimum seconds between shots. If enough time has passed, it returns true.
fire stamps the current time onto _lastFireTime (so the next canFire check knows when the weapon last fired) and returns the damage value. The server uses this return value to apply damage to the target.
- Add the module's return statement at the very bottom of the file:
return Weapon
- Save the file. Check that no red underlines appear anywhere in your Weapon ModuleScript.
Seeing type errors on the methods?
Make sure the explicit self: Weapon parameter is the first argument in each method. Luau uses this to type-check all property accesses inside the method body.
help me fix type errors on my Weapon methods
✔️ Awesome, I've got everything!
Great. Double check you've saved the file before moving on.
ⓧ I'd like to double check the full code
Here is the complete Weapon ModuleScript at this point:
--!strict
export type WeaponConfig = {
name: string,
damage: number,
fireRate: number,
maxDistance: number,
}
export type Weapon = typeof(setmetatable({} :: {
name: string,
damage: number,
fireRate: number,
maxDistance: number,
_lastFireTime: number,
}, {} :: { __index: any }))
local Weapon = {}
Weapon.__index = Weapon
function Weapon.new(config: WeaponConfig): Weapon
local self = setmetatable({}, Weapon)
self.name = config.name
self.damage = config.damage
self.fireRate = config.fireRate
self.maxDistance = config.maxDistance
self._lastFireTime = 0
return self :: any
end
function Weapon.canFire(self: Weapon): boolean
local currentTime = os.clock()
local timeSinceLastFire = currentTime - self._lastFireTime
return timeSinceLastFire >= (1 / self.fireRate)
end
function Weapon.fire(self: Weapon): number
self._lastFireTime = os.clock()
return self.damage
end
return Weapon
Test the module in ServerInit
The class looks correct on paper, but you need to prove that metatable inheritance actually works. Methods defined on the Weapon table should resolve through __index when called on an instance.
- Open the ServerInit Script inside ServerScriptService by double-clicking it in the Explorer panel.
- Replace the contents with this temporary test code:
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Weapon = require(ReplicatedStorage:WaitForChild("Weapons"):WaitForChild("Weapon"))
-- Create a test weapon instance
local testWeapon = Weapon.new({
name = "TestRifle",
damage = 25,
fireRate = 5,
maxDistance = 300,
})
-- Verify metatable inheritance works
print("Weapon name:", testWeapon.name)
print("Can fire:", testWeapon:canFire())
print("Damage dealt:", testWeapon:fire())
print("Can fire again immediately:", testWeapon:canFire())
What does this test prove?
The instance testWeapon is a plain table. It doesn't have canFire or fire directly on it. When you call testWeapon:canFire(), Lua sees the method is missing, follows the metatable's __index to the Weapon table, and finds it there.
The last print should show false because fire() just updated _lastFireTime and the cooldown hasn't elapsed yet.
- Save ServerInit.
- Make sure the Output window is visible. If you don't see it, click the **View** tab in the top menu bar, then click **Output**.
- Press the **Play** button in the top toolbar to run the experience.
- Check the Output window. You should see four lines printed with the Server context label:
Weapon name: TestRifle
Can fire: true
Damage dealt: 25
Can fire again immediately: false
- Press the **Stop** button to end the playtest.
Not seeing any output?
Check that the Output window's context filter is set to show **Server** messages. Click the funnel icon in the Output window and make sure Server is enabled.
If you see a red error instead of the expected prints, double-check that the require path matches your Explorer hierarchy: ReplicatedStorage > Weapons > Weapon.
help me debug why my ServerInit test isn't printing anything to Output
- Now that you've confirmed the metatable works, remove the test code. Replace the contents of ServerInit with just the strict mode annotation:
--!strict
- Save ServerInit.
Your Weapon class is built and proven. Next up, you'll create the WeaponService that manages weapons for every player and handles the server-side security pipeline.
Build the WeaponService
You have a Weapon class that knows its own stats and cooldown logic. Now you need something to manage weapons across all players and make authoritative decisions about whether a shot is valid.
This is where the WeaponService comes in. It follows the singleton service pattern used in professional Roblox games. One module, one instance, total authority over weapon logic on the server.
In this step, get ready to:
- Build the WeaponService singleton with player tracking and weapon assignment.
- Implement a 5-step server validation pipeline that rejects exploiter input.
- Add server-authoritative raycasting and damage application.
Create the singleton service pattern
A singleton service is a ModuleScript that exposes one table with an Init function. When the server starts, a single script calls Init to wire everything up. This mirrors how professional commission codebases organize server logic.
- In Roblox Studio's Explorer panel, click on the WeaponService ModuleScript inside ServerScriptService > Services to open it in the script editor.
- Replace the existing --!strict stub with the following service setup code:
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
-- Require the shared Weapon class from ReplicatedStorage
local Weapon = require(ReplicatedStorage:WaitForChild("Weapons"):WaitForChild("Weapon"))
local WeaponService = {}
-- Get the RemoteEvent for client-server weapon communication
local weaponAction: RemoteEvent = ReplicatedStorage:WaitForChild("WeaponAction") :: RemoteEvent
-- Track each player's weapon instance and cooldown timestamp
local playerWeapons: { [Player]: any } = {}
local playerCooldowns: { [Player]: number } = {}
-- Maximum allowed distance between player position and claimed fire origin
local MAX_ORIGIN_DISTANCE = 10
What does this code do?
This is the "top" of your service file. It grabs references to the services and modules it needs, then sets up the state it will manage.
- playerWeapons maps each Player to their Weapon instance so the server knows their damage and fire rate.
- playerCooldowns tracks the last fire timestamp per player for server-side cooldown enforcement.
- MAX_ORIGIN_DISTANCE is the maximum studs an exploiter could claim their shot originated from while still being near their character.
Now add the Init function and return statement at the bottom of the same file, directly below the code you just wrote:
function WeaponService.Init()
-- Assign a Rifle weapon to every player who joins
Players.PlayerAdded:Connect(function(player: Player)
playerWeapons[player] = Weapon.new({
name = "Rifle",
damage = 25,
fireRate = 5,
maxDistance = 300,
})
end)
-- Clean up when a player leaves
Players.PlayerRemoving:Connect(function(player: Player)
playerWeapons[player] = nil
playerCooldowns[player] = nil
end)
-- Connect the RemoteEvent to the handler (defined above Init)
weaponAction.OnServerEvent:Connect(onWeaponAction)
end
return WeaponService
What does Init do?
Init is the entry point that boots the service. It does three things:
- Connects to PlayerAdded to assign a fresh Weapon instance to every joining player.
- Connects to PlayerRemoving to clean up state and prevent memory leaks.
- Connects the RemoteEvent to onWeaponAction which you will define in the next substep.
Your script will show an error right now
The onWeaponAction reference in Init does not exist yet. Luau requires local functions to be defined above where they are referenced. You will define it between the constants and the Init function in the next substep, which resolves the error.
Write the 5-step server validation pipeline
Every RemoteEvent fired from a client should be treated as untrusted input from the public internet. Exploiters can send any data type, any value, at any rate. Your validation pipeline rejects bad input at each step before it reaches game logic.
- In the WeaponService file, find the line local MAX_ORIGIN_DISTANCE = 10 and add the following two helper functions below it (above the WeaponService.Init function):
local function isValidVector3(value: any): boolean
-- Step 2: Reject if not a Vector3 type
if typeof(value) ~= "Vector3" then
return false
end
-- Step 3: Reject if any component is NaN (NaN ~= NaN is true)
if value.X ~= value.X or value.Y ~= value.Y or value.Z ~= value.Z then
return false
end
return true
end
local function getHumanoidFromPart(part: BasePart): Humanoid?
-- Walk up the instance tree to find the character Model, then its Humanoid
local model = part:FindFirstAncestorOfClass("Model")
if model then
return model:FindFirstChildOfClass("Humanoid")
end
return nil
end
What do these helpers do?
- isValidVector3 covers validation steps 2 and 3: it confirms the value is actually a Vector3, then checks for NaN by exploiting the property that NaN never equals itself.
- getHumanoidFromPart takes a hit BasePart, walks up to its parent Model, and finds the Humanoid. This is how you go from "the raycast hit this part" to "deal damage to this character."
- Below the helper functions (still above WeaponService.Init), add the onWeaponAction handler with the full validation pipeline:
local function onWeaponAction(player: Player, action: any, data: any)
-- Step 1: Type-check the arguments
if typeof(action) ~= "string" then
return
end
if typeof(data) ~= "table" then
return
end
if action ~= "fire" then
return
end
-- Step 2 + 3: Validate Vector3 types and check for NaN
if not isValidVector3(data.origin) or not isValidVector3(data.direction) then
return
end
-- Step 5: Validate distance between player and claimed origin
local character = player.Character
if not character then
return
end
local humanoidRootPart = character:FindFirstChild("HumanoidRootPart") :: BasePart?
if not humanoidRootPart then
return
end
local distanceFromPlayer = (humanoidRootPart.Position - data.origin).Magnitude
if distanceFromPlayer > MAX_ORIGIN_DISTANCE then
return
end
local weapon = playerWeapons[player]
if not weapon then
return
end
-- Step 4: Enforce fire rate cooldown per-player
local lastFire = playerCooldowns[player] or 0
local now = os.clock()
if now - lastFire < (1 / weapon.fireRate) then
return
end
playerCooldowns[player] = now
end
How does the 5-step validation pipeline work?
Each check uses an early return to silently reject bad input. Exploiters get no feedback about why their request failed. Here is the pipeline:
- Step 1: Type-check that action is a string and data is a table. Exploiters can send any Luau type.
- Steps 2-3: Confirm data.origin and data.direction are valid Vector3 values with no NaN components.
- Step 4: Enforce the weapon's fire rate cooldown server-side. Even if the client fires faster, the server rejects excess shots.
- Step 5: Check that the claimed fire origin is within MAX_ORIGIN_DISTANCE studs of the player's actual position. Prevents teleport exploits.
Why silent returns instead of error messages?
Never tell exploiters what check they failed. Silent return gives them zero information about your validation logic. They have to guess what is being checked.
- Save the WeaponService file.
- The error on onWeaponAction in your Init function should now resolve since the local function is defined above it.
Still seeing an error on onWeaponAction?
Make sure local function onWeaponAction is placed ABOVE function WeaponService.Init() in the file. Luau resolves local references top-to-bottom. If Init comes first, the identifier does not exist yet.
Help me fix the function ordering in my WeaponService
Add server-authoritative raycasting and damage
The validation pipeline confirmed the input is safe. Now the server performs the actual raycast from its authoritative world state. The client never decides if it hit someone.
- In the WeaponService file, find the line playerCooldowns[player] = now inside onWeaponAction.
- Add the following raycast and damage code below it, replacing the existing end of the function:
-- Server performs the authoritative raycast
local raycastParams = RaycastParams.new()
raycastParams.FilterDescendantsInstances = { character }
raycastParams.FilterType = Enum.RaycastFilterType.Exclude
local direction = data.direction.Unit * weapon.maxDistance
local raycastResult = workspace:Raycast(data.origin, direction, raycastParams)
-- If the ray hit something, check if it has a Humanoid and deal damage
if raycastResult then
local hitHumanoid = getHumanoidFromPart(raycastResult.Instance)
if hitHumanoid then
hitHumanoid:TakeDamage(weapon.damage)
print("[WeaponService]", player.Name, "dealt", weapon.damage, "damage to", raycastResult.Instance.Parent.Name)
end
end
-- Confirm the shot back to the firing client
weaponAction:FireClient(player, "fireConfirmed", {
hit = raycastResult ~= nil,
position = if raycastResult then raycastResult.Position else data.origin + direction,
})
end
What does the raycast code do?
- RaycastParams.new() creates a filter that excludes the firing player's own character so they cannot shoot themselves.
- workspace:Raycast() fires a ray from the validated origin in the normalized direction, scaled to the weapon's max distance. This is the server's view of the world.
- Humanoid:TakeDamage() subtracts health while respecting ForceFields. The debug print logs every successful hit.
- FireClient() sends a confirmation back to the shooter with hit status and position. The client can use this for visual feedback later.
Why does the server raycast instead of trusting the client's hit?
If the client told the server "I hit Player2 for 25 damage," an exploiter could claim hits on anyone anywhere. By raycasting server-side, the server independently verifies what the ray actually intersects in its authoritative world state.
Now you need a script to actually call WeaponService.Init() when the server starts.
- In Explorer, click on the ServerInit Script inside ServerScriptService to open it.
- Replace the --!strict stub with the following initialization script:
--!strict
local ServerScriptService = game:GetService("ServerScriptService")
-- Require and initialize the WeaponService
local WeaponService = require(ServerScriptService:WaitForChild("Services"):WaitForChild("WeaponService"))
WeaponService.Init()
print("[ServerInit] All services initialized")
Why a separate ServerInit script?
In the singleton service pattern, each service is a ModuleScript that cannot run itself. A single "boot" script (ServerInit) requires each service and calls Init in the correct order. This gives you one place to control startup sequence as your game grows.
- Save both WeaponService and ServerInit.
- Press F5 to playtest. Check the Output window for [ServerInit] All services initialized printed without errors.
Seeing a require error or nil value?
- Confirm the WeaponService ModuleScript is inside ServerScriptService > Services (not ReplicatedStorage). The path in WaitForChild must match your Explorer hierarchy exactly.
- Check that WeaponService.Init is defined as a function on the table, not as a local function. It must be function WeaponService.Init() so it is accessible from ServerInit after requiring.
- Make sure you typed return WeaponService at the very end of the WeaponService file. Without it, require() returns nil.
Help me debug my WeaponService require error
✔️ Awesome, I've got everything!
Great. Double check you have saved both WeaponService and ServerInit before moving on.
ⓧ I'd like to double check the full code
Here is the complete WeaponService ModuleScript:
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local Weapon = require(ReplicatedStorage:WaitForChild("Weapons"):WaitForChild("Weapon"))
local WeaponService = {}
local weaponAction: RemoteEvent = ReplicatedStorage:WaitForChild("WeaponAction") :: RemoteEvent
local playerWeapons: { [Player]: any } = {}
local playerCooldowns: { [Player]: number } = {}
local MAX_ORIGIN_DISTANCE = 10
local function isValidVector3(value: any): boolean
if typeof(value) ~= "Vector3" then
return false
end
if value.X ~= value.X or value.Y ~= value.Y or value.Z ~= value.Z then
return false
end
return true
end
local function getHumanoidFromPart(part: BasePart): Humanoid?
local model = part:FindFirstAncestorOfClass("Model")
if model then
return model:FindFirstChildOfClass("Humanoid")
end
return nil
end
local function onWeaponAction(player: Player, action: any, data: any)
if typeof(action) ~= "string" then
return
end
if typeof(data) ~= "table" then
return
end
if action ~= "fire" then
return
end
if not isValidVector3(data.origin) or not isValidVector3(data.direction) then
return
end
local character = player.Character
if not character then
return
end
local humanoidRootPart = character:FindFirstChild("HumanoidRootPart") :: BasePart?
if not humanoidRootPart then
return
end
local distanceFromPlayer = (humanoidRootPart.Position - data.origin).Magnitude
if distanceFromPlayer > MAX_ORIGIN_DISTANCE then
return
end
local weapon = playerWeapons[player]
if not weapon then
return
end
local lastFire = playerCooldowns[player] or 0
local now = os.clock()
if now - lastFire < (1 / weapon.fireRate) then
return
end
playerCooldowns[player] = now
local raycastParams = RaycastParams.new()
raycastParams.FilterDescendantsInstances = { character }
raycastParams.FilterType = Enum.RaycastFilterType.Exclude
local direction = data.direction.Unit * weapon.maxDistance
local raycastResult = workspace:Raycast(data.origin, direction, raycastParams)
if raycastResult then
local hitHumanoid = getHumanoidFromPart(raycastResult.Instance)
if hitHumanoid then
hitHumanoid:TakeDamage(weapon.damage)
print("[WeaponService]", player.Name, "dealt", weapon.damage, "damage to", raycastResult.Instance.Parent.Name)
end
end
weaponAction:FireClient(player, "fireConfirmed", {
hit = raycastResult ~= nil,
position = if raycastResult then raycastResult.Position else data.origin + direction,
})
end
function WeaponService.Init()
Players.PlayerAdded:Connect(function(player: Player)
playerWeapons[player] = Weapon.new({
name = "Rifle",
damage = 25,
fireRate = 5,
maxDistance = 300,
})
end)
Players.PlayerRemoving:Connect(function(player: Player)
playerWeapons[player] = nil
playerCooldowns[player] = nil
end)
weaponAction.OnServerEvent:Connect(onWeaponAction)
end
return WeaponService
And the complete ServerInit Script:
--!strict
local ServerScriptService = game:GetService("ServerScriptService")
local WeaponService = require(ServerScriptService:WaitForChild("Services"):WaitForChild("WeaponService"))
WeaponService.Init()
print("[ServerInit] All services initialized")
Your server now has a complete weapon pipeline. It assigns weapons to players, validates every input, raycasts authoritatively, and deals damage. Next up, you will build the client-side controller that captures mouse input and sends fire intent to this service.
Build the Client WeaponController
Your server is ready to validate and process weapon fire requests. Now you need to build the client-side script that captures player input and sends that fire intent to the server.
The WeaponController is a LocalScript that runs on the player's machine. It detects mouse clicks, calculates where the player is aiming using raycasts from the camera, and sends that data to the server. The client never decides if a hit lands. It only sends the intent.
In this step, get ready to:
- Set up service references and create a local weapon instance for client-side prediction.
- Write a function that converts the 2D mouse position into a 3D world target.
- Connect input handling and fire the weapon intent to the server.
Set up service references and create a local weapon instance
The client needs access to the same Weapon class the server uses. By creating a local weapon instance, the client can predict cooldowns instantly without waiting for a server round-trip. This makes the weapon feel responsive.
- In the Explorer panel, expand StarterPlayerScripts and double-click the WeaponController LocalScript to open it.
- Replace the existing --!strict stub with the following code:
--!strict
-- Get services needed for input handling, shared modules, and player reference
local UserInputService = game:GetService("UserInputService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
-- Require the shared Weapon class from ReplicatedStorage
local Weapon = require(ReplicatedStorage:WaitForChild("Weapons"):WaitForChild("Weapon"))
-- References to the local player and the RemoteEvent
local player = Players.LocalPlayer
local weaponAction: RemoteEvent = ReplicatedStorage:WaitForChild("WeaponAction") :: RemoteEvent
-- Create a local weapon instance for client-side cooldown prediction
local localWeapon = Weapon.new({
name = "Rifle",
damage = 25,
fireRate = 5,
maxDistance = 300,
})
What does this code do?
The client requires the exact same Weapon module that the server uses. This is the power of shared ModuleScripts in ReplicatedStorage. Both sides run identical cooldown logic.
The local weapon instance lets the client instantly reject clicks that happen too fast (client-side prediction). The server still enforces its own cooldown independently, so exploiters cannot bypass it.
Why share the same class on client and server?
Without client-side prediction, the player clicks and nothing happens until the server responds (a round-trip delay). With a local weapon instance, the client can immediately tell the player "too fast" without waiting. The server still has the final say on whether the shot counts.
- Save WeaponController by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Confirm there are no red underlines in the Script Editor. The strict type checker should show no errors at this point.
Seeing red underlines on the require line?
Make sure the Weapon ModuleScript is inside ReplicatedStorage > Weapons (not ServerScriptService). The client cannot access anything inside ServerScriptService.
Also check that the ModuleScript is named exactly Weapon (capital W, no extra spaces).
Help me debug red underlines in my WeaponController require statement.
Write the getMouseWorldPosition() function
When the player clicks, you need to know where in 3D space they are aiming. The mouse exists in 2D screen space, so you need to convert it into a 3D ray that shoots from the camera into the world.
- Add the following function below the localWeapon variable:
local function getMouseWorldPosition(): Vector3
-- Get the 2D mouse position on screen
local mouseLocation = UserInputService:GetMouseLocation()
local camera = workspace.CurrentCamera
-- Convert 2D screen position into a 3D ray from the camera
local screenToWorldRay = camera:ViewportPointToRay(mouseLocation.X, mouseLocation.Y)
local directionVector = screenToWorldRay.Direction * localWeapon.maxDistance
-- Set up raycast params to ignore the local player's character
local character = player.Character
local raycastParams = RaycastParams.new()
if character then
raycastParams.FilterDescendantsInstances = { character }
raycastParams.FilterType = Enum.RaycastFilterType.Exclude
end
-- Raycast into the world to find what the player is aiming at
local raycastResult = workspace:Raycast(screenToWorldRay.Origin, directionVector, raycastParams)
if raycastResult then
return raycastResult.Position
else
-- No hit: use the maximum distance endpoint
return screenToWorldRay.Origin + directionVector
end
end
What does this function do?
- GetMouseLocation() returns the 2D pixel coordinates of the mouse cursor on screen.
- ViewportPointToRay() converts those 2D coordinates into a 3D ray with an origin (the camera position) and a direction (where the camera is looking through that pixel).
- The function then raycasts along that direction. If the ray hits something (a wall, the ground, another player), it returns that hit position. If nothing is hit, it returns the endpoint at maximum weapon range.
- The player's own character is excluded from the raycast so you don't accidentally shoot yourself.
- Save WeaponController.
- Confirm no red underlines appear. You won't see a visible change yet. The next chunk connects this function to player input.
Type error on workspace.CurrentCamera?
In strict mode, workspace.CurrentCamera can be nil (if no camera exists). The code works because Roblox always provides a CurrentCamera in normal gameplay. If you see a warning, it is safe to proceed.
Help me understand the CurrentCamera type warning in strict mode.
Connect input handling and fire intent to the server
Now you connect everything together. When the player left-clicks, the controller checks the local cooldown, computes the fire origin and direction, and sends that intent to the server via the RemoteEvent. The client sends intent, not results.
- Add the following code below the getMouseWorldPosition() function:
local function onInputBegan(input: InputObject, gameProcessedEvent: boolean)
-- Ignore input if the game already handled it (e.g. typing in chat)
if gameProcessedEvent then
return
end
-- Only respond to left mouse button clicks
if input.UserInputType ~= Enum.UserInputType.MouseButton1 then
return
end
-- Client-side cooldown prediction for instant feedback
if not localWeapon:canFire() then
return
end
-- Update local cooldown timer
localWeapon:fire()
-- Calculate the ray from the camera through the mouse position
local camera = workspace.CurrentCamera
local mouseLocation = UserInputService:GetMouseLocation()
local screenToWorldRay = camera:ViewportPointToRay(mouseLocation.X, mouseLocation.Y)
-- Get the 3D target and compute origin + direction
local targetPosition = getMouseWorldPosition()
local fireOrigin = screenToWorldRay.Origin
local fireDirection = (targetPosition - fireOrigin).Unit
-- Send fire intent to the server (NOT the result)
weaponAction:FireServer("fire", {
origin = fireOrigin,
direction = fireDirection,
})
end
-- Listen for server confirmation of shots
weaponAction.OnClientEvent:Connect(function(action: any, data: any)
if action == "fireConfirmed" then
print("[WeaponController] Shot confirmed, hit:", data.hit)
end
end)
-- Connect input handler
UserInputService.InputBegan:Connect(onInputBegan)
print("[WeaponController] Weapon ready:", localWeapon.name)
What does this code do?
- gameProcessedEvent prevents the weapon from firing while the player types in chat or interacts with UI.
- The local canFire() check gives instant feedback. If the player clicks too fast, the shot is rejected immediately without a server round-trip.
- FireServer("fire", {origin, direction}) sends the intent to the server. The client says "I want to fire from here, in this direction." The server decides if the shot is valid and if it hits anything.
- The OnClientEvent handler listens for the server's confirmation response after it processes the shot.
Why send intent instead of hit results?
If the client told the server "I hit Player2 for 25 damage," an exploiter could send fake hit messages to kill anyone instantly. By sending only the origin and direction, the server performs its own raycast and decides independently whether the shot hits. This is the core principle of server-authoritative networking.
- Save WeaponController.
- Check the Output window for any red error text. There should be none.
Seeing a type error on InputObject?
Make sure your function signature is exactly function onInputBegan(input: InputObject, gameProcessedEvent: boolean). The type annotation InputObject must be capitalized exactly as shown.
Help me fix type errors in my onInputBegan function.
✔️ Awesome, I've got everything!
Great. Double check you have saved WeaponController and there are no red underlines in the Script Editor.
ⓧ I'd like to double check the full code
Here is the complete WeaponController LocalScript. Compare your code against this:
--!strict
local UserInputService = game:GetService("UserInputService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local Weapon = require(ReplicatedStorage:WaitForChild("Weapons"):WaitForChild("Weapon"))
local player = Players.LocalPlayer
local weaponAction: RemoteEvent = ReplicatedStorage:WaitForChild("WeaponAction") :: RemoteEvent
local localWeapon = Weapon.new({
name = "Rifle",
damage = 25,
fireRate = 5,
maxDistance = 300,
})
local function getMouseWorldPosition(): Vector3
local mouseLocation = UserInputService:GetMouseLocation()
local camera = workspace.CurrentCamera
local screenToWorldRay = camera:ViewportPointToRay(mouseLocation.X, mouseLocation.Y)
local directionVector = screenToWorldRay.Direction * localWeapon.maxDistance
local character = player.Character
local raycastParams = RaycastParams.new()
if character then
raycastParams.FilterDescendantsInstances = { character }
raycastParams.FilterType = Enum.RaycastFilterType.Exclude
end
local raycastResult = workspace:Raycast(screenToWorldRay.Origin, directionVector, raycastParams)
if raycastResult then
return raycastResult.Position
else
return screenToWorldRay.Origin + directionVector
end
end
local function onInputBegan(input: InputObject, gameProcessedEvent: boolean)
if gameProcessedEvent then
return
end
if input.UserInputType ~= Enum.UserInputType.MouseButton1 then
return
end
if not localWeapon:canFire() then
return
end
localWeapon:fire()
local camera = workspace.CurrentCamera
local mouseLocation = UserInputService:GetMouseLocation()
local screenToWorldRay = camera:ViewportPointToRay(mouseLocation.X, mouseLocation.Y)
local targetPosition = getMouseWorldPosition()
local fireOrigin = screenToWorldRay.Origin
local fireDirection = (targetPosition - fireOrigin).Unit
weaponAction:FireServer("fire", {
origin = fireOrigin,
direction = fireDirection,
})
end
weaponAction.OnClientEvent:Connect(function(action: any, data: any)
if action == "fireConfirmed" then
print("[WeaponController] Shot confirmed, hit:", data.hit)
end
end)
UserInputService.InputBegan:Connect(onInputBegan)
print("[WeaponController] Weapon ready:", localWeapon.name)
All four scripts are now fully implemented. Your client captures input, computes aim direction, and fires intent to the server. Next up, you will test the full pipeline end-to-end and verify that the server's validation catches exploits.
Test and Verify the Full Pipeline
You have all four scripts in place. The Weapon class, the WeaponService, the ServerInit script, and the WeaponController are all fully implemented.
Now it is time to prove the system works. You will launch a multiplayer test, fire at another player, and watch the full pipeline execute. Then you will deliberately send bad data to prove your validation catches every exploit attempt.
In this step, get ready to:
- Test the weapon pipeline with a 2-player local server.
- Verify server-side security by sending invalid data.
- Confirm the full end-to-end pipeline with debug output.
Start a local server with 2 players
A single-player playtest cannot test RemoteEvents or damage between characters. You need two simulated players so one can shoot the other.
- In Roblox Studio, look at the top toolbar and find the Test tab.
- Click the dropdown arrow next to the Play button (not the Play button itself).
- Select Server & Clients from the dropdown menu.
- Set the number of players to 2.
- Press the Play button (or press F7) to begin the client-server simulation.
What happens when you press Play?
Roblox Studio opens three separate windows: one for the server and one for each simulated client. Each client window shows a player character standing on the baseplate. You can interact with each client independently.
Position the windows side by side so you can see both clients at the same time. This makes it easy to aim one player at the other.
- Switch to the first client window (labeled Client 1).
- Aim your mouse at the second player's character and left-click to fire.
- Check the Output window in the Server session for any type errors from strict mode.
- Look for the message [WeaponService] Player1 dealt 25 damage to Player2 in the server's Output.
- Check the client Output for [WeaponController] Shot confirmed, hit: true.
How do I know damage was applied?
The default Roblox character has 100 health. Each shot deals 25 damage based on your Rifle config. After 4 clicks on the same player, they will respawn (health reached 0). You can also check Player 2's health bar above their character model.
Not seeing any output messages?
Make sure you are checking the Output window in the Server session, not a client session. Server-side prints only appear in the server Output.
If you see no messages at all, check that your ServerInit script is inside ServerScriptService (not inside the Services folder). It must be a direct child of ServerScriptService.
Help me debug why my weapon isn't firing in the local server test.
- Press End Session from any of the simulation windows to close all sessions.
Test security validation with invalid data
Your 5-step validation pipeline is designed to reject anything that does not look like a legitimate fire request. You will now prove it works by temporarily sending bad data from the client.
- Open the WeaponController LocalScript in StarterPlayerScripts.
- Find the onInputBegan function and locate the line that calls weaponAction:FireServer.
- Find this line:
weaponAction:FireServer("fire", {
origin = fireOrigin,
direction = fireDirection,
})
- Comment out that FireServer call and replace it with the following three test lines:
-- Test 1: Wrong types (action is a number, data is a string)
weaponAction:FireServer(123, "not a table")
-- Test 2: NaN values in vectors
weaponAction:FireServer("fire", { origin = Vector3.new(0/0, 0, 0), direction = Vector3.new(0, 0, 1) })
-- Test 3: Impossible distance (origin far from player)
weaponAction:FireServer("fire", { origin = Vector3.new(9999, 9999, 9999), direction = Vector3.new(0, 0, 1) })
What do these tests prove?
Test 1 sends the wrong data types. Your server checks typeof(action) ~= "string" and typeof(data) ~= "table" to catch this.
Test 2 sends NaN (Not a Number) values. Your server detects NaN using the self-inequality check value.X ~= value.X. Exploiters use NaN to crash physics calculations.
Test 3 sends an origin position thousands of studs away from the player. Your MAX_ORIGIN_DISTANCE check of 10 studs catches this.
- Start the Server & Clients test again with 2 players by pressing Play (or F7).
- In the Client 1 window, click anywhere to fire.
- Check the server Output window.
You should see no damage messages. The server silently returns early for each invalid request without printing any error or applying damage. This is exactly what a production system should do. Never give exploiters feedback about why their requests failed.
- Press End Session to close all sessions.
Seeing damage messages during the invalid data test?
If you still see the [WeaponService] damage print, make sure you commented out the original FireServer call completely. The test lines must be the only active FireServer calls.
Help me figure out why my validation is not rejecting invalid data.
Restore the client and run the final end-to-end test
Your validation works. Now restore the original code and run one final clean test to see the full pipeline from start to finish.
- In the WeaponController LocalScript, delete the three test lines you added.
- Uncomment the original FireServer call so the function looks like this again:
weaponAction:FireServer("fire", {
origin = fireOrigin,
direction = fireDirection,
})
- Start the Server & Clients test one more time with 2 players.
- In the Client 1 window, aim at Player 2 and click to fire.
- Watch the server Output for the full pipeline message:
[WeaponService] Player1 dealt 25 damage to Player2
- Switch to the Client 1 Output and confirm you see:
[WeaponController] Shot confirmed, hit: true
What just happened end-to-end?
The client detected a mouse click, checked the local cooldown, calculated the ray origin and direction from the camera, and sent the intent to the server via FireServer.
The server received the request, validated types, checked for NaN, enforced cooldown, verified the origin distance, then performed its own authoritative raycast. It found a Humanoid, applied 25 damage with TakeDamage, and sent a confirmation back to the client via FireClient.
- Press End Session to close all sessions.
Confirm that your WeaponController matches the original code with no leftover test lines.
✔️ Awesome, I've got everything!
Great. Double check you have no leftover commented-out test lines in the WeaponController.
ⓧ I'd like to double check the full code
--!strict
local UserInputService = game:GetService("UserInputService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local Weapon = require(ReplicatedStorage:WaitForChild("Weapons"):WaitForChild("Weapon"))
local player = Players.LocalPlayer
local weaponAction: RemoteEvent = ReplicatedStorage:WaitForChild("WeaponAction") :: RemoteEvent
local localWeapon = Weapon.new({
name = "Rifle",
damage = 25,
fireRate = 5,
maxDistance = 300,
})
local function getMouseWorldPosition(): Vector3
local mouseLocation = UserInputService:GetMouseLocation()
local camera = workspace.CurrentCamera
local screenToWorldRay = camera:ViewportPointToRay(mouseLocation.X, mouseLocation.Y)
local directionVector = screenToWorldRay.Direction * localWeapon.maxDistance
local character = player.Character
local raycastParams = RaycastParams.new()
if character then
raycastParams.FilterDescendantsInstances = { character }
raycastParams.FilterType = Enum.RaycastFilterType.Exclude
end
local raycastResult = workspace:Raycast(screenToWorldRay.Origin, directionVector, raycastParams)
if raycastResult then
return raycastResult.Position
else
return screenToWorldRay.Origin + directionVector
end
end
local function onInputBegan(input: InputObject, gameProcessedEvent: boolean)
if gameProcessedEvent then
return
end
if input.UserInputType ~= Enum.UserInputType.MouseButton1 then
return
end
if not localWeapon:canFire() then
return
end
localWeapon:fire()
local camera = workspace.CurrentCamera
local mouseLocation = UserInputService:GetMouseLocation()
local screenToWorldRay = camera:ViewportPointToRay(mouseLocation.X, mouseLocation.Y)
local targetPosition = getMouseWorldPosition()
local fireOrigin = screenToWorldRay.Origin
local fireDirection = (targetPosition - fireOrigin).Unit
weaponAction:FireServer("fire", {
origin = fireOrigin,
direction = fireDirection,
})
end
weaponAction.OnClientEvent:Connect(function(action: any, data: any)
if action == "fireConfirmed" then
print("[WeaponController] Shot confirmed, hit:", data.hit)
end
end)
UserInputService.InputBegan:Connect(onInputBegan)
print("[WeaponController] Weapon ready:", localWeapon.name)
Your weapon system is production-ready. The server validates every request, performs its own raycast, and never trusts the client. Next up, you can extend this system with weapon inheritance in the Secret Mission.
Secret mission
Add Weapon Inheritance with a Shotgun Class
Your Weapon class works great for a single-shot rifle. But what if you need a shotgun that fires 8 pellets in a spread pattern? Metatable inheritance lets you extend the base class without rewriting shared logic. Build a Shotgun subclass that inherits all Weapon methods and overrides fire behavior to cast multiple spread rays.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally in Roblox Studio, so there are no ongoing costs. Decide whether to keep your place file open, save it for later, or delete it entirely.
Resources you used:
- Roblox Studio place file (Baseplate with weapon system scripts).
- Weapon ModuleScript inside ReplicatedStorage > Weapons.
- Shotgun ModuleScript inside ReplicatedStorage > Weapons.
- WeaponService ModuleScript inside ServerScriptService > Services.
- ServerInit Script inside ServerScriptService.
- WeaponController LocalScript inside StarterPlayerScripts.
- WeaponAction RemoteEvent inside ReplicatedStorage.
Keep everything running
No action needed. Choose this if you are still actively building on the weapon system or want to extend it further.
- Your Roblox Studio place file stays open with all scripts intact.
- You can continue iterating on the weapon system, adding new weapon types, or experimenting with the validation pipeline.
Pause - I'll come back to this later
Save your place file so you can pick up where you left off later.
- In Roblox Studio, click File, then Save to File.
- Choose a location on your computer and name the file something memorable like WeaponSystem.rbxl.
- Close Roblox Studio. When you are ready to return, double-click the .rbxl file to reopen the place.
Delete - I don't want to use this again
Remove the place file entirely if you do not plan to revisit this project.
- If you saved the place to a file, delete the .rbxl file from your computer.
- If you published the place to Roblox, go to https://create.roblox.com, find the experience under Creations, click the three-dot menu, and select Archive.
- Close Roblox Studio without saving if you have not already saved.
Nice Work!
Nice Work!
You just built a production-ready raycast weapon system using the exact architecture patterns that professional Roblox commission developers use. This is a serious portfolio piece.
Here's what you learned:
- Built a Weapon class using metatable-based OOP with __index inheritance, typed constructors, and methods that resolve through the prototype chain.
- Created a WeaponService singleton using the ModuleScript service pattern and implemented a full 5-step server validation pipeline that rejects exploiter input on RemoteEvents.
- Built a complete client-to-server weapon pipeline where the client sends fire intent and the server authoritatively resolves hits using raycasting.
- Secret Mission: Extended the OOP system with metatable inheritance by creating a Shotgun subclass that overrides fire behavior to cast multiple spread rays.
Ready to quiz yourself?