Your First Gun – Implementing Hitscan Weapons (FPS Series Part 3)

basic gun in fps game

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:

  1. 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.
  2. 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.
  3. Implement Hitscan Firing: We’ll write the logic that makes the gun fire a “hitscan” projectile—an instantaneous line trace, perfect for bullets.
  4. Build the Revolver’s Pickup Scene: We’ll create the 3D model that appears in the world when the revolver is thrown.
  5. Define the WeaponData: We’ll create the revolver.tres resource file, fill in its stats, and link our new scenes to it.
  6. 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.

  1. In the Weapons folder, create a new script named WeaponEquipped.gd.
  2. 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.

💡 Remember: When I say “add nodes …”, that means add them in the editor (the scene tree), not in the script.
This is how your scene for equipped revolver should look like now. – I have added cube-like shapes with materials to make them look like a pistol for visual appeal. However, you can just use a 3D model of a gun here too.

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.

  1. Open WeaponEquipped.gd.
  2. 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)
  1. Now, let’s call this new function from our revolver. Open RevolverEquipped.gd and modify the attack 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.

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

  1. Create a new scene with a RigidBody3D as its root. Name it RevolverPickup. Save it in the same Revolver/ folder.
  2. Copy the WeaponModel node and its children from RevolverEquipped.tscn and paste them as a child of RevolverPickup. This ensures our pickup model looks identical to our equipped model.
  3. RigidBody3D needs a collision shape to interact with the world. Add a CollisionShape3D node.
  4. In the Inspector for the CollisionShape3D, create a new BoxShape3D for its Shape property. Resize the box to roughly encapsulate the revolver model.
  5. 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.

This is how 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.

  1. In the FileSystem dock, navigate to Weapons/. Right-click and select New > Resource….
  2. A dialog will pop up. Search for and select WeaponData. Click “Create”.
  3. Save the new resource as revolver.tres.
  4. Select revolver.tres. The Inspector will now show all the @export variables from our WeaponData.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)
  5. The moment of truth. Open your Player.tscn scene.
  6. Select the WeaponManager node.
  7. In the Inspector, find the Weapon Data Array property. Click “Add Element”.
  8. A new slot will appear. Drag your revolver.tres resource from the FileSystem into this slot.
This is how your 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).
🔧 Note: If you run the game and don’t see any weapon (and there are no errors), it’s probably because the weapon is sitting right at the camera’s center. Just move your WeaponManager node a little forward so the camera can actually see it.

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.

Leave a Reply

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