Building a First-Person Shooter in Unity: Mouse Look, Movement, Shooting and Testing

A first-person shooter (FPS) shows the world through the character's eyes. The core systems are mouse look, WASD movement, shooting with a ray, and targets that have health. This article builds each system step by step in Unity, from a new project to code that passes tests.

Note: the code keeps the Thai comments from the original version. It has not been run in the Unity Editor. The tests run against a stub Unity API written for this article, which confirms the script logic only. The full stub file is in the Thai version of this article.

What you need

Step 1: create a 3D project and set up input

1. Open Unity Hub, click **New project**, choose the **Universal 3D** (URP) or **3D** (Built-in) template and name it `MyFPS`.

2. Go to **Edit → Project Settings → Player**, set **Active Input Handling** to **Both** or **Input Manager (Old)**, and restart the editor when asked.

3. Check that the axes **Mouse X**, **Mouse Y**, **Horizontal**, **Vertical** and the **Jump** button exist in **Edit → Project Settings → Input Manager**. They are included in the standard templates.

Step 2: create the ground and a target

1. Right-click in the Hierarchy and choose **3D Object → Plane**. Name it `Ground` and set Scale to `X = 5, Y = 1, Z = 5`.

2. Right-click again and choose **3D Object → Cube**. Name it `Target`, place it about 10 metres ahead, for example Position `Z = 10`, `Y = 1`.

3. Add the **Target** component (written in step 6) to the cube. A cube already has a Box Collider, which the ray needs in order to hit it.

Step 3: create the player

1. Right-click in the Hierarchy, choose **Create Empty**, name it `Player` and set Position `Y = 1`.

2. Add a **Character Controller** to `Player`. It moves the character without a Rigidbody.

3. Right-click `Player` and choose **Camera** to make it a child. Set its Position to `Y = 0.6` so the view sits at eye height.

4. Delete the original Main Camera, because the child camera replaces it.

The structure is `Player` → `Camera`. `Player` will also hold `MouseLook`, `FPSMovement` and `Weapon`.

Step 4: mouse look

Create a script named `MouseLook` and paste in this code.

using UnityEngine;

// หมุนกล้องตามเมาส์: ก้ม-เงยที่กล้อง (pitch) และหันซ้าย-ขวาที่ตัวละคร (yaw)
public class MouseLook : MonoBehaviour
{
    [SerializeField] private float sensitivity = 2f;   // ความไวเมาส์
    [SerializeField] private float minPitch = -80f;    // ก้มลงได้สุดกี่องศา
    [SerializeField] private float maxPitch = 80f;     // เงยขึ้นได้สุดกี่องศา
    [SerializeField] private Transform body;           // ตัวละครที่ต้องหมุนตามแนวนอน

    private float pitch;

    public float Pitch => pitch;

    private void Update()
    {
        float mouseX = Input.GetAxis("Mouse X") * sensitivity;
        float mouseY = Input.GetAxis("Mouse Y") * sensitivity;

        // เมาส์ขึ้นคือเงยหน้า จึงต้องลบค่า mouseY
        pitch = Mathf.Clamp(pitch - mouseY, minPitch, maxPitch);
        transform.localRotation = Quaternion.Euler(pitch, 0f, 0f);

        body.Rotate(0f, mouseX, 0f);
    }
}

Key ideas:

In the Inspector, drag `Player` into the **Body** field of `MouseLook` on the camera.

Step 5: WASD movement and jumping

Create a script named `FPSMovement`, paste in this code, and attach it to `Player`.

using UnityEngine;

// เดินแบบ FPS ด้วย WASD โดยใช้ CharacterController (ไม่ใช้ Rigidbody)
[RequireComponent(typeof(CharacterController))]
public class FPSMovement : MonoBehaviour
{
    [SerializeField] private float speed = 6f;        // ความเร็วเดิน
    [SerializeField] private float jumpHeight = 1.5f; // ความสูงกระโดด (เมตร)
    [SerializeField] private float gravity = -20f;    // แรงโน้มถ่วง

    private CharacterController controller;
    private float verticalVelocity;

    public bool IsGrounded => controller.isGrounded;

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

    private void Update()
    {
        // แกน x คือซ้าย-ขวา และแกน z คือหน้า-หลัง ทั้งคู่อิงทิศทางที่ตัวละครหันอยู่
        float h = Input.GetAxis("Horizontal");
        float v = Input.GetAxis("Vertical");
        Vector3 move = transform.right * h + transform.forward * v;

        // จำกัดความยาวเวกเตอร์ไม่ให้เดินเฉียงเร็วกว่าเดินตรง
        if (move.magnitude > 1f) move = move.normalized;
        controller.Move(move * speed * Time.deltaTime);

        // แรงโน้มถ่วงและการกระโดด
        if (controller.isGrounded && verticalVelocity < 0f)
        {
            verticalVelocity = -2f;                       // กดให้ติดพื้นเล็กน้อย
        }
        if (Input.GetButtonDown("Jump") && controller.isGrounded)
        {
            verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity);
        }
        verticalVelocity += gravity * Time.deltaTime;
        controller.Move(new Vector3(0f, verticalVelocity * Time.deltaTime, 0f));
    }
}

Key ideas:

Step 6: raycast weapon and a target with health

A hitscan weapon fires a ray forward from the muzzle. If the ray hits something with a `Target` component, that target loses health.

Create a script named `Weapon` and attach it to `Player`.

using UnityEngine;

// ปืนแบบ hitscan: ยิงรังสีจากปากกระบอกปืน ถ้าโดนเป้าหมายก็ลดเลือด
public class Weapon : MonoBehaviour
{
    [SerializeField] private Transform muzzle;        // จุดปากกระบอกปืน
    [SerializeField] private float range = 100f;      // ระยะยิงสูงสุด
    [SerializeField] private int damage = 25;         // ดาเมจต่อนัด
    [SerializeField] private float fireInterval = 0.2f; // เวลาขั้นต่ำระหว่างนัด (วินาที)
    [SerializeField] private int maxAmmo = 10;        // กระสุนเต็มซอง

    private int ammo;
    private float nextFireTime;

    public int Ammo => ammo;

    private void Awake()
    {
        ammo = maxAmmo;
    }

    private void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            TryShoot();
        }
    }

    // คืน true ถ้ายิงจริง, false ถ้ากระสุนหมดหรือยังยิงไม่ได้
    public bool TryShoot()
    {
        if (ammo <= 0 || Time.time < nextFireTime) return false;

        ammo--;
        nextFireTime = Time.time + fireInterval;

        RaycastHit hit;
        if (Physics.Raycast(muzzle.position, muzzle.forward, out hit, range))
        {
            Target target = hit.collider.GetComponent<Target>();
            if (target != null)
            {
                target.TakeDamage(damage);
            }
        }
        return true;
    }
}

Create a `Muzzle` empty object as a child of the camera, placed in front of the gun, and drag it into the **Muzzle** field of `Weapon`.

Create a script named `Target` and attach it to the cube.

using UnityEngine;

// เป้าหมายที่ถูกยิงได้: มีเลือด เมื่อเลือดหมดจะตาย
public class Target : MonoBehaviour
{
    [SerializeField] private int maxHealth = 100;

    private int health;

    public int Health => health;
    public bool IsDead => health <= 0;

    private void Awake()
    {
        health = maxHealth;
    }

    public void TakeDamage(int amount)
    {
        if (IsDead) return;

        health -= amount;
        if (health < 0) health = 0;
        if (IsDead)
        {
            Die();
        }
    }

    private void Die()
    {
        // ในเกมจริงอาจใช้ Destroy(gameObject) หรือเล่นแอนิเมชันตาย
        gameObject.SetActive(false);
    }
}

A note on the code: the first version of `Target` wrote `health = Mathf.Max(0, health - amount);`. That does not compile, because `Mathf.Max` returns a float and `health` is an int. The compiler caught it before any play test. The version above subtracts directly and clamps at zero.

Step 7: automated tests

The test harness uses a stub Unity API (`UnityStubs.cs`, included in the Thai version of this article) and a test file `FPSTests.cs`. The test file checks mouse look, movement, jumping, firing, the fire interval, the ammo limit, and target death.

Compile and run:

1. Create a `build` folder and copy `UnityStubs.cs`, `FPSTests.cs` and the four scripts into it.

2. In the copies only, delete the `using UnityEngine;` line. Keep it in the real project scripts.

3. Compile with a recent C# compiler (for example `csc -langversion:latest -out:FPSTests.exe *.cs`) and run `FPSTests.exe`.

Test results

PASS  mouse up: pitch becomes -2 (looking up)
PASS  mouse up for long: pitch stops at minPitch (-80)
PASS  mouse right: body turns 2 degrees
PASS  walk forward 1 second: moves about 6 units
PASS  diagonal walk: speed capped at about 6 (not 8.5)
PASS  jump from ground: rises above the ground in the first frame
PASS  shoot: fires when ammo is available
PASS  shoot hit: target health drops by damage (100 to 75)
PASS  shoot: ammo decreases from 10 to 9
PASS  fire interval: second shot within 0.2 s is blocked
PASS  fire interval: ammo is not spent on the blocked shot
PASS  4 hits of 25: target is dead
PASS  4 hits of 25: target object is deactivated
PASS  empty magazine: after 10 shots the 11th is refused

Passed 14 / Failed 0

These results come from the stub, not from Unity. Physics, collisions, animation and real camera behaviour still need to be checked by pressing Play in the editor.

Mock-up of the game view (not a real render): the target in the centre, the crosshair, the gun in hand and a HUD showing ammo and target health
Mock-up of the game view (not a real render): the target in the centre, the crosshair, the gun in hand and a HUD showing ammo and target health
Render of the actual test output: 14 passed, 0 failed (run against the stub, not the Unity Editor)
Render of the actual test output: 14 passed, 0 failed (run against the stub, not the Unity Editor)

Step 8: test in the editor

Press **Play** and check:

Common problems

Summary

An FPS has four main systems: camera look, movement, raycast shooting and targets with health. Each is a small script that can be tested separately. Testing caught a compile error before any play test, which saves time later. Next steps include enemies that move, an on-screen ammo counter, and reloading.