Easy Learn C#

Creating a First-Person Shooter in Unity

First-person shooters (FPS) are among the most popular game genres, offering immersive gameplay from the player's perspective. This tutorial will guide you through creating an FPS game in Unity, covering everything from player movement and weapon systems to AI enemies and gameplay mechanics.

Setting Up Your FPS Project

Let's start by setting up a proper foundation for your FPS game in Unity.

Implementation Guidelines

  1. Create Project: Open Unity Hub and click "New Project". Select the "3D" template, name your project (e.g., "MyFPSGame"), and click "Create Project".
  2. Configure Layers: In Edit → Project Settings → Tags and Layers, set up layers for Player, Enemy, Weapon, Environment, and Interactable objects.
  3. Set Up Input: In Edit → Project Settings → Input Manager, ensure you have mappings for movement (WASD), jumping (Space), shooting (Mouse 0), and weapon switching.
  4. Create Base Scene: Create a basic scene with a ground plane, some obstacles, lighting, and a skybox to test player movement.
  5. Organize Project: Create folders in your Project panel for Scripts, Prefabs, Materials, Models, Textures, Sounds, and Scenes.

Project Configuration

Create a new 3D project in Unity and configure the following settings:

  • Set up appropriate physics layers for players, enemies, projectiles, and environment
  • Configure input settings for mouse and keyboard controls
  • Import or create basic 3D assets for testing

First-Person Camera Setup

A proper camera setup is essential for an FPS game. Let's create a basic structure:

  1. Create a Player GameObject with a Character Controller component
  2. Add a Camera as a child of the Player at eye level
  3. Create an empty GameObject called "WeaponHolder" as a child of the Camera

Player Hierarchy Structure

// Your scene hierarchy should look like this:
// Player (with CharacterController, FPSController scripts)
// ├── Camera (with Audio Listener)
// │   └── WeaponHolder
// │       ├── Weapon1
// │       ├── Weapon2
// │       └── etc.
// 
// This structure allows the camera to follow the player's movement,
// and weapons to follow the camera's rotation for aiming.

First-Person Controller

A responsive and smooth first-person controller is the foundation of any good FPS game.

Implementation Guidelines

  1. Create Player GameObject: Create an empty GameObject named "Player" and position it in your scene.
  2. Add CharacterController: Add a Character Controller component and adjust its height (typically 2 units) and radius (typically 0.5 units).
  3. Add Main Camera: Create a camera as a child object of the Player, position it at the "head" level (e.g., Y = 1.6).
  4. Create Script: Create a new C# script named "FPSController" and attach it to the Player GameObject.
  5. Configure Inputs: Ensure your Input Manager has proper mappings for Mouse X, Mouse Y, Horizontal, Vertical, and Jump.

Complete First-Person Controller

using UnityEngine;

[RequireComponent(typeof(CharacterController))]
public class FPSController : MonoBehaviour
{
    [Header("Movement Settings")]
    public float walkSpeed = 5.0f;       // Base movement speed when walking
    public float runSpeed = 8.0f;        // Faster movement speed when holding shift
    public float jumpForce = 5.0f;       // How high the player jumps
    public float gravity = 20.0f;        // Gravity force applied to player (higher than default for better feel)
    
    [Header("Look Settings")]
    public float lookSensitivity = 2.0f;  // Mouse look sensitivity multiplier
    public float maxLookAngle = 80.0f;    // Maximum angle player can look up/down
    
    [Header("Audio")]
    public AudioClip[] footstepSounds;    // Array of different footstep sounds for variety
    public float footstepInterval = 0.5f; // Time between footstep sounds when walking
    
    // Component references - Initialized in Awake()
    private CharacterController characterController;  // Handles movement and collision
    private Camera playerCamera;                      // Main camera for player view
    private AudioSource audioSource;                  // For playing footstep and other sounds
    
    // Movement variables - Tracked during gameplay
    private Vector3 moveDirection = Vector3.zero;     // Current movement direction vector
    private float currentSpeed;                       // Current movement speed (walk or run)
    private float lastFootstepTime;                   // Tracks when last footstep sound played
    
    // Look variables
    private float rotationX = 0;                      // Current camera pitch (up/down rotation)
    
    private void Awake()
    {
        // Get component references - more efficient than GetComponent in Update
        characterController = GetComponent();
        playerCamera = GetComponentInChildren();
        audioSource = GetComponent();
        
        // If no audio source, add one for footstep sounds
        if (audioSource == null && footstepSounds.Length > 0)
        {
            audioSource = gameObject.AddComponent();
        }
        
        // Lock and hide cursor for FPS controls
        Cursor.lockState = CursorLockMode.Locked;
        Cursor.visible = false;
    }
    
    private void Update()
    {
        // Handle player input and movement
        HandleMovement();
        HandleMouseLook();
        
        // Play footstep sounds when moving
        PlayFootsteps();
    }
    
    private void HandleMovement()
    {
        // Get input axes - values between -1 and 1
        float horizontal = Input.GetAxis("Horizontal");  // A/D or Left/Right arrows
        float vertical = Input.GetAxis("Vertical");      // W/S or Up/Down arrows
        
        // Determine speed - run when holding Left Shift, otherwise walk
        currentSpeed = Input.GetKey(KeyCode.LeftShift) ? runSpeed : walkSpeed;
        
        // Calculate movement direction in local space (relative to player orientation)
        Vector3 moveInput = new Vector3(horizontal, 0, vertical);
        
        // Normalize input if it exceeds magnitude of 1 (diagonal movement)
        if (moveInput.magnitude > 1f)
        {
            moveInput.Normalize();
        }
        
        // Convert local direction to world space based on player rotation
        Vector3 moveLocal = transform.TransformDirection(moveInput) * currentSpeed;
        
        // Preserve Y velocity for gravity and jumping
        moveDirection.x = moveLocal.x;
        moveDirection.z = moveLocal.z;
        
        // Handle jump
        if (characterController.isGrounded)
        {
            moveDirection.y = -0.5f; // Small downward force when grounded to ensure isGrounded works reliably
            
            if (Input.GetButtonDown("Jump"))
            {
                moveDirection.y = jumpForce;  // Apply upward force for jumping
            }
        }
        
        // Apply gravity - multiplied by deltaTime for frame-rate independence
        moveDirection.y -= gravity * Time.deltaTime;
        
        // Move the character - multiplied by deltaTime for frame-rate independence
        characterController.Move(moveDirection * Time.deltaTime);
    }
    
    private void HandleMouseLook()
    {
        // Get mouse input values
        float mouseX = Input.GetAxis("Mouse X") * lookSensitivity;
        float mouseY = Input.GetAxis("Mouse Y") * lookSensitivity;
        
        // Calculate camera pitch (up/down) and clamp to prevent over-rotation
        rotationX -= mouseY;  // Subtract because mouse Y is inverted
        rotationX = Mathf.Clamp(rotationX, -maxLookAngle, maxLookAngle);
        playerCamera.transform.localRotation = Quaternion.Euler(rotationX, 0, 0);
        
        // Rotate player for yaw (left/right) - entire player rotates, not just camera
        transform.Rotate(0, mouseX, 0);
    }
    
    private void PlayFootsteps()
    {
        // Play footstep sounds when moving on ground
        // Conditions: player is grounded, has some velocity, and enough time passed since last footstep
        if (characterController.isGrounded && 
            characterController.velocity.magnitude > 0.1f && 
            Time.time > lastFootstepTime + (footstepInterval / (currentSpeed / walkSpeed)))
        {
            lastFootstepTime = Time.time;
            
            if (footstepSounds.Length > 0 && audioSource != null)
            {
                // Play random footstep sound for variety
                int randomIndex = Random.Range(0, footstepSounds.Length);
                audioSource.PlayOneShot(footstepSounds[randomIndex]);
            }
        }
    }
    
    // Method to toggle cursor lock/visibility - useful for menus and UI interaction
    public void ToggleCursorLock(bool lockCursor)
    {
        Cursor.lockState = lockCursor ? CursorLockMode.Locked : CursorLockMode.None;
        Cursor.visible = !lockCursor;
    }

Implementing Head Bob for Realism

Add a head bob effect to increase immersion. This simulates the natural up and down motion when walking or running.

Head Bob Component

using UnityEngine;

public class HeadBob : MonoBehaviour
{
    [Header("Bob Parameters")]
    public float bobFrequency = 5f;             // How fast the head bobs
    public float bobHorizontalAmplitude = 0.1f;  // How far the head bobs side to side
    public float bobVerticalAmplitude = 0.1f;    // How far the head bobs up and down
    public float headBobSmoothing = 0.1f;        // How smoothly the bob transitions
    
    [Header("References")]
    public CharacterController controller;       // Reference to player's CharacterController
    public Transform headTransform;              // Reference to the camera transform
    
    // Internal tracking variables
    private float walkingTime = 0;               // Accumulates while walking to drive bob cycle
    private Vector3 targetCameraPosition;        // Position the camera is moving towards
    private Vector3 originalLocalPosition;       // Default camera position
    
    private void Start()
    {
        // If no transform specified, use this object's transform (usually the camera)
        if (headTransform == null)
            headTransform = transform;
            
        // Store the original position to return to when not moving
        originalLocalPosition = headTransform.localPosition;
        targetCameraPosition = headTransform.localPosition;
    }
    
    private void Update()
    {
        if (controller == null) return;
        
        // Only bob when moving on the ground
        if (!controller.isGrounded || controller.velocity.magnitude < 0.1f)
        {
            // Return to original position when not moving - gradual reset looks more natural
            targetCameraPosition = originalLocalPosition;
            headTransform.localPosition = Vector3.Lerp(
                headTransform.localPosition, 
                targetCameraPosition, 
                headBobSmoothing * Time.deltaTime * 10f  // Multiply by 10 for faster reset
            );
            return;
        }
        
        // Increment time when moving - scale by velocity for speed-dependent bobbing
        walkingTime += Time.deltaTime * controller.velocity.magnitude;
        
        // Calculate bob offset based on sin waves for natural bobbing motion
        targetCameraPosition = originalLocalPosition + CalculateHeadBobOffset(walkingTime);
        
        // Apply smoothed head bob - lerping prevents jarring movements
        headTransform.localPosition = Vector3.Lerp(
            headTransform.localPosition, 
            targetCameraPosition, 
            headBobSmoothing * Time.deltaTime * 10f
        );
    }
    
    private Vector3 CalculateHeadBobOffset(float time)
    {
        // Horizontal bob uses a sin wave
        float horizontalOffset = Mathf.Sin(time * bobFrequency) * bobHorizontalAmplitude;
        
        // Vertical bob uses a faster sin wave (double frequency) for more natural motion
        // People typically bob up and down twice per step cycle
        float verticalOffset = Mathf.Sin(time * bobFrequency * 2f) * bobVerticalAmplitude;
        
        return new Vector3(horizontalOffset, verticalOffset, 0f);
    }
}

Weapon System

A flexible weapon system is crucial for an FPS game. Let's implement a modular system that can handle different types of weapons.

Implementation Guidelines

  1. Create Weapon Base Class: Create an abstract Weapon class that defines common functionality.
  2. Create Weapon Manager: Implement a WeaponManager script to handle switching between weapons.
  3. Create Weapon Models: Import or create 3D models for your weapons with textures and materials.
  4. Setup Weapon Prefabs: Create prefabs for each weapon with appropriate scripts and components.
  5. Configure Raycasting: Implement raycast-based shooting from the camera for accurate aiming.
  6. Add Visual Effects: Create muzzle flash, impact effects, and shell casing ejection effects.

Base Weapon Class

using UnityEngine;
using System.Collections;

public abstract class Weapon : MonoBehaviour
{
    [Header("Weapon Properties")]
    public string weaponName = "Default Weapon";  // Descriptive name for the weapon
    public int damage = 10;                       // Damage per shot/hit
    public float range = 100f;                    // Maximum distance the weapon can hit
    public float fireRate = 10f;                  // Rounds per second (higher = faster firing)
    public int maxAmmo = 30;                      // Maximum ammo in a magazine
    public int currentAmmo;                       // Current ammo in the magazine
    public float reloadTime = 1.5f;               // Time to reload in seconds
    
    [Header("Weapon State")]
    public bool isReloading = false;              // Whether the weapon is currently reloading
    public bool isEquipped = false;               // Whether the weapon is currently equipped
    
    [Header("Effects")]
    public ParticleSystem muzzleFlash;            // Particle effect for muzzle flash
    public GameObject impactEffect;               // Effect spawned at hit point
    public AudioClip fireSound;                   // Sound played when firing
    public AudioClip reloadSound;                 // Sound played when reloading
    public AudioClip emptySound;                  // Sound played when out of ammo
    
    [Header("Animation")]
    public Animator weaponAnimator;               // Animator for weapon model animations
    public string fireAnimationTrigger = "Fire";  // Trigger parameter for firing animation
    public string reloadAnimationTrigger = "Reload"; // Trigger parameter for reload animation
    
    // Component references - Cached for efficiency
    protected Camera fpsCam;                     // Main camera for raycasting from
    protected AudioSource audioSource;           // For playing weapon sounds
    protected WeaponManager weaponManager;       // Reference to the weapon manager
    
    // Timers
    protected float nextTimeToFire = 0f;         // When the weapon can fire next (for fire rate)
    
    protected virtual void Awake()
    {
        // Get component references - do this once in Awake for efficiency
        fpsCam = Camera.main;
        audioSource = GetComponent();
        
        // If no audio source, add one for sounds
        if (audioSource == null)
        {
            audioSource = gameObject.AddComponent();
        }
        
        // Start with full ammo
        currentAmmo = maxAmmo;
    }
    
    protected virtual void OnEnable()
    {
        // Reset state when weapon is equipped
        isReloading = false;
        
        // Cancel any pending reloads when switching weapons
        StopAllCoroutines();
        
        // Play equip animation if available
        if (weaponAnimator != null)
        {
            weaponAnimator.SetBool("Equipped", true);
        }
    }
    
    protected virtual void OnDisable()
    {
        // Update state when weapon is unequipped
        isEquipped = false;
        
        // Update animation state
        if (weaponAnimator != null)
        {
            weaponAnimator.SetBool("Equipped", false);
        }
    }
    
    public virtual void Initialize(WeaponManager manager)
    {
        // Store reference to weapon manager
        this.weaponManager = manager;
    }
    
    public virtual bool TryFire()
    {
        // Check if weapon can fire based on state and timing
        if (isReloading || Time.time < nextTimeToFire)
            return false;
            
        // Check if ammo is available
        if (currentAmmo <= 0)
        {
            // Play empty sound if out of ammo
            if (emptySound != null && audioSource != null)
            {
                audioSource.PlayOneShot(emptySound);
            }
            
            // Automatically try to reload if out of ammo
            StartCoroutine(Reload());
            return false;
        }
        
        // Calculate next time to fire based on fire rate
        // (1 / fireRate) is the delay between shots in seconds
        nextTimeToFire = Time.time + 1f / fireRate;
        
        // Fire the weapon - implemented by derived classes
        Fire();
        return true;
    }
    
    // Abstract method to be implemented by specific weapon types
    protected abstract void Fire();
    
    public virtual IEnumerator Reload()
    {
        // Only reload if not already reloading and not full ammo
        if (currentAmmo < maxAmmo && !isReloading)
        {
            isReloading = true;
            
            // Play reload sound
            if (reloadSound != null && audioSource != null)
            {
                audioSource.PlayOneShot(reloadSound);
            }
            
            // Trigger reload animation
            if (weaponAnimator != null)
            {
                weaponAnimator.SetTrigger(reloadAnimationTrigger);
            }
            
            // Wait for reload time to complete
            yield return new WaitForSeconds(reloadTime);
            
            // Refill ammo
            currentAmmo = maxAmmo;
            
            isReloading = false;
        }
    }
    
    protected virtual void PlayFireEffects()
    {
        // Play muzzle flash particle effect
        if (muzzleFlash != null)
        {
            muzzleFlash.Play();
        }
        
        // Play fire sound
        if (fireSound != null && audioSource != null)
        {
            audioSource.PlayOneShot(fireSound);
        }
        
        // Trigger fire animation
        if (weaponAnimator != null)
        {
            weaponAnimator.SetTrigger(fireAnimationTrigger);
        }
    }
}

Implementing Different Weapon Types

Let's create concrete implementations of different weapon types by extending our base Weapon class.

Hitscan Weapon (Pistol, Rifle, etc.)

using UnityEngine;

// Hitscan weapons use raycasts for immediate hit detection (pistols, rifles, etc.)
public class HitscanWeapon : Weapon
{
    [Header("Hitscan Settings")]
    public float bulletSpread = 0.02f;        // Accuracy spread (0 = perfect accuracy)
    public int bulletsPerShot = 1;            // For shotguns, this would be higher
    public float headshotMultiplier = 2.0f;   // Damage multiplier for headshots
    
    [Header("Effects")]
    public TrailRenderer bulletTrailPrefab;   // Optional visual bullet trail
    public float trailDuration = 0.05f;       // How long bullet trails last
    
    protected override void Fire()
    {
        // Reduce ammo
        currentAmmo--;
        
        // Play effects (muzzle flash, sound, animation)
        PlayFireEffects();
        
        // Fire the appropriate number of bullets (1 for rifles, multiple for shotguns)
        for (int i = 0; i < bulletsPerShot; i++)
        {
            // Calculate bullet direction with spread
            Vector3 bulletDirection = CalculateBulletDirection();
            
            // Perform raycast from camera
            if (Physics.Raycast(fpsCam.transform.position, bulletDirection, out RaycastHit hit, range))
            {
                // Draw bullet trail if enabled
                if (bulletTrailPrefab != null)
                {
                    CreateBulletTrail(hit.point);
                }
                
                // Check what was hit
                HandleHit(hit);
            }
        }
    }
    
    private Vector3 CalculateBulletDirection()
    {
        // Start with forward direction of camera
        Vector3 direction = fpsCam.transform.forward;
        
        // Add random spread if specified
        if (bulletSpread > 0)
        {
            // Random circle spread pattern
            float spreadX = Random.Range(-bulletSpread, bulletSpread);
            float spreadY = Random.Range(-bulletSpread, bulletSpread);
            
            // Add spread to direction
            direction += fpsCam.transform.right * spreadX;
            direction += fpsCam.transform.up * spreadY;
            
            // Normalize to ensure consistent range
            direction.Normalize();
        }
        
        return direction;
    }
    
    private void CreateBulletTrail(Vector3 hitPoint)
    {
        // Instantiate bullet trail
        TrailRenderer trail = Instantiate(bulletTrailPrefab, 
                                         transform.position, 
                                         Quaternion.identity);
        
        // Set trail start and end points
        StartCoroutine(SpawnTrail(trail, hitPoint));
    }
    
    private System.Collections.IEnumerator SpawnTrail(TrailRenderer trail, Vector3 hitPoint)
    {
        // Get start point
        Vector3 startPosition = trail.transform.position;
        
        // Set initial position
        trail.transform.position = startPosition;
        
        // Set trail time to 0 to avoid initial stretched look
        float time = 0;
        
        // Move trail to hit point over time
        while (time < trailDuration)
        {
            trail.transform.position = Vector3.Lerp(startPosition, hitPoint, time / trailDuration);
            time += Time.deltaTime;
            yield return null;
        }
        
        // Ensure endpoint is reached
        trail.transform.position = hitPoint;
        
        // Allow trail to fade out based on trail's time setting
        yield return new WaitForSeconds(trail.time);
        
        // Destroy trail object
        Destroy(trail.gameObject);
    }
    
    private void HandleHit(RaycastHit hit)
    {
        // Spawn impact effect at hit point
        if (impactEffect != null)
        {
            GameObject impact = Instantiate(impactEffect, hit.point, 
                                         Quaternion.LookRotation(hit.normal));
            Destroy(impact, 2f); // Destroy after 2 seconds
        }
        
        // Check if hit something with health
        HealthSystem healthSystem = hit.transform.GetComponent();
        if (healthSystem != null)
        {
            // Check for headshot
            bool isHeadshot = IsHeadshot(hit);
            
            // Apply damage (with headshot multiplier if applicable)
            int damageToApply = isHeadshot 
                ? Mathf.RoundToInt(damage * headshotMultiplier) 
                : damage;
                
            healthSystem.TakeDamage(damageToApply);
        }
    }
    
    private bool IsHeadshot(RaycastHit hit)
    {
        // Check if hit a designated headshot collider or tag
        // This implementation will vary based on your character setup
        return hit.transform.CompareTag("Head");
    }
}

Projectile Weapon (Rocket Launcher, Grenade Launcher)

using UnityEngine;

// Projectile weapons fire physical projectiles instead of raycasts
public class ProjectileWeapon : Weapon
{
    [Header("Projectile Settings")]
    public GameObject projectilePrefab;     // The projectile to spawn
    public Transform projectileSpawnPoint;  // Where to spawn the projectile
    public float projectileSpeed = 30f;     // How fast the projectile moves
    public float projectileGravity = 1f;    // Gravity multiplier (0 = no drop)
    
    [Header("Advanced Settings")]
    public bool usePooling = false;         // Whether to use object pooling for projectiles
    
    protected override void Fire()
    {
        // Reduce ammo
        currentAmmo--;
        
        // Play effects (muzzle flash, sound, animation)
        PlayFireEffects();
        
        // Spawn projectile
        SpawnProjectile();
    }
    
    private void SpawnProjectile()
    {
        if (projectilePrefab != null && projectileSpawnPoint != null)
        {
            // Create projectile at spawn point
            GameObject projectileObj = Instantiate(projectilePrefab, 
                                               projectileSpawnPoint.position, 
                                               projectileSpawnPoint.rotation);
            
            // Get projectile component
            Projectile projectile = projectileObj.GetComponent();
            
            // Initialize projectile if component exists
            if (projectile != null)
            {
                projectile.Initialize(damage, projectileSpeed, projectileGravity);
                projectile.SetOwner(gameObject);
            }
            else
            {
                // If no Projectile component, just add force to Rigidbody
                Rigidbody rb = projectileObj.GetComponent();
                if (rb != null)
                {
                    rb.velocity = projectileSpawnPoint.forward * projectileSpeed;
                }
                
                // Destroy after 10 seconds to avoid cluttering the scene
                Destroy(projectileObj, 10f);
            }
        }
    }
}