Hey everyone! Welcome to this tutorial on how to create a somewhat simple weapon system with some server checking without using the Roblox tool system! Lets get started!
Firstly, I would like to preface this by addressing all of the newer developers that found this post. Please try and learn from this tutorial, I try my best to explain anything that I do that may be confusing to newer developers. If you still find something difficult to wrap your head around, don’t be afraid to comment on this topic and ask your doubts, since chances are someone will help you understand the code, including me. Just try not to copy and paste the code into your game and be satisfied, as that isn’t the point of a tutorial.
Anyways, on to the actual system now!
Prerequisites
The following tutorial requires basic knowledge of these programming concepts, although they will be somewhat explained throughout the tutorial:
- Lua metatables and modules
- Remote functions
- ContextActionService
- Welding (Not really, you can get a pre welded model from the toolbox)
- Some scripting basics (Debounces, etc.)
Setting up our weapon
First, we want to get our weapon. You can model your own, but I grabbed one from the toolbox.
Now, if the model contains scripts, remove them, and only grab the visible parts. Then, create a part named ‘root’ or anything that you prefer. This will be our primary part, so set the model’s PrimaryPart to the part. Make sure you unanchor and disable can collide on all parts. Weld the other parts to the primary part using WeldConstraints.
Now our weapon is ready to start working!
General Setup
Next, create a few folders in ReplicatedStorage for further use:
In the events folder, add a few RemoteFunctions for now (We will work with these soon).
In modules, create a module and name it whatever you want

Create a script in ServerScriptService, and name it anything you want
Lastly, toss your rigged weapon in the weapons folder.
Handling input
Now, create a new LocalScript inside of your Player > StartCharacterScripts and lets begin coding (finally!) our input system:
Here’s the plan:
- We define our generic variables:
local CAS = game:GetService("ContextActionService")
local handler = require(game.ReplicatedStorage.modules.weaponSystem)
local plr = game.Players.LocalPlayer
- Then, lets create a simple table which maps KeyCodes (Basically objects for detecting key presses) to actual numbers:
local inputBinds = {
[Enum.KeyCode.One] = 1,
[Enum.KeyCode.Two] = 2 -- In case you ever plan on adding secondaries
}
- Create a new object of our class (Will be used later, can be commented out for now)
local weapon = handler.new()
- Let’s use a basic debounce mechanic in order to create a simple cooldown system:
local cooldown = 0.2
local cooling = false
If you want a more secure way on handling input like this, I suggest handling this debounce on the server, where exploiters can’t just change the cooling value.
- ContextActionService fires a function upon pressing the binded key, let’s create that function right now:
local function equip(_, state, object)
if cooling then return end
if state == Enum.UserInputState.Begin then
cooling = true
task.spawn(function()
task.wait(cooldown)
cooling = false
end)
local index = inputBinds[object.KeyCode]
warn("Input received from "..plr.UserId.."("..plr.Name..")"..": "..tostring(object.KeyCode).." ("..index..")") -- Is it unnecessarily long? Yes. Is it also nice to have? Yes!
weapon:equip(index)
end
end
- Finally, we just run a simple loop to bind our inputs:
for i, v in pairs(inputBinds) do
--Per (Enum.Keycode, actual number) in inputBinds do this (For anybody familiar with tuples in python, this may be a more intuitive way of understanding this loop)
CAS:BindAction('equip'..v, equip, true, i)
end
All in all:
local CAS = game:GetService("ContextActionService")
local handler = require(game.ReplicatedStorage.modules.weaponSystem)
local plr = game.Players.LocalPlayer
local inputBinds = {
[Enum.KeyCode.One] = 1,
[Enum.KeyCode.Two] = 2 -- In case you ever plan on adding secondaries
}
local weapon = handler.new()
local cooldown = 0.2
local cooling = false
local function equip(_, state, object)
if cooling then return end
if state == Enum.UserInputState.Begin then
cooling = true
task.spawn(function()
task.wait(cooldown)
cooling = false
end)
local index = inputBinds[object.KeyCode]
warn("Input received from "..plr.UserId.."("..plr.Name..")"..": "..tostring(object.KeyCode).." ("..index..")") -- Is it unnecessarily long? Yes. Is it also nice to have? Yes!
--weapon:equip(index)
end
end
for i, v in pairs(inputBinds) do
--Per (Enum.Keycode, actual number) in inputBinds do this (For anybody familiar with tuples in python, this may be a more intuitive way of understanding this loop)
CAS:BindAction('equip'..v, equip, true, i)
end
Now obviously, we run weapon:equip(index), but that doesn’t really do anything right now, since we never defined it in our module. However, if we just comment that out for now, we can see that our input system works beautifully! (Watch the output console)
Now, let’s head over to our module in ReplicatedStorage > modules:
- Let’s define our module as a class / meta table:
local system = {}
system.__index = system
return system
This basically makes system / any objects of the class system refer back to system when it can’t find an index. Here’s a more intuitive way of explaining this:
local mt = {
[1] = "foo",
}
mt.__index = mt
local otherT = {
[2] = "bar"
}
setmetatable(otherT, mt)
print(otherT[1]) -- This will output 'foo'.
It can output ‘foo’ as the metatable of otherT is set to mt. This means that when there’s an index that isn’t found in otherT, it will try to fall back and check the indexes of mt to see if the given index is valid in that table. If the index given isn’t valid in either table, an error is thrown.
Continuing on:
- Referencing some variables (again)
local events = game.ReplicatedStorage.events
- Making a function to create a new object of the class:
function system.new()
local self = {}
local loadout = events.returnData:InvokeServer().loadout -- We'll work on this part soon, it won't work right now
self.loadout = loadout
return setmetatable(self, system)
end
This creates a new object of the class system named self. setmetatable(self, system) makes it so that all the methods and meta methods of system (in this case, only .__index) are applied upon self, meaning all functions created inside the container system will also be accessible by the object self,
Now you may have seen that we invoked the RemoteFunction get, even though we haven’t even worked with it yet. Well, we are about to as soon as we finish up some other functions. (Remember, you can’t run this on its own before writing the server script as that script handles all the RemoteFunctions, basically making the module and server script go hand in hand with one another)
- Creating a simple equip function:
function system:equip(index)
if index > #self.loadout or index < 0 then return end
local currentData = events.returnData:InvokeServer()
if currentData.curwep and currentData.curwep.name == self.loadout[index] then
self:unequip()
else
local check = events.equip:InvokeServer(index)
if not check.value then warn(check.response) return end -- simple debugging system
end
end
Since we defined self as an object of system earlier, we can now use the keyword self to refer to that earlier object we created anywhere we want throughout our system methods! (Just ensure that the function uses a colon (:) and not a period (.). The colon just makes it so the first argument automatically becomes self, instead of you having to do function system.equip(self, index)
Now, you may see system:unequip(), which is what we are about to do next! Also, yes more events are being fired. Yes, we are going to handle these events soon.
- Creating an unequip function
function system:unequip()
local check = events.unequip:InvokeServer() -- You can integrate this and the equip event into one singular event, but for the sake of simplicity, we will use two distinct events
if not check.value then warn(check.event) return end
end
All in all:
local system = {}
system.__index = system
local events = game.ReplicatedStorage.events
function system.new()
local self = {}
local loadout = events.returnData:InvokeServer().loadout
self.loadout = loadout
return setmetatable(self, system)
end
function system:equip(index)
if index > #self.loadout or index < 0 then return end
local currentData = events.returnData:InvokeServer()
if currentData.curwep and currentData.curwep.name== self.loadout[index] then
self:unequip()
else
local check = events.equip:InvokeServer(index)
if not check.value then warn(check.response) return end -- simple debugging system
end
end
function system:unequip()
local check = events.unequip:InvokeServer() -- You can integrate this and the equip event into one singular event, but for the sake of simplicity, we will use two distinct events
if not check.value then warn(check.event) return end
end
return system
Now, time to handle the server events!
Server event handling
- Defining variables (Yes, again)
local events = game.ReplicatedStorage:WaitForChild("events")
local weapons = game.ReplicatedStorage:WaitForChild("weapons")
local plrs = game.Players
local loadout = {
[1] = "AK-47" -- Name of your primary weapon's model
} -- You can also use DataStores if you want to, I won't for this tutorial
local players = {} -- You'll see what this does soon!
- Add player’s data to the
playerstable once they join the game:
plrs.PlayerAdded:Connect(function(plr)
players[plr.UserId] = {}
local player = players[plr.UserId]
player.loadout = loadout
player.equipped = false
player.canFire = true
player.Character = plr.Character or plr.CharacterAdded:Wait()
player.curwep = nil -- Lots of debounces and empty values. They'll be filled up soon enough
end)
- Let’s handle the
returnDataevent first
events.returnData.OnServerInvoke = function(plr)
if not players[plr.UserId] then return end
return players[plr.UserId] -- simple stuff, just return the player's data if it exists
end
- Now, lets finish up the
equipevent
Before we can begin, we need to make a small addition to the model. In the root part of the gun, throw in an emptyMotor6Dand name it something like “torso”.
events.equip.OnServerInvoke = function(plr, index)
if not players[plr.UserId] then return {value = false, response="Player data not found"} end
local player = players[plr.UserId]
if player.equipped or player.curwep then return {value=false, response="Player already has a weapon equipped"} end -- Could just handle unequipping here using a BindableEvent for anyone that's more advanced
if not player.loadout then return {value=false, response="Player has no loadout"} end
if not player.Character then return {value=false, response="Player has no character"} end
local weaponName = player.loadout[index]
local wep = weapons:FindFirstChild(weaponName)
if not wep then return {value=false, response="Weapon not found"} end
wep = wep:Clone()
wep.Parent = player.Character
local weld: Motor6D = wep.PrimaryPart.torso -- Don't mind the : Motor6D thing, its just for auto fill (I can't live without it)
if not weld then
weld = Instance.new("Motor6D")
weld.Parent = wep.PrimaryPart
weld.Name = "torso"
end
weld.Part1 = wep.PrimaryPart
weld.Part0 = player.Character.Torso -- If you're going to use Torso, make sure you set Avatar settings to R6 only.
player.equipped = true
player.curwep = {name=weaponName} -- You'll see why this is a table in a bit!
return {value=true} -- No need to send a response here!
end
- Now let’s do the
unequipevent.
events.unequip.OnServerInvoke = function(plr)
if not players[plr.UserId] then return {value = false, response="Player data not found"} end
local player = players[plr.UserId]
if not player.equipped then return {value=false, response="Player does not have a weapon equipped"} end
if not player.Character then return {value=false, response="Player does not have a character"} end
if not player.curwep then return {value=false, response="Player does not have a weapon"} end
local weaponName = player.curwep.name
local wep = player.Character:FindFirstChild(weaponName)
if not wep then return {value=false, response="Weapon not found in character"} end
wep:Destroy()
player.equipped = false
player.curwep = nil
return {value=true}
end
Now, this should allow our equip and unequip functions to work as intended! Let’s test (Remember to uncomment the line weapon:equip(index) in your script in StarterPlayer > StarterPlayerScripts):
Animations
Set up a rig and weld your weapon to the model, and get started working on an idle animation! (Excuse my bad animating skills). Make sure your animation is looped and priority is set to idle!
Export your animation, create a new folder in your weapon named animations, and throw the animation in there
Now, back to our module in
ReplicatedStorage > modules
Add this in system.new():
self.loadedAnimations = {} -- used to cache already loaded animations
system:equip():
else
local check = events.equip:InvokeServer(index)
if not check.value then warn(check.response) return end -- simple debugging system
-- Now add this part here
if not self.loadedAnimations[self.loadout[index]] then
self.loadedAnimations[self.loadout[index]] = {} -- If the table doesn't have index of the weapon name, create a new index as an empty table
end
if not self.loadedAnimations[self.loadout[index]].idle then
self.loadedAnimations[self.loadout[index]].idle = game.Players.LocalPlayer.Character.Humanoid:LoadAnimation(game.Players.LocalPlayer.Character:FindFirstChild(self.loadout[index]).animations.idle) -- Make the idle value the loaded idle animation
end
self.loadedAnimations[self.loadout[index]].idle:Play(0) -- Play the idle animation
end
And finally in system:unequip():
First, add this line at the very start of the function:
local currentData = events.returnData:InvokeServer()
Then, after the check, add this:
for _, v in pairs(self.loadedAnimations[currentData.curwep.name]) do
v:Stop() -- Loop through the animations and stop each of them
end
Since locally running animations also replicates to the server, we don’t have to do any of this on the server, helping us split the workload between the client and server. Of course, the server is still doing the majority of the heavy lifting, but at least the client is getting involved.
Perfect, it works!
Conclusion
Well, thats it for now, I guess. If this receives some support I’ll make another part, which will probably include shooting and maybe an ammo system. Thanks everyone for reading through the entire post. If there is anything you would like to report that isn’t working in the code, I’ll gladly fix it. Feedback is much appreciated, and once again thank you for making it through the entirety of this tutorial!
Edit log
- Changed the explanation for
.__indexas it was too complex to understand. Now, it should be more straightforward. - Removed the part regarding the MuzzleFlash, as firing wasn’t covered in this tutorial. 20 likes and I’ll probably do part 2 ( Maybe 15, which is only one more )
Game place file:
- GamePlaceTutorial.rbxl (310.2 KB)







