Unity FPS Tutorial: Build a Shooter from Scratch

Build a first-person shooter in Unity from scratch. Covers FPS camera, weapon systems, projectiles, health, camera shake, and enemy AI.

There's something deeply satisfying about building an FPS. It's one of those genres where you feel the code — every frame of camera movement, every muzzle flash, every hit reaction. I've been tinkering with shooters in Unity since version 4, and the tooling has come a long way. Let me walk you through building one from the ground up.

TL;DR: We'll build a first-person shooter covering mouse-look camera, WASD movement with sprint, a weapon system supporting both raycast and projectile firing, object pooling for projectiles, health and damage, camera shake for game feel, basic enemy AI, and audio integration. Everything we build is available in the FPS Foundation Kit.

I'm assuming you know your way around Unity's editor and have written some C# before. We won't be explaining what a MonoBehaviour is. If you want pre-built, drop-in scripts for any of these systems, I'll link them as we go.

FPS Camera Setup

The camera is the soul of an FPS. Get it wrong and your game feels like you're controlling a shopping cart. Get it right and the player forgets there's a mouse involved. The core idea is simple: mouse X rotates the player body on the Y axis, mouse Y rotates the camera on the X axis. But the details matter a lot.

C#
using UnityEngine;

public class FPSCamera : MonoBehaviour
{
    [Header("Sensitivity")]
    [SerializeField] private float mouseSensitivity = 2f;
    [SerializeField] private float maxLookAngle = 85f;

    [Header("Smoothing")]
    [SerializeField] private bool enableSmoothing = true;
    [SerializeField] private float smoothSpeed = 15f;

    private Transform playerBody;
    private float xRotation;
    private float smoothXRotation;
    private float smoothYRotation;

    void Start()
    {
        playerBody = transform.parent;
        Cursor.lockState = CursorLockMode.Locked;
        Cursor.visible = false;
    }

    void LateUpdate()
    {
        float mouseX = Input.GetAxis("Mouse X") * mouseSensitivity;
        float mouseY = Input.GetAxis("Mouse Y") * mouseSensitivity;

        xRotation -= mouseY;
        xRotation = Mathf.Clamp(xRotation, -maxLookAngle, maxLookAngle);

        if (enableSmoothing)
        {
            smoothXRotation = Mathf.Lerp(smoothXRotation, xRotation,
                Time.deltaTime * smoothSpeed);
            smoothYRotation = mouseX;

            transform.localRotation = Quaternion.Euler(smoothXRotation, 0f, 0f);
            playerBody.Rotate(Vector3.up * smoothYRotation);
        }
        else
        {
            transform.localRotation = Quaternion.Euler(xRotation, 0f, 0f);
            playerBody.Rotate(Vector3.up * mouseX);
        }
    }
}

A few things to note: we're running in LateUpdate so the camera updates after all movement calculations. The vertical clamp at 85 degrees prevents that nauseating over-rotation. And the smoothing option adds a subtle lag that some players love and others hate — always expose it as a setting. Our First Person Camera script includes additional features like head bob, FOV changes during sprint, and weapon sway.

Player Movement: WASD + Sprint

FPS movement needs to feel responsive but grounded. The player should accelerate quickly, stop precisely, and have a sprint option that feels like it matters. We'll use Unity's CharacterController because it handles collision without the unpredictability of a Rigidbody.

C#
using UnityEngine;

[RequireComponent(typeof(CharacterController))]
public class FPSMovement : MonoBehaviour
{
    [Header("Movement")]
    [SerializeField] private float walkSpeed = 6f;
    [SerializeField] private float sprintSpeed = 10f;
    [SerializeField] private float gravity = -20f;
    [SerializeField] private float jumpHeight = 1.2f;

    [Header("Ground Check")]
    [SerializeField] private Transform groundCheck;
    [SerializeField] private float groundDistance = 0.3f;
    [SerializeField] private LayerMask groundMask;

    private CharacterController controller;
    private Vector3 velocity;
    private bool isGrounded;

    void Start()
    {
        controller = GetComponent<CharacterController>();
    }

    void Update()
    {
        // Ground detection
        isGrounded = Physics.CheckSphere(
            groundCheck.position, groundDistance, groundMask);

        if (isGrounded && velocity.y < 0)
            velocity.y = -2f; // Small downward force to stick to ground

        // Horizontal input
        float x = Input.GetAxis("Horizontal");
        float z = Input.GetAxis("Vertical");
        Vector3 move = transform.right * x + transform.forward * z;

        // Sprint
        bool sprinting = Input.GetKey(KeyCode.LeftShift) && z > 0;
        float speed = sprinting ? sprintSpeed : walkSpeed;

        controller.Move(move * speed * Time.deltaTime);

        // Jump
        if (Input.GetButtonDown("Jump") && isGrounded)
            velocity.y = Mathf.Sqrt(jumpHeight * -2f * gravity);

        // Gravity
        velocity.y += gravity * Time.deltaTime;
        controller.Move(velocity * Time.deltaTime);
    }

    public bool IsGrounded => isGrounded;
    public bool IsSprinting => Input.GetKey(KeyCode.LeftShift);
}

The ground check uses a small sphere cast at the player's feet. I've found this more reliable than CharacterController.isGrounded, which can give false negatives on slopes. The Player Controller 3D script on our site extends this with crouching, slope handling, and a stamina system for sprint.

Weapon System: Raycast and Projectile

Most FPS games use two firing methods: hitscan (raycast) for fast weapons like rifles and shotguns, and projectile-based for slower weapons like rocket launchers and grenade launchers. A good weapon system supports both through a shared interface. Our Weapon System script does exactly this, but let's build a simplified version.

C#
using UnityEngine;

public class Weapon : MonoBehaviour
{
    [Header("General")]
    public string weaponName;
    public float fireRate = 0.15f;
    public int magazineSize = 30;
    public float reloadTime = 1.5f;
    public FireMode fireMode = FireMode.Hitscan;

    [Header("Hitscan")]
    public float hitscanRange = 100f;
    public float hitscanDamage = 25f;
    public LayerMask hitLayers;

    [Header("Projectile")]
    public GameObject projectilePrefab;
    public float projectileSpeed = 40f;
    public float projectileDamage = 80f;
    public Transform muzzlePoint;

    [Header("Effects")]
    public ParticleSystem muzzleFlash;
    public AudioClip fireSound;
    public AudioClip reloadSound;

    private int currentAmmo;
    private float nextFireTime;
    private bool isReloading;

    void Start()
    {
        currentAmmo = magazineSize;
    }

    public bool TryFire(Camera cam)
    {
        if (isReloading) return false;
        if (Time.time < nextFireTime) return false;
        if (currentAmmo <= 0) { StartCoroutine(Reload()); return false; }

        nextFireTime = Time.time + fireRate;
        currentAmmo--;

        muzzleFlash?.Play();

        if (fireMode == FireMode.Hitscan)
            FireHitscan(cam);
        else
            FireProjectile(cam);

        return true;
    }

    private void FireHitscan(Camera cam)
    {
        Ray ray = cam.ViewportPointToRay(new Vector3(0.5f, 0.5f, 0f));
        if (Physics.Raycast(ray, out RaycastHit hit, hitscanRange, hitLayers))
        {
            var health = hit.collider.GetComponent<HealthSystem>();
            if (health != null)
                health.TakeDamage(hitscanDamage);
        }
    }

    private void FireProjectile(Camera cam)
    {
        Vector3 direction = cam.transform.forward;

        // Aim assist: raycast to find actual target point
        Ray ray = cam.ViewportPointToRay(new Vector3(0.5f, 0.5f, 0f));
        if (Physics.Raycast(ray, out RaycastHit hit, 200f, hitLayers))
            direction = (hit.point - muzzlePoint.position).normalized;

        var proj = Instantiate(projectilePrefab,
            muzzlePoint.position, Quaternion.LookRotation(direction));
        proj.GetComponent<Rigidbody>().velocity = direction * projectileSpeed;
    }

    private System.Collections.IEnumerator Reload()
    {
        isReloading = true;
        yield return new WaitForSeconds(reloadTime);
        currentAmmo = magazineSize;
        isReloading = false;
    }

    public int CurrentAmmo => currentAmmo;
    public bool IsReloading => isReloading;
}

public enum FireMode { Hitscan, Projectile }

The aim-assist raycast in FireProjectile is a subtle but critical detail. Without it, projectiles fire from the muzzle point in the camera's forward direction, which creates a parallax problem at close range — the bullet goes where the gun barrel points, not where the crosshair is. The raycast finds the actual world point the crosshair is targeting and adjusts the direction accordingly.

Projectile Physics with Object Pooling

If you're using Instantiate/Destroy for projectiles, you're going to hit GC spikes the moment things get hectic. Object pooling is non-negotiable for any weapon that fires frequently. Our Object Pool script handles this generically, but here's how it integrates with the Projectile System:

C#
using UnityEngine;

public class Projectile : MonoBehaviour
{
    [SerializeField] private float damage = 80f;
    [SerializeField] private float lifetime = 5f;
    [SerializeField] private GameObject impactEffectPrefab;

    private float spawnTime;

    void OnEnable()
    {
        spawnTime = Time.time;
    }

    void Update()
    {
        if (Time.time - spawnTime > lifetime)
            ReturnToPool();
    }

    void OnCollisionEnter(Collision collision)
    {
        var health = collision.collider.GetComponent<HealthSystem>();
        if (health != null)
            health.TakeDamage(damage);

        if (impactEffectPrefab != null)
        {
            ContactPoint contact = collision.GetContact(0);
            Instantiate(impactEffectPrefab,
                contact.point, Quaternion.LookRotation(contact.normal));
        }

        ReturnToPool();
    }

    private void ReturnToPool()
    {
        // If using object pool, return instead of destroying
        var pool = GetComponent<PooledObject>();
        if (pool != null)
            pool.ReturnToPool();
        else
            Destroy(gameObject);
    }
}

The OnEnable reset pattern is important — pooled objects get re-enabled instead of instantiated, so you reset state there instead of in Start. The impact effect could also be pooled if you're firing rockets at 600 RPM, but for most games Instantiate is fine for one-shot particles since they self-destruct.

Health and Damage

Both the player and enemies need health. Rather than writing two separate systems, use a single Health System component on anything that can take damage. Pair it with a Health Bar UI for the player's HUD and Damage Popup for floating numbers on enemies — it's a tiny addition that makes combat feel way more impactful.

C#
using UnityEngine;
using System;

public class HealthSystem : MonoBehaviour
{
    [SerializeField] private float maxHealth = 100f;
    [SerializeField] private bool destroyOnDeath = false;

    private float currentHealth;

    public event Action<float, float> OnHealthChanged; // current, max
    public event Action OnDeath;

    public float CurrentHealth => currentHealth;
    public float MaxHealth => maxHealth;
    public bool IsAlive => currentHealth > 0;

    void Awake()
    {
        currentHealth = maxHealth;
    }

    public void TakeDamage(float amount)
    {
        if (!IsAlive) return;

        currentHealth = Mathf.Max(currentHealth - amount, 0);
        OnHealthChanged?.Invoke(currentHealth, maxHealth);

        if (currentHealth <= 0)
        {
            OnDeath?.Invoke();
            if (destroyOnDeath)
                Destroy(gameObject, 0.1f);
        }
    }

    public void Heal(float amount)
    {
        if (!IsAlive) return;
        currentHealth = Mathf.Min(currentHealth + amount, maxHealth);
        OnHealthChanged?.Invoke(currentHealth, maxHealth);
    }
}

The event-driven approach here is crucial. The health system doesn't know or care about UI, sound effects, or quest tracking. It just broadcasts state changes. The HUD subscribes to OnHealthChanged, the quest system subscribes to OnDeath, and the damage popup system reads the damage amount. Clean separation.

Camera Shake and Juice

This is where your FPS goes from 'functional prototype' to 'this actually feels good to play.' Camera shake on weapon fire and hit impacts adds physicality that players feel subconsciously. Our Camera Shake script uses Perlin noise for organic-feeling shake rather than random jitter:

C#
using UnityEngine;

public class CameraShake : MonoBehaviour
{
    public static CameraShake Instance { get; private set; }

    private float shakeDuration;
    private float shakeIntensity;
    private float shakeFalloff;

    private Vector3 originalLocalPos;

    void Awake()
    {
        Instance = this;
        originalLocalPos = transform.localPosition;
    }

    public void Shake(float duration, float intensity)
    {
        shakeDuration = duration;
        shakeIntensity = intensity;
        shakeFalloff = intensity / duration;
    }

    void Update()
    {
        if (shakeDuration <= 0)
        {
            transform.localPosition = originalLocalPos;
            return;
        }

        float x = (Mathf.PerlinNoise(Time.time * 25f, 0f) - 0.5f) * 2f;
        float y = (Mathf.PerlinNoise(0f, Time.time * 25f) - 0.5f) * 2f;

        transform.localPosition = originalLocalPos +
            new Vector3(x, y, 0f) * shakeIntensity;

        shakeIntensity -= shakeFalloff * Time.deltaTime;
        shakeDuration -= Time.deltaTime;
    }
}

Call CameraShake.Instance.Shake(0.1f, 0.05f) for a subtle weapon fire shake, or Shake(0.3f, 0.15f) for an explosion impact. The linear falloff means the shake decays naturally. For extra juice, add a slight FOV kick on fire — bump it up by 2-3 degrees and lerp back. Players can't consciously see it, but it adds punch.

Simple Enemy AI

An FPS without enemies is just a walking simulator with a gun. We need targets that move, react, and shoot back. For a basic setup, we'll combine two behaviors: patrol when the player isn't visible, chase and attack when they are. Our Enemy Chase AI and Waypoint Patrol AI scripts handle these individually, or you can grab the Enemy AI Kit for a complete state-machine-based solution.

C#
using UnityEngine;
using UnityEngine.AI;

[RequireComponent(typeof(NavMeshAgent))]
public class FPSEnemy : MonoBehaviour
{
    [Header("Detection")]
    [SerializeField] private float sightRange = 20f;
    [SerializeField] private float sightAngle = 60f;
    [SerializeField] private float attackRange = 15f;
    [SerializeField] private LayerMask obstacleMask;

    [Header("Combat")]
    [SerializeField] private float damage = 10f;
    [SerializeField] private float fireRate = 0.5f;
    [SerializeField] private Transform firePoint;

    [Header("Patrol")]
    [SerializeField] private Transform[] waypoints;
    [SerializeField] private float waypointTolerance = 1f;

    private NavMeshAgent agent;
    private Transform player;
    private int currentWaypoint;
    private float nextFireTime;
    private EnemyState state = EnemyState.Patrol;

    void Start()
    {
        agent = GetComponent<NavMeshAgent>();
        player = GameObject.FindGameObjectWithTag("Player").transform;
    }

    void Update()
    {
        bool canSeePlayer = CheckLineOfSight();

        switch (state)
        {
            case EnemyState.Patrol:
                Patrol();
                if (canSeePlayer) state = EnemyState.Chase;
                break;

            case EnemyState.Chase:
                Chase();
                if (!canSeePlayer) state = EnemyState.Patrol;
                if (DistToPlayer() <= attackRange && canSeePlayer)
                    state = EnemyState.Attack;
                break;

            case EnemyState.Attack:
                Attack();
                if (!canSeePlayer) state = EnemyState.Chase;
                if (DistToPlayer() > attackRange) state = EnemyState.Chase;
                break;
        }
    }

    private void Patrol()
    {
        if (waypoints.Length == 0) return;
        agent.SetDestination(waypoints[currentWaypoint].position);
        if (agent.remainingDistance <= waypointTolerance)
            currentWaypoint = (currentWaypoint + 1) % waypoints.Length;
    }

    private void Chase()
    {
        agent.SetDestination(player.position);
    }

    private void Attack()
    {
        agent.SetDestination(transform.position); // Stop moving
        transform.LookAt(new Vector3(
            player.position.x, transform.position.y, player.position.z));

        if (Time.time >= nextFireTime)
        {
            nextFireTime = Time.time + fireRate;
            // Raycast attack with slight inaccuracy
            Vector3 dir = (player.position - firePoint.position).normalized;
            dir += Random.insideUnitSphere * 0.05f;

            if (Physics.Raycast(firePoint.position, dir,
                out RaycastHit hit, attackRange))
            {
                var health = hit.collider.GetComponent<HealthSystem>();
                if (health != null) health.TakeDamage(damage);
            }
        }
    }

    private bool CheckLineOfSight()
    {
        if (DistToPlayer() > sightRange) return false;

        Vector3 dirToPlayer = (player.position - transform.position).normalized;
        float angle = Vector3.Angle(transform.forward, dirToPlayer);
        if (angle > sightAngle) return false;

        return !Physics.Linecast(
            firePoint.position, player.position, obstacleMask);
    }

    private float DistToPlayer() =>
        Vector3.Distance(transform.position, player.position);
}

public enum EnemyState { Patrol, Chase, Attack }

The slight inaccuracy on enemy fire (Random.insideUnitSphere * 0.05f) is important. Perfectly accurate AI enemies feel unfair and frustrating. Real shooters give enemies a spread cone and sometimes intentional 'miss shots' to create tension without instant-killing the player. Tune this value for your desired difficulty.

Audio Integration

Sound design sells the fantasy. Gunshots need to be punchy, footsteps need to match the surface, and enemy death sounds need to be satisfying. Rather than scattering AudioSource components everywhere, use a centralized Audio Manager that handles pooling and spatial audio:

C#
// Integration example — call from your weapon's TryFire method
public bool TryFire(Camera cam)
{
    // ... existing fire logic ...

    if (fireSound != null)
        AudioManager.Instance.PlaySFX(fireSound, muzzlePoint.position);

    CameraShake.Instance.Shake(0.08f, 0.03f);

    return true;
}

// Integration example — enemy death
void Start()
{
    GetComponent<HealthSystem>().OnDeath += () =>
    {
        AudioManager.Instance.PlaySFX(deathSound, transform.position);
        // Spawn ragdoll, drop loot, etc.
    };
}

Spatial audio makes a massive difference in FPS games. When you hear gunshots from behind and to the left, you instinctively turn before you consciously process it. Unity's built-in spatial blend on AudioSources handles the basics, but the Audio Manager lets you fire-and-forget without managing individual source components.

Wrapping Up

We've covered the core systems of an FPS: camera, movement, weapons, projectiles, health, game feel, enemy AI, and audio. That's a playable shooter. From here, you'd add weapon switching, ammo pickups, level design, and a menu system — but the foundation is solid.

All of these are available as individual free downloads, or you can grab the FPS Foundation Kit which bundles everything together with prefabs, a demo scene, and integration examples. Pair it with the Enemy AI Kit for more sophisticated enemy behaviors like flanking, cover-seeking, and squad coordination.

The biggest mistake I see in FPS tutorials is stopping at 'it works.' Functional isn't fun. The camera shake, the audio, the damage popups, the enemy inaccuracy — that's what turns a tech demo into something people actually want to play. Spend as much time on feel as you do on systems, and your shooter will be miles ahead of most Unity prototypes.