Creating a modular weapon framework

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
Screenshot 2025-08-13 at 7.56.53 PM
Create a script in ServerScriptService, and name it anything you want
Screenshot 2025-08-13 at 8.04.23 PM

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:

  1. We define our generic variables:
local CAS = game:GetService("ContextActionService")
local handler = require(game.ReplicatedStorage.modules.weaponSystem)
local plr = game.Players.LocalPlayer
  1. 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
}
  1. Create a new object of our class (Will be used later, can be commented out for now)
local weapon = handler.new()
  1. 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.

  1. 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
  1. 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:

  1. 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:

  1. Referencing some variables (again)
local events = game.ReplicatedStorage.events
  1. 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)

  1. 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.

  1. 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

  1. 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!
  1. Add player’s data to the players table 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)
  1. Let’s handle the returnData event 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
  1. Now, lets finish up the equip event
    Before we can begin, we need to make a small addition to the model. In the root part of the gun, throw in an empty Motor6D and 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
  1. Now let’s do the unequip event.
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

  1. Changed the explanation for .__index as it was too complex to understand. Now, it should be more straightforward.
  2. 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:

16 Likes

Pretty nice, Would like it (and so would others) if you supplied the place file for this. Honestly myself and developers like myself like to get into the guts of resources and play with them.

Sure, I’ll update the topic and include the game file at the bottom!

Wait hold on, I forgot how to uncopylock..is it still available?

Havent checked as I am using SPH now (Spear Head) but ill take a look quick.

1 Like

Yeah I don’t see an option to uncopylock a game in create.roblox.com, is it there somewhere?

Nevermind, just attached the game’s rbxl file just to get it over with, you can check out the entire environment in there

1 Like

Thanks, When I bothered to look at this some time in the future ill take a look!

2 Likes