Designing a Data-Driven Weapon System (FPS Series Part 2)

fps game tutorial weapons

This post is part of Godot FPS tutorial series.

Concept: Before we make a single gun, we’ll design a flexible system to define all our weapons using Godot’s Resource system. This powerful architecture will make adding new weapons incredibly easy later.

In the last tutorial, we built a solid PlayerController that can move, jump, sprint, and crouch. It feels responsive, but an FPS character isn’t complete without something to hold. Right now, our player has a placeholder “gun” that doesn’t do anything.

In this tutorial, we’re going to replace that placeholder with a powerful, flexible, and data-driven weapon system. This is the heart of our FPS game.

The Goal: A System, Not Just a Gun

Instead of hard-coding a single gun, we’re going to build a system that can handle any weapon we can imagine: pistols, shotguns, knives, grenade launchers, you name it. The beauty of this approach is that once the system is built, adding new weapons becomes incredibly easy.

The Bigger Picture

  1. WeaponData (The Blueprint): We’ll create a custom Resource to act as a blueprint for each weapon. It will hold all the data: its name, models, damage, ammo capacity, recoil, and more. This keeps our data separate from our logic.
  2. WeaponManager (The Operator): This will be a node on our Player that reads the WeaponData blueprints. Its job is to manage the player’s inventory, handle switching between weapons, and tell the currently equipped weapon when to fire, reload, or be thrown.
  3. Weapon Scenes (The Physical Objects): We’ll define two types of scenes for each weapon:
    • Equipped Scene: The first-person view model you see in front of the camera.
    • Pickup Scene: The 3D model that exists in the game world when a weapon is dropped or thrown, which can be picked up later.

By the end of this tutorial, we will have a solid foundation for this system in place. We won’t create a specific gun just yet; that’s for the next part! Today is all about building the robust machinery that will power all our future weapons.

Let’s begin!

Creating the WeaponData Blueprint

First, we need a way to define what a weapon is. What’s its name? How much damage does it do? How much ammo does it hold? The best way to store this kind of information in Godot is with a custom Resource. Resources are fantastic because they are data containers that you can save and reuse, like presets.

  1. In the FileSystem dock, create a new script. Let’s save it in a new /Weapons/Definitions folder, and name it weapon_data.gd.
  2. Open the script and replace its contents with the following:
# weapon_data.gd
class_name WeaponData
extends Resource

@export_category("Identification")
@export var weapon_name: String = "Weapon"

@export_category("Scenes")
# The scene for the weapon when it's equipped (first-person view model)
@export var weapon_equipped_scene: PackedScene
# The scene for the weapon when it's on the ground (third-person/pickup model)
@export var weapon_pickup_scene: PackedScene

@export_category("Ammunition")
@export var max_magazine_capacity: int = 1
@export var max_total_ammo: int = 1
@export var reload_time: float = 2.0
@export var uses_ammo: bool = true  # Melee weapons won't use ammo

# --- Ammunition State ---
# These are the actual values that change during gameplay.
# We don't export them because they represent the *state* of a specific
# weapon instance, not its base configuration.
var current_magazine_ammo: int
var total_ammo: int
var is_reloading: bool = false

# We'll fill in the rest of the stats as we go. For now, this is a great start.
# This function will be called to set a weapon's ammo to full when it's first created.
func initialize_ammo() -> void:
	current_magazine_ammo = max_magazine_capacity
	total_ammo = max_total_ammo
	is_reloading = false

Let’s break this down:

  • class_name WeaponData and extends Resource are key. This tells Godot that this script defines a new type of resource we can create in the editor.
  • @export variables are properties we can edit in the Inspector. This is how we’ll configure our different guns without touching any code.
  • We’ve separated our variables into two types:
    • Configuration (@export): Things like max_magazine_capacity. These are the base stats of the weapon, the “factory settings.”
    • State (no @export): Things like current_magazine_ammo. This is the current amount of ammo in the magazine, which changes as you fire and reload. It’s the weapon’s live status.
  • The initialize_ammo() function is a helper to easily reset a weapon’s ammo to its maximum capacity.

To make our WeaponData resource complete for the whole system, let’s add the rest of the properties. Don’t worry about what they all do just yet; we’ll hook them up over time.

Update your WeaponData.gd to match the full script below. I’ve added comments to explain each new category.

# weapon_data.gd
class_name WeaponData
extends Resource

@export_category("Identification")
@export var weapon_name: String = "WeaponEquipped"

@export_category("Scenes")
@export var weapon_equipped_scene: PackedScene  # First-person model
@export var weapon_pickup_scene: PackedScene  # Third-person/dropped model

@export_category("Ammunition")
@export var max_magazine_capacity: int = 1
@export var max_total_ammo: int = 1
@export var reload_time: float = 2.0
@export var uses_ammo: bool = true  # Melee weapons like knife won't use ammo

# Ammunition state - these are the actual values that persist
var current_magazine_ammo: int
var total_ammo: int
var is_reloading: bool = false

@export_category("Stats")
@export var damage: int = 25

@export_category("Feel")
@export var recovery_speed: float = 7.0
# We will create WeaponImpact and CrosshairData resources later
# @export var impacts: Array[WeaponImpact]
# @export var muzzle_flash_scene: PackedScene

@export_category("Recoil")
@export var recoil_kickback: float = 0.1
@export var recoil_rotation_degrees: Vector3 = Vector3(-5, 2, 0)
@export var recoil_impact_multiplier: float = 1.0
@export var recovery_delay: float = 0.0

@export_category("Wall Collision")
@export var retract_distance: float = 0.5
@export var retract_lerp_speed: float = 30.0

@export_category("Melee Specific")
@export var slash_lunge_distance: float = -0.3
@export var slash_rotation_degrees: Vector3 = Vector3(30, 0, -15)
@export var slash_duration: float = 0.1
@export var return_duration: float = 0.2

@export_category("Projectile Specific")
# @export var projectile_scene: PackedScene
@export var projectile_launch_force: float = 20.0

# @export_group("Visuals")
# @export var crosshair_data: CrosshairData

func initialize_ammo() -> void:
	# Initialize ammunition to full capacity using the configured values
	current_magazine_ammo = max_magazine_capacity
	total_ammo = max_total_ammo
	is_reloading = false

(Note: I’ve commented out a few lines that depend on other custom resources we haven’t made yet, like WeaponImpact. This script will work perfectly without them for now.)

Excellent! Our blueprint is ready. We can’t use it yet, but the definition is there. Now, let’s build the machine that will read these blueprints.

Setting up the WeaponManager

The WeaponManager will be a node attached to our player that does all the heavy lifting.

  1. Open your Player.tscn scene.
  2. In the Scene Tree, navigate to Head/Camera3D/GunMount.
  3. Rename the GunMount node to WeaponManager.
  4. Delete the two placeholder MeshInstance3D nodes (TestGunBody and TestGunBarrel) that are children of WeaponManager. The manager will soon be spawning real weapon scenes here.
  5. With the WeaponManager node selected, create and attach a new script. Save it in your Weapons folder as WeaponManager.gd.

Your scene tree should now look like this:

Player (CharacterBody3D)
└─ ...
  └─ Head (Node3D)
     └─ Camera3D
        └─ WeaponManager (Node3D)  <-- Renamed, script attached, children deleted

Building the WeaponManager Script Incrementally

This script is the brain of the operation. We’ll build it up function by function.

The Core Variables and Initialization

First, let’s define what the manager needs to keep track of. It needs a list of all weapons the player owns, and it needs to know which one is currently active.

Open WeaponManager.gd and add the following code:

# WeaponManager.gd
class_name WeaponManager
extends Node3D

# --- Exports ---
# This is where we will drag-and-drop our WeaponData resources in the Inspector.
@export var weapon_data_array: Array[WeaponData]
# A reference to the player body, to prevent us from shooting ourselves.
@export var shooter_body: Node3D

# --- Private Variables ---
var current_weapon_index: int = -1 # -1 means no weapon is equipped
var current_weapon_instance: Node3D # A reference to the currently active weapon scene instance
var weapon_instances: Array[Node3D] = [] # An array to hold all spawned weapon instances

var weapons_initialized: bool = false

The most important variable here is weapon_instances. Why do we need this? When you switch from your pistol to your shotgun, you don’t want the pistol to forget it had only 5 bullets left. We will spawn all the weapons in the player’s inventory at the start, keep them hidden, and store them in this array. When we switch weapons, we just hide one and show another. This preserves the state of each weapon perfectly.

Now, let’s write the code to do that spawning.

Add the _ready and _initialize_all_weapons functions:

# (Add after the variables)

func _ready() -> void:
	# We use call_deferred to ensure this runs after the rest of the scene is ready.
	_initialize_all_weapons.call_deferred()

	# Equip the first weapon by default.
	if not weapon_data_array.is_empty():
		switch_weapon.call_deferred(0)
	else:
		print("WeaponManager: No weapon data assigned.")

# This function creates instances of all our weapons and stores them.
func _initialize_all_weapons() -> void:
	if weapons_initialized:
		return

	# Clear any old instances if this were to be run again
	for child in get_children():
		child.queue_free()
	weapon_instances.clear()

	for weapon_data in weapon_data_array:
		if weapon_data and weapon_data.weapon_equipped_scene:
			# Create an instance of the weapon's equipped scene
			var weapon_instance = weapon_data.weapon_equipped_scene.instantiate()
			weapon_instance.shooter_body = shooter_body

			# We will add a function to our weapon scenes later to pass the data
			if weapon_instance.has_method("set_weapon_data"):
				weapon_instance.set_weapon_data(weapon_data)
			
			# Add it to the manager, but keep it hidden and disabled for now.
			add_child(weapon_instance)
			weapon_instance.visible = false
			weapon_instance.set_process_mode(Node.PROCESS_MODE_DISABLED) # Important for performance!
			
			weapon_instances.append(weapon_instance)
		else:
			# If a weapon entry is invalid, add a null placeholder to keep arrays in sync.
			weapon_instances.append(null)
			printerr("WeaponManager: Invalid WeaponData or missing weapon_equipped_scene.")
	
	weapons_initialized = true

Here’s the logic: _ready kicks off the process. _initialize_all_weapons loops through our weapon_data_array, instantiates the scene for each weapon, adds it as a child, and then immediately hides it and disables its processing. This is crucial for performance – we don’t want 10 weapons running their logic when only one is active.

Switching Weapons

Now that we have a pool of instantiated (but hidden) weapons, we need a way to switch between them. This is surprisingly simple: we just hide the old one and show the new one.

Add the switch_weapon function and its helpers:

# (Add after _initialize_all_weapons)

# This is the core logic for changing the active weapon.
func switch_weapon(index: int) -> void:
	# 1. Basic safety checks.
	if not weapons_initialized or index < 0 or index >= weapon_instances.size():
		return
	
	# 2. Hide the currently equipped weapon, if there is one.
	if current_weapon_instance:
		current_weapon_instance.visible = false
		current_weapon_instance.set_process_mode(Node.PROCESS_MODE_DISABLED)

	# 3. Get the new weapon from our array and make it active.
	current_weapon_instance = weapon_instances[index]
	current_weapon_index = index

	if current_weapon_instance:
		current_weapon_instance.visible = true
		current_weapon_instance.set_process_mode(Node.PROCESS_MODE_INHERIT)
		print("WeaponManager: Switched to weapon %s" % current_weapon_instance.name)
	else:
		printerr("WeaponManager: Weapon instance at index %d is null." % index)


# These are public functions our PlayerController will call.
func switch_to_next_weapon() -> void:
	if weapon_data_array.size() <= 1:
		return
	
	var new_index = current_weapon_index + 1
	if new_index >= weapon_data_array.size():
		new_index = 0 # Wrap around to the beginning
	
	switch_weapon(new_index)

func switch_to_previous_weapon() -> void:
	if weapon_data_array.size() <= 1:
		return
	
	var new_index = current_weapon_index - 1
	if new_index < 0:
		new_index = weapon_data_array.size() - 1 # Wrap around to the end
	
	switch_weapon(new_index)

The logic is straightforward. switch_weapon deactivates the old weapon and activates the new one. The other two functions simply calculate the next index and call switch_weapon.

Firing, Throwing, and Picking Up

The WeaponManager acts as a middle-man. When the player presses the “fire” button, the PlayerController will simply tell the WeaponManager to “attack”. The manager will then pass that command to whatever weapon is currently active.

Let’s add the functions for interacting with the weapons. This includes the logic for throwing a weapon and picking one up. Remember, the pickup_weapon function won’t be called by anything yet, but we’re writing it now so the whole system is logically complete.

Paste the rest of the code into your WeaponManager.gd. The comments explain the purpose of each function.

# (Add after the switching functions)

# --- Public Interaction Functions ---

# Called by the PlayerController to fire the weapon.
func attack() -> void:
	if current_weapon_instance and current_weapon_instance.has_method("attack"):
		current_weapon_instance.attack()

# Called by the PlayerController to reload.
func reload_current_weapon() -> void:
	if current_weapon_instance and current_weapon_instance.has_method("manual_reload"):
		current_weapon_instance.manual_reload()

# --- Weapon Throwing/Pickup Logic ---

func throw_current_weapon(throw_force: Vector3 = Vector3.ZERO) -> bool:
	if not current_weapon_instance:
		return false
	
	# Get the data from the weapon we're about to throw.
	var weapon_data = weapon_data_array[current_weapon_index]
	
	if weapon_data and weapon_data.weapon_pickup_scene:
		# Instantiate the pickup/world scene for the weapon.
		var weapon_pickup = weapon_data.weapon_pickup_scene.instantiate()
		# Add it to the main game world, not as a child of the player.
		get_tree().root.add_child(weapon_pickup)
		weapon_pickup.global_position = current_weapon_instance.global_position
		
		# IMPORTANT: Copy the current weapon's data (including ammo state) to the pickup.
		if weapon_pickup.has_method("set_weapon_data"):
			weapon_pickup.set_weapon_data(weapon_data)
		
		# If it's a RigidBody3D, give it a shove.
		if weapon_pickup is RigidBody3D and throw_force != Vector3.ZERO:
			weapon_pickup.apply_central_impulse(throw_force)
		
		# Now, permanently remove the weapon from our inventory.
		remove_current_weapon()
		return true
	return false

func get_throw_direction(throw_strength: float = 7.5) -> Vector3:
	# We'll use the camera's forward direction to aim the throw.
	var camera = get_viewport().get_camera_3d()
	if camera:
		return -camera.global_transform.basis.z * throw_strength
	return Vector3.ZERO

func remove_current_weapon() -> void:
	if current_weapon_index == -1:
		return
	
	# Remove the data and the instance. Keeping these arrays in sync is critical!
	weapon_data_array.remove_at(current_weapon_index)
	weapon_instances.remove_at(current_weapon_index)
	current_weapon_instance.queue_free()
	
	# Reset the current weapon and switch to the next available one.
	current_weapon_instance = null
	current_weapon_index = -1
	
	if not weapon_data_array.is_empty():
		switch_weapon(0)
# This function adds a new weapon to our inventory.
# It will be called by our interaction system in a future tutorial.
func pickup_weapon(new_weapon_data: WeaponData) -> bool:
	if not new_weapon_data:
		return false

	# First, check if we already have this type of weapon.
	for i in range(weapon_data_array.size()):
		if weapon_data_array[i].weapon_name == new_weapon_data.weapon_name:
			# If we do, just take its ammo instead of picking up a duplicate weapon.
			var existing_data = weapon_data_array[i]
			if existing_data.uses_ammo:
				existing_data.total_ammo += new_weapon_data.current_magazine_ammo + new_weapon_data.total_ammo
				existing_data.total_ammo = min(existing_data.total_ammo, existing_data.max_total_ammo)
				print("Added ammo to %s" % existing_data.weapon_name)
				return true # Indicate success
			else:
				return false # Can't pick up duplicate melee weapon

	# If it's a new weapon, add it to our inventory.
	weapon_data_array.append(new_weapon_data)

	# Now, instantiate it and add it to our pool of weapon instances, just like in _initialize_all_weapons.
	if new_weapon_data.weapon_equipped_scene:
		var weapon_instance = new_weapon_data.weapon_equipped_scene.instantiate()
		if weapon_instance.has_method("set_weapon_data"):
			weapon_instance.set_weapon_data(new_weapon_data)
			
		weapon_instance.shooter_body = shooter_body
		
		add_child(weapon_instance)
		weapon_instance.visible = false
		weapon_instance.set_process_mode(Node.PROCESS_MODE_DISABLED)
		weapon_instances.append(weapon_instance)
		
		# Switch to the new weapon we just picked up.
		switch_weapon(weapon_instances.size() - 1)
		return true
	else:
		weapon_instances.append(null) # Keep arrays in sync
		return false

Our WeaponManager script is now feature-complete! It can initialize, switch, attack, throw, and pick up weapons. The final step is to let the player actually control it.

Integrating with the PlayerController

Let’s hook up our new system to player input.

  1. Open your PlayerController.gd script.
  2. First, we need a reference to the WeaponManager. Add this line near the top with your other @export variables.
# Add under the @export_group("Look & Feel")
@export_group("Components")
@export var weapons_manager: WeaponManager
  1. Next, find the _input(event) function. We’ll add our weapon controls here. Let’s add actions for firing and switching weapons.
# Add this code inside the _input(event) function

	# --- Weapon Input ---
	# We check if the manager exists before trying to use it.
	if weapons_manager:
		if event.is_action_pressed("fire"):
			weapons_manager.attack()

		if event.is_action_pressed("reload"):
			weapons_manager.reload_current_weapon()
		
		# Use mouse wheel for weapon switching
		if event.is_action_pressed("weapon_scroll_up"):
			weapons_manager.switch_to_next_weapon()
		elif event.is_action_pressed("weapon_scroll_down"):
			weapons_manager.switch_to_previous_weapon()

		if event.is_action_pressed("throw_weapon"):
			var throw_force = weapons_manager.get_throw_direction()
			weapons_manager.throw_current_weapon(throw_force)
  1. We need to define these new input actions. Go to Project > Project Settings > Input Map. Add the following actions:
    • fire: Add a Mouse Button event for Left Mouse Button.
    • reload: Add a Key event for R.
    • throw_weapon: Add a Key event for G.
    • weapon_scroll_up: Add a Mouse Button event for Mouse Wheel Up.
    • weapon_scroll_down: Add a Mouse Button event for Mouse Wheel Down.
  2. Finally, we need to link the nodes in the Godot editor.
    • Go back to the Player.tscn scene.
    • Select the root Player node.
    • In the Inspector, you’ll see the new “Weapons Manager” slot. Drag the WeaponManager node from the Scene Tree into this slot.
    • Now, select the WeaponManager node.
    • In the Inspector, you’ll see the “Shooter Body” slot. Drag the root Player node from the Scene Tree into this slot.

This creates the connection. The PlayerController now knows who the WeaponManager is, and the WeaponManager knows who its owner is.

Conclusion and What’s Next

Congratulations! You have just built a complete, data-driven weapon management system from the ground up.

We’ve created:

  • WeaponData resource to act as a universal blueprint for any weapon.
  • A powerful WeaponManager that handles inventory, switching, throwing, and picking up.
  • The input handling in our PlayerController to control the system.

If you run the game now, you won’t see anything different. Why? Because we haven’t created any actual WeaponData resources or the weapon scenes they point to. Our WeaponManager has an empty weapon_data_array and has nothing to load.

You have built the engine, but you haven’t given it any fuel.

In the very next tutorial, we will do exactly that. We will create our first weapon—a classic hitscan revolver. We will build its 3D scene, create its WeaponData resource file, and finally drag that resource into the WeaponManager‘s array in the Inspector. That is when you will see all of this hard work spring to life.

Leave a Reply

Your email address will not be published. Required fields are marked *