This post is part of Godot FPS tutorial series.
Concept: Bringing our weapon system to life. We’ll implement the logic for hitscan weapons (like pistols and shotguns) using RayCasting, handle shooting, and create the “equipped weapon” scenes that live on the player’s view.
In Part 2, we constructed the “engine” of our weapon system: the WeaponData
resource and the WeaponManager
script. It’s a powerful system, but right now it’s an empty garage with no car in it.
In this tutorial, we’re going to build that car. We will create our first fully functional weapon, a classic six-shooter revolver. This process will show you how the data, scenes, and scripts all connect to bring a weapon to life.
Here’s our plan:
- Create a Base
WeaponEquipped
Script: All our equippable weapons will share common logic (like handling ammo, recoil, etc.). We’ll create a reusable base script to avoid repeating code. - Build the Revolver’s Equipped Scene: We’ll create the first-person 3D model for our revolver and write a small script specific to it.
- Implement Hitscan Firing: We’ll write the logic that makes the gun fire a “hitscan” projectile—an instantaneous line trace, perfect for bullets.
- Build the Revolver’s Pickup Scene: We’ll create the 3D model that appears in the world when the revolver is thrown.
- Define the
WeaponData
: We’ll create therevolver.tres
resource file, fill in its stats, and link our new scenes to it. - Equip the Weapon: The final step—we’ll drag our new resource into the
WeaponManager
and see it all work in-game!
Let’s get started.
The WeaponEquipped
Base Script
Our revolver, a future shotgun, and a knife will all share some behaviors. For example, they all need to receive WeaponData
from the WeaponManager
. To keep our project organized and efficient (Don’t Repeat Yourself!), we’ll create a base script that all our equippable weapons will inherit from.
- In the
Weapons
folder, create a new script namedWeaponEquipped.gd
. - Open the script and add the following foundational code. This will be the parent class for all our weapons.
# WeaponEquipped.gd
class_name WeaponEquipped
extends Node3D
# This signal will be sent up to the WeaponManager when we hit something.
signal deal_damage(damage_amount: int, hit_point: Vector3, hit_normal: Vector3, collider: Object)
# This will hold the data resource for the specific weapon (e.g., revolver.tres)
var weapon_data: WeaponData
# We'll export node paths so we can link them easily in the editor.
@export var weapon_model: Node3D # The visual part of the weapon that will move.
@export var fire_ray: RayCast3D # The raycast that determines where we hit.
# This variable will be set by the WeaponManager so we know who is shooting.
@export var shooter_body: CharacterBody3D
# This function is the crucial link. The WeaponManager will call this
# right after it creates the weapon instance.
func set_weapon_data(data: WeaponData) -> void:
weapon_data = data
# This is a "virtual" function. We define it here, but each specific weapon
# script (like our revolver) will provide its own implementation.
func attack() -> void:
# Base implementation does nothing.
# The child script (RevolverEquipped.gd) will override this.
pass
This script establishes a contract. It guarantees that every weapon will have a weapon_data
variable and a set_weapon_data
function that the WeaponManager
can rely on.
Building the Revolver’s Equipped Scene
Now for the fun part. Let’s make the revolver that our player will hold. We’ll use simple box meshes to quickly make a revolver shape for visual display (we can later change that to real 3D model of revolver).
Create a new scene. The root node should be a Node3D
. Name it RevolverEquipped
.
Save the scene as RevolverEquipped.tscn
inside a new folder structure: Weapons/Scenes/Revolver/
.
Attach a new script to the root node. Call it RevolverEquipped.gd
and save it in the same folder.
Important: Open RevolverEquipped.gd
and make it inherit from our new base class:
# RevolverEquipped.gd
extends WeaponEquipped
# We will override the base attack function here.
func attack() -> void:
print("Revolver goes: PEW!")
# We will call the hitscan logic here soon.
Now, let’s build the gun model. Back in the RevolverEquipped.tscn
scene, create the following node structure. Don’t worry about perfect shapes, just get the basic form down.
RevolverEquipped (Node3D)
└─ WeaponModel (Node3D) <-- This will be the parent for all visual parts.
├─ MeshInstance3D nodes to make a gun shape
Next, we need a RayCast3D
to detect where we’re aiming. Add a RayCast3D
node as a direct child of the root RevolverEquipped
node.
Name it FireRay
.
In the Inspector, set its Target Position
to (X: 0, Y: 0, Z: -50)
. This makes the ray 50 meters long.
Position the FireRay
node so it originates from the tip of the barrel.

Finally, let’s link everything up. Select the root RevolverEquipped
node. In the Inspector, you’ll see the exported variables from our WeaponEquipped.gd
script.
Drag the WeaponModel
node into the Weapon Model
slot.
Drag the FireRay
node into the Fire Ray
slot.
Our equipped scene is now set up!
“PEW!” – Implementing Hitscan Firing
Let’s make our revolver actually shoot something. We’ll add the hitscan logic to our base WeaponEquipped.gd
script so other bullet-based weapons can use it too.
- Open
WeaponEquipped.gd
. - Add the following function. Read the comments carefully to understand how it works.
# Add this function to WeaponEquipped.gd
func fire_hitscan() -> void:
if not fire_ray:
printerr("Fire Ray not assigned for ", self.name)
return
# We get the physics world.
var space_state = get_world_3d().direct_space_state
# The query holds the ray's start, end, and any exceptions.
var query = PhysicsRayQueryParameters3D.create(
fire_ray.global_position,
fire_ray.to_global(fire_ray.get_target_position())
)
# This is crucial: we tell the ray to ignore the player's body.
query.exclude = [shooter_body.get_rid()]
# We execute the raycast!
var result = space_state.intersect_ray(query)
# Did we hit anything?
if result:
var collider = result.collider
var hit_point = result.position
var hit_normal = result.normal
print("Hit: ", collider.name, " at position ", hit_point)
# --- Placeholder Impact Effect ---
# To visually confirm our hit, let's spawn a small red cube.
var hit_marker = MeshInstance3D.new()
var box_mesh = BoxMesh.new()
var material = StandardMaterial3D.new()
material.albedo_color = Color.RED
box_mesh.material = material
box_mesh.size = Vector3(0.1, 0.1, 0.1)
hit_marker.mesh = box_mesh
# Add the marker to the main scene tree and position it.
get_tree().root.add_child(hit_marker)
hit_marker.global_position = hit_point
# Send the damage info up to the WeaponManager.
deal_damage.emit(weapon_data.damage, hit_point, hit_normal, collider)
- Now, let’s call this new function from our revolver. Open
RevolverEquipped.gd
and modify theattack
function:
# RevolverEquipped.gd
extends WeaponEquipped
func attack() -> void:
# Instead of just printing, we now call the hitscan logic from our base class.
fire_hitscan()
The Ammunition System
A revolver that never runs out of bullets isn’t much of a revolver! Let’s add the ammunition logic to our base WeaponEquipped.gd
script.
- Open
WeaponEquipped.gd
and add the new signals and functions for handling ammo.
# Add these signals at the top of WeaponEquipped.gd
signal ammo_changed(current_magazine: int, total_ammo: int)
signal reload_started()
signal reload_finished()
# ... (keep existing variables) ...
# Add these new variables
var reload_timer: Timer
func _ready() -> void:
# We will initialize things here later
pass
# ... (keep existing set_weapon_data and attack functions) ...
# Add all of the following functions to WeaponEquipped.gd
# This should be called after weapon_data is set
func _initialize_ammo_system() -> void:
if not weapon_data or not weapon_data.uses_ammo:
return
# If this is the first time, set ammo to full.
if weapon_data.total_ammo == 0:
weapon_data.initialize_ammo()
# Create a timer for reloading
if not reload_timer:
reload_timer = Timer.new()
reload_timer.one_shot = true
reload_timer.timeout.connect(_on_reload_finished)
add_child(reload_timer)
reload_timer.wait_time = weapon_data.reload_time
# Tell the UI about our current ammo count
ammo_changed.emit(weapon_data.current_magazine_ammo, weapon_data.total_ammo)
func can_fire() -> bool:
if not weapon_data: return false
if not weapon_data.uses_ammo: return true # Melee weapons can always fire
return weapon_data.current_magazine_ammo > 0 and not weapon_data.is_reloading
func consume_ammo() -> void:
if not can_fire(): return
weapon_data.current_magazine_ammo -= 1
ammo_changed.emit(weapon_data.current_magazine_ammo, weapon_data.total_ammo)
if weapon_data.current_magazine_ammo <= 0 and weapon_data.total_ammo > 0:
start_reload() # Auto-reload when the mag is empty
func start_reload() -> void:
if not weapon_data or not weapon_data.uses_ammo or weapon_data.is_reloading: return
if weapon_data.current_magazine_ammo >= weapon_data.max_magazine_capacity: return
if weapon_data.total_ammo <= 0: return
weapon_data.is_reloading = true
reload_timer.start()
reload_started.emit()
func _on_reload_finished() -> void:
var ammo_needed = weapon_data.max_magazine_capacity - weapon_data.current_magazine_ammo
var ammo_to_transfer = min(ammo_needed, weapon_data.total_ammo)
weapon_data.current_magazine_ammo += ammo_to_transfer
weapon_data.total_ammo -= ammo_to_transfer
weapon_data.is_reloading = false
reload_finished.emit()
ammo_changed.emit(weapon_data.current_magazine_ammo, weapon_data.total_ammo)
func manual_reload() -> void:
start_reload()
# --- Now, modify set_weapon_data and fire_hitscan ---
func set_weapon_data(data: WeaponData) -> void:
weapon_data = data
_initialize_ammo_system() # Call the setup function here
func fire_hitscan() -> void:
# Add this check at the very beginning of the function
if not can_fire():
print("Cannot fire: No ammo or reloading!")
return
consume_ammo() # Consume one bullet
# ... (rest of the fire_hitscan function remains the same)
We now have a complete ammunition system that any future weapon can use!
Building the Pickup Scene
What happens when we throw our revolver? It needs to become a physical object in the world.
- Create a new scene with a
RigidBody3D
as its root. Name itRevolverPickup
. Save it in the sameRevolver/
folder. - Copy the
WeaponModel
node and its children fromRevolverEquipped.tscn
and paste them as a child ofRevolverPickup
. This ensures our pickup model looks identical to our equipped model. - A
RigidBody3D
needs a collision shape to interact with the world. Add aCollisionShape3D
node. - In the Inspector for the
CollisionShape3D
, create a newBoxShape3D
for itsShape
property. Resize the box to roughly encapsulate the revolver model. - Create a new script,
WeaponPickup.gd
, and attach it to the root node. This will be a very simple script for now.
# WeaponPickup.gd
class_name WeaponPickup
extends RigidBody3D
@export var weapon_data: WeaponData
# This function allows a thrown weapon to pass its current data (like ammo count)
# to the pickup object that gets spawned.
func set_weapon_data(data: WeaponData) -> void:
weapon_data = data
That’s it for the pickup scene! We are intentionally leaving out the interaction logic. For now, its only job is to be a physical object that holds WeaponData
.

RevolverPickup.tscn
looks so far. It has a RigidBody3D
root, so it behaves in a physics manner if thrown. – It has a script WeaponPickup.gd
attached to it. All weapons’ pickup scenes will have same script attached to them in future.Define the WeaponData
and Equip!
This is the final step where all our work connects. We will create the data resource that defines our revolver.
- In the FileSystem dock, navigate to
Weapons/
. Right-click and select New > Resource…. - A dialog will pop up. Search for and select
WeaponData
. Click “Create”. - Save the new resource as
revolver.tres
. - Select
revolver.tres
. The Inspector will now show all the@export
variables from ourWeaponData.gd
script! This is the power of a data-driven system. Fill it out:- Weapon Name: “Revolver”
- Equipped Model Scene: Drag
RevolverEquipped.tscn
into this slot. - Pickup Model Scene: Drag
RevolverPickup.tscn
into this slot. - Max Magazine Capacity:
6
- Max Total Ammo:
24
- Reload Time:
1.5
- Damage:
25
- (Leave the other values as default for now)
- The moment of truth. Open your
Player.tscn
scene. - Select the
WeaponManager
node. - In the Inspector, find the
Weapon Data Array
property. Click “Add Element”. - A new slot will appear. Drag your
revolver.tres
resource from the FileSystem into this slot.

revolver.tres
resource looks like. Notice both equip & pickup scenes assigned to it, so whenever WeaponManager
gets this resource, it knows what scene to spawn when equipped and what scene to spawn when weapon is thrown to world. Similarly, both equip scene & pickup scene themselves also have a variable to hold this resource so if player fires bullets, all ammo changes will be reflected in real-time in this resource, and when weapon will be thrown in world, state will be preserved.Run the Game!
That’s it! Press F5 to play. You should now see your blocky revolver in front of you.
- Left-click to fire. You’ll see red cubes appear where you shoot, and your ammo count will go down (check the output log).
- After 6 shots, the gun will auto-reload.
- Press R to manually reload.
- Press G to throw your weapon. It will drop to the ground as a physical object. (You won’t be able to pick it up yet).
You have successfully built and integrated your first weapon into the system! Notice how we didn’t have to change the WeaponManager
at all? We just gave it a new WeaponData
blueprint, and it knew exactly what to do. This is the flexibility we were aiming for.
In the next tutorial, we’ll add slightly more juice to the weapon: recoil & basic hit impact effects.