U
tab 02Character & Combat9 lessons — tap to open
05 / 09 · 11 min
Character & Combat

Projectiles, damage and VFX

your project so far

Assets/

Scripts/

SkillCaster.csProjectile.csSimplePool.cs

Prefabs/

P_Fireball

Data/

Skill_Slash / Dash / Ultimate

you already havethis lesson adds

A projectile is a prefab that moves forward, hits something, deals damage and dies. Four responsibilities, one small script.

animated diagram

The Knight fires down an isometric lane; the dummy takes the hit; the projectile goes back into the pool it came from.

The projectile

Projectile.cs
using UnityEngine;

[RequireComponent(typeof(Collider))]
public class Projectile : MonoBehaviour
{
    [SerializeField] private float speed = 18f;
    [SerializeField] private float lifeTime = 4f;
    [SerializeField] private GameObject hitVfx;

    private float damage;
    private GameObject owner;                 // do not hit the shooter

    public void Launch(GameObject shooter, float dmg)
    {
        owner = shooter;
        damage = dmg;
        Destroy(gameObject, lifeTime);        // clean up if it never hits
    }

    void Update()
    {
        transform.position += transform.forward * speed * Time.deltaTime;
    }

    void OnTriggerEnter(Collider other)
    {
        if (other.gameObject == owner) return;

        if (other.TryGetComponent(out CharacterStats target))
            target.TakeDamage(damage);

        if (hitVfx != null)
            Instantiate(hitVfx, transform.position, Quaternion.identity);

        Destroy(gameObject);
    }
}
  1. 1

    GameObject▸3D Object▸Sphere, scale 0.3, name it P_Fireball.

  2. 2

    Tick Is Trigger on its Collider — a trigger reports overlaps without physically bouncing.

  3. 3

    Attach Projectile.cs, give it an emissive material, drag it into Assets/Prefabs.

  4. 4

    Delete it from the scene. Prefabs live in Project, not Hierarchy.

Firing it from a skill

add to SkillData and SkillCaster
// in SkillData.cs
public GameObject projectilePrefab;

// in SkillCaster.TryCast
if (s.projectilePrefab != null)
{
    GameObject go = Instantiate(s.projectilePrefab, castPoint.position, castPoint.rotation);
    go.GetComponent<Projectile>().Launch(gameObject, s.damage);
}

Object pooling — stop the stutter

Instantiate and Destroy allocate memory. Fire 20 projectiles a second and the garbage collector will hitch. A pool reuses the same objects forever.

SimplePool.cs
using System.Collections.Generic;
using UnityEngine;

public class SimplePool : MonoBehaviour
{
    [SerializeField] private GameObject prefab;
    [SerializeField] private int prewarm = 20;

    private readonly Queue<GameObject> pool = new Queue<GameObject>();

    void Awake()
    {
        for (int i = 0; i < prewarm; i++)
        {
            GameObject go = Instantiate(prefab, transform);
            go.SetActive(false);
            pool.Enqueue(go);
        }
    }

    public GameObject Get(Vector3 pos, Quaternion rot)
    {
        GameObject go = pool.Count > 0 ? pool.Dequeue() : Instantiate(prefab, transform);
        go.transform.SetPositionAndRotation(pos, rot);
        go.SetActive(true);
        return go;
    }

    public void Release(GameObject go)
    {
        go.SetActive(false);
        pool.Enqueue(go);
    }
}
  • Replace Destroy(gameObject) in the projectile with a call back to pool.Release(gameObject).

  • Unity also ships UnityEngine.Pool.ObjectPool<T> if you prefer the built-in version.

  • Pool anything that spawns more than a few times a second: bullets, hit sparks, damage numbers.

VFX that reads well

  • Tick Stop Action → Destroy on the Particle System so one-shot effects clean themselves up.

  • Impact sparks last 0.2–0.4 s. Longer than that and the screen turns to soup.

  • A tiny camera shake (0.1 s) plus a 0.05 s hit-stop sells the impact better than any particle.

hit-stop
using System.Collections;

IEnumerator HitStop(float seconds)
{
    Time.timeScale = 0.05f;
    yield return new WaitForSecondsRealtime(seconds);   // Realtime! timeScale is frozen
    Time.timeScale = 1f;
}

// call it
StartCoroutine(HitStop(0.05f));

Common mistakes

  • The projectile hits the shooter first: it spawns inside the Knight's own collider and OnTriggerEnter fires on the caster. Spawn it from a castPoint just in front of the body, and ignore hits whose transform.root is the caster.

  • A pooled projectile comes back with its old state: reset position, rotation and every timer in OnEnable, or the second shot dies at once or flies the wrong way.

  • Destroy(gameObject, lifeTime) is still there after switching to the pool, so a projectile that misses destroys the pool's object and the pool runs dry. Replace both Destroy calls with pool.Release, the timed one through Invoke or a coroutine.

  • Fast projectiles tunnel through thin colliders: at 30 m/s and 30 FPS a bullet moves a whole metre per frame and can skip a wall. Move it through the Rigidbody with MovePosition and set Collision Detection to Continuous, or keep the speed sane.