Loading 3D Models and Building a Fighting Game in Unity

A fighting game needs characters that move convincingly: punches, kicks, blocks and a death animation. Those characters come from 3D models that already have a skeleton and animations. This article covers two things together: loading a model into the game at runtime from code, and building the combat system that uses it, including attacks, blocking, health and an AI opponent.

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

What you need

Schematic of the FBX Inspector (not a real screenshot): 1 the file in Assets/Models 2 the Rig tab 3 the Animation Type dropdown 4 the Apply button
Schematic of the FBX Inspector (not a real screenshot): 1 the file in Assets/Models 2 the Rig tab 3 the Animation Type dropdown 4 the Apply button

Step 1: create a project and import the model

1. In Unity Hub, click **New project**, choose the **3D (URP)** or **3D** template and name it `MyFighter`.

2. Drag the character's FBX file into `Assets/Models/`.

3. Select the FBX. In the Inspector, under **Rig**, set **Animation Type** to **Humanoid** and click **Apply**.

4. Under **Animation**, check that the clips are there: Idle, Punch, Kick, Block, Hit and Die.

Setting Humanoid matters because it lets animations from other characters work on this one.

Schematic of the Animator window (not a real screenshot): 1 the add-parameter button 2 parameter names and types 3 the Idle state, orange for default 4 transitions to the state for each move
Schematic of the Animator window (not a real screenshot): 1 the add-parameter button 2 parameter names and types 3 the Idle state, orange for default 4 transitions to the state for each move

Step 2: create an Animator Controller

1. Right-click in `Assets` and choose **Create → Animation → Animator Controller**. Name it `FighterController`.

2. Double-click it to open the Animator window. Drag the Idle clip into the graph and set it as the default state (orange).

3. Create a **Trigger** parameter named `Attack`, `Hit` and `Die`, and a **Bool** named `Block`.

4. Create a Punch state reached from Idle with the `Attack` trigger, and Hit and Die states in the same way.

5. Create a Block state entered when `Block` is true and left when it is false.

Step 3: load the model at runtime from code

Loading from code is useful when a game lets the player choose among several characters without placing them all in the scene beforehand. The simplest method is to keep prefabs in `Assets/Resources/` and load them by name.

1. Drag the character prefab, with its Animator Controller already set, to `Assets/Resources/Characters/Fighter.prefab`.

2. Create a script named `ModelLoader` and paste this code.

using UnityEngine;

// โหลดโมเดลตัวละครตอนรันเกม จากโฟลเดอร์ Resources แล้วติดตั้งไว้ใต้จุดที่กำหนด
// ก่อนใช้งานให้วางไฟล์ Prefab ไว้ที่ Assets/Resources/Characters/<ชื่อ>.prefab
public class ModelLoader : MonoBehaviour
{
    [SerializeField] private string characterPath = "Characters/Fighter";  // ไม่ต้องใส่นามสกุลไฟล์
    [SerializeField] private Transform modelParent;                       // จุดที่จะวางโมเดล

    private GameObject model;
    private Animator animator;

    public GameObject Model => model;
    public Animator Animator => animator;

    private void Awake()
    {
        Load();
    }

    // คืนค่า true ถ้าโหลดสำเร็จและมี Animator ให้ควบคุม
    public bool Load()
    {
        GameObject prefab = Resources.Load<GameObject>(characterPath);
        if (prefab == null)
        {
            Debug.LogError("ไม่พบโมเดลที่ Resources/" + characterPath);
            return false;
        }

        model = Object.Instantiate(prefab, modelParent);
        animator = model.GetComponent<Animator>();
        if (animator == null)
        {
            Debug.LogError("โมเดลนี้ไม่มี Animator");
            return false;
        }
        return true;
    }
}

Key points:

Note: `Resources` suits small projects and learning. Larger projects that load many files should use Addressables.

Step 4: the fighter

`Fighter` keeps health, takes damage, blocks, attacks, and drives the Animator so the right animation plays.

using UnityEngine;

// ตัวละครนักสู้: มีเลือด โจมตี ป้องกัน และตาย ผ่าน Animator เพื่อให้เล่นท่าทางตามเหตุการณ์
public class Fighter : MonoBehaviour
{
    [SerializeField] private int maxHealth = 100;
    [SerializeField] private int attackDamage = 10;
    [SerializeField] private float attackRange = 1.5f;     // ระยะที่โจมตีถึง (เมตร)
    [SerializeField] private float attackCooldown = 0.6f;  // เวลาพักหลังโจมตีแต่ละครั้ง (วินาที)
    [SerializeField] private float blockMultiplier = 0.5f; // ป้องกันแล้วรับดาเมจเหลือกี่เปอร์เซ็นต์

    private int health;
    private bool isBlocking;
    private float nextAttackTime;
    private Animator animator;

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

    public void Init(Animator animatorSource)
    {
        animator = animatorSource;
        health = maxHealth;
    }

    // โจมตีคู่ต่อสู้ คืน true ถ้าการโจมตีเกิดขึ้น (ไม่ได้อยู่ในช่วงพัก ไม่ได้ตาย ไม่ได้กำลังป้องกัน)
    public bool Attack(Fighter target)
    {
        if (IsDead || isBlocking || Time.time < nextAttackTime) return false;

        nextAttackTime = Time.time + attackCooldown;
        SetTrigger("Attack");

        if (DistanceTo(target) <= attackRange)
        {
            target.TakeHit(attackDamage);
        }
        return true;
    }

    public void SetBlocking(bool value)
    {
        if (IsDead) return;
        isBlocking = value;
        animator?.SetBool("Block", value);
    }

    public void TakeHit(int damage)
    {
        if (IsDead) return;

        int finalDamage = isBlocking ? (int)(damage * blockMultiplier) : damage;
        health -= finalDamage;
        if (health < 0) health = 0;

        if (IsDead)
        {
            SetTrigger("Die");
        }
        else
        {
            SetTrigger("Hit");
        }
    }

    public float DistanceTo(Fighter other)
    {
        return Vector3.Distance(transform.position, other.transform.position);
    }

    private void SetTrigger(string name)
    {
        animator?.SetTrigger(name);
    }
}

Key points:

Step 5: the AI opponent

`FighterAI` walks toward the player and attacks when in range.

using UnityEngine;

// สมองของคู่ต่อสู้: เดินเข้าหาผู้เล่น และโจมตีเมื่ออยู่ในระยะ
[RequireComponent(typeof(Fighter))]
public class FighterAI : MonoBehaviour
{
    [SerializeField] private Fighter target;         // ผู้เล่น
    [SerializeField] private float walkSpeed = 2f;   // ความเร็วเดินเข้าหา (เมตรต่อวินาที)

    private Fighter self;

    private void Awake()
    {
        self = GetComponent<Fighter>();
    }

    private void Update()
    {
        if (self.IsDead || target == null || target.IsDead) return;

        if (self.DistanceTo(target) > 1.5f)
        {
            // ยังไกลเกินไป ให้เดินเข้าหาเป้าหมาย
            Vector3 direction = (target.transform.position - transform.position).normalized;
            transform.position = transform.position + direction * walkSpeed * Time.deltaTime;
        }
        else
        {
            // อยู่ในระยะแล้ว ลองโจมตี (ถ้าพักอยู่จะไม่โจมตี)
            self.Attack(target);
        }
    }
}

Key points:

Step 6: decide the winner

`RoundManager` checks who has died and announces the winner.

using UnityEngine;

// ตัดสินผลแมตช์: ฝ่ายที่เหลือเลือดหรือยืนอยู่หลังอีกฝ่ายตายคือผู้ชนะ
public class RoundManager : MonoBehaviour
{
    [SerializeField] private Fighter player;
    [SerializeField] private Fighter enemy;

    // คืน "Player" หรือ "Enemy" เมื่อมีคนตาย, คืน null ถ้าการต่อสู้ยังไม่จบ
    public string GetWinner()
    {
        if (enemy.IsDead && !player.IsDead) return "Player";
        if (player.IsDead && !enemy.IsDead) return "Enemy";
        if (player.IsDead && enemy.IsDead) return "Draw";
        return null;
    }
}
Schematic of the Player Inspector (not a real screenshot): 1 Character Path 2 Max Health 3 Self 4 Opponent
Schematic of the Player Inspector (not a real screenshot): 1 Character Path 2 Max Health 3 Self 4 Opponent

Step 7: assemble the scene and control the player

1. Add the ground and lighting to the scene.

2. Create a GameObject named `Player`. Add a `ModelLoader` with `characterPath` set to `Characters/Fighter`, and add `Fighter`.

3. Call `Init` on the `Fighter` with the Animator from the `ModelLoader`, so health starts at 100. A small scene controller script is the usual place for this.

4. Create an `Enemy` GameObject the same way, then add `FighterAI` with `Target` set to `Player`.

5. Create a `RoundManager` GameObject and link both fighters.

This simple controller uses left-click to attack and right-hold to block.

using UnityEngine;

// ผู้เล่นควบคุมด้วยเมาส์: คลิกซ้ายโจมตี คลิกขวาค้างเพื่อป้องกัน
public class PlayerInput : MonoBehaviour
{
    [SerializeField] private Fighter self;
    [SerializeField] private Fighter opponent;

    private void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            self.Attack(opponent);
        }
        self.SetBlocking(Input.GetMouseButton(1));
    }
}

Step 8: automated tests

The tests use the same approach as the FPS article. The stub file (`FightStubs.cs`) and the 24-case test file (`FightTests.cs`) are in the Thai version. The tests cover in-range and out-of-range attacks, the cooldown, blocking, death, a dead fighter that cannot attack, AI approach and attack, model loading with a missing file and with no Animator, and the winner logic.

To compile and run: copy the stub, the test file and the four scripts (`ModelLoader.cs`, `Fighter.cs`, `FighterAI.cs`, `RoundManager.cs`) into a `build` folder, delete `using UnityEngine;` from the copies only, compile with a recent C# compiler (for example `csc -langversion:latest -out:FightTests.exe *.cs`) and run `FightTests.exe`.

Test results

PASS  attack in range: attack starts
PASS  attack in range: defender loses 10 health (100 to 90)
PASS  attack in range: defender plays Hit animation
PASS  attack: attacker plays Attack animation
PASS  attack out of range: no damage (still 100)
PASS  cooldown: first attack succeeds
PASS  cooldown: second attack at 0.3 s is refused
PASS  cooldown: only one hit landed (90)
PASS  cooldown: attack after 0.7 s is allowed
PASS  block: damage halved (100 to 95)
PASS  death: fighter is dead at 0 health
PASS  death: Die animation is played
PASS  dead fighter cannot attack
PASS  damage to a dead fighter is ignored
PASS  AI approaches the player when far
PASS  AI attacks when in range
PASS  model loader: loads the prefab from Resources
PASS  model loader: finds the Animator on the model
PASS  model loader: missing file returns false
PASS  model loader: missing file logs an error
PASS  model loader: prefab without Animator returns false
PASS  round: no winner while both are alive
PASS  round: player wins when enemy is dead
PASS  round: enemy wins when player is dead

Passed 24 / Failed 0

These results come from the stub, not Unity. Model appearance, animation timing and real hit detection still need checking in the editor.

One bug the tests helped prevent: an early version of `Fighter` used `Mathf.Max(0, health - finalDamage)`. That does not compile, because `Mathf.Max` takes floats and `health` is an int. The fix is the direct subtraction with a clamp at zero.

Step 9: test in the editor

Press **Play** and check:

Common problems

Next steps

Once the basic fight works, you could add combos (chained moves), a jump and dodge system, several selectable characters, and sound and particle effects on hits.

Summary

Loading models from code lets the game pick characters flexibly, and the Animator keeps the animation separate from the combat logic. The combat system is split into four clear parts: the fighter, the AI, the round judge and the model loader. Each part can be tested on its own, and the tests catch compile errors before you open the editor.