Glusoft

Make a tower defense game with Unity

In this tutorial the goal is to make a simple tower defense game with Unity. This game template can be extended to make a full commercial game.

The game will be in 2d so you can create a project with 2D and URP template. URP (Universal Render pipeline) allow us to use lights and shaders so that’s always a good thing.

Unity 2d project with URP

The sprites

For the game you will need to have some basic sprites like a map, enemies, towers :

The map for the level :
map0.png

The circle for placing towers :
circle1.png

The basement :
basement.png

The enemies :
bat.png

A tower :
tower1.png

After the sprites are downloaded you can import the sprites inside a folder Sprites :

Sprites inside the folder - tower defense game with unity

You can selection all of the sprite and set the texture compression to none :

texture compression to none - tower defense game with unity

The path of the enemies

We can start by implementing the path for the enemies for that you will need to create a class Path :

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Path : MonoBehaviour
{
    [SerializeField] public List <Transform> waypoints;
    [SerializeField] public int basement_number;
}

The waypoints are the list of points defining the path and the basement number is here in case we have multiple basement.

Import the map to the Hierarchy we will create a path for the enemies. Create a parent game object with the script Path on it and create waypoints of the path you see on the map. You can add a sprite renderer on the waypoint to place them. The goal is to have something like this :

create waypoint for the path - tower defense game with unity

The next thing to do is to link the waypoints you have created to the list inside the path script like this :

Link the waypoints to the path -tower defense game with unity

If you have some problem with this you can download the project for this step :

TowerDefenseTutorial-Path

Enemy spawner script

We want to have enemies to spawn on the path we have previously defined. The spawner is a simple state machine the first thing to do is to define the states :

enum GameState
{
    PRE_WAVE,
    WAVE_STARTED,
    SPAWN_OVER,
    WAVE_OVER,
    GAME_OVER
}

Perform state actions function

After a state transition we need to perfom some type of action for that we define a new function : PerfomStateAction

private void PerfomStateAction()
    {
        switch (gameState)
        {
            case GameState.PRE_WAVE:
                break;
            case GameState.WAVE_STARTED:
                TrySpawn();
                break;
            case GameState.SPAWN_OVER:
                // Do nothing
                break;
            case GameState.WAVE_OVER:
                StartNewWave();
                break;
            case GameState.GAME_OVER:
                bool enemiesDead = enemyContainer.transform.childCount == 0;
                if (enemiesDead)
                {
                    WinGame.Invoke(this, EventArgs.Empty);
                }
                break;
        }
    }

When a wave is started we need to spawn something, the TrySpawn function do that. The Try spawn use the parameters of the current wave, spawn a new enemy, and ajust the time and delay after the spawning.

private void TrySpawn()
    {
        float spawnVariance = currentWave().spawnVariance;
        float meanSpawnInterval = currentWave().meanSpawnInterval;
        if (Time.time - lastSpawnTryTime  < nextSpawnDelay) return;
        SpawnNewEnemy();

        lastSpawnTryTime = Time.time;
        nextSpawnDelay = 2 * spawnVariance * meanSpawnInterval * UnityEngine.Random.Range(0f, 1f) + (1 - spawnVariance) * meanSpawnInterval;
    }

We need a short function to get the current wave :

    private WaveConfig currentWave()
    {
        return WaveConfigs.waves[currentWaveIndex];
    }

We also need a short function for starting a new wave :

    private void StartNewWave()
    {
        currentWaveIndex += 1;
        spawnedEnemyCount = 0;
        deadEnemyCount = 0;
        waveStartTime = Time.time;
        lastSpawnTryTime = Time.time;
    }

Here is the function to spawn a enemy, SpawnNewEnemy. The script simply choose an enemy and instantiate it, but also set multiple things like the basement and the path :

private void SpawnNewEnemy()
    {
        Path selectedpath = ChooseRandom <Path>(paths);
        GameObject selectedEnemy = ChooseRandomEnemy();
        selectedEnemy.GetComponent <Enemy>().SetPath(selectedpath.waypoints);

        if (selectedpath.basement_number == 2)
        {
            selectedEnemy.GetComponent <Enemy>().SetBasement(basement2);
        }
        else
        {
            selectedEnemy.GetComponent <Enemy>().SetBasement(basement);
        }

        GameObject newObject = Instantiate(selectedEnemy, selectedpath.waypoints[0].position, Quaternion.identity);
        newObject.transform.parent = enemyContainer.transform;
        spawnedEnemyCount++;
    }

The next step is the ChooseRandom and the ChooseRandomEnemy :

public static T ChooseRandom <T>(List <T> list)
    {
        int idx = Mathf.FloorToInt(UnityEngine.Random.Range(0, list.Count));
        return list[idx];
    }

public GameObject ChooseRandomEnemy()
    {
        float totalWeight = currentWave().enemies.Sum(item => item.weight);
        float randomValue = UnityEngine.Random.Range(0f, 1f) * totalWeight;

        float cumulatedWeight = 0f;
        foreach (var enemyConfig in currentWave().enemies)
        {
            cumulatedWeight += enemyConfig.weight;
            if (randomValue  < cumulatedWeight)
            {
                return enemyConfig.enemyPrefab;
            }
        }

        return null;
    }

You can see we choose the enemy based on the weight, we first choose a random weight and we return an enemy when the weight is below this random weight. The ChooseRandom is a simple function to choose a random element in a list.
The previous code does not compile because we don’t have a Enemy class yet.

Enemy class

This enemy class is not complete yet but this is just to have a class for the project to compile.

using System;
using System.Collections.Generic;
using UnityEngine;

public class Enemy : MonoBehaviour
{
    public static event EventHandler EnemyWin;
    public static event EventHandler <int> EnemyDie;
    private static readonly float MIN_DISTANCE = 0.001f;

    [Header("Path")]
    [SerializeField] GameObject basement;
    [SerializeField] List <Transform> waypoints;

    [Header("Stats")]
    [SerializeField] float speed;
    [SerializeField] float health;
    [SerializeField] int value;

    public float getSpeed()
    {
        return speed;
    }

    public void SetPath(List <Transform> waypoints)
    {
        this.waypoints = waypoints;
    }

    public void SetBasement(GameObject basement)
    {
        this.basement = basement;
    }

}

LevelConfig script

Before continuing defining the functions we will need to create a new class LevelConfig that store all the config parameters for the level. The WaveConfig will be used it contains multiple parameters for the waves. Each enemies also have some configuration parameters.

using System.Collections.Generic;
using UnityEngine;
using System;

[Serializable]
public struct WaveConfigs
{
    public List <WaveConfig> waves;
}

[Serializable]
public struct WaveConfig
{
    [SerializeField][Range(0, 10)] public float meanSpawnInterval;
    [SerializeField][Range(0, 1)] public float spawnVariance;
    [SerializeField][Range(0, 20)] public float startDelay;

    [SerializeField] public int enemyCount;
    public List <EnemyConfig> enemies;
}

[Serializable]
public struct EnemyConfig
{
    public float weight;
    public GameObject enemyPrefab;
}

After doing that we need to have a function for the transition state :

    private GameState PerformStateTransition()
    {
        switch (gameState)
        {
            case GameState.PRE_WAVE:
                bool preWaveDelayElapsed = currentWave().startDelay  < Time.time - waveStartTime;
                if (preWaveDelayElapsed) return GameState.WAVE_STARTED;
                break;

            case GameState.WAVE_STARTED:
                bool allEnemiesSpawned = spawnedEnemyCount >= currentWave().enemyCount;
                if (allEnemiesSpawned) return GameState.SPAWN_OVER;
                break;

            case GameState.SPAWN_OVER:
                bool enemiesAllGone = deadEnemyCount + enemyWinInWaveCount >= currentWave().enemyCount;
                bool noMoreWave = currentWaveIndex >= WaveConfigs.waves.Count - 1;
                if (enemiesAllGone  && noMoreWave) return GameState.GAME_OVER;
                if (enemiesAllGone  && !noMoreWave) return GameState.WAVE_OVER;
                break;

            case GameState.WAVE_OVER:
                return GameState.PRE_WAVE;

            // Terminal state
            case GameState.GAME_OVER:
                break;
        }

        return gameState;
    }

Full script for EnemySpawner

Here is the full script for enemy spawner with all the parameters :

using System.Collections.Generic;
using UnityEngine;
using System;
using System.Linq;
using static UnityEngine.EventSystems.EventTrigger;

public class EnemySpawner : MonoBehaviour
{
    public static event EventHandler WinGame;

    [SerializeField] public GameObject basement;
    [SerializeField] public GameObject basement2;
    [SerializeField] public List <Path> paths;

    public WaveConfigs WaveConfigs;

    private GameObject enemyContainer;

    private int currentWaveIndex = 0;
    private int spawnedEnemyCount = 0;
    private int deadEnemyCount = 0;
    private int enemyWinInWaveCount = 0;

    private float waveStartTime = 0f;
    private float lastSpawnTryTime = 0f;
    private float nextSpawnDelay = 0f;

    private GameState gameState = GameState.PRE_WAVE;

    private void Start()
    {
        enemyContainer = GameObject.Find("Enemies");
    }

    void OnEnable()
    {
        Enemy.EnemyWin += OnEnemyWin;
        Enemy.EnemyDie += OnEnemyDie;
    }

    void OnDisable()
    {
        Enemy.EnemyWin -= OnEnemyWin;
        Enemy.EnemyDie -= OnEnemyDie;
    }

    void Update()
    {
        PerfomStateAction();
        GameState newState = PerformStateTransition();
        if (!newState.Equals(gameState)) OnStateChange(newState);

        if (Input.GetKeyDown(KeyCode.W))
        {
            WinGame.Invoke(this, EventArgs.Empty);
        }
    }

    private void OnStateChange(GameState newState)
    {
        gameState = newState;
    }

    private void OnEnemyWin(object sender, EventArgs eventArgs)
    {
        enemyWinInWaveCount++;
    }

    private void OnEnemyDie(object sender, int e)
    {
        deadEnemyCount++;
    }
    private WaveConfig currentWave()
    {
        return WaveConfigs.waves[currentWaveIndex];
    }

    private void PerfomStateAction()
    {
        switch (gameState)
        {
            case GameState.PRE_WAVE:
                break;
            case GameState.WAVE_STARTED:
                TrySpawn();
                break;
            case GameState.SPAWN_OVER:
                // Do nothing
                break;
            case GameState.WAVE_OVER:
                StartNewWave();
                break;
            case GameState.GAME_OVER:
                bool enemiesDead = enemyContainer.transform.childCount == 0;
                if (enemiesDead)
                {
                    WinGame.Invoke(this, EventArgs.Empty);
                }
                break;
        }
    }

    private GameState PerformStateTransition()
    {
        switch (gameState)
        {
            case GameState.PRE_WAVE:
                bool preWaveDelayElapsed = currentWave().startDelay  < Time.time - waveStartTime;
                if (preWaveDelayElapsed) return GameState.WAVE_STARTED;
                break;

            case GameState.WAVE_STARTED:
                bool allEnemiesSpawned = spawnedEnemyCount >= currentWave().enemyCount;
                if (allEnemiesSpawned) return GameState.SPAWN_OVER;
                break;

            case GameState.SPAWN_OVER:
                bool enemiesAllGone = deadEnemyCount + enemyWinInWaveCount >= currentWave().enemyCount;
                bool noMoreWave = currentWaveIndex >= WaveConfigs.waves.Count - 1;
                if (enemiesAllGone  && noMoreWave) return GameState.GAME_OVER;
                if (enemiesAllGone  && !noMoreWave) return GameState.WAVE_OVER;
                break;

            case GameState.WAVE_OVER:
                return GameState.PRE_WAVE;

            // Terminal state
            case GameState.GAME_OVER:
                break;
        }

        return gameState;
    }

    private void StartNewWave()
    {
        currentWaveIndex += 1;
        spawnedEnemyCount = 0;
        deadEnemyCount = 0;
        waveStartTime = Time.time;
        lastSpawnTryTime = Time.time;
    }

    private void TrySpawn()
    {
        float spawnVariance = currentWave().spawnVariance;
        float meanSpawnInterval = currentWave().meanSpawnInterval;
        if (Time.time - lastSpawnTryTime  < nextSpawnDelay) return;
        SpawnNewEnemy();

        lastSpawnTryTime = Time.time;
        nextSpawnDelay = 2 * spawnVariance * meanSpawnInterval * UnityEngine.Random.Range(0f, 1f) + (1 - spawnVariance) * meanSpawnInterval;
    }


    private void SpawnNewEnemy()
    {
        Path selectedpath = ChooseRandom <Path>(paths);
        GameObject selectedEnemy = ChooseRandomEnemy();
        selectedEnemy.GetComponent <Enemy>().SetPath(selectedpath.waypoints);

        if (selectedpath.basement_number == 2)
        {
            selectedEnemy.GetComponent <Enemy>().SetBasement(basement2);
        }
        else
        {
            selectedEnemy.GetComponent <Enemy>().SetBasement(basement);
        }

        GameObject newObject = Instantiate(selectedEnemy, selectedpath.waypoints[0].position, Quaternion.identity);
        newObject.transform.parent = enemyContainer.transform;
        spawnedEnemyCount++;
    }

    public static T ChooseRandom <T>(List <T> list)
    {
        int idx = Mathf.FloorToInt(UnityEngine.Random.Range(0, list.Count));
        return list[idx];
    }


    public GameObject ChooseRandomEnemy()
    {
        float totalWeight = currentWave().enemies.Sum(item => item.weight);
        float randomValue = UnityEngine.Random.Range(0f, 1f) * totalWeight;

        float cumulatedWeight = 0f;
        foreach (var enemyConfig in currentWave().enemies)
        {
            cumulatedWeight += enemyConfig.weight;
            if (randomValue  < cumulatedWeight)
            {
                return enemyConfig.enemyPrefab;
            }
        }

        return null;
    }
}

enum GameState
{
    PRE_WAVE,
    WAVE_STARTED,
    SPAWN_OVER,
    WAVE_OVER,
    GAME_OVER
}

The Basement Game Object

After creating the script for the enemies you can also create a new game object for the basement and place it at the end of the map.

basement game object - tower defense game with unity

Enemy Spawner property

You can now create a Enemy Spawner Game Object with the script attached on it. Then you will need to create the waves of Enemies :

Enemy spawner property - tower defense game with unity

For the wave you can set some parameters but you will need some enemies :

waves property - tower defense game with unity

Enemy prefabs

Create a prefabs folder and drag and drop the bat inside the hierarchy. Then add a Rigidbody 2d and a circle collider 2d.
The Rigidbody 2d need to be set to Kinematic. Create a new tag Enemy and set it to the bat.

kinematic rigidbody

Edit the circle collider to be near the contour of the bat like this :

collider edit for the bat - tower defense game with unity

LevelManager class

We need a class to create win/loose condition for that we create a LevelManager class :

using System;
using UnityEngine;
using UnityEngine.SceneManagement;

public class LevelManager : MonoBehaviour
{
    public static string MENU_NAME = "Start";
    public static string LEVEL_SELECTION_NAME = "LevelSelection";
    public static string LOSE_LEVEL_NAME = "LoseLevel";
    public static string WIN_LEVEL_NAME = "WinLevel";
    public static int FIRST_LEVEL_INDEX = 4;

    private int maxLevel;

    void OnEnable()
    {
        EnemySpawner.WinGame += OnWinGame;
        HealthManager.LoseGame += OnLoseGame;
    }

    void OnDisable()
    {
        EnemySpawner.WinGame -= OnWinGame;
        HealthManager.LoseGame -= OnLoseGame;
    }

    public void Start()
    {
        maxLevel = 0;

        if (PlayerPrefs.HasKey("maxLevel"))
        {
            maxLevel = PlayerPrefs.GetInt("maxLevel");
        }

        // unselect levels
        GameObject go = GameObject.Find("LevelBoard");
        if (go != null)
        {
            Transform trans_canvas = go.transform.GetChild(0);
            for (int i = maxLevel + 1; i  < trans_canvas.childCount; i++)
            {
                trans_canvas.GetChild(i).gameObject.SetActive(false);
            }
        }

    }
    public void LoadStartMenu()
    {
        SceneManager.LoadScene(0, LoadSceneMode.Single);
    }

    public void QuitGame()
    {
        Application.Quit();
    }

    public static void LoadLevelSelection()
    {
        SceneManager.LoadScene(LEVEL_SELECTION_NAME, LoadSceneMode.Single);
    }

    public static void StartLevel(int level)
    {

        SceneManager.LoadScene(FIRST_LEVEL_INDEX + level, LoadSceneMode.Single);
    }

    private void OnLoseGame(object sender, EventArgs e)
    {
        Debug.Log("Lose game ");
        SceneManager.LoadScene(LOSE_LEVEL_NAME, LoadSceneMode.Single);
    }

    private void OnWinGame(object sender, EventArgs e)
    {
        bool levelUnlocked = getSceneIndexOfLevel(maxLevel) == getSceneIndexOfLevel(SceneManager.GetActiveScene().buildIndex);
        if (levelUnlocked)
        {
            Debug.Log("new level unlocked");
            maxLevel++;
            PlayerPrefs.SetInt("maxLevel", maxLevel);
        }

        SceneManager.LoadScene(WIN_LEVEL_NAME, LoadSceneMode.Single);
    }

    private int getSceneIndexOfLevel(int level)
    {
        return level - FIRST_LEVEL_INDEX;
    }

    private void LoadNextLevel()
    {
        SceneManager.LoadScene(
            SceneManager.GetActiveScene().buildIndex + 1,
            LoadSceneMode.Single
            );
    }

    private void ReloadLevel()
    {
        SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex, LoadSceneMode.Single);
    }

}

Health Manager class

As you can see we also need to create a Health Manager:

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class HealthManager : MonoBehaviour
{
    [SerializeField] int initHealth = 3;
    [SerializeField] GameObject healthCanvas;
    [SerializeField] GameObject heart;
    public static event EventHandler LoseGame;

    private float heartSpacing = 0.5f;
    public int currentHealth;

    private List <GameObject> heartStack = new List <GameObject>();

    void Start()
    {
        currentHealth = initHealth;
        FillHealthPanel();
    }

    void OnEnable() {
        Enemy.EnemyWin += OnEnemyWin;
    }

    void OnDisable() {
        Enemy.EnemyWin -= OnEnemyWin;
    }

    private void OnEnemyWin(object sender, EventArgs eventArgs)
    {
        if (currentHealth  < 1)
        {
            LoseGame.Invoke(this, EventArgs.Empty);
            return;
        }

        currentHealth--;
        
        RemoveHeart();
    }

    private void FillHealthPanel()
    {
        for (int i = 0; i  < currentHealth; i++)
        {
            GameObject instance = GameObject.Instantiate(heart,
                 healthCanvas.transform.position + new Vector3((float)(i * heartSpacing), 0, 0),
                Quaternion.identity);
            heartStack.Add(instance);
            instance.transform.parent = healthCanvas.transform;
        }

    }


    private void RemoveHeart()
    {
        GameObject heart = heartStack[heartStack.Count - 1];
        heartStack.RemoveAt(heartStack.Count - 1);
        Destroy(heart);
    }

}

For the health manager you will need to download some heart sprite for the life :

life.png

After downloading the heart sprite create a heart prefab by dropping the heart into the hierarchy.

You can now create a new game object and attach the Heath Manager script on it.

health manager property

Hearth Panel

The heart panel can be a child of the health manager like this:

hearth panel

The heath panel need to be position at the top left of the screen where the life bar should be.

The waves should be working fine, there is still some things missing for the level like a game over scene and a win scene.

Here is the projects after the waves have been implemented : TowerDefenseTutorial-Wave